@beeos-ai/device-mcp-server 0.2.3

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.
Files changed (62) hide show
  1. package/LICENSE +201 -0
  2. package/dist/backends/android-adb-runner.d.ts +32 -0
  3. package/dist/backends/android-adb-runner.js +15 -0
  4. package/dist/backends/android-adb-runner.js.map +1 -0
  5. package/dist/backends/android-adb.d.ts +153 -0
  6. package/dist/backends/android-adb.js +723 -0
  7. package/dist/backends/android-adb.js.map +1 -0
  8. package/dist/backends/base.d.ts +150 -0
  9. package/dist/backends/base.js +116 -0
  10. package/dist/backends/base.js.map +1 -0
  11. package/dist/backends/desktop.d.ts +62 -0
  12. package/dist/backends/desktop.js +176 -0
  13. package/dist/backends/desktop.js.map +1 -0
  14. package/dist/backends/index.d.ts +63 -0
  15. package/dist/backends/index.js +105 -0
  16. package/dist/backends/index.js.map +1 -0
  17. package/dist/backends/linux.d.ts +69 -0
  18. package/dist/backends/linux.js +230 -0
  19. package/dist/backends/linux.js.map +1 -0
  20. package/dist/backends/mac.d.ts +154 -0
  21. package/dist/backends/mac.js +881 -0
  22. package/dist/backends/mac.js.map +1 -0
  23. package/dist/backends/stubs/ios.d.ts +17 -0
  24. package/dist/backends/stubs/ios.js +32 -0
  25. package/dist/backends/stubs/ios.js.map +1 -0
  26. package/dist/backends/stubs/macos.d.ts +13 -0
  27. package/dist/backends/stubs/macos.js +27 -0
  28. package/dist/backends/stubs/macos.js.map +1 -0
  29. package/dist/backends/stubs/windows.d.ts +69 -0
  30. package/dist/backends/stubs/windows.js +191 -0
  31. package/dist/backends/stubs/windows.js.map +1 -0
  32. package/dist/cli.d.ts +37 -0
  33. package/dist/cli.js +177 -0
  34. package/dist/cli.js.map +1 -0
  35. package/dist/index.d.ts +13 -0
  36. package/dist/index.js +14 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/server/action-mapping.d.ts +21 -0
  39. package/dist/server/action-mapping.js +153 -0
  40. package/dist/server/action-mapping.js.map +1 -0
  41. package/dist/server/app.d.ts +23 -0
  42. package/dist/server/app.js +157 -0
  43. package/dist/server/app.js.map +1 -0
  44. package/dist/server/tool-registry.d.ts +50 -0
  45. package/dist/server/tool-registry.js +504 -0
  46. package/dist/server/tool-registry.js.map +1 -0
  47. package/dist/util/adb-files.d.ts +92 -0
  48. package/dist/util/adb-files.js +221 -0
  49. package/dist/util/adb-files.js.map +1 -0
  50. package/dist/util/adb-shell.d.ts +80 -0
  51. package/dist/util/adb-shell.js +102 -0
  52. package/dist/util/adb-shell.js.map +1 -0
  53. package/dist/util/android-apps.d.ts +10 -0
  54. package/dist/util/android-apps.js +103 -0
  55. package/dist/util/android-apps.js.map +1 -0
  56. package/dist/util/image-dim.d.ts +27 -0
  57. package/dist/util/image-dim.js +37 -0
  58. package/dist/util/image-dim.js.map +1 -0
  59. package/dist/util/ui-xml.d.ts +20 -0
  60. package/dist/util/ui-xml.js +184 -0
  61. package/dist/util/ui-xml.js.map +1 -0
  62. package/package.json +56 -0
