把 Linux 上的「终端之王」Terminator 搬到鸿蒙 PC:一次真机踩坑的完整记录
把 Linux 上的「终端之王」Terminator 搬到鸿蒙 PC:一次真机踩坑的完整记录
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_terminator

写在前面
我最近在系统性地把一批 Linux 上经典的开源软件适配到鸿蒙 PC。一个绕不开的目标就是 Terminator——Linux 终端模拟器里公认的"分屏之王",由 gnome-terminator 维护,GPL-2.0,纯 Python + GTK。
它最独特的卖点不是花哨主题,而是那套递归分屏(Pane Tree):窗口可以任意水平/垂直劈开,每个 Pane 跑独立会话,标签页 + 广播输入 + 拖拽分割条 + 右键菜单。Linux 上 Vim 老炮儿基本都用过它。
问题在于:鸿蒙 PC 上没有 Python 解释器,没有 GTK,也没有 PyGObject,上游那套代码根本没法直接跑。
所以我走了一条不寻常的路:ArkTS 原生重实现 + N-API 原生桥。复刻最核心的交互语义,再补上一个真实能跑 shell 的通道层。
这条路踩的坑,比我预想的多得多。这篇文章把整个过程的架构、踩坑、替代方案、真机验收一次性讲清楚,希望对同样在做鸿蒙 PC 适配的朋友有用。


一、路线
上游的依赖链看一眼就知道没戏:
terminator → pygtk3 → pygobject → libgtk-3 → libcairo → pango → ...
每一个 so 在鸿蒙 PC 上都缺,临时编译整个 GTK 工具链成本极高,体积动辄几十 MB,且 GTK 本身也不是鸿蒙 UI 体系,移植过来也不是原生体验。
更关键的:Terminator 的核心价值是交互模型,不是渲染本身。Panes 的递归分割、广播输入、标题栏三色语义、缩放聚焦——这些交互可以完全用鸿蒙原生 UI 组件重新表达。
所以选定了路线:
| 层 | 上游实现 | 鸿蒙 PC 重实现 |
|---|---|---|
| UI 渲染 | GTK Widget Tree + VTE | ArkTS Component Tree + Text 组件 |
| 分屏模型 | terminatorlib/paned.py Pane 树 | PaneNode 递归结构 + Column/Row 嵌套 |
| 终端协议 | VTE widget + PTY | N-API 通道 + 自研轻量 VT 解析器 |
| 标签页 | notebook.py | 顶部 Chip Row + Scroll |
| 后端 shell | childprocessmanager + 真实 PTY | startNativeChildProcess + socketpair |
非源码级移植,但在用户视角的"功能性等价"上已经覆盖了 80%。
二、整体架构:四层 + 双向通道
整个应用的运行时结构:

四层职责清晰:
- ArkTS 只管 UI 和协议解析,不碰文件描述符的跨进程所有权
- N-API 桥 只负责创建通道并把 FD 暴露给 ArkTS,不管数据
- child process 只负责接 FD + execv,不管 UI
- ArkTS ↔ N-API ↔ 子进程 三者通过一对 socket FD 完成所有字节流传输
这也是鸿蒙 PC 推荐的"原生子进程 + IPC"的标准模式。
三、第一个大坑:PTY 不向普通应用开放
按 Linux 习惯,第一反应是:
int masterFd = posix_openpt(O_RDWR | O_NOCTTY);
grantpt(masterFd); unlockpt(masterFd);
代码三行,鸿蒙 PC 真机上直接翻车:
$ hdc shell "ls -l /dev/ptmx"
ls: /dev/ptmx: Permission denied # 连调试 shell 域都无权访问
根因:鸿蒙 PC 把 /dev/ptmx 视为内核敏感资源,普通 HAP 应用(即使声明 INTERNET)的应用域 SELinux 类型是 debug_hap:s0,根本没有 ACL 授权。
我尝试了几条绕墙方案,全部被平台拒绝:
| 尝试 | 结果 |
|---|---|
ohos.permission.kernel.ALLOW_WRITABLE_CODE_MEMORY | 安装失败 code:9568289(debug 签名 APL=normal,无权授 system 级权限) |
ohos.permission.ACCESS_PTY(如果有) | 该权限不存在 |
hdc shell setprop persist.sys.pty_permitted 1 | 设备不响应,无效 |
结论:这条路在普通 HAP 上下文化不通死路。
好在 MTPuTTY 这个项目(同一个适配工作区)在 HUAWEI MateBook Pro、HarmonyOS 7.0.0 上做过完整真机验证,确认了替代方案:
socketpair(AF_UNIX, SOCK_STREAM, 0, fds) — 双向字节流、零权限、零额外文件、无需任何 SELinux 放行。
int fds[2] = { -1, -1 };
if (socketpair(AF_UNIX, SOCK_STREAM, 0, fds) != 0) {
napi_throw_error(env, nullptr, strerror(errno));
return nullptr;
}
// fds[0] = parent (ArkTS 端),fds[1] = child (子进程 stdin/stdout/stderr)
代价是本地 shell 进入非交互模式:
- 没有提示符(应用层在每条命令前自己补
$) - 没有 echo(应用层在发送命令时本地补一行
$ cmd) - vim / top / htop 这类全屏交互程序不可用(因为没有 TTY,没有 TIOCSCTTY)
但对于**「发命令 → 看输出」**这个 80% 的实际场景,完全够用。
child 子进程:接 FD + execv
extern "C" int Main(int argc, char **argv) {
// argv 解码 hex → 还原原始参数数组(ArkTS 端用 \u0000 分隔转 hex)
const char *term_fd = ...; // 来自 env 的 TERMINAL_FD
int fd = atoi(term_fd);
setsid();
dup2(fd, 0); dup2(fd, 1); dup2(fd, 2);
close(fd);
char *sh_argv[] = { (char*)"/system/bin/sh", nullptr };
execv("/system/bin/sh", sh_argv);
_exit(127);
}
注意:不能调用 ioctl(fd, TIOCSCTTY),因为 socket 不是终端设备,ioctl 会立刻 ENOTTY 让整个进程崩溃。
四、ArkTS 侧:会话、Pane、VT 解析

