深入macOS窗口内部:SkyLight如何实现多光标后台代理

  • trycua
  • 发布于 2026-04-28 13:20
  • 阅读 12

本文深入探讨了macOS窗口管理内部机制,详细介绍了如何利用私有框架SkyLight实现后台多光标代理。作者通过逆向工程,发现了SLEventPostToPid等私有API,解决了Chrome过滤合成事件、激活窗口不提升窗口、Electron应用无障碍树暂停等问题。文章还提供了cua-driver开源驱动,支持三种捕获模式,让任何AI代理可以在不干扰用户前台操作的情况下驱动Mac应用。

发表于 2026 年 4 月 23 日,作者:Francesco Bonacci

Cua 驱动程序 —— 后台任意计算机使用代理

TL;DR:

  • cua-driver 是一个开源的 macOS 驱动程序,允许任何代理(Claude Code、Codex、你自己的适配器)在后台驱动任何 Mac 应用。
  • 用户的光标不移动,焦点不改变,macOS 不会把你拖到不同的 Space 上——这被称为后台计算机使用
  • 基于 SkyLight 的 SLEventPostToPid、用于稀疏无障碍树的私有 AX SPI 以及 yabai 的“聚焦但不提升”模式构建。
  • 源代码:github.com/trycua/cua

继 OpenAI 的 Codex 计算机使用公告 以及 Sky 团队的出色工作之后,我们深入逆向工程 macOS 窗口管理内部机制。我想告诉你我们发现了什么,以及我认为我们接下来会走向何方。

让我先回顾一下。自 2024 年以来,我目睹了很多基于 GUI 代理的产品发布并失败,主要是因为桌面输入的嵌入式特性是同步的:一个光标和一个键盘对应一个焦点窗口。

正是这个限制,让我们在 Cua 一直推荐隔离的虚拟机和 GUI 容器作为计算机代理的目标行动空间,而从未倡导用户直接在自用桌面主机上安装 computer-server 组件。

不过,几个月前我们开始尝试一种 macOS CUA 代理体验,它可以在某个应用中点击按钮,而不会劫持用户的光标、不会抢占前台、也不会把用户拖到目标窗口所在的其他 Space 上。只要谷歌一下“space focus steal macos”,你就会发现多年来用户对做这种事的应用的抱怨帖子。这可不是新抱怨。我当时放下了,觉得这应该是苹果解决的事。后来我们在 ClawCon 上推出了多人计算机使用作为这个方向的第一个原型,但输入方法之间的隔离仍然只能通过 GUI 容器实现,并借助 Xpra 屏蔽,这意味着你无法针对 macOS 应用,只能针对 Linux。

然而几天前,Codex 宣布了一种新型计算机使用,他们称之为后台计算机使用。OpenAI 和前 Sky 团队(最近被收购)把这个形态做对了:一个代理在后台驱动真实的 Mac 应用,而不会接管你的电脑。Sky 团队主要来自苹果,包括苹果快捷指令背后的那些人,所以他们可能在 macOS 未文档化的管道方面有先发优势。

后台计算机使用驱动程序应该是一个通用组件,而不是像 OAI 那样只作为某个代理产品的特性。任何适配器都应该能接入它,并且不关心另一端是哪个模型。这就是我们着手构建的东西。

我本以为这需要一个周末(确实差不多,再加上几天的管道搭建!)。我们真正需要的是一个叫做 SkyLight 的私有苹果框架:这是 WindowServer 用来驱动屏幕上每个窗口的未文档化的 C 层。

SkyLight 位于 AppKit 和 HID 事件流之间——SLEventPostToPid 绕过 IOHIDPostEvent 直接到达特定 pid,而不移动共享光标

SkyLight 是真正有趣的地方。SLEventPostToPid 将合成的事件发送到特定进程,而无需经过 HID tap。SLPSPostEventRecordTo 可以翻转窗口的 AppKit 活动状态而不提升窗口。_AXObserverAddNotificationAndCheckRemote 可以在 Electron 应用的窗口被遮挡时保持其无障碍树存活。

所有这些都没有文档记录。其中一半甚至没有出现在任何苹果头文件中。我是通过阅读 yabai 的源代码、用 lldb 探查 Chrome 的事件过滤器以及写了很多能提供有用信息的 Swift 代码(虽然会崩溃)来了解它的。

