惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

GbyAI
GbyAI
Y
Y Combinator Blog
Recent Announcements
Recent Announcements
D
Docker
Blog — PlanetScale
Blog — PlanetScale
罗磊的独立博客
美团技术团队
V
V2EX
Last Week in AI
Last Week in AI
D
DataBreaches.Net
T
The Blog of Author Tim Ferriss
宝玉的分享
宝玉的分享
Microsoft Security Blog
Microsoft Security Blog
Microsoft Azure Blog
Microsoft Azure Blog
人人都是产品经理
人人都是产品经理
M
MIT News - Artificial intelligence
P
Proofpoint News Feed
B
Blog RSS Feed
博客园_首页
B
Blog
博客园 - 叶小钗
I
InfoQ
WordPress大学
WordPress大学
L
LangChain Blog
Apple Machine Learning Research
Apple Machine Learning Research
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
A
About on SuperTechFans
The GitHub Blog
The GitHub Blog
The Register - Security
The Register - Security
MyScale Blog
MyScale Blog
云风的 BLOG
云风的 BLOG
博客园 - 司徒正美
Latest news
Latest news
W
WeLiveSecurity
T
The Exploit Database - CXSecurity.com
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
aimingoo的专栏
aimingoo的专栏
小众软件
小众软件
Cyberwarzone
Cyberwarzone
Scott Helme
Scott Helme
D
Darknet – Hacking Tools, Hacker News & Cyber Security
C
CERT Recently Published Vulnerability Notes
C
CXSECURITY Database RSS Feed - CXSecurity.com
Recent Commits to openclaw:main
Recent Commits to openclaw:main
N
News and Events Feed by Topic
S
Secure Thoughts
The Hacker News
The Hacker News
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Google DeepMind News
Google DeepMind News

华为

