把 Mac 独立窗口搬上 iPad:Sill 零云端串流管线、VideoToolbox 信箱调度与硬件级极低延迟实录
在多设备协同的日常工作中,我一直对现有的远程桌面方案感到非常别扭:无论是 VNC、RDP 还是各种投屏工具,本质上都在做“整屏镜像”。当你把一块 16:10 的 4K Mac 桌面塞进 11 英寸或 13 英寸的 iPad,甚至 6 英寸的 iPhone 时,窗口挤成一团,文字小如米粒,操作全靠两指缩放,体验支离破碎;而苹果官方的 Sidecar 不仅将设备限制在同 Wi-Fi 和 iPad 上,还依然停留在虚拟扩展屏的整屏交互思维。
近期由 Noah Saffer 开源的项目 Sill(GitHub: Saffsanity/sill)引起了我的极大兴趣。它彻底颠覆了“投屏即整屏”的固有模式,专注实现单个 Mac 独立窗口向 iPhone、iPad 乃至折叠屏的硬件级超低延迟串流。更让我惊艳的是,Sill 全程基于纯 Swift 开发,没有任何第三方依赖,完全抛弃中心化云端服务器,将端到端玻璃到玻璃(Glass-to-glass)的交互延迟硬生生压到了 40 到 60 毫秒以内。今天我就带大家深入它的源码骨架,拆解它在 ScreenCaptureKit、VideoToolbox 硬件编码避坑、CoreGraphics 虚拟舞台以及原生无感交互上的工业级设计。
1. 传统串流的死穴与 Sill 的极简架构哲学
在深入代码之前,我先梳理一下传统远程桌面在移动端无法胜任“生产力级窗口协同”的底层瓶颈:
- 画面挤压与纵横比失配:全桌面串流意味着移动设备必须妥协于 Mac 显示器的宽高比。为了在手机或平板上看清某个代码编辑器或调色盘,我必须在小屏幕上不断平移、放大,这与 iOS 原生应用顺滑直接的触摸直觉完全相悖;
- 多级排队引发的延迟雪崩:常规流媒体推流框架普遍遵循“采集 -> 缓冲队列 -> 异步编码 -> 封包分发 -> 播放器时钟同步平滑渲染”的消费级长链路。一旦网络或编码器出现几毫秒的抖动,客户端为了防卡顿会主动引入 50 到 200 毫秒的重播缓冲区,导致鼠标指针像陷在泥潭里一样粘滞;
- 隐私与云端依赖负担:市场上部分闭源商业软件不仅采用每年 59 美元的高昂订阅制,其握手和信令服务还严重依赖第三方中心化云服务器,企业敏感代码或机密窗口在云端暴露的风险始终存在。
Sill 确立的设计哲学非常纯粹:“单窗口即应用(Single Window as an App)”、“零第三方依赖(Pure Swift & Apple Frameworks Only)”、“零服务器(No Cloud Servers)” 以及 “延迟压倒画质(Latency beats quality)”。
它的端到端数据与控制流拓扑如下图所示:
flowchart TD
subgraph MacHost["macOS 端 (SillHost)"]
SCK["ScreenCaptureKit<br/>(独立窗口零拷贝采集)"] -->|420f YpCbCr| Mailbox["EncoderMailbox<br/>(单帧容量深度信箱)"]
Mailbox -->|最新帧覆盖| VT["VideoToolbox<br/>(HEVC 硬件即时编码)"]
VT -->|NALUs 长度前缀封装| Framer["StreamMessage 封包<br/>(Kind 0/1/14/26/32)"]
Framer -->|TLS 1.3 直连 / USB| Net["Network.framework<br/>(Bonjour / AWDL / USB Cable)"]
Injector["InputInjector / KeyStrokes<br/>(CGEvent 合成与物理 HID 注入)"] <---|JSON InputEvent (Kind 8)| Net
HotKeyEngine["SymbolicHotKeys<br/>(解析系统原生热键表)"] --> Injector
MenuEngine["MenuReader<br/>(AXUIElement 菜单树按需映射)"] --->|Kind 24 菜单状态| Framer
VStage["CGVirtualDisplay<br/>(私有 API 虚拟舞台隔离遮挡)"] -.-> SCK
end
subgraph iOSDevice["iOS / iPadOS 端 (iOSClient)"]
Net -->|加密字节流| Dispatcher["MessageReader 帧解调"]
Dispatcher -->|HEVC NALUs| Layer["AVSampleBufferDisplayLayer<br/>(硬件解码 + DisplayImmediately)"]
Dispatcher -->|Kind 14/26 光标外形与坐标| Sprite["CAShapeLayer 本地光标精灵<br/>(120Hz 零网络延迟直接渲染)"]
Layer -.-> Composite["屏幕无损即时混成"]
Sprite -.-> Composite
Touch["触控 / Apple Pencil / 键盘手势"] -->|归一化坐标 (0~1)| ClientNet["StreamClient 输入上报"]
ClientNet --> Net
end
从上面的全景图可以看出,从 Mac 的 GPU 显存帧缓冲,到 iPad 的屏幕像素呈现,中间没有经过任何第三方封装层或无意义的内存拷贝,所有的设计都围绕着“削减延迟”展开。
2. 深入 VideoToolbox 硬件编码与单帧信箱(EncoderMailbox)
在低延迟视频传输领域,很多人常犯的一个误区是:只要开启了苹果的硬件编码加速,就能天然获得极佳的实时响应。但在我分析 Sill 的 HEVCEncoder.swift 时,作者记录的几处实战巨坑让人印象极为深刻。
2.1 延迟压倒画质:单帧深度信箱(Single-slot Mailbox)
常规的 VTCompressionSession 往往允许内部存在多帧重排或管道排队。但在远程交互场景下,正在编码的旧帧没有任何等待价值——如果用户移动了鼠标或滚动了页面,上一帧还没出炉,下一帧就已经到达,那么强行等待旧帧编码并发送只会让客户端显示“过去的画面”,进而造成滚雪球式的延迟恶化。
Sill 在 HEVCEncoder 中引入了手写的线程安全信箱 EncoderMailbox:
final class HEVCEncoder {
// 互斥锁保护信箱状态
private let lock = NSLock()
private var mailbox = EncoderMailbox<(CVPixelBuffer, CMTime)>()
private let encodeQueue = DispatchQueue(label: "sill.encode", qos: .userInteractive)
func encode(_ pixelBuffer: CVPixelBuffer, at time: CMTime) {
lock.lock()
// 核心哲学:信箱深度严格为 1。如果上一帧仍在等待送入 VideoToolbox,
// 最新的帧直接无情覆盖(Drop)掉旧帧,ScreenCaptureKit 永不阻塞!
mailbox.put((pixelBuffer, time))
lock.unlock()
encodeQueue.async { [weak self] in
self?.submitNextFrame()
}
}
}
其核心调度规则极其严苛:
- VT 内部永远只有 1 帧:仅当上一帧编码回调
VTCompressionOutputHandler返回并送出后,才从信箱中提取下一帧送入VTCompressionSessionEncodeFrame; - 信箱内新帧覆盖旧帧:在 VT 忙碌的几毫秒窗口期内,如果 ScreenCaptureKit 投递了多帧,信箱始终只保留最新的一帧,老帧就地丢弃;
- 零等待非阻塞:采集线程(
sill.capture)永远不需要等待编码器释放锁,采集吞吐率与编码节拍完全解耦。
2.2 击穿 Apple Silicon 的“33 FPS 平台期”(The 33 fps plateau)
这是我在源码中见到的最硬核的 Apple Silicon 硬件排坑手记之一。
在开发初期,作者 Noah 发现了一个极其诡异的硬件表现:当 Mac 屏幕上的窗口保持静止约 1 秒以上(此时 ScreenCaptureKit 会跳过 .idle 帧或以极低频率送帧),随后用户突然剧烈滚动或拖动窗口时,VideoToolbox 硬件编码器并没有立刻爆发至 60 fps,而是被锁死在 约 29 毫秒/帧(即 33~34 fps) 的低迷速率,迟迟无法恢复满帧!
深入定位发现,这是 Apple Silicon 芯片(特别是 M 系列的 AppleAVE2 硬件媒体引擎固件)的省电降频策略导致的。当硬件编码器在短时间内遭遇稀疏帧输入,固件会自动切入降频的“慢速状态(Slow State)”,而且在当前 CompressionSession 持续活跃的情况下,即使后续输入大量高帧率画面,固件也无法敏锐地重新拉高频率。
为了攻克这一芯片级顽疾,Sill 在 EncoderSlowState.swift 中设计了一套精准的状态机监控:
- 当捕获到帧间隔突变为高频,而编码器输出耗时连续多帧滞留在 ~29ms 阈值以上时,立即判定为命中了硬件平台的慢速锁定;
- 启动 “后台 Session 热替换机制(Hot-swap Replacement)”:在独立的后台队列中瞬间拉起一个全新的
VTCompressionSession,完成参数配置与准备; - 在下一个交接节拍,优雅完成旧 Session 的结算与析构,同时让新 Session 强制输出关键帧(IDR 帧)与最新的 VPS/SPS/PPS 参数集,无缝完成流水线切换。
实测数据显示,通过动态换血 Session,画面运动流畅度瞬间从 34 fps 跃升至 57~60 fps,彻底抹平了能耗调度带来的交互卡顿!
2.3 硬件死锁看门狗与无缝软编降级
除了能耗降频,硬件编码器还会遭遇其他极端异常:
- 系统级死锁(Stuck):在某些 macOS 版本中,全系统硬件编码管线可能会因驱动异常出现数小时的死死卡死;
- 优先级剥夺(Preemption):当 iOS 模拟器录屏工具以最高固件优先级(Priority 80)占用 AppleAVE2 引擎时,作为实时任务(Priority 0)的 Sill 帧会被挤压挂起长达数秒。
Sill 在独立的 sill.encode.watchdog 队列中常驻了一个 1.5 秒超时看门狗。一旦单帧在 VideoToolbox 内部驻留超过 1.5 秒未触发回调,看门狗立刻宣告当前 Session 阵亡,自动触发 onHung 回调,将编码管线在毫秒级无缝降级到 CPU 软件编码器(Software HEVC Encoder),保证交互链路永不断流;并在后台启动轻量探针,待硬件引擎空闲后再伺机平滑切回硬件。
3. 光标解耦架构:从“画面烧录”到“客户端本地精灵”
在远程控制软件中,鼠标指针的跟手度直接决定了产品的生死。绝大多数桌面镜像方案都默认开启系统的“录制光标”选项,这在长距离或移动端串流中往往是一场灾难。
3.1 为什么严禁在视频中录入光标?
如果把 Mac 的鼠标光标交由 ScreenCaptureKit 绘制进视频帧(config.showsCursor = true),会引发三大不可逆问题:
- 1 个完整的网络 RTT 延迟:你在 iPad 触控板上滑动手指,信号发到 Mac,Mac 移动光标并被采集,经过编码、网络传输、解码上屏,你至少要在 40~60 毫秒之后才能看到光标动弹,主观感受严重拖泥带水;
- 高频运动造成的压缩糊焦:鼠标箭头这种高频边缘在 HEVC 有损压缩下极易产生色度边缘噪点与马赛克伪影;
- 触控与笔尖视差:在使用 Apple Pencil 点触时,物理笔尖下方如果悬浮着一个迟钝的录制鼠标,会产生严重的视觉重影割裂感。
3.2 客户端 120Hz 本地光标精灵(Cursor Sprite)
Sill 的解决方案极其优雅:在 Mac 采集端,果断将 config.showsCursor 设为 false,视频流本身是一张干干净净的纯窗口像素流。
光标系统被彻底拆解为纯数据外发与本地图层渲染:
sequenceDiagram
autonumber
participant Mac as Mac (SillHost)
participant iOS as iPad / iPhone (iOSClient)
participant User as 用户 (Pencil / 触控板)
Note over Mac: PointerWatch 检测到 Mac 本地鼠标移动
Mac->>iOS: StreamMessage Kind 26 (MacPointer 绝对坐标)
Mac->>iOS: StreamMessage Kind 14 (CursorShape 外形矢量/图元)
Note over iOS: 更新本地 CAShapeLayer 外形与位置
User->>iOS: Apple Pencil 点触或滑动
Note over iOS: 立即以 120Hz 刷新本地 Cursor Sprite 坐标 (零网络延迟响应)
iOS->>Mac: StreamMessage Kind 8 (归一化坐标 x, y: 0.0~1.0)
Note over Mac: InputInjector 转换为 CGEvent 注入目标窗口
在 iOS 端,HEVCDisplayView 内部构建了一个专属的 CAShapeLayer 光标图层,叠加在 AVSampleBufferDisplayLayer 之上:
// HEVCDisplayView.swift: 像素级还原 macOS 原生光标
static let arrowPath: CGPath = {
let arrow = CGMutablePath()
arrow.addLines(between: [
CGPoint(x: 0, y: 0), // 箭头顶点 (热点 Hotspot)
CGPoint(x: 0, y: 16.8), // 左侧垂直边
CGPoint(x: 4.0, y: 12.8), // 内凹拐角
CGPoint(x: 6.8, y: 19.2), // 箭尾左下
CGPoint(x: 9.6, y: 18.0), // 箭尾右下
CGPoint(x: 6.9, y: 12.0), // 回折到头部
CGPoint(x: 12.0, y: 12.0), // 翼展右侧
])
arrow.closeSubpath()
return arrow
}()
当使用 iPad 外接触控板或 Apple Pencil 滑动时,客户端直接在本地驱动该 CAShapeLayer,配合 ProMotion 达到 120Hz 的绝对丝滑体验;而当 Mac 本地物理鼠标移动或光标切换为文本 I-Beam、拖拽手势、缩放箭头时,Mac 仅向通道推送数十字节的 JSON/二进制消息,客户端即时切换 Sprite 贴图与锚点。
4. 突破系统限制:CGVirtualDisplay 虚拟舞台与几何还原
单窗口串流面临的一个核心技术壁垒是:macOS 窗口管理器的遮挡优化机制。
当你在 Mac 上将某个正在被串流的代码窗口最小化,或者用全屏 Safari、终端把它彻底盖住时,macOS Window Server 与 ScreenCaptureKit 会认定该窗口处于“不可见(Occluded)”状态,出于能耗考虑,该窗口的重绘事件会被降频甚至完全冻结。这对于远程操作来说是致命的。
4.1 动态调用 CoreGraphics 私有 API 构建虚拟舞台
为了彻底摆脱遮挡限制并为移动设备提供最佳 Retina 点对点排版,Sill 引入了 CoreGraphics 内部隐藏但极为强大的私有类族:CGVirtualDisplay。
Sill 并没有静态链接私有符号(这能完全绕开 Notarization 公证扫描),而是通过 Objective-C Runtime 进行动态查询与严格的签名验证:
// VirtualDisplay.swift: 纯 Runtime 动态调用私有 API
final class VirtualDisplay {
init(name: String, widthPt: Int, heightPt: Int, scale: Int, refreshHz: Double) throws {
guard let descClass = NSClassFromString("CGVirtualDisplayDescriptor") as? NSObject.Type,
let displayClass = NSClassFromString("CGVirtualDisplay") as? NSObject.Type else {
throw Failure.classMissing("CGVirtualDisplay")
}
// 构造虚拟显示器描述符,指定点宽、点高、HiDPI 缩放比(2x/3x)与刷新率(60/120Hz)
let desc = descClass.init()
desc.setValue(name, forKey: "name")
desc.setValue(UInt32(widthPt), forKey: "maxVirtualDisplayWidth")
desc.setValue(UInt32(heightPt), forKey: "maxVirtualDisplayHeight")
// ...
}
}
启用该特性后,Mac 会在后台静默虚拟出一块宽高比完全契合 iPad/iPhone 的专属显示器(Virtual Stage)。当你在移动端串流某个窗口时,Sill 会自动通过 Accessibility API 将该窗口搬移到这个虚拟屏幕上。这样一来:
- 永不遮挡:虚拟屏幕与 Mac 主物理屏幕彻底隔离,你在 Mac 本地如何堆叠盖覆其他应用,都不会干扰虚拟屏上窗口的实时渲染;
- 点对点 Retina 锐利度:由于虚拟屏的逻辑分辨率与 HiDPI 缩放倍率直接适配 iPad 的屏幕比例,macOS 系统字体会以原生矢量精度进行渲染,告别缩放模糊。
4.2 智能几何还原(WindowRestore)
如果用户未开启虚拟显示,或者运行在不支持私有 API 的保守模式下,Sill 会通过 WindowSizer.swift 利用 Accessibility API(AXUIElement)动态调整被串流窗口的几何尺寸以契合移动端视口。
而最体现工程细节的是 WindowRestore.swift:
- 在推流启动前,Sill 精准快照当前窗口在全局 CoreGraphics 坐标系下的初始位置和尺寸(
Placement.originalFrame); - 当客户端断开连接、切换窗口或退出应用时,Sill 会自动触发还原逻辑,以毫秒级将窗口平滑归位至 Mac 桌面原处;如果用户在串流期间于 Mac 端手动移动过窗口,还原模块会智能感知并保留新位移,绝不强行颠覆用户的心智预期。
5. 传输网络与硬件级安全:纯 Network.framework 的工业级实践
Sill 摒弃了 WebRTC 这种动辄数十兆二进制体积、过度封装的庞大中间件,完全采用苹果原生的 Network.framework 构建轻量、确定性极高的网络拓扑。
5.1 802.11 物理层无线电防休眠 Heartbeat
在局域网 Wi-Fi 串流测试中,不少开发者会遇到一种周期性的“诡异丢帧”:明明网络带宽充足,但每隔几秒甚至十几秒,客户端就会突发一次长达 200~300ms 的明显延迟。
经过抓包分析,这是移动设备(特别是 iPhone/iPad)在下行链路处于闲置或数据稀疏时,Wi-Fi 芯片自动触发了 802.11 物理层节能模式(Power Save Mode),导致接收天线陷入低功耗待机状态。当 Mac 端突然送出一个编码关键帧时,需要消耗额外的握手帧把天线从低功耗状态“唤醒”,直接引入了数百毫秒的物理层唤醒延迟。
Sill 在协议中定义了 StreamMessageKind.tick(Kind 13):
case tick = 13 // host → client, empty: keeps the device's Wi-Fi radio out of power save
Mac 端每隔 30 毫秒向链路喷射一个极其轻量(负载为空)的 Tick 数据包。这几个字节的数据宛如物理层兴奋剂,强制让 iOS 设备的 Wi-Fi 芯片持续保持全功率接收状态,从而彻底扑灭了无线电休眠带来的随机卡顿!
5.2 基于 IOKit CDC NCM 的 USB 硬件级免密配对
很多工具在有线连接时依然要求用户在手机端输入复杂的验证码。Sill 实现了一套极为巧妙的 “硬件级零配置配对(Zero-click Cable Pairing)”:
// CableLink.swift: 深度遍历 IOKit 证明物理线缆直连
enum CableLink {
static let appleVendor = 0x05AC // Apple 官方 Vendor ID
static let products: Set<String> = ["iPhone", "iPad"]
static func isPhoneOrPadCable(_ a: Ancestry?) -> Bool {
guard let a, a.ncm, a.vendor == appleVendor, let name = a.productName else { return false }
return products.contains(name)
}
}
其验证机制层层递进:
- IPv6 Link-Local 范围限制:要求连接源地址必须为
fe80::/10链路本地单播地址,该地址在内核层面不可路由,物理上不可能跨越子网伪造; - IOKit IOService 注册表追根溯源:沿着该网络接口(如
en14)向上遍历 macOS 的 IORegistry 硬件服务树,直至第一级IOUSBHostDevice; - 严格校验硬件属性:必须满足
idVendor == 0x05AC、具备 CDC NCM(类 2、子类 13)通信接口,且USB Product Name明确为iPhone或iPad。
只有当三者同时满足时,Mac 才会认定当前连接为“物理线缆直连”,直接完成 Ed25519 秘钥对签名握手与全自动受信配对,完全免去了手动输入 12 位 PIN 码的繁琐流程。
6. 原生生态沉浸:菜单投射、原生手势与剪贴板防护
除了纯粹的像素串流,Sill 还把交互深度做到了极致,让 Mac 应用在移动端真正像是一个原生的本地 App。
6.1 AXUIElement 菜单树实时映射
在 iPadOS 26+ 环境下,Sill 能够将当前串流 Mac 应用的完整菜单栏投射到 iPad 系统的原生菜单栏或悬浮菜单面板中。
MenuReader.swift 通过 Accessibility API(kAXMenuBarAttribute)动态抓取菜单项层级,但为了防止某些重度依赖插件或响应迟缓的复杂应用(例如 Blender)阻塞推流主线程,Sill 引入了严格的时间与条目预算机制:
- 独立的后台线程
sill.menus执行读取; - 单个菜单项读取配额上限为 500 项,读取时间预算限制为 1.5 秒;
- 针对触发模态对话框的菜单操作,自动容错并返回
pressedNoAnswer,坚决不让应用挂起主控链路。
6.2 动态解析系统快捷键映射(SymbolicHotKeys)
许多远程软件的三指、四指手势都是写死在程序里的固定组合键,而当用户在 Mac 的“系统设置 -> 键盘快捷键”中微调过调度中心(Mission Control)或显示桌面后,手势就会直接失效。
Sill 的 GestureChords.swift 选择尊重用户的原生习惯:它在启动时直接读取 macOS 底层的 com.apple.symbolichotkeys.plist 注册表,动态查询用户当前为 Mission Control、App Exposé、左右 Spaces 桌面绑定的真实虚拟键值(Virtual Keycode)与修饰键掩码。当你在 iPad 触控板上执行三指上滑或轻扫时,Mac 注入的是 100% 精准对应用户个人配置的系统原生热键。
6.3 严密的剪贴板隐私防护(ClipboardPolicy)
双向剪贴板同步在提升效率的同时,极易成为密码泄露的温床。Sill 在 ClipboardPolicy.swift 中实施了严格的内容审查:
- 密码管理器敏感标记拦截:一旦剪贴板项被 1Password、Bitwarden、KeePass 或苹果系统密码应用标记为
org.nspasteboard.ConcealedType、TransientType或AutoGeneratedType,Sill 坚决不读取、不转发,严禁敏感凭据离开本机显存; - 环路递归抑制:严格规避 Universal Clipboard(
com.apple.is-remote-clipboard)导致的跨端死循环复制; - 容量硬上限:文本传输严格限制在 256 KB 以内,杜绝巨量文本引发信道拥塞。
7. 结语与工程启示
纵观整个 Sill 项目的架构设计与代码实现,它向我展示了一种极高水准的“原生工程美学”:
在各种跨平台大型框架横行的今天,Noah Saffer 仅用不到 1 万行精练纯粹的 Swift 代码,通过深度吃透苹果官方的底层基建——ScreenCaptureKit、VideoToolbox、AVSampleBufferDisplayLayer、Network.framework、IOKit 与 Accessibility,不仅将单窗口远程串流的体验做到了极致,更以教科书般的细致度解决了 Apple Silicon 固件降频、Wi-Fi 节能抖动、光标感知延迟以及虚拟舞台防遮挡等一系列深水区工程难题。
对于所有追求极致性能、深耕音视频串流与跨端协同系统的开发者而言,Sill 无疑是一座不可多得的现代 Swift 系统级工程宝藏。
原文链接与参考资料
- Sill 官方开源仓库:GitHub - Saffsanity/sill: Any window from your Mac, live on your iPhone or iPad.
- Sill 官方产品主页与下载:getsill.app - Any window from your Mac, live on your iPhone or iPad.
- Apple Developer 官方文档:ScreenCaptureKit Documentation
- Apple Developer 官方文档:VideoToolbox Compression Session
- Apple Developer 官方文档:AVSampleBufferDisplayLayer