rwecho
V2EX  ›  华为

我一个人, 53 天, 425 次提交,把终端装进了鸿蒙手机

  •  
  •   rwecho · 1 day ago · 2382 views

    那天晚上 11 点,我在火车上 SSH 到服务器查日志。手机浏览器切了个微信回来,tab 被 kill 了,session 断了,查了一半的日志全没了。

    我翻了翻手机上所有终端 app——Termius 、Blink Shell 、ServerCat——它们都有同一个问题:你不能真的"保持连接"。系统杀后台、网络切换、锁屏省电,随便哪个都能把你的 SSH 掐断。

    那能不能反过来?让 shell 在远程服务器上一直跑,手机只是个"显示器"——断了就断了,重连回来输出还在。

    这就是 Corterm (云枢终端)的出发点:session 不是连接,是状态。

    先把架子搭起来

    思路很直接:

    1. Worker — 装在远程机器上的轻量 agent ,管 PTY 生命周期。你断了连,shell 照跑。
    2. Gateway — 中间层,管认证、路由、session 协调。Worker 和 Client 之间不直接通信。
    3. Client — 纯渲染层。断了重连时,Gateway 把 Worker 上的 scrollback buffer 吐给你,无缝衔接。
    Client (Browser/iOS/Android/HarmonyOS)
             ↕  SignalR
         Gateway (.NET 10)
             ↕  SignalR
         Worker (.NET 10 + PTY)
    

    Gateway 和 Worker 用 .NET 10 + SignalR ,Client 端浏览器用 React + xterm.js ,iOS/Android 用 MAUI 。浏览器、手机 App 都跑通了,接下来是鸿蒙。

    手搓 SignalR:1091 行 ArkTS 的协议实现

    鸿蒙端的第一道坎:SignalR 。

    Corterm 的 Gateway 是 .NET 写的,实时通信用的 SignalR 。iOS/Android 那边有官方 SDK ,浏览器更不用说。但鸿蒙……我翻了半天文档,没有。连第三方实现都没有。

    两条路:要么在 Gateway 加一层 WebSocket 中间层,要么直接在 ArkTS 里实现 SignalR 协议。前者意味着改服务端,所有客户端都得测。后者意味着我要在一个 TypeScript 的严格子集里,手写一个协议栈。

    我选了后者。

    Negotiate 握手

    SignalR 连接的第一步不是 WebSocket ,而是一个 HTTP POST negotiate 请求。服务端返回一个 connectionToken,后续 WebSocket 连接必须带上这个 token 。

    // HttpConnection.ets
    private async negotiate(accessToken: string): Promise<string> {
      const negotiateUrl = `${this.url}/negotiate?negotiateVersion=1`;
      const httpClient = http.createHttp();
      const headers: Record<string, string> = {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
      };
      if (accessToken.length > 0) {
        headers['Authorization'] = `Bearer ${accessToken}`;
      }
    
      const response = await httpClient.request(negotiateUrl, {
        method: http.RequestMethod.POST,
        header: headers,
        connectTimeout: 15000,
        readTimeout: 15000,
      });
    
      const body = response.result as string;
      const negotiateResponse = JSON.parse(body) as NegotiateResponse;
      this.connectionId = negotiateResponse.connectionId ?? '';
      return negotiateResponse.connectionToken ?? '';
    }
    

    鸿蒙的网络 API 是 @kit.NetworkKit 里的 http.createHttp()webSocket.createWebSocket(),用法跟 Node.js 的差不多,但所有东西都得显式类型声明。

    WebSocket 连接

    拿到 token 后,拼 URL ,建 WebSocket:

    // HttpConnection.ets
    private async connectWebSocket(accessToken: string): Promise<void> {
      const wsUrl = this.url
        .replace('https://', 'wss://')
        .replace('http://', 'ws://');
    
      let fullUrl = wsUrl;
      const params: string[] = [];
      if (this.connectionToken.length > 0) {
        params.push(`id=${encodeURIComponent(this.connectionToken)}`);
      }
      if (accessToken.length > 0) {
        params.push(`access_token=${encodeURIComponent(accessToken)}`);
      }
      if (params.length > 0) {
        fullUrl += '?' + params.join('&');
      }
    
      this.ws = webSocket.createWebSocket();
      const ws = this.ws;
    
      const openPromise = new Promise<void>((resolve, reject) => {
        ws.on('open', () => resolve());
        ws.on('error', (err: Error) => {
          if (!this.stopRequested) reject(new Error(`WebSocket error: ${err.message}`));
        });
      });
    
      ws.on('message', (_err: Error, data: string | ArrayBuffer) => {
        let text: string;
        if (typeof data === 'string') {
          text = data;
        } else {
          text = buffer.from(data).toString('utf-8');
        }
        if (this.onreceive !== null) {
          this.onreceive(text);
        }
      });
    
      await ws.connect(fullUrl, { header: connectHeaders });
      await openPromise;
    }
    

    Hub 协议层

    SignalR 不是裸 WebSocket 。它有自己的消息格式——我打开 C# 源码看了下,其实就 5 种消息类型:

    • Type 1 — InvocationMessage (双向 RPC 调用)
    • Type 2 — StreamItemMessage (流式结果)
    • Type 3 — CompletionMessage ( RPC 响应)
    • Type 6 — Ping (心跳)
    • Type 7 — Close (关闭)

    消息之间用 0x1E( ASCII record separator )分隔。processIncomingData 是整个消息分发管道的入口:

    // HubConnection.ets
    private processIncomingData(data: string): void {
      // 第一条消息是 handshake response
      if (this.handshakePromise !== null) {
        this.protocol.decodeHandshakeResponse(data);
        const promise = this.handshakePromise;
        this.handshakePromise = null;
        promise.resolve();
        return;
      }
    
      // 常规消息
      const messages = this.protocol.decodeMessages(data, this.logger);
      for (const message of messages) {
        this.dispatchMessage(message);
      }
    }
    
    private dispatchMessage(message: HubMessageBase): void {
      this.resetServerTimeout();
    
      switch (message.type) {
        case 1: { // Invocation
          const invocation = message as InvocationMessage;
          this.invokeHandler(invocation.target, invocation.arguments);
          break;
        }
        case 2: { // StreamItem
          const pending = this.streamManager.getInvocation(streamItem.invocationId);
          if (pending !== undefined) pending.resolve(streamItem.item);
          break;
        }
        case 3: { // Completion
          const pending = this.streamManager.removeInvocation(completion.invocationId);
          if (pending !== undefined) {
            if (completion.error.length > 0) pending.reject(new Error(completion.error));
            else pending.resolve(completion.result);
          }
          break;
        }
        case 6: break; // Ping
        case 7: this.handleCloseMessage(close); break;
      }
    }
    

    Keepalive 和重连

    心跳每 15 秒发一次 Ping ,服务端 30 秒没消息就判定超时:

    private resetKeepAlive(): void {
      this.pingTimer = setInterval(() => {
        const ping = new PingMessage();
        const encoded = this.protocol.encodeMessage(ping);
        this.httpConnection.send(encoded);
      }, this.keepAliveIntervalInMilliseconds) as number;  // 15000ms
    }
    
    private resetServerTimeout(): void {
      clearTimeout(this.serverTimeoutTimer);
      this.serverTimeoutTimer = setTimeout(() => {
        this.httpConnection.stop(new Error('Server timeout'));
      }, this.serverTimeoutInMilliseconds) as number;  // 30000ms
    }
    

    重连策略是 SignalR 的经典配置 [0, 2000, 5000, 10000, 30000]——先立即重试,然后 2 秒、5 秒、10 秒、30 秒。但官方 SDK 试完这 5 次就放弃了。我的实现改成了循环重试,延迟数组里的最后一个值( 30 秒)会一直用下去,最多 15 次之后才真正断开:

    private scheduleReconnect(): void {
      if (this.stopRequested) return;
      const delayIndex = Math.min(this.reconnectAttempt, this.reconnectDelays.length - 1);
      const delay = this.reconnectDelays[delayIndex];
      this.reconnectTimer = setTimeout(() => this.attemptReconnect(), delay) as number;
    }
    

    ArkTS 的那些坑

    写 SignalR 客户端最痛的不是协议本身,而是 ArkTS 的限制。它是 TypeScript 的严格子集:

    • 不能用 as const — 只能用 class X { static readonly A = '...' }
    • 不能写无类型对象字面量{ key: value } 直接报错,必须声明类型
    • 不能用解构赋值const [k, v] of Object.entries(obj) 编译不过
    • throw 只能抛 Error — catch 到的任意值不能直接 throw

    每一条都是我在编译报错后才学到的。

    在 ArkWeb 里跑 xterm.js

    终端渲染的答案很明确:xterm.js 。问题是它跑在浏览器里,而我要在 HarmonyOS 的原生 app 里用它。

    HarmonyOS 提供了 ArkWeb ( WebView 组件),有 WebMessagePort 做双向通信。我先试了 javaScriptProxy,崩溃不断,换成 WebMessagePort 才稳定下来。

    核心逻辑:创建一对 MessagePort ,Port 0 发给 HTML 端,Port 1 留在 native 端监听:

    // XtermWebview.ets
    private initMessagePort() {
      this.msgPorts = this.webviewController.createWebMessagePorts();
      // Port 1 留在 native 端
      this.msgPorts[1].onMessageEvent((result: webview.WebMessage) => {
        const msg = JSON.parse(result as string) as Record<string, Object>;
        const type = msg['type'] as string;
        if (type === 'input') {
          this.onInput(msg['data'] as string);
        } else if (type === 'resize') {
          const cols = msg['cols'] as number;
          const rows = msg['rows'] as number;
          this.onResize(cols, rows);
        }
      });
      // Port 0 发给 HTML 端
      this.webviewController.postMessage('__init_port__', [this.msgPorts[0]], '*');
    }
    

    输出方向反过来:native 拿到 Worker 的输出,base64 编码后调 runJavaScript 写入 xterm:

    writeOutput(base64Payload: string) {
      const escaped = base64Payload.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
      this.webviewController.runJavaScript(`writeBase64Output("${escaped}")`);
    }
    

    为什么用 base64 ?因为终端输出包含二进制数据( ANSI 转义序列、控制字符),直接当 JSON 字符串传会炸。

    整个终端页面的生命周期是一个 9 状态的状态机:Disconnected → Connecting → Replaying → Live → Reconnecting → ...。重连时 Gateway 先 replay scrollback buffer ,然后切到 Live 模式,用户感觉不到断过。

    手机上怎么按 Ctrl+C

    终端有了,但我怎么在手机上发 SIGINT ?

    没有键盘的设备用终端,这是所有移动端终端 app 的噩梦。

    我的解法是 VirtualKeyBar——一个水平可滚动的虚拟按键条。关键是 Sticky Modifier:Ctrl 和 Alt 是 latch 按键,按一下变亮(激活),再按下一个字符键时才发送组合键。

    // VirtualKeyBar.ets — LatchButton 组件
    @Component
    struct LatchButton {
      label: string = ''
      @Prop latched: boolean = false
      onToggle: () => void = () => {}
    
      build() {
        Button(this.label)
          .backgroundColor(this.latched ?
            $r('app.color.terminal_secondary_container') :
            $r('app.color.terminal_surface_container_high'))
          .onClick(() => {
            clickHaptic();
            this.onToggle();
          })
      }
    }
    

    Ctrl + 字母的映射藏在 handleVirtualKey 里:

    // TerminalPage.ets
    private handleVirtualKey(key: string) {
      if (key.startsWith('Ctrl+')) {
        const label = key.substring(5);
        // a-z → 0x01-0x1A
        const ch = label.toLowerCase().charCodeAt(0);
        if (ch >= 97 && ch <= 122) {
          this.sendInput(String.fromCharCode(ch - 96));
        }
      }
      // Escape sequences
      const inputMap: Record<string, string> = {
        'ArrowUp': '\x1b[A',
        'ArrowDown': '\x1b[B',
        'ArrowRight': '\x1b[C',
        'ArrowLeft': '\x1b[D',
      };
    }
    

    'c'.charCodeAt(0) 是 99 ( 0x63 ),减 96 得 3 ,String.fromCharCode(3) 就是 \x03——SIGINT 。一行数学运算解决了所有 Ctrl+字母的映射。

    CI/CD 十五连跪

    6 月 8 号,我开始写 harmony-release.yml

    然后接下来的 3 天里,我推了这个文件 15 次。

    Pipeline 长这样:

    Tag push (harmony-v*) → 版本提取 → 签名准备 → hvigor 构建
    → AGConnect 认证 → OBS 上传 → 编译轮询 → 提审
    

    踩坑中最惨的几个:

    AGConnect API 文档是解谜游戏。 /upload-url/for-obs 端点文档只写了入参,没告诉你返回的 header 要原样传给 OBS 的 PUT 请求。我是抓包才搞明白的。

    编译状态要轮询。 华为的服务端编译一个 .app 文件要 60 秒以上,API 没有回调,只能 30 秒一次轮询,最多 20 次:

    - name: Query compile status
      run: |
        for i in $(seq 1 20); do
          SUCCESS_STATUS=$(curl -s ... | jq -r '.pkgStateList[0].successStatus')
          if [ "$SUCCESS_STATUS" = "0" ]; then
            echo "Compile successful"
            exit 0
          fi
          sleep 30
        done
    

    自托管 runner 的脏文件。 有一次构建失败,查了半天发现是 /tmp 下残留了上次的 .app 文件,签名步骤拿错了文件。于是加了一行 rm -rf 在 pipeline 开头。

    每次看到 GitHub Actions 红叉,我都觉得自己在跟华为的文档玩解谜游戏。

    53 天的数字

    425 次提交。53 天。1 个人。5 个平台。

    其中鸿蒙端:

    • 8645 行 ArkTS
    • 1091 行 手写 SignalR 客户端
    • 9 个 HAR 模块( 1 entry + 5 feature + 3 common )
    • 5 月 8 日 第一个鸿蒙 commit → 6 月 11 日 上架华为应用市场

    接下来要做的:文件传输、端口转发、多 tab 、命令片段。

    如果你也觉得手机上应该有个不中断的终端,来看看:github.com/monster-echo/CortexTerminal2

    Docker 一键体验:

    docker run -d -p 5000:5000 ghcr.io/monster-echo/cortex-terminal:latest
    
    37 replies    2026-06-12 14:28:16 +08:00
    Cooky
        1
    Cooky  
       1 day ago
    锟斤拷烫烫烫
    Cooky
        2
    Cooky  
       1 day ago
    刷新一下又不乱码了?
    rwecho
        3
    rwecho  
    OP
       1 day ago
    @Cooky #2 我修改了。第一次没有复制好
    rwecho
        4
    rwecho  
    OP
       1 day ago
    @Cooky #1 从 vscode 复制的
    Vegetable
        5
    Vegetable  
       1 day ago   ❤️ 1
    这和 screen 命令解决的问题是不是同一个
    nevin47
        6
    nevin47  
       1 day ago
    挺有意思的项目,不过我也想问这个和 screen 的差异化在哪儿
    bitkuang
        7
    bitkuang  
       1 day ago via Android
    @Vegetable
    @nevin47 给点面子
    rwecho
        8
    rwecho  
    OP
       1 day ago
    @bitkuang
    @Vegetable
    @nevin47

    他不一样
    rwecho
        9
    rwecho  
    OP
       1 day ago
    Screenshot_2026-06-11T101642.png
    HomeZane
        10
    HomeZane  
       1 day ago
    牛呀
    676529483
        11
    676529483  
       1 day ago
    服务端开个 vnc ,手机下载个 vnc 是不是也可以?
    hxsf
        12
    hxsf  
       1 day ago
    对比 screen/tmux + ssh 的优势是?
    namejaho0
        13
    namejaho0  
       1 day ago
    鸿蒙 app 叫啥
    qq135449773
        14
    qq135449773  
       1 day ago via Android
    你是没用过 tmux 吗
    ovtfkw
        15
    ovtfkw  
       1 day ago
    您是否在找 tmux ?
    rwecho
        16
    rwecho  
    OP
       1 day ago
    @ovtfkw
    @qq135449773
    @hxsf

    还真没有用过 tmux 我去试下。
    rwecho
        17
    rwecho  
    OP
       1 day ago
    @namejaho0 云枢终端。 还在上架中。 不过已经可以测试了。 可以的话,我拉你进测试。rwecho
    ndxxx
        18
    ndxxx  
       1 day ago via Android   ❤️ 1
    现在上架鸿蒙还有补贴吗
    swananan
        19
    swananan  
       1 day ago   ❤️ 1
    tmux 不就是这样吗,我反正离不开 tmux 了
    honjow
        20
    honjow  
       1 day ago
    楼主没用过 tmux 也没用过 screen 吗
    FreeLester
        21
    FreeLester  
       1 day ago   ❤️ 1
    还手写?不是 AI 一把梭?
    cs8425
        22
    cs8425  
       1 day ago   ❤️ 1
    screen 跟 tmux 就不提了
    让我讶异的是用.net + SignalR 搞自己...
    嫌直接用 raw ws 啰唆麻烦就算了
    socket.io 这种到处都有 port 的方案也不用
    反而自己重搞一次 SignalR 真佩服这毅力
    rwecho
        23
    rwecho  
    OP
       1 day ago
    @cs8425 啊,有道理。


    但是如果用 webview + websocket ,切后台容易被系统停掉。

    所以我都是将消息处理放到了 app 的后端,这样 app 存在,websocket 就存在。


    因为我是写 C#比较熟悉,所以就直接延续了。

    当然我去看了下,可能下次会采用 socket.io 或者 ws

    或者还是 SignalR
    rwecho
        24
    rwecho  
    OP
       1 day ago
    @FreeLester 这里大量用了 Claude Code + glm5.1
    PaulSamuelson
        25
    PaulSamuelson  
       1 day ago
    IOS 版本要求有点高,能降低点不?
    Paradoxos
        27
    Paradoxos  
       1 day ago   ❤️ 1
    重新发明了 screen/tmux
    kierankihn
        28
    kierankihn  
       1 day ago via Android   ❤️ 1
    为啥不用 mosh ?
    rwecho
        29
    rwecho  
    OP
       1 day ago
    @PaulSamuelson 我用的 iPhone12 还能用,你是什么版本?
    rwecho
        30
    rwecho  
    OP
       1 day ago
    @namejaho0 英文叫做 Corterm 中文叫做 云枢终端

    现在在内测还在备案+审核。

    可以+v rwecho 我拉进内测组。
    rwecho
        31
    rwecho  
    OP
       1 day ago
    @kierankihn 我去看看能不能吸收点优秀的功能
    yuedingwangji
        32
    yuedingwangji  
       21h 10m ago
    大佬没用过 screen 或者 tmux 最差 nohup 也可以
    Smile945
        33
    Smile945  
       17h 36m ago via Android
    除了 tmux 跟 screen 外,还有个 zellij ,我用起来也很顺手
    wm5d8b
        34
    wm5d8b  
       17h 8m ago via Android
    借楼问,我觉得 tmux 挺好的,但还是得 ssh 连上去。如果是安全的内网使用,有没有通过 web 打开 tmux session 的方案?
    getadoggie
        35
    getadoggie  
       16h 43m ago via iPhone
    我用的 byobu 。感觉挺好用。知道它后端用的 tmux 。不知道 tmux 原生咋样?
    rwecho
        36
    rwecho  
    OP
       16h 5m ago
    @wm5d8b 啊哦,就是我这个程序做的事情。 现在已经上架了 appstore 国内/国际 google play

    鸿蒙正在上架中。
    PaulSamuelson
        37
    PaulSamuelson  
       10h 13m ago
    @rwecho 装不了,我说的是 IOS ,能支持 IOS 18 么?
    About   ·   Help   ·   Advertise   ·   Blog   ·   API   ·   FAQ   ·   Solana   ·   1544 Online   Highest 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 86ms · UTC 16:41 · PVG 00:41 · LAX 09:41 · JFK 12:41
    ♥ Do have faith in what you're doing.