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 @@ -95,7 +95,7 @@
- `get_app_state.window_placement` 由 `virtualDisplay.enabled` 控制(默认 `false`;ENV `OPEN_COMPUTER_USE_VIRTUAL_DISPLAY_ENABLED` 优先)。关闭时不进入工具 schema 或 JS 提示,并在窗口查找和底层创建前拒绝 `agent_display`;`restore` 与 session cleanup 保留恢复能力。启用后是开源版的可选扩展:`keep`(默认)、`agent_display`、`restore`。`agent_display` 通过 `AgentDisplay` + `packages/OpenComputerUseVirtualDisplayShim` 创建 agent 专用 virtual display,用 AX 位置把目标窗口的 frame 移过去(不激活、不抬升),让已被遮挡或在其他 Space 的窗口对其 app 真正可见;`restore` 按 app PID 恢复当前 runtime 停放的全部窗口,session cleanup 或进程退出也会恢复全部窗口并销毁显示器。恢复失败时保留原位置记录和 virtual display,避免把未恢复窗口误记为成功并允许后续重试;已关闭的窗口则直接清除记录。shim 捕获私有 KVC/selector 的 Objective-C exception,并把不兼容系统收敛为创建失败。Windows / Linux 对非 `keep` 值返回 unsupported。
- `type_text.key_method` / `press_key.key_method` 是开源版的可选扩展,支持 `auto`(默认)和 macOS-only 的 `sky_key`。`sky_key` 复用 `sky_click` 的 target-only synthetic-active record,再投递 yabai 的 make-key-window record 让目标 app 在进程内把当前 snapshot 窗口设为 key window,之后走现有 `CGEvent.postToPid` 键盘路径;带 `cmd` 的单字符组合键改为在目标 AX 菜单栏找到对应菜单项并 `AXPress`。它不要求窗口 on-screen(可用于其他 Space),但对隐藏 app fail closed;action-result snapshot 与 `sky_click` 一样使用 read-only recovery policy。
- 对 renderer 合成的 summary `text` 行,`click` 不再默认点击父容器中心;这类元素使用左侧安全锚点。对普通 row/container/text 的后代候选,也会过滤右侧紧凑 hover action(例如 Lark / Electron 会话列表里的“完成”勾),避免把主行点击误操作成 side action;Electron/Lark 这类 app 的 WebArea 合成文本会优先寻找紧邻的行级 `AXPress` 祖先并静默执行,避免用物理鼠标点行。浏览器 WebArea(例如 Chrome/GitHub)不走这条 Electron-scoped 行级祖先点击优化,仍保留通用 link/container 点击路径。命中点如果只反查到覆盖整页的 Electron/WebArea 级 AX 元素,不再继续扫描整个大容器的子孙候选,避免一次行点击被远处的可点击元素截走。
- `AXUIElementCopyElementAtPosition` 做坐标命中,尽量把 coordinate click 反解成可操作 AX 元素
- `AXUIElementCopyElementAtPosition` 做坐标命中,尽量把 coordinate click 反解成可操作 AX 元素。点击候选的子孙扫描只按下落在点击目标上的元素:coordinate click 要求子孙 frame 包含点击点,element-targeted click 要求子孙 frame 与目标 click frame 重叠;点空白处时最佳候选常是窗口本身,不再因此按下窗口里别处最小的按钮(例如关闭按钮),而是落到 `postToPid` 定向鼠标事件。子孙 frame 必须有效且面积非零。显式点击窗口索引跳过子孙和就近命中搜索,直接进入已有的窗口 Raise / focus 回退;显式点击关闭按钮仍可执行。可用 `node scripts/tests/macos-hit-test-smoke.mjs <signed-ocu-executable> --descendant-guards` 验证真实窗口行为;桌面有人操作时可附加 `--allow-desktop-activity`,明确跳过前台和指针稳定性断言
- `CGEvent.postToPid` 定向发送键盘事件,避免为了 `type_text` / `press_key` 抢前台;`type_text` 会把文本按 Unicode extended grapheme cluster 聚合成小批量 `keyboardSetUnicodeString` 事件,避免中文标点、emoji / 代理对和组合字符被逐个 UTF-16 code unit 拆开后在 Electron 富文本输入框里乱序或变形。如果当前 focused element 的 `AXValue` 可设置,`type_text` 会优先按可编辑内容追加并写回 `AXValue`,这覆盖 Feishu / Electron 富文本输入框不可靠接收后台键盘事件的场景,并会过滤已知占位提示,避免把 placeholder 拼进草稿;如果当前 focused element 不是可编辑文本目标,`type_text` 会报错要求先 click 文本输入区或使用 `set_value`,不再把无效果的后台键盘投递当成成功;`press_key` 的 xdotool parser 覆盖官方 binary key table 里常见的 `BackSpace`、`Page_Up`、`Prior` / `Next`、`F1...F12` 和 `KP_*` alias
- `scroll.pages` 对齐官方 `1.0.755` 的 `number` schema,支持小数页数;整数页且目标暴露 `AXScroll*ByPage` 时优先走 AX action,否则用 `CGEvent.postToPid` 向目标进程定向发送 scroll event
- `drag` 仍是 coordinate-only API,但默认不再使用全局 `.cghidEventTap` mouse event;默认改为 `CGEvent.postToPid` 定向发送 mouse move / down / dragged / up 事件,避免移动用户真实硬件光标;这些 coordinate tool 的 `x/y` 先按 screenshot pixel 坐标解释,再依据截图像素尺寸与目标 window bounds 的比例映射回 window point / Quartz global 坐标,避免 Retina 窗口上把 2x 像素误当成 1x point 导致点击落到错误位置
Expand Down
7 changes: 7 additions & 0 deletions docs/exec-plans/completed/20261031-pr127-descendant-click.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# PR #127: constrain descendant clicks