我从另一端得出的成果是 cua-driver——一个 macOS 驱动程序,允许任何代理(Claude、GPT、Codex、你自己的循环)在你继续在前台工作的同时,驱动一个真实的 Mac 应用。光标不移动。焦点不改变。macOS 不会跟着代理在 Space 间跳转。这是 v0.1 版本,处于早期预览阶段。我们现在以宽松许可证发布它,以便下一波代理体验不会受限于单一供应商。

首先,简单的方法(但行不通)

在 macOS 上阻力最小的路径是使用 CGEventPost 在你想要点击的按钮屏幕坐标处发送一个 LeftMouseDown 事件,然后发送一个 LeftMouseUp 事件。但这也会移动光标。CGEventPost 将事件放入 HID 事件流,这个流与你物理鼠标使用的流相同。WindowServer 在 (342, 198) 处看到点击,作为副作用将指针更新到 (342, 198),因为这正是你点击某处时会发生的事情。

下一次尝试:CGEvent.postToPid。同样的事件,但传递给特定进程而不是全局 HID 流。没有光标跳动。效果很好。除了 Chrome 之外,其他所有东西都很好。Chrome 在渲染器 IPC 边界处过滤合成事件。如果你的点击事件没有携带 HID 管道通常附加到真实用户手势上的遥测信息(一个特定的 mouseEventSubtype 字节、一个 clickState 计数器、一个由私有 SPI 设置的窗口局部坐标戳),渲染器会将其视为不可信并静默丢弃。你的点击到达外部窗口进程,然后消失。你可以用 lldb 在渲染器 IPC 边界处验证,或者通过观察页面没有响应来验证。无论哪种方式:如果不能匹配真实鼠标事件携带的信息,Chrome 就行不通。

第三次尝试:先激活目标,发送一个常规的 HID tap 事件,再停用。这能行。这正是每个计算机使用工具所采用的方法,也恰恰是我想要避免的行为。提升窗口会让 Space 跟着它移动,把你的焦点移回代理,这正是我们想要避免的。

yabai,以及如何在不提升窗口的情况下激活窗口

这时我开始寻找已经解决了部分问题的人。最好的现有成果在 yabai 中,这是一个适用于 macOS 的平铺窗口管理器,它必须在不提升窗口的情况下激活窗口(否则在多个 Space 的设置中会无法使用)。它的 window_manager_focus_window_without_raise 函数大约有四十行 C 代码,并明确记录了其功能:翻转目标应用的 AppKit 活动状态,而不调用 SLPSSetFrontProcessWithOptions,后者会将窗口前置并触发 macOS 的“将应用与窗口一同切换 Space”的行为。

AppKit 激活分为两步。第一步告诉应用“你现在是输入路由的焦点应用”(通过 SLPSPostEventRecordTo 进行内部状态翻转)。第二步告诉 WindowServer“请提升窗口并将其重新父级到当前 Space” (SLPSSetFrontProcessWithOptions)。你可以只做第一步而不做第二步。yabai 已经这样做了很多年。

我们复制了这个模式。两次 SLPSPostEventRecordTo 调用,一次针对之前最前进程的 PSN,一次针对目标的 PSN,都使用特定的事件记录类型。之后目标就处于 AppKit 活动状态以便事件路由,但其窗口仍然在 z 堆栈中的原来位置。发送一个 CGEvent,它实际上会进入正确的事件循环!

使用 SkyLight.framework 发送点击

yabai 的模式让我解决了路由问题,但仅仅 CGEvent.postToPid 仍然无法到达 Chromium 的网页内容。AppKit 分发器和渲染器之间的某些东西正在过滤事件,任何“聚焦但不提升”的技巧都无法改变这一点。

缺失的部分在 SkyLight.framework 中:SLEventPostToPid。从调用方来看,签名与 CGEvent.postToPid 相同,但事件通过不同的代码路径传输。这是一个经过认证的 SkyLight 通道,完全绕过 IOHIDPostEvent。出于我不完全理解的原因,Chrome 的渲染器过滤器会接受通过此通道到达的事件,而拒绝未通过此通道的事件。我最好的猜测是 SLEventPostToPid 在事件记录中标记了某些内容,表明它“源自 WindowServer 信任信封”,而 Chrome 的过滤器会读取该位。如果有人知道确切检查内容,请写出来。

