WWW.YOUINFO.SITE
标签聚合 日志

/tag/日志

LinuxDo 最新话题 · 2026-06-11 17:33:51+08:00 · tech

NewAPI 的日志目前是单表存储,已经看到太多佬友因为日志撑爆磁盘了,下面给出一种解决方式,可能不完全兼容,但是个人测试下来没有什么问题 与 NewAPI 版本无关,基本所有版本都可以用,我用的版本是 1.0.0-rc.10 自带的日志删除 最常用的是使用 NewAPI 自带的 “清理历史日志” 来进行删除 // 代码逻辑如下 // 直接按照时间戳删除 LOG_DB.Where("created_at < ?", targetTimestamp).Limit(limit).Delete(&Log{}) 这里有两个问题: Delete 的效率不高,或者说在数据量过大的情况下使用 Delete 带来的 IO 可能成为灾难 Delete 之后磁盘空间并不会回收,需要手动回收 通过数据库分片解决日志删除 适用于 MySQL,如果您是其他的 DB 如 Postgres / SQLite,将下面的内容提供给 AI 相信也会有对应的解决方案 分片的核心逻辑是,将日志拆分到对应的日期上,比如分片 logs_20260601 只存储当天的数据,在超出需要删除的日期后,比如 1 个月后的 2026/07/01,直接删除 logs_20260601 分片,分片占用的空间会立刻释放,对应的日志数据也会直接删除 不过这里要提示您,所有的 DB 变更都可能引入风险,请您在执行前通过 mysqldump 或者类似工具,导出完整的数据结构和数据内容,确保执行出现异常可以随时回滚 执行下面的步骤前,建议停机,否则可能导致数据不完整 ,如果数据完整性不在考虑范围内,也可以在线更新 Step 1 检查数据符合预期 (Read) 执行下面的查询语句,确保 count 为 0,即所有的日志的创建时间不为空 SELECT COUNT(*) AS null_created_at_count FROM logs WHERE created_at IS NULL; Step 2 创建临时表 (Write) CREATE TABLE logs_new LIKE logs; Step 3 添加准备分区语句 (Read) 通过下面的 SQL,可以获得一个建表 SQL,对 DB 无任何副作用,可以放心执行 下面的 @start_date 和 @end_date 两行,可以按照您的实际数据存储情况调整,下面的配置为创建 180 天前到 7 天后的分区,如果您的日志已经回收或删除过了,可以考虑缩减分区数量 SET time_zone = '+08:00'; 这里可以按照您的需求调整为具体的时区 分区必须提前创建,否则插入数据会有问题 ,所以您至少需要创建 7 天后的备用 SET SESSION group_concat_max_len = 1024 * 1024; SET time_zone = '+08:00'; SET @start_date = DATE_SUB(CURDATE(), INTERVAL 180 DAY); SET @end_date = DATE_ADD(CURDATE(), INTERVAL 7 DAY); WITH RECURSIVE dates AS ( SELECT @start_date AS d UNION ALL SELECT DATE_ADD(d, INTERVAL 1 DAY) FROM dates WHERE d < @end_date ) SELECT GROUP_CONCAT( CONCAT( ' PARTITION p', DATE_FORMAT(d, '%Y%m%d'), ' VALUES LESS THAN (', UNIX_TIMESTAMP(DATE_ADD(d, INTERVAL 1 DAY)), ')' ) ORDER BY d SEPARATOR ',\n' ) INTO @parts FROM dates; SET @sql = CONCAT( 'ALTER TABLE logs_new MODIFY `created_at` bigint NOT NULL, DROP PRIMARY KEY, DROP INDEX `idx_created_at_id`, ADD PRIMARY KEY (`id`, `created_at`) PARTITION BY RANGE (`created_at`) ( ', @parts, ', PARTITION pmax VALUES LESS THAN MAXVALUE )' ); SELECT @sql; Step 4 执行分区语句 (Write) 执行上一步输出的 SQL,会将新的 logs_new 表调整为分区表 Step 5 导入旧的数据 (Write) 取决于您的数据量,这一步可能会花费一些时间 INSERT INTO logs_new SELECT * FROM logs; Step 6 校验数据已经导入完成 (Read) 如果您是停机更新,确保两条 SQL 输出的内容是一致的 如果您是在线更新,确保数据接近或一致 SELECT "logs" as `table`, COUNT(*), MIN(created_at), MAX(created_at) FROM logs UNION SELECT "logs_new" as `table`, COUNT(*), MIN(created_at), MAX(created_at) FROM logs_new; Step 7 确认分区 (Read) 下面的 SQL 会打印新表的所有分区,以及每个分区的数据量 SELECT PARTITION_NAME, PARTITION_DESCRIPTION, TABLE_ROWS FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'logs_new' ORDER BY PARTITION_ORDINAL_POSITION; Step 8 切表 (Write) 切换流量到新表,执行完成后请检查 NewAPI 各组件各页面是否能正常工作,特别是依赖日志的内容 RENAME TABLE logs TO logs_old, logs_new TO logs; Step 9 创建自动回收创建/分区任务 (Write) MySQL 不会自动创建或者删除分区,需要您创建定时任务来实现,下面会给出创建/删除分区的任务,您可以按需添加 Step 9.1 开启 MySQL Event Scheduler 在数据库中执行下面的命令,通常需要 root 用户,无需重启 SET GLOBAL event_scheduler = ON; SHOW VARIABLES LIKE 'event_scheduler'; 在配置文件的 mysqld 章节,增加下面的配置,无需重启 这一步是为了保证,即使后面重启数据库,Event Scheduler 仍然会是开启状态 [mysqld] event_scheduler=ON Step 9.2 创建新建分区任务 下面的两个 SQL 都需要执行 第一个 SQL 中的 SET time_zone = '+08:00'; 可按需修改为您的时区 第二个 SQL 中的时间您可以修改为适用于您的服务的时间,目前设置的是每天的 02:00,用于控制定时触发的时机 DELIMITER $$ DROP PROCEDURE IF EXISTS sp_logs_create_future_partitions$$ CREATE PROCEDURE sp_logs_create_future_partitions() BEGIN DECLARE v_i INT DEFAULT 0; DECLARE v_d DATE; DECLARE v_partition_name VARCHAR(32); DECLARE v_less_than BIGINT; DECLARE v_exists INT DEFAULT 0; DECLARE v_sql TEXT; SET time_zone = '+08:00'; WHILE v_i <= 7 DO SET v_d = DATE_ADD(CURDATE(), INTERVAL v_i DAY); SET v_partition_name = CONCAT('p', DATE_FORMAT(v_d, '%Y%m%d')); SET v_less_than = UNIX_TIMESTAMP(DATE_ADD(v_d, INTERVAL 1 DAY)); SELECT COUNT(*) INTO v_exists FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'logs' AND PARTITION_NAME = v_partition_name; IF v_exists = 0 THEN SET v_sql = CONCAT( 'ALTER TABLE logs REORGANIZE PARTITION pmax INTO (', 'PARTITION ', v_partition_name, ' VALUES LESS THAN (', v_less_than, '), ', 'PARTITION pmax VALUES LESS THAN MAXVALUE', ')' ); SET @sql = v_sql; PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; END IF; SET v_i = v_i + 1; END WHILE; END$$ DELIMITER ; DROP EVENT IF EXISTS ev_logs_create_future_partitions; CREATE EVENT ev_logs_create_future_partitions ON SCHEDULE EVERY 1 DAY STARTS TIMESTAMP(CURRENT_DATE, '02:00:00') DO CALL sp_logs_create_future_partitions(); Step 9.3 创建删除分区任务 (可选) 这个是用于替代 NewAPI 自带的删除数据任务,如果您有需要可以配置这里的自动删除 第一个 SQL 中的 INTERVAL 180 DAY 表示删除 180 天之前的数据,可以按需修改, SET time_zone = '+08:00'; 也可按需修改为您的时区 第二个 SQL 中的时间您可以修改为适用于您的服务的时间,目前设置的是每天的 03:00,用于控制定时触发的时机 DELIMITER $$ DROP PROCEDURE IF EXISTS sp_logs_drop_old_partitions$$ CREATE PROCEDURE sp_logs_drop_old_partitions() BEGIN DECLARE v_cutoff_date DATE; DECLARE v_cutoff_ts BIGINT; DECLARE v_drop_partitions TEXT; DECLARE v_sql TEXT; SET time_zone = '+08:00'; SET v_cutoff_date = DATE_SUB(CURDATE(), INTERVAL 180 DAY); SET v_cutoff_ts = UNIX_TIMESTAMP(v_cutoff_date); SELECT GROUP_CONCAT(PARTITION_NAME ORDER BY PARTITION_ORDINAL_POSITION) INTO v_drop_partitions FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'logs' AND PARTITION_NAME REGEXP '^p[0-9]{8}$' AND CAST(PARTITION_DESCRIPTION AS UNSIGNED) <= v_cutoff_ts; IF v_drop_partitions IS NOT NULL AND v_drop_partitions <> '' THEN SET v_sql = CONCAT( 'ALTER TABLE logs DROP PARTITION ', v_drop_partitions ); SET @sql = v_sql; PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; END IF; END$$ DELIMITER ; DROP EVENT IF EXISTS ev_logs_drop_old_partitions; CREATE EVENT ev_logs_drop_old_partitions ON SCHEDULE EVERY 1 DAY STARTS TIMESTAMP(CURRENT_DATE, '00:10:00') DO CALL sp_logs_drop_old_partitions(); Step 9.4 检查任务 show events; SHOW PROCEDURE STATUS WHERE Db = DATABASE(); Step 10 大功告成 一切已准备就绪,请使用吧!数据库将按照您的配置自动创建新的分片,回收旧的分片,后续如果有调整,也可以直接修改 SQL 配置再次执行。 您可以定期通过下面的 SQL 来检查分区任务的运行状态和分区的数据量,请检查 pmax 分区数据量为 0,且已经创建了 7 天后的分区 SELECT PARTITION_NAME, PARTITION_DESCRIPTION, TABLE_ROWS FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'logs' ORDER BY PARTITION_ORDINAL_POSITION DESC LIMIT 15; 1 个帖子 - 1 位参与者 阅读完整话题

