Rebuilt against main so the branch carries the Computer Use work and no other divergence. Three unrelated efforts had been sitting uncommitted in this worktree and were swept into an earlier commit; they are preserved on cu-worktree-full-backup and belong on their own branches — adapter control credentials, Electron asar sealing, and the sidecar code-loading audit. Every file outside Computer Use now matches main exactly. The engine A Swift helper drives apps through the accessibility tree, with coordinate actuation for the Chromium and Electron apps whose tree is a bare window frame. Ten primitives matching the shape Codex uses, so an app's guidance and the model's habits transfer. Coordinate actions resolve their target window once and refuse when none can be named. The unbound event they used to fall back to is discarded by custom renderers, so a minimized target produced a whole session of "Action completed" with nothing behind it. Input acceptance is established for typing and key presses as well as clicks: each MCP call is seconds apart, so the keyboard cannot inherit the focus a click established. The synthetic focus notification is gated on the target not already being active — sent unconditionally it names window 0 at an app that already owns a key window, and nine window-bound clicks were discarded with the traffic lights fully lit. State the model can trust An off-screen target says so, and says which tools still reach it: element actions need no on-screen geometry, so an app with a real tree can still be driven from the Dock. A fully covered window is recovered once, then left alone — burying it again is the user wanting their screen back. A repeated capture is reported with the cause that actually applies rather than both, because coverage is something we compute. Signing The helper is signed under a stable identity before electron-builder sees it, and excluded from re-signing: macOS ties Accessibility and Screen Recording grants to the signing identity, so rotating it drops both on every update. Discoverability The desktop slash menu falls back to a directory scan while a session's CLI has not started, which is when the menu is first opened. Built-ins and bundled skills live in the binary, so /computer-use was absent until after the first message.
16 KiB
Computer Use 原生化设计方案(Swift Helper)
目标:把底层执行层从
pyautogui(HID 层模拟,独占鼠标)替换为原生 Swift helper(按 PID 定向注入 + Accessibility + ScreenCaptureKit),实现 不抢占用户鼠标键盘 与 后台窗口操作/截图,向 Codex Computer Use 的体验对齐。本文是技术方案/评审稿,不含落地代码。决策点见末尾「八、待决策」。
相关文档:Computer Use 架构深度解析 · 功能指南
一、动机与问题
当前实现(见架构文档「五、Python Bridge」)用 pyautogui 执行鼠标/键盘:
# runtime/mac_helper.py
pyautogui.moveTo(x, y) # 移动系统唯一的真光标
pyautogui.click(x=x, y=y, ...)
pyautogui.write(text, ...)
pyautogui 在 macOS 底层调用 CGEventPost(kCGHIDEventTap, event)——注入到硬件 HID 事件层。后果:
- 独占外设:系统只有一个物理光标,注入即把它拖走,用户无法同时操作;
- 全局生效:事件落到"当前前台窗口/光标下的窗口",无法定向到某个后台 App;
- 必须前台可见:要点的窗口必须在最前。
这与用户的核心诉求冲突:一边自己工作,一边让 Agent 在另一个 App 上跑自动化(build / 点击 / 截图),且不需要把那个桌面亮在最前。
二、Codex 机制拆解(逆向证据)
对 Codex Computer Use.app 做 otool -L / nm -u / strings / codesign 分析,得到以下事实(证据见附录 A):
2.1 进程拓扑
Codex Computer Use.app
├─ MacOS/SkyComputerUseService 主服务(Swift,LSUIElement=true 无 Dock 后台代理)
└─ SharedSupport/
├─ SkyComputerUseClient.app 真正承载 MCP 的客户端(.mcp.json 指向它 + `mcp` 参数)
├─ CUALockScreenGuardian.app 锁屏守卫(检测物理输入→fail-closed 重锁)
└─ Codex Computer Use Installer.app 安装授权插件
- entitlements:
com.apple.security.application-groups(进程间共享)+com.apple.security.automation.apple-events。 - 自动更新:
SUFeedURL指向 OpenAI 的 appcast(独立于 Codex 主体更新)。
2.2 三项关键能力 = 不抢鼠标的根因
| 能力 | 用到的 API(二进制实证) | 作用 |
|---|---|---|
| 按 PID 定向注入 | CGEvent* + IPC ComputerUseIPCAppPerformActionRequest + targetPID |
事件投给目标 App 进程,不经过全局 HID 光标 |
| 控件级操作 | AXUIElementPerformAction / AXUIElementSetAttributeValue / AXUIElementCopyElementAtPosition |
直接按按钮、设值,后台窗口也能操作;字符串提示 Prefer simulating physical clicks over Accessibility actions. 说明 CGEvent 优先、AX 兜底 |
| 虚拟光标 | AccessibilitySupport.VirtualCursor、cursorPositionInScaledCoordinates、SkyLensView + Lens_frame_00~44.png |
自己维护一个逻辑光标位置,并画一个**覆盖层"AI 光标(透镜)"**给用户看,而非借用系统真光标 |
2.3 截屏
ScreenCaptureKit(SCStream + ComputerUseIPCAppStartCaptureRequest,带捕获动画覆盖层)——可捕获指定窗口的图层 surface,窗口即使不在最前、被遮挡、在别的 Space 也能抓到。
2.4 共存 / 锁屏自治(高级,本方案不复刻)
ComputerUseUserInteractionMonitor+userInteractionDebounceDuration:检测真人输入并退避。LockScreenPhysicalInputMonitor/SystemLockScreenOverlayPresenter:可在锁屏下 auto-unlock 全自动接管,一旦检测物理输入立即 fail-closed 重锁。涉及密码与安全,风险高,v1 砍掉。
2.5 结论
Codex 不抢鼠标的本质 = 事件按 PID 定向投递 + AX 控件操作 + 自绘虚拟光标,三者都绕开了"系统唯一物理光标"。我们用
kCGHIDEventTap走的恰是相反方向。
三、关键认知校正:macOS 没有"免费的后台桌面"
必须写清楚,避免方向性误解:
- macOS 整机只有一个 WindowServer / 一个 GUI 会话,没有 Linux Xvfb 式的无头第二桌面。
- 所谓"后台/不亮桌面"的真实含义:
- 截屏靠 ScreenCaptureKit 抓窗口 surface → 窗口不必在最前;
- 注入靠按 PID / AX → 不抢真光标;
- 你能在 App B 工作、让它驱动 App A。
- 真·完全隔离(互不干扰的独立桌面)在 macOS 上只能靠:① 独立用户账户 + 快速用户切换;② macOS 虚拟机。
- Codex 的锁屏自治是"你离开 → 全自动;你回来碰一下 → 退避重锁"的折中,不是隔离桌面。
四、目标架构:只换最底一层
最大化复用——上层全不动,只把 helper 从 Python 换成 Swift 二进制。
┌─────────────────────────────────────────────────────────┐
│ Layer 1-4 MCP 工具(24) · 9 层安全关卡 · 191 应用分类 · 权限 UI │ ← 完全复用,零改动
├─────────────────────────────────────────────────────────┤
│ Layer 5 pythonBridge.ts —— 进程调用边界 │ ← 改几行:指向 Swift 二进制
├─────────────────────────────────────────────────────────┤
│ Layer 6 [旧] mac_helper.py (pyautogui, HID 层) │
│ [新] cu-helper (Swift: CGEventPostToPid+AX+SCK) │ ← 本方案唯一新增/重写
└─────────────────────────────────────────────────────────┘
4.1 可替换边界(已确认)
src/utils/computerUse/pythonBridge.ts:159 的调用形态:
execFile(pythonBinPath(), [helperPath, command, '--payload', JSON.stringify(payload)])
// 解析 stdout 的 { ok: boolean, result?: T, error?: { message } }
Swift 二进制只要遵守完全相同的协议即可热插拔:
cu-helper <command> --payload '<json>'
# stdout: {"ok":true,"result":...} 或 {"ok":false,"error":{"message":"..."}}
替换点:把 pythonBinPath() + helperPath 抽象成 resolveHelperCommand(),按配置/平台返回 ["python3", mac_helper.py] 或 [cu-helper]。保留 Python 作为 fallback / 非 mac 平台。
4.2 命令协议(Swift 端需实现的全集)
沿用架构文档「命令映射表」,逐条给出新实现方式:
| 命令 | 旧(pyautogui/mss) | 新(Swift) |
|---|---|---|
screenshot / zoom |
mss 全屏 | SCScreenshotManager.captureImage(显示器或窗口 filter) |
click/double/triple |
pyautogui.click (HID) |
CGEventPostToPid(pid) 鼠标事件;可选 AX kAXPressAction 兜底 |
type |
pyautogui.write (HID) |
CGEventPostToPid 键盘事件;文本框可走 AX setValue |
key / hold_key |
pyautogui.hotkey |
CGEventPostToPid 带 flags |
drag / mouse_down / mouse_up |
pyautogui.dragTo |
CGEventPostToPid mouseDown→moved→up 序列 |
scroll |
pyautogui.scroll |
CGEventCreateScrollWheelEvent + PostToPid |
mouse_move / cursor_position |
pyautogui.moveTo/position |
仅更新虚拟光标逻辑坐标 + mouseMoved 事件(驱动 hover) |
frontmost_app / list_* / open_app |
NSWorkspace/Quartz | 同(AppKit/CoreGraphics 原生) |
read_clipboard/write_clipboard |
NSPasteboard | 同 |
check_permissions |
osascript+CGDisplayCapture | CGPreflightScreenCaptureAccess + AXIsProcessTrustedWithOptions |
关键差异只在 鼠标/键盘注入 和 截屏 两类;其余命令照搬原生等价实现即可。
五、关键技术点详解
5.1 事件注入:从 HID 层切到按 PID
核心就这一个 API 替换(伪代码):
// 旧路径(pyautogui 等价):注入到硬件层 → 抢真光标
CGEvent(...)?.post(tap: .cghidEventTap)
// 新路径:投给目标进程 → 不动真光标
let e = CGEvent(mouseEventSource: src, mouseType: .leftMouseDown,
mouseCursorPosition: pt, mouseButton: .left)
e?.postToPid(targetPid) // CGEventPostToPid(pid, event)
targetPid来自frontmost_app/ 已授权 App 的NSRunningApplication.processIdentifier。- 点击序列:
mouseMoved → leftMouseDown → leftMouseUp,多击靠setIntegerValueField(.mouseEventClickState, n)。 - 坐标仍走现有
scaleCoord()(架构文档「坐标系统」),无需改上层。
5.2 Accessibility 兜底
CGEventPostToPid 对后台 App 不是万能(见 §6.2),故对"按钮/菜单项/文本框"提供 AX 路径:
let app = AXUIElementCreateApplication(targetPid)
// 命中坐标处控件
var elem: AXUIElement?
AXUIElementCopyElementAtPosition(systemWide, Float(x), Float(y), &elem)
AXUIElementPerformAction(elem, kAXPressAction as CFString) // 点击
AXUIElementSetAttributeValue(elem, kAXValueAttribute as CFString, text) // 设值
策略:CGEvent 优先(更像真实交互),失败/不可靠场景 AX 兜底(与 Codex 一致)。
5.3 ScreenCaptureKit 后台窗口捕获
let content = try await SCShareableContent.current
let window = content.windows.first { $0.owningApplication?.bundleIdentifier == bundleId }
let filter = SCContentFilter(desktopIndependentWindow: window) // 或 display
let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: cfg)
- macOS 14+ 的
SCScreenshotManager适合"每步一张"的单帧截图,无需常驻 stream。 - 输出仍编码为 JPEG/base64,尺寸经现有
imageResize.ts处理,上层零改动。
5.4 虚拟光标覆盖层(可选但强烈建议)
不动真光标后,用户看不到 AI 在点哪 → 需自绘:
let win = NSWindow(...)
win.level = .screenSaver // 或 CGShieldingWindowLevel
win.ignoresMouseEvents = true // 不拦截用户真实操作
win.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
win.backgroundColor = .clear; win.isOpaque = false
// 内部画一个光标/透镜图标,随逻辑坐标移动(对应 Codex SkyLensView/Lens_frame_*)
5.5 人机共存监听(可选,v2)
NSEvent.addGlobalMonitorForEvents(matching: [.mouseMoved, .leftMouseDown, .keyDown]) { _ in
// 检测到真人输入 → 暂停注入 / debounce 让位
}
六、风险与权衡(决策关键)
6.1 签名 / TCC —— 隐形的最大成本
- Computer Use 需要两项 TCC:屏幕录制 + 辅助功能(Accessibility)。
- 未签名 / ad-hoc 签名的二进制,每次 rebuild 都会让已授予的 TCC 失效,开发期反复重新授权,极其影响迭代。
- 要稳定授权 + 可分发,需要 Developer ID 签名(理想情况再公证)。这与本项目已知的 Apple 付费签名墙 是同一堵墙(见 memory:未签名 DMG 分发问题、Electron 迁移的签名前置)。
- 缓解:开发期固定一个稳定签名身份(哪怕自签 + 手动信任),把 helper 作为长期稳定的独立二进制,减少 rebuild 对主 App 授权的牵连。
6.2 各 App 可控性预期(务必管理预期)
| App 类型 | CGEventPostToPid | AX | 预期 |
|---|---|---|---|
| 原生 AppKit(Notes/Finder/系统设置/多数三方原生) | 多数可用 | 好 | ✅ 可靠 |
| 后台键盘输入(非 key 窗口) | 最不稳 | 文本框走 setValue 较稳 | ⚠️ 需 AX 兜底 |
| 浏览器(Safari/Chrome/Arc…) | 不可靠 | 差 | ❌ 应走 Chrome MCP(现有 deniedApps 已降级只读) |
| Electron(VS Code/部分应用) | 不稳 | 不全 | ⚠️ 逐个验证 |
| 游戏 / Metal / IOKit HID | 几乎不响应 | 无 | ❌ 不支持 |
Codex 之所以给 Notion/Spotify 等各写
AppInstructions/*.md、并保留锁屏全自动通道,正是因为"任意 App 都稳"做不到,要逐 App 调教。我们 v1 只承诺 原生 AppKit App 的不抢鼠标控制。
6.3 macOS 单会话限制
见 §三。"后台"≠ 隔离桌面;要真隔离须独立账户或 VM,超出本方案范围。
七、分阶段实施计划
| 里程碑 | 内容 | 验收 | 预估 |
|---|---|---|---|
| M0 | TS 侧抽象 resolveHelperCommand(),支持 Python/Swift 切换 + 配置开关 |
切到一个 echo Swift 二进制能跑通 JSON 协议 | 0.5 天 |
| M1 | Swift:screenshot/zoom(SCK)+ 只读类命令(frontmost/list/clipboard/permissions) |
截图与 Python 版一致;权限检测正确 | 2–3 天 |
| M2 | Swift:click 用 CGEventPostToPid,验证真光标不动 |
在 Notes/计算器点击成功且光标不移动 | 2–3 天 |
| M3 | type/key/scroll/drag/mouse_down/up |
输入/滚动/拖拽在原生 App 成立 | 2–3 天 |
| M4 | AX 兜底(kAXPressAction/setValue/命中坐标) |
CGEvent 失败的控件用 AX 成功 | 2–3 天 |
| M5 | 虚拟光标覆盖层 | 用户能看到 AI 光标位置 | 1–2 天 |
| M6(可选) | 共存监听退避 | 真人输入时暂停 | 1–2 天 |
| 砍 | 锁屏自治 / Guardian / auto-unlock | —— | 不做 |
打包:Swift binary 随 runtime/ 分发(类比 mac_helper.py),或作为 SwiftPM executable 在构建期产物。注意 §6.1 签名。
总计:核心(M0–M5)约 2 周;M6 + 调教另算。
八、对照方案:你"自测自己 App"其实有更轻的路
用户的原始诉求里有"开发桌面 App → build → 自动测试 → 自动截图"。对自己开发的 App,computer-use(视觉+盲点击)是错的重型工具:
- build:
cargo tauri build/xcodebuild—— 纯 CLI,无需 computer use。 - 自动测试 + 截图:本项目是 Tauri,正解是
tauri-driver+ WebdriverIO(Tauri 官方 WebDriver)或对 WebView 直接 Playwright;原生壳可用 XCUITest。它们通过自动化协议驱动,天然不碰光标、可真后台、可进 CI,比模拟点击稳一个数量级。
判据:computer-use 适合驱动"无 API 的第三方任意 App";驱动"你自己的 App"用 WebDriver/Playwright 更优。 两者可并存:自测走 WebDriver,通用控制走 Swift helper。
九、待决策
- 范围:v1 是否就锁定"原生 AppKit App 的不抢鼠标控制 + 后台窗口截图",明确不承诺浏览器/Electron/游戏?
- 签名:开发期是否接受固定一个自签身份手动信任以稳住 TCC?分发是否仍走现有未签名脚本路径(helper 单独处理)?
- 是否并行做对照方案:要不要先用 tauri-driver/Playwright 把"自测自己 App"这条最高频需求快速跑通,Swift helper 作为中长期通用能力推进?
- 虚拟光标 / 共存监听 是否纳入 v1(建议虚拟光标纳入、共存监听放 v2)?
附录 A:逆向证据(可复现)
BIN="/Applications/Codex.app/Contents/Resources/plugins/openai-bundled/plugins/computer-use/Codex Computer Use.app/Contents/MacOS/SkyComputerUseService"
otool -L "$BIN" # → ScreenCaptureKit / ApplicationServices / CoreGraphics / AppKit / Carbon
nm -u "$BIN" # → _AXUIElementPerformAction / _AXUIElementSetAttributeValue / _AXUIElementCopyElementAtPosition ...
strings -a "$BIN" # → VirtualCursor / cursorPositionInScaledCoordinates / SkyLensView
# ComputerUseIPCAppPerformActionRequest / targetPID
# "Prefer simulating physical clicks over Accessibilty actions."
# LockScreenPhysicalInputMonitor / CUALockScreenGuardian
codesign -d --entitlements - "$BIN" # → application-groups / automation.apple-events
关键字符串原样:
Enable the virtual cursor in Computer Use.Prefer simulating physical clicks over Accessibilty actions.(原文拼写)Detected physical input during lock-screen Computer Use auto-unlock; relocking and suppressing auto-unlock until manual unlock.
附录 B:我们当前实现锚点
- 调用边界:
src/utils/computerUse/pythonBridge.ts:159(callPythonHelper) - 执行器:
src/utils/computerUse/executor.ts - Python helper:
runtime/mac_helper.py(click()在 :585) - 上层(复用不动):
src/vendor/computer-use-mcp/(tools.ts / toolCalls.ts / deniedApps.ts …)