ucu-mcp 0.4.3 → 0.5.1

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.
@@ -2,6 +2,20 @@ import { execFileSync } from "node:child_process";
2
2
  import { ElementNotFoundError } from "../../util/errors.js";
3
3
  import { rethrowElementActionError } from "./helpers.js";
4
4
  import { jxaElementActionHelpers } from "../jxa-helpers.js";
5
+ /**
6
+ * App-name hints for which AXPress is known to be silently swallowed
7
+ * (Tauri custom window decorations, some Electron controls). When the target
8
+ * app matches, clickElement/clickMenuBarExtra skip AXPress and go straight to
9
+ * a coordinate click. Conservative starting set — extend as more silent-swallow
10
+ * apps are confirmed.
11
+ */
12
+ const AX_SILENT_APP_HINTS = ["tauri"];
13
+ function preferCoordinateClick(appName) {
14
+ if (!appName)
15
+ return false;
16
+ const n = appName.toLowerCase();
17
+ return AX_SILENT_APP_HINTS.some((h) => n.includes(h));
18
+ }
5
19
  function prepareCache(elementId) {
6
20
  this.evictExpiredCacheEntries();
7
21
  const cached = this.elementCache.get(elementId);
@@ -10,19 +24,65 @@ function prepareCache(elementId) {
10
24
  }
11
25
  return this.elementCache.get(elementId) ?? null;
12
26
  }