v2ex · 2026-06-11 11:23:15+08:00 · tech

那天晚上 11 点,我在火车上 SSH 到服务器查日志。手机浏览器切了个微信回来,tab 被 kill 了,session 断了,查了一半的日志全没了。 我翻了翻手机上所有终端 app——Termius 、Blink Shell 、ServerCat——它们都有同一个问题:你不能真的"保持连接"。系统杀后台、网络切换、锁屏省电,随便哪个都能把你的 SSH 掐断。 那能不能反过来?让 shell 在远程服务器上一直跑,手机只是个"显示器"——断了就断了,重连回来输出还在。 这就是 Corterm (云枢终端)的出发点: session 不是连接,是状态。 先把架子搭起来 思路很直接: Worker — 装在远程机器上的轻量 agent ,管 PTY 生命周期。你断了连,shell 照跑。 Gateway — 中间层,管认证、路由、session 协调。Worker 和 Client 之间不直接通信。 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

v2ex · 2026-06-11 10:45:03+08:00 · tech

那天晚上 11 点,我在火车上 SSH 到服务器查日志。手机浏览器切了个微信回来,tab 被 kill 了,session 断了,查了一半的日志全没了。 我翻了翻手机上所有终端 app——Termius 、Blink Shell 、ServerCat——它们都有同一个问题:你不能真的"保持连接"。系统杀后台、网络切换、锁屏省电,随便哪个都能把你的 SSH 掐断。 那能不能反过来?让 shell 在远程服务器上一直跑,手机只是个"显示器"——断了就断了,重连回来输出还在。 这就是 Corterm (云枢终端)的出发点: session 不是连接,是状态。 先把架子搭起来 思路很直接: Worker — 装在远程机器上的轻量 agent ,管 PTY 生命周期。你断了连,shell 照跑。 Gateway — 中间层,管认证、路由、session 协调。Worker 和 Client 之间不直接通信。 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

