@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/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "@vashp2029/glovebox",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }