Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
- JS REPL 的 API 和当前官方 code-first 形态对齐到异步 app binding,而不是把离散 tools 简单包成同步函数。snapshot 的 `windowPlacement` 以及 keyboard action 的 `keyMethod` 会转换成 native tool 的 snake_case 参数。实现不复制或运行 proprietary `@oai/*` package;协议和行为记录在 `docs/references/js-repl.md`。
- `open-computer-use call <tool> --args '{...}'` 会直接输出 MCP-style JSON result;`open-computer-use call --calls '[...]'` / `--calls-file <path>` 会在同一进程里顺序执行 JSON 数组里的 tool calls,并复用同一个 `ComputerUseService` 内存态,因此 `get_app_state` 之后的 action tool 可以继续使用同一轮 snapshot 的 `element_index`。序列执行默认会在成功的相邻操作之间 sleep 1 秒,也可以用 `--sleep <seconds>` 覆盖;遇到 `isError=true` 的 tool result 后停止。
- 对真实 app 的 `get_app_state` / action tool 入口,当前只保留一层密码管理器 bundle denylist:bundle-id 直传时直接返回 safety denial;名称匹配时默认不解析到这些 app。终端、Chrome / Atlas 和系统组件不再属于内置阻止目标。
- 普通 app 的 element frame 当前按“窗口左上角为原点”的 window-relative 坐标输出,便于后续把 `element_index` 和截图坐标统一到同一套参考系。
- 普通 app 的 element frame 当前按“窗口左上角为原点”的 window-relative 坐标输出,便于后续把 `element_index` 和截图坐标统一到同一套参考系。点击 AX 命中测试接收窗口点,仅加窗口屏幕原点;截图坐标在入口换算一次,元素索引点击不依赖截图。真实 AppKit 回归可运行 `node scripts/tests/macos-hit-test-smoke.mjs <signed-ocu-executable>`(需要现有辅助功能、录屏权限和 Retina 显示器)。
- 三个平台的显式 `get_app_state` 均支持 `include_ax` / `include_screenshot`,默认 true,至少保留一项。JS 对应 `includeAX` / `includeScreenshot`,CLI snapshot 提供同名连字符参数。关闭 AX 跳过树、焦点和选区,并使旧快照 / 查询索引及别名失效;关闭截图跳过捕获与编码,元素操作仍可用,截图坐标操作须重新获取图片。参数经 macOS app agent 转发;不改变动作回读,也不做自动图片去重。见 [按需观察](selective-observation.md)。
- macOS 额外提供 `query` 和 `select_text`,当前 native surface 为 11 个工具;Linux / Windows 仍为 9 个核心工具。JS `app.selectText(elementIndex, text, {prefix?, suffix?, selection?})` 复用快照或 query 索引,支持选中文字及前后光标。按字面文本和 UTF-16 定位、拒绝歧义;写入前及返回前检查会话,验证文本未变且实际选区等于请求,即使关闭完整回读也不能省略验证。失败通过 native `isError` / JS 异常传出;结果刷新使用 read-only recovery,不通过激活、键盘或真实指针补救。
- `click` / `set_value` 在执行真实动作前后,会额外驱动一层透明 `SoftwareCursorOverlay` window:两者的移动阶段现在共用一条 heading-driven 的官方风格 motion 内核,显式把“当前 cursor 朝向”和“最终 resting pose”一起喂给选路器,优先生成需要时先掉头、再沿车头方向推进的 C 形/单侧大弧轨迹;首次显示时按官方 binary 的 fresh state 从 AppKit 全局 `(0,0)` window origin 生成起点,后续动作继续复用上一帧 visible tip。真正显示出来的 cursor 不再直接等于 path sample,而是经过一层独立的 visual dynamics 状态,把 visible tip、velocity、angle 和 fog/offset 持续推进。`click` 结尾会衔接 click pulse 和更明显但仍然很小的 rotate wobble,`set_value` 则只做 settle / idle,不给 pulse;两者收尾后会在目标点继续保持 idle 状态,等待下一次动作时 tip 保持 anchored、只保留可感知的小角度摆动;只有连续 30 秒没有新动作时才做 cleanup,这样连续 tool call 不会反复从 fresh `(0,0)` 起步;如果宿主在任务 / turn 结束时发出 `turn-ended`,cursor 会立即消失并清掉本轮位置状态。
Expand Down
6 changes: 6 additions & 0 deletions docs/exec-plans/completed/20261031-pr126-hit-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# PR #126: window-point hit testing