v2ex · 2026-06-11 10:10:05+08:00 · tech

那天晚上 11 点,我在火车上 SSH 到服务器查日志。手机浏览器切了个微信回来,tab 被 kill 了,session 断了,查了一半的日志全没了。 我翻了翻手机上所有终端 app——Termius 、Blink Shell 、ServerCat——它们都有同一个问题:你不能真的"保持连接"。系统杀后台、网络切换、锁屏省电,随便哪个都能把你的 SSH 掐断。 那能不能反过来?让 shell 在远程服务器上一直跑,手机只是个"显示器"——断了就断了,重连回来输出还在。 这就是 Corterm (云枢终端)的出发点: session 不是连接,是状态。 先把架子搭起来 思路很直接: Worker — 装在远程机器上的轻量 agent ,管 PTY 生命周期。你断了连,shell 照跑。 Gateway — 中间层,管认证、路由、session 协调。Worker 和 Client 之间不直接通信。 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

v2ex · 2026-06-11 10:05:36+08:00 · tech

那天晚上 11 点,我在火车上 SSH 到服务器查日志。手机浏览器切了个微信回来,tab 被 kill 了,session 断了,查了一半的日志全没了。 我翻了翻手机上所有终端 app——Termius 、Blink Shell 、ServerCat——它们都有同一个问题:你不能真的"保持连接"。系统杀后台、网络切换、锁屏省电,随便哪个都能把你的 SSH 掐断。 那能不能反过来?让 shell 在远程服务器上一直跑,手机只是个"显示器"——断了就断了,重连回来输出还在。 这就是 Corterm (云枢终端)的出发点: session 不是连接,是状态。 先把架子搭起来 思路很直接: Worker — 装在远程机器上的轻量 agent ,管 PTY 生命周期。你断了连,shell 照跑。 Gateway — 中间层,管认证、路由、session 协调。Worker 和 Client 之间不直接通信。 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

