@EEEEYHN: https://x.com/EEEEYHN/status/2057397813999456759

X AI KOLs Timeline 新闻

摘要

本文详细解析了如何在MacOS上利用Accessibility API、CGEvent.postToPid和event tap技术,实现让AI agent在后台操作窗口而不干扰用户,从而支持两个鼠标指针共存的场景。

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

缓存时间: 2026/05/21 15:48

MacOS 居然可以同时有两个鼠标指针?

OpenAI 在 4 月 16 日发布的 codex computer use 里有一个让人惊奇的画面:屏幕上同时存在两个鼠标指针——一个是用户自己在控制的,另一个是 AI agent 的浮动光标。它可以在后台点击窗口、输入文字,而完全不干扰你正常使用电脑。

同时有2个鼠标指针

同时有2个鼠标指针

其实那个浮动光标只是障眼法,技术上根本不需要真的画一个指针。真正重要的是后台点击怎么生效——这篇文章来讲讲背后的原理:从 Accessibility API 的基础能力,到直接用 CGEvent 往后台窗口投递事件,再到最关键的一步——“骗过” macOS 的窗口系统,让一个后台窗口也进入激活状态

在详细展开之前,还要特别提一篇非常重要的参考:cua 团队的 cua driver。这篇文章发得非常早,codex computer use 出来大约一周就上线了,初步复现了和 codex 类似的后台 computer use。不过一个月下来我收集了更多信息,也发现那篇文章有些错误,一些关键细节没有写出来——比如他们大量依赖 SkyLight.framework 的私有 API,我试下来发现有更简单、更稳定的方案。

先叠个甲:这套方法依赖 macOS 窗口系统非常底层的原理,甚至可能是 bug。很多东西仍是未知的,有些手段在某些 app 上有效,换个 app 就不行了。

相关实现都随 OpenBridge 一起开源在 kwwk-computer-use-core 里,包括后台读窗口、点窗口、输入键盘的全部逻辑。

如果想实际体验搭载这套 computer use 的 agent,可以加 bridge 的 waiting list:https://bridge.surf/

Computer Use in Bridge

Computer Use in Bridge

首先 Accessibility 作为主力,但不够

做 computer use,大家第一反应基本都是模拟鼠标键盘,把目标窗口切到前台再点。其实 macOS 自带 Accessibility API(平时都叫 AX),拿到权限之后可以直接读任何 app 的 UI,也能对一些控件动手——后台就能用,不要求目标 app 是 frontmost,也不一定要真的动系统鼠标。

具体能做的事:

  • 读:枚举窗口、遍历 AX 树,拿 role、title、frame、value 这些

  • 写:原生 AppKit 控件可以直接调 action——点按钮走 AXPress,改文本框走 setValue,滚动条还能 AXIncrement / AXDecrement

这些 action 是送进 app 自己处理的,窗口不用成为 key window,光标也不用挪过去。TextEdit、Finder、系统设置这类原生 app,agent 读一遍 AX 树,找到对应 element 调 action,很多时候整条任务链就能跑完。

但 AX 盖不住所有场景:

  • Chrome、Electron 在后台时 AX 树经常不完整,AXPress 也不靠谱,得 fallback 到模拟鼠标

  • 键盘逐字输入也得靠 postToPid 送按键

这些就是后面要讲的窗口系统技巧。对原生 app 来说 Accessibility 是主力,后面的魔法补的是 AX 搞不定的地方。

使用 CGEvent.postToPid 直接推送事件到窗口

AX 搞不定的时候,下一步就是直接用 CGEvent 往目标窗口送鼠标和键盘事件。核心 API 是 postToPid——不是走全局 HID 通道,而是直接投递到指定进程的 event queue。

cua driver 文章里提到的 SLEventPostToPid 其实不是重点,实测 CGEvent.postToPid 完全够用。

要做的事大概就这些:

  • 拿到目标 pid、windowNumber、控件坐标

  • 构造 mouse/keyboard event,填上 eventTargetUnixProcessID 和 window 相关字段,让系统知道发给哪个窗口

  • 调 postToPid 投递出去;一次点击就是 down + up 两颗 event

一次左键点击最终由两颗 event 组成:

具体实现看 BackgroundInputDispatcher.swift。

后台窗口的激活:欺骗窗口以为自己被激活

postToPid 能把事件送到后台窗口,但很多 app 收到 click 之前,会先看自己的窗口是不是「活的」——是不是 key window、main window,焦点在不在自己身上。后台窗口默认不满足这些条件,事件送过去可能被直接丢掉。

Chrome 和 Electron 还有额外的问题:窗口不在 frontmost 时,常常不会暴露完整的 AX 树,agent 连该点什么都读不到。

