本文深入解析了在macOS上实现后台计算机使用的技术原理,包括利用Accessibility API读取和操作UI,使用CGEvent.postToPid直接向后台窗口派发事件,并通过“trick”窗口激活机制(appKitDefined primer + center primer)让后台窗口进入激活状态,从而无需切换前台即可进行鼠标点击、键盘输入等操作。作者提供了开源实现参考,并指出了其他文章中的不准确之处。

@OpenAI 在 4 月 16 日发布的 codex computer use 中最引人注目的一个细节是:屏幕上同时出现了两个鼠标光标——一个由用户控制,另一个浮动的光标由 AI Agent 控制。这个 Agent 可以在后台点击窗口和输入文字,而不会打断用户当前在电脑上的任何操作。

但这个浮动光标其实只是视觉上的障眼法——从技术上讲,根本不需要真的渲染出第二个光标。真正重要的是如何实现后台点击。本文将深入剖析底层机制:从 Accessibility API 的基本能力,到使用 CGEvent 直接向后台窗口分发事件,再到最关键的一步——“欺骗”macOS 的窗口系统,让后台窗口进入激活状态。
在深入细节之前,我还想提一篇非常重要的参考文献:cua 团队的 cua driver。那篇文章发布得相当早——大约在 codex computer use 推出后一周——并在初期复现了一个类似 Codex 的后台计算机使用系统。但在过去一个月里,我收集了更多信息,也发现那篇文章中存在几处不准确的地方。一些关键的实现细节被省略了——例如,它们严重依赖 SkyLight.framework 中的私有 API,而我在测试中发现了更简单、更稳定的方法。
先快速声明一下:这种方法依赖于 macOS 窗口系统内部非常底层的行为,甚至可能依赖于某些 bug。很多事情仍是未知的,有些技巧在某些应用上有效,但在其他应用上完全失败。
所有相关的实现都已随 OpenBridge 在 kwwk-computer-use-core 中开源,包括读取后台窗口、点击窗口和键盘输入的完整逻辑。
如果你真的想尝试由这个 computer use 栈驱动的 Agent,可以加入 Bridge 的等待列表:https://bridge.surf 
当人们想到计算机使用时,第一反应通常是模拟鼠标和键盘输入:把目标窗口放到前台,然后点击它。但 macOS 其实自带了 Accessibility API(通常称为 AX)。一旦获得权限,它可以直接读取任何应用的 UI,甚至与某些控件进行交互——完全在后台进行,不需要目标应用处于最前面,甚至不需要移动真实的系统光标。
它能具体做什么:
这些动作由应用内部自行处理,因此窗口不需要成为关键窗口,光标也无需移动到那里。对于 TextEdit、Finder 和系统设置等原生应用,Agent 通常只需读取 AX 树、找到对应元素并调用其动作,就能完成整个任务链。
但 AX 并不能覆盖所有场景:
这些就是后面要讨论的窗口系统技术。对于原生应用,Accessibility 是主要机制;后面的“魔法”主要用于填补 AX 无法处理的空白。
当 AX 不够用时,下一步就是直接使用 CGEvent 将鼠标和键盘事件发送到目标窗口。核心 API 是 postToPid——它不经过全局 HID 通道,而是直接将事件分发到指定进程的事件队列中。
cua driver 文章中提到的 SLEventPostToPid 实际上并不是关键部分。在实践中,CGEvent.postToPid 已经完全足够了。
大致步骤如下:
一次左键点击最终由两个事件组成:
leftMouseDown:
location = screenPoint
button = left
clickState = 1
pressure = 1
targetPID = pid
windowUnderMouse = windowNumber
windowThatCanHandle = windowNumber
private field 51 = windowNumber
private field 58 = 1
CGEventSetWindowLocation = quartz window-local point
postToPid(pid)
30ms 延迟
leftMouseUp:
相同的目标/窗口/位置字段
pressure = 0
postToPid(pid)
具体实现见 BackgroundInputDispatcher.swift。
postToPid 可以将事件发送到后台窗口,但在许多应用处理点击之前,它们会先检查自己的窗口是否“活跃”——即它是否是关键窗口、主窗口以及焦点是否属于它。后台窗口默认不满足这些条件,因此事件可能直接被丢弃。
Chrome 和 Electron 还有一个额外的问题:当窗口不在最前面时,它们通常不会暴露完整的 AX 树,因此 Agent 甚至无法确定要点击什么。
所以我们需要稍微欺骗它们一下:让目标窗口在进程内部进入“激活”状态,而不在屏幕上将其实际前置。用户正在使用的应用必须保持最前。

