把 PuTTY 装进鸿蒙 PC:MTPuTTY 交叉编译、沙箱排错与上架应用市场全流程实战
把 PuTTY 装进鸿蒙 PC:MTPuTTY 交叉编译、沙箱排错与上架应用市场全流程实战
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_mtputty
本文记录了「墨签终端(from MTPuTTY)」从一行 C 代码到鸿蒙 PC 应用市场上架的完整历程。内容涵盖:PuTTY 0.83 协议栈交叉编译到
aarch64-linux-ohos、进程内化改造、ArkTS 多标签终端 GUI、以及真机上踩过的六大深坑(子进程沙箱 DNS 全灭、HOME 不可写、GBK 乱码、输入丢焦、终端不滚动、发送失效)的定位与修复全过程。全文基于真实真机调试日志,代码均为实际提交的修复版本。

一、写在前面:为什么要做这个项目
鸿蒙 PC(HarmonyOS 7.0,MateBook Pro 系列)的生态正在起步期。作为一个"生产力工具"形态的设备,它最缺的恰恰是那些 Linux/macOS 上开发者习以为常的基础设施——终端模拟器就是其中之一。
系统自带了一个终端应用,能跑本地 shell,但对于开发者、运维、测试同学来说,真正的高频场景是:
- SSH 远程连接服务器,改配置、看日志、重启服务;
- Telnet / Rlogin / Raw 连接网络设备、调试嵌入式板子、探测端口;
- 多标签并行,同时盯着三五台机器。
Windows 上这个需求被 PuTTY 和它的多标签增强版 MTPuTTY(ttywin.com 出品的闭源工具)统治了二十多年。而鸿蒙 PC 上,这个位置是空的。
所以这个项目的目标非常明确:
把 PuTTY 的完整协议引擎移植到鸿蒙 PC,做一个原生多标签终端应用,并成功上架应用市场。
"移植"和"上架"这两个词,说起来各只要两个字,做起来是两条完全不同的战线。移植战线的主旋律是 C 语言:交叉编译、符号处理、平台适配层、沙箱补丁,每一件事都在和"这套代码原本假设自己运行在什么环境"较劲。上架战线的主旋律是流程:签名、隐私声明、审核意见、演示视频,每一件事都在和"审核员如何理解你的应用"较劲。两条战线交替进行——往往 是代码修好了,上架时又发现新的真机问题,修完再提审。本文按时间线把两条线交织着讲,希望读者能同时看到两边的全貌。
还有一个动机值得说透:这个项目从第一天起就是开源的。仓库托管在 AtomGit 的 OpenHarmonyPCDeveloper 组织下,所有补丁(包括给 PuTTY 上游打的 OHOS 沙箱适配)都随仓库公开。这意味着文章里的每一段代码、每一个结论,读者都可以 clone 下来对照验证——这也是技术文章和软文最大的区别:可复现。
最终交付的「墨签终端(from MTPuTTY)」在上架后长这样:

安装量破万、分类「工具」、年龄分级 3+,应用介绍里那句话是这个项目的最好概括:
墨签终端是一款面向鸿蒙 PC 的多标签远程终端应用,基于开源网络安全工具 PuTTY 0.83 的协议引擎构建。应用将 SSH、Telnet、Rlogin、Raw 四种连接协议交叉编译为 ARM64 原生库,通过 PTY 伪终端桥与原子子进程通道实现毫秒级双向数据收发,加密全部在本地完成,不依赖任何第三方云服务。
下面进入正题。
二、项目全景:技术选型与三层架构
2.1 为什么选 PuTTY 0.83
自己从零写一套 SSH 协议栈是不现实的——SSH2 涉及密钥交换、主机密钥算法协商、加密套件、认证流程、通道复用等一大堆 RFC,任何一处实现不严谨都是安全问题。可选方案有三个:
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| libssh2 + 自写 UI | 库成熟 | 只管传输层,终端模拟、行编辑、主机密钥管理全要自己写 | 工作量失控 |
| OpenSSH 移植 | 功能完整 | 依赖 OpenBSD 兼容层,且是交互式程序,进程内化极难 | 放弃 |
| PuTTY 0.83 | 单文件依赖极少、MIT 协议、自带完整终端后端(terminal.c)、有 plink 这种纯命令行入口 | UNIX 侧 GUI 依赖 GTK | 选它,剥离 GTK |
PuTTY 的代码组织对移植极其友好:
ssh/目录:完整 SSH2 协议栈(纯 C,无平台依赖);unix/network.c:套接字与 DNS 适配层,这正是我们后面打沙箱补丁的地方;unix/plink.c:命令行入口,main()一路走到 SSH 会话,没有 GTK 依赖;terminal/:终端模拟器(ANSI/xterm 转义序列解析),多标签 GUI 只需把输出流渲染出来。
MIT 协议也意味着可以合法地二次分发、改名上架——这是"开源软件上架"的法律前提。
2.2 三层架构
整体架构分三层,各层职责严格隔离:

