返回列表

博客

深入协议:Mac 到 iPhone 的实时终端流式传输

我们如何构建了一个基于 WebSocket 的协议,将 tmux 终端输出实时流式传输到 iPhone,支持差分更新、ANSI 渲染和严格的延迟目标。

发布于 标签: engineering / protocol / performance / terminal
博客

继续查看该栏目下的产品与工程更新。

浏览此分类

终端流式传输是 Tactic Remote 的核心。所有功能——审批、通知、会话监控——都依赖于 iPhone 准确、及时地呈现 Mac 终端中正在发生的内容。要做到这一点,需要在协议层面解决几个不那么显而易见的问题。

本文将介绍我们的流式传输协议如何工作,从 tmux 捕获到 iPhone 渲染的完整流程,以及我们在此过程中做出的工程取舍。

数据管线

终端数据流经五个阶段:

  1. tmux 捕获 — Mac companion 应用从 tmux 面板读取终端内容。
  2. 差分计算 — 将当前捕获与上次传输的状态进行比较。
  3. 序列化 — 将变更区域打包成紧凑的二进制帧。
  4. WebSocket 传输 — 帧通过持久 WebSocket 连接发送到 iPhone。
  5. 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 KB400-800 字节
20 fps 时的带宽160-240 KB/s8-16 KB/s
空闲时的带宽160-240 KB/s0 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 当前的外观模式,并应用颜色映射来保持对比度。这不是主题切换——而是亮度调整,在保持原始颜色意图的同时确保可读性。

性能预算

我们在三个百分位数上跟踪流式传输性能:

指标P50P95P99
捕获延迟4ms7ms12ms
差分计算1ms3ms5ms
序列化<1ms1ms2ms
网络(局域网)3ms8ms15ms
网络(隧道)45ms90ms150ms
iPhone 渲染6ms12ms18ms
总计(局域网)15ms31ms52ms
总计(隧道)57ms113ms187ms

这些数据来自我们 Beta 用户群的生产遥测。P50 局域网延迟为 15 毫秒,这意味着在大多数交互中,iPhone 显示屏的更新速度快到人眼无法感知延迟。

我们学到的经验

构建实时终端输出流式传输协议让我们学到了几条经验:

对于这个场景,轮询优于事件驱动。 我们最初尝试挂接 tmux 的内部更新事件,但发现 50 毫秒轮询更简单、更可靠,且性能开销可以忽略不计。

行级差分已经足够好。 我们原型实现了字符级差分,但额外的复杂性只带来了 5-8% 的带宽进一步减少,不值得承担维护负担。

高频传输下二进制帧格式很重要。 对于每秒发送 20 次的消息,序列化格式中每一个字节的开销都会被放大。JSON 的冗余在测量中代价明显。

重连用户体验与连接性能同等重要。 如果用户理解正在发生什么,他们可以容忍 2 秒的重连。但如果没有视觉反馈,即使 500 毫秒的重连也会被感知为故障。

有关实现细节,请参阅我们的架构文档。如果你正在构建类似的系统,欢迎通过支持页面与我们讨论。

立即体验 Tactic Remote

用手机远程控制编程 Agent

连接 Mac、Windows 或 Linux 电脑上的 Claude Code、Codex 等 Agent,在 iPhone 或 iPad 上查看进展并发送下一条要求。