ucu-mcp 0.3.9 → 0.4.2

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 (53) hide show
  1. package/CHANGELOG.md +67 -3
  2. package/dist/bin/ucu-mcp.js +1 -1
  3. package/dist/src/index.d.ts +2 -2
  4. package/dist/src/index.js +2 -2
  5. package/dist/src/mcp/server.js +1 -1
  6. package/dist/src/mcp/tools/app-tools.d.ts +2 -0
  7. package/dist/src/mcp/tools/app-tools.js +220 -0
  8. package/dist/src/mcp/tools/element-tools.d.ts +23 -0
  9. package/dist/src/mcp/tools/element-tools.js +59 -0
  10. package/dist/src/mcp/tools/helpers.d.ts +82 -0
  11. package/dist/src/mcp/tools/helpers.js +243 -0
  12. package/dist/src/mcp/tools/index.d.ts +19 -0
  13. package/dist/src/mcp/tools/index.js +54 -0
  14. package/dist/src/mcp/tools/input-tools.d.ts +2 -0
  15. package/dist/src/mcp/tools/input-tools.js +66 -0
  16. package/dist/src/mcp/tools/keyboard-tools.d.ts +2 -0
  17. package/dist/src/mcp/tools/keyboard-tools.js +35 -0
  18. package/dist/src/mcp/tools/screen-tools.d.ts +2 -0
  19. package/dist/src/mcp/tools/screen-tools.js +69 -0
  20. package/dist/src/mcp/tools.d.ts +9 -0
  21. package/dist/src/mcp/tools.js +96 -25
  22. package/dist/src/platform/base.d.ts +3 -0
  23. package/dist/src/platform/jxa-helpers.d.ts +11 -0
  24. package/dist/src/platform/jxa-helpers.js +206 -0
  25. package/dist/src/platform/macos/ax-tree.d.ts +4 -0
  26. package/dist/src/platform/macos/ax-tree.js +462 -0
  27. package/dist/src/platform/macos/base.d.ts +57 -0
  28. package/dist/src/platform/macos/base.js +92 -0
  29. package/dist/src/platform/macos/clipboard.d.ts +3 -0
  30. package/dist/src/platform/macos/clipboard.js +20 -0
  31. package/dist/src/platform/macos/element.d.ts +4 -0
  32. package/dist/src/platform/macos/element.js +212 -0
  33. package/dist/src/platform/macos/focus.d.ts +3 -0
  34. package/dist/src/platform/macos/focus.js +33 -0
  35. package/dist/src/platform/macos/helpers.d.ts +35 -0
  36. package/dist/src/platform/macos/helpers.js +54 -0
  37. package/dist/src/platform/macos/index.d.ts +2 -0
  38. package/dist/src/platform/macos/index.js +1 -0
  39. package/dist/src/platform/macos/input.d.ts +9 -0
  40. package/dist/src/platform/macos/input.js +62 -0
  41. package/dist/src/platform/macos/screen.d.ts +7 -0
  42. package/dist/src/platform/macos/screen.js +197 -0
  43. package/dist/src/platform/macos/window.d.ts +6 -0
  44. package/dist/src/platform/macos/window.js +251 -0
  45. package/dist/src/platform/macos.d.ts +1 -0
  46. package/dist/src/platform/macos.js +114 -583
  47. package/dist/src/safety/guard.js +1 -1
  48. package/dist/src/util/errors.d.ts +7 -2
  49. package/dist/src/util/errors.js +7 -3
  50. package/native/cgevent/cgevent-helper +0 -0
  51. package/native/ocr/ocr-helper +0 -0
  52. package/native/windowlist/windowlist-helper +0 -0
  53. package/package.json +1 -1
@@ -4,9 +4,11 @@ import { promisify } from "node:util";
4
4
  import { captureFullScreen, captureRegion } from "../utils/screenshot.js";
5
5
  import { click as inputClick, doubleClick as inputDoubleClick, move as inputMove, drag as inputDrag, scroll as inputScroll, typeText, pressShortcut } from "../utils/input.js";
6
6
  import { CaptureError, ElementNotFoundError, InputSynthesisError, PermissionError, PlatformError, TargetStaleError, UcuError, WindowNotFoundError } from "../util/errors.js";
7
+ import { logger } from "../util/logger.js";
7
8
  import { existsSync } from "node:fs";
8
9
  import { join, dirname } from "node:path";
9
10
  import { fileURLToPath } from "node:url";
11
+ import { jxaChildElements, jxaGetBounds, jxaIsVisible, jxaElementActionHelpers } from "./jxa-helpers.js";
10
12
  const __macosDirname = dirname(fileURLToPath(import.meta.url));
11
13
  const execFileAsync = promisify(execFile);
