macOS 双光标后台操作技术解析

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

Image

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

Image

但这个浮动光标其实只是视觉上的障眼法——从技术上讲,根本不需要真的渲染出第二个光标。真正重要的是如何实现后台点击。本文将深入剖析底层机制:从 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 Image

Accessibility 作为主要工具——但还不够

当人们想到计算机使用时,第一反应通常是模拟鼠标和键盘输入:把目标窗口放到前台,然后点击它。但 macOS 其实自带了 Accessibility API(通常称为 AX)。一旦获得权限,它可以直接读取任何应用的 UI,甚至与某些控件进行交互——完全在后台进行,不需要目标应用处于最前面,甚至不需要移动真实的系统光标。

它能具体做什么:

  • 读取:枚举窗口、遍历 AX 树、获取属性如角色、标题、位置和值
  • 写入:原生 AppKit 控件可以直接调用动作——按钮通过 AXPress,文本字段通过 setValue,滚动条通过 AXIncrement / AXDecrement

这些动作由应用内部自行处理,因此窗口不需要成为关键窗口,光标也无需移动到那里。对于 TextEdit、Finder 和系统设置等原生应用,Agent 通常只需读取 AX 树、找到对应元素并调用其动作,就能完成整个任务链。

但 AX 并不能覆盖所有场景:

  • 在后台运行的 Chrome 和 Electron 应用中,AX 树常常不完整,AXPress 也不可靠,因此需要退回到模拟鼠标输入
  • 逐字符的键盘输入仍然依赖于通过 postToPid 发送按键事件

这些就是后面要讨论的窗口系统技术。对于原生应用,Accessibility 是主要机制;后面的“魔法”主要用于填补 AX 无法处理的空白。

使用 CGEvent.postToPid 直接向窗口分发事件

当 AX 不够用时,下一步就是直接使用 CGEvent 将鼠标和键盘事件发送到目标窗口。核心 API 是 postToPid——它不经过全局 HID 通道,而是直接将事件分发到指定进程的事件队列中。

cua driver 文章中提到的 SLEventPostToPid 实际上并不是关键部分。在实践中,CGEvent.postToPid 已经完全足够了。

大致步骤如下:

  • 获取目标 pid、窗口编号(windowNumber)和控件坐标
  • 构建鼠标/键盘事件,填充 eventTargetUnixProcessID 和与窗口相关的字段,以便系统知道事件应传递给哪个窗口
  • 调用 postToPid 分发事件;一次点击由两个事件组成:按下(down)和释放(up)

一次左键点击最终由两个事件组成:

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 甚至无法确定要点击什么。

所以我们需要稍微欺骗它们一下:让目标窗口在进程内部进入“激活”状态,而不在屏幕上将其实际前置。用户正在使用的应用必须保持最前。

Image

截图中,Apple Music 和 Chrome 窗口左上角的“红绿灯”按钮 🚥 都亮着,这意味着两个应用都认为自己是活跃的。通常情况下,后台窗口的“红绿灯”是灰色的——这表明我们成功欺骗了 Apple Music,使其在后台运行时仍处于激活状态。

激活本质上就是“点击窗口”。

方法出奇地简单:通过 postToPid 向目标窗口发送一次点击(即“中心启动器”)。一旦应用接收到合法的鼠标按下/释放序列,它会内部执行正常的窗口激活流程——窗口成为关键窗口,开始接受输入,Chrome/Electron 此时也会暴露其 AX 树。从功能上讲,这与真实的用户点击窗口没有区别。

区别在于点击之后。正常情况下,这会导致 macOS 将目标应用带到屏幕最前方,而用户当前使用的应用会收到一个失活事件。我们想要的是前半部分——进程内部的激活——而不想要后半部分:可视的前台应用切换。

点击被发送到窗口正中心,因此不会触发任何实际的应用行为——只触发窗口激活。

这利用了 macOS 的一个行为:当一个非活跃窗口接收到第一次点击时,它通常不会立即触发实际的 UI 动作,而是先进入激活流程。点击位置理论上可以任意,但要避免左上角——因为即使在窗口非活跃时,“红绿灯”按钮仍然会响应,这可能会意外关闭或最小化应用。

如何拦截焦点消息

应用接收到点击后,会向当前最前端的应用发送一个失活事件,并向目标应用发送一个激活事件——导致前台应用切换。解决方法是在相关进程上安装事件监听(event tap),在焦点消息送达应用之前拦截它们。因此顺序很重要:先安装监听,再发送激活点击。

实现中使用 CGEvent.tapCreateForPid——为特定 pid 附加一个每进程的事件监听,将其插入进程事件队列的头部,使事件在应用看到之前先经过回调。这与全局 HID 监听不同。在代码库中,整个逻辑封装在 BackgroundActivationSession 中。

BackgroundActivationSession.start 安装两个监听:

  • previous:当前最前端应用的 pid(用户正在使用的应用)
  • target:Agent 想要操作的后台应用的 pid

注册时使用 CGEventMask.max 监听所有事件类型,然后在回调中进行窄过滤。焦点消息没有稳定的公共 CGEventType 值——名称可能因 macOS 版本而异,因此唯一可靠的方法是通过原始值识别:13、19 和 20。

规则非常简单——如果焦点消息是发送给 previous 应用的,则丢弃它(返回 nil);允许目标应用的激活消息通过:

backgroundActivationEventTapCallback:
  if isFocusMessage(type) && 目的地是 previous 应用:
    return nil          // 阻止失活
  return event          // 允许目标激活

安装好监听后,实际的激活分两步进行:

步骤 1:appKitDefined 启动器

首先,向目标 pid 发送一个 NSEvent.otherEvent(类型为 appKitDefined,子类型 1)。根据 Apple 的公开头文件,子类型 1 对应 applicationActivated,这是一个内部的 AppKit 应用激活事件。

这个事件有以下几个关键细节:

  • 它通过 postToPid 直接送入目标进程的事件队列,绕过了 WindowServer 的正常前台路由
  • 事件携带了 windowNumber,并写入了 field 51/58(setWindowAddressingFields),使 AppKit 知道该事件与哪个窗口关联
  • 从功能上讲,它相当于提前告诉目标应用:“你现在应该进入激活状态”,为后面的中心启动器做准备

最后,发送子类型 2(applicationDeactivated)来将目标应用返回到后台状态。Apple 没有公开记录确切的处理路径——这是通过实际测试验证的内部机制。

步骤 2:中心启动器

然后向窗口中心再发送一次 postToPid 点击——这就是前面提到的“点击一次窗口”。

完整操作流程

  • 创建 BackgroundActivationSession,首先安装事件监听
  • activateWindow:appKitDefined 启动器 + 中心启动器
  • 执行实际的点击 / 输入 / 滚动操作
  • 保持监听运行直到会话结束,防止后续操作再次抢夺焦点

如果目标应用已经处于最前端,或者已经被我们激活过,则无需再次执行激活流程。为此,我还实现了 FrontmostApplicationMonitor 来监控最前端应用的切换——无论是用户手动切换窗口还是 Agent 在后台操作,状态始终同步。具体逻辑见 BackgroundActivationSession.swiftComputerUseSession.swift

在 cua driver 的文章中,他们使用私有的 SkyLight API 来实现后台激活,但我的测试表明这并不稳定。而我这套 appKitDefined 启动器 + 中心启动器的组合,在我的测试中非常可靠,并且在所有测试的应用上都有效。

通过以上步骤,我们成功欺骗了窗口,让它相信自己已被激活,从而允许点击、输入等交互完全在后台进行。

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

0 条评论

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