- Scope: merge current main, retain contributor's coordinate fix, add reproducible real AppKit regression coverage. Keep unrelated descendant selection (#127) separate.
- Verify: native MCP with Retina and capped screenshots; AX-only indexed clicks; screenshot-coordinate refusal without an image; unit suite and document checks.
- Acceptance: correct hit target without changing foreground application or physical pointer; negative control with the old conversion fails; record actual coverage and limitations.
- Status: complete. Merged main; real Retina/capped/AX-only native MCP smoke passed, old-conversion negative control failed as expected. Full Swift suite and documentation checks passed. See the existing hit-test history for evidence and coverage limits.
50 changes: 50 additions & 0 deletions docs/histories/2026-10/20261023-0219-hit-test-window-points.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
## [2026-10-23 02:19] | Task: 修复 macOS 点击命中测试的坐标空间

### Execution Context

- Agent ID: Claude Code subagent(Agent tool 派发)
- Base Model: Claude Opus 5.5
- Runtime: Claude Code CLI

### User Query

> 修复 `hitTestElement` 把窗口点当成截图像素换算的问题,并用最小的回归检查证明修复前后行为差异。

### Changes Overview

- `hitTestElement(at:in:)` 改用 `windowPointToGlobalPoint` 换算,补一行注释说明入参是窗口点(`ElementRecord.localFrame` 的坐标空间)。
- 两处调用方都传窗口点:坐标点击的 `clickCandidates`(已经由截图像素换算成窗口点)和 element_index 点击的就近命中回退(`clickActionPoints` 来自 `clickFrame`)。
- 坐标拖拽仍走 `screenshotToGlobalPoint`,它的入参确实是截图像素,不受影响。

### Design Intent

截图与窗口不是 1:1 时(Retina 缩放、`maxLongEdgePixels` 上限,几乎总是如此),命中测试会被再除一次缩放比例,探测到错误位置。实测在 2x 屏幕上点击表格行时,命中测试落到上方的列表头并对它执行了 AXPress。另外,`include_screenshot:false` 的 element_index 点击在这条回退里会误报 "coordinate input requires a delivered screenshot",尽管调用方根本没有给坐标。只改共享的换算函数即可同时修好两个调用方。

### Files Modified

- `packages/OpenComputerUseKit/Sources/OpenComputerUseKit/ComputerUseService.swift`
- `docs/releases/feature-release-notes.md`

### Validation

- 用一个不激活、位于所有窗口之后的 AppKit 探针(无动作的表格行 + 列表头)在进程内运行 CLI(禁用 app agent 代理与软件光标),打开点击决策调试日志:
- 修复前:`click element_index`(行,窗口点 y=104..128)的就近命中把窗口点 (125,116) 当作像素换算成全局 (122,608),命中列表头 AXButton(y=42)并执行 AXPress。
- 修复后:同一窗口点换算为全局 (185,666),命中该行自己的 AXStaticText(y=108),不再按下列表头。
- 修复前 `get_app_state include_screenshot:false` 后的 element_index 点击报截图缺失错误;修复后不再报错。
- 坐标点击路径用同一个函数,命中测试同样落到正确的行文字上;但在该探针里快照中的窗口记录会先处理点击,因此该路径的最终结果在探针中看不出差异。
- `swift test` 全量通过。

### 2026-10-31 maintainer review