程序员纯血鸿蒙一个月体验 - V2EX 想买鸿蒙的那个阔折叠 pura x - V2EX 上架的过程中发现,华为是目前最友好的应用商城 - V2EX 各家编程大模型都训练到鸿蒙开发的技术了吗? - V2EX 我上架的第一款鸿蒙应用 - V2EX 鸿蒙开发工具 devecostudio64.exe 为什么一直在发谷歌广告? - V2EX [深圳] 某头部券商 鸿蒙应用开发(需兼具 iOS 开发能力, 985/211 本科/硕士) - V2EX 今年有人拿到鸿蒙激励计划的奖励么? - V2EX 华为大模型确实挺早的 做了 iOS / Android / 鸿蒙三端,说下鸿蒙端的真实开发体验 HarmonyOS app 开发交流群,激励计划互助 鸿蒙版的 APTV,我开发的 HMTV 上架啦! 鸿蒙版的屏幕套壳上架了 ,限免中 鸿蒙版的 Bark, Shark 来了 摸鱼对华为的感慨 独立开发周记 172:计划有变,鸿蒙再见 大家怎么看这几天比较火的华为“韬定律芯片”逻辑折叠技术架构 公司给我弄了一个华为开发者大会的门票,有去过的同学能给些参观的建议吗? 我觉得没人能说清华为的价值观. 华为这个“韬(τ)定律”有没有懂行的给讲讲呢。 鸿蒙车机取消了桌面 3D 车辆模型? 真新闻假新闻 [2026 年 5 月] 纯血鸿蒙发展如何了?系统流畅度及头部应用功能完成度如何?作为备机是否可以替代 IOS?去广告是否有可行方案了? [外包] Uniapp 打包上架 APP 到 IOS、鸿蒙、安卓应用市场 AI 编程实战案例:基于 Codex 开发鸿蒙 6 App 有参加鸿蒙开发者激励计划的开发者吗,来互助呀~ HarmonyOS 鸿蒙应用备案高效获取公钥和证书 MD5 指纹的保姆级教程 - V2EX 鸿蒙系车口碑最近怎么急转直下,或许是我的信息茧房了? 基于 VVEX 适配的鸿蒙端 v 站 - V2EX [咨询]想买一个二手华为 pura x - V2EX pura x max 有点心动 - V2EX 鸿蒙 app 开发这么麻烦吗? - V2EX 鸿蒙是真 TM 难用 - V2EX 鸿蒙真 TM 难用 - V2EX 近期短视频平台在宣传开源鸿蒙旧 PC 都能安装 - V2EX 华为内部通报:原终端 BG 多媒体技术部部长被批准逮捕 - V2EX 我没招了,华为鸿蒙怎么这个云存储这么难搞 - V2EX 最近鸿蒙或者微信有什么大动作吗,兼容性问题爆发。 - V2EX 鸿蒙 6.0 怎么安装 clash 软件? - V2EX 请教鸿蒙 6.0 系统怎么安装第三方的商店。 - V2EX 有没有鸿蒙手机的大哥愿意帮忙安装一个 APP - V2EX 糟了,我觉得鸿蒙真成了喜欢国产手机的父母的首选了 - V2EX 关于华为闹钟节假日错乱的事情 - V2EX 华为 Mate80 如何连接 ADB? - V2EX 这新款的荣耀 Power2,看着是不是有点眼熟 - V2EX 和 HW 合作真的好恶心啊 - V2EX HarmonyOS 鸿蒙版支持性的一个段子,哈哈哈哈 - V2EX 有做鸿蒙 APP 开发的吗? - V2EX 免费帮你答题鸿蒙初级开发者,这是什么套路? - V2EX 感觉很多人对鸿蒙系统缺乏基本的认知 - V2EX 怎么没人聊聊华为 mate 80 呢? 9030 芯片好像性能可以,没抢到 pro max,有没不走黄牛的正规渠道可以购买嘛 - V2EX 华为这波操作要赢麻了? - V2EX 华为 mate80 发布了,标准版比 mate 70 便宜 800 元,大家怎么看? - V2EX Mate 80 pro 值得预定购买吗?请使用 mate 系列手机的兄弟们分享下意见 - V2EX 近期的鸿蒙 6 扫码能力宣传大家怎么看? - V2EX 华为 Mate80,11 月 25 号发布,大家有预定的没? - V2EX 大家知道怎么查鸿蒙软件的更新日志吗? - V2EX 鸿蒙 next 是屏蔽了 apk 的安卓改吗 - V2EX 有人在用华为 mate30+鸿蒙 4 的吗?值得升鸿蒙 4 吗? - V2EX 论物种的多样性:有人能搞定鸿蒙开发者奖励,却分不清 iPhone 16 和 17 - V2EX 抵触华为的原因思考 [镜像主题] - V2EX 鸿蒙 6, HarmonyOS6 大家怎么看? - V2EX 华为 FreeClip 2 没有充电线 - V2EX uniapp 调用鸿蒙原生功能 - V2EX [外包私活]uniapp 鸿蒙插件开发 - V2EX 最近种草了华为的三个东西 - V2EX 在鸿蒙 next 系统上面需要转发短信,请问有什么方法可以用嘛? 有没有在搞移动跨端的,现在哪个框架对鸿蒙适配最完善 台风在下班前走了,华为东莞的打工人可以顺利下班了 我 TM 再买一个华为的产品我就是 SB,深圳全市停工华为装死 [远程兼职] U3D 游戏 PC 端游-鸿蒙平台 UI 适配, 500-700/天 华为鸿蒙的激励计划的月活,到底怎么计算? 鸿蒙 5.1 让我最惊奇的两个功能:“出境易”“卓易通” - V2EX 华为发布会开完了,很遗憾好像没有 ESim 开发 鸿蒙 APP,被华为卡验资,被用户质疑是流氓软件 普通用户有没有路子使用华为 ensp pro? 鸿蒙系统研发费用达几百亿,有没有在华为的老哥,真的有这么遥遥领先吗? 鸿蒙 socket 长链接如何保活?
我一个人, 53 天, 425 次提交,把终端装进了鸿蒙手机
rwecho · 2026-06-11 · via 华为

那天晚上 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 红叉,我都觉得自己在跟华为的文档玩解谜游戏。

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