- Merge latest main, preserve #126 coordinate fix and original contributor commits.
- Review coordinate and indexed click fallbacks, reject degenerate frames, prevent window-index clicks from pressing window contents.
- Add real AppKit/native MCP regression: blank coordinates, actionless row, window index, intentional button/close actions. Run a pre-fix negative control and #126 smoke.
- Validate Swift suite, documentation, clean diff, then push original PR branch.
- Status: complete. 402 Swift tests (9 skipped) passed; real AppKit/native MCP regression passed, pre-fix negative control failed. Active desktop prevented foreground/pointer stability assertions; global pointer fallback was disabled. See existing coordinate-click history.
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
## [2026-10-23 02:45] | Task: 修复坐标点击空白处会按下窗口里别处的按钮

### Execution Context

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

### User Query

> 坐标点击(`click` 传 x/y,`click_method` 为 `auto`)点到没有可操作元素的空白处时,会按下窗口里别处无关的按钮。追到根因并做最小修复:坐标点击只作用于 frame 包含点击点的元素或命中元素的祖先,绝不按下窗口里别处的子孙,也不按窗口控制按钮;点击点处没有可操作元素时,落到现有的 pid 定向鼠标事件。顺带检查 element_index 点击路径,只在它会按下目标 frame 之外的元素时修复。附回归检查。

### Changes Overview

**Scope:** `packages/OpenComputerUseKit`

**Key Actions:**
- **根因**:坐标点击的 `clickCandidates` 先取 `bestElement(containing:)`。点在空白处时它是窗口记录(窗口有 `AXRaise`,`clickPriority` 排第一)。`performAXClickSequence` 对窗口本身做不了 `AXPress`,于是扫描窗口的全部子孙(`descendantClickCandidates`,按优先级和面积排序,与点击点无关),按下最小的那个可按元素,常常就是关闭按钮。
- **修复**:新增纯函数 `isDescendantClickCandidateAtTarget`,`performAXClickSequence` 的两处子孙扫描都经过它:坐标点击时子孙 frame 必须包含点击点;element_index 点击时子孙 frame 必须与目标的 click frame 重叠;没有 frame 的子孙不按。坐标点击把窗口点传进 `performAXClickSequence(clickPoint:)`。
- **element_index 路径**:就近命中回退会扫描命中元素的子孙;命中元素可能是窗口或大容器(frame 为空时 `shouldScanDescendantsOfHitRecord` 直接放行),所以也能按到目标之外。现在同样限制在目标 click frame 内;按下目标自己或其行内的子孙不受影响。
- 就近命中回退里重复计算的 `clickFrame(for:)` 复用同一个值。
- 补单元测试,并在 `docs/ARCHITECTURE.md` 写明这条规则。

### Design Intent (Why)

人点空白处什么也不会按下;坐标点击应该同样只作用于点下去的位置。问题出在子孙扫描不知道点击点,所以把约束放在扫描结果的唯一出口上,一处同时覆盖坐标点击、element_index 点击和就近命中回退三条路径。命中元素本身(AX hit-test 的结果)和祖先(列表选择、Electron 行级祖先)本来就在点击点上,保持不变。