截图中,Apple Music 和 Chrome 窗口左上角的“红绿灯”按钮 🚥 都亮着,这意味着两个应用都认为自己是活跃的。通常情况下,后台窗口的“红绿灯”是灰色的——这表明我们成功欺骗了 Apple Music,使其在后台运行时仍处于激活状态。
激活本质上就是“点击窗口”。
方法出奇地简单:通过 postToPid 向目标窗口发送一次点击(即“中心启动器”)。一旦应用接收到合法的鼠标按下/释放序列,它会内部执行正常的窗口激活流程——窗口成为关键窗口,开始接受输入,Chrome/Electron 此时也会暴露其 AX 树。从功能上讲,这与真实的用户点击窗口没有区别。
区别在于点击之后。正常情况下,这会导致 macOS 将目标应用带到屏幕最前方,而用户当前使用的应用会收到一个失活事件。我们想要的是前半部分——进程内部的激活——而不想要后半部分:可视的前台应用切换。
点击被发送到窗口正中心,因此不会触发任何实际的应用行为——只触发窗口激活。
这利用了 macOS 的一个行为:当一个非活跃窗口接收到第一次点击时,它通常不会立即触发实际的 UI 动作,而是先进入激活流程。点击位置理论上可以任意,但要避免左上角——因为即使在窗口非活跃时,“红绿灯”按钮仍然会响应,这可能会意外关闭或最小化应用。
应用接收到点击后,会向当前最前端的应用发送一个失活事件,并向目标应用发送一个激活事件——导致前台应用切换。解决方法是在相关进程上安装事件监听(event tap),在焦点消息送达应用之前拦截它们。因此顺序很重要:先安装监听,再发送激活点击。
实现中使用 CGEvent.tapCreateForPid——为特定 pid 附加一个每进程的事件监听,将其插入进程事件队列的头部,使事件在应用看到之前先经过回调。这与全局 HID 监听不同。在代码库中,整个逻辑封装在 BackgroundActivationSession 中。
BackgroundActivationSession.start 安装两个监听:
注册时使用 CGEventMask.max 监听所有事件类型,然后在回调中进行窄过滤。焦点消息没有稳定的公共 CGEventType 值——名称可能因 macOS 版本而异,因此唯一可靠的方法是通过原始值识别:13、19 和 20。
规则非常简单——如果焦点消息是发送给 previous 应用的,则丢弃它(返回 nil);允许目标应用的激活消息通过:
backgroundActivationEventTapCallback:
if isFocusMessage(type) && 目的地是 previous 应用:
return nil // 阻止失活
return event // 允许目标激活
安装好监听后,实际的激活分两步进行:
首先,向目标 pid 发送一个 NSEvent.otherEvent(类型为 appKitDefined,子类型 1)。根据 Apple 的公开头文件,子类型 1 对应 applicationActivated,这是一个内部的 AppKit 应用激活事件。
这个事件有以下几个关键细节:
最后,发送子类型 2(applicationDeactivated)来将目标应用返回到后台状态。Apple 没有公开记录确切的处理路径——这是通过实际测试验证的内部机制。
然后向窗口中心再发送一次 postToPid 点击——这就是前面提到的“点击一次窗口”。
如果目标应用已经处于最前端,或者已经被我们激活过,则无需再次执行激活流程。为此,我还实现了 FrontmostApplicationMonitor 来监控最前端应用的切换——无论是用户手动切换窗口还是 Agent 在后台操作,状态始终同步。具体逻辑见 BackgroundActivationSession.swift 和 ComputerUseSession.swift
在 cua driver 的文章中,他们使用私有的 SkyLight API 来实现后台激活,但我的测试表明这并不稳定。而我这套 appKitDefined 启动器 + 中心启动器的组合,在我的测试中非常可靠,并且在所有测试的应用上都有效。
通过以上步骤,我们成功欺骗了窗口,让它相信自己已被激活,从而允许点击、输入等交互完全在后台进行。
- 原文链接: x.com/bridge_surf/status...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!
作者暂未设置收款二维码