博客
深入协议:Mac 到 iPhone 的实时终端流式传输
我们如何构建了一个基于 WebSocket 的协议,将 tmux 终端输出实时流式传输到 iPhone,支持差分更新、ANSI 渲染和严格的延迟目标。
继续查看该栏目下的产品与工程更新。
浏览此分类终端流式传输是 Tactic Remote 的核心。所有功能——审批、通知、会话监控——都依赖于 iPhone 准确、及时地呈现 Mac 终端中正在发生的内容。要做到这一点,需要在协议层面解决几个不那么显而易见的问题。
本文将介绍我们的流式传输协议如何工作,从 tmux 捕获到 iPhone 渲染的完整流程,以及我们在此过程中做出的工程取舍。
数据管线
终端数据流经五个阶段:
- tmux 捕获 — Mac companion 应用从 tmux 面板读取终端内容。
- 差分计算 — 将当前捕获与上次传输的状态进行比较。
- 序列化 — 将变更区域打包成紧凑的二进制帧。
- WebSocket 传输 — 帧通过持久 WebSocket 连接发送到 iPhone。
- ANSI 渲染 — iPhone 客户端解析转义序列并渲染带样式的文本。
每个阶段都有自己的延迟预算。我们的目标是在局域网上端到端传输在 100 毫秒以内,在每个阶段内留有充足的余量。
tmux 面板捕获
Tactic Remote 通过 tmux 管理 Claude Code 会话。这是一个早期的架构决策,对流式传输带来了很大优势:tmux 维护一个虚拟终端缓冲区,我们可以通过编程方式读取,而无需拦截实际的 PTY 流。
我们使用带 -p 和 -e 标志的 tmux capture-pane 命令来提取保留 ANSI 转义序列的面板内容。在活跃流式传输期间,捕获以 50 毫秒的轮询间隔运行——足够快以感觉像实时传输,又足够低频以避免明显的 CPU 开销。
一个细节要注意:capture-pane 返回可见面板内容加上回滚缓冲区。默认情况下我们只捕获可见视口,并在用户在 iPhone 上向上滚动时按需流式传输回滚内容。这使得常见情况下每帧的载荷保持较小。
在现代 Mac 上,捕获过程每帧增加约 3-8 毫秒,测量范围覆盖 M1 到 M4 硬件。
差分更新
每帧都发送完整的终端缓冲区是浪费的。一个典型的 80x24 终端面板包含大约 1,920 个字符。加上 ANSI 序列,一个完整帧可以达到 8-12 KB。以每秒 20 帧计算,带宽需求为 160-240 KB/s——在本地 Wi-Fi 上可以接受,但通过 Cloudflare Tunnel 的蜂窝连接就有问题了。
取而代之,我们计算连续帧之间的行级差分。大多数终端更新只影响几行——出现一行新日志、进度条前进、光标移动。我们的差分算法通过哈希比较识别变更的行,只传输修改的内容。
在实践中,差分更新将平均帧大小减少了 85-92%。Claude Code 活跃输出期间,典型的差分帧为 400-800 字节。空闲期间(等待用户输入或 API 响应时),根本不发送帧——我们只在内容变化时传输。
| 指标 | 完整帧 | 差分帧 |
|---|---|---|
| 平均帧大小 | 8-12 KB | 400-800 字节 |
| 20 fps 时的带宽 | 160-240 KB/s | 8-16 KB/s |
| 空闲时的带宽 | 160-240 KB/s | 0 KB/s |
传输格式
差分帧被序列化为紧凑的二进制格式,而非 JSON。每帧包含:
- 4 字节头部:协议版本(1 字节)、帧类型(1 字节)和载荷长度(2 字节)。
- 用于排序和丢失检测的序列号。
- 行更新记录数组,每条记录包含行索引、新内容(UTF-8 编码,嵌入 ANSI 序列)和用于完整性验证的 CRC32 校验和。
我们在 Protocol Buffers、MessagePack 和自定义二进制格式之间选择了自定义二进制格式,因为基准测试表明,对于小型、高频消息,序列化开销主要来自库的初始化成本。我们手写的序列化器每帧增加不到 1 毫秒。
WebSocket 传输
流式连接使用单个持久 WebSocket。我们评估了 Server-Sent Events (SSE) 和原始 TCP,但 WebSocket 在双向通信(滚动请求和流量控制所需)、广泛的网络兼容性和内置帧结构方面提供了最佳组合。
连接管理处理三种场景:
局域网 — iPhone 直接连接到 Mac companion 的本地服务器。往返时间通常为 2-8 毫秒。端到端流式传输延迟(捕获到渲染)平均为 40-60 毫秒。
Cloudflare Tunnel — 流量经过 Cloudflare 的边缘网络路由。往返时间增加到 30-80 毫秒,取决于地理位置。端到端流式传输延迟平均为 120-200 毫秒。对于监控来说仍然足够响应,但用户在快速滚动输出时会注意到差异。
网络切换 — 当 iPhone 在 Wi-Fi 和蜂窝网络之间切换(或在不同网络之间切换)时,WebSocket 连接会断开。我们的重连逻辑在 1-2 秒内重新建立连接,并请求全帧重新同步以恢复状态。重连期间,iPhone 上的视觉指示器显示连接状态,让用户了解输出暂停的原因。
ANSI 转义序列处理
Claude Code 的终端输出包含丰富的 ANSI 转义序列——用于语法高亮的颜色、用于强调的粗体、用于进度指示器的光标定位。在 iPhone 上准确保留这些序列对可读性至关重要。
我们的 iOS 渲染器处理最常见的 SGR(Select Graphic Rendition)序列:16 种标准颜色、256 色调色板、24 位真彩色、粗体、斜体、下划线和反色。我们有意不在渲染视图中支持光标移动序列(CSI H、CSI A/B/C/D)——这些在 tmux 捕获期间被消费并反映在行定位中,而不是作为原始转义码传输。
移动端面临一个特殊挑战:深色模式。为深色背景设计的终端配色方案在 iOS 浅色模式下渲染效果不佳(反之亦然)。我们检测 iPhone 当前的外观模式,并应用颜色映射来保持对比度。这不是主题切换——而是亮度调整,在保持原始颜色意图的同时确保可读性。
性能预算
我们在三个百分位数上跟踪流式传输性能:
| 指标 | P50 | P95 | P99 |
|---|---|---|---|
| 捕获延迟 | 4ms | 7ms | 12ms |
| 差分计算 | 1ms | 3ms | 5ms |
| 序列化 | <1ms | 1ms | 2ms |
| 网络(局域网) | 3ms | 8ms | 15ms |
| 网络(隧道) | 45ms | 90ms | 150ms |
| iPhone 渲染 | 6ms | 12ms | 18ms |
| 总计(局域网) | 15ms | 31ms | 52ms |
| 总计(隧道) | 57ms | 113ms | 187ms |
这些数据来自我们 Beta 用户群的生产遥测。P50 局域网延迟为 15 毫秒,这意味着在大多数交互中,iPhone 显示屏的更新速度快到人眼无法感知延迟。
我们学到的经验
构建实时终端输出流式传输协议让我们学到了几条经验:
对于这个场景,轮询优于事件驱动。 我们最初尝试挂接 tmux 的内部更新事件,但发现 50 毫秒轮询更简单、更可靠,且性能开销可以忽略不计。
行级差分已经足够好。 我们原型实现了字符级差分,但额外的复杂性只带来了 5-8% 的带宽进一步减少,不值得承担维护负担。
高频传输下二进制帧格式很重要。 对于每秒发送 20 次的消息,序列化格式中每一个字节的开销都会被放大。JSON 的冗余在测量中代价明显。
重连用户体验与连接性能同等重要。 如果用户理解正在发生什么,他们可以容忍 2 秒的重连。但如果没有视觉反馈,即使 500 毫秒的重连也会被感知为故障。