两个关键设计决策:
决策一:为什么走 Native 子进程,而不是把 plink 编成 .so 直接 dlopen?
OpenHarmony 提供了 childProcessManager.startNativeChildProcess() API,允许 HAP 拉起一个原生子进程,并通过 fd(文件描述符)直通的方式建立双向管道。这个方案的好处是:
- 进程隔离:plink 内部大量
exit()/断言,一旦协议异常整个进程退出——如果跑在主进程里,应用直接闪退;跑在子进程里,崩溃只是"连接断开",GUI 还活着,可以提示用户重连; - fd 天然是管道:子进程的 stdin/stdout 接到 GUI 的 fd 上,就是最纯粹的终端数据流,不需要 IPC 序列化;
- 独立编码、独立环境变量:后面会看到,我们需要给子进程注入
HOME、MTPUTTY_DNS、MTPUTTY_IPMAP等环境变量,子进程方案的 env 隔离让这件事干净利落。
决策二:为什么终端渲染在 ArkTS 而不是用 xterm.js?
鸿蒙 PC 的 ArkUI 是原生渲染路径,Text 组件 + monospace 字体 + Scroller 就能做出可用的终端视图。第一版刻意不引入 WebView(xterm.js),把复杂度留给后续版本——先跑通"真连接",再优化"真渲染"。事实证明这个取舍是对的:上架审核最关心的就是核心功能真实性,而不是渲染引擎多先进。
2.3 仓库与构建体系
ohos_MTPuTTY/
├── native/ # CMake 交叉编译工程
│ ├── CMakeLists.txt # 编译 plink/pscp/psftp/puttygen 四个目标
│ ├── plink_core/ # 进程内化后的静态库(符号重命名)
│ └── build/ # 构建产物
├── third_party/putty/ # PuTTY 0.83 源码(含 OHOS 补丁)
├── ohos_hap/ # ArkTS HAP 工程
│ ├── entry/src/main/ets/pages/Index.ets # 全部 GUI(约 1300 行)
│ ├── entry/src/main/cpp/native_child_main.cpp # 子进程入口
│ └── entry/src/main/module.json5
├── pkg/ohos/scripts/build-putty.sh # 一键构建 HNP 包
└── tools/mtputty_test_sshd.py # 本地测试服务器(SSH/Telnet/Rlogin/Raw)
三、第一步:交叉编译 PuTTY 到 aarch64-linux-ohos
3.1 工具链
DevEco Studio 自带的 OpenHarmony SDK 里有完整的 LLVM 交叉工具链:
CMAKE_C_COMPILER = .../openharmony/native/llvm/bin/clang.exe
CMAKE_EXE_LINKER_FLAGS = -static # 独立 plink 用纯静态 musl
CMAKE_C_FLAGS = -fPIC -D_DEFAULT_SOURCE -D_GNU_SOURCE
SYSROOT = .../sysroot/usr # aarch64-linux-ohos
PuTTY 自己有一套 CMakeLists.txt(cmake/ 目录),我们套一层外层 CMake 工程,把四个可执行目标(plink、pscp、psftp、puttygen)编出来。其中 plink 是 App 真正用的,其余三个作为 HNP CLI 工具一起分发,凑成完整工具集。
3.2 plink 的"进程内化"改造
这是移植中最有技巧性的一步。plink 是一个 main() 开头的可执行程序,我们要把它塞进 libmtputty_child.so 里作为子进程入口。两步:
第一步:符号重命名。用 llvm-objcopy 把 main 改名,避免和子进程壳的 main 冲突:
llvm-objcopy --redefine-sym main=plink_main plink.c.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
这里把四个 .o 归档成静态库:plink.c.o(主流程)、unicode.c.o(字符集)、no-gtk.c.o(GTK 桩,我们写了空实现替换 unix/gtk* 的依赖)、no-lineedit.c.o(行编辑桩——终端行编辑由 GUI 侧负责,plink 侧关闭本地行编辑)、be_list.c.o(后端注册表)。
第二步:子进程入口解析 ENV 参数。native_child_main.cpp 是子进程的真正入口,它从 entryParams 里解码出参数数组,把 ENV= 前缀的条目转成 setenv,剩下的作为 plink 的 argv:
std::vector<std::string> command;
// Entries prefixed with ENV= are environment assignments for this process
// (e.g. ENV=HOME=/data/storage/...), not plink arguments.
for (const std::string &item : SplitArgs(decoded)) {
if (item.rfind("ENV=", 0) == 0) {
const std::string kv = item.substr(4);
const size_t eq = kv.find('=');
if (eq != std::string::npos) {
setenv(kv.substr(0, eq).c_str(), kv.substr(eq + 1).c_str(), 1);
}
continue;
}
command.push_back(item);
}
if (command.empty()) {
command.push_back(DEFAULT_PLINK);
}
这个 ENV= 通道后来成了整个项目的"生命线"——DNS 修复、HOME 修复、IPMAP 注入全部走它。
3.3 HNP 包封装
鸿蒙的原生二进制分发包格式是 HNP(Harmony Native Package)。构建脚本把四个静态链接的 ELF 放进 hnppublic/bin/,应用运行时按 getBinPath() 拼路径。同时 App 内嵌的 libmtputty_child.so 链接同一份 libplink_core.a + libnetwork.a,保证 GUI 内跑的协议引擎和 CLI 工具是同一份代码。
至此,plink -ssh -l demo -pw demo test.rebex.net 这条命令的每一个字节,都是 ARM64 原生执行的 PuTTY 0.83 代码。
3.4 四个工具的分工与 HNP 包结构
打包时除了 App 内嵌的协议引擎,还把四个 CLI 工具一起装进 HNP 包:
| 工具 | 角色 | App 内的用途 |
|---|---|---|
plink | SSH/Telnet/Rlogin/Raw 连接客户端 | 核心,进程内跑的就是它 |
pscp | SCP 文件拷贝 | HNP CLI,配合终端做文件传输 |
psftp | SFTP 交互式文件管理 | HNP CLI |
puttygen | 密钥生成与转换 | HNP CLI,生成 ED25519/RSA 密钥对 |
HNP 的目录结构遵循鸿蒙原生包规范:
hnppublic/
├── bin/
│ ├── plink # 静态链接 aarch64-linux-ohos ELF
│ ├── pscp
│ ├── psftp
│ └── puttygen
└── ...
这四个二进制全部 -static 纯静态 musl 链接,不依赖任何系统 so——这也是为什么它们从 hdc shell 直接跑会被 SELinux 拦(shell 域没有执行权限),而由 App 的子进程域拉起则完全合法。静态链接还有一个隐藏好处:musl 的行为完全确定,不会被目标设备 libc 的差异影响,排错时可以排除掉一整个变量维度。
四、第二步:ArkTS 多标签终端 GUI
4.1 界面:还原 PuTTY 的"经典味道"
MTPuTTY 在 Windows 上的价值在于"熟悉的 PuTTY + 多标签"。所以 GUI 直接复刻了 PuTTY 的经典配置窗口:分组框(group box)、标题骑缝文字、单选按钮组、底部「打开/取消」按钮——ArkUI 的 Stack + Border 能 1:1 还原这种 Windows 95 风格的控件:

