把 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 for HarmonyOS PC 真机运行效果——多标签 + 分屏终端 + 广播输入

一、路线

上游的依赖链看一眼就知道没戏:

terminator → pygtk3 → pygobject → libgtk-3 → libcairo → pango → ...

每一个 so 在鸿蒙 PC 上都缺,临时编译整个 GTK 工具链成本极高,体积动辄几十 MB,且 GTK 本身也不是鸿蒙 UI 体系,移植过来也不是原生体验。

更关键的:Terminator 的核心价值是交互模型,不是渲染本身。Panes 的递归分割、广播输入、标题栏三色语义、缩放聚焦——这些交互可以完全用鸿蒙原生 UI 组件重新表达。

所以选定了路线:

上游实现鸿蒙 PC 重实现
UI 渲染GTK Widget Tree + VTEArkTS Component Tree + Text 组件
分屏模型terminatorlib/paned.py Pane 树PaneNode 递归结构 + Column/Row 嵌套
终端协议VTE widget + PTYN-API 通道 + 自研轻量 VT 解析器
标签页notebook.py顶部 Chip Row + Scroll
后端 shellchildprocessmanager + 真实 PTYstartNativeChildProcess + 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)));
    }
  }
}

这里有个非常容易被忽略的细节childFdstartNativeChildProcess 之后所有权移交给子进程,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) }

HandlePanGesture + 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.pypalette 默认值抄过来),渲染用 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 确实是应用域内拉起的,没突破沙箱。

真机验证:基础命令 + 红色标题栏 + pid 实时显示(终端 1/2/3 三标签)

真机验证:echo hello / id / pwd / uname -a 全链路输出 + id -Z 显示 SELinux 上下文

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 终端上的默认配色完全一致。

真机验证:Tango 16 色调色板完整渲染(0–7 正常色 + 8–15 加亮色)

5.3 长输出 + 长行换行

$ seq 1 200
1
2
...
200
$ printf 'X%.0s' $(seq 1 400); echo
XXXXXXXXX...(400 个 X)

List 组件渲染 200 行单字符无压力,400 字符超长行靠 Text 自动换行完成,没有因为宽度估算而错位。

真机验证:seq 1 200 输出尾部(第 158–200 行),自动滚动到底,标题栏 pid 61998

5.4 多标签 + 分割 + 广播

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

真机验证:右键上下文菜单(新建标签页 / 水平分割 / 垂直分割 / 缩放 / 复制 / 中断 / 清屏 / 关闭),快捷键对齐上游 Ctrl+Shift+O/E/X/C/G/W

真机验证:echo "pane-$" 会话隔离——上下两个分割面板显示不同的 shell PID(26533 与 26559)

5.5 OSC 标题

$ printf '\033]0;MY-TITLE-TEST\007'; echo ok
ok

终端面板标题栏文字变成 MY-TITLE-TEST

真机验证:上层面板标题栏显示 MY-TITLE-TEST(OSC 标题生效),下层面板验证 sleep 3 延时异步输出 done-after-3s

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 块红色分屏加 >_ 命令符号。

我做了几件事:

上游图标的两代图形:左为仓库内 256x256 PNG(旧版三层叠窗设计),右为 scalable SVG(当前采用的分屏显示器设计)——两者并不一致,必须以 SVG 为准

  1. PNG 版本不能用:仓库里 256x256/apps/terminator.png 是旧版图形(多层叠显示器 + 大红块),跟现代 SVG 不一致
  2. 必须用 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/ 即可。

真机运行总览:三标签布局 + 红色焦点标题栏(sh · pid 8348 · 220x53 行列实时计算)

主题/配色也一样——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.json5products[].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 上的"终端类 / 命令行类"应用,几条经验:

  1. PTY 别抱希望,socketpair 是当前唯一可用的方案,接受"非交互模式"的代价
  2. childProcessManager.startNativeChildProcess + fds 参数 是启动 shell 子进程的正确姿势,别尝试 child_process.spawn(那是 Node 生态)
  3. FD 所有权:传过去就要在 ArkTS 端 close,否则泄漏
  4. TIOCSCTTY 坚决不能要,socketpair 不是 TTY,调用就 ENOTTY
  5. 图标用上游 SVG,自己画是吃力不讨好
  6. 配色抄上游 DEFAULTS,开源软件的用户期待的是"看起来像原来那款"
  7. 真机验证比什么都重要,模拟器跑通 ≠ 真机跑通(权限、SELinux、PTY、字体都不同)

如果觉得有用,欢迎在鸿蒙开发者社区转发、提 issue、做 PR。也欢迎贡献上游 logo / 配色资源到 oh_modules 下做模块化。

Logo

赋能鸿蒙PC开发者,共建全场景原生生态,共享一次开发多端部署创新价值。

更多推荐