4.1 Session 模型
每个叶子 Pane 一个 TerminalSession:
class TerminalSession {
parentFd: number = -1;
childFd: number = -1;
pid: number = 0;
state: 'starting' | 'ready' | 'dead' = 'starting';
lines: TermLine[] = []; // 终端内容模型
// ...
async start() {
const ch = terminatorTerminal.createChannel();
this.parentFd = ch.parentFd;
this.childFd = ch.childFd;
this.pid = await childProcessManager.startNativeChildProcess(
'libterminator_child.so:Main',
{ entryParams: encodeArguments(['/system/bin/sh']), fds: { terminal: this.childFd } },
{ isolationMode: false }
);
fileIo.close(this.childFd); // FD 所有权移交子进程
this.readLoop();
}
private async readLoop() {
const buf = new ArrayBuffer(4096);
while (this.reading) {
const n = await fileIo.read(this.parentFd, buf);
if (n <= 0) break;
this.feed(decoder.decodeToString(new Uint8Array(buf, 0, n)));
}
}
}
这里有个非常容易被忽略的细节:childFd 在 startNativeChildProcess 之后所有权移交给子进程,ArkTS 端必须 fileIo.close(this.childFd),否则两边都不释放,FD 泄漏。Linux 老手对这点敏感,但鸿蒙上因为文档分散,第一次写很容易踩。
4.2 Pane 树
完全对齐上游 terminatorlib/paned.py:
interface PaneNode {
id: string;
kind: 'term' | 'split';
dir?: 'h' | 'v'; // h = stacked, v = side by side
ratio?: number;
children?: PaneNode[];
}
递归分割:
function splitNode(root, targetId, dir, newId) {
if (root.kind === 'term' && root.id === targetId) {
return { id: 's_' + targetId + '_' + dir, kind: 'split', dir, ratio: 0.5,
children: [root, { id: newId, kind: 'term' }] };
}
if (root.kind === 'split' && root.children) {
return { ...root, children: root.children.map(c => splitNode(c, targetId, dir, newId)) };
}
return root;
}
关闭时若子节点全空则塌缩到上一个非空兄弟(上游行为一致):
function closeNode(root, targetId) {
if (root.kind === 'term') return root.id === targetId ? null : root;
const kept = (root.children ?? []).map(c => closeNode(c, targetId)).filter(x => x !== null);
if (kept.length === 0) return null;
if (kept.length === 1) return kept[0];
return { ...root, children: kept };
}
UI 渲染就是 ArkTS 的递归组件:
if (node.kind === 'term') TerminalPane(...)
else if (node.dir === 'h') Column { Child(0, weight); Handle; Child(1, weight) }
else Row { Child(0, weight); Handle; Child(1, weight) }
Handle 用 PanGesture + onActionUpdate(e.offsetY/X) 做实时比例反馈——拖动分割条改比例,参照上游 paned.py 的拖拽逻辑。
4.3 自研 VT 解析器
鸿蒙没有 VTE 组件可用,但 shell 输出绝大多数只是 ANSI SGR(颜色/粗体),所以解析器可以做到 80 行:
function parseAnsi(chunk, st) {
let plain = ''; const out = []; let i = 0;
while (i < chunk.length) {
if (chunk[i] === '\x1b' && chunk[i+1] === '[') {
// 收 'm' 结束的位置
const j = chunk.indexOf('m', i + 2);
if (j >= 0) {
if (plain) { out.push({ t: plain, c: st.fg, b: st.bold }); plain = ''; }
applySgr(chunk.substring(i + 2, j), st);
i = j + 1; continue;
}
}
if (chunk[i] === '\x1b' && chunk[i+1] === ']') {
// OSC: ESC ] 0 ; title \007
const j = chunk.indexOf('\x07', i + 2);
if (j >= 0) { st.title = chunk.substring(i + 4, j); i = j + 1; continue; }
}
plain += chunk[i]; i++;
}
if (plain) out.push({ t: plain, c: st.fg, b: st.bold });
return out;
}
颜色用 Tango 16 色调色板(直接从上游 terminatorlib/config.py 的 palette 默认值抄过来),渲染用 Span 多色同段:
Text() {
ForEach(line.segs, (seg, i) => {
Span(seg.t)
.fontColor(seg.c || termFg)
.fontWeight(seg.b ? FontWeight.Bold : FontWeight.Normal)
})
}
.fontFamily('monospace').fontSize(fontSize).lineHeight(fontSize + lineExtra)
4.4 标题栏三色语义(对齐上游)
上游标题栏的核心信息密度:
- 标题 + PID
- 尺寸
WxH(GtkBox 的 actual_size) - 三色背景:焦点(红 #c80003 发送)、非焦点空闲(灰 #c0bebf)、非焦点接收广播(蓝 #0076c9)
我严格用上游的色值,并加了一行 pid 显示:
private titleBg() {
if (this.active) return '#c80003'; // upstream title_transmit_bg_color
return this.broadcast === 'off' ? '#c0bebf' : '#0076c9';
}
private titleFg() {
if (this.active) return '#ffffff';
return this.broadcast === 'off' ? '#1a1a1a' : '#ffffff';
}
cols/rows 通过 onAreaChange 测量终端容器尺寸 + fontSize * 0.6(等宽字符的字符宽度估算)实时算出来,标题栏右侧 208x45 就来自这里。
五、真机验收
设备:HUAWEI MateBook Pro,HarmonyOS 7.0.0(基于 OpenHarmony 5.x 内核)。
5.1 基础链路
$ echo hello
hello
$ id
uid=20020038(pig) gid=20020038(pig_a20020038) groups=20020038(pig_a20020038),1097(netys_socket),3099(shader_cache) context=u::r:debug_hap:s0
$ pwd
/
$ uname -a
HarmonyOS localhost HongMeng Kernel 1.13.0 #1 SMP Sat Aug 15 03:57:31 UTC 2026 aarch64 Toybox
注意 id 的输出里能看到 uid=20020038(pig) 这种应用沙箱专属 UID,以及 context=u::r:debug_hap:s0 这种 SELinux 类型——证明 shell 确实是应用域内拉起的,没突破沙箱。


5.2 ANSI 颜色(VT 解析器)
$ printf '\033[1;31mBOLD RED\033[0m \033[32mGREEN\033[0m ...'
BOLD RED GREEN YELLOW BLUE MAGENTA CYAN
$ i=0; while [ $i -lt 8 ]; do printf '\033[4%dm %2d \033[0m' $i $i; i=$((i+1)); done; echo
[0 1 2 3 4 5 6 7 ]
$ i=0; while [ $i -lt 8 ]; do printf '\033[10%dm %2d \033[0m' $i $i; i=$((i+1)); done; echo
[8 9 10 11 12 13 14 15]
16 色 Tango 调色板完整渲染——和上游 Terminator 在 Linux 终端上的默认配色完全一致。

5.3 长输出 + 长行换行
$ seq 1 200
1
2
...
200
$ printf 'X%.0s' $(seq 1 400); echo
XXXXXXXXX...(400 个 X)
List 组件渲染 200 行单字符无压力,400 字符超长行靠 Text 自动换行完成,没有因为宽度估算而错位。

5.4 多标签 + 分割 + 广播
- 顶部
+新建 3 个标签:终端 1 / 终端 2 / 终端 3 - 标签栏下方切换激活标签
- 在任一面板右键 → 水平分割(Ctrl+Shift+O) / 垂直分割(Ctrl+Shift+E)
- 拖拽分割条可调整子面板比例
- 底栏点击「广播:关闭」可循环切换「关闭 / 本标签页 / 全部」
- 切到「本标签页」后,多面板输入
echo broadcast-ok,所有面板同步执行


5.5 OSC 标题
$ printf '\033]0;MY-TITLE-TEST\007'; echo ok
ok
终端面板标题栏文字变成 MY-TITLE-TEST。

5.6 已知限制(如实记录)
| 限制 | 原因 |
|---|---|
| 无 shell 提示符 | socketpair ≠ TTY,应用层本地补 $ |
| 无命令自动回显 | 同上,应用层在 submit 时本地补一行 $ cmd |
vim / top / htop 不可用 | 需要真实 TTY 全屏控制 |
clear 命令无效 | 无 tput,用右键菜单的「清屏」 |
非交互 python3 / node -i 无 REPL | 非交互 shell |
这些都是应用层无法绕过的平台边界,不是 bug。后续如果鸿蒙开放 PTY,可以直接换回 posix_openpt,UI 层一行不用改。
六、上游图标 / 主题:直接复用,不重画
很多人忽略这点:开源软件的品牌一致性很重要。Terminator 上游自带官方图标 data/icons/hicolor/scalable/apps/terminator.svg——一台显示器加 4 块红色分屏加 >_ 命令符号。
我做了几件事:

- PNG 版本不能用:仓库里
256x256/apps/terminator.png是旧版图形(多层叠显示器 + 大红块),跟现代 SVG 不一致 - 必须用 SVG:通过 Chrome headless 渲染为 1024×1024 透明背景位图,再按鸿蒙规范的 70% 安全区居中缩放
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new \
--default-background-color=00000000 \
--window-size=1024,1024 \
--screenshot=term_icon.png icon.html
生成的 app_icon.png(1024×1024)和 startIcon.png(512×512)放进 entry/src/main/resources/base/media/ 即可。

主题/配色也一样——terminatorlib/config.py 的 DEFAULTS、标题栏三色、Tango 调色板、字号默认 Mono 10 全部抄过来。「看起来像上游」比「自己设计配色」重要得多。
七、踩过的几个 ArkTS / 构建坑
7.1 tabIndex 命名冲突
ArkTS 的 CustomComponent 基类有一个 tabIndex: (index: number) => CommonAttribute 属性(在 a11y 上下文里)。如果子类自己定义 private tabIndex() 会被认为是 override,编译报:
Property 'tabIndex' in type 'Index' is not assignable to the same property in base type 'CustomComponent'.
解法:改名。我改成 curTabIdx()。
7.2 pasteboard.getData() 的权限
应用层读剪贴板需要 ohos.permission.READ_PASTEBOARD,属于 system_basic APL,普通 debug 签名应用根本拿不到,强行申请会装不上。
解法:去掉"主动读取剪贴板"功能,输入行长按即可触发系统粘贴(这是平台自带能力,不需要权限)。
7.3 HAP 自动签名
File → Project Structure → Signing Configs → 勾选自动签名 这一步不少人会漏,漏了之后产物是 entry-default-unsigned.hap,真机安装报 9568320:
$ hdc install entry-default-unsigned.hap
[Error] msg:no signature file
务必确认 build-profile.json5 里 products[].signingConfig 引用了一个真实的 signingConfigs[].name。
7.4 ohpm install 不可省
hvigorw assembleHap 前必须先 ohpm install:
cd ohos_Terminator/ohos_hap
export NODE_HOME=/Applications/DevEco-Studio.app/Contents/tools/node
export PATH=$NODE_HOME/bin:$PATH
ohpm install # ← 否则 hvigorw 报大量 ArkTS:10905204,找不到 @kit.* 模块
hvigorw assembleHap --mode module -p product=default -p buildMode=debug
八、性能与稳定性观察
跑了几个小时的压力测试,结论:
| 项 | 表现 |
|---|---|
| 200 行输出 | 滚动流畅,CPU < 5% |
| 400 字符长行 | 正确换行,无 OOM |
| 多标签切换(5 个) | 无状态丢失,每个会话独立 PID |
| 分割拖拽比例 | 跟随手指,1px 精度反馈 |
| Ctrl+C 中断 | 会话不结束,标题栏 PID 保持 |
| 应用热退出 → 重新打开 | 子进程被 process.kill(15) 清理,无僵尸进程 |
九、还能往哪走
短期(自己用):
- ✅ 完成:分屏 / 多标签 / 广播 / 标题栏三色 / OSC 标题 / ANSI 颜色 / 长行换行 / 长输出 / Ctrl+C 中断
- ⏳ 下一步:剪贴板(需要平台权限进展)/ 选中复制(已支持 ListItem.copyOption)/ 字体大小热调节(已支持)
长期(社区):
- ⏳ 等平台放开 PTY:一旦能用
posix_openpt,无需改 UI 就能获得完整 TTY 体验(vim / top 复活) - ⏳ Terminator 插件体系:上游的
custom_commands/layout插件是 Python 写的,鸿蒙上需要换成 ArkTS 插件框架 - ⏳ 远程会话:MTPuTTY 已经验证 SSH 客户端的 socketpair + 原生子进程模式,可以无缝集成进 Terminator 做远程 pane
十、给后来者的话
如果你也在适配鸿蒙 PC 上的"终端类 / 命令行类"应用,几条经验:
- PTY 别抱希望,socketpair 是当前唯一可用的方案,接受"非交互模式"的代价
- childProcessManager.startNativeChildProcess + fds 参数 是启动 shell 子进程的正确姿势,别尝试
child_process.spawn(那是 Node 生态) - FD 所有权:传过去就要在 ArkTS 端 close,否则泄漏
- TIOCSCTTY 坚决不能要,socketpair 不是 TTY,调用就 ENOTTY
- 图标用上游 SVG,自己画是吃力不讨好
- 配色抄上游 DEFAULTS,开源软件的用户期待的是"看起来像原来那款"
- 真机验证比什么都重要,模拟器跑通 ≠ 真机跑通(权限、SELinux、PTY、字体都不同)
如果觉得有用,欢迎在鸿蒙开发者社区转发、提 issue、做 PR。也欢迎贡献上游 logo / 配色资源到 oh_modules 下做模块化。
更多推荐




所有评论(0)