主机名、端口、连接类型(SSH/Telnet/Rlogin/Raw 单选组)、自动登录用户名、自动登录密码一屏填完;下方是「载入/保存/删除」已存会话区——和 PuTTY 的 Session 面板交互完全一致。
ArkUI 没有 Windows 经典分组框控件,用 Stack + 带边框的 Column + 骑缝标题就能 1:1 模拟:
Stack({ alignContent: Alignment.TopStart }) {
Column() {
// ... 表单内容
}
.width('100%')
.border({ width: 1, color: PuTTYTheme.BORDER })
.padding(12)
this.GroupCaption('会话的基本选项') // 背景色与父容器一致,盖住边框线
}
.width('100%')
.margin({ top: 16 })
骑缝标题的原理是让标题的背景色与面板背景相同、位置压在边框上,视觉上就是"文字嵌在框线上"。这类像素级还原的细节共做了七处:分组框、单选钮(用 CheckBox 自绘成圆点样式)、底部按钮组、标签栏的梯形激活态、终端标题栏的"断开连接"按钮、状态栏的 PID 徽章、关于对话框。目的只有一个:让用过 PuTTY 十年的人打开这个 App 的第一秒就觉得"这是我认识的那个工具"。
连接类型的单选组还有一个交互联动:切换协议时端口自动切换到该协议的知名端口(SSH→22、Telnet→23、Rlogin→513、Raw→空),和真实 PuTTY 行为一致。这个联动用的是最朴素的实现——Radio 的 onChange 里改 tab.config.port 后 refreshTabs()。注意这里必须调 refreshTabs(),因为端口输入框的显示值依赖它;它和坑四的"输入框 onChange 不刷新"并不矛盾——配置项之间的联动是低频事件,重建一次没有感知;逐字符输入是高频事件,一次重建都嫌多。什么时候该刷新、什么时候不该,判断标准始终是频率。
4.2 会话数据模型与持久化
interface SessionConfig {
name: string;
protocol: string; // 'ssh' | 'telnet' | 'rlogin' | 'raw'
host: string;
port: number;
username: string;
password: string; // 仅运行期,不落盘
}
interface TerminalTab {
config: SessionConfig;
output: string; // 终端缓冲
connected: boolean;
connecting: boolean;
inputText: string;
processId: number;
terminalFd: number;
readerActive: boolean;
encoding: string; // 'utf-8' | 'gbk',自动探测
}
已存会话用 @ohos.data.preferences 持久化,语义对齐 PuTTY:保存主机、端口、协议、用户名,永不保存密码。
4.3 启动子进程与数据通道
点「打开」后的核心链路:
channel = mtputtyTerminal.createChannel(); // 拿到一对 fd
const processId = await childProcessManager.startNativeChildProcess(
NATIVE_CHILD_ENTRY, // 'libmtputty_child.so:Main'
{
entryParams: encodeArguments(payload), // argv + ENV= 条目
fds: { 'terminal': channel.childFd } // 子进程 stdout -> GUI
},
{ isolationMode: false }
);
tab.processId = processId;
tab.terminalFd = channel.parentFd; // GUI 侧读写这个 fd
this.readTerminal(tab); // 异步循环读
readTerminal 是一个 8KB 缓冲的循环读:
while (tab.readerActive && tab.terminalFd >= 0) {
const bytesRead = await fileIo.read(tab.terminalFd, buffer);
if (bytesRead <= 0) break;
const bytes = new Uint8Array(buffer, 0, bytesRead);
// ... 编码探测与解码(见第六章)
tab.output += chunk;
if (this.tabs[this.currentTabIndex] === tab) {
this.currentOutput = tab.output;
}
}
4.4 数据通路:一次回车键的完整旅程
把整条链路串起来看一次"用户敲下 ls 回车"后发生了什么,有助于理解这个架构为什么毫秒级:
- 用户在底部输入行敲入
ls,onChange把字符同步进currentInput; - 点「发送」,
sendInput把ls\r写进tab.terminalFd(管道写端); - 内核把这个字节送到 Native 子进程的标准输入;
- 子进程里的 plink 正阻塞在 stdin 读取上,立刻拿到
ls\r,交给 SSH 通道层加密、打包、发出; - 远端 shell 执行
ls,输出经 SSH 通道解密,plink 的终端模拟层(terminal.c)解析转义序列后,把"应该显示什么"写到 stdout; - 子进程的 stdout 就是 GUI 持有的
parentFd,readTerminal的阻塞读被唤醒; - 数据块解码、追加进
currentOutput,@State变化触发 ArkUI 重绘Text节点; onAreaChange感知文本高度变化,scrollEdge(Edge.Bottom)跟随到底。
全程没有序列化、没有跨语言绑定调用、没有事件总线,就是两条操作系统管道。这也是为什么它比任何 WebView 方案都快——快的不是渲染,而是数据通路上没有任何多余的一跳。
值得说明的是 plink 侧的"无行编辑"改造:PuTTY 在交互模式下默认启用本地行编辑(line editing),用户输入先在客户端缓冲、回显,回车才发送。这对传统场景是对的,但在我们这里输入行的全部职责都在 GUI 侧(ArkTS 的 TextInput),客户端再缓冲一层反而会出现"双重回显"。所以构建时用 no-lineedit.c.o 桩替换了它的行编辑模块,让 plink 工作在"透传"模式——每个字符到达即转发,回显完全依赖远端的 echo。这也是终端语义的标准行为(PTY echo),和 OpenSSH 直接交互时的行为一致。
五、第三步:DevEco Studio 真机运行
代码写完只是开始。鸿蒙 PC 的调试路径是:DevEco Studio 选择真机设备 → Run → 自动 hdc install + hdc shell aa start:

输出面板里这三行是每个鸿蒙开发者最熟悉的节奏:
12:51:09.208: Launching org.ttyplus.mtputty.ohos
12:51:09.267: $ hdc shell aa force-stop org.ttyplus.mtputty.ohos
12:51:09.943: org.ttyplus.mtputty.ohos successfully launched within 677 ms
应用第一次跑起来,填上 test.rebex.net / demo / demo(Rebex 官方的公益 SSH 测试服务器,无数教程用它),点「打开」——然后,真正的故事才刚开始。
六、实战排错篇:真机上的六大深坑
这一章是全文最干货的部分。以下六个问题,每一个在模拟器上都发现不了,全部只在真机上暴露;每一个的定位过程都值得单独写一篇博客。按发现的顺序讲。
6.1 坑一:Unable to write random seed —— HOME 不可写
第一次连接,终端里刷出来的第一行就是:
ERROR: Unable to write random seed: /storage/users/currentUser/.putty: mkdir: Operation not permitted

定位:PuTTY 启动时要在 $HOME/.putty 写随机种子文件。子进程默认 HOME=/storage/users/currentUser,这个目录对 HAP 子进程只读。
修复:应用沙箱里有完全可写的 filesDir,把它通过 ENV=HOME= 传下去:
const homeDir = `${getContext(this).filesDir}/putty_home`;
try {
fileIo.accessSync(homeDir);
} catch (_) {
fileIo.mkdirSync(homeDir);
}
payload = command.concat([`ENV=HOME=${homeDir}`]);
这里有个隐藏 bug 值得一提:最初写的是直接 fileIo.mkdirSync(homeDir),目录已存在时抛 EEXIST 异常,整个 HOME 条目被 catch 吞掉,修复看起来"没生效"。正确姿势就是上面这样 accessSync 先探测。这个教训是:捕获块静默吞异常时,至少要 hilog.warn 一条。
6.2 坑二:Name does not resolve —— 子进程沙箱里 DNS 全灭
HOME 修好后,出现第二个错误,也是最致命的一个:
Unable to open connection:
Name does not resolve