- Merged current main (`a7fdec1`), retaining both sides of the release-note conflict and the original contributor commit.
- Kept the production fix; added opt-in debug logging of window/screen hit-test points and AX result codes.
- Added `scripts/tests/macos-hit-test-smoke.mjs` and its real AppKit receiver. This uses native MCP and ordinary AX APIs, not the synthetic fixture bridge. Run with a normally signed, admitted OCU executable on an unlocked Retina desktop with Accessibility/Screen Recording permissions.
- Verified 600×432-point window with 1200×864 (2×) and 300×216 (0.5×) screenshots: indexed fallback hits the expected screen point and never presses the unrelated button; screenshot-coordinate auto clicks reach the button, with correct hit-test coordinates. AX-only indexed fallback succeeds, while coordinate clicks without an image fail. Explicit accessibility clicks are observed by the receiver. Foreground PID and physical pointer remain unchanged during actions.
- Negative control: changing only the conversion back to `screenshotToGlobalPoint` makes the same live regression fail: expected (490,627), actual (305,498.5). Restored the fix afterward.
- Limits: one physical Retina display; this tests AX hit locations and real button activation, not general background mouse delivery to actionless custom views. The separate descendant-selection issue remains in #127.
- Full Swift suite: 399 tests, 9 skipped, no failures. Documentation checks passed.

### Follow-up main synchronization

- Merged main `21a33a4` after #139 landed. Resolved only the release-note insertion conflict, preserving both feature entries. Hit-test implementation and its regression tests are unchanged from the previously tested head.
- Documentation and whitespace checks passed; no repeat desktop run was needed for the documentation-only resolution.
1 change: 1 addition & 0 deletions docs/releases/feature-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
| 2026-10-30 | 虚拟显示器显式启用 | 默认不向模型提供虚拟显示器参数,避免意外改变显示器布局。 | 新增 `virtualDisplay.enabled=false` 与环境覆盖;关闭时隐藏 schema/JS/遮挡提示,在运行入口和底层阻止创建,保留恢复与结束清理。 |
| 2026-10-29 | macOS 权限引导定位 | 已有辅助功能权限时,拖拽引导紧贴目标权限列表,更容易看清拖放方向;首次未授权时继续跟随设置窗口底边。 | 优先使用上方列表的增删控件容器定位,读不到时自动退回窗口锚点;后台有界读取 AX,跳过应用行。 |
| 2026-10-29 | macOS 权限引导 | 切换回系统设置后,权限引导浮窗的返回按钮与拖拽卡片完整显示。 | 修复入场动画中断后保留中间尺寸与透明度的问题,并固定动画结束尺寸。 |
| 2026-10-23 | macOS 点击命中测试 | 截图与窗口比例不是 1:1 时(如 Retina),`auto` 点击不再按下目标旁边的控件;只取 AX 的 element_index 点击也不再误报需要截图。 | `hitTestElement` 的入参本就是窗口点,改用窗口点换算,不再按截图像素再除一次缩放比例;修复前点击表格行会命中并按下上方列表头。 |
| 2026-10-13 | Windows 滚动与坐标 | Windows 上 `scroll` 不再在默认配置下直接失败;150% 缩放且窗口位于屏幕右侧时,`get_app_state` 也能返回截图和窗口 bounds。 | 修复 `scroll` 请求漏传 `windowBounds` 导致坐标回退必然报 `No usable screenshot coordinates`;滚轮消息保留屏幕坐标并修正横向方向,PowerShell bridge 启动时对齐 DPI 物理坐标;`ScrollPattern` 在当前状态不可滚动时回退到坐标路径而不是让整个 tool call 失败。 |
| 2026-10-13 | Windows 全局输入兜底 | 需要真实指针或键盘的 Windows 动作(`click_method=global`、WinUI 键盘输入、子控件点击)在显式授权后可用,默认路径完全不变。 | Windows 实现 `click_method=global` 真实指针点击;`drag` / `scroll` 的坐标回退在 `OPEN_COMPUTER_USE_ALLOW_GLOBAL_POINTER_FALLBACKS=1` 时使用真实指针,按键另需显式 `key_method=global`,`app_post` 仍保持 HWND 消息语义;未授权时在任何物理输入前拒绝,核对目标前台 / 命中 / 几何并在异常时释放输入。 |
| 2026-10-17 | 1.1.2 修复发布 | 软件光标持续跟随移动窗口,Linux 批量动作可减少回读开销,安装链接统一到新仓库。 | 同步所有版本源至 1.1.2;发布窗口锚点跟踪、Linux 动作回读配置与仓库改名后的 npm / 插件元信息,沿用既有命令和 App 签名身份。 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1566,11 +1566,13 @@ public final class ComputerUseService {
.first
}