27
+ /**
28
+ * Shared JXA snippet: read an observable state signature from an AX element.
29
+ * Used by clickElement/clickMenuBarExtra to verify whether AXPress actually
30
+ * changed anything (Tauri/Electron silently swallow AXPress without throwing).
31
+ * Returns a string concatenation of value/focused/selected (each defensively
32
+ * read; missing attributes contribute ''). An empty signature means the element
33
+ * exposes no observable state — verification is inconclusive.
34
+ */
35
+ const JXA_STATE_SIGNATURE = `
36
+ function stateSignature(elem) {
37
+ var parts = [];
38
+ try { var v = elem.value(); parts.push('v=' + (v === undefined || v === null ? '' : String(v))); } catch(e) { parts.push('v='); }
39
+ try { parts.push('f=' + (elem.focused ? (elem.focused() ? 1 : 0) : '')); } catch(e) { parts.push('f='); }
40
+ try { parts.push('s=' + (elem.selected ? (elem.selected() ? 1 : 0) : '')); } catch(e) { parts.push('s='); }
41
+ return parts.join('|');
42
+ }
43
+ // Brief synchronous spin to let an async AXPress propagate state.
44
+ function spinMs(ms) { var t = Date.now(); while (Date.now() - t < ms) {} }
45
+ `;
46
+ /**
47
+ * Shared JXA snippet: perform a coordinate click at the element's bounds center.
48
+ * Returns the {x, y} used, or null if bounds could not be read.
49
+ */
50
+ const JXA_COORDINATE_CLICK = `
51
+ function coordinateClick(elem) {
52
+ var pos = elem.position();
53
+ var sz = elem.size();
54
+ var cx = pos[0] + sz[0] / 2;
55
+ var cy = pos[1] + sz[1] / 2;
56
+ ObjC.import('CoreGraphics');
57
+ var src = $.CGEventSourceCreate($.kCGEventSourceStateHIDSystemState);
58
+ var pt = $.CGPointMake(cx, cy);
59
+ var down = $.CGEventCreateMouseEvent(src, $.kCGEventLeftMouseDown, pt, $.kCGMouseButtonLeft);
60
+ $.CGEventPost($.kCGHIDEventTap, down);
61
+ var up = $.CGEventCreateMouseEvent(src, $.kCGEventLeftMouseUp, pt, $.kCGMouseButtonLeft);
62
+ $.CGEventPost($.kCGHIDEventTap, up);
63
+ return {x: cx, y: cy};
64
+ }
65
+ `;
13
66
  export async function clickElement(elementId, app) {
14
67
  const elementIdLiteral = JSON.stringify(elementId);
15
68
  const effectiveApp = app || this.activeTarget?.appName;
16
69
  const appLiteral = JSON.stringify(effectiveApp || "");
17
70
  const cachedDescriptor = prepareCache.call(this, elementId);
18
71
  const cachedJson = JSON.stringify(cachedDescriptor);
72
+ const preferCoord = preferCoordinateClick(effectiveApp);
19
73
  const jxaScript = `
20
74
  var se = Application('System Events');
21
75
  var _result = null;
22
76
  ${jxaElementActionHelpers()}
77
+ ${JXA_STATE_SIGNATURE}
78
+ ${JXA_COORDINATE_CLICK}
23
79
  var elemPath = ${elementIdLiteral};
24
80
  var appName = ${appLiteral};
81
+ // 容忍大小写/空格/连字符/下划线变体(cc-switch vs CC Switch vs cc_switch)
82
+ var _norm = function(s) { return String(s||'').toLowerCase().split(' ').join('').split('-').join('').split('_').join(''); };
83
+ var appNorm = _norm(appName);
25
84
  var cached = ${cachedJson};
85
+ var preferCoord = ${preferCoord ? "true" : "false"};
26
86
 
27
87
  var elem = resolveElementInApp(elemPath, appName) || resolveElementByFullPath(elemPath);
28
88
  if (elem && !descriptorMatches(elem)) {
@@ -34,27 +94,51 @@ export async function clickElement(elementId, app) {
34
94
 
35
95
  if (!elem) {
36
96
  _result = {success: false, error: "Element not found: " + elemPath};
97
+ } else if (preferCoord) {
98
+ // 启发式命中(Tauri 等已知静默吞 AXPress 的应用):直接坐标点击
99
+ try {
100
+ coordinateClick(elem);
101
+ _result = {success: true, method: "coordinate", verified: false};
102
+ } catch(e) {
103
+ _result = {success: false, error: "Could not click element (coordinate): " + String(e.message || e)};
104
+ }
37
105
  } else {
106
+ // AXPress + verify:采样前后状态签名,无变化则降级坐标
107
+ var sigBefore = stateSignature(elem);
108
+ var axpressThrew = false;
38
109
  try {
39
110
  elem.actions.AXPress.perform();
40
- _result = {success: true};
41
111
  } catch(e) {
112
+ axpressThrew = true;
113
+ }
114
+ if (axpressThrew) {
115
+ // AXPress 抛异常 → 坐标 fallback
42
116
  try {
43
- var pos = elem.position();
44
- var sz = elem.size();
45
- var cx = pos[0] + sz[0] / 2;
46
- var cy = pos[1] + sz[1] / 2;
47
- ObjC.import('CoreGraphics');
48
- var src = $.CGEventSourceCreate($.kCGEventSourceStateHIDSystemState);
49
- var pt = $.CGPointMake(cx, cy);
50
- var down = $.CGEventCreateMouseEvent(src, $.kCGEventLeftMouseDown, pt, $.kCGMouseButtonLeft);
51
- $.CGEventPost($.kCGHIDEventTap, down);
52
- var up = $.CGEventCreateMouseEvent(src, $.kCGEventLeftMouseUp, pt, $.kCGMouseButtonLeft);
53
- $.CGEventPost($.kCGHIDEventTap, up);
54
- _result = {success: true};
117
+ coordinateClick(elem);
118
+ _result = {success: true, method: "coordinate", verified: false};
55
119
  } catch(e2) {
56
120
  _result = {success: false, error: "Could not click element: " + String(e2.message || e2)};
57
121
  }
122
+ } else {
123
+ // AXPress 未抛异常:spin 等待异步生效,再采样签名判断是否真的生效
124
+ spinMs(80);
125
+ var sigAfter = stateSignature(elem);
126
+ if (sigBefore !== '' && sigAfter !== sigBefore) {
127
+ // 状态变化 → AXPress 生效
128
+ _result = {success: true, method: "axpress", verified: true};
129
+ } else if (sigBefore === '' && sigAfter === '') {
130
+ // 无可观测状态(纯按钮无 value/focused/selected):无法 verify,
131
+ // 保守认为 AXPress 可能成功(不降级坐标,避免对正常按钮误伤)
132
+ _result = {success: true, method: "axpress", verified: false};
133
+ } else {
134
+ // 有可观测状态但无变化 → 静默吞,降级坐标
135
+ try {
136
+ coordinateClick(elem);
137
+ _result = {success: true, method: "coordinate", verified: false};
138
+ } catch(e3) {
139
+ _result = {success: false, error: "Could not click element (coordinate fallback): " + String(e3.message || e3)};
140
+ }
141
+ }
58
142
  }
59
143
  }
60
144
  JSON.stringify(_result);
@@ -70,9 +154,14 @@ export async function clickElement(elementId, app) {
70
154
  ? new Error(result.error)
71
155
  : new ElementNotFoundError(elementId);
72
156
  }
157
+ return {
158
+ method: result.method ?? "axpress",
159
+ verified: Boolean(result.verified),
160
+ };
73
161
  }
74
162
  catch (error) {
75
163
  rethrowElementActionError(error, "click_element", elementId);
164
+ return { method: "axpress", verified: false }; // unreachable — rethrow throws
76
165
  }
77
166
  }
78
167
  export async function typeInElement(elementId, text, app, clearFirst) {
@@ -88,6 +177,9 @@ export async function typeInElement(elementId, text, app, clearFirst) {
88
177
  ${jxaElementActionHelpers()}
89
178
  var elemPath = ${elementIdLiteral};
90
179
  var appName = ${appLiteral};
180
+ // 容忍大小写/空格/连字符/下划线变体(cc-switch vs CC Switch vs cc_switch)
181
+ var _norm = function(s) { return String(s||'').toLowerCase().split(' ').join('').split('-').join('').split('_').join(''); };
182
+ var appNorm = _norm(appName);
91
183
  var textToType = ${textLiteral};
92
184
  var shouldClear = ${clearFirst ? "true" : "false"};
93
185
  var cached = ${cachedJson};
@@ -167,6 +259,9 @@ export async function setElementValue(elementId, value, app) {
167
259
  ${jxaElementActionHelpers()}
168
260
  var elemPath = ${elementIdLiteral};
169
261
  var appName = ${appLiteral};
262
+ // 容忍大小写/空格/连字符/下划线变体(cc-switch vs CC Switch vs cc_switch)
263
+ var _norm = function(s) { return String(s||'').toLowerCase().split(' ').join('').split('-').join('').split('_').join(''); };
264
+ var appNorm = _norm(appName);
170
265
  var valueToSet = ${valueLiteral};
171
266
  var cached = ${cachedJson};
172
267
 
@@ -210,3 +305,272 @@ export async function setElementValue(elementId, value, app) {
210
305
  rethrowElementActionError(error, "set_value", elementId);
211
306
  }
212
307
  }
308
+ export async function findMenuBarExtra(app) {
309
+ const appLiteral = JSON.stringify(app);
310
+ // 两阶段 JXA:先遍历 app 自身 menuBarItems(host:"self"),若为空或只有 Apple 菜单,
311
+ // 再遍历 SystemUIServer.menuBarItems 找第三方托盘 status item(host:"systemuiserver")。
312
+ // 纯 LSUIElement 托盘应用的 status item 由 SystemUIServer 进程托管,不在 app 自身进程里。
313
+ const jxaScript = `
314
+ var se = Application('System Events');
315
+ var _result = null;
316
+ var appName = ${appLiteral};
317
+ // 容忍大小写/空格/连字符/下划线变体(cc-switch vs CC Switch vs cc_switch)
318
+ var _norm = function(s) { return String(s||'').toLowerCase().split(' ').join('').split('-').join('').split('_').join(''); };
319
+ var appNorm = _norm(appName);
320
+
321
+ // 双向 includes 容忍 "CC Switch" vs "cc-switch" 之类长度差异
322
+ var _matchApp = function(itemNorm) {
323
+ if (!itemNorm) return false;
324
+ return itemNorm === appNorm || itemNorm.indexOf(appNorm) !== -1 || appNorm.indexOf(itemNorm) !== -1;
325
+ };
326
+
327
+ var _readItem = function(item, mb, i, host) {
328
+ var desc = '', nm = '';
329
+ try { desc = item.description(); } catch(e) {}
330
+ try { nm = item.name(); } catch(e) {}
331
+ var pos = [0,0], sz = [0,0];
332
+ try { pos = item.position(); } catch(e) {}
333
+ try { sz = item.size(); } catch(e) {}
334
+ if (sz[0] === 0 && sz[1] === 0) return null;
335
+ return {menuBar: mb, index: i, name: nm, description: desc, x: pos[0], y: pos[1], width: sz[0], height: sz[1], host: host};
336
+ };
337
+
338
+ try {
339
+ var procs = se.processes;
340
+ var p = null;
341
+ for (var k = 0; k < procs.length; k++) {
342
+ var pn = '';
343
+ try { pn = procs[k].name(); } catch(e) {}
344
+ if (_norm(pn) === appNorm) { p = procs[k]; break; }
345
+ }
346
+
347
+ var items = [];
348
+
349
+ // 阶段 1:app 自身 menuBarItems(host:"self")
350
+ if (p) {
351
+ try {
352
+ var menuBars = p.menuBars();
353
+ for (var mb = 0; mb < menuBars.length; mb++) {
354
+ var mbItems;
355
+ try { mbItems = menuBars[mb].menuBarItems(); } catch(e) { continue; }
356
+ for (var i = 0; i < mbItems.length; i++) {
357
+ var rec = _readItem(mbItems[i], mb, i, "self");
358
+ if (rec) items.push(rec);
359
+ }
360
+ }
361
+ } catch(e) {}
362
+ }
363
+
364
+ // 是否需要阶段 2:app 自身无 item,或仅含 Apple 菜单(index 0 的 app-name 项)
365
+ var hasNonApple = false;
366
+ for (var j = 0; j < items.length; j++) {
367
+ if (_norm(items[j].name) !== "apple") { hasNonApple = true; break; }
368
+ }
369
+
370
+ // 阶段 2:SystemUIServer 托管的第三方托盘 status item(host:"systemuiserver")
371
+ // 仅当 app 自身没有可点击的非 Apple 项时才查 SystemUIServer,避免对有窗口的应用产生噪音。
372
+ if (!hasNonApple) {
373
+ try {
374
+ var suiProcs = se.processes.byName("SystemUIServer");
375
+ if (suiProcs) {
376
+ var suiBars = suiProcs.menuBars();
377
+ for (var smb = 0; smb < suiBars.length; smb++) {
378
+ var suiItems;
379
+ try { suiItems = suiBars[smb].menuBarItems(); } catch(e) { continue; }
380
+ for (var si = 0; si < suiItems.length; si++) {
381
+ var sItem = suiItems[si];
382
+ var sDesc = '', sNm = '';
383
+ try { sDesc = sItem.description(); } catch(e) {}
384
+ try { sNm = sItem.name(); } catch(e) {}
385
+ // 按 description/name 匹配目标 app(status item 的 description 通常是 app 名)
386
+ if (_matchApp(_norm(sDesc)) || _matchApp(_norm(sNm))) {
387
+ var sRec = _readItem(sItem, smb, si, "systemuiserver");
388
+ if (sRec) {
389
+ // 保留匹配信号,供 click 二次定位
390
+ sRec.name = sNm; sRec.description = sDesc;
391
+ items.push(sRec);
392
+ }
393
+ }
394
+ }
395
+ }
396
+ }
397
+ } catch(e) {
398
+ // SystemUIServer 不可达(罕见),忽略,继续返回阶段 1 结果
399
+ }
400
+ }
401
+
402
+ if (!p && items.length === 0) {
403
+ _result = {error: "process not found: " + appName, items: []};
404
+ } else {
405
+ _result = {items: items};
406
+ }
407
+ } catch(e) {
408
+ _result = {error: "menu bar AX read failed: " + String(e.message || e), items: []};
409
+ }
410
+ JSON.stringify(_result);
411
+ `;
412
+ let out;
413
+ try {
414
+ out = execFileSync("osascript", ["-l", "JavaScript", "-e", jxaScript], { encoding: "utf-8", timeout: 15000 }).trim();
415
+ }
416
+ catch (error) {
417
+ rethrowElementActionError(error, "find_menu_bar_extra", app);
418
+ return []; // unreachable — rethrow throws
419
+ }
420
+ const parsed = JSON.parse(out);
421
+ if (parsed.error) {
422
+ throw new Error(parsed.error);
423
+ }
424
+ return parsed.items;
425
+ }
426
+ export function matchMenuBarExtra(items, selector) {
427
+ if (items.length === 0)
428
+ return undefined;
429
+ let filtered = items;
430
+ if (selector.description) {
431
+ const d = selector.description.toLowerCase();
432
+ filtered = filtered.filter((it) => (it.description || "").toLowerCase().includes(d) || (it.name || "").toLowerCase().includes(d));
433
+ }
434
+ else if (selector.name) {
435
+ const n = selector.name.toLowerCase();
436
+ filtered = filtered.filter((it) => (it.name || "").toLowerCase().includes(n) || (it.description || "").toLowerCase().includes(n));
437
+ }
438
+ else if (selector.index === undefined) {
439
+ // 无 selector 时排除 Apple 菜单(macOS 每个 app 的 index 0 app-name 项),
440
+ // 否则 click_menu_bar_extra(app) 不带 selector 会误点 Apple 菜单。
441
+ // 注意:SystemUIServer 托管的第三方托盘 item 不应被此过滤误删——它们的 name 通常非 "apple"。
442
+ filtered = filtered.filter((it) => (it.name || "").toLowerCase() !== "apple");
443
+ }
444
+ if (selector.index !== undefined) {
445
+ return filtered[selector.index];
446
+ }
447
+ return filtered[0];
448
+ }
449
+ export async function clickMenuBarExtra(app, selector = {}) {
450
+ const items = await this.findMenuBarExtra(app);
451
+ const target = matchMenuBarExtra(items, selector);
452
+ if (!target) {
453
+ throw new ElementNotFoundError(`menu bar extra not found in ${app} (selector: ${JSON.stringify(selector)}; ${items.length} items scanned)`);
454
+ }
455
+ const appLiteral = JSON.stringify(app);
456
+ const mb = target.menuBar;
457
+ const idx = target.index;
458
+ const host = target.host;
459
+ const tgtNameLiteral = JSON.stringify(target.name || "");
460
+ const tgtDescLiteral = JSON.stringify(target.description || "");
461
+ const preferCoord = preferCoordinateClick(app);
462
+ // AXPress + verify-then-fallback(与 clickElement 同模式,应对 Tauri 等静默吞)。
463
+ // host==="systemuiserver" 时在 SystemUIServer 进程上重定位(托盘 status item 由它托管),
464
+ // SystemUIServer.menuBarItems 顺序不稳定,用保存的 name/description 二次匹配定位具体 item。
465
+ // host==="self" 时按 app 进程的 menuBars()[mb].menuBarItems()[idx] 重定位(稳定)。
466
+ const resolveItemBlock = host === "systemuiserver"
467
+ ? `// SystemUIServer 托管的第三方托盘:按 name/description 二次匹配(顺序不稳定)
468
+ var suiProc = null;
469
+ try { suiProc = se.processes.byName("SystemUIServer"); } catch(e) {}
470
+ var item = null;
471
+ if (suiProc) {
472
+ var suiBars = suiProc.menuBars();
473
+ outer: for (var b = 0; b < suiBars.length; b++) {
474
+ var suiItems;
475
+ try { suiItems = suiBars[b].menuBarItems(); } catch(e) { continue; }
476
+ for (var ii = 0; ii < suiItems.length; ii++) {
477
+ var it = suiItems[ii];
478
+ var iDesc = '', iNm = '';
479
+ try { iDesc = it.description(); } catch(e) {}
480
+ try { iNm = it.name(); } catch(e) {}
481
+ if (_matchApp(_norm(iDesc)) || _matchApp(_norm(iNm))
482
+ || _norm(iDesc) === tgtDescNorm || _norm(iNm) === tgtNameNorm) {
483
+ item = it; break outer;
484
+ }
485
+ }
486
+ }
487
+ }
488
+ if (!item) { _result = {success: false, error: "SystemUIServer status item not found for " + appName}; }`
489
+ : `// app 自身 menu bar:按 menuBar/index 重定位(稳定)
490
+ if (!p) {
491
+ _result = {success: false, error: "process not found: " + appName};
492
+ } else {
493
+ var item = p.menuBars()[${mb}].menuBarItems()[${idx}];
494
+ }`;
495
+ const jxaScript = `
496
+ var se = Application('System Events');
497
+ var _result = null;
498
+ var appName = ${appLiteral};
499
+ var tgtName = ${tgtNameLiteral};
500
+ var tgtDesc = ${tgtDescLiteral};
501
+ ${JXA_STATE_SIGNATURE}
502
+ ${JXA_COORDINATE_CLICK}
503
+ // 容忍大小写/空格/连字符/下划线变体(cc-switch vs CC Switch vs cc_switch)
504
+ var _norm = function(s) { return String(s||'').toLowerCase().split(' ').join('').split('-').join('').split('_').join(''); };
505
+ var appNorm = _norm(appName);
506
+ var tgtNameNorm = _norm(tgtName);
507
+ var tgtDescNorm = _norm(tgtDesc);
508
+ var preferCoord = ${preferCoord ? "true" : "false"};
509
+ var _matchApp = function(itemNorm) {
510
+ if (!itemNorm) return false;
511
+ return itemNorm === appNorm || itemNorm.indexOf(appNorm) !== -1 || appNorm.indexOf(itemNorm) !== -1;
512
+ };
513
+ try {
514
+ var procs = se.processes;
515
+ var p = null;
516
+ for (var k = 0; k < procs.length; k++) {
517
+ var pn = '';
518
+ try { pn = procs[k].name(); } catch(e) {}
519
+ if (_norm(pn) === appNorm) { p = procs[k]; break; }
520
+ }
521
+ ${resolveItemBlock}
522
+ if (!_result && item) {
523
+ if (preferCoord) {
524
+ // 启发式命中:直接坐标点击
525
+ coordinateClick(item);
526
+ _result = {success: true, method: "coordinate", verified: false};
527
+ } else {
528
+ // AXPress + verify
529
+ var sigBefore = stateSignature(item);
530
+ var axpressThrew = false;
531
+ try {
532
+ item.actions.AXPress.perform();
533
+ } catch(e) {
534
+ axpressThrew = true;
535
+ }
536
+ if (axpressThrew) {
537
+ coordinateClick(item);
538
+ _result = {success: true, method: "coordinate", verified: false};
539
+ } else {
540
+ spinMs(80);
541
+ var sigAfter = stateSignature(item);
542
+ if (sigBefore !== '' && sigAfter !== sigBefore) {
543
+ _result = {success: true, method: "axpress", verified: true};
544
+ } else if (sigBefore === '' && sigAfter === '') {
545
+ // 托盘 status item 通常无可观测状态(无 value/selected);AXPress 开菜单的效果
546
+ // 难以从 item 本身验证。保守认为成功(菜单是否打开由后续 find_element 判断)。
547
+ _result = {success: true, method: "axpress", verified: false};
548
+ } else {
549
+ coordinateClick(item);
550
+ _result = {success: true, method: "coordinate", verified: false};
551
+ }
552
+ }
553
+ }
554
+ }
555
+ } catch(e) {
556
+ _result = {success: false, error: String(e.message || e)};
557
+ }
558
+ JSON.stringify(_result);
559
+ `;
560
+ let out;
561
+ try {
562
+ out = execFileSync("osascript", ["-l", "JavaScript", "-e", jxaScript], { encoding: "utf-8", timeout: 15000 }).trim();
563
+ }
564
+ catch (error) {
565
+ rethrowElementActionError(error, "click_menu_bar_extra", app);
566
+ return { method: "axpress", verified: false }; // unreachable — rethrow throws
567
+ }
568
+ const result = JSON.parse(out);
569
+ if (!result.success) {
570
+ throw new Error(`click_menu_bar_extra failed in ${app}: ${result.error}`);
571
+ }
572
+ return {
573
+ method: result.method ?? "axpress",
574
+ verified: Boolean(result.verified),
575
+ };
576
+ }
@@ -1,3 +1,13 @@
1
1
  import type { MacOSPlatform } from "./base.js";
2
+ /**
3
+ * @deprecated Focus management is intentionally disabled. CGEvent input
4
+ * synthesis (click/type/key) works at the HID layer and does not require the
5
+ * target app to be frontmost, so stealing/restoring focus is unnecessary and
6
+ * could disrupt the user. `saveFocus`/`restoreFocus` are retained for API
7
+ * compatibility but are NOT called from the action pipeline — see the
8
+ * `not.toHaveBeenCalled()` assertions in `tools-layer.test.ts` that lock this
9
+ * disabled state. Do not wire these into new code; prefer CGEvent-based input.
10
+ */
2
11
  export declare function saveFocus(this: MacOSPlatform): Promise<void>;
12
+ /** @deprecated See {@link saveFocus} — intentionally disabled, not called from the action pipeline. */
3
13
  export declare function restoreFocus(this: MacOSPlatform): Promise<void>;
@@ -1,4 +1,13 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ /**
3
+ * @deprecated Focus management is intentionally disabled. CGEvent input
4
+ * synthesis (click/type/key) works at the HID layer and does not require the
5
+ * target app to be frontmost, so stealing/restoring focus is unnecessary and
6
+ * could disrupt the user. `saveFocus`/`restoreFocus` are retained for API
7
+ * compatibility but are NOT called from the action pipeline — see the
8
+ * `not.toHaveBeenCalled()` assertions in `tools-layer.test.ts` that lock this
9
+ * disabled state. Do not wire these into new code; prefer CGEvent-based input.
10
+ */
2
11
  export async function saveFocus() {
3
12
  try {
4
13
  const apps = await this.listApps();
@@ -16,6 +25,7 @@ export async function saveFocus() {
16
25
  this.savedFocus = undefined;
17
26
  }
18
27
  }
28
+ /** @deprecated See {@link saveFocus} — intentionally disabled, not called from the action pipeline. */
19
29
  export async function restoreFocus() {
20
30
  if (!this.savedFocus)
21
31
  return;
@@ -74,7 +74,7 @@ export async function ocr(display, region) {
74
74
  const nativeResult = await ocrNative(tmpPath, scaleFactor, region);
75
75
  if (nativeResult)
76
76
  return nativeResult;
77
- return await ocrJxa(tmpPath, screenSize, scaleFactor, region, buf);
77
+ return await ocrJxa(tmpPath, screenSize, scaleFactor, region);
78
78
  }
79
79
  finally {
80
80
  await unlink(tmpPath).catch(() => { });
@@ -83,6 +83,9 @@ export async function ocr(display, region) {
83
83
  async function ocrNative(tmpPath, scaleFactor, region) {
84
84
  const screenDirname = dirname(fileURLToPath(import.meta.url));
85
85
  const candidates = [
86
+ // npm prod: screen.js 编译落在 dist/src/platform/macos/(4 级深),到包根需 4 级 ../
87
+ join(screenDirname, "..", "..", "..", "..", "native", "ocr", "ocr-helper"),
88
+ // dev: screen.ts 在 src/platform/macos/(3 级深),3 级到包根
86
89
  join(screenDirname, "..", "..", "..", "native", "ocr", "ocr-helper"),
87
90
  join(screenDirname, "..", "..", "native", "ocr", "ocr-helper"),
88
91
  join(process.cwd(), "native", "ocr", "ocr-helper"),
@@ -120,45 +123,47 @@ async function ocrNative(tmpPath, scaleFactor, region) {
120
123
  return null;
121
124
  }
122
125
  }
123
- async function ocrJxa(tmpPath, screenSize, scaleFactor, region, buf) {
126
+ async function ocrJxa(tmpPath, screenSize, scaleFactor, region) {
124
127
  const pathLiteral = JSON.stringify(tmpPath);
125
128
  const jxaScript = `
126
129
  function run() {
127
130
  ObjC.import('Vision');
128
131
  ObjC.import('AppKit');
129
132
  ObjC.import('Foundation');
130
- var app = Application.currentApplication();
131
- app.includeStandardAdditions = true;
132
133
  var path = ${pathLiteral};
133
- var url = $.NSURL.fileURLWithPath(path);
134
- var image = $.NSImage.alloc.initWithContentsOfURL(url);
135
- if (!image || !image.isValid) {
134
+ var fm = $.NSFileManager.defaultManager;
135
+ if (!fm.fileExistsAtPath(path)) {
136
136
  return JSON.stringify({error: "Failed to load screenshot image", elements: [], fullText: ""});
137
137
  }
138
- var cgImage = image.CGImageForProposedRectContextHints(null, null, null);
139
- if (!cgImage) {
138
+ var url = $.NSURL.fileURLWithPath(path);
139
+ var handler = $.VNImageRequestHandler.alloc.initWithURLOptions(url, $());
140
+ if (!handler) {
140
141
  return JSON.stringify({error: "Failed to get CGImage from screenshot", elements: [], fullText: ""});
141
142
  }
142
143
  var request = $.VNRecognizeTextRequest.alloc.init;
143
144
  request.recognitionLevel = $.VNRequestTextRecognitionLevelAccurate;
144
145
  request.usesLanguageCorrection = true;
145
- var handler = $.VNImageRequestHandler.alloc.initWithCGImageOptions(cgImage, null);
146
- var performError = $();
147
- var success = handler.performRequestsError([request], performError);
146
+ var performError = Ref();
147
+ var success = handler.performRequestsError($([request]), performError);
148
148
  if (!success) {
149
149
  return JSON.stringify({error: "OCR request failed", elements: [], fullText: ""});
150
150
  }
151
151
  var results = request.results;
152
+ var image = $.NSImage.alloc.initWithContentsOfURL(url);
153
+ if (!image || $(image.representations()).count === 0) {
154
+ return JSON.stringify({error: "Failed to load screenshot image", elements: [], fullText: ""});
155
+ }
156
+ var rep = image.representations().objectAtIndex(0);
157
+ var imgWidth = rep.pixelsWide;
158
+ var imgHeight = rep.pixelsHigh;
152
159
  var elements = [];
153
160
  var fullTextParts = [];
154
- var imgWidth = cgImage.width;
155
- var imgHeight = cgImage.height;
156
161
  for (var i = 0; i < results.count; i++) {
157
162
  var obs = $(results).objectAtIndex(i);
158
163
  var candidates = obs.topCandidates(1);
159
- if (candidates && candidates.count > 0) {
164
+ if (candidates && $(candidates).count > 0) {
160
165
  var candidate = $(candidates).objectAtIndex(0);
161
- var text = candidate.string.toString();
166
+ var text = ObjC.unwrap(candidate.string);
162
167
  var confidence = candidate.confidence;
163
168
  var bbox = obs.boundingBox;
164
169
  var bx = bbox.origin.x * imgWidth;
@@ -183,8 +188,6 @@ async function ocrJxa(tmpPath, screenSize, scaleFactor, region, buf) {
183
188
  : "";
184
189
  throw new CaptureError(`ocr failed: ${parsed.error}${hint}`);
185
190
  }
186
- const imgWidth = buf.readUInt32BE(16);
187
- const scaleFactorX = screenSize.width / (region ? region.width : (imgWidth / scaleFactor));
188
191
  const elements = parsed.elements.map((el) => ({
189
192
  text: el.text,
190
193
  x: Math.round(el.x / scaleFactor) + (region ? region.x : 0),
@@ -53,6 +53,25 @@ export async function focusApp(app) {
53
53
  await new Promise((resolve) => setTimeout(resolve, 150));
54
54
  } while (Date.now() < deadline);
55
55
  if (!target) {
56
+ // 托盘应用(LSUIElement,如 cc-switch)没有常规窗口——回退找菜单栏 status item,
57
+ // 建立 tray activeTarget(windowId='tray',validateActiveTarget 对它特判不查窗口)。
58
+ try {
59
+ const extras = await this.findMenuBarExtra(app);
60
+ if (extras.length > 0) {
61
+ this.activeTarget = {
62
+ targetId: randomUUID(),
63
+ appName: app,
64
+ pid: 0,
65
+ windowId: "tray",
66
+ title: "",
67
+ capturedAt: new Date().toISOString(),
68
+ };
69
+ return this.activeTarget;
70
+ }
71
+ }
72
+ catch {
73
+ // findMenuBarExtra 失败(AX 权限/进程不可达等),落到下面的 WindowNotFoundError
74
+ }
56
75
  this.activeTarget = undefined;
57
76
  const err = new WindowNotFoundError(app, { hint: "list_windows returned no match for this app. If the app is running, " +
58
77
  "the most likely cause is that it is an Electron app whose AX tree is " +
@@ -194,6 +213,9 @@ function resolveNativeHelper(folder, binary) {
194
213
  return override === null ? null : override;
195
214
  }
196
215
  const candidates = [
216
+ // npm prod: window.js 在 dist/src/platform/macos/(4 级深),到包根需 4 级 ../
217
+ join(__windowDirname, "..", "..", "..", "..", "native", folder, binary),
218
+ // dev: window.ts 在 src/platform/macos/(3 级深),3 级到包根
197
219
  join(__windowDirname, "..", "..", "..", "native", folder, binary),
198
220
  join(__windowDirname, "..", "..", "native", folder, binary),
199
221
  ];
@@ -1,4 +1,4 @@
1
- import type { Platform, ScreenRegion, ScreenSize, CursorPosition, WindowInfo, WindowState, OcrResult, FindElementOptions, FindElementResponse } from "./base.js";
1
+ import type { Platform, ScreenRegion, ScreenSize, CursorPosition, WindowInfo, WindowState, OcrResult, FindElementOptions, FindElementResponse, ClickResult } from "./base.js";
2
2
  export declare class WindowsPlatform implements Platform {
3
3
  screenshot(_display?: number, _region?: ScreenRegion): Promise<Buffer>;
4
4
  getScreenSize(_display?: number): ScreenSize;
@@ -13,7 +13,7 @@ export declare class WindowsPlatform implements Platform {
13
13
  key(_keys: string[]): Promise<void>;
14
14
  ocr(_display?: number, _region?: ScreenRegion): Promise<OcrResult>;
15
15
  findElement(_options: FindElementOptions): Promise<FindElementResponse>;
16
- clickElement(_elementId: string, _app?: string): Promise<void>;
16
+ clickElement(_elementId: string, _app?: string): Promise<ClickResult>;
17
17
  typeInElement(_elementId: string, _text: string, _app?: string, _clearFirst?: boolean): Promise<void>;
18
18
  readClipboard(): Promise<string>;
19
19
  writeClipboard(text: string): Promise<void>;