这个问题排查了整整一天,把三层假设逐一证伪,最后发现是一个全新的系统行为。
当时用来复现问题的标准会话配置如下(Rebex 公益测试服务器,demo/demo),保存为命名会话以便反复载入:

第一层假设:网络不通? 证伪。hdc shell "ping -c 2 test.rebex.net" 正常返回 194.108.117.16,网络和系统级 DNS 都好好的。
第二层假设:/etc/resolv.conf 不存在? 部分证伪。文件存在且内容正常(114.114.114.114 / 8.8.8.8)。但是——拉取子进程的 hilog 沙箱挂载记录,发现大量 errno 13(Permission Denied)的 mount 条目,/etc 根本没有完整挂进子进程的 mount 命名空间。
musl libc 的 getaddrinfo 有个特性:读不到任何 nameserver 配置时,直接返回 EAI_NONAME,而 gai_strerror(EAI_NONAME) 恰好就是 “Name does not resolve”。与现象完全吻合。
根因:7.0.0.111 固件给 HAP 的 Native 子进程上了更严格的沙箱,/etc/resolv.conf 不可达 → musl 认为系统没有 DNS → 所有域名解析瞬间失败。
修复方案的三次迭代,值得完整记录:
迭代 1:自带 DNS 解析器(UDP 直查)。在 unix/network.c 里给 getaddrinfo 失败后加兜底:自己构造 DNS 报文,往 nameserver 的 53 端口发 UDP 查询,收到响应后手工解析 A 记录、构造 addrinfo 链返回给 PuTTY。nameserver 来源:环境变量 MTPUTTY_DNS → /etc/resolv.conf → 内置公共 DNS。
结果:UDP 查询能收到响应(84 字节),但 rcode=3(NXDOMAIN)——连 8.8.8.8 都说域名不存在?而 PC 上同样的查询返回正常。
这个手写 DNS 解析器本身值得一提,它只有一百来行 C,却完整覆盖了查询构造、超时重试、响应解析三个环节。报文构造部分:
unsigned char q[512];
memset(q, 0, sizeof q);
q[0] = 0x4d; q[1] = 0x50; /* id "MP" */
q[2] = 0x01; q[3] = 0x00; /* flags: RD */
q[5] = 1; /* qdcount */
size_t off = 12;
const char *p = name; /* 域名 → label 序列 */
while (*p) {
const char *dot = strchr(p, '.');
size_t len = dot ? (size_t)(dot - p) : strlen(p);
q[off++] = (unsigned char)len;
memcpy(q + off, p, len);
off += len;
if (!dot) break;
p = dot + 1;
}
q[off++] = 0; /* root label */
q[off++] = 0; q[off++] = 1; /* QTYPE = A */
q[off++] = 0; q[off++] = 1; /* QCLASS = IN */
响应解析要处理压缩指针(0xC0 开头的偏移引用)和 CNAME 链——第一个 A 记录前面往往跟着一条 CNAME,跳过非 A 记录才能拿到真正的地址。SO_RCVTIMEO 设 3 秒,逐个 nameserver 轮询。这套代码最后虽然被 DoH 方案取代为主路径,但它保留在代码里作为兜底:万一某台设备 DoH 也不可用(比如内网全封),至少还能试一把直连 53。
迭代 2:TCP DNS + 权威服务器直查。怀疑 UDP 被劫持,加 TCP 53 重试、扩大候选列表(1.1.1.1、9.9.9.9、OpenDNS、甚至 rebex 的权威服务器 ns.rebex.cz)。结果:所有 :53 端口的流量,无论 UDP 还是 TCP,返回的 NXDOMAIN 应答字节一模一样——这是 netsys 在网络层把应用的 DNS 统一重定向到了系统解析器。
迭代 3:DoH(最终方案)。既然 :53 全被劫持,那就彻底绕开这个端口。ArkTS 主进程(不受子进程沙箱限制)用 HTTPS(443 端口,无法被劫持)向 DoH 服务发起解析,把结果通过 ENV=MTPUTTY_IPMAP=host=ip 注入子进程;native 侧在 getaddrinfo 失败后先查这张表:
/*
* The ArkTS GUI may resolve hosts itself (DoH over HTTPS, which OHOS cannot
* hijack) and hand the results down via MTPUTTY_IPMAP="host=ip,host=ip".
* Connecting still uses the hostname, so the host-key cache stays keyed by
* name; only the destination address is pinned.
*/
static int ohos_ipmap_lookup(const char *host, SockAddr *addr,
char **canonicalname)
{
const char *env = getenv("MTPUTTY_IPMAP");
if (!env || !*env)
return -1;
char *dup = dupstr(env);
char *save = NULL, *tok;
int found = -1;
for (tok = strtok_r(dup, ",", &save); tok; tok = strtok_r(NULL, ",", &save)) {
char *eq = strchr(tok, '=');
if (!eq)
continue;
*eq = '\0';
if (strcasecmp(tok, host) != 0)
continue;
struct in_addr a;
if (inet_pton(AF_INET, eq + 1, &a) != 1)
continue;
found = ntohl(a.s_addr);
break;
}
sfree(dup);
if (found < 0)
return -1;
/* ... 用查到的 IP 构造 addrinfo,canonicalname 仍是主机名 ... */
}
ArkTS 侧的 DoH 解析器(AliDNS / DNSPod JSON API,注意 @ohos.net.http 会把 application/json 自动解析成对象,需要兼容两种类型):
private async resolveViaDoH(host: string): Promise<string> {
const endpoints: string[] = [
`https://dns.alidns.com/resolve?name=${host}&type=A`,
`https://doh.pub/dns-query?name=${host}&type=A`,
];
for (const url of endpoints) {
try {
const resp = await http.createHttp().request(url, req);
const raw = typeof resp.result === 'string'
? resp.result : JSON.stringify(resp.result);
const obj = JSON.parse(raw) as DoHResponse;
if (obj.Status !== 0 || !obj.Answer) continue;
for (const ans of obj.Answer) {
if (ans.type === 1 && /^\d+\.\d+\.\d+\.\d+$/.test(ans.data)) {
return ans.data;
}
}
} catch (e) { /* 尝试下一个端点 */ }
}
return '';
}
设计上有个细节值得强调:连接仍然用主机名发起(plink 命令行里还是 test.rebex.net),IPMAP 只固定目标地址。这样 SSH 的主机密钥仍然按域名缓存,符合 PuTTY 的安全语义——中间人不能靠换 IP 骗过密钥确认。
验证日志:
DoH resolved test.rebex.net -> 194.108.117.16
Starting native terminal: plink -P 22 -ssh -l demo -pw ****** test.rebex.net payloadEntries=12
Store key in cache? (y/n, Return cancels connection, i for more info)
问题解决。 这套 DoH+IPMAP 机制是通用能力,任何域名都受益,不是只针对某台服务器的特例。
6.3 坑三:Windows 服务器上的 GBK 乱码
DNS 通了、SSH 能登录了,连 Windows 机器(cmd.exe)时终端里出现:
Microsoft Windows [???汾 10.0.26200.9711]
(c) Microsoft Corporation????????????????
ls 一个中文目录名也是满屏 ��。
根因:readTerminal 固定用 util.TextDecoder.create('utf-8')。Windows 的 cmd 输出是 GBK(CP936),UTF-8 解码器遇到非法序列就产出 U+FFFD 替换符。而 Linux 服务器是 UTF-8,不能一刀切改 GBK。
修复:字节级编码自动探测。写一个 UTF-8 语法校验器,对每个读取块做判定:
private isUtf8Bytes(bytes: Uint8Array): boolean {
let i = 0;
const n = bytes.length;
while (i < n) {
const b = bytes[i];
if (b < 0x80) { i++; continue; }
let need = 0;
if ((b & 0xE0) === 0xC0) need = 1;
else if ((b & 0xF0) === 0xE0) need = 2;
else if ((b & 0xF8) === 0xF0) need = 3;
else return false; // 非法起始字节 → 不是 UTF-8
for (let k = 1; k <= need; k++) {
if (i + k >= n) return true; // 块尾截断的多字节序列,合法
if ((bytes[i + k] & 0xC0) !== 0x80) return false;
}
i += need + 1;
}
return true;
}
首个含高位字节的块判定编码后锁定整个会话,并用 { stream: true } 流式解码避免多字节字符被读取边界截断。修复后 dir 一个中文目录,终端干干净净:

博雅产品 和谐架构、Redis学习_TEMPLATE.md、23 个文件、可用字节——全部正确。
6.4 坑四:输入一个字符就丢焦点
用户反馈:在主机名/密码输入框里敲一个字符,焦点就弹出来,想敲第二个字得重新点。
根因:这三个 TextInput 的 onChange 里都调了 this.refreshTabs(),而它就是一行 this.tabs = this.tabs.slice()——每次按键都重赋值 @State 数组,触发整个会话面板重建,TextInput 被销毁重建,焦点自然丢失。
修复:这些配置值只在点「连接」时才读取,输入过程根本不需要刷 UI。删掉三处 onChange 里的 refreshTabs() 即可。这个 bug 的教训在于:@State 数组重赋值是最重的刷新方式,能用局部状态就不要用它——这个教训在坑六里还会再应验一次,而且更隐蔽。
6.5 坑五:命令输出后,终端永远停在顶部
连接成功后敲 dir,几十行输出刷出来,但视口停在缓冲区最上面,最新输出在下面看不见,必须手动拖滚动条。
根因:Scroll 组件没有绑定 Scroller,且每次 refreshTabs() 重建子树时滚动偏移被复位。
修复:绑定 Scroller,并在输出 Text 上挂 onAreaChange——文本高度一变化(有新输出)就 scrollEdge(Edge.Bottom):
Scroll(this.termScroller) {
Column() {
Text(this.currentOutput)
// ...
.onAreaChange(() => {
// Follow new output (the tab array is re-assigned on every
// chunk, which rebuilds the subtree and resets the offset).
this.termScroller.scrollEdge(Edge.Bottom);
})
}
}
用 onAreaChange 而不是定时器,是因为重建后的首帧也会触发,时序天然正确。
6.6 坑六:点「发送」没反应 —— 最隐蔽的一个
这个坑的表象是:终端连上了,在输入框敲了 whoami,点「发送」按钮,没有任何反应;截图一看,输入框里的字甚至没了。
定位过程:先怀疑按钮事件没触发,加了日志发现 sendInput 确实被调用,但在入口就被 return 了——tab.inputText.length === 0。也就是说,用户输入的内容根本没有同步到数据模型。
根因:又是 refreshTabs()!终端区每收到一个输出块就重建整棵子树,TextInput 跟着被销毁重建。用户正在打字时,一次重建就把输入框的 DOM 换掉了——内容、焦点、输入法组合状态全部丢失。坑四和它同根同源,只是终端页的输出块更频繁,把问题放大到了必然复现。
修复:把"高频变化的状态"从 tabs 数组里拆出来,用独立的 @State 承载,让输出刷新只作用于 Text 节点:
@State currentOutput: string = ''; // 当前 tab 的终端缓冲镜像
@State currentInput: string = ''; // 当前 tab 的输入行镜像
读取线程只更新镜像,不再重赋值数组:
tab.output += chunk;
// Only refresh the visible text node; re-assigning `tabs` here would
// rebuild the terminal subtree on every chunk and kill the input box.
if (this.tabs[this.currentTabIndex] === tab) {
this.currentOutput = tab.output;
}
输入框绑定 this.currentInput,发送逻辑同样走独立状态:
private async sendInput(tab: TerminalTab): Promise<void> {
const value = this.currentInput.length > 0 ? this.currentInput : tab.inputText;
if (!tab.connected || tab.terminalFd < 0 || value.length === 0) {
return;
}
const text = `${value}\r`; // 回车跟在内容后,触发命令执行
tab.inputText = '';
this.currentInput = '';
await fileIo.write(tab.terminalFd, text);
}
修复后完整链路一次跑通——输入保留、点发送、命令执行、回显到提示符:

[连接中] ssh testuser@192.168.1.4:2222
[已连接] 原生会话进程 46346
Using username "testuser".
Microsoft Windows [版本 10.0.26200.9711]
C:\Users\24190\Desktop\harmony-pc>laptop-kj5mptm2\24190
这一坑的教训值得单独记住:ArkUI 中 @State 数组重赋值 = 整棵子树重建。任何"每秒都在变"的数据(终端输出、日志流、进度)都不该放进它。
6.7 番外:两个与应用无关但会耽误一下午的环境坑
其一:Windows 防火墙。 联调本地测试服务器时,设备连 PC 死活 Operation timed out。双向 ping 验证:PC ping 设备通、设备 ping PC 丢包——典型的 Windows「公用网络」配置文件阻止一切入站。一条命令解决:
New-NetFirewallRule -DisplayName "MTPuTTY Test" -Direction Inbound -Action Allow `
-Protocol TCP -LocalPort 2222,2323,1513,9000
其二:公网测试服务器不可靠。 test.rebex.net 在联调期间 DNS 记录失效 + demo 密码被官方停用(PC 上 OpenSSH 官方客户端同样登录失败)。结论:演示和联调的对端必须自己掌控。项目里附带了一个 60 行的 paramiko 测试服务器 tools/mtputty_test_sshd.py,一条命令在本机同时起 SSH(2222)/Telnet(2323)/Rlogin(1513)/Raw(9000) 四种对端,还把 host key 持久化避免重复确认。
七、上架流程:从 HAP 到应用市场
7.1 签名与打包
上架包必须是签名 HAP。DevEco Studio 的 hvigor 一条命令完成编译+签名:
hvigorw --mode module -p module=entry@default -p product=default assembleHap
→ entry/build/default/outputs/default/entry-default-signed.hap
注意上架包要关闭调试选项(buildOptionSet 里 debuggable: false、copyLibsResource: false),并核对 apiType: stageMode、externalNativeOptions 等。
7.2 应用信息配置
AGC(AppGallery Connect)里创建应用后需要准备:应用名、图标、分类、隐私声明、版本说明、截图。应用名有个小细节:审核要求桌面图标名与提审名完全一致,所以 AppScope/resources/base/element/string.json 的 app_name 和 EntryAbility_label 都要改成「墨签终端(from MTPuTTY)」,改完重新出包安装确认桌面显示无误再提审。
7.2.1 一份可复用的 AGC 提审检查清单
把实际走过一遍的提审材料整理成清单,照着勾就行:
- 应用信息:应用名(与桌面图标名逐字符一致)、简介、分类(工具)、年龄分级(3+)
- 图标:1024×1024,无圆角(系统自动裁切),内容与桌面图标一致
- 截图:至少 3 张真机截图,覆盖核心功能(配置窗口 / 终端会话 / 多标签)
- 介绍图:可选但强烈建议,直接决定转化率
- 隐私声明:URL 可访问;本应用无任何数据收集,声明写"不收集"即可,但必须写
- 权限说明:
ohos.permission.INTERNET(网络连接),无敏感权限,无需人工审批 - 版本说明:写清本版本功能点
- 自测文件:演示录屏(见 7.3)
- 软件版权:开源组件清单(PuTTY 0.83, MIT)在应用内"关于"页可查
特别说一下隐私声明:很多开发者以为"我不收集数据就不用写",实际上 AGC 要求必须提交隐私声明且 URL 可访问,哪怕内容只有一句"本应用不收集、不存储、不上传任何用户数据"。这个应用把"不依赖云服务"写进了架构,隐私声明反而成了最容易过的部分。
7.3 审核被拒:核心功能必须可体验
第一轮审核被拒,意见很明确:
您应用内的新建会话-连接类型-ssh/telnet/rlogin/raw 功能需要连接终端才能体验,需提供便捷使用的演示录屏。
1、需要您提供一段在鸿蒙设备上录制、从启动应用开始、可完整呈现用户使用核心功能的演示录屏。
2、上传路径:开发者管理 → 对应应用 → 分发 → 版本信息 → 应用审核信息栏「自测文件」。
应对方式就是前面 6.7 节的本地测试服务器 + 一套分镜脚本,一镜到底录了 4 分钟:
| 分镜 | 时长 | 内容 |
|---|---|---|
| 开场 | 10s | 桌面点图标启动,主界面 |
| SSH | 60s | 填 192.168.1.4:2222 → 连接 → 提示符 → whoami/ver/dir(展示中文正常+自动滚动) |
| Telnet | 40s | 2323 端口 → help/echo hello |
| Rlogin | 40s | 1513 端口 → help/whoami |
| Raw | 30s | 9000 端口 → 任意输入原样回显 |
| 多标签 | 20s | 标签栏 4 个会话并列切换 |
| 结尾 | 10s | 断开,回到会话列表 |
两个容易翻车的细节:切换连接类型后端口会自动联动(Telnet→23、Rlogin→513),演示时要手动改回测试端口;录制时把抖音等抢前台的 App 杀掉,避免中途被遮挡。
视频传「自测文件」并在审核意见里回复一句说明,第二轮顺利通过。
7.4 上架
过审后应用市场页面(见第一章首图)即刻生效。从被拒到过审,间隔一轮"补演示视频"的周期。
八、宣传素材:让应用"像被认真对待"
上架页面用户第一眼看到的是应用介绍图。用真实截图做了两张:
第一张突出核心卖点——真连接、真终端,不只是个壳:

文案三行点透架构:内置 PuTTY 完整 SSH 协议栈、本地加密;黑底等宽终端、命令实时回显;多标签并行、每台服务器一个窗口互不干扰。
第二张突出经典交互——经典 PuTTY 配置窗口,熟悉的味道:

四条特性:经典 PuTTY 配置窗口;主机、端口、连接类型一屏填完;支持 SSH / Telnet / Rlogin / Raw 四种协议;会话可保存、载入、删除,下次连接一键直达。
一个经验:宣传图里的截图必须是真机截图,报错也好、成功也好,真实感比美化更重要——审核员和用户都见过太多 P 出来的假界面。
八点五、性能与体验数据
上架前在真机上跑了一组主观可用性验证(非严格基准测试,但足够说明量级):
| 指标 | 表现 |
|---|---|
| 点击「打开」到出现主机密钥确认/提示符 | 局域网 < 1s,公网 1~3s(取决于服务器) |
| 命令回显延迟(局域网 SSH) | 不可感知(< 50ms) |
dir 大目录输出(50+ 行) | 无卡顿,滚动跟随流畅 |
| 输入行打字 | 无延迟、无丢焦、无重复字符 |
| 会话断开重连 | 一键「重新连接」,host key 免二次确认 |
| 终端缓冲上限 | 依赖 Text 渲染能力,超长会话建议定期重连(v1.0 策略) |
坦率记录两个已知的 v1.0 局限:一是终端缓冲区用单个 Text 字符串渲染,没有实现滚动回放和选择复制,超长会话(数万行)的渲染压力会上升;二是没有实现 PuTTY 的代理、隧道、密钥认证(当前只支持密码认证)。这些都列在 v1.1 的迭代计划里——先上架拿到真实用户反馈,再决定迭代优先级,比闷头堆功能更有效。
九、经验总结与避坑清单
最后把整个项目浓缩成一张清单,按"如果重来一次"的视角:
架构层面
- Native 子进程 + fd 直通是鸿蒙上跑 CLI 型开源软件的最优解:进程隔离保 GUI 不崩,
ENV=参数通道万能,成本是一次 fd 的读写循环。 - 进程内化三板斧:
llvm-objcopy --redefine-sym main=xxx→ 桩文件替换平台依赖(GTK/lineedit)→ 静态库归档后被 .so 链接。 - 加密、协议、密钥全部留在本地进程内,应用介绍里"不依赖任何第三方云服务"不是口号,是架构事实。
真机排错方法论
- 模拟器永远发现不了沙箱问题:mount 命名空间、SELinux、netsys 劫持,全部只在真机暴露。排错第一步永远是拉 hilog 看子进程的沙箱挂载记录。
- 不要相信"系统能 = 应用能":系统 ping 通 ≠ 应用进程能解析域名(沙箱);系统能上网 ≠ 子进程能开 UDP(netsys 重定向)。每一层都要单独验证。
- 报错信息会撒谎:
Name does not resolve的真实原因是"读不到 resolv.conf",不是"域名不存在"。把gai_strerror的每个错误码对应的 musl 行为记熟,能省一半时间。 - 打点先行:给 C 代码里所有失败路径加
fprintf(stderr, ...)(会流进 GUI 的调试文件),比猜测高效十倍。本项目里[MTPuTTY dns]、[MTPuTTY ipmap]系列日志功不可没。
ArkUI 层面
@State数组重赋值 = 子树全量重建。高频数据(终端输出、日志)用独立@State镜像,只在结构变化(连接/断开/切标签)时才动数组。- 终端自动滚底用
Text.onAreaChange + Scroller.scrollEdge(Edge.Bottom),不要用定时器。 - 输入框丢焦问题,九成是"onChange 里刷了全量状态",删掉那行就好了。
上架层面
- 桌面应用名与提审名必须逐字符一致,改
app_name和EntryAbility_label两处。 - 核心功能"需要连接才能体验"的,主动录一镜到底演示视频传自测文件,别等审核员来问。
- 联调对端自己搭(paramiko 四合一服务器),公网公益服务器说挂就挂。
最后一条,也是最重要的:
- 开源协议是上架的通行证,也是底线。PuTTY 是 MIT 协议,可以合法修改、改名、分发;应用名里保留 “from MTPuTTY”、关于页注明基于 PuTTY 0.83,既是对上游的尊重,也让用户知道这软件的协议引擎来路正。
十、开源信息
10.1 欢迎共建
仓库里有一份开放的迭代清单,欢迎以任何形式参与:
- 密钥认证:当前
-pw密码认证已通,-i私钥认证的 GUI 表单和密钥管理页待做; - 终端增强:选择复制、滚动回放、字体缩放;
- 隧道与代理:SOCKS/HTTP 代理、SSH 隧道(plink 本身支持,GUI 缺入口);
- 上游回馈:
unix/network.c的 OHOS 沙箱补丁理论上对"任何在受限沙箱里跑 musl 程序"的场景都有价值,欢迎整理后提交给 PuTTY 上游讨论。
给上游回馈这件事多说一句:我们在 network.c 里打的补丁始终用 #ifdef OHOS_PORT_DNS_FALLBACK 包裹,默认不开启,只在 OHOS 构建配置里定义该宏。打补丁时保持"对上游零侵入"的习惯,日后回馈和版本合并的成本会低一个数量级。
10.2 写在最后
从 git clone putty 到应用市场可安装,这个项目断断续续跨了一个季度。回头看他其实是三件事的乘积:
一件老实事(协议引擎)× 一件新事(鸿蒙 PC 平台)× 一件琐碎事(上架流程)。
老实事有 PuTTY 二十六年的沉淀兜底,新事有华为的开发者文档和社区撑腰,真正磨人的是琐碎事——那一堆只在真机上出现的、文档里查不到的、报错信息答非所问的问题。这篇文章用最长的篇幅写了第六章,就是希望把这些琐碎事一次性摊开。
如果这篇文章帮你少踩一个坑,欢迎去仓库点个 Star;如果它帮你把某个开源软件搬上了鸿蒙 PC,那更好——鸿蒙 PC 生态的基建,就是这么一行一行攒出来的。
十一、常见问题 Q&A
这一章把留言、群里被问过最多的问题按主题整理出来。问得多的往往是"我也遇到了,但我不知道该往哪个方向想",所以每个回答尽量给出判断依据,而不只是结论。
A. 技术选型
Q1:为什么不直接用 WebView + xterm.js?那样不是几小时就搞定了吗?
确实更快,但代价是你要自己实现 SSH 协议——xterm.js 只负责"渲染终端画面",它不认识 SSH。你仍然需要一个 SSH 客户端把远端数据喂给它,于是问题回到原点。反过来,如果你已经有了一个能跑通 SSH 的原生引擎(PuTTY),那么"渲染"这部分反而是最简单的:终端画面本质上就是一串带转义序列的文本流,ArkUI 的 Text 足够。
真正的取舍在于:WebView 会引入一个完整的浏览器内核(内存、启动耗时、沙箱权限、包体积),而换来的只是"渲染更漂亮"。在 v1.0 阶段优先保证"连得上、用得住",渲染的精致度留给 v1.1。
Q2:为什么不用 OpenSSH 而用 PuTTY?
OpenSSH 功能更全,但它对移植极不友好:依赖 OpenBSD 兼容层(arc4random、strlcpy、explicit_bzero 等一大堆 shim),构建系统要 autotools 跑一遍,且它是交互式程序——ssh 命令启动就占据前台终端,没有类似 plink 那种"把会话跑在纯管道上"的入口。PuTTY 的 plink 天生就是这个入口:stdin/stdout 走管道、不碰 TTY、退出码语义清晰。移植成本差了一个数量级。
Q3:为什么不把 plink 编成 .so,主进程里 dlopen 调用?
试过,放弃了,理由是崩溃隔离。plink 内部有大量 exit() 和断言,遇到畸形的服务端响应、不支持的算法协商,它会直接终止进程。如果它跑在主进程里,一次协议异常就是"应用闪退";跑在子进程里,同样情况只是"连接断开",GUI 仍然活着,还能给用户提示"连接已断开,是否重连"。
再加上子进程有独立的环境变量空间,ENV=HOME、ENV=MTPUTTY_DNS、ENV=MTPUTTY_IPMAP 想传什么传什么,不要污染主进程。这个独立性在后面修 DNS 问题时救了命。
Q4:终端画面用 Text 渲染,性能扛得住吗?
局域网回显、50+ 行 dir 输出都无卡顿。真正的压力不在渲染,而在刷新策略:早期版本每收到一块数据就重赋值 @State tabs 数组,导致整棵子树重建,卡顿和用户可见的丢焦点都源于此(见 Q10)。改成只更新 currentOutput 这一个字符串状态后,Text 节点局部重绘,性能问题就消失了。先确认你的瓶颈是渲染还是刷新,别急着换渲染引擎。
B. 移植实现
Q5:--redefine-sym main=plink_main 这一步到底在解决什么?
libmtputty_child.so 作为子进程的入口,自己有一个 Main(NAPI 约定)。而 plink.c.o 里也有一个 main——两个 main 链接到一起会符号冲突。最干净的做法不是改 PuTTY 源码(那样每次同步上游都要重新改),而是在目标文件层面改符号名:
llvm-objcopy --redefine-sym main=plink_main plink.c.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
好处是对上游源码零侵入,PuTTY 升级只要重跑一遍这两行。
Q6:为什么 CLI 工具用 -static 纯静态链接?
两个理由。一是行为确定:静态链接的是我们选的 musl,解析器行为、DNS 逻辑、locale 处理全部固定,不会因为目标设备 libc 版本不同而产生差异——排错时可以直接排除"libc 差异"这个变量维度。二是分发简单:HNP 包里放进去就能跑,不依赖系统里有没有对应的 so。
代价是体积(每个二进制几 MB),对于工具类应用完全可接受。
Q7:ENV= 通道能传什么?有什么限制?
任何形如 ENV=KEY=VALUE 的条目都会被子进程解析成 setenv(KEY, VALUE),剩下的条目才作为 plink 的 argv。本项目用它传了三类东西:可写 HOME、DNS 服务器列表、域名→IP 映射表(IPMAP)。
限制有两个:一是值里不能出现 = 之外的分隔符冲突(解析按第一个 = 切分,所以 MTPUTTY_IPMAP=host=ip 的 value 是 host=ip,没问题);二是总长度受 entryParams 大小限制,别往里塞大段数据——它是参数通道,不是 IPC。
C. 真机排错
Q8:怎么快速判断 DNS 失败是"沙箱"还是"网络"?
在设备上跑三条命令,结果对照判断:
hdc shell "ping -c 2 <你的域名>" # 系统级能不能解析
hdc shell "cat /etc/resolv.conf" # 有没有 nameserver
hdc shell "hilog -x | grep <子进程pid>" # 看沙箱挂载记录里的 errno 13
判据:ping 通 + 应用仍报 Name does not resolve = 沙箱/解析器问题,不是网络问题。musl 的 getaddrinfo 在读不到任何 nameserver 时直接返回 EAI_NONAME,而 gai_strerror(EAI_NONAME) 恰好就是 “Name does not resolve”——这个报错文案极具误导性,它说的不是"域名不存在",而是"我压根不知道该问谁"。
Q9:为什么连 :53 的 UDP 和 TCP 都被劫持?是不是我代码写错了?
不是。实测所有发往 :53 的查询(无论 UDP、TCP,无论目标是 114、8.8.8.8、1.1.1.1 还是域名的权威服务器)返回的 NXDOMAIN 应答字节完全一致——这说明是网络层(netsys)把应用的 DNS 统一重定向到了系统解析器,而不是你的报文有问题。
应对思路是换协议层:走 HTTPS 443 的 DoH(见 6.2 迭代 3)。能用更高层协议绕开,就不要和底层策略硬碰。
Q10:输入框打字丢焦点、点「发送」没反应,第一反应该查什么?
查 onChange 里有没有触发全量状态刷新。在 ArkUI 里 this.tabs = this.tabs.slice() 这样的数组重赋值会让整棵子树重建,TextInput 被销毁重建,焦点、内容、输入法状态一起丢失。
排查方法很直接:把这行注释掉、重新构建,如果焦点不再丢,根因就确认了(本项目里坑四和坑六都是它,一个在配置页、一个在终端页)。修法是把高频变化的数据移出数组,用独立 @State 承载。
Q11:连 Windows 服务器满屏乱码,能一刀切改成 GBK 吗?
不能——Linux 服务器是 UTF-8,切成 GBK 会反过来乱码。正确做法是自动探测:对每个读取块做 UTF-8 语法校验(见 6.3 的 isUtf8Bytes),首个含高位字节的块判定编码后锁定整个会话,并用 stream: true 流式解码避免多字节字符被读取边界截断。判定为 UTF-8 就走 UTF-8,否则走 GBK。
D. 上架与合规
Q12:演示视频是必须的吗?不传会怎样?
只要你的核心功能"需要连接外部资源才能体验",就强烈建议主动传。本项目第一轮就是因为这个被拒:审核员看不到 SSH/Telnet/Rlogin/Raw 到底能不能连,就只能打回要求补录屏。
上传路径:开发者管理 → 对应应用 → 分发 → 版本信息 → 应用审核信息栏「自测文件」。视频要求:鸿蒙设备上录制、从启动应用开始、一镜到底不剪辑,把每个协议的连接类型选项停留 1~2 秒让审核看清。
Q13:应用名里带括号英文(墨签终端(from MTPuTTY))会不会有问题?
不会,但要保证三处一致:AGC 里提审的应用名、桌面图标显示名、应用市场页面名。桌面显示名由 AppScope/resources/base/element/string.json 的 app_name 和 EntryAbility_label 两个字段决定,改完必须重新出包安装确认,否则会出现"提审名和桌面名不一致"被打回。
Q14:PuTTY 是 MIT 协议,我改名字、加上自己的品牌上架,合规吗?
合规,MIT 允许修改、重命名、再分发,包括商用。但有三件事要做:一是保留版权声明(在应用「关于」页注明基于 PuTTY 0.83 及其许可证);二是保留对上游的署名(应用名里的 “from MTPuTTY”、介绍文案里的来源说明);三是公开你的修改(如果你分发了修改后的二进制,MIT 要求随附许可证文本,开源仓库天然满足)。
需要提醒的是:Windows 上的 MTPuTTY(ttyplus.com)本体是闭源软件,本项目只是借鉴了它的"多标签"产品形态,协议引擎是 PuTTY 的,名字里的 “from MTPuTTY” 表达的是产品形态致敬,不是代码继承。README 里这一点写得很清楚,避免误导。
Q15:应用不收集任何数据,还需要隐私声明吗?
**需要,而且是必填项。**只要提交上架就得提供可访问的隐私政策 URL,哪怕内容只有一句"本应用不收集、不存储、不上传任何用户数据,所有连接在设备本地完成"。写一个静态页面挂上去即可,成本五分钟,但不写会被卡住。
——写于墨签终端上架当晚,配着一台 MateBook Pro 和四个终端标签。
更多推荐


所有评论(0)