把 PuTTY 搬上鸿蒙 PC:一个 SSH 终端应用适配的踩坑实录
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_mtputty
环境搭建文章:https://blog.csdn.net/weixin_52908342/article/details/161343743
把 GUI 应用移植到鸿蒙 PC 的路线已经很成熟——Electron 塞 HAP 壳、Qt 交叉编译、Web 套 WebView。但当我移植的是一个终端类应用时,事情完全不一样了。 
这篇文章记录把 PuTTY 移植到鸿蒙 PC 的全过程:两次方向错误的诊断、一次彻底的架构推翻重来,以及最终在真机跑通 SSH 完整会话的证据。如果你要做终端模拟器、内嵌解释器或任何需要"执行外部程序"的应用,这些坑大概率躲不掉。文中所有代码、命令、报错均来自真实工程。
PC真机适配效果:

一、背景:终端类应用的特殊性
PuTTY 是最经典的开源 SSH 客户端套件(MIT 协议),核心资产是纯 POSIX 的命令行工具:plink(SSH 客户端)、pscp/psftp(文件传输)、puttygen(密钥生成)。MTPuTTY 是它的多标签增强版。本项目方案:PuTTY CLI 引擎做核心,ArkTS 写多标签终端 GUI。
它和 GUI 应用移植的本质区别:
| 核心问题 | 选哪个框架 | 底层系统能力是否可用 |
| 需要伪终端(PTY) | 不需要 | 需要 |
| 需要执行外部二进制 | 不需要 | 需要 |
GUI 应用的难点在"选路线",终端类应用的难点在"系统能力边界"——PTY 能否创建、二进制能否执行、文件系统有无执行权限,每一个都致命。 
选 PuTTY 有三个实际理由:纯 POSIX 无 GUI 依赖;自带完整 crypto 不依赖系统 OpenSSL;OHOS sysroot 网络栈完备。事实证明上游代码一行没改,问题全部出在"怎么跑起来"。
工程采用两阶段构建:阶段一用 OHOS NDK 交叉编译 PuTTY,产出独立 CLI 二进制(进 HNP 供 HiShell 用)、十个静态库、以及改过符号的 libplink_core.a;阶段二是标准 DevEco 工程,CMake 把阶段一产物链进两个 .so——libmtputty_terminal.so(NAPI 桥)和 libmtputty_child.so(子进程入口 + 整个 PuTTY)。拆分的好处是上游编译一次几分钟、HAP 构建只要几秒,迭代 UI 不用重编;代价是阶段一产物必须进版本库,构建脚本对每步都做了符号校验(plink_main、backends 不存在直接报错),避免静默产出坏归档。
按 PC 世界的常识,终端模拟器架构是:

这是 xterm、gnome-terminal 共同的设计,运行了三十年。我按它实现了第一版——然后撞了第一堵墙。
二、第一堵墙:PTY 创建被拒
现象与排查
点击 Connect,终端区立即吐出:
[Error] posix_openpt failed: Permission denied
posix_openpt() 的本质是打开 /dev/ptmx。设备上直接验证:
$ hdc shell "ls -l /dev/ptmx"
ls: /dev/ptmx: Permission denied
这已经是 hdc shell(权限高于普通应用)的结果。连调试 shell 都摸不到,应用更不可能。我又查了社区里另一个用相同 posix_openpt 实现的终端项目,它的"PTY 双向 I/O"至今未打勾——确认这是平台级限制。
绕墙尝试:加内核权限,死路
照其他项目加 ohos.permission.kernel.ALLOW_WRITABLE_CODE_MEMORY 等权限,安装直接失败:
code:9568289
error: install failed due to grant request permissions failed.
原因:鸿蒙应用特权等级(APL)分 normal/system_basic/system_core 三级,kernel.* 是 system 级,而 DevEco 自动签名生成的调试证书 APL 是 normal——无权授予,声明了反而装不上:
$ strings ~/.ohos/config/*.p7b | grep -o '"apl":"[^"]*"'
"apl":"normal"
正解:socketpair 替代
终端要 PTY,本质是要一个双向字节流通道——一端给 GUI 收发,一端给子进程当 stdin/stdout。Unix 域套接字对提供完全等价的能力,且不需要任何权限:
#include <sys/socket.h>
napi_value CreateChannel(napi_env env, napi_callback_info info)
{
(void)info;
int fds[2] = { –1, –1 };
if (socketpair(AF_UNIX, SOCK_STREAM, 0, fds) != 0) {
char msg[160] = {0};
std::snprintf(msg, sizeof(msg),
"socketpair failed: %s", std::strerror(errno));
napi_throw_error(env, nullptr, msg);
return nullptr;
}
// fds[0] 留给 ArkTS 读写;fds[1] 交给原生子进程
napi_value result = nullptr;
napi_create_object(env, &result);
napi_value p = nullptr, c = nullptr;
napi_create_int32(env, fds[0], &p);
napi_create_int32(env, fds[1], &c);
napi_set_named_property(env, result, "parentFd", p);
napi_set_named_property(env, result, "childFd", c);
return result;
}
返回结构 {parentFd, childFd} 与 PTY 方案完全一致,ArkTS 侧一行不用改。但配套必须删掉一行:
// ioctl(terminalFd, TIOCSCTTY, 0); ← 删除:socket 非 TTY,会 ENOTTY 中断会话
代价要想清楚:没有真实 TTY,远端 shell 走非交互模式,vim/top 无法用;但"发命令、拿输出"这个核心场景完好,对 SSH 客户端够用。
三、第二堵墙:外部二进制执行不了
PTY 通了,execv 拉起 plink 时报:
[Native error] execv /data/app/…/hnppublic/bin/plink failed: No such file or directory
诡异的是文件确实存在——符号链接在,目标在,大小与本地编译产物一字节不差。
第一次诊断:动态链接器(自洽但错误)
execv 返回 ENOENT 但文件存在,经典解释是 ELF 的 PT_INTERP 指定的动态链接器不存在。验证:二进制确实依赖 /lib/ld-musl-aarch64.so.1,设备上确实没有。推理闭合,改静态链接:
cmake ... -DCMAKE_EXE_LINKER_FLAGS="-static"
产物 1.0→2.4 MB,确认 statically linked。重新打包安装运行——报错一字未变。
教训
"修好了"和"错误一字未变"不可能同时成立。修复后错误毫无变化,说明根因没被触及,应立刻质疑前提,而不是继续加码。我停下来重新列出 ENOENT 的所有可能成因:路径不存在?排除,文件在。符号链接断了?排除,readlink 追到真实目标。PT_INTERP 缺失?排除,静态链接后依旧报错。SELinux 域限制?——还没查过。
真相:SELinux 类型限制
回头看项目自己的 README,早就写着:
hdc shell 无法执行 hnp_file 类型文件(rc=126)。HNP public 命令需通过 HiShell 终端 App 的域执行。
HNP 二进制被打上 hnp_file 的 SELinux 标签,只有 HiShell 的域能执行。HAP 应用及其原生子进程继承应用域——execv 被 SELinux 拦截,内核表现恰恰就是 ENOENT。
另一个铁证来自社区的 Thonny 项目文档:
filesDir 是 noexec —— 不能把二进制丢进沙箱再 exec;解释器以静态库链进 NAPI .so
两条路全堵死:execv 不行(SELinux),拷进沙箱也不行(noexec)。答案只剩一个。
最终方案:把 PuTTY 静态链进 .so,进程内调用
不再"启动 plink 程序",而是"调用 plink_main() 函数"。四步,每步一个坑:
① 把 main() 变成普通函数——对编译产物做符号重命名,源码零改动:
llvm-objcopy –redefine-sym main=plink_main plink.c.o
$ llvm-nm plink.c.o | grep plink_main
00000000000002ec T plink_main # ✅
② 补齐 backends 符号——首次链接报 undefined symbol: backends。该数组由 be_list(plink …) 按目标生成,是个独立 .o,不在任何静态库里,要手动并入归档:
llvm-ar rcs libplink_core.a plink.c.o unicode.c.o no-gtk.c.o \\
no-lineedit.c.o be_list.c.o # ← 缺它必报 undefined
③ 别用 –whole-archive——照搬 Thonny 的写法会报一片 duplicate symbol: pinger_new。PuTTY 各静态库间故意存在重叠对象,whole-archive 强制全量拉入必然撞车,正常链接即可。
④ 必须 -fPIC,-fPIE 不行——报 R_AARCH64_ADD_ABS_LO12_NC cannot be used … recompile with -fPIC。-fPIE 是可执行文件代码,-fPIC 才是共享库代码。陷阱:CMAKE_POSITION_INDEPENDENT_CODE=ON 给可执行目标加的是 -fPIE,必须显式:
-DCMAKE_C_FLAGS="-fPIC -D_DEFAULT_SOURCE -D_GNU_SOURCE"
调用侧:
extern "C" int plink_main(int argc, char **argv);
// 取代 execv
const int rc = plink_main(argc, argv);
_exit(rc < 0 ? 1 : rc);
验证产物——这一步不能省,链接成功不代表 PuTTY 真的进来了:
$ ls -lh libmtputty_child.so
-rwxr-xr-x 1.6M # 改造前只有几十 KB
$ llvm-nm libmtputty_child.so | wc -l
3361 # 符号从个位数涨到 3361
$ llvm-nm libmtputty_child.so | grep -E 'backends|host_key'
D backends
T console_confirm_ssh_host_key
T have_ssh_host_key # PuTTY 核心符号都在
最终的 CMake 链接配置:
set(PUTTY_LIBS
${PUTTY_BUILD}/libeventloop.a
${PUTTY_BUILD}/libnoterminal.a
${PUTTY_BUILD}/libconsole.a
${PUTTY_BUILD}/ssh/libsshclient.a
${PUTTY_BUILD}/libotherbackends.a
${PUTTY_BUILD}/libsettings.a
${PUTTY_BUILD}/libnetwork.a
${PUTTY_BUILD}/libcrypto.a
${PUTTY_BUILD}/charset/libcharset.a
${PUTTY_BUILD}/libutils.a )
add_library(mtputty_child SHARED native_child_main.cpp)
target_link_libraries(mtputty_child PRIVATE
${PUTTY_CORE} ${PUTTY_LIBS}) # 刻意不用 whole-archive
架构演进一目了然:
改造前:execv("/data/app/…/plink") ← 启动新程序(被 SELinux 拒)
改造后:plink_main(argc, argv) ← 调用函数(代码已链入自身)
四、应用层要点
原生层通了,应用层几个关键决策简单记录:
会话状态机:每个标签页一个对象,状态全部收敛在一处:
interface TerminalTab {
config: SessionConfig; // name/host/port/protocol/username/password
output: string; // 终端区累积输出
connected: boolean; // 已连接
connecting: boolean; // 连接中(驱动 Loading 动画)
inputText: string; // 输入框内容
processId: number; // 原生子进程 PID
terminalFd: number; // socketpair 的 parentFd
readerActive: boolean; // 读循环存活标志
}
connecting 和 connected 分开是刻意的:连接中黄色转圈、已连接绿点、离线灰点,用户一眼扫出哪个会话活着。
参数传递:startNativeChildProcess 的 entryParams 只收一个字符串,而 plink 需要整套 argv。做法是用 \\0 连接所有参数再整体转十六进制,原生侧解码回 std::vector<std::string>——绕,但保证任意参数(含空格、中文)安全传输:
function encodeArguments(args: string[]): string {
const bytes = new util.TextEncoder()
.encode(args.join('\\u0000'));
let s = '';
for (let i = 0; i < bytes.length; i++)
s += bytes[i].toString(16).padStart(2, '0');
return s;
}
读循环:fileIo.read 异步循环累积输出:
private async readTerminal(tab: TerminalTab): Promise<void> {
const decoder = util.TextDecoder.create('utf-8');
const buffer = new ArrayBuffer(8192);
tab.readerActive = true;
try {
while (tab.readerActive && tab.terminalFd >= 0) {
const n = await fileIo.read(tab.terminalFd, buffer);
if (n <= 0) break; // 对端关闭
tab.output += decoder.decodeToString(
new Uint8Array(buffer, 0, n));
this.refreshTabs(); // 触发 UI 刷新
}
} finally {
tab.readerActive = false; // 任何路径都回收资源
await fileIo.close(tab.terminalFd);
tab.terminalFd = –1;
tab.connected = false;
this.refreshTabs();
}
}
finally 里做回收,保证断开、崩溃、异常任何路径不泄漏 fd。一个经典陷阱:childFd 移交给子进程后,父进程必须立即关闭自己那份,否则子进程退出后父进程的 read 不返回 0,读循环挂死——这是 Unix fd 传递的经典坑。
密码处理:无 TTY 时 PuTTY 无法交互式询问密码(它依赖终端回显控制),必须 -pw 传入。密码因此进了命令行,而命令行会进 hilog 系统日志——打日志前必须脱敏:
private redactCommand(command: string[]): string {
const out: string[] = [];
for (let i = 0; i < command.length; i++) {
if (command[i] === '-pw' && i + 1 < command.length) {
out.push('-pw', '******'); i++;
} else { out.push(command[i]); }
}
return out.join(' ');
}
一行都不能省——hilog 是系统级日志,明文密码进去等于写进设备日志文件。
命令构造两个易错点:不加 -t(本地无 TTY,强求远端 PTY 反而出问题);-P 是大写(小写 -p 是另一个参数)。
连接的完整生命周期——把所有环节串起来:
const channel = mtputtyTerminal.createChannel(); // ① socketpair
const processId = await childProcessManager.startNativeChildProcess(
'libmtputty_child.so:Main',
{ entryParams: encodeArguments(command), // 十六进制 argv
fds: { 'terminal': channel.childFd } }, // childFd 移交子进程
{ isolationMode: false });
await fileIo.close(channel.childFd); // ⑤ 父进程立即关闭自己那份
tab.terminalFd = channel.parentFd;
this.readTerminal(tab); // 启动异步读循环
断开走反向流程:先置 readerActive = false 让读循环退出,再 process.kill 终止子进程,最后关 fd。顺序不能乱——先杀进程再关读循环,会丢子进程退出前吐出的最后一段输出。
设备侧验证——调试时光看 App 内日志不够,配合 hdc 可以独立确认状态:
hdc shell "ls /data/app/el1/bundle/100/hnppublic/bin/ | grep plink" # HNP 注册
hdc shell "ps -ef | grep mtputty" # 进程存活
hdc shell "hilog | grep MTPuTTY" # 原生层日志
原生代码关键路径都埋了 hilog,App 崩溃或黑屏时,系统日志往往比界面先给出答案。
HNP 打包:虽然 GUI 已不依赖 HNP 执行 plink,但设备上的 HiShell 终端(唯一能执行 hnp_file 的域)可以直接用这些 CLI 工具,所以保留了 HNP 层。清单 hnp.json 的 links 数组声明命令注册到 /data/app/el1/bundle/100/hnppublic/bin/。这里有个冷知识:hnpcli 生成的 ZIP 会给文件条目打 MS-DOS 卷标属性,macOS 的 unzip 解包时全部跳过——看起来像包坏了,其实设备侧能正常解压。排查 HNP 内容要在设备上验证,别被开发机解包结果误导。
ArkTS 语法坑三个,第一个最坑——Button(){} 内容块必须紧跟组件、属性链在后:
// ❌ 属性链写在内容块之前,从这行开始语法崩
Button()
.type(ButtonType.Capsule)
.onClick(() => { … }) { Text('Send') }
// ✅ 正确顺序
Button('Send')
.type(ButtonType.Capsule)
.onClick(() => { … })
写反后报 41 个错,且报错信息完全看不出与 Button 有关(Cannot find name 'margin'、struct must have one build method),38 个是连锁误报。经验:看到结构性报错先往上找第一个真正的语法错位。另外两个:@Builder 不能接收函数类型参数,只能内联展开;FontWeight 没有 SemiBold,用 Medium。
五、真机验收:证据链
截图均来自真机(HUAWEI MateBook Pro,HarmonyOS 7.0.0),连接 Rebex 公共 SSH 测试服务器(test.rebex.net,账号 demo/password,无需搭建)。
5.1 应用启动

DevEco 通过 hdc shell aa start 拉起应用,677 毫秒启动成功。
5.2 桌面运行,零配置可用

深色多标签终端 UI 完整渲染:标签栏、连接栏(SSH 协议徽章 + 预填地址)、终端区空态引导、底部状态栏一应俱全。产品化小设计:应用打开即预填测试服务器完整配置(地址、端口、账号、密码),用户零配置直接点 Connect——对演示和评审场景省掉大量解释成本。
5.3 SSH 会话跑通(核心证据)

终端区完整记录会话建立过程:
[Connected] Native session process 43188 ← 原生子进程拉起
Welcome to test.rebex.net! … ← 服务端欢迎横幅
demo@test:~$ ← 远端 shell 提示符
状态栏绿点 Online,PID 43188 持续显示。截图时用户正打开 New Session 对话框准备建第二个会话——顺带验证了多标签 UI 可用。注意终端里两行 ERROR: Unable to write random seed / store host key——PuTTY 往 ~/.putty 写密钥缓存被 HAP 沙箱拦截。它不阻断会话:握手、认证、横幅、提示符全都在。生产化解法是 -hostkey 指纹固定或预置 known_hosts。
5.4 会话稳定运行

关闭对话框后的干净视图,终端区内容完整:连接日志、主机密钥指纹提示(ssh-ed25519 255 dTeZDom+…)、欢迎横幅、shell 提示符。绿色在线状态持续保持。
5.5 主机密钥交互

首次连接时 PuTTY 完整打印服务器指纹并要求确认,用户键入 y 继续。这条交互链路的完整保留,证明终端 I/O 通道(输入框 → socketpair → plink → 终端区)双向贯通。
现场演示推荐四步两分钟:连接 → whoami(返回 demo)→ ls(返回 pub readme.txt)→ exit(干净退出),覆盖会话完整生命周期。该测试服务器还实测支持 pwd(返回 /)、echo、uname、hostname、help,输出稳定可预期。连接自己的服务器时换掉地址账号即可,唯一前置条件是服务器开启密码认证——当前 UI 只有密码字段,纯密钥登录连不上。
六、复现步骤
# 1. 交叉编译 PuTTY(产出 CLI 二进制 + libplink_core.a)
cd ohos_MTPuTTY && bash pkg/ohos/scripts/build-putty.sh
# 2. 打包 HNP(HiShell 终端里可独立用 CLI 工具)
bash pkg/ohos/scripts/build-hnp.sh
# 3. DevEco 自动签名(File → Project Structure → Signing Configs)
# 4. 构建 HAP
cd ohos_hap
ohpm install # ← 不可省!生成 HAR 软链,缺了报几十个编译错
hvigorw assembleHap –mode module -p product=default -p buildMode=debug
# 5. 安装启动
$HDC install entry/build/default/outputs/default/entry-default-signed.hap
$HDC shell aa start -a EntryAbility -b org.ttyplus.mtputty.ohos
一个体积对比值得一提:同样功能,Electron 路线的 HAP 动辄上百兆(光运行时就 169 MB),本方案整包 6.5 MB,启动 677 ms——原生实现除了绕开权限限制,还附带了可观的体积与启动红利。
七、常见问题 FAQ
Q1:为什么 vim、top 这类全屏交互程序用不了?
socketpair 是普通字节流,不是真实 TTY。远端 shell 检测到无 TTY 走非交互模式,vim/top 依赖的终端控制(光标定位、屏幕刷新)无法工作。但"发命令、拿输出"的核心场景完好。真实 PTY 需要平台开放 /dev/ptmx,目前是系统级限制,应用侧无解。
Q2:连接时的两行 ERROR: Unable to write random seed / store host key 要紧吗?
不要紧。PuTTY 往 ~/.putty 写密钥缓存被 HAP 沙箱拦截,但不阻断会话——握手、认证、横幅、提示符全部正常。副作用只是每次连接都重新确认主机指纹。生产化可用 -hostkey 指纹固定或预置 known_hosts。
Q3:支持密钥登录吗?
当前 UI 只有密码字段,纯密钥登录连不上——这是唯一前置限制,服务器必须开启密码认证。密钥支持在路线图上(需扩 UI 并集成 puttygen)。
Q4:我想自己测试,用什么服务器?
Rebex 公共测试服务器:test.rebex.net,账号 demo / 密码 password,无需搭建。应用打开即预填了这套配置,点 Connect 就能验证。四步两分钟:连接 → whoami → ls → exit。
Q5:为什么整包只有 6.5 MB?
PuTTY 是纯 C 实现,静态链入 NAPI .so 后没有额外运行时开销。对比 Electron 路线(光运行时就 169 MB),原生方案附带体积与启动(677 ms)双重红利。
八、总结
| 1 | PTY 对普通 HAP 不可用 | posix_openpt() → EACCES,kernel 权限调试签名无法授予 | socketpair() |
| 2 | HNP 二进制不能被 HAP execv | ENOENT(hnp_file SELinux 类型限制) | 静态链入 NAPI .so,进程内调用 |
| 3 | filesDir 以 noexec 挂载 | 沙箱内执行二进制被拒 | 同上 |
一句话:在鸿蒙 PC 上跑原生代码,别想着"启动进程",要想着"链接代码"。
修复后错误一字未变,立刻质疑前提。 在"动态链接器缺失"这个假设上我浪费了一整轮构建。错误假设之所以危险,恰恰因为它一半是真的——设备上确实没有 musl 链接器,静态链接也确实该做,只是它不是当前错误的成因。一半为真的假设最难识破,验证标准不能是"推理通不通",必须是"修复后错误变没变"。
僵局时重读自己的文档和同类项目的"未完成"清单。 解开 execv 之谜的钥匙,一行不差地写在项目自己的 README 里;noexec 的证据来自 Thonny 项目的一句注释。写文档时不觉得重要,卡住时才发现——前人留下的"此路不通",比一百篇成功教程都珍贵。
回头看,这次适配最有价值的不是"成功了",而是把三条死路走到底:PTY 加权限走不通、静态链接救不了 execv、沙箱执行也不行。排除法走到最后,剩下的方案才是被约束逼出来的正解。移植工作难的不是找答案,是确认哪些答案不存在。