@@ -0,0 +1,881 @@
1
+ /**
2
+ * MacOsBackend — concrete `SandboxBackend` driving a real macOS host.
3
+ *
4
+ * Best-practice topology after researching the field (Anthropic Computer Use,
5
+ * OpenInterpreter os-mode, mac-control skill, the Python reference at
6
+ * `deviceagent_research/.../mcp_tools/backend_macos.py`, etc.):
7
+ *
8
+ * - **Pointer** (click / move / drag / scroll / double / right / longPress):
9
+ * `cliclick` (Homebrew, optional) preferred when on `PATH`; falls back to
10
+ * `osascript` System Events when absent. cliclick is significantly more
11
+ * reliable for coordinate-based mouse synthesis on modern (sandboxed) apps.
12
+ * - **Keyboard** (typeText / pressKey / hotkey): `osascript` System Events.
13
+ * Handles unicode, modifier chords, and raw key codes. No extra dependency.
14
+ * - **Screenshot**: `screencapture -x -t png <tmpfile>`, read back into
15
+ * memory, unlink — `screencapture` does NOT support stdout output despite
16
+ * `-o -` / `-` folklore, so we round-trip through a per-call temp file.
17
+ * `sharp` then resizes to the display's logical-point resolution so click
18
+ * coords and screenshot pixels share one coordinate system (Retina-correct).
19
+ * - **Screen size**: `system_profiler SPDisplaysDataType -json` parsed at
20
+ * `connect()` time, cached for the process lifetime.
21
+ * - **Launch app**: `open -a NAME` for human names, `open -b BUNDLE_ID` for
22
+ * reverse-DNS bundle ids, `open <path>` for `.app`/`.dmg`/`.pkg`.
23
+ * - **executeCommand**: `/bin/sh -c body`. Same shape as the Android backend.
24
+ * - **file_read / write / list_directory**: Node `fs/promises` direct (we
25
+ * run *on* the host).
26
+ * - **Mobile verb aliasing** (matches reference convention): `tap → click`,
27
+ * `swipe → drag`, `long_press → cliclick dd + sleep + du`. `back` is
28
+ * unsupported on macOS, `home` is mapped to F11 (Mission Control "show
29
+ * desktop").
30
+ *
31
+ * **Permissions**: the host process MUST hold Accessibility permission
32
+ * (System Settings → Privacy & Security → Accessibility). Without it macOS
33
+ * silently drops every synthetic input event. There is no programmatic
34
+ * bypass — we surface a `DeviceError("permission_required")` when cliclick /
35
+ * osascript exit non-zero so the operator can grant the permission.
36
+ *
37
+ * **Testability**: the constructor accepts an injected `runner` (an
38
+ * `execFile`-like async callable) and an `fs` shim. Defaults bind to a
39
+ * spawn-backed runner and `node:fs/promises`. Unit tests pass spy runners
40
+ * so the suite runs identically on Linux CI without any real shell calls.
41
+ */
42
+ import { spawn } from "node:child_process";
43
+ import * as fsPromises from "node:fs/promises";
44
+ import * as os from "node:os";
45
+ import * as path from "node:path";
46
+ import sharp from "sharp";
47
+ import { COMMON_TOOLS, DESKTOP_TOOLS, DeviceError, } from "@beeos-ai/device-common";
48
+ import { BaseSandboxBackend, } from "./base.js";
49
+ import { parsePngDimensions } from "../util/image-dim.js";
50
+ /**
51
+ * macOS tool catalogue.
52
+ *
53
+ * Common tools minus `install_apk` (mac has no APK story) plus the full
54
+ * desktop catalogue. Mobile-only tools (`tap`, `swipe`, `back`, `home`,
55
+ * `ui_dump`) are intentionally absent — they have no macOS analogue and
56
+ * the OS-family split keeps prompts platform-faithful instead of
57
+ * relying on internal aliasing (`tap → click`, `swipe → drag`).
58
+ */
59
+ const MACOS_TOOLS = new Set([
60
+ ...COMMON_TOOLS.filter((t) => t !== "install_apk"),
61
+ ...DESKTOP_TOOLS,
62
+ ]);
63
+ /* ----------------------------------------------------------------------- */
64
+ /* Embedded AppleScript / JXA snippets */
65
+ /* ----------------------------------------------------------------------- */
66
+ /*
67
+ * IMPORTANT: System Events' `click at {x, y}` AppleScript syntax does NOT
68
+ * actually work as a synthetic-input primitive on modern macOS (10.14+) — it
69
+ * raises a -2741 syntax error or silently no-ops depending on the build. The
70
+ * reliable, dependency-free fallback is **JXA + Quartz CGEvent**, which every
71
+ * production computer-use agent on macOS uses (pyautogui, mac-control,
72
+ * Anthropic Computer Use, etc.). Keyboard verbs (`keystroke`, `key code`)
73
+ * remain on AppleScript because that path *does* work and avoids JXA's
74
+ * keyboard-mapping subtleties.
75
+ */
76
+ /** JXA — synthetic mouse click via Quartz CGEvent. Used as cliclick fallback. */
77
+ const _JXA_CLICK = `ObjC.import('CoreGraphics');
78
+ function run(argv) {
79
+ var x = parseInt(argv[0], 10);
80
+ var y = parseInt(argv[1], 10);
81
+ var btn = argv[2] || 'left';
82
+ var clicks = parseInt(argv[3], 10) || 1;
83
+ var isRight = (btn === 'right');
84
+ var downType = isRight ? 4 : 1;
85
+ var upType = isRight ? 5 : 2;
86
+ var button = isRight ? 1 : 0;
87
+ for (var i = 0; i < clicks; i++) {
88
+ var p = $.CGPointMake(x, y);
89
+ var ed = $.CGEventCreateMouseEvent($(), downType, p, button);
90
+ $.CGEventPost(0, ed);
91
+ var eu = $.CGEventCreateMouseEvent($(), upType, p, button);
92
+ $.CGEventPost(0, eu);
93
+ delay(0.05);
94
+ }
95
+ return 'ok';
96
+ }`;
97
+ /** JXA — synthetic mouse drag (down → move → up). Used as cliclick fallback. */
98
+ const _JXA_DRAG = `ObjC.import('CoreGraphics');
99
+ function run(argv) {
100
+ var x1 = parseInt(argv[0], 10);
101
+ var y1 = parseInt(argv[1], 10);
102
+ var x2 = parseInt(argv[2], 10);
103
+ var y2 = parseInt(argv[3], 10);
104
+ var p1 = $.CGPointMake(x1, y1);
105
+ var p2 = $.CGPointMake(x2, y2);
106
+ var ed = $.CGEventCreateMouseEvent($(), 1, p1, 0);
107
+ $.CGEventPost(0, ed);
108
+ delay(0.05);
109
+ var em = $.CGEventCreateMouseEvent($(), 6, p2, 0);
110
+ $.CGEventPost(0, em);
111
+ delay(0.05);
112
+ var eu = $.CGEventCreateMouseEvent($(), 2, p2, 0);
113
+ $.CGEventPost(0, eu);
114
+ return 'ok';
115
+ }`;
116
+ /**
117
+ * JXA — synthetic mouse-wheel scroll. cliclick doesn't have a scroll
118
+ * primitive, so we ALWAYS use this script for scroll regardless of cliclick
119
+ * presence.
120
+ */
121
+ const _JXA_SCROLL = `ObjC.import('CoreGraphics');
122
+ function run(argv) {
123
+ var dx = parseInt(argv[0], 10) || 0;
124
+ var dy = parseInt(argv[1], 10) || 0;
125
+ var e;
126
+ if (dx === 0) {
127
+ e = $.CGEventCreateScrollWheelEvent($(), 1, 1, dy);
128
+ } else {
129
+ e = $.CGEventCreateScrollWheelEvent($(), 1, 2, dy, dx);
130
+ }
131
+ $.CGEventPost(0, e);
132
+ return 'ok';
133
+ }`;
134
+ /** JXA — synthetic mouse-move (no click). Used as cliclick fallback. */
135
+ const _JXA_MOVE = `ObjC.import('CoreGraphics');
136
+ function run(argv) {
137
+ var x = parseInt(argv[0], 10);
138
+ var y = parseInt(argv[1], 10);
139
+ var p = $.CGPointMake(x, y);
140
+ var e = $.CGEventCreateMouseEvent($(), 5, p, 0);
141
+ $.CGEventPost(0, e);
142
+ return 'ok';
143
+ }`;
144
+ const _OSA_TYPE = `on run argv
145
+ set t to item 1 of argv as text
146
+ tell application "System Events" to keystroke t
147
+ end run`;
148
+ const _OSA_KEY = `on run argv
149
+ set k to item 1 of argv as text
150
+ tell application "System Events" to key code (k as integer)
151
+ end run`;
152
+ const _OSA_HOTKEY = `on run argv
153
+ -- argv: keycode, modlist (comma-separated of: command, control, option, shift)
154
+ set kc to (item 1 of argv) as integer
155
+ set modlist to item 2 of argv as text
156
+ set mods to {}
157
+ if modlist contains "command" then set end of mods to command down
158
+ if modlist contains "control" then set end of mods to control down
159
+ if modlist contains "option" then set end of mods to option down
160
+ if modlist contains "shift" then set end of mods to shift down
161
+ tell application "System Events" to key code kc using mods
162
+ end run`;
163
+ const _OSA_HOME = `tell application "System Events" to key code 103 using {fn down}`;
164
+ /**
165
+ * AppleScript key code map for common keys.
166
+ * Reference: https://eastmanreference.com/complete-list-of-applescript-key-codes
167
+ *
168
+ * Lookups are case-insensitive; we also accept a few aliases people commonly
169
+ * type (Enter ↔ Return, BackSpace ↔ Delete, Esc ↔ Escape).
170
+ */
171
+ const _KEY_CODES = {
172
+ return: 36,
173
+ enter: 36,
174
+ tab: 48,
175
+ space: 49,
176
+ delete: 51,
177
+ backspace: 51,
178
+ escape: 53,
179
+ esc: 53,
180
+ up: 126,
181
+ down: 125,
182
+ left: 123,
183
+ right: 124,
184
+ home: 115,
185
+ end: 119,
186
+ pageup: 116,
187
+ pagedown: 121,
188
+ // Single letters/digits map to the same code as the lowercase character.
189
+ // Common ASCII letter codes:
190
+ a: 0,
191
+ s: 1,
192
+ d: 2,
193
+ f: 3,
194
+ h: 4,
195
+ g: 5,
196
+ z: 6,
197
+ x: 7,
198
+ c: 8,
199
+ v: 9,
200
+ b: 11,
201
+ q: 12,
202
+ w: 13,
203
+ e: 14,
204
+ r: 15,
205
+ y: 16,
206
+ t: 17,
207
+ o: 31,
208
+ u: 32,
209
+ i: 34,
210
+ p: 35,
211
+ l: 37,
212
+ j: 38,
213
+ k: 40,
214
+ n: 45,
215
+ m: 46,
216
+ // Function keys
217
+ f1: 122,
218
+ f2: 120,
219
+ f3: 99,
220
+ f4: 118,
221
+ f5: 96,
222
+ f6: 97,
223
+ f7: 98,
224
+ f8: 100,
225
+ f9: 101,
226
+ f10: 109,
227
+ f11: 103,
228
+ f12: 111,
229
+ };
230
+ function lookupKeyCode(key) {
231
+ return _KEY_CODES[key.toLowerCase()];
232
+ }
233
+ const DEFAULT_TIMEOUT_MS = 10_000;
234
+ const SHELL_TIMEOUT_MS = 30_000;
235
+ const SCREENCAPTURE_TIMEOUT_MS = 15_000;
236
+ /** Default runner — `child_process.spawn` wrapper supporting stdin pipe. */
237
+ function defaultRunner(file, args, opts = {}) {
238
+ return new Promise((resolve, reject) => {
239
+ const child = spawn(file, args, {
240
+ stdio: ["pipe", "pipe", "pipe"],
241
+ cwd: opts.cwd,
242
+ });
243
+ const stdoutChunks = [];
244
+ const stderrChunks = [];
245
+ child.stdout?.on("data", (c) => stdoutChunks.push(c));
246
+ child.stderr?.on("data", (c) => stderrChunks.push(c));
247
+ let timer;
248
+ let timedOut = false;
249
+ if (opts.timeoutMs && opts.timeoutMs > 0) {
250
+ timer = setTimeout(() => {
251
+ timedOut = true;
252
+ try {
253
+ child.kill("SIGTERM");
254
+ }
255
+ catch {
256
+ /* swallow */
257
+ }
258
+ }, opts.timeoutMs);
259
+ }
260
+ if (opts.input !== undefined) {
261
+ child.stdin?.end(opts.input);
262
+ }
263
+ else {
264
+ child.stdin?.end();
265
+ }
266
+ child.on("error", (err) => {
267
+ if (timer)
268
+ clearTimeout(timer);
269
+ reject(err);
270
+ });
271
+ child.on("close", (code, signal) => {
272
+ if (timer)
273
+ clearTimeout(timer);
274
+ const stdoutBuf = Buffer.concat(stdoutChunks);
275
+ const stderr = Buffer.concat(stderrChunks).toString("utf8");
276
+ const stdout = opts.encoding === "buffer" ? stdoutBuf : stdoutBuf.toString("utf8");
277
+ if (timedOut) {
278
+ const e = new Error(`${file} timed out after ${opts.timeoutMs}ms`);
279
+ e.signal = "SIGTERM";
280
+ e.killed = true;
281
+ e.stdout = stdout;
282
+ e.stderr = stderr;
283
+ reject(e);
284
+ return;
285
+ }
286
+ if (code === 0) {
287
+ resolve({ stdout, stderr });
288
+ return;
289
+ }
290
+ const err = new Error(`${file} exited with code ${code}${signal ? ` (signal ${signal})` : ""}`);
291
+ err.stdout = stdout;
292
+ err.stderr = stderr;
293
+ // `NodeJS.ErrnoException.code` is typed as `string`; the intersection
294
+ // above widens it to `string | number` for the read-side, but we still
295
+ // need a type-safe write. Stash on a private prop so production code
296
+ // and tests both rely on the same field.
297
+ err.code =
298
+ typeof code === "number" ? code : 1;
299
+ reject(err);
300
+ });
301
+ });
302
+ }
303
+ function defaultFs() {
304
+ return {
305
+ readFile: (p) => fsPromises.readFile(p),
306
+ writeFile: (p, data) => fsPromises.writeFile(p, data),
307
+ readdir: (p, _opts) => fsPromises.readdir(p, { withFileTypes: true }),
308
+ stat: (p) => fsPromises.stat(p),
309
+ access: (p) => fsPromises.access(p),
310
+ unlink: (p) => fsPromises.unlink(p).catch(() => undefined),
311
+ tmpdir: () => os.tmpdir(),
312
+ };
313
+ }
314
+ export class MacOsBackend extends BaseSandboxBackend {
315
+ os = "desktop-macos";
316
+ tools = MACOS_TOOLS;
317
+ runner;
318
+ fs;
319
+ sleep;
320
+ cliclickPath;
321
+ cliclickProbed;
322
+ forceFallback;
323
+ cachedSize;
324
+ retinaScale;
325
+ constructor(opts = {}) {
326
+ super();
327
+ this.runner = opts.runner ?? defaultRunner;
328
+ this.fs = opts.fs ?? defaultFs();
329
+ this.sleep =
330
+ opts.sleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
331
+ this.cliclickPath = opts.cliclickPath;
332
+ this.cliclickProbed = opts.cliclickPath !== undefined || !!opts.forceFallback;
333
+ this.forceFallback = !!opts.forceFallback;
334
+ this.cachedSize = opts.size;
335
+ this.retinaScale = opts.retinaScale ?? 1;
336
+ }
337
+ /* ------------------------------------------------------------------ */
338
+ /* Lifecycle */
339
+ /* ------------------------------------------------------------------ */
340
+ async connect() {
341
+ await this.probeCliclick();
342
+ if (!this.cachedSize) {
343
+ const probed = await this.probeDisplay().catch(() => undefined);
344
+ if (probed) {
345
+ this.cachedSize = { w: probed.logicalW, h: probed.logicalH };
346
+ this.retinaScale = probed.scale;
347
+ }
348
+ else {
349
+ this.cachedSize = { w: 1440, h: 900 };
350
+ }
351
+ }
352
+ }
353
+ /* ------------------------------------------------------------------ */
354
+ /* Info / observation */
355
+ /* ------------------------------------------------------------------ */
356
+ async info() {
357
+ const size = await this.screenSize();
358
+ const [osVersion, name] = await Promise.all([
359
+ this.runUtf8("sw_vers", ["-productVersion"]).catch(() => ""),
360
+ this.runUtf8("scutil", ["--get", "ComputerName"]).catch(() => "macOS"),
361
+ ]);
362
+ const capabilities = [
363
+ "screenshot",
364
+ "click",
365
+ "tap",
366
+ "drag",
367
+ "swipe",
368
+ "scroll",
369
+ "type",
370
+ "press_key",
371
+ "hotkey",
372
+ "launch_app",
373
+ "shell",
374
+ "file_io",
375
+ "home",
376
+ ];
377
+ return {
378
+ type: "desktop-macos",
379
+ name: (name || "macOS").trim(),
380
+ os: "macOS",
381
+ width: size.w,
382
+ height: size.h,
383
+ capabilities,
384
+ osVersion: osVersion.trim(),
385
+ density: 0,
386
+ metadata: {
387
+ cliclick: this.cliclickPath ? "present" : "absent",
388
+ retinaScale: String(this.retinaScale),
389
+ },
390
+ };
391
+ }
392
+ async screenshot() {
393
+ // macOS `screencapture` does NOT honour `-o -` as stdout (despite some
394
+ // online folklore — `-o` is not a stdout switch in the official manpage),
395
+ // and the bare `-` filename is also not interpreted as stdout. Real-world
396
+ // usage on every modern macOS release requires writing to a path, so we
397
+ // round-trip through a per-call temp file. This still keeps the runner
398
+ // injectable: tests can mock both the runner and the fs shim.
399
+ const tmpPath = path.join(this.fs.tmpdir(), `beeos-mac-shot-${process.pid}-${Date.now()}-${Math.floor(Math.random() * 1e6)}.png`);
400
+ let raw;
401
+ try {
402
+ await this.runUtf8("screencapture", ["-x", "-t", "png", tmpPath], {
403
+ timeoutMs: SCREENCAPTURE_TIMEOUT_MS,
404
+ });
405
+ raw = await this.fs.readFile(tmpPath);
406
+ }
407
+ finally {
408
+ await this.fs.unlink(tmpPath).catch(() => undefined);
409
+ }
410
+ if (!Buffer.isBuffer(raw) || raw.length < 8) {
411
+ throw new DeviceError("screencapture returned no data", {
412
+ subtype: "screenshot_failed",
413
+ });
414
+ }
415
+ // Non-Retina (or scale-not-yet-detected) hosts: pass the PNG straight
416
+ // through. Click coords already share the screencapture coordinate space.
417
+ if (this.retinaScale <= 1) {
418
+ const [rawW, rawH] = parsePngDimensions(raw);
419
+ return { data: raw, width: rawW, height: rawH, format: "png" };
420
+ }
421
+ const size = await this.screenSize();
422
+ try {
423
+ const data = await sharp(raw, { failOn: "none" })
424
+ .resize({ width: size.w, height: size.h, fit: "fill" })
425
+ .png()
426
+ .toBuffer();
427
+ return { data, width: size.w, height: size.h, format: "png" };
428
+ }
429
+ catch {
430
+ const [rawW, rawH] = parsePngDimensions(raw);
431
+ return { data: raw, width: rawW, height: rawH, format: "png" };
432
+ }
433
+ }
434
+ async screenSize() {
435
+ if (this.cachedSize)
436
+ return this.cachedSize;
437
+ try {
438
+ const probed = await this.probeDisplay();
439
+ this.cachedSize = { w: probed.logicalW, h: probed.logicalH };
440
+ this.retinaScale = probed.scale;
441
+ return this.cachedSize;
442
+ }
443
+ catch {
444
+ this.cachedSize = { w: 1440, h: 900 };
445
+ return this.cachedSize;
446
+ }
447
+ }
448
+ /* ------------------------------------------------------------------ */
449
+ /* Pointer / gesture */
450
+ /* ------------------------------------------------------------------ */
451
+ async click(x, y, button = "left", clicks = 1) {
452
+ if (this.useCliclick()) {
453
+ const verb = button === "right" ? "rc" : "c";
454
+ const args = [];
455
+ for (let i = 0; i < clicks; i++) {
456
+ args.push(`${verb}:${x},${y}`);
457
+ }
458
+ await this.runCliclick(args);
459
+ return;
460
+ }
461
+ await this.runJxa(_JXA_CLICK, [
462
+ String(x),
463
+ String(y),
464
+ button,
465
+ String(clicks),
466
+ ]);
467
+ }
468
+ async doubleClick(x, y) {
469
+ if (this.useCliclick()) {
470
+ await this.runCliclick([`dc:${x},${y}`]);
471
+ return;
472
+ }
473
+ await this.runJxa(_JXA_CLICK, [String(x), String(y), "left", "2"]);
474
+ }
475
+ async rightClick(x, y) {
476
+ return this.click(x, y, "right", 1);
477
+ }
478
+ async tap(x, y) {
479
+ return this.click(x, y, "left", 1);
480
+ }
481
+ async longPress(x, y, durationMs = 800) {
482
+ if (this.useCliclick()) {
483
+ await this.runCliclick([`dd:${x},${y}`]);
484
+ await this.sleep(durationMs);
485
+ await this.runCliclick([`du:${x},${y}`]);
486
+ return;
487
+ }
488
+ // JXA fallback: drag in place with a brief delay simulates a long press.
489
+ await this.runJxa(_JXA_DRAG, [
490
+ String(x),
491
+ String(y),
492
+ String(x),
493
+ String(y),
494
+ ]);
495
+ }
496
+ async drag(x1, y1, x2, y2, _durationMs) {
497
+ if (this.useCliclick()) {
498
+ await this.runCliclick([
499
+ `dd:${x1},${y1}`,
500
+ `m:${x2},${y2}`,
501
+ `du:${x2},${y2}`,
502
+ ]);
503
+ return;
504
+ }
505
+ await this.runJxa(_JXA_DRAG, [
506
+ String(x1),
507
+ String(y1),
508
+ String(x2),
509
+ String(y2),
510
+ ]);
511
+ }
512
+ async swipe(x1, y1, x2, y2, durationMs) {
513
+ return this.drag(x1, y1, x2, y2, durationMs);
514
+ }
515
+ async scroll(x, y, direction, amount = 3) {
516
+ // cliclick has NO scroll primitive (verified against cliclick 5.x — its
517
+ // command list is c/dc/rc/tc/m/dd/dm/du/kp/kd/ku/t/cp/w only). For scroll
518
+ // we always go through JXA + Quartz CGEventCreateScrollWheelEvent, which
519
+ // works regardless of cliclick installation. We optionally move the mouse
520
+ // first via cliclick `m:` if available so the scroll lands on the
521
+ // intended hit-test target; otherwise we fall back to a JXA move.
522
+ const dy = direction === "up" ? amount : direction === "down" ? -amount : 0;
523
+ const dx = direction === "right" ? amount : direction === "left" ? -amount : 0;
524
+ if (x !== 0 || y !== 0) {
525
+ await this.move(x, y);
526
+ }
527
+ await this.runJxa(_JXA_SCROLL, [String(dx), String(dy)]);
528
+ }
529
+ async move(x, y) {
530
+ if (this.useCliclick()) {
531
+ await this.runCliclick([`m:${x},${y}`]);
532
+ return;
533
+ }
534
+ // JXA fallback: emit a single MouseMoved CGEvent at the target point.
535
+ await this.runJxa(_JXA_MOVE, [String(x), String(y)]);
536
+ }
537
+ /* ------------------------------------------------------------------ */
538
+ /* Keyboard */
539
+ /* ------------------------------------------------------------------ */
540
+ async typeText(text) {
541
+ await this.runOsa(_OSA_TYPE, [text]);
542
+ }
543
+ async pressKey(key) {
544
+ const code = lookupKeyCode(key);
545
+ if (code === undefined) {
546
+ // Fall back to typeText for printable single chars / unknown names.
547
+ await this.typeText(key);
548
+ return;
549
+ }
550
+ await this.runOsa(_OSA_KEY, [String(code)]);
551
+ }
552
+ async hotkey(...keys) {
553
+ const mods = [];
554
+ let target;
555
+ for (const k of keys) {
556
+ const lk = k.toLowerCase();
557
+ if (lk === "cmd" || lk === "command" || lk === "meta" || lk === "win") {
558
+ mods.push("command");
559
+ }
560
+ else if (lk === "ctrl" || lk === "control") {
561
+ mods.push("control");
562
+ }
563
+ else if (lk === "alt" || lk === "option" || lk === "opt") {
564
+ mods.push("option");
565
+ }
566
+ else if (lk === "shift") {
567
+ mods.push("shift");
568
+ }
569
+ else {
570
+ target = k;
571
+ }
572
+ }
573
+ if (!target) {
574
+ throw new DeviceError("hotkey requires a non-modifier target key", {
575
+ subtype: "invalid_args",
576
+ });
577
+ }
578
+ const code = lookupKeyCode(target);
579
+ if (code === undefined) {
580
+ // No key code → emit as plain keystroke; modifiers are dropped because
581
+ // AppleScript `keystroke "x" using {command down}` requires a literal
582
+ // single character which we can pass safely only for ASCII.
583
+ if (mods.length === 0 && target.length === 1) {
584
+ await this.typeText(target);
585
+ return;
586
+ }
587
+ throw new DeviceError(`unknown hotkey target '${target}'`, {
588
+ subtype: "invalid_args",
589
+ });
590
+ }
591
+ await this.runOsa(_OSA_HOTKEY, [String(code), mods.join(",")]);
592
+ }
593
+ async home() {
594
+ await this.runOsa(_OSA_HOME, []);
595
+ }
596
+ /* ------------------------------------------------------------------ */
597
+ /* App lifecycle */
598
+ /* ------------------------------------------------------------------ */
599
+ async launchApp(pkgOrName, _activity) {
600
+ const args = [];
601
+ if (pkgOrName.endsWith(".app") || pkgOrName.startsWith("/")) {
602
+ args.push(pkgOrName);
603
+ }
604
+ else if (looksLikeBundleId(pkgOrName)) {
605
+ args.push("-b", pkgOrName);
606
+ }
607
+ else {
608
+ args.push("-a", pkgOrName);
609
+ }
610
+ await this.runUtf8("open", args);
611
+ }
612
+ /* ------------------------------------------------------------------ */
613
+ /* Shell / files */
614
+ /* ------------------------------------------------------------------ */
615
+ async executeCommand(body, opts = {}) {
616
+ const timeoutMs = (opts.timeoutS ?? SHELL_TIMEOUT_MS / 1000) * 1000;
617
+ try {
618
+ const { stdout, stderr } = await this.runner("/bin/sh", ["-c", body], {
619
+ encoding: "utf8",
620
+ timeoutMs,
621
+ cwd: opts.cwd,
622
+ });
623
+ return {
624
+ stdout: typeof stdout === "string" ? stdout : stdout.toString("utf8"),
625
+ stderr,
626
+ exitCode: 0,
627
+ };
628
+ }
629
+ catch (e) {
630
+ const err = e;
631
+ if (err.killed && err.signal === "SIGTERM") {
632
+ throw new DeviceError(`shell timed out after ${timeoutMs}ms`, {
633
+ subtype: "exec_timeout",
634
+ retriable: true,
635
+ cause: err,
636
+ });
637
+ }
638
+ const stdout = typeof err.stdout === "string"
639
+ ? err.stdout
640
+ : Buffer.isBuffer(err.stdout)
641
+ ? err.stdout.toString("utf8")
642
+ : "";
643
+ return {
644
+ stdout,
645
+ stderr: err.stderr ?? err.message,
646
+ exitCode: typeof err.code === "number" ? err.code : 1,
647
+ };
648
+ }
649
+ }
650
+ async fileRead(path) {
651
+ try {
652
+ return await this.fs.readFile(path);
653
+ }
654
+ catch (e) {
655
+ throw new DeviceError(`fileRead failed: ${e.message}`, {
656
+ subtype: "file_read_failed",
657
+ cause: e,
658
+ });
659
+ }
660
+ }
661
+ async fileWrite(path, content) {
662
+ try {
663
+ await this.fs.writeFile(path, content);
664
+ }
665
+ catch (e) {
666
+ throw new DeviceError(`fileWrite failed: ${e.message}`, {
667
+ subtype: "file_write_failed",
668
+ cause: e,
669
+ });
670
+ }
671
+ }
672
+ async listDirectory(path) {
673
+ try {
674
+ const entries = await this.fs.readdir(path, { withFileTypes: true });
675
+ const result = [];
676
+ for (const e of entries) {
677
+ const isDir = e.isDirectory();
678
+ let size = 0;
679
+ if (!isDir) {
680
+ try {
681
+ const st = await this.fs.stat(`${path}/${e.name}`);
682
+ size = st.size;
683
+ }
684
+ catch {
685
+ /* ignore */
686
+ }
687
+ }
688
+ result.push({ name: e.name, isDir, size });
689
+ }
690
+ return result;
691
+ }
692
+ catch (e) {
693
+ throw new DeviceError(`listDirectory failed: ${e.message}`, {
694
+ subtype: "list_directory_failed",
695
+ cause: e,
696
+ });
697
+ }
698
+ }
699
+ async install(path) {
700
+ try {
701
+ await this.fs.access(path);
702
+ }
703
+ catch {
704
+ throw new DeviceError(`install: file not found '${path}'`, {
705
+ subtype: "invalid_args",
706
+ });
707
+ }
708
+ await this.runUtf8("open", [path]);
709
+ }
710
+ /* ------------------------------------------------------------------ */
711
+ /* Internals */
712
+ /* ------------------------------------------------------------------ */
713
+ useCliclick() {
714
+ if (this.forceFallback)
715
+ return false;
716
+ return !!this.cliclickPath;
717
+ }
718
+ async probeCliclick() {
719
+ if (this.cliclickProbed)
720
+ return;
721
+ this.cliclickProbed = true;
722
+ try {
723
+ // `command -v` is a POSIX builtin; always available via /bin/sh.
724
+ const { stdout } = await this.runner("/bin/sh", ["-c", "command -v cliclick"], {
725
+ encoding: "utf8",
726
+ timeoutMs: 2_000,
727
+ });
728
+ const path = (typeof stdout === "string" ? stdout : stdout.toString()).trim();
729
+ if (path) {
730
+ this.cliclickPath = path;
731
+ }
732
+ }
733
+ catch {
734
+ this.cliclickPath = undefined;
735
+ }
736
+ }
737
+ async probeDisplay() {
738
+ const { stdout } = await this.runner("system_profiler", ["SPDisplaysDataType", "-json"], { encoding: "utf8", timeoutMs: 5_000 });
739
+ const text = typeof stdout === "string" ? stdout : stdout.toString("utf8");
740
+ const data = JSON.parse(text);
741
+ const root = data["SPDisplaysDataType"];
742
+ if (!Array.isArray(root)) {
743
+ throw new Error("missing SPDisplaysDataType");
744
+ }
745
+ for (const node of root) {
746
+ const screens = node["spdisplays_ndrvs"];
747
+ if (!Array.isArray(screens))
748
+ continue;
749
+ // Prefer the screen marked as main when present.
750
+ const mainFirst = [...screens].sort((a, b) => a["spdisplays_main"] === "spdisplays_yes" ? -1 : b["spdisplays_main"] === "spdisplays_yes" ? 1 : 0);
751
+ for (const s of mainFirst) {
752
+ const resStr = s["_spdisplays_resolution"] ?? "";
753
+ const pxStr = s["_spdisplays_pixels"] ?? "";
754
+ const logical = parseDimensionString(resStr);
755
+ const native = parseDimensionString(pxStr) ?? logical;
756
+ if (logical) {
757
+ const native2 = native ?? logical;
758
+ const scale = native2.w > 0 && logical.w > 0
759
+ ? Math.round((native2.w / logical.w) * 100) / 100
760
+ : 1;
761
+ return {
762
+ logicalW: logical.w,
763
+ logicalH: logical.h,
764
+ nativeW: native2.w,
765
+ nativeH: native2.h,
766
+ scale: scale > 0 ? scale : 1,
767
+ };
768
+ }
769
+ }
770
+ }
771
+ throw new Error("no display found in system_profiler output");
772
+ }
773
+ async runCliclick(args) {
774
+ const bin = this.cliclickPath ?? "cliclick";
775
+ try {
776
+ await this.runner(bin, args, {
777
+ encoding: "utf8",
778
+ timeoutMs: DEFAULT_TIMEOUT_MS,
779
+ });
780
+ }
781
+ catch (e) {
782
+ throw wrapMacRunFailure("cliclick", args, e);
783
+ }
784
+ }
785
+ async runOsa(script, args) {
786
+ try {
787
+ const { stdout } = await this.runner("osascript", ["-", ...args], {
788
+ encoding: "utf8",
789
+ timeoutMs: DEFAULT_TIMEOUT_MS,
790
+ input: script,
791
+ });
792
+ return typeof stdout === "string" ? stdout : stdout.toString("utf8");
793
+ }
794
+ catch (e) {
795
+ throw wrapMacRunFailure("osascript", args, e);
796
+ }
797
+ }
798
+ /**
799
+ * Run a JXA (JavaScript for Automation) script via `osascript -l JavaScript`.
800
+ * Used for pointer / scroll synthesis — JXA's ObjC bridge gives us direct
801
+ * Quartz CGEvent access, which is the only dependency-free way to drive
802
+ * synthetic mouse input on modern macOS (the AppleScript `click at {x, y}`
803
+ * surface has been broken since the 10.14 era).
804
+ */
805
+ async runJxa(script, args) {
806
+ try {
807
+ const { stdout } = await this.runner("osascript", ["-l", "JavaScript", "-", ...args], {
808
+ encoding: "utf8",
809
+ timeoutMs: DEFAULT_TIMEOUT_MS,
810
+ input: script,
811
+ });
812
+ return typeof stdout === "string" ? stdout : stdout.toString("utf8");
813
+ }
814
+ catch (e) {
815
+ // Surface this as "osascript_failed" so callers can write a single
816
+ // error-handler that matches the AppleScript and JXA paths uniformly.
817
+ throw wrapMacRunFailure("osascript", args, e);
818
+ }
819
+ }
820
+ async runUtf8(file, args, opts = {}) {
821
+ try {
822
+ const { stdout } = await this.runner(file, args, {
823
+ encoding: "utf8",
824
+ timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
825
+ });
826
+ return typeof stdout === "string" ? stdout : stdout.toString("utf8");
827
+ }
828
+ catch (e) {
829
+ throw wrapMacRunFailure(file, args, e);
830
+ }
831
+ }
832
+ async runBuffer(file, args, opts = {}) {
833
+ try {
834
+ const { stdout } = await this.runner(file, args, {
835
+ encoding: "buffer",
836
+ timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
837
+ });
838
+ return Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout);
839
+ }
840
+ catch (e) {
841
+ throw wrapMacRunFailure(file, args, e);
842
+ }
843
+ }
844
+ }
845
+ /* ----------------------------------------------------------------------- */
846
+ /* Module-level helpers */
847
+ /* ----------------------------------------------------------------------- */
848
+ function looksLikeBundleId(s) {
849
+ return /^[a-z0-9-]+(\.[a-z0-9-]+){2,}$/i.test(s);
850
+ }
851
+ function parseDimensionString(s) {
852
+ if (!s)
853
+ return null;
854
+ const m = s.match(/(\d+)\s*x\s*(\d+)/i);
855
+ if (!m)
856
+ return null;
857
+ const w = Number(m[1]);
858
+ const h = Number(m[2]);
859
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0)
860
+ return null;
861
+ return { w, h };
862
+ }
863
+ function wrapMacRunFailure(file, args, cause) {
864
+ const err = cause;
865
+ const isTimeout = err.killed && err.signal === "SIGTERM";
866
+ const subtype = isTimeout
867
+ ? `${file}_timeout`
868
+ : file === "cliclick"
869
+ ? "cliclick_failed"
870
+ : file === "osascript"
871
+ ? "osascript_failed"
872
+ : `${file}_failed`;
873
+ const summary = err.stderr?.trim() || err.message || "unknown error";
874
+ const argSnippet = args.slice(0, 3).join(" ");
875
+ return new DeviceError(`${file} ${argSnippet}: ${summary}`, {
876
+ subtype,
877
+ retriable: !!isTimeout,
878
+ cause: err,
879
+ });
880
+ }
881
+ //# sourceMappingURL=mac.js.map