v2ex · 2026-06-11 09:56:25+08:00 · tech

那天晚上 11 点,我在火车上 SSH 到服务器查日志。手机浏览器切了个微信回来,tab 被 kill 了,session 断了,查了一半的日志全没了。 我翻了翻手机上所有终端 app——Termius 、Blink Shell 、ServerCat——它们都有同一个问题:你不能真的"保持连接"。系统杀后台、网络切换、锁屏省电,随便哪个都能把你的 SSH 掐断。 那能不能反过来?让 shell 在远程服务器上一直跑,手机只是个"显示器"——断了就断了,重连回来输出还在。 这就是 Corterm (云枢终端)的出发点: session 不是连接,是状态。 先把架子搭起来 思路很直接: Worker — 装在远程机器上的轻量 agent ,管 PTY 生命周期。你断了连,shell 照跑。 Gateway — 中间层,管认证、路由、session 协调。Worker 和 Client 之间不直接通信。 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

LinuxDo 最新话题 · 2026-06-10 10:18:27+08:00 · tech

我有一个长期 vc 的项目,我在 AGENTS.md里定义了如果多次遇到相同问题要记录进复盘日志,并在开始任务时阅读进行规避.两个月过去了,我们一起看看AI都记录了什么. 建议大家也开一个类似的复盘日志,用来证据指导人类进行 Harness 迭代 复盘日志模版为了方便佬友复制就不当LLM产出截图了 工程复盘日志 (RETRO.md) 用途 : 记录开发过程中反复出现的错误、vibe coding 中遇到的典型困难、以及解决方案。 当类似问题再次出现时,先来这里查询,避免重复踩坑。 维护规则 : 每条记录必须包含:现象、根因、是否已解决、解决方案(或规避方式)。 未解决的问题必须标注 [未解决] ,已解决的标注 [已解决] 。 新条目追加在对应分类末尾,不要打乱已有顺序。 如果某条经验已经固化为 AGENTS.md 的硬约束或棘轮条目,在此标注"已升级为规则"并注明位置。 新增条目请复制以下模板: ### RETRO-XXX: 简短标题 [已解决/未解决] - **现象**: 具体的错误表现或困难描述。 - **根因**: 为什么会出现这个问题。 - **解决方案**: 怎么修复的,或者当前的规避方式。 - **后续防护**: 是否已升级为 AGENTS.md 规则,如果是注明具体条目。 2 个帖子 - 2 位参与者 阅读完整话题

LinuxDo 最新话题 · 2026-06-05 21:54:32+08:00 · tech

相关日志 INFO service=llm providerID=openai modelID=gpt-5.5 ... agent=Hephaestus - Deep Agent mode=primary stream INFO service=provider providerID=openai pkg=@ai-sdk/openai using bundled provider INFO service=default llm.runtime=ai-sdk llm.provider=openai llm.model=gpt-5.5 llm runtime selected ERROR service=llm providerID=openai modelID=gpt-5.5 ... error={ "url":"https://muyuan.do/v1/responses", "requestBodyValues":{"model":"gpt-5.5", ...}, "statusCode":400, "responseBody":"{\"error\":{\"message\":\"openai_error\",\"type\":\"bad_response_status_code\",\"param\":\"\",\"code\":\"bad_response_status_code\"}}", "isRetryable":false } ERROR service=session.processor ... error=openai_error stack=AI_APICallError: openai_error 但是在codex用正常qwq,并且claude也是正常使用的qwq 5 个帖子 - 3 位参与者 阅读完整话题