与修复 `hitTestElement` 坐标空间的 #126 相互独立:本修复不改命中测试;没有 #126 时 2x 屏幕上的命中测试仍会探测错误位置,其结果作为候选仍可能按到旁边的控件,两者都合入后才完全干净。两边都在 `feature-release-notes.md` 表头后插入一行,合并时会有一个容易解决的文本冲突。

### Validation

- 新建一个不激活、位于所有窗口之后的 AppKit 探针:窗口有可用的关闭按钮、角落里一个小按钮、一个普通按钮和一块空白,并在 app 收到 mouseDown 时记录落点。CLI 在进程内运行(`OPEN_COMPUTER_USE_DISABLE_APP_AGENT_PROXY=1`、`OPEN_COMPUTER_USE_DEBUG_INPUT_FALLBACKS=1`)。
- 修复前:坐标点击空白处 (300,180),候选为窗口,"handled by descendant" 按下 16x16 的关闭按钮,探针窗口被关闭,回读报 `cgWindowNotFound`。
- 修复后:不按任何子孙,探针收到落在 (300,180) 的 mouseDown;窗口仍在。
- 修复前后一致:坐标点击和 element_index 点击普通按钮都会按下它。
- 已有探针:MarksProbe 修复前按下一个 0x0 的 AXButton,修复后不再按;AXSelectProbe 修复前按下(禁用的)关闭按钮,修复后不再按,但在没有 #126 时命中测试仍会按下列表头,叠加 #126 后什么也不按。
- 遗留:element_index 直接点窗口记录本身时,关闭按钮在窗口 frame 内,仍会被子孙扫描按下(修复前后相同),不在本次范围内。
- `swift test` 全量通过(384 项,7 项跳过)。

### Files Modified

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


### 2026-10-31 maintainer review

- Merged main `74d86db` including #126 and #139; preserved both release-note additions and the contributor commit.
- Closed the remaining window-index hole: explicit AXWindow index clicks skip both descendant and nearby hit searches and use the existing window Raise/focus fallback. Explicit Close button indexes remain actionable.
- Reject empty, null, infinite and non-finite descendant frames; added regression coverage for degenerate geometry.
- Extended the real AppKit/native MCP smoke with `--descendant-guards`. A window delegate counts Close requests and vetoes closing so a failure cannot lose the fixture mid-test.
- Real signed release build passed with 1200×864 and 300×216 screenshots, plus AX-only observation: blank points/actionless row/window indexes do not press Close or unrelated buttons; normal indexed and coordinate button clicks work; explicit Close produces exactly one request. #126 hit-test and image-omission checks remain covered.
- Negative control using the pre-#127 signed binary fails on the first blank coordinate click: one unexpected Close request.
- This desktop was in use. The completed live run used `--allow-desktop-activity`, which disables only foreground/pointer stability assertions and reports that limitation. Global pointer fallbacks are explicitly disabled. No claim of foreground/pointer stability is made for this run; explicit window Raise may change focus by design.
- `swift test`: 402 tests, 9 skipped, 0 failures. Documentation and whitespace checks pass.
1 change: 1 addition & 0 deletions docs/releases/feature-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
| 2026-10-29 | macOS 权限引导定位 | 已有辅助功能权限时,拖拽引导紧贴目标权限列表,更容易看清拖放方向;首次未授权时继续跟随设置窗口底边。 | 优先使用上方列表的增删控件容器定位,读不到时自动退回窗口锚点;后台有界读取 AX,跳过应用行。 |
| 2026-10-29 | macOS 权限引导 | 切换回系统设置后,权限引导浮窗的返回按钮与拖拽卡片完整显示。 | 修复入场动画中断后保留中间尺寸与透明度的问题,并固定动画结束尺寸。 |
| 2026-10-23 | macOS 点击命中测试 | 截图与窗口比例不是 1:1 时(如 Retina),`auto` 点击不再按下目标旁边的控件;只取 AX 的 element_index 点击也不再误报需要截图。 | `hitTestElement` 的入参本就是窗口点,改用窗口点换算,不再按截图像素再除一次缩放比例;修复前点击表格行会命中并按下上方列表头。 |
| 2026-10-23 | macOS 坐标点击 | 坐标点击点到空白处时,不再按下窗口里别处的按钮(例如关闭窗口)。 | 点击候选的子孙扫描只按下落在目标上的元素:坐标点击要求包含点击点,element_index 点击要求与目标重叠;空白处落到定向鼠标事件;窗口索引走 Raise,不扫描内部按钮,无效尺寸的子孙被过滤。修复前点空白处会按下窗口的关闭按钮。 |
| 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 @@ -446,6 +446,24 @@ func shouldScanDescendantsOfHitRecord(originalFrame: CGRect?, hitFrame: CGRect?)
return true
}

