commit 8798dbd7b62caa77195c94c8c6c233bce81944f1 Author: Kinneyzhang Date: Fri Oct 2 09:26:20 2026 +0800 release v0.1.0 diff --git a/MANIFEST.json b/MANIFEST.json new file mode 100644 index 0000000..e445edf --- /dev/null +++ b/MANIFEST.json @@ -0,0 +1,256 @@ +{ + "manifest_version": 1, + "name": "macos-desktop-control", + "summary": "操作 macOS 桌面与应用界面:读无障碍元素树、点击按钮/菜单、填输入框、发文本与组合键、按窗口截图", + "tier": 1, + "status": "stable", + "version": "2d2f88d", + "source": { + "path": "skills/macos-desktop-control", + "commit": "2d2f88d", + "describe": "2d2f88d", + "dirty": false, + "packed_at": "2026-10-02T01:26:20.586Z" + }, + "platforms": [ + "darwin" + ], + "entry": { + "path": "scripts/desktop-ctl.sh" + }, + "commands": [ + "check", + "apps", + "front", + "windows", + "tree", + "find", + "get", + "shot", + "ocr", + "focus", + "press", + "set", + "menu", + "type", + "key", + "win-state", + "win-place", + "win-screen", + "win-fullscreen", + "win-minimize", + "win-unminimize", + "click", + "drag", + "scroll", + "space-list", + "space-goto", + "space-move", + "volume", + "media", + "brightness", + "clipboard", + "wifi", + "screen-list", + "input-source" + ], + "errors": [ + "usage", + "deps", + "platform", + "auth", + "notfound", + "conflict", + "blocked", + "timeout", + "external", + "internal" + ], + "aliases": [ + "desktop-ctl" + ], + "admin": [], + "depends_on": [], + "requires": [ + { + "kind": "binary", + "name": "swiftc", + "install": "xcode-select --install(Command Line Tools)", + "check": "swiftc --version", + "note": "截图/窗口原语的小工具需要编译;已编译过就不再需要" + }, + { + "kind": "binary", + "name": "shasum", + "install": "macOS 自带(perl)", + "check": "shasum --version" + } + ], + "config": [], + "external_imports": [], + "contract": { + "version": 1, + "skill": "macos-desktop-control", + "tier": 1, + "platforms": [ + "darwin" + ], + "commands": { + "apps": { + "destructive": false + }, + "brightness": { + "destructive": true + }, + "check": { + "destructive": false + }, + "click": { + "destructive": true + }, + "clipboard": { + "destructive": true + }, + "drag": { + "destructive": true + }, + "find": { + "destructive": false + }, + "focus": { + "destructive": false + }, + "front": { + "destructive": false + }, + "get": { + "destructive": false + }, + "input-source": { + "destructive": true + }, + "key": { + "destructive": true + }, + "media": { + "destructive": true + }, + "menu": { + "destructive": true + }, + "ocr": { + "destructive": false + }, + "press": { + "destructive": true + }, + "screen-list": { + "destructive": false + }, + "scroll": { + "destructive": true + }, + "set": { + "destructive": true + }, + "shot": { + "destructive": false + }, + "space-goto": { + "destructive": true + }, + "space-list": { + "destructive": false + }, + "space-move": { + "destructive": true + }, + "tree": { + "destructive": false + }, + "type": { + "destructive": true + }, + "volume": { + "destructive": true + }, + "wifi": { + "destructive": true + }, + "win-fullscreen": { + "destructive": true + }, + "win-minimize": { + "destructive": true + }, + "win-place": { + "destructive": true + }, + "win-screen": { + "destructive": true + }, + "win-state": { + "destructive": false + }, + "win-unminimize": { + "destructive": true + }, + "windows": { + "destructive": false + } + }, + "errors": [ + "auth", + "blocked", + "conflict", + "deps", + "external", + "internal", + "notfound", + "platform", + "timeout", + "usage" + ], + "aliases": [ + "desktop-ctl" + ], + "adminAliases": [] + }, + "files": [ + { + "path": "REFERENCE.md", + "bytes": 35774, + "sha256": "62f1daf1602dc924" + }, + { + "path": "SKILL.md", + "bytes": 6906, + "sha256": "37960b69f6ca1435" + }, + { + "path": "VERSION", + "bytes": 6, + "sha256": "e9dd8507f4bf0c6f" + }, + { + "path": "contract.lock.json", + "bytes": 2056, + "sha256": "936e1b4f3dc8aaca" + }, + { + "path": "interface.json", + "bytes": 5105, + "sha256": "a38ad5e9c2119891" + }, + { + "path": "scripts/desktop-ctl.sh", + "bytes": 7574, + "sha256": "22827dc8cd5e4831" + } + ], + "leak_scan": { + "errors": 0, + "warnings": 0, + "findings": [] + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b781f6 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# macos-desktop-control + +操作 macOS 桌面与应用界面:读无障碍元素树、点击按钮/菜单、填输入框、发文本与组合键、按窗口截图 + +以 Pi package(技能形态)发布。 +技能本体在 `skills/macos-desktop-control/`,用法见其 `SKILL.md` 与 `REFERENCE.md`。 + +## 安装 + +```sh +pi install git:gitea.vhkd.top/geekinney/macos-desktop-control.git@v0.1.0 +``` + +装完 `pi-skill list` 能看到 `macos-desktop-control`,`pi-skill macos-desktop-control check` 会告诉还缺什么。 diff --git a/package.json b/package.json new file mode 100644 index 0000000..e73d461 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "name": "macos-desktop-control", + "version": "0.1.0", + "description": "操作 macOS 桌面与应用界面:读无障碍元素树、点击按钮/菜单、填输入框、发文本与组合键、按窗口截图" +} diff --git a/skills/macos-desktop-control/REFERENCE.md b/skills/macos-desktop-control/REFERENCE.md new file mode 100644 index 0000000..051cade --- /dev/null +++ b/skills/macos-desktop-control/REFERENCE.md @@ -0,0 +1,399 @@ +# REFERENCE — macOS 桌面控制 + +SKILL.md 是选路表;本文件是映射表、配方、排查与原理。**只有命令报错或需要扩能力时才读这里。** + +## 1. 命令映射表:用户要什么 → 执行什么 + +| 用户要 | 执行 | +|---|---| +| 现在有哪些应用 / 某个应用开了吗 | `apps`(`--all` 含无 Dock 图标的) | +| 屏幕上有什么窗口 / 某个窗口的 ID | `windows` | +| 某个应用的界面结构 | `tree <应用> --depth 6` | +| 找某个按钮/输入框/菜单项 | `find <应用> --role AXButton --title 保存` | +| 看某个元素的全部属性、能不能点 | `get <应用> <地址>` | +| 读某个元素的某个值 | `get <应用> <地址> value`(或 `title` `enabled` `selectedText` `position`) | +| 点按钮 / 点单元格 / 点标签 | `press <应用> <地址> --yes` | +| 往输入框里写内容 | `set <应用> <地址> "文本" --yes` | +| 点菜单(含子菜单) | `menu <应用> <一级> <二级> ... --yes` | +| 键入文本(含中文) | `type <应用> "文本" --yes` | +| 发快捷键 | `key <应用> cmd+s --yes`(可连发:`key <应用> cmd+a cmd+c --yes`) | +| 切到某个应用 | `focus <应用> --yes` | +| 截图某个窗口 / 应用 / 主屏 | `shot --window --out /tmp/x.png` / `--app <应用>` / `--screen` | +| **读界面上的文字(AX 树为空,如 CEF/Electron)** | `ocr --app <应用> [--find 文字]`,每行给 `@中心x,y`,可直接接 `click` | +| 权限/环境自检 | `check` | +| **把窗口摆成半屏/四角/三分之一/居中/整屏** | `win-place <应用> left`(具名区域见下) | +| **摆好并且让我看到它(置前)** | `win-place <应用> <区域> --front --yes`(摆完强制置前,并用 AX 命中测试验证;实测裸 win-place 会被 Chrome 全屏窗口盖住,Emacs 连 `activate` 都不抬窗) | +| **窗口放到精确位置尺寸** | `win-place <应用> "100,25,720,875"`(绝对屏幕坐标) | +| **按比例摆窗口** | `win-place <应用> "0%,0%,33%,100%"`(相对可视区) | +| **把窗口移到另一块屏** | `win-screen <应用> next` / `prev` / `0` | +| **全屏开关 / 最小化 / 恢复** | `win-fullscreen <应用> on\|off\|toggle` / `win-minimize` / `win-unminimize` | +| **看窗口位置尺寸、全屏/最小化、在哪块屏** | `win-state <应用>` | +| **点屏幕某个坐标** | `click 800,400` | +| **点某个元素的几何中心**(AX 点不动的自绘控件) | `click <应用> <地址>` | +| **双击 / 右键** | `click <坐标或元素> --double` / `--right` | +| **拖拽**(选文本、拖文件、拖滑块) | `drag , ,` | +| **滚动** | `scroll down --amount 20 --at 800,400` | +| **有几块桌面 / 现在在第几块** | `space-list` | +| **切换桌面** | `space-goto next` / `prev` / `0` | +| **把窗口丢到别的桌面** | `space-move <应用> next` | +| **读/设系统音量、静音** | `volume`(读)/ `volume 40`、`volume mute`(写) | +| **播放/暂停、上/下一曲** | `media play-pause` / `next` / `previous`(**发出即完成,不要读 UI、不要验证**) | +| **谁在播放 / 确认媒体键接收方** | `media status`(读播放断言与音频输出持有者;`players=` 空即没人播) | +| **读/设显示器亮度** | `brightness`(读)/ `brightness 60`、`brightness up`(写) | +| **读/写剪贴板** | `clipboard`(读文本)/ `clipboard "文本"`(写文本)/ `--image <文件>`(图片进剪贴板)/ `--out <文件>`(存出剪贴板里的图)/ `--info`(只看类型) | +| **读/开关 Wi-Fi** | `wifi`(读)/ `wifi on`、`wifi off`(写) | +| **几块屏、各自几何与可视区** | `screen-list`(多屏布局时用来确认 `--on-screen N` 的 N) | +| **有哪些输入法 / 当前是哪个 / 切换** | `input-source` / `input-source list` / `input-source <名字> --yes` | +| **打开文件 / 在 Finder 里定位** | 系统 CLI:`open <路径>` / `open -R <路径>`(不包,直接用) | +| **挂载 / 弹出磁盘** | 系统 CLI:`diskutil list` / `diskutil eject /Volumes/X`(不包) | +| **Finder 里选中文件、右键、拖文件** | 用现有原语组合(见 §5 配方) | +| 只看不做的预演 | 上述改变状态的命令**不带 `--yes`** 即是预演(也可显式 `--dry-run`) | + +### `win-place` 的区域写法 + +| 写法 | 例 | 含义 | +|---|---|---| +| 具名 | `left` `right` `top` `bottom` `topleft` `topright` `bottomleft` `bottomright` `center` `fill` | 相对该屏可视区 | +| 三分之一 | `left-third` `center-third` `right-third` `left-two-thirds` `right-two-thirds` | 相对该屏可视区 | +| 像素 | `100,25,720,875` | **绝对屏幕坐标**(左上原点,与 `windows` 输出一致) | +| 比例 | `0%,0%,33%,100%` | 相对该屏可视区(已扣除菜单栏与 Dock) | + +两者可混用,例 `100,0%,800,100%`。默认按**窗口当前所在屏幕**计算,多屏不会被拉回主屏;`--on-screen N` 强制指定。 + +全局旗标:`--json --dry-run --yes --quiet --timeout <秒>`。`--timeout` 设 AX 消息超时(默认 3 秒)。 + +## 2. 地址语法 + +``` +app 应用自身 +menubar 菜单栏(tree/find 加 --menubar 才列出) +window[0] 第 0 个窗口 +focused 当前焦点元素 +window[0]/child[1]/child[3] 逐层下钻 +``` + +- **地址是当次快照的下标**,界面一变就漂移。取到地址后立刻用,不要跨多轮对话复用。 +- 唯一较稳定的形式是 `window[N]`(窗口数不变时)。要连续操作同一窗口,先用 `windows` 确认窗口数。 +- 找不到地址就先 `find`,不要猜。失败时 `hint=` 会给重新取地址的命令。 +- 需要跨调用复用地址时,用 `--json` 一次拿到,例如: + ```bash + A=$(pi-skill macos-desktop-control find TextEdit --role AXTextArea --json \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["items"][0]["address"])') + pi-skill macos-desktop-control set TextEdit "$A" "内容" --yes + ``` + +## 3. 焦点规则 + +| 命令 | 需要目标应用在前台吗 | +|---|---| +| `check apps front windows tree find get shot` | 不需要 | +| `press set menu` | **不需要**——AX 动作直接投递到目标应用,也不改变用户焦点 | +| `win-state win-place win-screen win-fullscreen win-minimize win-unminimize` | **不需要**——走 AX 改窗口属性,也不改变用户焦点 | +| `type key` | **需要**——会自动激活、raise 窗口、设焦点,并等 300ms 稳定;等不到就报 `error=blocked` | +| `click drag scroll`(坐标形式) | **需要**——见下方"落点归属" | + +### 落点归属(坐标点击/拖拽特有) + +坐标没有"目标应用"的概念,事件落到**最上层窗口**。本工具会: + +1. 用 **AX 命中测试**(`AXUIElementCopyElementAtPosition`)报出真实接收者:`over_app` / `over_role` / `over_title`。 + 不要用 CGWindowList 判最上层——里面有一堆覆盖整屏的高层窗口(Dock、通知中心、输入法、平板驱动), + 按它判断会把普通窗口误判成 `Window Server`。 +2. **先激活接收者再发事件**。macOS 对后台窗口有"首次点击只激活窗口"的行为,那一下会被吞掉; + `focus` 只把应用带到前台,**窗口不会成为 key**,所以不先激活的话第一次点击静默无效。 +3. 传 `--expect-app <应用>` 时,接收者不符直接 `error=conflict`,避免静默点到别的应用。 +4. 屏幕外坐标直接拒绝:自动隐藏的 Dock 会报告 y=900 以下(屏幕高 900),发出去无效却报成功。 + +`type`/`key` 是唯一会改变用户焦点的命令。用户没让你切应用时,优先用 `set`/`press`/`menu`。 + +## 4. 跨层规则:什么时候不用本工具 + +本工具只做系统没有 CLI 等价物的事。**不要包一层再调**: + +| 需求 | 直接用 | +|---|---| +| app 自身的结构化 API(播放/退出/打开文件/关闭文档) | `osascript -e 'tell application "X" to ...'` | +| 读写应用偏好与隐藏设置 | `defaults read/write ` | +| 找文件(全文/元数据) | `mdfind` | +| 服务、自启、重启进程 | `launchctl` | +| 电源、休眠、防睡眠 | `pmset` / `caffeinate` | +| 日志 | `log show --predicate` / `log stream` | +| 系统与硬件信息 | `system_profiler -json` | +| 网络配置 | `networksetup` / `scutil` | +| 运行快捷指令 | `shortcuts run "<名称>"` | +| 需要常驻状态或事件回调(全局热键、监听界面变化) | `hs -c ''`(Hammerspoon IPC,见 §12) | +| **只在某个界面控件上才会发生的事** | ✅ 才用本工具 | + +**关键分工**:Apple Events 能做的优先用它(快、稳、无坐标依赖);Apple Events 做不到的(点任意按钮、读界面文本、操作没有 sdef 字典的 app)才用 AX。 + +### 本工具内部的实现归属(按实测决定,不按偏好) + +| 维度 | 归谁 | 依据 | +|---|---|---| +| AX 元素树、内容读写、Dock/菜单栏图标 | **本技能自己的 Swift** | hs 的 `axuielement` 也能做,但已有且验证过,且这层还要兼做契约 | +| 窗口几何、多屏、最小化/全屏 | **转发 Hammerspoon** | 实测与裸 AX **结果完全一致**(4 种布局全精确、越界处理相同、稳定性 6/6),而 hs 更快(7.9ms vs 36ms,它是常驻进程 + 轻量 IPC) | +| 鼠标点击/拖拽/滚动 | **转发 Hammerspoon** | `hs.eventtap` 成熟;本技能只在其外包了契约与安全层 | +| 桌面、系统音量、媒体、亮度、剪贴板、Wi-Fi | **薄转发 + 契约包装** | 后端已存在(见下表),本技能只做参数解析、统一入口与契约输出 | + +### 桌面与系统层的后端归属(每条命令的 `source=` 会标明) + +| 命令 | 后端 | 为什么是它 | +|---|---|---| +| `space-list` `space-goto` `space-move` | `hs.spaces` | 系统没有 CLI 等价物;AX 也不暴露 Spaces | +| `brightness` | `hs.brightness` | 系统没有 CLI 等价物 | +| `media` | 系统媒体键事件 | 与具体播放器无关,用哪个 app 在放就控制哪个 | +| `media status` | `pmset -g assertions` | 系统自带;读应用自己的 `is playing` 断言 + coreaudiod 的 audio-out 持有者,不依赖 Hammerspoon | +| `volume` | `osascript` | 系统自带(Apple Events 到系统音量);本技能只补回读与错误码 | +| `clipboard` | `pbpaste` / `pbcopy`(文本)、`osascript` / `NSPasteboard`(图片)| 系统自带,且**不依赖 Hammerspoon 运行** | +| `wifi` | `networksetup` | 系统自带 CLI;接口名自动探测(它随机器变化) | +| `screen-list` | `NSScreen` | 本技能自己读(无外部依赖);只读,不改排列 | +| `input-source` | `hs.keycodes` | 系统没有 CLI 等价物 | + +### 做得到 vs 做不到(如实记录,别绕) + +**做不到,且已确认原因**: + +| 需求 | 为什么做不到 | +|---|---| +| **窗口置顶**(让某窗口浮在其它应用之上) | 需要 `NSWindow.level`,那是应用自己的窗口才能设;改别的应用的层级要私有 API。Hammerspoon 也没有这个能力 | +| **切换主屏 / 排列显示器** | `hs.screen` 只有 `screenPositions`(读),没有 setter;macOS 没有公开 API,只能在系统设置里改 | +| **操作系统安全提示框** | TCC 授权框、登录窗、FileVault 解锁由系统保护,任何进程都点不了 | +| **Secure Input 状态下注入键盘** | 密码框、1Password 等会开启 secure input,键盘事件被系统吞掉 | +| **读通知内容** | 通知中心的内容不通过 AX 暴露;`hs.distributednotifications` 只能监听广播,读不到内容 | +| **AX 树为空的界面**(部分 Electron canvas / Java / Flutter / 游戏) | 应用没实现无障碍接口,只能退到 `shot` 看像素 | + +这些维度系统或 Hammerspoon 已有成熟实现,包一层是为了**统一入口与契约**(错误码、默认预览、写入回读、`source=`), +不是为了重写逻辑。若只想要原始能力,直接用上表的后端命令即可。 + +硬证据:`src/winio.swift` 里直接写 `AXPosition`/`AXSize` 的次数是 **0** —— 窗口几何完全交给 hs,没有第二套实现。 + +**本技能给转发层补的是可靠性,不是重写能力**:落点归属报告与断言、屏幕外坐标拒绝、拖拽后等窗口停稳、全屏/最小化的轮询到目标态、hs 返回值定界。这些都是裸 Lua 没有的。 + +**Hammerspoon 的边界**(决定哪些必须自己写):它有 109 个模块但没有**通用 Objective-C 桥**(`hs.objc` 不存在),所以能力上限就是那些模块的并集;模块外的系统框架它够不着,只能退回 `hs.task` 或 `hs.osascript`。本技能的 Swift 层不受此限制。 + +## 5. 配方(多步组合) + +- **控制音乐播放器(网易云 / Spotify / Music)**:媒体键与播放器无关,**发出即成功**(`executed=true`), + 不要再 `tree`/`find`/`shot` 去验证: + ```bash + pi-skill macos-desktop-control media next --yes # 下一首(play-pause / previous 同理) + pi-skill macos-desktop-control media status # 只有需要确认“谁在播”时才读 + ``` + 实测事实(网易云 NeteaseMusic,CEF):无可见窗口时 AX 树**只有菜单栏**,读不到播放栏与歌名,不要探索; + 媒体键被别的应用抢走时的回退是它自己的菜单:`menu NeteaseMusic Controls Next --yes`(`Controls` 含 Pause/Next/Previous)。 +- **处理弹出的对话框 / sheet**:对话框就是一个新窗口或子元素。先 `tree <应用> --depth 4` 找 `AXSheet`,再 `find <应用> --role AXButton` 列出它的按钮,`press` 你要的那个。 +- **关闭有未保存修改的文档**:AX 处理不了“是否存储”的决策,用 Apple Events: + ```bash + osascript -e 'tell application "TextEdit" to close every window saving no' + ``` + 然后**必须**检查落盘残留:未命名文档会被 autosave 到 + `~/Library/Mobile Documents/com~apple~TextEdit/Documents/`,下次启动会被恢复; + autosave 记录在 `~/Library/Containers/com.apple.TextEdit/Data/Library/Autosave Information/`。 + 确认是本次产生的再删,别碰用户自己的文档。 +- **点菜单并确认生效**:`menu` 之后回读状态。例:`menu <应用> Format Font Show Fonts --yes` 后再 `find <应用> --role AXWindow --title Fonts`。 +- **操作还没打开的应用**:`open -a "<应用>"` → 等 `apps` 里出现 → 再 `tree`。不要对没启动的应用调 `tree`。 +- **截图给人看**:先 `windows` 取 `cg_window_id`,再 `shot --window --out /tmp/<有意义的名字>.png`,把路径报给用户。 +- **在 Finder 里操作文件**(选中/右键/拖拽):Finder 的图标是 AX 元素,用 `tree`/`find` 定位后 `press`;选中后按 `cmd+c`/`cmd+v` 用 `key`。要"用某个应用打开"或"在 Finder 里显示",直接用系统 CLI 更省事:`open -a <应用> <文件>` / `open -R <文件>`。 +- **一键多窗口布局**(把 N 个应用摆成左中右): + ```bash + for a in Safari "Google Chrome" Emacs; do pi-skill macos-desktop-control win-place "$a" fill --yes; done + pi-skill macos-desktop-control win-place Safari left-third --yes + pi-skill macos-desktop-control win-place "Google Chrome" center-third --yes + pi-skill macos-desktop-control win-place Emacs right-third --yes + ``` + `win-place` 不抢焦点、不要求应用在前台,所以整条可以一次跑完。 +- **判断一次窗口操作到底是瞬时还是有动画**(“窗口慢慢变/渐进”类问题的通用判据):录整屏,再数“画面跨了几帧”。 + ```bash + screencapture -v -V8 -x /tmp/anim_check.mov & # -V 后不能有空格;目标窗口必须在前台可见 + sleep 2; <在这里触发一次窗口操作>; sleep 5 + ffmpeg -loglevel error -i /tmp/anim_check.mov -vf "tblend=all_mode=difference,signalstats,metadata=print:file=-" \ + -f null - 2>/dev/null | grep YAVG | awk -F= '$2>1.0 {print NR": "$2}' + ``` + 判读:只出现 1~2 帧 = 瞬时;连续 7~9 帧、跨 0.25~0.45s = 应用在做分步落地(iTerm2 3.6.9 实测如此,同期 TextEdit 是 1 帧)。 + **不要用 AX 读窗口几何下结论**:动画期间读到的都是中间值(实测 x 70→41→18→11 逼近目标,会被误判成“写不进去”)。 + **`screencapture -l <窗口>` 对遮挡窗口给的是过期画面**,所以必须让目标窗口可见。 +- **查是谁在注入合成键鼠**(按键被吞、窗口自己移动、焦点乱跳、两个 agent 抢输入):真硬件事件的 `eventSourceUnixProcessID` 是 0,非 0 的 pid 就是注入者。 + ```bash + hs -c 'local f=io.open("/tmp/input-src.txt","a") local P=hs.eventtap.event.properties + f:write("START\n") f:flush() + local t=hs.eventtap.new({hs.eventtap.event.types.keyDown,hs.eventtap.event.types.leftMouseDown, + hs.eventtap.event.types.leftMouseUp,hs.eventtap.event.types.leftMouseDragged},function(e) + local pid=e:getProperty(P.eventSourceUnixProcessID) + if pid and pid~=0 then f:write(string.format("%.1f pid=%d\n",hs.timer.secondsSinceEpoch(),pid)) f:flush() end + return false end) + t:start() hs.timer.doAfter(120,function() t:stop() end)' + cat /tmp/input-src.txt; ps -p <上面出现的 pid> -o pid,comm= # 用 pid 认人,用完删掉 /tmp/input-src.txt + ``` + 实测:ChatGPT 桌面版的 Computer-Use agent(`/Applications/ChatGPT.app/.../@oai/cua-repl`)就是这样认出来的;它会在用户操作的同时注入鼠标拖拽,把窗口拖走。 + +## 6. 权限模型 + +macOS 的 TCC 权限**按「责任进程」授予**。从 iTerm2 运行 → 授权记在 iTerm2 上 → 它派生的所有子进程(swift / osascript / python / 脚本)全部继承。这是本工具能在 CLI 里直接拿到完整权限的原因。 + +| 权限 | 用到的地方 | 检查 | 授予位置 | +|---|---|---|---| +| Accessibility | 全部 AX 命令、CGEvent 注入 | `check` 的 `accessibility` | 系统设置 › 隐私与安全性 › 辅助功能 | +| Screen Recording | `windows` 的标题字段、`shot` | `check` 的 `screen_recording` | 系统设置 › 隐私与安全性 › 屏幕录制 | +| Automation | `osascript` 发 Apple Events | 首次调用弹框;拒绝后报 `-1743` | 系统设置 › 隐私与安全性 › 自动化 | + +改完授权**必须完全重启承载进程**(退出并重开终端)才生效。换宿主(换成 launchd 后台任务或 Hammerspoon)需要在新宿主上重新授权。 + +## 7. 失败契约 + +- 失败一律在 stderr 上给 `error=<封闭码>` + `hint=<可直接执行的下一步>`。 +- 退出码:**0 成功**;**2 调用错误**(重新读 `help`);**其它非零 = 执行失败**(看 `error=`)。 +- 封闭错误码:`usage deps platform auth notfound conflict blocked timeout external internal` +- 写入类命令遵守 **validate → write → 回读**,输出 `value_written` / `value_readback` / `verified`,禁止只报“已写入”。 +- 读取类命令输出带 `source=` 与 `as_of=`。 + +## 8. 排查表 + +| 症状 | 原因 | 处理 | +|---|---|---| +| `error=auth` | Accessibility 或 Screen Recording 未授权 | `check`;授完权限**完全重启终端** | +| `tree` 输出空 / 只有窗口没有内部元素 | Accessibility 未生效,或应用不可访问 | 同上 | +| `error=timeout`(`目标应用无响应`) | 应用卡住或在等模态框 | 看它是否有 `AXSheet`/`AXDialog` 需要处理 | +| `error=conflict` 不可写 | 元素只读(按钮、静态文本) | 按钮/菜单项用 `press`;能写的是文本框、勾选框、下拉框 | +| `error=conflict` 没有可执行的动作 | 容器元素没有 AXPress | 往下找真正的按钮子元素;`get` 看 actions | +| 按键被吞 / 窗口自己移动 / 焦点乱跳(不是错误码,是现象) | 有别的进程在注入合成键鼠(Computer-Use agent、自动化脚本、另一个会话) | 跑 §5「查是谁在注入合成键鼠」按 pid 定位,停掉那个进程 | +| 窗口调整“慢慢变”/要按好几次(现象) | 应用自己分步落地(iTerm2 3.6.9 实测 7~9 步 / 0.25~0.45s);动画期间重复下发会把窗口停在中间位置 | 用 §5 的动画判据确认;**不要加重试**,改为一次下发后轮询到稳定(`win-place` 已这样做) | +| `error=notfound` 地址失效 | 界面在两次调用之间变了 | 重新 `tree`/`find` 取地址(设计如此,不是 bug) | +| `window[N] 不存在` | 窗口被关闭/新建 | `windows` 重新确认窗口数 | +| `type` 报成功但界面没变 | 目标不是前台,或 Secure Input 打开 | 看是否报 `blocked`;密码框无法注入,这是系统限制 | +| 截图全黑 / 只有壁纸 | Screen Recording 未生效 | `check`;完全重启承载终端 | +| `error=notfound` 截图失败 | CGWindowID 已失效(窗口关了) | `windows` 重新取 ID | +| `error=notfound` 菜单某级找不到 | 菜单标题语言/名称不符 | 按报错里列出的**真实可用项**改标题 | +| `error=conflict` 取不到菜单栏 | 应用自绘菜单(部分 Electron) | 改用 `tree` 直接找按钮元素 | +| `error=notfound` 找不到应用 | 应用没启动或名字拼错 | 报错里列了实际在跑的;先 `open -a` | +| `error=deps` 找不到 swiftc | 没装 Xcode CLT | `xcode-select --install` | +| `error=deps` Hammerspoon 未运行 | 窗口/鼠标类命令依赖 hs.ipc | `pkill -x Hammerspoon && open -a Hammerspoon`;检查 `~/.hammerspoon/init.lua` 顶部有 `require('hs.ipc')` | +| `win-place` 报 `exact=false` | 被系统约束(超出工作区/最小尺寸) | 输出里有 `requested` 对比;改用更小的尺寸或换区域 | +| 坐标 `click` 报 `error=conflict` 接收者不符 | 落点被别的窗口占着 | 先 `focus <目标应用> --yes`,或用 `click <应用> <地址>` 走元素定位 | +| 坐标 `click`/`drag` 报"不在任何屏幕内" | 坐标在屏幕外,常见于自动隐藏的 Dock | 让 Dock 显示出来,或改用 `press` 走 AX | +| `win-screen` 报"只有 1 块屏幕" | 没接外接显示器 | 正常行为;`check` 的 `screens` 字段可确认 | + +## 9. 已知坑(都已在实现里处理,扩展时别踩回去) + +1. **AXValue 是不透明容器。** `AXPosition`/`AXSize`/`AXSelectedRange` 不能当 String/NSNumber 读,必须 `AXValueGetValue` 按类型解包。见 `axValueDescribe`。 +2. **AX 调用会挂住。** 目标应用卡死时 AX 调用会一直等,所以每个应用元素都设了消息超时(`AXUIElementSetMessagingTimeout`,可用 `--timeout` 调)。 +3. **`NSWorkspace.frontmostApplication` 在短命 CLI 里不可靠。** 它靠 run loop 处理分布式通知刷新,读到的是陈旧值。必须用 system-wide 元素的 `kAXFocusedApplicationAttribute` + `AXUIElementGetPid`。见 `systemFocusedPID`。 +4. **元素树可能有几千个节点。** 遍历有 `--depth`(默认 8)和 2500 节点预算,超了告警而不是卡死。 +5. **CJK 是双宽字符。** 列对齐不能用 `padding(toLength:)`(按字符数),要用显示宽度。见 `displayWidth`/`pad`。 +6. **Swift 单表达式太长会编译超时。** `J.o([...])` 超过约 8 项可能触发 `unable to type-check in reasonable time`,要拆成中间变量。见 `NodeInfo.json`。 +7. **未命名文档会被 autosave 并在下次启动时恢复。** 见 §5 的关闭配方;其他 autosave 应用(Preview、QuickTime)同理。 +8. **预览输出不能混进 `--json`。** 破坏性命令统一走 `Contract.previewOrProceed`,它在 JSON 模式下只输出 JSON 外壳,不输出 `key=value`。 +9. **入口脚本不能用 `dirname`。** PATH 被裁剪时自举会失败;用 `${BASH_SOURCE[0]%/*}` 参数展开定位自身。依赖检查(swiftc)必须放在取编译锁之前,否则 PATH 异常时报的是 `mkdir: command not found` 而不是可执行的修复命令。 + +## 10. 窗口与鼠标层的已知行为(都是实测踩出来的) + +1. **窗口状态是异步的,固定 sleep 一定出错。** 摆放有动画、全屏有 1 秒以上的过渡、最小化恢复也是异步。 + 本工具内部一律**轮询到目标态**再返回(`win-place` 关掉动画并轮询到几何稳定;`win-fullscreen` 轮询 100×100ms; + `win-minimize` 轮询 40×50ms)。调用方读到的一定是停稳后的值。 +2. **拖拽结束后窗口还会滑行。** 实测拖完 0.2s 读到的位置只有终点的约 50%,所以 `drag` 会等几何连续 4 次采样不变才返回,并把结果放进 `settled_x`/`settled_y`。 +3. **拖窗口标题栏不可靠。** macOS 窗口拖拽有死区与吸附:实测同样 +120px 的位移,在不同方向上会少走一半甚至完全不走(40,200 时 x 完全不移动)。**移动窗口请用 `win-place`。** +4. **首次点击会被吞。** 后台窗口的第一次点击只用于激活,`NSRunningApplication.activate()` 之后窗口也不会成为 key。 + `click`/`drag` 会先激活接收者;`type`/`key` 会显式 `AXRaise` + 设 `AXFocused`,再等 300ms。 +5. **坐标点击没有"目标应用"。** 落点归属必须用 **AX 命中测试**判断,不能用 CGWindowList 最上层——后者会把普通窗口误判成 `Window Server`(屏幕上有 Dock/通知中心/输入法/驱动等覆盖整屏的高层窗口)。 +6. **AX 报告几何 ≠ 实际渲染几何。** 实测 TextEdit 在退出全屏后会进入不一致状态:AX 与 CGWindowList 都报 `720x875`,但文本实际在约 300px 处换行。这是 macOS/应用侧行为,不是本工具能修的;依赖像素坐标的连续操作遇到它必然失准。 +7. **hs 的 console 噪声会混进 stdout/stderr。** Hammerspoon 会把 hotkey 提示等异步写出来,与返回值混在一起。 + 所以 Lua 侧一律 `return "MDC|" .. <值>`,Swift 侧只认带前缀的行(`hsUnmark`);失败判定只看退出码和 Lua 错误标记,不看 stderr 是否为空。 +8. **`hs.window.find("应用名")` 不按应用名匹配**(实测返回 nil)。窗口定位一律按 **pid** 遍历 `hs.window.allWindows()`。 +9. **显示器亮度是硬件量化的。** 实测多数值精确、个别差 1(42→41、90→89),所以 `brightness` 的 `verified` 留 ±2 容差并报出 `requested`;要求逐位相等会把正常的硬件行为报成失败。 +10. **空间切换与无线开关是异步的。** `space-goto` 回读确认后才返回;`wifi` 切换后等 0.5 秒再回读。二者都不可幂等(切换本身改变状态),所以默认预览。 +11. **CGWindowList 的前后顺序 ≠ 实际堆叠。** 实测窗口被盖住与否用它判断会出错(Emacs 明明可见时它仍报在第 4 层)。判断"用户实际看到什么"必须用 **AX 命中测试**(`click ` 不带 --yes 即可看接收者,或 `--expect-app`)。这也是 `--front` 的验证机制。 +12. **激活 ≠ 抬窗。** `NSRunningApplication.activate()` 是合作式的,Emacs NS 端口实测会拒绝(应用变前台但窗口留在别处下面);`raise-frame`(emacsclient)说成功但窗口序不变。可靠路径是旧式 `activate(options: [.activateAllWindows, .activateIgnoringOtherApps])`(deprecated 但有效)+ AX 命中验证。 +13. **窗口序号有三套**:`win-state` 的 `--window N`(hs/AX 顺序)、`tree` 的 `window[N]`(AX 窗口顺序)、`windows` 的 `CGID`(CGWindowList 顺序)。可能不一致——**`--window` 一律以 `win-state` 输出为准**,`CGID` 只用于截图。 +14. **程序化改 frame 后,应用会自己“分步落地”,别当成工具慢。** 实测 60fps 录屏:同一次瞬时下发(`hs.window.animationDuration=0`),TextEdit 1 帧到位,iTerm2 3.6.9 要 7~9 帧、跨 0.25~0.45s(窗口在前台、机器空载也一样)。期间 AX 读到的几何是中间值,“有没有动画/跨几帧”只能用录屏数帧判断(见 §5)。想让它一次到位就关掉应用自己的吸附/动画:iTerm → Settings → Advanced → `Disable window size snap`,改完要重启 iTerm。**不要在动画期间重复下发同一 frame**:实测会把窗口停在中间位置(x 落在 70/41/18…),越按越乱。 + +## 11. 架构与扩展 + +``` +scripts/desktop-ctl.sh 通道层:本地 help + 编译缓存(带互斥锁)+ 原样转发 +src/contract.swift 契约原语:结论行 / key=value / source=as_of / 封闭错误码 / 干跑 +src/contract.swift 契约原语:结论行 / key=value / source=as_of / 封闭错误码 / 干跑 +src/main.swift 全局旗标、应用定位、check / apps / front / focus +src/axio.swift Accessibility 原语:地址解析、树遍历、get / set / press / menu +src/cgio.swift 窗口枚举、截图、CGEvent 合成输入(type/key 走这里) +src/winio.swift 窗口与鼠标层:转发 Hammerspoon + 契约与安全包装 +src/systemio.swift 桌面/音量/媒体/亮度/剪贴板/Wi-Fi:薄转发 + 契约包装 +tests/test_entrypoints.py 离线层(默认)+ 真机层(MDC_LIVE=1) +tests/fixture/ 测试专用靶子窗口(自报 AX 落点;只在测试时启动) +``` + +- **原语层**就是那 14 个子命令,每个都能单独验证、单独复用,不含场景逻辑。 +- **场景组合只写在文档的配方表里**,不进代码。新需求 = 用原语组合;某组合反复出现时,在配方表加一行。 +- 编译产物在 `$PI_CODING_AGENT_DIR/local/skills/macos-desktop-control/bin/`,按源码+编译器版本的内容哈希自动重建,带 `build.lock` 互斥与陈旧锁回收。已构建的机器上 PATH 里没有编译器也照常可用;`desktop-ctl.sh build` 强制重建。 + +加一个子命令: + +1. `src/` 里加 `cmdXxx(_ args: Args)`;用 `Contract.conclusion/kv/provenance` 输出,用 `Contract.fail/usage` 报错,破坏性命令走 `Contract.previewOrProceed`。 +2. `src/main.swift` 的 `switch cmd` 注册,并在 `cmdHelp` 与 `usage` 各加一行。 +3. 更新 `interface.json` 的 `commands`(破坏性的加 `"destructive": true`)。 +4. `tests/test_entrypoints.py` 的 `ALL_COMMANDS` 加上命令名,按 只读/破坏性 分别加进 `READONLY`/`DESTRUCTIVE`,并补用例。 +5. 跑 `python3 tests/test_entrypoints.py`(离线)→ `MDC_LIVE=1 python3 tests/test_entrypoints.py`(真机,要求 TextEdit 未运行)→ `skillcheck macos-desktop-control`。 + +设计约束:原语必须原子、参数化、不含场景分支;能参数化的不硬编码;不要为了一个场景加命令。 + +### 真机层的靶子 + +依赖像素坐标的用例(click/drag/scroll/type)跑在**自建靶子窗口**上(`tests/fixture/`), +而不是用户的任何应用。原因:TextEdit 被连续合成操作后会出现「AX/CGWindowList 报告的几何 ≠ +实际渲染几何」(实测退出全屏后文本在约 300px 处换行、两个通道都报 720x875),导致坐标类 +用例失准且失败项随执行顺序轮换。 + +靶子要点:几何写死、等宽字体、**启动时把自己在 AX 坐标系里的精确落点打印出来** +(`geom textarea=… char_dx=… line_dy=… char_step=…`),测试据此直接算出第 N 个字符的 +屏幕坐标,不必猜也不必扫。它带一个按钮 + 计数标签(press 的闭环)和标准 Edit 菜单 +(没有主菜单时 cmd+a 不会生效——macOS 的组合键走菜单 key equivalent)。 + +会切换 Space 的用例(全屏)单独成类 `LiveSpaceState` 并按类名字母序排在最后: +Space 转场期间注入的事件与 AX 读到的几何不可靠,插在中间会污染后面所有坐标类用例。 + +## 12. 验证状态 + +### 离线层(无副作用,默认运行) + +`python3 tests/test_entrypoints.py` — 覆盖:帮助与用法、未知命令/选项的退出码、 +`error=`/`hint=` 的封闭性、只读命令的 `source=`/`as_of=`、`--json` 是纯 JSON 且不混 `key=value`、 +列对齐、地址可复制、编译缓存(首次构建/命中/源码变更重建/强制重建/缺 swiftc/无编译器可用/无残留锁)、 +窗口层的参数校验与"默认预览不执行"、区域写法白名单、落点归属字段。 + +### 真机层(`MDC_LIVE=1`,闭环验证) + +每一步写入都用**另一个通道**读回来确认(写入走 AX,回读走 `get`/`windows`),不是只看退出码: + +- `press` 建文档、`find` 定位、`set` 写入并回读校验、`type` 中文回读、`key cmd+a` 选中态回读、`menu Edit>Select All` 回读 +- 三类破坏性命令的**默认预览必须证明"真的没执行"** +- `shot` PNG 有效性与尺寸、只读元素报 `conflict`、菜单 miss 列出真实可用项 +- **窗口层**:`win-place` 8 种具名区域 + 像素 + 比例全部精确命中(用 CGWindowList 独立读回);`win-place` 不改变前台应用;`win-state` 与 `windows` 两条独立通道读数一致;`win-minimize`/`win-unminimize`/`win-fullscreen` 状态回读 +- **鼠标层**(自建靶子):`click` 落点与字符位置**逐个精确对应**(点第 N 个字符光标就在 N);`drag` 拖选长度**精确等于字符数差**;`scroll` 可见区间前移;`type`(含中文)回读校验;`press` 点按钮标签计数闭环;`key cmd+a` 全选闭环;`win-place` 五种区域精确命中 +- **桌面与系统层**:`space-goto` 切换后回读并**切回原桌面**;`input-source` 切换后回读并**切回原输入法**;`screen-list` 几何自洽性(可视区 ≤ 完整区、恰有一块主屏);`volume`/`brightness` 写入后回读验证并**恢复原值**;`clipboard` 往返写入后**还原用户原内容**;`wifi`/`media` 在用例里**只验证预览、绝不真的关机或打断音乐**(这两个的写入路径靠 `verified` 回读逻辑与离线契约保证) + +### 已知不稳定项(诚实记录) + +`drag` 的**文本选中**断言在 TextEdit 上不稳定:TextEdit 的文本视图在连续操作后会出现 +"报告几何 ≠ 实际渲染几何"(见 §10.6),那时像素级拖选必然失准。该用例在这种情况下会 +**带原因 skip**,而不是把环境问题伪装成通过或失败。 + +工具本身的行为(事件已发出、落点归属正确、返回停稳坐标)是稳定断言的。 +若布局稳定的自建靶子可用(tests/fixture/),拖选/点击/滚动全部在它上面做精确断言。 + +## 13. 常驻运行时(可选) + +Hammerspoon 提供 macOS 版的 `emacsclient`:常驻、有完整权限的 Lua 运行时,适合低延迟重复调用、全局热键、AX 通知回调。 + +本机状态:已完成。`/opt/homebrew/bin/hs` 已软链到 `Hammerspoon.app/Contents/Frameworks/hs/hs`;`~/.hammerspoon/init.lua` 顶部已加 `require('hs.ipc')`(备份 `init.lua.bak-*`)。 + +```bash +hs -c 'return hs.window.focusedWindow():title()' # 前台窗口标题 +hs -c 'return #hs.screen.allScreens()' # 屏幕数 +hs -c 'return hs.pasteboard.getContents()' # 读剪贴板 +hs -c 'hs.eventtap.keyStroke({"cmd"}, "s")' # 发按键 +hs -c 'hs.reload()' # 重载配置 +``` + +修复:若报 `can't access Hammerspoon message port`,说明 `require('hs.ipc')` 没加载。加进 `init.lua` 顶部后重载(`pkill -x Hammerspoon && open -a Hammerspoon`,或用户绑定的 Ctrl+`)。 + +注意 `hs.hotkey.getHotkeys()` 只返回**当前启用**的热键,数量随前台窗口变化,不代表配置丢失。 + +**分工**:一次性、可脚本化、要写进配方的操作走本技能的 CLI;需要常驻状态或事件回调的走 Hammerspoon。不要用 `hs` 替代原子命令,也不要让本技能依赖 Hammerspoon 必须运行。 diff --git a/skills/macos-desktop-control/SKILL.md b/skills/macos-desktop-control/SKILL.md new file mode 100644 index 0000000..b724ba9 --- /dev/null +++ b/skills/macos-desktop-control/SKILL.md @@ -0,0 +1,88 @@ +--- +name: macos-desktop-control +description: 完全操作 macOS 桌面与应用界面。读取任意应用的无障碍元素树(= 原生 app 的 DOM)、点击按钮与菜单项、写入输入框、发送文本与组合键、摆布窗口(半屏/四角/自定义比例/多屏)、鼠标点击与拖拽、按窗口截图。用户要求操作某个 Mac 应用(点按钮、点菜单、填表单、关窗口)、排布窗口、读取界面文字与状态、截图某个窗口,或说“帮我点一下/填一下/摆一下窗口/看看那个窗口里写了什么”时使用。 +platforms: [darwin] +--- + +# macOS 桌面控制 + +**一句话**:`tree` 打印的地址就是原生 app 的 DOM 路径,`press`/`set` 等于 `element.click()` / `value = x`,`key`/`type` 是原始输入事件,`win-*` 是窗口几何,`click`/`drag`/`scroll` 是原始鼠标。**先取地址(或坐标),再对目标做动作。** + +**如果你支持关闭 thinking/reasoning,请先关闭再执行。不要思考、不要侦察、不要读源码。把用户的话按下表编译成命令,完成后一句话汇报。不要写 Python/jq 解析输出。** + +## 入口 + +```bash +pi-skill macos-desktop-control check # 权限与可达性;任何失败先跑它 +pi-skill macos-desktop-control help <命令> # 每个命令的完整用法(唯一权威,不要凭记忆拼参数) +``` + +| 类别 | 命令 | 行为 | +|---|---|---| +| 观察 | `check apps front windows tree find get shot ocr win-state` | 直接执行,不改状态 | +| 改状态 | `focus press set menu type key` | **默认只预览**,加 `--yes` 才执行 | +| 窗口 | `win-place win-screen win-fullscreen win-minimize win-unminimize` | 同上,默认预览 | +| 鼠标 | `click drag scroll` | 同上,默认预览 | +| 桌面与系统 | `space-list space-goto space-move volume media brightness clipboard wifi screen-list input-source` | 读形式直接执行;写形式默认预览 | + +用户要什么 → 执行哪条,见 [REFERENCE.md](REFERENCE.md) 的映射表。 + +## 常用配方 + +```bash +# 结构 → 地址 → 动作(地址用 --json 一次拿到,避免跨调用漂移) +pi-skill macos-desktop-control find TextEdit --role AXTextArea --json +pi-skill macos-desktop-control set TextEdit 'window[0]/child[0]/child[0]' "内容" --yes +pi-skill macos-desktop-control menu TextEdit Edit "Select All" --yes + +# 窗口布局:只摆位置、不抢焦点、不要求目标在前台 +pi-skill macos-desktop-control win-place Safari left --yes # 具名区域 +pi-skill macos-desktop-control win-place Safari "0,25,720,875" --yes # 绝对坐标 + +# 用户说"把 X 放到左边/让我看到它" → 加 --front(摆完置前并用命中测试验证) +pi-skill macos-desktop-control win-place Emacs left --front --yes +# ⚠ win-place 不置前时,窗口可能被别的全屏窗口盖住(实测 Chrome 就盖过 Emacs) +pi-skill macos-desktop-control shot --app Safari --out /tmp/safari.png + +# 桌面与系统(不带参数就是读;薄转发,source= 标明后端) +pi-skill macos-desktop-control space-list # 有几个桌面、现在在第几个 +pi-skill macos-desktop-control space-goto next --yes # 切到下一个桌面 +pi-skill macos-desktop-control volume 40 --yes # 音量 40% +# 媒体/音乐:发出即成功(executed=true)——不要 tree/find/截图验证 +pi-skill macos-desktop-control media next --yes # 下一首(play-pause / previous 同理) +pi-skill macos-desktop-control media status # 只有要确认“谁在播”时才读 +pi-skill macos-desktop-control clipboard # 读剪贴板 +pi-skill macos-desktop-control clipboard "文本" --yes # 写剪贴板(默认预览) +pi-skill macos-desktop-control clipboard --image /tmp/shot.png --yes # 图片进剪贴板 +pi-skill macos-desktop-control clipboard --out /tmp/save.png # 存出剪贴板里的图 +pi-skill macos-desktop-control brightness --json # 读亮度 +pi-skill macos-desktop-control screen-list # 多屏布局时确认 --on-screen N +pi-skill macos-desktop-control input-source list # 有哪些输入法 +``` + +## 失败 → 动作 + +| 现象 | 动作 | +|---|---| +| `error=auth` | `check` 看缺哪项 → 系统设置授权 → **完全重启承载终端** | +| `error=deps`(Hammerspoon 未运行) | 窗口/鼠标类命令依赖它:`pkill -x Hammerspoon && open -a Hammerspoon` | +| `error=notfound` | 地址失效或应用没开:`tree`/`find` 重取地址,或 `open -a "<应用>"` | +| `error=conflict` | 元素不可按/不可写:`get <应用> <地址>` 看它真实支持的动作与属性 | +| `error=timeout` | 目标应用卡住或在等模态框:`tree <应用> --depth 3` 找 `AXSheet` | +| `error=blocked` | 需要人介入(授权框 / Secure Input):把 `hint=` 原文转给用户 | +| **摆完看不见窗口** | 被别的窗口盖住(win-place 只摆位置不置顶)→ 重跑加 `--front`:`win-place <应用> <区域> --front --yes`;还不行就 `focus <应用> --yes` | +| **media 切歌没反应** | 先 `media status` 看是不是别的应用在播;是就用目标应用自己的菜单:`menu <应用> <菜单> Next --yes`(网易云是 `Controls`) | +| 输出 `executed=false` | 这是预览,核对后加 `--yes` 重跑 | + +## 边界 + +- **不抢焦点**:`tree find get windows apps shot press set menu` 与全部 `win-*` 都不要求目标应用在前台(`win-*` 也不改变用户焦点)。 +- **只有 `type`/`key` 与坐标 `click`/`drag` 需要目标在前台**,会自动激活;密码框等 Secure Input 场景无法注入。 +- **坐标点击落到最上层窗口**:输出里的 `over_app` 就是真实接收者;传 `--expect-app` 可在不符时直接拦住。 +- 移动窗口用 `win-place`(AX,精确瞬发),**不要用 `drag` 拖标题栏**——macOS 窗口拖拽有死区与吸附,落点不可预测。 +- 系统安全提示框(TCC 授权、登录窗)、SIP 保护路径不能被脚本操作;AX 树为空的私有渲染界面退到 `shot` 看像素。 +- 系统已有 CLI 等价物的事**不要用本工具**:Apple Events 用 `osascript`,系统状态用 `defaults/mdfind/log/launchctl/pmset`,快捷指令用 `shortcuts`。 +- 没启动的应用:`open -a "<应用>"` → `apps` 里出现 → 再 `tree`。关闭有未保存修改的文档用 Apple Events(见 REFERENCE)。 +- **桌面/系统层是薄转发,不是重写**:`space-*` 走 `hs.spaces`、`brightness` 走 `hs.brightness`、`media` 发系统媒体键(`media status` 读 `pmset`)、`volume` 走 `osascript`、`clipboard` 文本走 `pbpaste/pbcopy`、图片走 `osascript`/`NSPasteboard`、`wifi` 走 `networksetup`。每条命令的 `source=` 会标明后端。 + +映射表、跨层分工、排查表、扩展方式见 [REFERENCE.md](REFERENCE.md)。 diff --git a/skills/macos-desktop-control/VERSION b/skills/macos-desktop-control/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/skills/macos-desktop-control/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/skills/macos-desktop-control/contract.lock.json b/skills/macos-desktop-control/contract.lock.json new file mode 100644 index 0000000..7050902 --- /dev/null +++ b/skills/macos-desktop-control/contract.lock.json @@ -0,0 +1,128 @@ +{ + "version": 1, + "skill": "macos-desktop-control", + "tier": 1, + "platforms": [ + "darwin" + ], + "commands": { + "apps": { + "destructive": false + }, + "brightness": { + "destructive": true + }, + "check": { + "destructive": false + }, + "click": { + "destructive": true + }, + "clipboard": { + "destructive": true + }, + "drag": { + "destructive": true + }, + "find": { + "destructive": false + }, + "focus": { + "destructive": false + }, + "front": { + "destructive": false + }, + "get": { + "destructive": false + }, + "input-source": { + "destructive": true + }, + "key": { + "destructive": true + }, + "media": { + "destructive": true + }, + "menu": { + "destructive": true + }, + "ocr": { + "destructive": false + }, + "press": { + "destructive": true + }, + "screen-list": { + "destructive": false + }, + "scroll": { + "destructive": true + }, + "set": { + "destructive": true + }, + "shot": { + "destructive": false + }, + "space-goto": { + "destructive": true + }, + "space-list": { + "destructive": false + }, + "space-move": { + "destructive": true + }, + "tree": { + "destructive": false + }, + "type": { + "destructive": true + }, + "volume": { + "destructive": true + }, + "wifi": { + "destructive": true + }, + "win-fullscreen": { + "destructive": true + }, + "win-minimize": { + "destructive": true + }, + "win-place": { + "destructive": true + }, + "win-screen": { + "destructive": true + }, + "win-state": { + "destructive": false + }, + "win-unminimize": { + "destructive": true + }, + "windows": { + "destructive": false + } + }, + "errors": [ + "auth", + "blocked", + "conflict", + "deps", + "external", + "internal", + "notfound", + "platform", + "timeout", + "usage" + ], + "aliases": [ + "desktop-ctl" + ], + "adminAliases": [] +} diff --git a/skills/macos-desktop-control/interface.json b/skills/macos-desktop-control/interface.json new file mode 100644 index 0000000..2f89d44 --- /dev/null +++ b/skills/macos-desktop-control/interface.json @@ -0,0 +1,207 @@ +{ + "summary": "操作 macOS 桌面与应用界面:读无障碍元素树、点击按钮/菜单、填输入框、发文本与组合键、按窗口截图", + "useWhen": "用户要求操作某个 Mac 应用(点按钮、点菜单、填表单、切选项、关窗口)、读界面文字、或截某窗口时", + "tier": 1, + "requires": [ + { + "kind": "binary", + "name": "swiftc", + "install": "xcode-select --install(Command Line Tools)", + "check": "swiftc --version", + "note": "截图/窗口原语的小工具需要编译;已编译过就不再需要" + }, + { + "kind": "binary", + "name": "shasum", + "install": "macOS 自带(perl)", + "check": "shasum --version" + } + ], + "entry": { + "path": "scripts/desktop-ctl.sh" + }, + "aliases": [ + "desktop-ctl" + ], + "commands": [ + { + "name": "check", + "summary": "前置检查:权限与可达性(其它命令失败时先跑它)" + }, + { + "name": "apps", + "summary": "正在运行的 GUI 应用" + }, + { + "name": "front", + "summary": "当前前台应用" + }, + { + "name": "windows", + "summary": "屏幕上所有窗口(含 CGWindowID)" + }, + { + "name": "tree", + "summary": "无障碍元素树(= 原生 app 的 DOM)" + }, + { + "name": "find", + "summary": "找元素,打印可直接使用的地址" + }, + { + "name": "get", + "summary": "读元素属性(省略属性时列出全部属性与可用动作)" + }, + { + "name": "shot", + "summary": "截图(默认前台应用窗口,默认写临时文件;--clipboard 直接进剪贴板,--no-shadow 去阴影并等同窗口几何)" + }, + { + "name": "ocr", + "summary": "识别屏幕文字并给出屏幕坐标(AX 树为空的界面用它定位)" + }, + { + "name": "focus", + "summary": "把应用切到前台" + }, + { + "name": "press", + "summary": "按下元素", + "destructive": true + }, + { + "name": "set", + "summary": "写元素属性,写完回读", + "destructive": true + }, + { + "name": "menu", + "summary": "按标题路径点菜单", + "destructive": true + }, + { + "name": "type", + "summary": "输入任意文本(含中文)", + "destructive": true + }, + { + "name": "key", + "summary": "发组合键", + "destructive": true + }, + { + "name": "win-state", + "summary": "读窗口位置/尺寸/全屏/最小化/所在屏幕" + }, + { + "name": "win-place", + "summary": "把窗口摆到指定区域(半屏/四角/三分之一/自定义比例)", + "destructive": true + }, + { + "name": "win-screen", + "summary": "把窗口移到另一块屏幕", + "destructive": true + }, + { + "name": "win-fullscreen", + "summary": "全屏开关", + "destructive": true + }, + { + "name": "win-minimize", + "summary": "最小化窗口", + "destructive": true + }, + { + "name": "win-unminimize", + "summary": "恢复被最小化的窗口", + "destructive": true + }, + { + "name": "click", + "summary": "点击坐标或元素中心", + "destructive": true + }, + { + "name": "drag", + "summary": "拖拽", + "destructive": true + }, + { + "name": "scroll", + "summary": "滚动", + "destructive": true + }, + { + "name": "space-list", + "summary": "列出各屏的桌面与当前所在" + }, + { + "name": "space-goto", + "summary": "切换桌面", + "destructive": true + }, + { + "name": "space-move", + "summary": "把窗口移到别的桌面", + "destructive": true + }, + { + "name": "volume", + "summary": "读/设系统音量与静音", + "destructive": true + }, + { + "name": "media", + "summary": "发送系统媒体键(播放/暂停/上下曲);status 读当前谁在播", + "destructive": true + }, + { + "name": "brightness", + "summary": "读/设显示器亮度", + "destructive": true + }, + { + "name": "clipboard", + "summary": "读/写剪贴板文本;--image <文件> 写图片、--out <文件> 存出剪贴板里的图、--info 只看类型", + "destructive": true + }, + { + "name": "wifi", + "summary": "读/开关 Wi-Fi", + "destructive": true + }, + { + "name": "screen-list", + "summary": "列出显示器几何与可视区" + }, + { + "name": "input-source", + "summary": "读/列/切换输入法", + "destructive": true + } + ], + "errors": [ + "usage", + "deps", + "platform", + "auth", + "notfound", + "conflict", + "blocked", + "timeout", + "external", + "internal" + ], + "helpListsCommands": true, + "dryRun": true, + "probeUnknownCommand": true, + "publish": { + "package_repo": "https://gitea.vhkd.top/geekinney/macos-desktop-control.git" + }, + "catalog": { + "layer": "core", + "why": "别的技能建在它上面" + } +} diff --git a/skills/macos-desktop-control/scripts/desktop-ctl.sh b/skills/macos-desktop-control/scripts/desktop-ctl.sh new file mode 100755 index 0000000..8a871a7 --- /dev/null +++ b/skills/macos-desktop-control/scripts/desktop-ctl.sh @@ -0,0 +1,202 @@ +#!/bin/bash +# desktop-ctl.sh — 通道层:确保原语二进制与源码一致,然后原样转发。 +# +# 原语是 src/*.swift;编译产物缓存在运行目录的 local/ 下(不进共享仓库)。 +# 源码或编译器版本变化时自动重建,其余情况零等待直接转发。 +# +# 用法: desktop-ctl.sh <命令> [参数...] 完整帮助: desktop-ctl.sh help +set -euo pipefail + +SELF_DIR="$(cd "${BASH_SOURCE[0]%/*}" && pwd)" +SKILL_DIR="$(cd "$SELF_DIR/.." && pwd)" +SRC_DIR="$SKILL_DIR/src" + +AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}" +CACHE_DIR="$AGENT_DIR/local/skills/macos-desktop-control" +BIN="$CACHE_DIR/bin/desktop-ctl" +STAMP="$CACHE_DIR/bin/source.sha256" + +usage() { + cat <<'EOF' +desktop-ctl — macOS 桌面控制(读无障碍元素树 / 窗口 / 合成输入) + +用法: desktop-ctl <命令> [参数...] +全局旗标: --json --dry-run --yes --quiet --timeout <秒> + +观察(不改变状态): + check 前置检查:权限与可达性 + apps [--all] 正在运行的 GUI 应用 + front 当前前台应用 + windows [--all] 屏幕上所有窗口(含 CGWindowID) + tree <应用> 无障碍元素树(= 原生 app 的 DOM) + find <应用> [--role/--title/--value ...] 找元素,打印可用地址 + get <应用> <地址> [属性] 读元素属性 + shot [--app 应用|--window CGID|--screen] [--out 文件] 截图 + ocr [--app 应用|--window CGID|--image 文件] [--find 文字] + 识别屏幕文字并给出屏幕坐标 + (AX 树为空的界面用它) + +改变状态(默认只预览,加 --yes 才真做): + focus <应用> | press <应用> <地址> | set <应用> <地址> <文本> + menu <应用> <菜单标题> [子项...] | type <应用> <文本> | key <应用> <组合键...> + +窗口(转发 Hammerspoon,不要求目标在前台,不抢焦点): + win-state <应用> [--window N] 读位置/尺寸/全屏/最小化/所在屏 + win-place <应用> <区域> [--window N] 摆窗口 + 区域: left right top bottom topleft topright bottomleft bottomright + center fill left-third center-third right-third left-two-thirds + right-two-thirds | "100,25,720,875"(绝对坐标) | "0%,0%,50%,100%"(相对可视区) + win-screen <应用> 移到另一块屏 + win-fullscreen <应用> + win-minimize <应用> | win-unminimize <应用> + +鼠标(转发 Hammerspoon): + click , | click <应用> <地址> [--double] [--right] + drag , , | scroll [--amount N] + +桌面与系统(薄转发,输出的 source= 标明后端): + space-list | space-goto | space-move <应用> [hs.spaces] + volume [0-100|up|down|mute|unmute] [osascript] + media [媒体键 / pmset] + brightness [0-100|up|down] [hs.brightness] + clipboard [文本] [pbpaste/pbcopy] + wifi [status|on|off] [networksetup] + screen-list [NSScreen] + input-source [list|名字|id] [hs.keycodes] + +选择: + help <命令> 该命令的完整说明(首次可能需先编译) + help --full 原语完整用法 + build 强制重新编译原语 + +help 不触发编译;权限与依赖看 check 输出。 +EOF +} + +# 裸 help 走本地文本(零延迟,满足 help-latency);help <命令> 交给原语拿详情 +if [ "${1:-}" = "help" ]; then + if [ $# -eq 1 ]; then + usage + exit 0 + fi + if [ "${2:-}" = "--full" ]; then + set -- help + fi +fi +if [ "${1:-}" = "--help-entry" ]; then + usage + exit 0 +fi + +FORCE_BUILD=0 +if [ "${1:-}" = "build" ]; then + FORCE_BUILD=1 + shift + if [ $# -eq 0 ]; then + set -- help + fi +fi + +LOCK="$CACHE_DIR/bin/build.lock" +LOCK_HELD=0 +release_lock() { + # 只能释放自己拿到的锁:否则会误删别人的 + if [ "$LOCK_HELD" = 1 ]; then + rm -rf "$LOCK" + LOCK_HELD=0 + fi +} +build_lock() { + # 编译互斥:mkdir 原子抢锁;陈旧锁(进程已死)自动回收,避免并发编译互相破坏 + local waited=0 + mkdir -p "$CACHE_DIR/bin" + while ! mkdir "$LOCK" 2>/dev/null; do + if [ -f "$LOCK/pid" ] && ! kill -0 "$(cat "$LOCK/pid" 2>/dev/null || echo 0)" 2>/dev/null; then + rm -rf "$LOCK" + continue + fi + waited=$((waited + 1)) + if [ "$waited" -gt 600 ]; then + echo "error=conflict 等待其它编译进程超时" >&2 + echo "hint=rm -rf $LOCK 后重试" >&2 + return 1 + fi + sleep 0.1 + done + echo "$$" > "$LOCK/pid" + LOCK_HELD=1 + trap 'release_lock' EXIT INT TERM + return 0 +} + +compute_hash() { + # 只哈希源码内容。 + # 不要把 `swiftc --version` 算进来:它要 156ms,而这段代码在每次调用都会跑, + # 会把 12ms 的原语变成 190ms。编译器升级是低频事件,用 `desktop-ctl.sh build` 处理。 + shasum -a 256 "$SRC_DIR"/*.swift | shasum -a 256 | awk '{print $1}' +} + +need_build=0 +if [ "$FORCE_BUILD" = 1 ]; then + need_build=1 +elif [ ! -x "$BIN" ] || [ ! -f "$STAMP" ]; then + need_build=1 +elif ! command -v shasum >/dev/null 2>&1; then + # 无法校验(PATH 被裁剪的环境):信任现有二进制,不重建 + need_build=0 +elif [ "$(cat "$STAMP")" != "$(compute_hash)" ]; then + need_build=1 +fi + +# 依赖检查必须放在取锁之前:PATH 被裁剪时要给出可执行的修复命令, +# 而不是让 mkdir/swiftc 各自报一句 command not found。 +if [ "$need_build" = 1 ] && ! command -v swiftc >/dev/null 2>&1; then + echo "error=deps 找不到 swiftc(编译原语所需)" >&2 + echo "hint=xcode-select --install 安装 Xcode Command Line Tools 后重试" >&2 + exit 2 +fi + +if [ "$need_build" = 1 ]; then + build_lock || exit 1 + # 拿到锁后重新判断:并发的另一个进程可能已经编译好了 + if [ "$FORCE_BUILD" != 1 ] && [ -x "$BIN" ] && [ -f "$STAMP" ] && [ "$(cat "$STAMP")" = "$(compute_hash)" ]; then + need_build=0 + fi +fi + +if [ "$need_build" = 1 ]; then + if ! ls "$SRC_DIR"/*.swift >/dev/null 2>&1; then + echo "error=deps 源码目录里没有 .swift 文件(${SRC_DIR})" >&2 + echo "hint=检查技能目录是否完整" >&2 + exit 2 + fi + + mkdir -p "$CACHE_DIR/bin" + chmod 700 "$CACHE_DIR" "$CACHE_DIR/bin" 2>/dev/null || true + + if [ -x "$BIN" ]; then + echo "desktop-ctl: 源码已变更,重新编译原语…" >&2 + else + echo "desktop-ctl: 首次运行,编译原语(约 10 秒,只发生一次)…" >&2 + fi + + TMP="$CACHE_DIR/bin/.desktop-ctl.$$.tmp" + LOG="$CACHE_DIR/bin/build.log" + if ! swiftc -O -o "$TMP" "$SRC_DIR"/*.swift 2>"$LOG"; then + echo "error=deps 编译失败(原语未更新)" >&2 + echo "hint=cat $LOG" >&2 + cat "$LOG" >&2 + rm -f "$TMP" + exit 2 + fi + mv -f "$TMP" "$BIN" + chmod 700 "$BIN" + compute_hash > "$STAMP" + rm -f "$LOG" + echo "desktop-ctl: 编译完成(编译器升级后请跑 desktop-ctl.sh build 强制重建)" >&2 +fi + +# exec 会替换当前进程,EXIT trap 不会触发 —— 必须在这里显式放锁, +# 否则 build.lock 永久泄漏,下次源码变更时构建会先卡满等待超时。 +release_lock +exec "$BIN" "$@"