OpenScreen macOS 原生光标捕获测试管线:Helper 构建、冒烟测试与 Sidecar 验证实战
OpenScreen 在 macOS 上通过一个独立的 Swift helper 子进程(openscreen-macos-cursor-helper)捕获真实系统光标位图,并让位图光标进入编辑器与导出管线。本文基于仓库内的测试文档 macOS native cursor test pipeline,完整覆盖 helper 的工作原理、NDJSON 协议格式、构建与冒烟测试命令、macOS 权限矩阵、分优先级(P0/P1/P2)的手工测试清单,以及如何通过 .cursor.json sidecar 文件判定一次录制是否健康;并结合 Swift 源码 与 TypeScript 会话实现 说明这些测试项背后的真实调用链,帮助你在 macOS 上独立搭建、诊断和回归验证这条原生光标捕获链路。
Helper 的工作原理:轮询、去重与点击事件
cursor helper(openscreen-macos-cursor-helper)在录制期间作为 Electron 的子进程运行,它做五件事:
- 以配置的采样间隔轮询
NSCursor.currentSystem,拿到当前活动的 AppKit 系统光标; - 把每个光标图像编码为 PNG,并计算 SHA-256 内容哈希作为稳定的资产 id(
assetId); - 每个唯一的光标形状在整场录制中只发送一次完整的 base64 位图负载,后续采样只携带
assetId,从而保持 stdout 体量很小; - 通过
CGEventTap(listen-only 模式)跟踪左键按下/抬起事件,为每个样本打上interactionType标签; - 在获得辅助功能(Accessibility)授权时,使用 Accessibility API 检测
text/pointer亲和(affordance,即输入框/链接/按钮等角色),这类形状会用仓库自带的高质量 SVG 替换原始位图渲染。
在 main.swift 中可以直接看到这套实现:currentCursorAsset() 函数先用 NSCursor.currentSystem 取光标,转 NSBitmapImageRep 后编码 PNG,再用 SHA256.hash(data: png) 生成 id;scaleFactor 由 pixelsWide / pointSize.width 推出(Retina 上为 2.0),hotspot 坐标会乘以 scaleFactor 转成像素单位,供渲染端再除回点(point)尺寸。主循环里的 emittedAssetIds: Set<String> 保证即使用户在 arrow → text → arrow 之间来回切换,同一形状也只序列化一次;整个采样循环包裹在 autoreleasepool 中,避免长录制时 Cocoa 对象堆积导致内存增长(这也是后文 P2 长录制内存检查项的依据)。
NDJSON 采样协议
helper 通过 stdout 输出按行分隔的 JSON(NDJSON),每行一个事件:
{ "type": "ready", "timestampMs": 1234567890, "accessibilityTrusted": true, "mouseTapReady": true }
{ "type": "sample", "timestampMs": 1234567891, "assetId": "a7472...", "asset": { "id": "a7472...", "imageDataUrl": "data:image/png;base64,...", "width": 64, "height": 64, "hotspotX": 16, "hotspotY": 16, "scaleFactor": 2.0 }, "cursorType": null, "leftButtonDown": false, "leftButtonPressed": false, "leftButtonReleased": false }
{ "type": "sample", "timestampMs": 1234567924, "assetId": "a7472...", "cursorType": null, "leftButtonDown": false, "leftButtonPressed": false, "leftButtonReleased": false }
注意 asset 字段只在某个 assetId 首次出现时携带。各字段含义:
| 字段 | 说明 |
|---|---|
type: "ready" |
helper 启动完成的握手事件,accessibilityTrusted 指示辅助功能是否已授权,mouseTapReady 指示 CGEventTap 是否建立成功 |
assetId |
光标位图的 SHA-256 内容哈希,是位图资产的稳定标识 |
asset |
仅首见时出现:imageDataUrl(base64 PNG)、像素宽高、像素单位 hotspot、scaleFactor |
cursorType |
Accessibility 探测到的亲和类型("text" / "pointer"),无授权或非亲和形状时为 null |
leftButtonDown / leftButtonPressed / leftButtonReleased |
当前左键状态(CGEventSource.buttonState)、本采样周期内是否发生按下/抬起 |
TypeScript 端的 MacNativeCursorRecordingSession 按行解析这些事件,把唯一资产收集进 assets 的 Map,并在 stop() 时输出 provider: "native"(当且仅当至少捕获到一个位图),否则为 provider: "none"(纯位置遥测)。该数据结构与 contracts.ts 中的 CursorRecordingData / NativeCursorAsset 接口一一对应:version: 2、provider、samples[](归一化坐标 cx/cy、visible、interactionType、可选 assetId/cursorType)、assets[](platform: "darwin"、hotspot、scaleFactor)。
会话层还有一个值得注意的细节:captureSample() 里会统计连续越界样本数,达到 OUTSIDE_HIDE_THRESHOLD = 3(33ms 间隔下约 100ms)才把 visible 置为 false——短暂滑出屏幕的快划会被渲染端按 clip-path 裁到画布边缘,而不是突然消失。这正是后文多显示器测试项要验证的行为。
构建 helper
构建命令一条:
npm run build:native:mac
它会同时构建两个 Swift helper(openscreen-screencapturekit-helper 与 openscreen-macos-cursor-helper),并复制到两个位置:
electron/native/screencapturekit/build/—— 本地 dev server 使用;electron/native/bin/darwin-arm64/或darwin-x64/—— 打包(packaged)构建使用。
对应实现是 build-macos-screencapturekit-helper.mjs:它先用 xcodebuild -version 检查完整 Xcode 是否处于激活状态(只有 Command Line Tools 会因缺少 SwiftPM 需要的 SDK/平台元数据而失败),然后对每个目标架构执行 swift build -c release --arch <arch>,再把产物拷入上述两处并 chmod 0755。构建脚本还支持 OPENSCREEN_MAC_HELPER_ARCHS 环境变量按架构矩阵构建(CI 用),每个架构产出独立的单架构二进制、分放在各自的 darwin-<arch> 目录,不生成 fat binary。
如果构建报错提示缺少 SDK 元数据,切换到完整 Xcode 并接受许可:
sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -license accept
另外注意 Package.swift 声明了 platforms: [.macOS(.v13)],即 helper 本身要求 macOS 13(Ventura)及以上,与后文原生录制的可用性规则一致。
直接冒烟测试 helper
在启动整个应用之前,可以先单独运行 cursor helper 观察原始输出:
BIN=electron/native/screencapturekit/build/openscreen-macos-cursor-helper
("$BIN" '{"sampleIntervalMs":100}' & PID=$!; sleep 2; kill $PID) | head -20
helper 把命令行第一个参数作为 JSON 请求解析(字段 sampleIntervalMs 可选,缺省 33ms,且被 max(8, ...) 钳制到不低于 8ms)。预期第一行输出:
{"type":"ready","mouseTapReady":true,"accessibilityTrusted":false,"timestampMs":...}
accessibilityTrusted: false 在开发/未签名构建中是正常现象——它意味着 text/pointer 亲和检测被禁用,但基于 NSCursor 的原生位图捕获仍然工作。
预期采样行:
{"type":"sample","assetId":"a7472...","asset":{"id":"a7472...","imageDataUrl":"data:image/png;base64,...","width":64,"height":64,"hotspotX":26,"hotspotY":16,"scaleFactor":2.0},...}
{"type":"sample","assetId":"a7472...",...}
在 helper 运行期间把光标移到一个文本输入框上方(需已授权 Accessibility),应看到出现一个新的 assetId 且位图不同。对应源码路径:currentCursorType() 先检查 AXIsProcessTrusted(),未授权直接返回 nil;授权后调用 AXUIElementCopyElementAtPosition 取鼠标下的元素,沿最多 5 层父链向上找角色——文本类角色(AXTextField / AXTextArea / AXTextView / AXComboBox)返回 "text",指针类角色(AXLink、AXButton、AXMenuButton、AXCheckBox、AXTab、AXMenuItem 等)返回 "pointer";其余情况返回 nil,渲染端回退到原生捕获的位图——这正是默认箭头与自定义光标能以真实图像呈现的机制。
让应用使用自定义 helper 二进制
在本地诊断时,可以用环境变量把会话指向自编译的 helper:
export OPENSCREEN_MAC_CURSOR_HELPER_EXE=/path/to/openscreen-macos-cursor-helper
npm run dev
从 macNativeCursorRecordingSession.ts 的 helperCandidates() 可以看到完整的解析优先级:
OPENSCREEN_MAC_CURSOR_HELPER_EXE环境变量;electron/native/screencapturekit/build/openscreen-macos-cursor-helper(dev 路径);electron/native/bin/<arch>/openscreen-macos-cursor-helper(源码树内的打包路径);resources/electron/native/bin/<arch>/...(打包应用的process.resourcesPath下)。
按顺序探测第一个可执行(X_OK)的文件,全部失败则返回 null,会话转入"仅位置"回退模式(startPositionOnlyFallback(),只记录鼠标坐标、不捕获位图)。另外会话启动时会用 READY_TIMEOUT_MS = 5_000(5 秒)等待 ready 事件,超时或子进程提前退出都会触发同样的回退并打印 [cursor-macos] falling back to position-only cursor telemetry 警告。
macOS 权限:两项独立授权
这条链路需要两个相互独立的权限:
| 权限 | 作用 | 授权位置 |
|---|---|---|
| Screen Recording | ScreenCaptureKit 视频捕获 | System Settings → Privacy & Security → Screen & System Audio Recording → Electron ✅ |
| Accessibility | text / pointer 光标类型检测(affordance 提示) |
System Settings → Privacy & Security → Accessibility → Electron ✅ |
- Screen Recording 是必需的:没有它,录制根本不会开始(原生录制的三项可用性规则之一)。
- Accessibility 是可选的:没有它,
cursorType永远为null,所有光标都从捕获到的位图渲染(不做 SVG 替换)。对非 text/pointer 形状而言这不是质量损失,而是预期的回退行为。
授予任一权限后,必须完全退出并重启 dev server——getMediaAccessStatus 按进程缓存结果。还有一个更可靠的信号:文档特别指出,在未签名的 dev 构建中 getMediaAccessStatus("accessibility") 可能不反映实际开关状态,应以 helper 在 ready 事件中上报的 accessibilityTrusted 为准(对应源码中 requestAccessibilityTrust() 调用 AXIsProcessTrustedWithOptions 的探测结果)。
手工测试清单
以下清单按优先级组织,覆盖核心捕获、亲和替换、热点对齐、点击检测、优雅降级、多显示器与长录制内存。
P0 — 核心位图捕获
- [ ] 录制一段短视频,打开编辑器,确认默认箭头光标是真实系统箭头(而不是仓库自带的 SVG 近似图)。
- [ ] 在悬停网页浏览器时录制,确认自定义 CSS 光标(如
cursor: grab、cursor: crosshair)以其实际形状出现。 - [ ] 导出 MP4,确认导出视频中的光标渲染正确。
- [ ] 导出 GIF,做同样的检查。
P1 — 亲和替换(需要 Accessibility)
- [ ] 授予 Accessibility 权限并重启应用。
- [ ] 录制悬停文本输入框,确认 text I-beam 使用的是仓库自带 SVG 版本(比系统位图更精致)。
- [ ] 录制悬停链接/按钮,确认 pointer 手型使用自带 SVG。
P1 — 热点对齐(Retina)
- [ ] 在 Retina 显示器上录制一次对小型按钮的精确点击,在编辑器中确认光标尖端与实际点击点重合。helper 上报
scaleFactor: 2.0,渲染器会把像素尺寸和 hotspot 除以该值恢复为点(point)尺寸——即 Swift 端hotSpot.x * scaleFactor的逆运算。
P1 — 点击检测
- [ ] 录制若干次左键单击,确认编辑器中每次点击都触发点击回弹(click-bounce)动画。
- [ ] 确认录制会话 sidecar(
<videoPath>.cursor.json内的cursorRecordingData)中存在interactionType: "click"与"mouseup"事件。
sidecar 中 interactionType 的判定在 macNativeCursorRecordingSession.ts 的 captureSample() 里:本周期出现按下(leftButtonPressed)或状态由松变按记为 "click";出现抬起或由按变松记为 "mouseup";其余为 "move"。click/mouseup 成对出现意味着 CGEventTap 事件流完整。
P2 — 优雅降级
-
[ ] 移走两份构建产物位置的 helper 二进制后开始录制,会话应当以
provider: "none"成功(仅位置遥测、渲染默认箭头),之后再恢复两份二进制:ARCH=$([ "$(uname -m)" = "arm64" ] && echo darwin-arm64 || echo darwin-x64) mv electron/native/screencapturekit/build/openscreen-macos-cursor-helper /tmp/cursor-helper-build mv electron/native/bin/$ARCH/openscreen-macos-cursor-helper /tmp/cursor-helper-bin # ... 开始录制,然后恢复: mv /tmp/cursor-helper-bin electron/native/bin/$ARCH/openscreen-macos-cursor-helper mv /tmp/cursor-helper-build electron/native/screencapturekit/build/openscreen-macos-cursor-helper -
[ ] 撤销 Accessibility 授权,确认录制仍可用、光标从位图渲染(无 SVG 替换)。
这一项验证的正是 findMacCursorHelperPath() 全部候选失败后 startPositionOnlyFallback() 的路径:回退模式按同一采样间隔持续记录鼠标位置,stop() 时因 assets 为空而输出 provider: "none"。
P2 — 多显示器
- [ ] 录制期间把光标移到副显示器,确认光标被裁切(clip)到画布边缘、而不是快速划动时突然消失,并在连续约 100ms 越界后隐藏。
对应实现即前文提到的 consecutiveOutsideSamples 计数与 OUTSIDE_HIDE_THRESHOLD = 3:短暂越界走 clip-path 裁切,持续越界才置 visible: false,避免多屏移动产生的残影光标与运动拖尾。
P2 — 长录制内存
- [ ] 在浏览器、终端、编辑器之间切换并录制 3–5 分钟。helper 不应内存增长,因为每次循环通过
autoreleasepool排空 Cocoa 对象。用 Activity Monitor 观察openscreen-macos-cursor-helper的 RSS 应在头几秒后保持平稳。
健康录制的 Sidecar 长什么样
检查与录制视频一同写出的光标 sidecar 文件:视频保存为 /tmp/rec.mp4 时,sidecar 即 /tmp/rec.mp4.cursor.json(IPC 层以 `${videoPath}.cursor.json` 生成该路径,见 handlers.ts):
{
"version": 2,
"provider": "native",
"assets": [
{ "id": "a7472...", "platform": "darwin", "imageDataUrl": "data:image/png;base64,...", "width": 64, "height": 64, "hotspotX": 26.0, "hotspotY": 16.0, "scaleFactor": 2.0 }
],
"samples": [
{ "timeMs": 0, "cx": 0.42, "cy": 0.38, "visible": true, "assetId": "a7472...", "interactionType": "move" },
...
]
}
判读标准:
provider: "native"且assets非空 → 位图捕获处于活动状态;provider: "none"且assets: []→ helper 未找到,或在发出ready之前就退出了(可结合启动日志中的falling back to position-only警告定位)。
samples 中的 cx/cy 是相对录制显示区边界的归一化坐标(0–1),由 screen.getCursorScreenPoint() 减去显示区原点再除以宽高得到,并做 clamp(0,1)。
原生 macOS 捕获后端与视频/光标分离
OpenScreen 在 macOS 上会把录制路由到 ScreenCaptureKit helper(openscreen-screencapturekit-helper),使真实系统光标不出现在视频帧内;光标的位置与位图由 cursor helper 独立捕获,最终在编辑器与导出管线中合成。这条路由的可用性规则为:
- macOS 13(Ventura)或更新版本;
openscreen-screencapturekit-helper二进制存在;- 已授予 Screen Recording 权限。
构建两个 helper 仍是 npm run build:native:mac;本地诊断自定义 cursor helper 二进制时同样使用 OPENSCREEN_MAC_CURSOR_HELPER_EXE 环境变量叠加 npm run dev。这个"视频去光标、位图单独采样、后期合成"的架构意味着两条链路可以独立调试:视频侧问题看 SCK helper 的 warning/error 事件,光标侧问题看 cursor helper 的 NDJSON 输出与 sidecar 文件。
已知限制
- Intel(x86_64)Mac:分发的 helper 构建目标是
darwin-arm64。Intel Mac 需要在目标机器上用npm run build:native:mac从源码构建(构建脚本会按process.arch选择x86_64并输出darwin-x64目录)。 - 未签名/dev 构建中的 Accessibility:
getMediaAccessStatus("accessibility")对 dev 模式下的未签名 Electron 可能不反映开关状态。helper 始终会自行探测并在ready事件中上报accessibilityTrusted——应把它当作权威信号。 - 应用自定义光标(CGS 层):
NSCursor.currentSystem捕获的是活动 AppKit 光标;某些游戏或 GPU 加速应用通过 CoreGraphics/CGS 层设置的光标在这里可能不可见,这是 macOS API 的已知限制。
小结
这条测试管线的核心思路是"分层验证":先用 standalone 冒烟命令确认 helper 的 NDJSON 协议与 ready/sample 事件,再用 sidecar 文件核对 provider、assets、interactionType 是否完整,最后按 P0→P2 清单覆盖位图真实性、SVG 亲和替换、Retina 热点对齐、点击事件、降级行为、多显示器与长录制内存。每一步的"预期值"都能直接对应到 main.swift 的采样实现与 macNativeCursorRecordingSession.ts 的解析/降级逻辑,使得任何一步失败时都能快速区分是 helper 侧(Swift 进程、权限)还是会话侧(TS 解析、路径解析)的问题。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00