@vashp2029/glovebox 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/LICENSE +21 -0
- package/README.md +238 -2
- package/SECURITY.md +81 -0
- package/bin/glovebox.mjs +319 -0
- package/docs/agents.md +124 -0
- package/docs/api.md +146 -0
- package/docs/backend-protocol.md +118 -0
- package/docs/cli.md +150 -0
- package/docs/design.md +212 -0
- package/docs/recipes.md +162 -0
- package/docs/roadmap.md +167 -0
- package/docs/testing.md +79 -0
- package/native/macos/GloveboxHelper.swift +1654 -0
- package/package.json +28 -4
- package/skills/glovebox/SKILL.md +49 -0
- package/src/api.mjs +552 -0
- package/src/axformat.mjs +158 -0
- package/src/backends/index.mjs +27 -0
- package/src/backends/macos.mjs +106 -0
- package/src/config.mjs +92 -0
- package/src/daemon.mjs +127 -0
- package/src/geometry.mjs +38 -0
- package/src/kernel.mjs +217 -0
- package/src/keys.mjs +96 -0
- package/src/macos-helper.mjs +164 -0
- package/src/mcp.mjs +126 -0
- package/src/session.mjs +110 -0
- package/src/smoke.mjs +160 -0
- package/src/transform.mjs +131 -0
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vashp2029/glovebox",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Computer use for coding agents on macOS: one JavaScript call per step, accessibility diffs instead of screenshots, input in the background. MCP and CLI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Vash Patel",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/vpatel9202/glovebox.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/vpatel9202/glovebox#readme",
|
|
13
|
+
"bugs": "https://github.com/vpatel9202/glovebox/issues",
|
|
14
|
+
"keywords": ["computer-use", "mcp", "mcp-server", "claude-code", "macos", "accessibility", "automation", "agents", "gui"],
|
|
15
|
+
"bin": {
|
|
16
|
+
"glovebox": "bin/glovebox.mjs"
|
|
17
|
+
},
|
|
18
|
+
"files": ["bin", "src", "native", "skills", "docs", "README.md", "LICENSE", "SECURITY.md", "CHANGELOG.md"],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20.10"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test test/unit.test.mjs",
|
|
24
|
+
"build:helper": "node bin/glovebox.mjs setup --rebuild",
|
|
25
|
+
"smoke": "node bin/glovebox.mjs smoke"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"acorn": "^8.15.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: glovebox
|
|
3
|
+
description: >-
|
|
4
|
+
Control desktop apps (click, type, read the screen) through glovebox's
|
|
5
|
+
persistent JavaScript session, exposed as the MCP tool `js` (often
|
|
6
|
+
`glovebox__js` or `mcp__glovebox__js`) or the `glovebox run` CLI. Use for GUI
|
|
7
|
+
work that has no API, CLI, or file-based path. Prefer a programmatic path
|
|
8
|
+
when one can finish the task.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# glovebox
|
|
12
|
+
|
|
13
|
+
Drive the GUI through one persistent JavaScript session. Each `js` call should
|
|
14
|
+
be a whole step: several actions, then one read. Bindings persist between
|
|
15
|
+
calls until `js_reset`.
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
const calc = await gb.getApp("Calculator");
|
|
19
|
+
await calc.pressKey("Escape");
|
|
20
|
+
await calc.typeText("12*3=");
|
|
21
|
+
await calc.getAXState();
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The loop:
|
|
25
|
+
|
|
26
|
+
1. Bind the app once: `const app = await gb.getApp("Name")`. Reuse `app` in
|
|
27
|
+
later calls. Define helpers in the session when you repeat yourself.
|
|
28
|
+
2. Read with `app.getAXState()`. The first read is the full tree; later reads
|
|
29
|
+
show only changes. Lines look like `14 button "7" id=Seven`; the leading
|
|
30
|
+
number is the element index.
|
|
31
|
+
3. Act by index when the tree has the element: `app.click(14)`,
|
|
32
|
+
`app.setValue(9, "text")`. Then read again in the same call.
|
|
33
|
+
4. Take `app.getScreenshot()` only when the tree cannot tell you what you need
|
|
34
|
+
(remote desktops such as Citrix, canvases, visual layout). Click with
|
|
35
|
+
`[x, y]` pixels from that screenshot; they are the same pixels the image
|
|
36
|
+
shows. `getAXState({frames: true})` gives element boxes in that same space.
|
|
37
|
+
|
|
38
|
+
Keys use xdotool syntax: `Return`, `Tab`, `Escape`, `ctrl+a`, `shift+Tab`,
|
|
39
|
+
`super+c` (Cmd on macOS). `typeText` treats `\n` as Return. Input stays in
|
|
40
|
+
the background: the app is not brought forward and the user's cursor does
|
|
41
|
+
not move. If an app ignores it, pass `{foreground: true}` to that action.
|
|
42
|
+
|
|
43
|
+
`help()` prints the full API; `help("scroll")` prints one part.
|
|
44
|
+
|
|
45
|
+
Approval: the user may see a dialog the first time you touch an app. If a
|
|
46
|
+
call fails with "denied" or "blocked", tell the user what you need and why;
|
|
47
|
+
do not look for a way around it. If a result starts with "Stopped:" (the user
|
|
48
|
+
switched away or pressed Escape), stop GUI work and ask before continuing. Call `js_reset` when the session is
|
|
49
|
+
stuck or a binding should be dropped.
|
package/src/api.mjs
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
// The scripting surface: `gb` (alias `cua`) and the App handles it returns.
|
|
2
|
+
//
|
|
3
|
+
// Every action goes through ctx, which owns output, the abort flag, and the
|
|
4
|
+
// audit log. Reads (getAXState, getScreenshot) emit into the result by
|
|
5
|
+
// default; pass {emit: false} to only get the return value.
|
|
6
|
+
import { diffTrees, formatTree } from "./axformat.mjs";
|
|
7
|
+
import { center, toImage, toScreen, viewFor } from "./geometry.mjs";
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
import { parseKeys } from "./keys.mjs";
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
|
|
15
|
+
// Roles where pressing through accessibility is the same as a click and
|
|
16
|
+
// works without bringing the app to the front.
|
|
17
|
+
const PRESSABLE = new Set([
|
|
18
|
+
"AXButton",
|
|
19
|
+
"AXCheckBox",
|
|
20
|
+
"AXRadioButton",
|
|
21
|
+
"AXMenuItem",
|
|
22
|
+
"AXMenuBarItem",
|
|
23
|
+
"AXLink",
|
|
24
|
+
"AXDisclosureTriangle",
|
|
25
|
+
"AXPopUpButton",
|
|
26
|
+
"AXMenuButton",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function isPoint(value) {
|
|
30
|
+
return Array.isArray(value) && value.length === 2 && value.every(Number.isFinite);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function describeTarget(target) {
|
|
34
|
+
return isPoint(target) ? `[${target.map((v) => Math.round(v)).join(", ")}]` : `#${target}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A window an agent means to work in: capturable and bigger than a tooltip,
|
|
38
|
+
// a popover, or Finder's rename field editor.
|
|
39
|
+
function isNormalWindow(w) {
|
|
40
|
+
return w.windowId != null && !w.minimized && w.frame[2] >= 160 && w.frame[3] >= 100;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function area(w) {
|
|
44
|
+
return w.frame[2] * w.frame[3];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Default read and click target: the focused window if it is a normal one (a
|
|
48
|
+
// new document or settings window), else the window last used, else the main
|
|
49
|
+
// window, else the largest normal one. Small focused windows never win, so a
|
|
50
|
+
// tooltip or field editor can't take over the reads.
|
|
51
|
+
export function pickWindow(windows, last) {
|
|
52
|
+
const sticky = last?.windowId != null ? windows.find((w) => w.windowId === last.windowId) : undefined;
|
|
53
|
+
if (last?.explicit && sticky) return sticky;
|
|
54
|
+
const largest = [...windows].sort((a, b) => area(b) - area(a));
|
|
55
|
+
return (
|
|
56
|
+
windows.find((w) => w.focused && isNormalWindow(w)) ??
|
|
57
|
+
sticky ??
|
|
58
|
+
windows.find((w) => w.main && isNormalWindow(w)) ??
|
|
59
|
+
largest.find(isNormalWindow) ??
|
|
60
|
+
largest.find((w) => w.windowId != null) ??
|
|
61
|
+
largest[0]
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hashImage(data) {
|
|
66
|
+
return createHash("sha1").update(data).digest("hex");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class App {
|
|
70
|
+
#ctx;
|
|
71
|
+
#view = null;
|
|
72
|
+
#explicitWindow = false;
|
|
73
|
+
#lastTrees = new Map(); // window id -> {lines, base, frames}: what the model last saw
|
|
74
|
+
// What waitForChange compares against: the last printed read (or the state a
|
|
75
|
+
// previous wait settled on), as {pixels, value, format, quality}.
|
|
76
|
+
#waitBase = null;
|
|
77
|
+
#activatedCell = -1;
|
|
78
|
+
|
|
79
|
+
constructor(ctx, info) {
|
|
80
|
+
this.#ctx = ctx;
|
|
81
|
+
this.name = info.name;
|
|
82
|
+
this.bundleId = info.bundleId;
|
|
83
|
+
this.pid = info.pid;
|
|
84
|
+
// true: always bring this app forward for input (see #useBackground).
|
|
85
|
+
this.foreground = false;
|
|
86
|
+
// How the last input reached the app: accessibility, background, or foreground.
|
|
87
|
+
this.lastRoute = null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
toString() {
|
|
91
|
+
return `App(${this.name}, pid ${this.pid})`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
[Symbol.for("nodejs.util.inspect.custom")]() {
|
|
95
|
+
return this.toString();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
get view() {
|
|
99
|
+
return this.#view;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async #call(method, params = {}) {
|
|
103
|
+
this.#ctx.checkAbort();
|
|
104
|
+
return this.#ctx.backend.request(method, { pid: this.pid, ...params });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async #act(action, detail, fn) {
|
|
108
|
+
this.#ctx.checkAbort();
|
|
109
|
+
this.#ctx.log({ app: this.bundleId ?? this.name, action, ...detail });
|
|
110
|
+
const result = await fn();
|
|
111
|
+
await this.#ctx.settle();
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async windows() {
|
|
116
|
+
const list = await this.#call("windows");
|
|
117
|
+
return list.map((w) => ({ ...w, view: viewFor(w, this.#ctx.config.screenshot) }));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Refreshes the view for the target window. Keeps the scale from the last
|
|
121
|
+
// read when the window size has not changed, so earlier coordinates stay valid.
|
|
122
|
+
async #refreshView(window) {
|
|
123
|
+
const windows = await this.#call("windows");
|
|
124
|
+
if (windows.length === 0) {
|
|
125
|
+
// Without Accessibility every app looks windowless; say which it is.
|
|
126
|
+
const { accessibility } = await this.#ctx.backend.request("permissions", {});
|
|
127
|
+
if (!accessibility) throw new Error("Accessibility permission is missing for the glovebox helper. Run: glovebox doctor");
|
|
128
|
+
throw new Error(`${this.name} has no open windows`);
|
|
129
|
+
}
|
|
130
|
+
const pick =
|
|
131
|
+
window != null
|
|
132
|
+
? windows.find((w) => w.index === window)
|
|
133
|
+
: pickWindow(windows, this.#view && { windowId: this.#view.windowId, explicit: this.#explicitWindow });
|
|
134
|
+
if (!pick) throw new Error(`${this.name} has no window ${window}`);
|
|
135
|
+
if (window != null) this.#explicitWindow = true;
|
|
136
|
+
else if (pick.windowId !== this.#view?.windowId) this.#explicitWindow = false;
|
|
137
|
+
const next = viewFor(pick, this.#ctx.config.screenshot);
|
|
138
|
+
// Said once in a header when the target is a tooltip-sized or uncapturable window.
|
|
139
|
+
next.note =
|
|
140
|
+
!isNormalWindow(pick) && windows.some(isNormalWindow)
|
|
141
|
+
? ` (a small or uncapturable window; pass {window: ${windows.find(isNormalWindow).index}} for the main one)`
|
|
142
|
+
: "";
|
|
143
|
+
this.#view = next;
|
|
144
|
+
return next;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async #ensureView() {
|
|
148
|
+
return this.#refreshView(undefined);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async getAXState(options = {}) {
|
|
152
|
+
const { disableDiffing = false, frames = false, menus = false, window, emit = true } = options;
|
|
153
|
+
const ax = this.#ctx.config.ax;
|
|
154
|
+
const view = await this.#refreshView(window);
|
|
155
|
+
const tree = await this.#call("axTree", {
|
|
156
|
+
window: view.window,
|
|
157
|
+
maxNodes: options.maxNodes ?? ax.maxNodes,
|
|
158
|
+
maxDepth: options.maxDepth ?? ax.maxDepth,
|
|
159
|
+
maxText: ax.maxTextLength,
|
|
160
|
+
menus,
|
|
161
|
+
});
|
|
162
|
+
// `base` has no frames, so a plain read after a {frames: true} read still
|
|
163
|
+
// diffs. Element indices are stable ids, so the baseline is kept per window
|
|
164
|
+
// (not per list position or title) and diffs across title changes.
|
|
165
|
+
const base = formatTree(tree.nodes);
|
|
166
|
+
const lines = frames ? formatTree(tree.nodes, { toImage: (f) => toImage(view, f) }) : base;
|
|
167
|
+
const key = tree.window.windowId ?? view.windowId ?? `#${view.window}`;
|
|
168
|
+
const header = `${this.name} — window ${view.window} "${tree.window.title}" — image ${view.width}x${view.height}${view.note ?? ""}`;
|
|
169
|
+
const last = disableDiffing ? undefined : this.#lastTrees.get(key);
|
|
170
|
+
let text;
|
|
171
|
+
// A first {frames: true} read prints in full: the frames are what was asked for.
|
|
172
|
+
if (last && (!frames || last.frames)) {
|
|
173
|
+
const diff = frames ? diffTrees(last.lines, lines) : diffTrees(last.base, base);
|
|
174
|
+
text = diff.length ? `${header} — changes since last read (~ changed, + added):\n${diff.join("\n")}` : `${header} — no change.`;
|
|
175
|
+
} else {
|
|
176
|
+
text = `${header}\n${lines.join("\n")}`;
|
|
177
|
+
}
|
|
178
|
+
if (tree.truncated) text += `\n(truncated at ${lines.length} elements; pass {maxNodes} to see more)`;
|
|
179
|
+
// The baseline is what the model last saw, so silent reads leave it alone.
|
|
180
|
+
if (emit) {
|
|
181
|
+
this.#lastTrees.set(key, { lines, base, frames });
|
|
182
|
+
this.#waitBase = { pixels: false, value: base.join("\n") };
|
|
183
|
+
this.#ctx.out.text(text);
|
|
184
|
+
}
|
|
185
|
+
return text;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async getScreenshot(options = {}) {
|
|
189
|
+
const { window, emit = true } = options;
|
|
190
|
+
const shot = this.#ctx.config.screenshot;
|
|
191
|
+
const view = await this.#refreshView(window);
|
|
192
|
+
if (view.windowId == null) throw new Error(`Cannot find a capturable window id for ${this.name}`);
|
|
193
|
+
const image = await this.#call("screenshot", {
|
|
194
|
+
windowId: view.windowId,
|
|
195
|
+
width: view.width,
|
|
196
|
+
height: view.height,
|
|
197
|
+
format: options.format ?? shot.format,
|
|
198
|
+
quality: (options.quality ?? shot.quality) / 100,
|
|
199
|
+
});
|
|
200
|
+
const result = { width: image.width, height: image.height, mimeType: image.mimeType, view: { width: view.width, height: view.height, scale: view.scale } };
|
|
201
|
+
// Keep base64 out of printed results; it is still there for code that wants it.
|
|
202
|
+
Object.defineProperty(result, "data", { value: image.data, enumerable: false });
|
|
203
|
+
if (emit) {
|
|
204
|
+
this.#waitBase = { pixels: true, value: hashImage(image.data), format: options.format ?? shot.format, quality: options.quality };
|
|
205
|
+
this.#ctx.out.text(`Screenshot of ${this.name} window ${view.window}${view.note}: ${image.width}x${image.height}. Click coordinates are these pixels.`);
|
|
206
|
+
await this.#ctx.out.image(image.data, image.mimeType, `${this.name}-w${view.window}`);
|
|
207
|
+
}
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Polls silently until the window stops changing, then returns. For apps
|
|
212
|
+
// that repaint late (remote desktops, reports, async loads). A frame that
|
|
213
|
+
// matches any of the last few counts as stable, so a blinking caret settles.
|
|
214
|
+
async settle(options = {}) {
|
|
215
|
+
const { stable, ms, frames } = await this.#settle(options);
|
|
216
|
+
return { stable, ms, frames };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async #settle(options = {}, like = {}) {
|
|
220
|
+
const { timeout = 10_000, interval = 400, min = 300, stableFor = 2, window } = options;
|
|
221
|
+
const started = Date.now();
|
|
222
|
+
await new Promise((r) => setTimeout(r, min));
|
|
223
|
+
const recent = [];
|
|
224
|
+
let same = 0;
|
|
225
|
+
let frames = 0;
|
|
226
|
+
let hash;
|
|
227
|
+
while (Date.now() - started < timeout) {
|
|
228
|
+
hash = await this.#frameHash(window, like);
|
|
229
|
+
frames += 1;
|
|
230
|
+
same = recent.includes(hash) ? same + 1 : 0;
|
|
231
|
+
if (same >= stableFor - 1 && frames > 1) return { stable: true, ms: Date.now() - started, frames, hash };
|
|
232
|
+
recent.push(hash);
|
|
233
|
+
if (recent.length > 3) recent.shift();
|
|
234
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
235
|
+
}
|
|
236
|
+
return { stable: false, ms: Date.now() - started, frames, hash };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Waits until the window differs from the last printed read (or from where
|
|
240
|
+
// the previous wait settled), then settles. Returns {changed: false} at the
|
|
241
|
+
// timeout instead of throwing.
|
|
242
|
+
async waitForChange(options = {}) {
|
|
243
|
+
const { timeout = 10_000, interval = 300, window } = options;
|
|
244
|
+
const started = Date.now();
|
|
245
|
+
const base = this.#waitBase;
|
|
246
|
+
if (!base) throw new Error("waitForChange needs an earlier printed read; call getAXState() or getScreenshot() first");
|
|
247
|
+
const current = async () => (base.pixels ? this.#frameHash(window, base) : (await this.#treeBase(window)).join("\n"));
|
|
248
|
+
while (Date.now() - started < timeout) {
|
|
249
|
+
if ((await current()) !== base.value) {
|
|
250
|
+
const settled = await this.#settle({ ...options, timeout: Math.max(1, timeout - (Date.now() - started)), min: 0 }, base);
|
|
251
|
+
this.#waitBase = { ...base, value: base.pixels ? settled.hash : await current() };
|
|
252
|
+
return { changed: true, ms: Date.now() - started, stable: settled.stable };
|
|
253
|
+
}
|
|
254
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
255
|
+
}
|
|
256
|
+
return { changed: false, ms: Date.now() - started };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Hashed in the same format as `like`, so it can match a printed screenshot.
|
|
260
|
+
async #frameHash(window, like = {}) {
|
|
261
|
+
const shot = await this.getScreenshot({ window, emit: false, format: like.format ?? "png", quality: like.quality });
|
|
262
|
+
return hashImage(shot.data);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async #treeBase(window) {
|
|
266
|
+
const view = await this.#refreshView(window);
|
|
267
|
+
const ax = this.#ctx.config.ax;
|
|
268
|
+
const tree = await this.#call("axTree", { window: view.window, maxNodes: ax.maxNodes, maxDepth: ax.maxDepth, maxText: ax.maxTextLength });
|
|
269
|
+
return formatTree(tree.nodes);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async getAXStateAndScreenshot(options = {}) {
|
|
273
|
+
const text = await this.getAXState(options);
|
|
274
|
+
const image = await this.getScreenshot(options);
|
|
275
|
+
return { text, image };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Image-space frame of an element from the last tree.
|
|
279
|
+
async frame(index) {
|
|
280
|
+
const view = this.#view ?? (await this.#ensureView());
|
|
281
|
+
const { frame } = await this.#call("elementFrame", { index });
|
|
282
|
+
return toImage(view, frame).map((v) => Math.round(v));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async center(index) {
|
|
286
|
+
return center(await this.frame(index)).map((v) => Math.round(v));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async activate() {
|
|
290
|
+
await this.#activate();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Brings the app forward at most once per call. If it is no longer in front
|
|
294
|
+
// later in the same call, the user switched away: stop rather than take
|
|
295
|
+
// focus back from them.
|
|
296
|
+
async #activate() {
|
|
297
|
+
if (this.#activatedCell === this.#ctx.cell) {
|
|
298
|
+
if ((await this.#call("isActive")).active) return;
|
|
299
|
+
this.#ctx.stop(`Stopped: the user switched away from ${this.name}. Do not continue GUI actions until the user asks.`);
|
|
300
|
+
}
|
|
301
|
+
await this.#bringForward();
|
|
302
|
+
this.#activatedCell = this.#ctx.cell;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// macOS may refuse an activation requested by a background app, and a
|
|
306
|
+
// switch to another Space takes a moment. LaunchServices (`open -b`) is
|
|
307
|
+
// honored, so fall back to it and wait longer.
|
|
308
|
+
async #bringForward() {
|
|
309
|
+
try {
|
|
310
|
+
await this.#call("activate");
|
|
311
|
+
return;
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (error.code !== "activate_failed" || !this.bundleId || process.platform !== "darwin") throw error;
|
|
314
|
+
}
|
|
315
|
+
await execFileAsync("open", ["-b", this.bundleId]);
|
|
316
|
+
for (let i = 0; i < 40; i++) {
|
|
317
|
+
if ((await this.#call("isActive")).active) return;
|
|
318
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
319
|
+
}
|
|
320
|
+
throw new Error(`Could not bring ${this.name} to the front`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async #screenPoint(target) {
|
|
324
|
+
if (isPoint(target)) {
|
|
325
|
+
const view = await this.#ensureView();
|
|
326
|
+
const [x, y] = target;
|
|
327
|
+
// A point outside the window would land in whatever app is underneath.
|
|
328
|
+
if (x < 0 || y < 0 || x >= view.width || y >= view.height) {
|
|
329
|
+
throw new RangeError(`[${x}, ${y}] is outside ${this.name}'s window image (${view.width}x${view.height})`);
|
|
330
|
+
}
|
|
331
|
+
return toScreen(view, target);
|
|
332
|
+
}
|
|
333
|
+
if (Number.isInteger(target)) {
|
|
334
|
+
const { frame } = await this.#call("elementFrame", { index: target });
|
|
335
|
+
return center(frame);
|
|
336
|
+
}
|
|
337
|
+
throw new TypeError("Target must be [x, y] image pixels or an element index");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async click(target, options = {}) {
|
|
341
|
+
const { button = "left", count = 1, modifiers = [] } = options;
|
|
342
|
+
const modList = [].concat(modifiers);
|
|
343
|
+
const mods = modList.length ? parseKeys(`${modList.join("+")}+a`)[0].mods : [];
|
|
344
|
+
return this.#act("click", { target: describeTarget(target), button, count }, async () => {
|
|
345
|
+
const [x, y] = await this.#screenPoint(target);
|
|
346
|
+
const hit = await this.#call("hitTest", { x, y }).catch(() => null);
|
|
347
|
+
const onMenu = hit?.role === "AXMenuItem";
|
|
348
|
+
// A plain click on a pressable element: press it through accessibility,
|
|
349
|
+
// which works in the background and cannot hit the wrong window. An open
|
|
350
|
+
// menu is its own window that background pixel clicks never reach, so a
|
|
351
|
+
// menu item is pressed this way even for a raw click.
|
|
352
|
+
let press = null;
|
|
353
|
+
if (button === "left" && count === 1 && mods.length === 0 && (options.raw !== true || (onMenu && this.#useBackground(options)))) {
|
|
354
|
+
if (Number.isInteger(target)) {
|
|
355
|
+
const { actions = [] } = await this.#call("actionNames", { index: target });
|
|
356
|
+
press = { index: target, actions, role: null };
|
|
357
|
+
} else {
|
|
358
|
+
press = hit;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
await this.#pointCursor([x, y]);
|
|
362
|
+
if (press?.index != null && press.actions?.includes("AXPress") && (press.role == null || PRESSABLE.has(press.role))) {
|
|
363
|
+
await this.#call("performAction", { index: press.index, action: "AXPress" });
|
|
364
|
+
this.lastRoute = "accessibility";
|
|
365
|
+
} else {
|
|
366
|
+
await this.#deliver("click", { x, y, button, count, mods }, options);
|
|
367
|
+
}
|
|
368
|
+
if (onMenu) await this.#waitForMenuClose(x, y);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// A menu pick lands as the menu closes. Until then the next click hits the
|
|
373
|
+
// closing menu and is dropped, and a pop-up button still has its old value.
|
|
374
|
+
async #waitForMenuClose(x, y) {
|
|
375
|
+
for (let i = 0; i < 20; i++) {
|
|
376
|
+
const now = await this.#call("hitTest", { x, y }).catch(() => null);
|
|
377
|
+
if (now?.role !== "AXMenuItem") break;
|
|
378
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
379
|
+
}
|
|
380
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
doubleClick(target, options = {}) {
|
|
384
|
+
return this.click(target, { ...options, count: 2 });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
rightClick(target, options = {}) {
|
|
388
|
+
return this.click(target, { ...options, button: "right" });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Whether this action should stay in the background. Off when the caller,
|
|
392
|
+
// this handle, the config, or the app's entry in foregroundApps says so.
|
|
393
|
+
#useBackground(options) {
|
|
394
|
+
if (options.foreground === true || options.background === false || this.foreground) return false;
|
|
395
|
+
const config = this.#ctx.config;
|
|
396
|
+
if (config.input === "foreground") return false;
|
|
397
|
+
const names = [this.bundleId, this.name].filter(Boolean).map((n) => n.toLowerCase());
|
|
398
|
+
return !(config.foregroundApps ?? []).some((entry) => names.includes(String(entry).toLowerCase()));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Sends one input request to the app. Background first: the window is made
|
|
402
|
+
// key inside its app and events go to the process, so focus and the real
|
|
403
|
+
// cursor stay with the user. If this macOS or this app can't take that, the
|
|
404
|
+
// app is brought forward instead and `lastRoute` says so.
|
|
405
|
+
async #deliver(method, params, options = {}) {
|
|
406
|
+
if (this.#useBackground(options)) {
|
|
407
|
+
const view = await this.#ensureView();
|
|
408
|
+
if (view.windowId != null) {
|
|
409
|
+
try {
|
|
410
|
+
const result = await this.#call(method, { ...params, mode: "background", windowId: view.windowId });
|
|
411
|
+
this.lastRoute = result?.focusRestored ? `${result.route} (focus restored)` : (result?.route ?? "background");
|
|
412
|
+
return;
|
|
413
|
+
} catch (error) {
|
|
414
|
+
if (!["background_unavailable", "background_unconfirmed"].includes(error.code)) throw error;
|
|
415
|
+
this.#ctx.log({ app: this.bundleId ?? this.name, action: "fallback", reason: error.message });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
await this.#activate();
|
|
420
|
+
await this.#call(method, params);
|
|
421
|
+
this.lastRoute = "foreground";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Moves the drawn agent cursor to a screen point above the app's window.
|
|
425
|
+
async #pointCursor([x, y]) {
|
|
426
|
+
if (this.#ctx.config.cursor === false) return;
|
|
427
|
+
try {
|
|
428
|
+
await this.#ctx.backend.request("cursor", { x, y, windowId: this.#view?.windowId });
|
|
429
|
+
} catch {
|
|
430
|
+
// The cursor is decoration; never fail an action over it.
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async pressKey(keys, options = {}) {
|
|
435
|
+
const chords = parseKeys(keys);
|
|
436
|
+
const repeat = Math.max(1, options.repeat ?? 1);
|
|
437
|
+
return this.#act("pressKey", { keys: String(keys), repeat }, async () => {
|
|
438
|
+
const all = Array.from({ length: repeat }, () => chords).flat();
|
|
439
|
+
await this.#deliver("keys", { chords: all }, options);
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async typeText(text, options = {}) {
|
|
444
|
+
if (typeof text !== "string") throw new TypeError("typeText expects a string");
|
|
445
|
+
const detail = this.#ctx.config.log.text ? { text } : { length: text.length };
|
|
446
|
+
return this.#act("typeText", detail, () => this.#deliver("type", { text }, options));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async scroll(target, amount = 3, options = {}) {
|
|
450
|
+
const { dy, dx } = typeof amount === "number" ? { dy: amount, dx: options.dx ?? 0 } : { dy: 0, dx: 0, ...amount };
|
|
451
|
+
return this.#act("scroll", { target: describeTarget(target), dy, dx }, async () => {
|
|
452
|
+
const [x, y] = await this.#screenPoint(target);
|
|
453
|
+
await this.#pointCursor([x, y]);
|
|
454
|
+
await this.#deliver("scroll", { x, y, dy, dx }, options);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async drag(from, to, options = {}) {
|
|
459
|
+
const points = to === undefined && Array.isArray(from) && Array.isArray(from[0]) ? from : [from, to];
|
|
460
|
+
return this.#act("drag", { path: points.map(describeTarget).join(" -> ") }, async () => {
|
|
461
|
+
const path = [];
|
|
462
|
+
for (const point of points) path.push(await this.#screenPoint(point));
|
|
463
|
+
await this.#pointCursor(path[0]);
|
|
464
|
+
await this.#deliver("drag", { path, button: options.button ?? "left" }, options);
|
|
465
|
+
await this.#pointCursor(path.at(-1));
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async setValue(index, value) {
|
|
470
|
+
return this.#act("setValue", { target: `#${index}` }, () => this.#call("setValue", { index, value }));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async focus(index) {
|
|
474
|
+
return this.#act("focus", { target: `#${index}` }, () => this.#call("focusElement", { index }));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async selectText(index, options = {}) {
|
|
478
|
+
return this.#act("selectText", { target: `#${index}` }, () =>
|
|
479
|
+
this.#call("selectText", { index, start: options.start, length: options.length }),
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async listActions(index) {
|
|
484
|
+
const { actions } = await this.#call("actionNames", { index });
|
|
485
|
+
return actions;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async performSecondaryAction(index, action) {
|
|
489
|
+
const actions = await this.listActions(index);
|
|
490
|
+
const wanted = String(action).toLowerCase().replace(/\s+/g, "");
|
|
491
|
+
const match = actions.find((a) => a.toLowerCase().replace(/^ax/, "").replace(/\s+/g, "") === wanted.replace(/^ax/, ""));
|
|
492
|
+
if (!match) throw new Error(`Element ${index} has no action "${action}". It has: ${actions.join(", ") || "none"}`);
|
|
493
|
+
return this.#act("performAction", { target: `#${index}`, action: match }, () =>
|
|
494
|
+
this.#call("performAction", { index, action: match }),
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export function createApi(ctx) {
|
|
500
|
+
const apps = new Map();
|
|
501
|
+
|
|
502
|
+
const gb = {
|
|
503
|
+
async listApps(options = {}) {
|
|
504
|
+
ctx.checkAbort();
|
|
505
|
+
const all = await ctx.backend.request("listApps");
|
|
506
|
+
const running = all.filter((a) => a.running);
|
|
507
|
+
const list = options.installed ? all : running;
|
|
508
|
+
if (options.emit !== false) {
|
|
509
|
+
const lines = list.map((a) => `${a.name} (${a.bundleId ?? "?"})${a.running ? ` pid ${a.pid}` : ""}${a.active ? " [front]" : ""}`);
|
|
510
|
+
ctx.out.text(`${options.installed ? "Apps" : "Running apps"}:\n${lines.join("\n")}${options.installed ? "" : "\n(pass {installed: true} for installed apps)"}`);
|
|
511
|
+
}
|
|
512
|
+
return list;
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
// Launches the app if needed and returns a handle. Does not take focus.
|
|
516
|
+
async getApp(query, options = {}) {
|
|
517
|
+
ctx.checkAbort();
|
|
518
|
+
let info = await ctx.backend.request("resolveApp", { query: String(query) });
|
|
519
|
+
if (!info.running) {
|
|
520
|
+
ctx.log({ app: info.bundleId, action: "launch" });
|
|
521
|
+
info = await ctx.backend.request("launch", {
|
|
522
|
+
bundleId: info.bundleId,
|
|
523
|
+
path: info.path,
|
|
524
|
+
name: info.name,
|
|
525
|
+
activate: options.activate ?? false,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
const existing = apps.get(info.pid);
|
|
529
|
+
if (existing) return existing;
|
|
530
|
+
const app = new App(ctx, info);
|
|
531
|
+
apps.set(info.pid, app);
|
|
532
|
+
if (options.foreground) app.foreground = true;
|
|
533
|
+
if (options.activate) await app.activate();
|
|
534
|
+
return app;
|
|
535
|
+
},
|
|
536
|
+
|
|
537
|
+
sleep(ms) {
|
|
538
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
539
|
+
},
|
|
540
|
+
|
|
541
|
+
async permissions() {
|
|
542
|
+
return ctx.backend.request("permissions", {});
|
|
543
|
+
},
|
|
544
|
+
|
|
545
|
+
help(topic) {
|
|
546
|
+
const text = ctx.help(topic);
|
|
547
|
+
ctx.out.text(text);
|
|
548
|
+
return undefined;
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
return gb;
|
|
552
|
+
}
|