@bridge_surf: https://x.com/bridge_surf/status/2057416247319618039

X AI KOLs Timeline 新闻

摘要

技术解析:macOS 如何通过 Accessibility API 和底层 CGEvent 分发机制支持两个同时存在的光标(用户与 AI 代理),从而实现后台计算机操作而不中断前台任务。

https://t.co/iogcE5aNzc
查看原文
查看缓存全文

缓存时间: 2026/05/21 17:38

macOS 可以同时支持两个光标 —— Computer Use 2.0

@OpenAI 在 4 月 16 日发布 Codex Computer Use 时,最令人着迷的场景之一是:屏幕上同时出现了两个鼠标光标 —— 一个由用户控制,另一个浮动光标由 AI Agent 控制。Agent 可以在后台点击窗口、输入内容,而完全不打断用户当前对电脑的操作。

两个光标同时存在。

两个光标同时存在。

不过,那个浮动的光标其实只是一种视觉表演 —— 从技术上讲,根本不需要真正渲染第二个光标。真正关键的是如何让后台点击生效。本文拆解背后的机制:从 Accessibility API 的基础能力,到用 CGEvent 直接向后台窗口派发事件,再到最关键的一步 —— “欺骗” macOS 的窗口系统,让后台窗口进入激活状态。

在深入细节之前,我还想提一个非常重要的参考:cua 团队的 cua driver 文章。那篇文章发布得非常早 —— 大约在 Codex Computer Use 上线后一周 —— 并初步复现了一个类似 Codex 的后台 Computer Use 系统。但过去一个月我收集了更多信息,也发现那篇文章有几处不准确的地方。一些关键的实现细节被省略了 —— 例如,他们严重依赖 SkyLight.framework 中的私有 API,而我在测试中发现了更简单、更稳定的方法。

先做一个快速免责声明:这种方法依赖于 macOS 窗口系统内部的非常底层行为,甚至可能依赖于某种 bug。很多东西仍然是未知的,有些技术在某些应用上有效,但在其他应用上则完全失效。

所有相关的实现已经与 OpenBridge 一起开源在 kwwk-computer-use-core 中,包括读取后台窗口、点击窗口、键盘输入的完整逻辑。

如果你想实际尝试由这套 Computer Use 栈驱动的 Agent,可以加入 Bridge 的等待列表:https://bridge.surf

Bridge 中的 Computer Use

Bridge 中的 Computer Use

辅助功能是主要工具 —— 但这还不够

当人们想到 Computer Use 时,第一反应通常是模拟鼠标和键盘输入:把目标窗口带到前台,然后点击它。但 macOS 实际上内置了辅助功能 API(通常称为 AX)。一旦获得权限,它可以直接读取任何应用的 UI,甚至与某些控件交互 —— 完全在后台进行,不需要目标应用成为最前端,甚至不需要移动真实的系统光标。

它具体能做什么:

  • 读取:枚举窗口、遍历 AX 树、获取角色、标题、位置、值等属性

  • 写入:原生 AppKit 控件可以直接调用操作 —— 按钮用 AXPress,文本字段用 setValue,滚动条用 AXIncrement / AXDecrement

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

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

  • 在后台运行的 Chrome 和 Electron 应用中,AX 树通常不完整,AXPress 也不可靠,因此需要回退到模拟鼠标输入

  • 逐字键盘输入仍然依赖通过 postToPid 发送按键事件

这些就是后文要讲的窗口系统技术。对于原生应用,辅助功能是主要机制;后面的“魔法”主要用于填补 AX 无法处理的空缺。

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

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

cua driver 文章中提到的 SLEventPostToPid 其实并不重要。在实际使用中,CGEvent.postToPid 已经完全可以满足需求。

大致的步骤是:

  • 获取目标 pid、windowNumber 和控件坐标

  • 构造鼠标/键盘事件,填充 eventTargetUnixProcessID 和窗口相关字段,让系统知道事件应该投递到哪个窗口

  • 调用 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:
  同样的 target/window/location 字段
  pressure = 0
  postToPid(pid)

具体实现见 BackgroundInputDispatcher.swift。

后台窗口激活:欺骗窗口让它以为自己已被激活

postToPid 可以将事件投递到后台窗口,但在许多应用处理点击之前,它们会先检查自己的窗口是否处于“活跃”状态 —— 即是否为关键窗口、主窗口,以及焦点是否归属自己。后台窗口默认不满足这些条件,因此事件可能直接被丢弃。

Chrome 和 Electron 还有一个额外问题:当窗口不是最前端时,它们通常不会暴露完整的 AX 树,因此 Agent 甚至无法确定要点击什么。

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

Apple Music 窗口在后台被激活。

Apple Music 窗口在后台被激活。

截图中,Apple Music 和 Chrome 窗口左上角的红绿灯按钮 🚥 都是亮起的,这意味着两个应用都认为自己是活跃的。正常情况下,后台窗口的红绿灯是暗淡的 —— 这说明我们成功欺骗了 Apple Music,让它在后台运行时进入了激活状态。

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

方法出奇地简单:向目标窗口发送一个 postToPid 的点击(“中心预击”)。一旦应用接收到合法的鼠标按下/抬起序列,它内部就会走正常的窗口激活流程 —— 窗口成为关键窗口,开始接受输入,Chrome/Electron 也会在这时暴露它们的 AX 树。从功能上说,这与真实用户点击窗口没有区别。

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

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

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

如何拦截焦点消息

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

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

BackgroundActivationSession.start 安装两个 tap:

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

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

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

backgroundActivationEventTapCallback:
  if isFocusMessage(type) && destined for previous app:
    return nil          // 抑制失活
  return event          // 允许目标激活

一旦 tap 安装完毕,实际的激活分两步进行:

第一步:预击(appKitDefined)

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

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

  • 通过 postToPid 直接投递到目标进程的事件队列,绕过 WindowServer 的正常最前端路由
  • 事件携带 windowNumber,并写入字段 51/58(setWindowAddressingFields),让 AppKit 知道事件与哪个窗口关联
  • 功能上等同于提前告诉目标应用:“你现在应该进入激活状态”,为后续的中心预击做准备

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

第二步:中心预击

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

一个完整的操作

  • 创建 BackgroundActivationSession,先安装事件 tap
  • activateWindow:appKitDefined 预击 + 中心预击
  • 执行真正的点击 / 输入 / 滚动操作
  • 保持 tap 运行直到会话结束,防止后续操作再次窃取焦点

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

在 cua driver 文章中,他们使用私有的 SkyLight API 来实现后台激活,但我在测试中发现它并不稳定。我测试的这套 appKitDefined 预击 + 中心预击的组合极其可靠,在我测试过的每一个应用上都有效。

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

相似文章

AgentQuartz

Product Hunt

AgentQuartz 是一款 macOS 菜单栏工具,可跟踪并显示您对 Claude 和 Cursor AI 工具的使用情况,方便监控。

Computer use in Codex

YouTube AI Channels

OpenAI demonstrates the 'Computer Use' feature in Codex, allowing the AI to directly interact with local GUI applications on macOS using an accessibility framework and the fast Spark model for non-blocking, high-speed automation.