我通过搜索 SkyLight 的符号导出找到了这个函数。它没有出现在任何苹果头文件中。我编写的包装器是二十行 Swift 代码;函数指针查找是通过 dlopen + dlsym 针对 /System/Library/PrivateFrameworks/SkyLight.framework 进行的。

预点击

两次点击:一次渲染器丢弃,一次信任——(-1, -1) 处的诱饵点击触发 Chromium 的用户激活门,使真正的点击作为可信的延续落地

即使使用了 SkyLight,还有一件事会出问题:Chromium 的用户激活门。如果事件通过正确的信任信封到达,但渲染器最近没有看到“可信用户手势”,该门就会拒绝让点击激活诸如视频播放/暂停、window.open 或全屏 API 等功能。

解决办法是诱饵点击。在 (-1, -1) 处进行一次 LeftMouseDown / LeftMouseUp 对,这是一个超出屏幕上所有窗口的像素坐标。Chromium 会丢弃该点击,因为没有窗口认领该坐标,但用户激活门仍然会向前计数。几毫秒后跟随的真实点击被视为该手势的可信延续。

这是整个过程中最难找到的部分。用户激活门在 Chromium 面向网页方面几乎没有文档记录,更不用说原生命中测试路径了,而且 (-1, -1) 处的离屏诱饵会起作用这一点你根本猜不到。我是通过阅读 content/browser/renderer_host 源代码直到眼睛疼,然后尝试各种方法才找到的。

键盘操作比预期的简单

键盘操作是个简单的事情。CGEvent.postToPid 就够了,不需要 SkyLight。限定于特定 pid 的按键会进入该应用的事件队列,而不会到其他地方。这样做安全的原因是 macOS 没有一个等同于 Chromium 渲染器鼠标过滤器的全局按键过滤器。应用会接收到达其事件循环的任何按键事件。

大多数应用是 Electron……

要使后台故事成立,还有一件事必须可行:Electron 应用上的无障碍树。

Chrome、Slack、VS Code、Discord、Notion 以及基本上所有基于 Electron 构建的东西都有一个怪癖。当应用窗口被遮挡时,它们的无障碍树更新会暂停,因为 Blink 的无障碍代码在认为没有人在观察时会短路。公共 AXObserverAddNotification 不会将观察者标记为“远程感知”,所以 Blink 永远不知道有人在监听。但另一个私有的 _AXObserverAddNotificationAndCheckRemote 变体会这样做。只需一次 dlsym 调用,AX 树就会在整个启动-截图-动作-验证循环期间保持活动状态,即使目标被隐藏、位于其他应用后面或在其他 Space 上。

我很想告诉你我是通过阅读博客文章找到这个的,但事实并非如此。我是通过比较无障碍检查器在检查一个被遮挡的 Electron 窗口时所做的事情和我自己的包装器所做的事情,注意到检查器的路径触及了一个我们没有的额外符号而找到的。

Cua-Driver 附带三种模态

有了后台运行的点击、按键和 AX 树,客户端仍然需要做出一个路由决策:客户端需要推理什么来决定点击哪里?

cua-driver 暴露了三种捕获模式。

ax

只返回一个简化的 AX 树,以 Markdown 大纲形式呈现,每个可操作节点都由一个索引标记。ax 不需要任何屏幕截图或屏幕录制权限。它最适合系统应用(例如计算器、备忘录、iMessage 等)或使用 AppKit、SwiftUI 开发的应用,这些应用的无障碍树能很好地代表用户看到的内容。

vision

只返回目标窗口的 PNG 图像。最适合以视觉为主的 VLM,它们基于像素进行推理,不使用 element_index。负载最小,速度最快,但代理必须自己完成所有空间推理。注意:仅视觉模式对于像 Claude Code 这样的编码代理仍然不稳定(出于某些原因,Anthropic 在上传图像到其端点时不包含计算机使用测试版字段)。

som

(默认,set-of-mark)同时返回 AX 树和截图。树告诉代理什么可点击,截图在标签重复或为空时进行区分。这是默认模式,因为它让元素索引点击(驱动程序的主要寻址模式)能在第一次快照时工作,同时免费提供视觉确认。在 cua-driver 底层,元素索引点击(click({pid, window_id, element_index}))是主要寻址模式。它们直接触发底层的 AX 动作,能在隐藏和遮挡的目标上工作,且不涉及坐标。像素点击(click({pid, x, y}))是画布、WebGL 和其他非 AX 表面的后备方案,使用上面提到的 SkyLight 方法。

