深入 macOS 窗口内部机制:cua-driver 如何用 SkyLight 实现后台多光标 Agent
【免费下载链接】cuaScale computer-use 2.0 with open-source drivers, cross-OS fleets, and benchmarks for training, evaluation, and data generation.项目地址: https://gitcode.com/GitHub_Trending/cua/cua
macOS 桌面输入天然是"单光标、单键盘、单聚焦窗口"的同步模型,这让 GUI Agent 在驱动真实 Mac 应用时总是被迫抢占用户的前台焦点。本文基于 cua-driver 开源驱动(本仓库 libs/cua-driver)对 macOS 窗口管理内部机制的逆向工程,完整讲解 SkyLight 私有框架中的SLEventPostToPid、SLPSPostEventRecordTo与_AXObserverAddNotificationAndCheckRemote三个关键 SPI,以及 Chromium 用户激活门(user-activation gate)的破解配方。读完你将理解后台 computer-use 的完整技术栈,并能在自己的 Agent 框架中接入 cua-driver 的三种采集模式与 element-indexed 点击。
为什么需要"后台 computer-use"驱动
自 2024 年以来,大量 GUI Agent 产品失败的核心原因在于桌面输入的嵌入式本质:一个光标、一个键盘、只能服务一个聚焦窗口。一旦 Agent 要点击某个应用,就必须先激活该窗口,于是用户的光标被劫持、前台被抢占、macOS 还会把用户"拖"到目标窗口所在的 Space——这是多年来被反复吐槽的"space focus steal"问题。
这正是 Cua 团队此前一直推荐隔离 VM 与 GUI 容器作为 Agent 动作空间的根本原因,而不是让用户把 computer-server 直接装在自己的桌面主机上。直到 Codex 与 Sky 团队推出"背景 computer-use"(agent 在后台驱动真实 Mac 应用、不接管你的电脑)的形态,后台驱动才成为可能。cua-driver 的目标是让这种能力成为通用组件而非某个单一 Agent 产品的专属特性:任何 harness 都可以直接接入,对模型选择无任何意见。
三层"简单方案"的失败路径
第一层:CGEventPost全局投递——会移动光标
最省事的做法是对着按钮的屏幕坐标CGEventPost一个LeftMouseDown/LeftMouseUp。但CGEventPost把事件丢进 HID 事件流——这正是物理鼠标所在的流。WindowServer 看到一个位于(342, 198)的点击,会顺带把指针更新到该坐标。副作用就是用户的光标被移动。
第二层:CGEvent.postToPid——Chrome 过滤合成事件
CGEvent.postToPid把同样的事件直接投递给指定进程而不是全局 HID 流,不会引起光标跳变,对绝大多数应用都好用——除了 Chrome。Chromium 在 renderer IPC 边界过滤合成事件:如果点击没有携带真实用户手势通常附加的遥测信息(特定的mouseEventSubtype字节、clickState计数器、由私有 SPI 设置的窗口局部坐标戳),renderer 会将其视为不可信事件并静默丢弃。点击到达外层窗口进程后凭空消失。
第三层:先激活目标再发 HID 事件——正是要避免的行为
先激活目标窗口、发送普通 HID 事件、再失活——能工作,但抬升窗口会触发 Space 跟随,把用户的焦点拉回 Agent 身上。这正是所有传统计算机使用工具的做法,也恰恰是想要避免的行为。
yabai 的 focus-without-raise 模式
macOS 的 AppKit 激活实际上是两步操作:
- 输入路由激活:通过内部状态翻转(
SLPSPostEventRecordTo)告诉应用"你现在是输入路由的焦点应用"; - 窗口抬升:通过
SLPSSetFrontProcessWithOptions告诉 WindowServer"请把窗口抬升并重新挂载到当前 Space"。
关键在于第一步可以不伴随第二步。yabai(macOS 平铺窗口管理器)在多年实践中必须"激活窗口而不抬升窗口",否则在多 Space 场景下根本无法使用。它的window_manager_focus_window_without_raise函数约四十行 C 代码,正是这个模式的教科书实现。
cua-driver 在 skylight.rs 中完整移植了该配方:activate_without_raise(target_pid, target_wid)首先用_SLPSGetFrontProcess捕获当前前台 PSN,再用SLSGetWindowOwner + SLSGetConnectionPSN(老系统回退GetProcessForPID)解析目标 PSN,然后投递两个 248 字节的合成事件记录:
- 第一个记录发给原前台 PSN,
bytes[0x8A] = 0x02标记为失焦(defocus); - 第二个记录发给目标 PSN,
bytes[0x8A] = 0x01标记为聚焦(focus),并把目标窗口 ID 以小端序写入bytes[0x3C..0x3F]。
整个缓冲区构造在源码注释中有精确的字节级说明(bytes[0x04]=0xf8、bytes[0x08]=0x0d等)。执行之后,目标应用进入 AppKit-active 状态,事件路由生效,但窗口仍停留在 z-stack 原位置。此时再发送 CGEvent 就能进入正确的事件循环,且用户完全无感。
值得注意的是,源码注释特别强调:刻意跳过SLPSSetFrontProcessWithOptions是保持 Chromium 用户激活门处于开启状态的关键之一(skylight.rs)。仓库中还提供了配套的 Space 查询工具集(CGSGetActiveSpace、SLSCopySpacesForWindows、SLSCopyManagedDisplayForWindow),用于判断目标窗口落在哪个 Space,确保后台交互始终不改变用户的桌面布局。
SkyLight.framework 与SLEventPostToPid
yabai 模式解决了事件路由,但CGEvent.postToPid单独仍无法到达 Chromium web 内容。真正的缺失拼图位于SkyLight.framework:SLEventPostToPid。
从调用方视角,它和CGEvent.postToPid签名相同,但事件走的是完全不同的代码路径——一个绕过IOHIDPostEvent的 auth-signed SkyLight 通道。cua-driver 源码 skylight.rs 注释给出了更精确的两层故事:
- 投递路径:
SLEventPostToPid→SLEventPostToPSN→CGSTickleActivityMonitor→SLSUpdateSystemActivityWithLocation→IOHIDPostEvent。公开的CGEventPostToPid跳过了 activity-monitor tickle,这正是 Chromium/Catalyst 目标不接受那些事件作为实时输入的原因; - 认证(仅键盘):macOS 14+ 上 WindowServer 对发往 Chromium 类目标的合成键盘事件要求附加
SLSEventAuthenticationMessage,通过 ObjC 工厂构建并调用SLEventSetAuthenticationMessage附加后再投递。
Chromium 的 renderer 过滤器接受经此通道到达的事件而拒绝其他事件。一个合理的推断是SLEventPostToPid在事件记录中盖上了某种标记,表明事件"源自 WindowServer 信任信封"(trust envelope),Chromium 的过滤器读取该位。原文档作者也明确表示这仍是推测,欢迎知情者给出精确答案。
函数本身不出现在任何 Apple 头文件中,通过 grep SkyLight 的符号导出找到。cua-driver 的实现方式(skylight.rs)是:先用dlopen加载/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight(RTLD_LAZY | RTLD_GLOBAL),再通过dlsym解析全部所需符号,所有句柄都用OnceLock惰性解析一次;任何符号解析失败时函数返回false,调用方自动回退到公开 APICGEvent::post_to_pid。is_available()与is_focus_without_raise_available()两个探针函数让上层可以在运行时检测当前 macOS 版本的能力。
Primer Click:攻克 Chromium 用户激活门
即便事件通过 SkyLight 信任通道到达,Chromium 还有一道用户激活门(user-activation gate):如果 renderer 最近没有看到"受信任的用户手势",它会拒绝让点击激活视频播放/暂停、window.open、全屏 API 等能力。
解决方案是一个诱饵点击(decoy click / primer click):在(-1, -1)——屏幕上没有任何窗口覆盖的像素坐标——投递一对LeftMouseDown/LeftMouseUp。Chromium 因为没有窗口认领该坐标而丢弃这次点击,但用户激活门仍然向前推进。几毫秒后到达的真实点击被当作该手势的"受信任延续"。
这是整个逆向过程中最难找到的一环:用户激活门在 Chromium 面向 Web 的一侧都几乎无文档,更不用说原生命中测试路径。cua-driver 的 Rust 实现 mouse.rs 中的click_at_xy_chromium函数把整个 Chromium 兼容左键配方完整落地为三步事件流:
mouseMovedprimer:在目标坐标发送带phase=2标记的鼠标移动事件,先激活目标窗口的 cursor-tracking 状态(后台 AppKit 控件如果从未收到 move 事件,mouseDown会被静默忽略);- 屏外 primer 点击:在
(-1,-1)发送phase=1/2的 down/up 对,满足 Chromium 用户激活门且不命中任何 DOM 元素,之后等待约 100ms 让 primer 与真实点击构成独立手势; - 目标点击对:在真实坐标发送
phase=3的 down/up 对,clickState从 1 递增到 N 以支持双击合并。
每个事件都通过set_integer_field盖上一组关键字段(源码注释逐字段说明):f0=手势阶段、f1=clickState、f3=按钮编号(0=左键)、f7=3(NSEventSubtypeTouch)、f40=目标 pid(Chromium 合成事件过滤器)、f51/f91/f92=CGWindowID(窗口路由)、f58=恒定 click-group ID(手势合并),并通过CGEventSetWindowLocation盖上窗口局部坐标点。(-1,-1)这个坐标在源码中以off_screen/off_local常量直接出现,是整个配方的"签名"。
键盘输入:比预想简单,但 macOS 14+ 需要认证信封
键盘是"无聊的故事":CGEvent.postToPid就足够了,不需要 SkyLight。按 pid 作用域的击键只落入该应用的事件队列,不会去任何别处。原因在于 macOS 没有与 Chromium renderer 鼠标过滤器对等的全局击键过滤器——应用会照单全收到达其事件循环的按键事件。
不过 cua-driver 的实现比文档走得更远:在 macOS 14(Sonoma)及以上,发往 Chromium 类目标的合成键盘事件需要SLSEventAuthenticationMessage信封。源码 skylight.rs 中有完整的兼容性处理:通过objc_getClass+sel_registerName+objc_msgSend调用+[SLSEventAuthenticationMessage messageWithEventRecord:pid:version:]构建消息,再用SLEventSetAuthenticationMessage附加到事件上。关键细节是必须用class_respondsToSelector验证选择器存在——该工厂方法只在 macOS 15(Sequoia)才加入,macOS 14 上仅凭sel_registerName成功不能保证可用,否则运行时直接抛NSInvalidArgumentException: unrecognized selector(源码注释关联 issue #1503)。
另一个有意思的权衡出现在 keyboard.rs:hotkey_no_auth(不带认证信封)专门用于 NSMenu 键盘等效键——带信封时SLEventPostToPid会分叉到绕过IOHIDPostEvent的 direct-Mach 路径,NSMenu 根本看不到那些事件;不带信封时路径经过IOHIDPostEvent,NSApplication.sendEvent:才能分派 NSMenu 键盘等效键。此外type_text对每个字符强制set_flags(CGEventFlagNull),因为 Chrome 会检查 flags 字段推断修饰键状态,否则大写字符会被视为 Shift+e 并让修饰键泄漏到下一个字符(keyboard.rs)。
Electron 应用的 AX 树保活
后台化场景还有最后一个必须解决的问题:Electron 应用(Chrome、Slack、VS Code、Discord、Notion 等)的辅助功能树在窗口被遮挡时暂停更新——Blink 的 accessibility 代码在认为"没有人在看"时会短路。公开的AXObserverAddNotification不会把观察者标记为"remote-aware",Blink 因此永远不知道有人在监听。
而另一个私有变体_AXObserverAddNotificationAndCheckRemote会。一次dlsym调用即可让 AX 树在完整的 launch-snapshot-act-verify 循环中保持活跃,即使目标窗口被隐藏、被其他应用挡住或位于其他 Space。作者发现它的方式颇具工程趣味:对比 Accessibility Inspector 在检查被遮挡的 Electron 窗口时的行为与自己封装的差异,注意到 Inspector 的路径多触碰了一个自己缺失的符号。当前仓库的 Rust 实现中该能力由dlsym惰性探测,具体符号解析失败时驱动会回退到公开的 AX API 路径。
三种采集模式与两种点击寻址
点击、击键、AX 树都在后台工作后,仍有一个路由决策留给客户端:决定点什么时,客户端需要基于什么来推理?cua-driver 提供三种捕获模式:
| 模式 | 返回内容 | 适用场景 | 特点 |
|---|---|---|---|
ax | 简化 AX 树(Markdown 大纲),每个可交互节点带索引 | 系统应用(计算器、备忘录、iMessage)或 AppKit/SwiftUI 应用 | 无需屏幕捕获、无需屏幕录制权限,AX 树能很好表征用户所见 |
vision | 目标窗口的 PNG 截图 | vision-first VLM,基于像素 grounding、不使用 element_index | 负载最小、最快,但 Agent 需自己做全部空间推理;对 Claude Code 等编码 Agent 仍不稳定 |
som(默认) | AX 树 + 截图 | 通用场景 | 树告诉 Agent 什么可点,截图在标签重复或为空时消歧 |
som是默认模式,因为它能让 element-indexed 点击(driver 的主要寻址方式)在第一次快照上就工作,并免费附带视觉确认。cua-driver 的寻址体系(结合 SKILL.md 的最新约定)分为两层:
- 元素寻址(ax):
click({pid, window_id, element_index})直接触发底层 AX action,可作用于隐藏/被遮挡目标,完全不涉及坐标; - 像素寻址(px):
click({pid, x, y})作为 canvas、WebGL 等非 AX 表面的回退方案,使用上述 SkyLight 配方。
值得注意的演进:SKILL.md 中标注capture_mode参数已废弃并被忽略——决策点从"捕获时"移到了"动作时",动作时根据目标选择element_token(ax)或x,y(px),感知始终是两者的结合。
真实用例
以下四个场景只有驱动行为像"机器上的第二个光标"(而非试图替代第一个光标)时才能成立:
- 开发循环 QA:Agent harness 在后台跑 repro-fix-verify 循环,用户继续在编辑器打字。Claude Code 通过 cua-driver 驱动目标应用、读像素、读 AX 树、改源码、重建、核对截图——用户的 Agent harness 永不失焦,滚动位置不变;
- 容易被遗忘的消息:轻量个人助理工作(发消息、查日历、从邮件取快递单号)。有趣的性质不是"能发 iMessage",而是"发生时你正在读的屏幕纹丝不动";
- 从没在看的应用拉取视觉上下文:Claude Code 读取 Figma 画布、Preview 窗口、YouTube 页面内容而不前置任何窗口。后台像素点击配方让 YouTube 全屏切换落在从未抬升的窗口上;
- 委托演示录制:让 Agent 录制产品演示视频。Agent 驱动被演示的应用、记录轨迹,cua-driver renderer 在导出时放大每次点击。因为点击是后台化的,最终视频里唯一的鼠标就是 driver 绘制的那个。
已知限制:SkyLight 配方解决不了的两件事
- Chromium 会把合成右键强制转为左键。renderer-IPC 过滤器在非 HID-tap 路径上丢弃右键 subtype。通过 AX 的 element-indexed 右键对 AX 可寻址目标(链接、按钮、工具栏项)工作正常,但纯 web 内容只能左键。作者认为除非内置浏览器扩展(违背 drop-in driver 设计),否则无解;
- Canvas 应用(Blender GHOST、Unity、游戏)会整体过滤 per-pid 路由。它们的事件循环只接受来自
cghidEventTap且带前置mouseMoved的事件,因此需要短暂的前台激活。cua-driver 会对这类应用回退到激活后再点击——"不抢占前台"的承诺在这一类上被打破。如果要自动化 Blender,光标会跳变。
安装与接入
在 macOS 上安装 cua-driver(即本仓库 libs/cua-driver 中 scripts/install.sh 对应的安装脚本):
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh)"脚本会把CuaDriver.app放入/Applications,将cua-driverCLI 软链到/usr/local/bin,并安装每周自动更新器。然后在 系统设置 → 隐私与安全性 中为CuaDriver.app授予一次**辅助功能(Accessibility)与屏幕录制(Screen Recording)**权限即可使用。
接入 Claude Code、Cursor 或任意 MCP 客户端——将以下配置粘贴进客户端的 MCP 配置:
{ "mcpServers": { "cua-driver": { "command": "/Applications/CuaDriver.app/Contents/MacOS/cua-driver", "args": ["mcp"] } } }或直接从 shell 驱动——每个 MCP 工具都是顶层cua-driver <name>子命令:
cua-driver list_apps cua-driver launch_app '{"bundle_id":"com.apple.calculator"}' cua-driver click '{"pid":1234,"window_id":5678,"element_index":14}'总结
cua-driver 证明了"后台 computer-use"不需要依赖单一厂商:通过SLEventPostToPid的信任通道、SLPSPostEventRecordTo的 focus-without-raise 模式、(-1,-1)诱饵点击对 Chromium 用户激活门的破解、以及_AXObserverAddNotificationAndCheckRemote对 Electron AX 树的保活,任何 Agent harness 都可以在用户继续工作的同时驱动真实的 Mac 应用。cua-driver 仍处于 v0.1 早期预览阶段,以宽松许可发布,其完整实现细节可继续阅读 skylight.rs、mouse.rs 与 keyboard.rs,动作契约与寻址约定见 SKILL.md 和 协议定义。
【免费下载链接】cuaScale computer-use 2.0 with open-source drivers, cross-OS fleets, and benchmarks for training, evaluation, and data generation.项目地址: https://gitcode.com/GitHub_Trending/cua/cua
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考