12
14
  function errorMessage(error) {
@@ -69,6 +71,7 @@ export class MacOSPlatform {
69
71
  elementCacheMaxSize = 100;
70
72
  windowCacheTtlMs = 300;
71
73
  windowCache;
74
+ windowCacheInFlight = false;
72
75
  activeTarget;
73
76
  savedFocus;
74
77
  constructor(options) {
@@ -114,8 +117,12 @@ export class MacOSPlatform {
114
117
  return;
115
118
  this.windowCache = undefined; // Bypass cache — stale detection must use fresh data
116
119
  const windows = await this.listWindows(true);
117
- const stillExists = windows.some(w => w.id === this.activeTarget.windowId);
118
- if (!stillExists) {
120
+ const match = windows.find(w => w.id === this.activeTarget.windowId);
121
+ if (!match) {
122
+ throw new TargetStaleError(this.activeTarget.windowId);
123
+ }
124
+ // Also invalidate if pid changed (app restarted)
125
+ if (match.pid !== this.activeTarget.pid) {
119
126
  throw new TargetStaleError(this.activeTarget.windowId);
120
127
  }
121
128
  }
@@ -144,8 +151,9 @@ export class MacOSPlatform {
144
151
  return;
145
152
  try {
146
153
  const { appName } = this.savedFocus;
154
+ const appNameLiteral = JSON.stringify(appName);
147
155
  execFileSync("osascript", [
148
- "-e", `tell application "${appName}" to activate`,
156
+ "-e", `tell application ${appNameLiteral} to activate`,
149
157
  ], { timeout: 5000 });
150
158
  }
151
159
  catch {
@@ -190,8 +198,9 @@ export class MacOSPlatform {
190
198
  ], { encoding: "utf-8", timeout: 5000 }).trim();
191
199
  return JSON.parse(out);
192
200
  }
193
- catch {
194
- return { width: 1920, height: 1080, scaleFactor: 2 };
201
+ catch (error) {
202
+ logger.warn("getScreenSize failed, using fallback", { error: errorMessage(error) });
203
+ return { width: 1920, height: 1080, scaleFactor: 2, estimated: true };
195
204
  }
196
205
  }
197
206
  isScreenLocked() {
@@ -203,7 +212,8 @@ export class MacOSPlatform {
203
212
  return /"IOConsoleLocked"\s*=\s*Yes/.test(out);
204
213
  }
205
214
  catch {
206
- return false;
215
+ // Fail-closed: if we can't determine lock state, assume locked
216
+ return true;
207
217
  }
208
218
  }
209
219
  // ── Window Management ───────────────────────────────────────────────────
@@ -230,22 +240,28 @@ export class MacOSPlatform {
230
240
  }
231
241
  JSON.stringify(result);
232
242
  `;
233
- const out = execFileSync("osascript", [
234
- "-l", "JavaScript",
235
- "-e", jxaScript,
236
- ], { encoding: "utf-8", timeout: 10000 }).trim();
237
- return JSON.parse(out);
238
- }
239
- async focusApp(app) {
240
- const escapedApp = app.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
241
- this.windowCache = undefined;
242
243
  try {
243
- execFileSync("osascript", ["-e", `tell application "${escapedApp}" to activate`], { timeout: 5000 });
244
+ const out = execFileSync("osascript", [
245
+ "-l", "JavaScript",
246
+ "-e", jxaScript,
247
+ ], { encoding: "utf-8", timeout: 10000 }).trim();
248
+ return JSON.parse(out);
244
249
  }
245
- catch {
246
- // Some app names are process labels rather than AppleScript application names.
247
- // Continue with the AX window lookup below so existing callers still work.
250
+ catch (error) {
251
+ rethrowAccessibilityError(error, "list_apps");
248
252
  }
253
+ }
254
+ async focusApp(app) {
255
+ const appLiteral = JSON.stringify(app);
256
+ this.windowCache = undefined;
257
+ // NOTE: We intentionally do NOT call AppleScript "activate" here.
258
+ // focus_app sets the internal target context so subsequent operations
259
+ // know which app/window to target. It does NOT bring the app to the
260
+ // foreground — the user should remain in their current app (terminal,
261
+ // Codex, etc.) while the agent works in the background.
262
+ // CGEvent input injection works at the HID level and doesn't require
263
+ // the target app to be frontmost. AX operations target processes by
264
+ // name/PID via System Events, also without needing frontmost status.
249
265
  let target;
250
266
  const deadline = Date.now() + 3000;
251
267
  do {
@@ -262,16 +278,15 @@ export class MacOSPlatform {
262
278
  // app name so the tool handler can surface a remediation hint. The
263
279
  // bare WindowNotFoundError("CC Switch") was indistinguishable from
264
280
  // "the app is not running", which led models to retry forever.
265
- const err = new WindowNotFoundError(app);
266
- err.hint =
267
- "list_windows returned no match for this app. If the app is running, " +
281
+ this.activeTarget = undefined; // Clear stale target on focus failure
282
+ const err = new WindowNotFoundError(app, { hint: "list_windows returned no match for this app. If the app is running, " +
268
283
  "the most likely cause is that it is an Electron app whose AX tree is " +
269
284
  "not exposed to System Events (System Settings > Privacy & Security > " +
270
285
  "Accessibility must be granted to the Electron process itself, not just " +
271
286
  "to the host terminal). Pixel-level workaround: call screenshot to " +
272
287
  "capture the screen, then ocr to locate UI text and get its bounding " +
273
288
  "box coordinates, then click(x, y) at those screen coordinates. " +
274
- "Alternatively, modify the app's config file or database directly.";
289
+ "Alternatively, modify the app's config file or database directly." });
275
290
  throw err;
276
291
  }
277
292
  this.activeTarget = {
@@ -301,10 +316,10 @@ export class MacOSPlatform {
301
316
  ].some((name) => normalized.includes(name));
302
317
  if (!knownBrowser)
303
318
  return undefined;
304
- const escapedApp = appName.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
319
+ const appLiteral = JSON.stringify(appName);
305
320
  const jxaScript = `
306
321
  function run() {
307
- var appName = "${escapedApp}";
322
+ var appName = ${appLiteral};
308
323
  try {
309
324
  var app = Application(appName);
310
325
  var url = "";
@@ -343,6 +358,12 @@ export class MacOSPlatform {
343
358
  bounds: { ...window.bounds },
344
359
  }));
345
360
  }
361
+ // P0 #3: Prevent concurrent cache refreshes
362
+ if (this.windowCacheInFlight) {
363
+ // Another call is already refreshing; return stale or empty
364
+ return this.windowCache?.windows.map(w => ({ ...w, bounds: { ...w.bounds } })) ?? [];
365
+ }
366
+ this.windowCacheInFlight = true;
346
367
  try {
347
368
  // Try native Swift helper first (CGWindowListCopyWindowInfo, ~1ms).
348
369
  // Falls back to JXA System Events if the helper is not available.
@@ -370,6 +391,9 @@ export class MacOSPlatform {
370
391
  // Fallback: return empty list if both methods fail
371
392
  return [];
372
393
  }
394
+ finally {
395
+ this.windowCacheInFlight = false;
396
+ }
373
397
  }
374
398
  listWindowsNative() {
375
399
  try {
@@ -452,11 +476,16 @@ export class MacOSPlatform {
452
476
  }
453
477
  JSON.stringify(result);
454
478
  `;
455
- const jxaOut = execFileSync("osascript", [
456
- "-l", "JavaScript",
457
- "-e", jxaScript
458
- ], { encoding: "utf-8", timeout: 15000 });
459
- return JSON.parse(jxaOut.trim());
479
+ try {
480
+ const jxaOut = execFileSync("osascript", [
481
+ "-l", "JavaScript",
482
+ "-e", jxaScript
483
+ ], { encoding: "utf-8", timeout: 15000 });
484
+ return JSON.parse(jxaOut.trim());
485
+ }
486
+ catch (error) {
487
+ rethrowAccessibilityError(error, "list_windows_jxa");
488
+ }
460
489
  }
461
490
  async getWindowState(windowId, depth, includeBounds = true) {
462
491
  if (!windowId || windowId === this.activeTarget?.windowId) {
@@ -468,18 +497,14 @@ export class MacOSPlatform {
468
497
  }
469
498
  const maxDepth = Math.min(depth || 3, 10);
470
499
  const maxElements = 50;
471
- const escapedWindowId = resolvedWindowId.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$');
500
+ const windowIdLiteral = JSON.stringify(resolvedWindowId);
472
501
  const targetWindow = (await this.listWindows(true)).find((w) => w.id === resolvedWindowId);
473
502
  const targetJson = JSON.stringify(targetWindow ?? null);
474
503
  try {
475
504
  const jxaScript = `
476
505
  ObjC.import('AppKit');
477
506
  var se = Application('System Events');
478
- function childElements(elem) {
479
- try { return elem.uiElements(); } catch(e1) {
480
- try { return elem.elements(); } catch(e2) { return []; }
481
- }
482
- }
507
+ ${jxaChildElements()}
483
508
  var result = {window: null, focusedElement: null, tree: null, error: null};
484
509
  var target = ${targetJson};
485
510
  var includeBounds = ${includeBounds ? "true" : "false"};
@@ -490,7 +515,7 @@ export class MacOSPlatform {
490
515
 
491
516
  function windowMatches(win, proc) {
492
517
  if (!target) {
493
- try { return String(win.id()) === String("${escapedWindowId}"); } catch(e) { return false; }
518
+ try { return String(win.id()) === String(${windowIdLiteral}); } catch(e) { return false; }
494
519
  }
495
520
  try {
496
521
  if (target.pid && proc.unixId && proc.unixId() !== target.pid) return false;
@@ -510,7 +535,7 @@ export class MacOSPlatform {
510
535
  closeEnough(size[1], b.height, 24);
511
536
  } catch(e) {}
512
537
 
513
- try { return String(win.id()) === String("${escapedWindowId}"); } catch(e) {}
538
+ try { return String(win.id()) === String(${windowIdLiteral}); } catch(e) {}
514
539
  return false;
515
540
  }
516
541
 
@@ -518,7 +543,7 @@ export class MacOSPlatform {
518
543
  var foundProc = null;
519
544
 
520
545
  // Fast path: resolve "ProcessName/winN" format directly
521
- var idParts = "${escapedWindowId}".split('/');
546
+ var idParts = ${windowIdLiteral}.split('/');
522
547
  if (idParts.length >= 2 && idParts[0]) {
523
548
  var procName = idParts[0];
524
549
  var winIdx = 0;
@@ -559,7 +584,7 @@ export class MacOSPlatform {
559
584
  var winPos = foundWin.position();
560
585
  var winSize = foundWin.size();
561
586
  result.window = {
562
- id: String("${escapedWindowId}"),
587
+ id: String(${windowIdLiteral}),
563
588
  title: foundWin.name() || '',
564
589
  processName: foundProc.name() || '',
565
590
  pid: foundProc.unixId ? foundProc.unixId() : 0,
@@ -806,7 +831,7 @@ export class MacOSPlatform {
806
831
  }
807
832
  }
808
833
  async ocrJxa(tmpPath, screenSize, scaleFactor, region, buf) {
809
- const escapedPath = tmpPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, "\\`").replace(/$/g, "\\$");
834
+ const pathLiteral = JSON.stringify(tmpPath);
810
835
  const jxaScript = `
811
836
  function run() {
812
837
  ObjC.import('Vision');
@@ -814,7 +839,7 @@ export class MacOSPlatform {
814
839
  ObjC.import('Foundation');
815
840
  var app = Application.currentApplication();
816
841
  app.includeStandardAdditions = true;
817
- var path = "${escapedPath}";
842
+ var path = ${pathLiteral};
818
843
  var url = $.NSURL.fileURLWithPath(path);
819
844
  var image = $.NSImage.alloc.initWithContentsOfURL(url);
820
845
  if (!image || !image.isValid) {
@@ -898,10 +923,10 @@ export class MacOSPlatform {
898
923
  const effectiveApp = app || this.activeTarget?.appName;
899
924
  const maxDepth = Math.min(depth || 5, 10);
900
925
  const maxResults = Math.min(Math.max(options.maxResults ?? 50, 1), 200);
901
- const escapedApp = (effectiveApp || "").replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$');
902
- const escapedText = text ? text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$') : "";
903
- const escapedRole = role ? role.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$') : "";
904
- const escapedValue = value ? value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$') : "";
926
+ const appLiteral = JSON.stringify(effectiveApp || "");
927
+ const textLiteral = text ? JSON.stringify(text) : "null";
928
+ const roleLiteral = role ? JSON.stringify(role) : "null";
929
+ const valueLiteral = value ? JSON.stringify(value) : "null";
905
930
  // Pre-compile regex on TS side to validate syntax before passing to JXA
906
931
  if (text && textMode === "regex") {
907
932
  try {
@@ -925,11 +950,7 @@ export class MacOSPlatform {
925
950
  const startTime = Date.now();
926
951
  const jxaScript = `
927
952
  var se = Application('System Events');
928
- function childElements(elem) {
929
- try { return elem.uiElements(); } catch(e1) {
930
- try { return elem.elements(); } catch(e2) { return []; }
931
- }
932
- }
953
+ ${jxaChildElements()}
933
954
  var results = [];
934
955
  var scannedCount = 0;
935
956
  var matchedCount = 0;
@@ -937,22 +958,13 @@ export class MacOSPlatform {
937
958
  var maxResults = ${maxResults};
938
959
  var includeBounds = ${includeBounds ? "true" : "false"};
939
960
  var visibleOnly = ${visibleOnly ? "true" : "false"};
940
- var textMode = "${textMode}";
961
+ var textMode = ${JSON.stringify(textMode)};
941
962
 
942
- var textFilter = ${text ? `"${escapedText}"` : "null"};
943
- var roleFilter = ${role ? `"${escapedRole}"` : "null"};
944
- var valueFilter = ${value ? `"${escapedValue}"` : "null"};
963
+ var textFilter = ${textLiteral};
964
+ var roleFilter = ${roleLiteral};
965
+ var valueFilter = ${valueLiteral};
945
966
 
946
- function isVisible(elem) {
947
- try {
948
- var pos = elem.position();
949
- var sz = elem.size();
950
- if (!pos || !sz) return false;
951
- return sz[0] > 0 && sz[1] > 0 && pos[0] > -10000 && pos[1] > -10000;
952
- } catch(e) {
953
- return false;
954
- }
955
- }
967
+ ${jxaIsVisible()}
956
968
 
957
969
  // Shared filter helper. textMatches and valueMatches used to be near
958
970
  // copies of the same three-branch dispatch (contains / exact / regex);
@@ -1019,15 +1031,7 @@ export class MacOSPlatform {
1019
1031
  return true;
1020
1032
  }
1021
1033
 
1022
- function getBounds(elem) {
1023
- try {
1024
- var pos = elem.position();
1025
- var sz = elem.size();
1026
- return {x: pos[0] || 0, y: pos[1] || 0, width: sz[0] || 0, height: sz[1] || 0};
1027
- } catch(e) {
1028
- return {x: 0, y: 0, width: 0, height: 0};
1029
- }
1030
- }
1034
+ ${jxaGetBounds()}
1031
1035
 
1032
1036
  function traverse(elem, path, currentDepth) {
1033
1037
  if (resultCount[0] >= maxResults) return;
@@ -1078,11 +1082,11 @@ export class MacOSPlatform {
1078
1082
  }
1079
1083
 
1080
1084
  try {
1081
- if ("${escapedApp}") {
1082
- var proc = se.processes["${escapedApp}"]();
1085
+ if (${appLiteral}) {
1086
+ var proc = se.processes[${appLiteral}]();
1083
1087
  var wins = proc.windows();
1084
1088
  for (var w = 0; w < wins.length && resultCount[0] < maxResults; w++) {
1085
- traverse(wins[w], "${escapedApp}/win" + w, 0);
1089
+ traverse(wins[w], ${appLiteral} + "/win" + w, 0);
1086
1090
  }
1087
1091
  } else {
1088
1092
  var procs = se.processes();
@@ -1167,9 +1171,9 @@ export class MacOSPlatform {
1167
1171
  }
1168
1172
  async clickElement(elementId, app) {
1169
1173
  this.evictExpiredCacheEntries();
1170
- const escapedElementId = elementId.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$');
1174
+ const elementIdLiteral = JSON.stringify(elementId);
1171
1175
  const effectiveApp = app || this.activeTarget?.appName;
1172
- const escapedApp = (effectiveApp || "").replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$');
1176
+ const appLiteral = JSON.stringify(effectiveApp || "");
1173
1177
  const cached = this.elementCache.get(elementId);
1174
1178
  if (cached && this.isCacheEntryExpired(cached)) {
1175
1179
  this.elementCache.delete(elementId);
@@ -1177,173 +1181,13 @@ export class MacOSPlatform {
1177
1181
  const cachedJson = JSON.stringify(this.elementCache.get(elementId) ?? null);
1178
1182
  const jxaScript = `
1179
1183
  var se = Application('System Events');
1180
- function childElements(elem) {
1181
- try { return elem.uiElements(); } catch(e1) {
1182
- try { return elem.elements(); } catch(e2) { return []; }
1183
- }
1184
- }
1185
- var elemPath = "${escapedElementId}";
1186
- var appName = "${escapedApp}";
1184
+ var _result = null;
1185
+ ${jxaElementActionHelpers()}
1186
+ var elemPath = ${elementIdLiteral};
1187
+ var appName = ${appLiteral};
1187
1188
  var cached = ${cachedJson};
1188
1189
 
1189
- function resolveElementByFullPath(path) {
1190
- var parts = path.split('/');
1191
- if (parts.length < 2) return null;
1192
-
1193
- var procName = parts[0];
1194
- var winPart = parts[1];
1195
- var winIdx = 0;
1196
- var match = winPart.match(/^win(\\\\d+)$/);
1197
- if (match) {
1198
- winIdx = parseInt(match[1]);
1199
- }
1200
-
1201
- try {
1202
- var proc = se.processes[procName]();
1203
- var wins = proc.windows();
1204
- if (winIdx >= wins.length) return null;
1205
- var current = wins[winIdx];
1206
-
1207
- for (var i = 2; i < parts.length; i++) {
1208
- var idx = parseInt(parts[i]);
1209
- if (isNaN(idx)) return null;
1210
- try {
1211
- var kids = childElements(current);
1212
- if (idx >= kids.length) return null;
1213
- current = kids[idx];
1214
- } catch(e) { return null; }
1215
- }
1216
- return current;
1217
- } catch(e) { return null; }
1218
- }
1219
-
1220
- function elemString(elem, getter) {
1221
- try {
1222
- var value = getter(elem);
1223
- return value === undefined || value === null ? '' : String(value);
1224
- } catch(e) {
1225
- return '';
1226
- }
1227
- }
1228
-
1229
- function getBounds(elem) {
1230
- try {
1231
- var pos = elem.position();
1232
- var sz = elem.size();
1233
- return {x: pos[0] || 0, y: pos[1] || 0, width: sz[0] || 0, height: sz[1] || 0};
1234
- } catch(e) {
1235
- return {x: 0, y: 0, width: 0, height: 0};
1236
- }
1237
- }
1238
-
1239
- function descriptorMatches(elem) {
1240
- if (!cached) return true;
1241
- var role = elemString(elem, function(e) { return e.role(); });
1242
- var name = elemString(elem, function(e) { return e.name(); });
1243
- var desc = elemString(elem, function(e) { return e.description(); });
1244
- var value = elemString(elem, function(e) { return e.value(); });
1245
- if (cached.role && role && role !== cached.role) return false;
1246
- if (cached.name && name && name !== cached.name) return false;
1247
- if (cached.value && value && value !== cached.value) return false;
1248
- if (cached.description && desc && desc !== cached.description) return false;
1249
- return true;
1250
- }
1251
-
1252
- function scoreEquivalent(elem) {
1253
- if (!cached) return -1;
1254
- var score = 0;
1255
- var role = elemString(elem, function(e) { return e.role(); });
1256
- var name = elemString(elem, function(e) { return e.name(); });
1257
- var desc = elemString(elem, function(e) { return e.description(); });
1258
- var value = elemString(elem, function(e) { return e.value(); });
1259
- var subrole = elemString(elem, function(e) { return e.subrole(); });
1260
- var identifier = elemString(elem, function(e) { return e.identifier(); });
1261
- if (cached.role && role === cached.role) score += 4;
1262
- if (cached.name && name === cached.name) score += 4;
1263
- if (cached.value && value === cached.value) score += 3;
1264
- if (cached.description && desc === cached.description) score += 2;
1265
- if (cached.subrole && subrole === cached.subrole) score += 2;
1266
- if (cached.identifier && identifier === cached.identifier) score += 3;
1267
- var b = getBounds(elem);
1268
- if (cached.bounds) {
1269
- var cx = b.x + b.width / 2;
1270
- var cy = b.y + b.height / 2;
1271
- var ocx = cached.bounds.x + cached.bounds.width / 2;
1272
- var ocy = cached.bounds.y + cached.bounds.height / 2;
1273
- var distance = Math.sqrt(Math.pow(cx - ocx, 2) + Math.pow(cy - ocy, 2));
1274
- if (distance < 8) score += 4;
1275
- else if (distance < 40) score += 2;
1276
- else if (distance < 120) score += 1;
1277
- }
1278
- return score;
1279
- }
1280
-
1281
- function refetchEquivalent() {
1282
- if (!cached) return null;
1283
- var targetApp = appName || cached.appName || '';
1284
- var best = null;
1285
- var bestScore = 0;
1286
- var visited = [0];
1287
- function visit(elem, depth) {
1288
- if (visited[0] > 350 || depth > 10) return;
1289
- visited[0]++;
1290
- var score = scoreEquivalent(elem);
1291
- if (score > bestScore) {
1292
- best = elem;
1293
- bestScore = score;
1294
- }
1295
- try {
1296
- var kids = childElements(elem);
1297
- for (var i = 0; i < kids.length; i++) visit(kids[i], depth + 1);
1298
- } catch(e) {}
1299
- }
1300
- try {
1301
- if (targetApp) {
1302
- var proc = se.processes[targetApp]();
1303
- var wins = proc.windows();
1304
- for (var w = 0; w < wins.length; w++) visit(wins[w], 0);
1305
- } else {
1306
- var procs = se.processes();
1307
- for (var p = 0; p < procs.length; p++) {
1308
- try {
1309
- var wins2 = procs[p].windows();
1310
- for (var w2 = 0; w2 < wins2.length; w2++) visit(wins2[w2], 0);
1311
- } catch(e2) {}
1312
- }
1313
- }
1314
- } catch(e) {}
1315
- return bestScore >= 6 ? best : null;
1316
- }
1317
-
1318
- var elem = null;
1319
-
1320
- if (appName) {
1321
- try {
1322
- var proc = se.processes[appName]();
1323
- var wins = proc.windows();
1324
- var parts = elemPath.split('/');
1325
- var winIdx = 0;
1326
- var match = parts[0].match(/^win(\\\\d+)$/);
1327
- if (match) winIdx = parseInt(match[1]);
1328
- if (winIdx < wins.length) {
1329
- var current = wins[winIdx];
1330
- for (var i = 1; i < parts.length; i++) {
1331
- var idx = parseInt(parts[i]);
1332
- if (isNaN(idx)) break;
1333
- try {
1334
- var kids = childElements(current);
1335
- if (idx >= kids.length) break;
1336
- current = kids[idx];
1337
- } catch(e) { break; }
1338
- }
1339
- elem = current;
1340
- }
1341
- } catch(e) {}
1342
- }
1343
-
1344
- if (!elem) {
1345
- elem = resolveElementByFullPath(elemPath);
1346
- }
1190
+ var elem = resolveElementInApp(elemPath, appName) || resolveElementByFullPath(elemPath);
1347
1191
 
1348
1192
  if (elem && !descriptorMatches(elem)) {
1349
1193
  elem = refetchEquivalent() || elem;
@@ -1354,11 +1198,11 @@ export class MacOSPlatform {
1354
1198
  }
1355
1199
 
1356
1200
  if (!elem) {
1357
- JSON.stringify({success: false, error: "Element not found: " + elemPath});
1201
+ _result = {success: false, error: "Element not found: " + elemPath};
1358
1202
  } else {
1359
1203
  try {
1360
1204
  elem.actions.AXPress.perform();
1361
- JSON.stringify({success: true});
1205
+ _result = {success: true};
1362
1206
  } catch(e) {
1363
1207
  try {
1364
1208
  var pos = elem.position();
@@ -1372,12 +1216,13 @@ export class MacOSPlatform {
1372
1216
  $.CGEventPost($.kCGHIDEventTap, down);
1373
1217
  var up = $.CGEventCreateMouseEvent(src, $.kCGEventLeftMouseUp, pt, $.kCGMouseButtonLeft);
1374
1218
  $.CGEventPost($.kCGHIDEventTap, up);
1375
- JSON.stringify({success: true});
1219
+ _result = {success: true};
1376
1220
  } catch(e2) {
1377
- JSON.stringify({success: false, error: "Could not click element: " + String(e2.message || e2)});
1221
+ _result = {success: false, error: "Could not click element: " + String(e2.message || e2)};
1378
1222
  }
1379
1223
  }
1380
1224
  }
1225
+ JSON.stringify(_result);
1381
1226
  `;
1382
1227
  try {
1383
1228
  const out = execFileSync("osascript", [
@@ -1397,10 +1242,10 @@ export class MacOSPlatform {
1397
1242
  }
1398
1243
  async typeInElement(elementId, text, app, clearFirst) {
1399
1244
  this.evictExpiredCacheEntries();
1400
- const escapedText = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$');
1245
+ const textLiteral = JSON.stringify(text);
1401
1246
  const effectiveApp = app || this.activeTarget?.appName;
1402
- const escapedApp = (effectiveApp || "").replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$');
1403
- const escapedElementId = elementId.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$');
1247
+ const appLiteral = JSON.stringify(effectiveApp || "");
1248
+ const elementIdLiteral = JSON.stringify(elementId);
1404
1249
  const cached = this.elementCache.get(elementId);
1405
1250
  if (cached && this.isCacheEntryExpired(cached)) {
1406
1251
  this.elementCache.delete(elementId);
@@ -1408,175 +1253,15 @@ export class MacOSPlatform {
1408
1253
  const cachedJson = JSON.stringify(this.elementCache.get(elementId) ?? null);
1409
1254
  const jxaScript = `
1410
1255
  var se = Application('System Events');
1411
- function childElements(elem) {
1412
- try { return elem.uiElements(); } catch(e1) {
1413
- try { return elem.elements(); } catch(e2) { return []; }
1414
- }
1415
- }
1416
- var elemPath = "${escapedElementId}";
1417
- var appName = "${escapedApp}";
1418
- var textToType = "${escapedText}";
1256
+ var _result = null;
1257
+ ${jxaElementActionHelpers()}
1258
+ var elemPath = ${elementIdLiteral};
1259
+ var appName = ${appLiteral};
1260
+ var textToType = ${textLiteral};
1419
1261
  var shouldClear = ${clearFirst ? "true" : "false"};
1420
1262
  var cached = ${cachedJson};
1421
1263
 
1422
- function resolveElementByFullPath(path) {
1423
- var parts = path.split('/');
1424
- if (parts.length < 2) return null;
1425
-
1426
- var procName = parts[0];
1427
- var winPart = parts[1];
1428
- var winIdx = 0;
1429
- var match = winPart.match(/^win(\\\\d+)$/);
1430
- if (match) {
1431
- winIdx = parseInt(match[1]);
1432
- }
1433
-
1434
- try {
1435
- var proc = se.processes[procName]();
1436
- var wins = proc.windows();
1437
- if (winIdx >= wins.length) return null;
1438
- var current = wins[winIdx];
1439
-
1440
- for (var i = 2; i < parts.length; i++) {
1441
- var idx = parseInt(parts[i]);
1442
- if (isNaN(idx)) return null;
1443
- try {
1444
- var kids = childElements(current);
1445
- if (idx >= kids.length) return null;
1446
- current = kids[idx];
1447
- } catch(e) { return null; }
1448
- }
1449
- return current;
1450
- } catch(e) { return null; }
1451
- }
1452
-
1453
- function elemString(elem, getter) {
1454
- try {
1455
- var value = getter(elem);
1456
- return value === undefined || value === null ? '' : String(value);
1457
- } catch(e) {
1458
- return '';
1459
- }
1460
- }
1461
-
1462
- function getBounds(elem) {
1463
- try {
1464
- var pos = elem.position();
1465
- var sz = elem.size();
1466
- return {x: pos[0] || 0, y: pos[1] || 0, width: sz[0] || 0, height: sz[1] || 0};
1467
- } catch(e) {
1468
- return {x: 0, y: 0, width: 0, height: 0};
1469
- }
1470
- }
1471
-
1472
- function descriptorMatches(elem) {
1473
- if (!cached) return true;
1474
- var role = elemString(elem, function(e) { return e.role(); });
1475
- var name = elemString(elem, function(e) { return e.name(); });
1476
- var desc = elemString(elem, function(e) { return e.description(); });
1477
- var value = elemString(elem, function(e) { return e.value(); });
1478
- if (cached.role && role && role !== cached.role) return false;
1479
- if (cached.name && name && name !== cached.name) return false;
1480
- if (cached.value && value && value !== cached.value) return false;
1481
- if (cached.description && desc && desc !== cached.description) return false;
1482
- return true;
1483
- }
1484
-
1485
- function scoreEquivalent(elem) {
1486
- if (!cached) return -1;
1487
- var score = 0;
1488
- var role = elemString(elem, function(e) { return e.role(); });
1489
- var name = elemString(elem, function(e) { return e.name(); });
1490
- var desc = elemString(elem, function(e) { return e.description(); });
1491
- var value = elemString(elem, function(e) { return e.value(); });
1492
- var subrole = elemString(elem, function(e) { return e.subrole(); });
1493
- var identifier = elemString(elem, function(e) { return e.identifier(); });
1494
- if (cached.role && role === cached.role) score += 4;
1495
- if (cached.name && name === cached.name) score += 4;
1496
- if (cached.value && value === cached.value) score += 3;
1497
- if (cached.description && desc === cached.description) score += 2;
1498
- if (cached.subrole && subrole === cached.subrole) score += 2;
1499
- if (cached.identifier && identifier === cached.identifier) score += 3;
1500
- var b = getBounds(elem);
1501
- if (cached.bounds) {
1502
- var cx = b.x + b.width / 2;
1503
- var cy = b.y + b.height / 2;
1504
- var ocx = cached.bounds.x + cached.bounds.width / 2;
1505
- var ocy = cached.bounds.y + cached.bounds.height / 2;
1506
- var distance = Math.sqrt(Math.pow(cx - ocx, 2) + Math.pow(cy - ocy, 2));
1507
- if (distance < 8) score += 4;
1508
- else if (distance < 40) score += 2;
1509
- else if (distance < 120) score += 1;
1510
- }
1511
- return score;
1512
- }
1513
-
1514
- function refetchEquivalent() {
1515
- if (!cached) return null;
1516
- var targetApp = appName || cached.appName || '';
1517
- var best = null;
1518
- var bestScore = 0;
1519
- var visited = [0];
1520
- function visit(elem, depth) {
1521
- if (visited[0] > 350 || depth > 10) return;
1522
- visited[0]++;
1523
- var score = scoreEquivalent(elem);
1524
- if (score > bestScore) {
1525
- best = elem;
1526
- bestScore = score;
1527
- }
1528
- try {
1529
- var kids = childElements(elem);
1530
- for (var i = 0; i < kids.length; i++) visit(kids[i], depth + 1);
1531
- } catch(e) {}
1532
- }
1533
- try {
1534
- if (targetApp) {
1535
- var proc = se.processes[targetApp]();
1536
- var wins = proc.windows();
1537
- for (var w = 0; w < wins.length; w++) visit(wins[w], 0);
1538
- } else {
1539
- var procs = se.processes();
1540
- for (var p = 0; p < procs.length; p++) {
1541
- try {
1542
- var wins2 = procs[p].windows();
1543
- for (var w2 = 0; w2 < wins2.length; w2++) visit(wins2[w2], 0);
1544
- } catch(e2) {}
1545
- }
1546
- }
1547
- } catch(e) {}
1548
- return bestScore >= 6 ? best : null;
1549
- }
1550
-
1551
- var elem = null;
1552
-
1553
- if (appName) {
1554
- try {
1555
- var proc = se.processes[appName]();
1556
- var wins = proc.windows();
1557
- var parts = elemPath.split('/');
1558
- var winIdx = 0;
1559
- var match = parts[0].match(/^win(\\\\d+)$/);
1560
- if (match) winIdx = parseInt(match[1]);
1561
- if (winIdx < wins.length) {
1562
- var current = wins[winIdx];
1563
- for (var i = 1; i < parts.length; i++) {
1564
- var idx = parseInt(parts[i]);
1565
- if (isNaN(idx)) break;
1566
- try {
1567
- var kids = childElements(current);
1568
- if (idx >= kids.length) break;
1569
- current = kids[idx];
1570
- } catch(e) { break; }
1571
- }
1572
- elem = current;
1573
- }
1574
- } catch(e) {}
1575
- }
1576
-
1577
- if (!elem) {
1578
- elem = resolveElementByFullPath(elemPath);
1579
- }
1264
+ var elem = resolveElementInApp(elemPath, appName) || resolveElementByFullPath(elemPath);
1580
1265
 
1581
1266
  if (elem && !descriptorMatches(elem)) {
1582
1267
  elem = refetchEquivalent() || elem;
@@ -1587,7 +1272,7 @@ export class MacOSPlatform {
1587
1272
  }
1588
1273
 
1589
1274
  if (!elem) {
1590
- JSON.stringify({success: false, error: "Element not found: " + elemPath});
1275
+ _result = {success: false, error: "Element not found: " + elemPath};
1591
1276
  } else {
1592
1277
  try {
1593
1278
  elem.focused = true;
@@ -1614,13 +1299,15 @@ export class MacOSPlatform {
1614
1299
  if (!didSet) {
1615
1300
  try {
1616
1301
  se.keystroke(textToType);
1302
+ _result = {success: true};
1617
1303
  } catch(e) {
1618
- JSON.stringify({success: false, error: "Could not type into element: " + String(e.message || e)});
1304
+ _result = {success: false, error: "Could not type into element: " + String(e.message || e)};
1619
1305
  }
1306
+ } else {
1307
+ _result = {success: true};
1620
1308
  }
1621
-
1622
- JSON.stringify({success: true});
1623
1309
  }
1310
+ JSON.stringify(_result);
1624
1311
  `;
1625
1312
  try {
1626
1313
  const out = execFileSync("osascript", [
@@ -1669,170 +1356,13 @@ export class MacOSPlatform {
1669
1356
  const cachedJson = JSON.stringify(this.elementCache.get(elementId) ?? null);
1670
1357
  const jxaScript = `
1671
1358
  var se = Application('System Events');
1672
- function childElements(elem) {
1673
- try { return elem.uiElements(); } catch(e1) {
1674
- try { return elem.elements(); } catch(e2) { return []; }
1675
- }
1676
- }
1359
+ var _result = null;
1360
+ ${jxaElementActionHelpers()}
1677
1361
  var elemPath = ${elementIdLiteral};
1678
1362
  var appName = ${appLiteral};
1679
1363
  var valueToSet = ${valueLiteral};
1680
1364
  var cached = ${cachedJson};
1681
1365
 
1682
- function resolveElementByFullPath(path) {
1683
- var parts = path.split('/');
1684
- if (parts.length < 2) return null;
1685
-
1686
- var procName = parts[0];
1687
- var winPart = parts[1];
1688
- var winIdx = 0;
1689
- var match = winPart.match(/^win(\\\\d+)$/);
1690
- if (match) winIdx = parseInt(match[1]);
1691
-
1692
- try {
1693
- var proc = se.processes[procName]();
1694
- var wins = proc.windows();
1695
- if (winIdx >= wins.length) return null;
1696
- var current = wins[winIdx];
1697
-
1698
- for (var i = 2; i < parts.length; i++) {
1699
- var idx = parseInt(parts[i]);
1700
- if (isNaN(idx)) return null;
1701
- try {
1702
- var kids = childElements(current);
1703
- if (idx >= kids.length) return null;
1704
- current = kids[idx];
1705
- } catch(e) { return null; }
1706
- }
1707
- return current;
1708
- } catch(e) { return null; }
1709
- }
1710
-
1711
- function resolveElementInApp(path, targetApp) {
1712
- if (!targetApp) return null;
1713
- var parts = path.split('/');
1714
- var start = parts[0] === targetApp ? 1 : 0;
1715
- var winPart = parts[start] || 'win0';
1716
- var winIdx = 0;
1717
- var match = winPart.match(/^win(\\\\d+)$/);
1718
- if (match) winIdx = parseInt(match[1]);
1719
-
1720
- try {
1721
- var proc = se.processes[targetApp]();
1722
- var wins = proc.windows();
1723
- if (winIdx >= wins.length) return null;
1724
- var current = wins[winIdx];
1725
- for (var i = start + 1; i < parts.length; i++) {
1726
- var idx = parseInt(parts[i]);
1727
- if (isNaN(idx)) return null;
1728
- try {
1729
- var kids = childElements(current);
1730
- if (idx >= kids.length) return null;
1731
- current = kids[idx];
1732
- } catch(e) { return null; }
1733
- }
1734
- return current;
1735
- } catch(e) { return null; }
1736
- }
1737
-
1738
- function elemString(elem, getter) {
1739
- try {
1740
- var value = getter(elem);
1741
- return value === undefined || value === null ? '' : String(value);
1742
- } catch(e) {
1743
- return '';
1744
- }
1745
- }
1746
-
1747
- function getBounds(elem) {
1748
- try {
1749
- var pos = elem.position();
1750
- var sz = elem.size();
1751
- return {x: pos[0] || 0, y: pos[1] || 0, width: sz[0] || 0, height: sz[1] || 0};
1752
- } catch(e) {
1753
- return {x: 0, y: 0, width: 0, height: 0};
1754
- }
1755
- }
1756
-
1757
- function descriptorMatches(elem) {
1758
- if (!cached) return true;
1759
- var role = elemString(elem, function(e) { return e.role(); });
1760
- var name = elemString(elem, function(e) { return e.name(); });
1761
- var desc = elemString(elem, function(e) { return e.description(); });
1762
- var value = elemString(elem, function(e) { return e.value(); });
1763
- if (cached.role && role && role !== cached.role) return false;
1764
- if (cached.name && name && name !== cached.name) return false;
1765
- if (cached.value && value && value !== cached.value) return false;
1766
- if (cached.description && desc && desc !== cached.description) return false;
1767
- return true;
1768
- }
1769
-
1770
- function scoreEquivalent(elem) {
1771
- if (!cached) return -1;
1772
- var score = 0;
1773
- var role = elemString(elem, function(e) { return e.role(); });
1774
- var name = elemString(elem, function(e) { return e.name(); });
1775
- var desc = elemString(elem, function(e) { return e.description(); });
1776
- var value = elemString(elem, function(e) { return e.value(); });
1777
- var subrole = elemString(elem, function(e) { return e.subrole(); });
1778
- var identifier = elemString(elem, function(e) { return e.identifier(); });
1779
- if (cached.role && role === cached.role) score += 4;
1780
- if (cached.name && name === cached.name) score += 4;
1781
- if (cached.value && value === cached.value) score += 3;
1782
- if (cached.description && desc === cached.description) score += 2;
1783
- if (cached.subrole && subrole === cached.subrole) score += 2;
1784
- if (cached.identifier && identifier === cached.identifier) score += 3;
1785
- var b = getBounds(elem);
1786
- if (cached.bounds) {
1787
- var cx = b.x + b.width / 2;
1788
- var cy = b.y + b.height / 2;
1789
- var ocx = cached.bounds.x + cached.bounds.width / 2;
1790
- var ocy = cached.bounds.y + cached.bounds.height / 2;
1791
- var distance = Math.sqrt(Math.pow(cx - ocx, 2) + Math.pow(cy - ocy, 2));
1792
- if (distance < 8) score += 4;
1793
- else if (distance < 40) score += 2;
1794
- else if (distance < 120) score += 1;
1795
- }
1796
- return score;
1797
- }
1798
-
1799
- function refetchEquivalent() {
1800
- if (!cached) return null;
1801
- var targetApp = appName || cached.appName || '';
1802
- var best = null;
1803
- var bestScore = 0;
1804
- var visited = [0];
1805
- function visit(elem, depth) {
1806
- if (visited[0] > 350 || depth > 10) return;
1807
- visited[0]++;
1808
- var score = scoreEquivalent(elem);
1809
- if (score > bestScore) {
1810
- best = elem;
1811
- bestScore = score;
1812
- }
1813
- try {
1814
- var kids = childElements(elem);
1815
- for (var i = 0; i < kids.length; i++) visit(kids[i], depth + 1);
1816
- } catch(e) {}
1817
- }
1818
- try {
1819
- if (targetApp) {
1820
- var proc = se.processes[targetApp]();
1821
- var wins = proc.windows();
1822
- for (var w = 0; w < wins.length; w++) visit(wins[w], 0);
1823
- } else {
1824
- var procs = se.processes();
1825
- for (var p = 0; p < procs.length; p++) {
1826
- try {
1827
- var wins2 = procs[p].windows();
1828
- for (var w2 = 0; w2 < wins2.length; w2++) visit(wins2[w2], 0);
1829
- } catch(e2) {}
1830
- }
1831
- }
1832
- } catch(e) {}
1833
- return bestScore >= 6 ? best : null;
1834
- }
1835
-
1836
1366
  var elem = resolveElementInApp(elemPath, appName) || resolveElementByFullPath(elemPath);
1837
1367
  if (elem && !descriptorMatches(elem)) {
1838
1368
  elem = refetchEquivalent() || elem;
@@ -1842,15 +1372,16 @@ export class MacOSPlatform {
1842
1372
  }
1843
1373
 
1844
1374
  if (!elem) {
1845
- JSON.stringify({success: false, error: "Element not found: " + elemPath});
1375
+ _result = {success: false, error: "Element not found: " + elemPath};
1846
1376
  } else {
1847
1377
  try {
1848
1378
  elem.value = valueToSet;
1849
- JSON.stringify({success: true});
1379
+ _result = {success: true};
1850
1380
  } catch(e) {
1851
- JSON.stringify({success: false, error: "Could not set AX value: " + String(e.message || e)});
1381
+ _result = {success: false, error: "Could not set AX value: " + String(e.message || e)};
1852
1382
  }
1853
1383
  }
1384
+ JSON.stringify(_result);
1854
1385
  `;
1855
1386
  try {
1856
1387
  const out = execFileSync("osascript", [