release v0.1.0
This commit is contained in:
commit
8798dbd7b6
256
MANIFEST.json
Normal file
256
MANIFEST.json
Normal file
@ -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": []
|
||||
}
|
||||
}
|
||||
14
README.md
Normal file
14
README.md
Normal file
@ -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` 会告诉还缺什么。
|
||||
5
package.json
Normal file
5
package.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "macos-desktop-control",
|
||||
"version": "0.1.0",
|
||||
"description": "操作 macOS 桌面与应用界面:读无障碍元素树、点击按钮/菜单、填输入框、发文本与组合键、按窗口截图"
|
||||
}
|
||||
399
skills/macos-desktop-control/REFERENCE.md
Normal file
399
skills/macos-desktop-control/REFERENCE.md
Normal file
@ -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 <CGID> --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 <x1>,<y1> <x2>,<y2>` |
|
||||
| **滚动** | `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 <bundleId>` |
|
||||
| 找文件(全文/元数据) | `mdfind` |
|
||||
| 服务、自启、重启进程 | `launchctl` |
|
||||
| 电源、休眠、防睡眠 | `pmset` / `caffeinate` |
|
||||
| 日志 | `log show --predicate` / `log stream` |
|
||||
| 系统与硬件信息 | `system_profiler -json` |
|
||||
| 网络配置 | `networksetup` / `scutil` |
|
||||
| 运行快捷指令 | `shortcuts run "<名称>"` |
|
||||
| 需要常驻状态或事件回调(全局热键、监听界面变化) | `hs -c '<lua>'`(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 <CGID> --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 <x,y>` 不带 --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 必须运行。
|
||||
88
skills/macos-desktop-control/SKILL.md
Normal file
88
skills/macos-desktop-control/SKILL.md
Normal file
@ -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)。
|
||||
1
skills/macos-desktop-control/VERSION
Normal file
1
skills/macos-desktop-control/VERSION
Normal file
@ -0,0 +1 @@
|
||||
0.1.0
|
||||
128
skills/macos-desktop-control/contract.lock.json
Normal file
128
skills/macos-desktop-control/contract.lock.json
Normal file
@ -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": []
|
||||
}
|
||||
207
skills/macos-desktop-control/interface.json
Normal file
207
skills/macos-desktop-control/interface.json
Normal file
@ -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": "别的技能建在它上面"
|
||||
}
|
||||
}
|
||||
202
skills/macos-desktop-control/scripts/desktop-ctl.sh
Executable file
202
skills/macos-desktop-control/scripts/desktop-ctl.sh
Executable file
@ -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 <应用> <next|prev|N> 移到另一块屏
|
||||
win-fullscreen <应用> <on|off|toggle>
|
||||
win-minimize <应用> | win-unminimize <应用>
|
||||
|
||||
鼠标(转发 Hammerspoon):
|
||||
click <x>,<y> | click <应用> <地址> [--double] [--right]
|
||||
drag <x1>,<y1> <x2>,<y2> | scroll <up|down|left|right> [--amount N]
|
||||
|
||||
桌面与系统(薄转发,输出的 source= 标明后端):
|
||||
space-list | space-goto <next|prev|N> | space-move <应用> <next|prev|N> [hs.spaces]
|
||||
volume [0-100|up|down|mute|unmute] [osascript]
|
||||
media <play-pause|next|previous|fast|rewind|status> [媒体键 / 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" "$@"
|
||||
Loading…
Reference in New Issue
Block a user