rwecho
V2EX  ›  华为

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

  •  
  •   rwecho · 7h 14m ago · 1779 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
    
    28 replies    2026-06-11 15:52:35 +08:00
    Cooky
        1
    Cooky  
       7h 13m ago
    锟斤拷烫烫烫
    Cooky
        2
    Cooky  
       7h 12m ago
    刷新一下又不乱码了?
    rwecho
        3
    rwecho  
    OP
       7h 10m ago
    @Cooky #2 我修改了。第一次没有复制好
    rwecho
        4
    rwecho  
    OP
       7h 9m ago
    @Cooky #1 从 vscode 复制的
    Vegetable
        5
    Vegetable  
       7h 6m ago   ❤️ 1
    这和 screen 命令解决的问题是不是同一个
    nevin47
        6
    nevin47  
       7h 3m ago
    挺有意思的项目,不过我也想问这个和 screen 的差异化在哪儿
    bitkuang
        7
    bitkuang  
       6h 59m ago via Android
    @Vegetable
    @nevin47 给点面子
    rwecho
        8
    rwecho  
    OP
       6h 54m ago
    @bitkuang
    @Vegetable
    @nevin47

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

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


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

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


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

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

    或者还是 SignalR
    rwecho
        24
    rwecho  
    OP
       2h 16m ago
    @FreeLester 这里大量用了 Claude Code + glm5.1
    PaulSamuelson
        25
    PaulSamuelson  
       1h 28m ago
    IOS 版本要求有点高,能降低点不?
    Paradoxos
        27
    Paradoxos  
       1h 21m ago
    重新发明了 screen/tmux
    kierankihn
        28
    kierankihn  
       1h 16m ago via Android
    为啥不用 mosh ?
    About   ·   Help   ·   Advertise   ·   Blog   ·   API   ·   FAQ   ·   Solana   ·   5310 Online   Highest 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 79ms · UTC 09:09 · PVG 17:09 · LAX 02:09 · JFK 05:09
    ♥ Do have faith in what you're doing.