/// `point` is a window point (the space of `ElementRecord.localFrame`), not a screenshot pixel.
private func hitTestElement(at point: CGPoint, in snapshot: AppSnapshot) throws -> ElementRecord? {
let appElement = AXUIElementCreateApplication(snapshot.app.pid)
let globalPoint = try screenshotToGlobalPoint(snapshot: snapshot, x: Double(point.x), y: Double(point.y))
let globalPoint = try windowPointToGlobalPoint(snapshot: snapshot, point: point)
var hitElement: AXUIElement?
let result = AXUIElementCopyElementAtPosition(appElement, Float(globalPoint.x), Float(globalPoint.y), &hitElement)
debugClickDecision("hit test window=\(point) screen=\(globalPoint) result=\(result.rawValue)")
guard result == .success, let hitElement else {
return nil
}
Expand Down
54 changes: 54 additions & 0 deletions scripts/tests/fixtures/MacOSHitTestFixture.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import AppKit

final class HitTestWindow: NSWindow {
override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
}
final class TargetView: NSView {
var clicks = 0
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
override func isAccessibilityElement() -> Bool { true }
override func accessibilityRole() -> NSAccessibility.Role? { .row }
override func accessibilityLabel() -> String? { "Hit target row" }
override func mouseDown(with event: NSEvent) { clicks += 1 }

}
final class Counter: NSObject {
var clicks = 0
@objc func press(_ sender: Any?) { clicks += 1 }
}
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
let window = HitTestWindow(contentRect: NSRect(x: 120, y: 180, width: 600, height: 400),
styleMask: [.titled], backing: .buffered, defer: false)
window.title = "OCU Hit Test Fixture"
window.isReleasedWhenClosed = false
let target = TargetView(frame: NSRect(x: 280, y: 150, width: 180, height: 50))
let counter = Counter()
// Half the target's window-relative coordinates: the old Retina conversion hits this button.
let decoy = NSButton(frame: NSRect(x: 140, y: 275, width: 110, height: 50))
decoy.title = "Wrong target"
decoy.target = counter
decoy.action = #selector(Counter.press(_:))
window.contentView!.addSubview(decoy)
window.contentView!.addSubview(target)
window.orderFront(nil)
let stateURL = URL(fileURLWithPath: CommandLine.arguments[1])
Timer.scheduledTimer(withTimeInterval: 0.03, repeats: true) { _ in
let screenRect = window.convertToScreen(target.frame)
let top = NSScreen.screens[0].frame.maxY
let frame = window.frame
let buttonRect = window.convertToScreen(decoy.frame)
let data = try! JSONSerialization.data(withJSONObject: ["targetClicks": target.clicks,
"wrongClicks": counter.clicks, "scale": window.backingScaleFactor,
"point": [screenRect.midX - frame.minX, frame.maxY - screenRect.midY],
"screenPoint": [screenRect.midX, top - screenRect.midY],
"buttonPoint": [buttonRect.midX - frame.minX, frame.maxY - buttonRect.midY],
"buttonScreenPoint": [buttonRect.midX, top - buttonRect.midY],
"windowSize": [frame.width, frame.height],
"frontPID": NSWorkspace.shared.frontmostApplication?.processIdentifier ?? -1,
"pointer": [NSEvent.mouseLocation.x, top - NSEvent.mouseLocation.y]])
try? data.write(to: stateURL, options: .atomic)
}
Timer.scheduledTimer(withTimeInterval: 180, repeats: false) { _ in app.terminate(nil) }
app.run()
Loading