/// A descendant found by scanning below a click target may be pressed only where the click was aimed:
/// for a coordinate click its frame must contain the click point, for an element click it must overlap
/// the element's click frame. Without this, a coordinate click on a blank spot, whose best record is
/// the window, pressed the window's smallest button wherever it was, such as its close button.
func isDescendantClickCandidateAtTarget(candidateFrame: CGRect?, clickPoint: CGPoint?, targetFrame: CGRect?) -> Bool {
guard let candidateFrame, !candidateFrame.isEmpty, !candidateFrame.isInfinite,
candidateFrame.minX.isFinite, candidateFrame.minY.isFinite,
candidateFrame.width.isFinite, candidateFrame.height.isFinite else {
return false
}

if let clickPoint {
return candidateFrame.contains(clickPoint)
}

return targetFrame.map(candidateFrame.intersects) ?? false
}

func isLikelyContainingRowActionFrame(
targetFrame: CGRect,
candidateFrame: CGRect?,
Expand Down Expand Up @@ -767,7 +785,8 @@ public final class ComputerUseService {
button: button,
clickCount: clickCount,
includeNearbyHitTesting: false,
allowActivationFallback: false
allowActivationFallback: false,
clickPoint: point
) {
handled = true
break
Expand Down Expand Up @@ -1398,8 +1417,13 @@ public final class ComputerUseService {
button: MouseButtonKind,
clickCount: Int,
includeNearbyHitTesting: Bool,
allowActivationFallback: Bool
allowActivationFallback: Bool,
clickPoint: CGPoint? = nil
) throws -> Bool {
let targetFrame = clickFrame(for: record, snapshot: snapshot)
let isAtTarget = { (candidate: ElementRecord) in
isDescendantClickCandidateAtTarget(candidateFrame: candidate.localFrame, clickPoint: clickPoint, targetFrame: targetFrame)
}
let preferContainingWebRowAXClick = shouldPreferContainingWebRowAXClick(record, in: snapshot)
debugClickDecision("record=\(clickDebugDescription(record)) preferContainingWebRowAXClick=\(preferContainingWebRowAXClick)")

Expand All @@ -1410,14 +1434,19 @@ public final class ComputerUseService {
return true
}

if !preferContainingWebRowAXClick {
// Selecting a window index means the window itself, not a button inside it.
// Skip both descendant and nearby-point searches and use the existing raise path.
let windowIndexClick = clickPoint == nil && record.element.map {
canUseActivationOnlyClickFallback(role: stringValue(of: $0, attribute: kAXRoleAttribute))
} == true
if !preferContainingWebRowAXClick && !windowIndexClick {
if try performPreferredClick(on: record, button: button, clickCount: clickCount) {
debugClickDecision("handled by preferred target \(clickDebugDescription(record))")
pauseBeforeReadBack(0.15)
return true
}

for candidate in descendantClickCandidates(for: record, snapshot: snapshot) {
for candidate in descendantClickCandidates(for: record, snapshot: snapshot) where isAtTarget(candidate) {
if try performPreferredClick(on: candidate, button: button, clickCount: clickCount) {
debugClickDecision("handled by descendant \(clickDebugDescription(candidate))")
pauseBeforeReadBack(0.15)
Expand All @@ -1440,14 +1469,14 @@ public final class ComputerUseService {
}

if shouldScanDescendantsOfHitRecord(
originalFrame: clickFrame(for: record, snapshot: snapshot),
originalFrame: targetFrame,
hitFrame: hitRecord.localFrame
) {
for candidate in descendantClickCandidates(
for: hitRecord,
snapshot: snapshot,
sideActionScope: record
) {
) where isAtTarget(candidate) {
if try performPreferredClick(on: candidate, button: button, clickCount: clickCount) {
debugClickDecision("handled by hit descendant \(clickDebugDescription(candidate))")
pauseBeforeReadBack(0.15)
Expand Down
Loading