我一直在用它做什么

四件事,它们之所以能工作,完全是因为驱动程序像我的机器上的第二个光标,而不是试图取代第一个光标。

1. 使用真实代理进行开发循环 QA。

<video src="https://github.com/user-attachments/assets/c81cac3c-4693-408d-bb9e-870e6a337db0" controls></video>

一个代理适配器运行复现-修复-验证循环,同时我继续在编辑器中打字。Claude Code 通过 cua-driver 驱动目标应用,读取像素,读取 AX 树,编辑源代码,重新构建,检查截图。你的代理适配器永远不会失去焦点——你的滚动位置也永远不会改变。我发现修复生效了,因为代理告诉了我。

2. 我本来会忘记的消息。

<video src="https://github.com/user-attachments/assets/c4c27bd7-3e96-429a-868e-0485e89e70c6" controls></video>

轻量级个人助理工作。发送消息、查看日历、从邮件中提取追踪号码。代理已经能够发送 iMessage 一年了,但有趣的属性是,你正在读的屏幕在发生这些事情时不会改变。

3. 从我正在查看的应用中提取视觉上下文。

<video src="https://github.com/user-attachments/assets/9c7db52b-63b3-42b0-bcd3-d29659048d84" controls></video>

Claude Code 读取 Figma 画布上的内容、预览窗口中的内容、YouTube 页面上的内容,而无需将其中任何一个带到前台。后台像素点击方法能让 YouTube 的全屏切换生效在一个我们从未提升过的窗口上。

4. 委托演示录制。

<video src="https://github.com/user-attachments/assets/d5b80bad-bd4f-4690-9512-7b4a7cd44f13" controls></video>

想象一下让一个代理为你录制产品演示视频。代理驱动正在演示的应用,录制轨迹,cua-driver 渲染器在导出时在每次点击处缩放。由于点击是后台进行的,驱动程序绘制的光标是最终视频中唯一的光标。

仍然存在的问题

SkyLight 方法无法解决两件事。

Chromium 将网页内容上的合成右键转换为左键。 渲染器 IPC 过滤器在非 HID tap 路径上丢弃了右键子类型。通过 AX 进行元素索引右键点击对于 AX 可寻址目标(链接、按钮、工具栏项)工作正常。纯网页内容只能进行左键点击。我看不到绕过这个问题的办法,除非发布一个浏览器扩展,但这会破坏即插即用的驱动程序设计。

画布应用(Blender GHOST、Unity、游戏)完全过滤按 pid 的路由。 它们的事件循环只接受来自 cghidEventTap 且带有前导 mouseMoved 的事件,这意味着它们需要短暂的前台激活。cua-driver 回退到在这些应用之前激活它们。对于这一类别,“不窃取前台”的承诺被打破了。如果你在自动化 Blender,光标会跳动。抱歉。

安装

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh)"

这会将 CuaDriver.app 放入 /Applications,将 cua-driver CLI 软链接到 /usr/local/bin,并安装每周自动更新程序。在系统设置 → 隐私与安全性中,授予 CuaDriver.app 无障碍和屏幕录制权限一次,即可准备就绪。

接入 Claude Code、Cursor 或任何 MCP 客户端。 将此内容粘贴到客户端的 MCP 配置中:

{
  "mcpServers": {
    "cua-driver": {
      "command": "/Applications/CuaDriver.app/Contents/MacOS/cua-driver",
      "args": ["mcp"]
    }
  }
}

或从 shell 驱动。 每个 MCP 工具都是一个顶层 cua-driver &lt;名称> 子命令。例如:

cua-driver list_apps
cua-driver launch_app '{"bundle_id":"com.apple.calculator"}'
cua-driver click '{"pid":1234,"window_id":5678,"element_index":14}'

源代码和问题请访问 github.com/trycua/cua。我特别想知道你最终是如何使用它的。最奇怪的用例正是我们还没有想到的那些。

  • 原文链接: github.com/trycua/cua/bl...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
trycua
trycua
江湖只有他的大名,没有他的介绍。