所以要骗它一下:让目标窗口在进程内部进入一种「已激活」状态,但不要真的把它切到屏幕最前面,用户正在用的 app 还得保持 frontmost。

Apple Music 窗口在后台被激活

Apple Music 窗口在后台被激活

图里 Apple Music 和 Chrome 窗口左上角的交通灯🚥都是亮的,说明两个 app 都觉得自己处于激活状态。正常情况下后台窗口的红绿灯是灰的——这说明我们成功骗过了 Apple Music,让它在后台也进入了激活状态。

激活就是点一下窗口

做法很直白:往目标窗口里发一次 postToPid 点击(center primer)。app 收到合法的 mouse down/up 之后,会在内部走一遍正常的窗口激活流程——窗口变成 key window、开始接受输入,Chrome/Electron 也会在这时候把 AX 树暴露出来。这和用户真的点了一下没有本质区别。

区别在点完之后。正常情况下,这一下会让 macOS 把目标 app 切到屏幕最前面,用户正在用的 app 收到 deactivation。我们要的是前半段(进程内部 activation),不要后半段(视觉上的 frontmost 切换)。

坐标选在窗口正中心,这次点击不会触发任何应用行为,只触发窗口激活。

这里利用的是 macOS 的一个特性:窗口未激活时收到的第一次点击,通常不会触发真实操作,而是先走激活流程。点击位置可以自定,但别点左上角——红绿灯在未激活状态下也能响应,会误触关闭、最小化。

怎么拦截 focus 消息

当应用收到点击之后会给 frontmost app 发 deactivation、给目标 app 发 activation——前台就换了。解决办法是给相关进程装上 event tap,在 focus 消息送进 app 之前把它拦下来。所以顺序不能反:先装 tap,再发激活点击

实现上用的是 CGEvent.tapCreateForPid——给指定 pid 装一个 per-process 的 event tap,插在进程 event queue 头部,在 app 看到之前先过一遍 callback。这和全局 HID tap 不一样。代码里这套逻辑封装在 BackgroundActivationSession 里。

BackgroundActivationSession.start 会装两个 tap:

  • previous:当前 frontmost app 的 pid(用户正在用的那个)

  • target:agent 要操作的后台 app 的 pid

注册时用 CGEventMask.max 监听所有 event type,再在 callback 里窄过滤。focus 消息没有稳定的公开 CGEventType,不同 macOS 版本名字可能对不上,只能靠 raw value 认:13、19、20。

规则很简单——focus 消息如果发往 previous app 就丢掉(return nil),target 的 activation 放行:

tap 装好后,实际激活分两步:

第一步:appKitDefined primer

先往目标 pid 发一颗 NSEvent.otherEvent(type = appKitDefined,subtype = 1)。按 Apple 公开头文件,subtype 1 对应的是 applicationActivated,是 AppKit 内部的 app 激活事件

这颗 event 有几个关键点:

  • 通过 postToPid 直接送进目标进程的 event queue,不经过 WindowServer 的正常 frontmost 路由

  • event 上挂了 windowNumber,并写入 field 51/58(setWindowAddressingFields),让 AppKit 知道跟哪个窗口有关

  • 效果上相当于提前告诉目标 app「你该进入激活状态了」,给后面的 center primer 铺路

结束时发 subtype 2(applicationDeactivated),让目标 app 回到后台状态。具体 handler 路径 Apple 没公开,属于实测有效的内部机制。

第二步:center primer

再在窗口正中心发一次 postToPid 点击——这就是上面说的「点一下窗口」。

一次完整操作

  • 创建 BackgroundActivationSession,装好 event tap

  • activateWindow:appKitDefined primer + center primer

  • 执行真正的 click / type / scroll

  • session 结束前保持 tap 运行,防止后续操作再次抢焦点

如果目标 app 本来就在 frontmost,或者已经被我们激活过了,就不需要再走激活流程。为此我还写了 FrontmostApplicationMonitor 监控前台切换——不管用户自己换窗口,还是 agent 在后台操作,状态都不会乱。具体逻辑看 BackgroundActivationSession.swift 和 ComputerUseSession.swift。

在 cua driver 的文章中用了 skylight 的私有 API 来实现后台激活,但我实测下来并不稳定。这一套 appKitDefined primer + center primer 在我的使用中是非常稳定的,对我测试过的所有 app 都有效。

通过上面这些步骤,我们成功欺骗了窗口以为自己被激活了,从而在后台也能进行点击、输入等操作。

下一篇文章我来讲讲这套 computer use 另一个重要的部分,如何构建一个 agent 友好的界面(harness),让 agent 真正的用好电脑。

相似文章