safari-mcp 2.11.6 → 2.11.8

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 (2) hide show
  1. package/package.json +1 -1
  2. package/safari.js +65 -37
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.11.6",
3
+ "version": "2.11.8",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 80 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
package/safari.js CHANGED
@@ -279,8 +279,11 @@ if (SAFARI_PROFILE) {
279
279
  return;
280
280
  }
281
281
  try {
282
+ // Read-only window-name query — opt out of focus-guard so a rare
283
+ // user-app-switch race never triggers the hide-fallback against them.
282
284
  const name = await osascriptFast(
283
- `tell application "Safari" to return name of ${_targetWindowRef}`
285
+ `tell application "Safari" to return name of ${_targetWindowRef}`,
286
+ { noFocusGuard: true }
284
287
  ).catch(() => '');
285
288
  if (name && !name.startsWith(`${SAFARI_PROFILE} \u2014`)) {
286
289
  // Window ID no longer belongs to profile — invalidate cache immediately
@@ -394,17 +397,35 @@ async function _stampTab(idx) {
394
397
  export async function runJSQuick(js) { return runJS(js); }
395
398
 
396
399
  // ========== FOCUS PRESERVATION ==========
397
- // Safari AppleScript can steal focus (bring Safari window to front).
398
- // We hide Safari instead of activating the previous app — prevents ANY Safari window from flashing.
400
+ // Safari AppleScript can steal focus (bring Safari window to front), especially
401
+ // on macOS Tahoe where window-mutation commands trigger an implicit activate.
402
+ // Strategy: 1) read frontmost from daemon (~0.1ms), 2) try to re-activate previous
403
+ // app, 3) settle 5ms (Tahoe needs time to honor the activate), 4) verify, and
404
+ // 5) fall back to hiding Safari if activate didn't take.
399
405
  export async function saveFrontmostApp() {
400
406
  const app = await _helperGetFrontApp();
401
407
  return app?.bundleId || null;
402
408
  }
403
409
  export async function restoreFocusIfStolen(savedBundleId) {
404
410
  if (!savedBundleId || savedBundleId === "com.apple.Safari") return;
405
- const current = await _helperGetFrontApp();
411
+ let current = await _helperGetFrontApp();
412
+ if (current?.bundleId !== "com.apple.Safari") return;
413
+
414
+ // Primary: bring previous app back. Await so the caller doesn't return
415
+ // control to user-space while Safari is still frontmost.
416
+ await _helperActivateApp(savedBundleId).catch(() => {});
417
+
418
+ // Tahoe settle window — NSRunningApplication.activate() is async at the OS
419
+ // level; without this brief wait the verify check below races and reports a
420
+ // false success.
421
+ await new Promise(r => setTimeout(r, 5));
422
+
423
+ current = await _helperGetFrontApp();
406
424
  if (current?.bundleId === "com.apple.Safari") {
407
- await _helperActivateApp(savedBundleId);
425
+ // Activate did not take effect (Safari's window-server policy can block it
426
+ // under Tahoe). Hide Safari as a last-resort fallback — the OS auto-picks
427
+ // the next app, which is reliably the one we saved.
428
+ await _helperHideSafari().catch(() => {});
408
429
  }
409
430
  }
410
431
 
@@ -590,40 +611,52 @@ async function osascript(script, { timeout = 10000 } = {}) {
590
611
  }
591
612
  throw new Error(`AppleScript error: ${err.stderr || err.message}`);
592
613
  } finally {
593
- // Re-activate previous app if Safari stole focus (~1ms)
614
+ // Awaited restore — caller must not return to user-space while Safari is still frontmost.
594
615
  if (shouldGuardFocus && frontApp?.bundleId && frontApp.bundleId !== 'com.apple.Safari') {
595
- const current = await _helperGetFrontApp();
596
- if (current?.bundleId === 'com.apple.Safari') {
597
- _helperActivateApp(frontApp.bundleId).catch(() => {});
598
- }
616
+ await restoreFocusIfStolen(frontApp.bundleId).catch(() => {});
599
617
  }
600
618
  }
601
619
  }
602
620
 
603
621
  // osascriptFast: uses persistent Swift daemon (~5ms) — 18x faster than subprocess (~90ms)
604
- async function osascriptFast(script, { timeout = 10000 } = {}) {
622
+ async function osascriptFast(script, { timeout = 10000, noFocusGuard = false } = {}) {
605
623
  if (!(await isSafariRunning())) throw safariNotRunningError();
606
624
  if (!_helperProc) startHelper();
607
- if (_helperProc) {
608
- try {
609
- return await _osascriptFastHelper(script, timeout);
610
- } catch (err) {
611
- // Retry once if the window ID became stale
612
- if (isStaleWindowError(err) && SAFARI_PROFILE) {
613
- const oldRef = _targetWindowRef;
614
- await refreshTargetWindow(true);
615
- if (_targetWindowRef !== oldRef) {
616
- const retryScript = script.replace(new RegExp(oldRef.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), _targetWindowRef);
617
- // Guard: helper may have died during the stale-window retry
618
- if (!_helperProc) startHelper();
619
- if (_helperProc) return await _osascriptFastHelper(retryScript, timeout);
620
- return await osascript(retryScript, { timeout });
625
+
626
+ // Focus guard — Tahoe AppleScript can implicitly activate Safari (especially
627
+ // on window-mutating commands: set URL / set bounds / set current tab).
628
+ // Skip if an outer caller (extensionOrFallback / runJSLarge / osascript)
629
+ // already handles focus, or if the caller knows the script is read-only and
630
+ // not worth the round-trip overhead (e.g. background polling every 3s).
631
+ const shouldGuardFocus = !_focusGuardActive && !noFocusGuard;
632
+ const frontApp = shouldGuardFocus ? await _helperGetFrontApp() : null;
633
+
634
+ try {
635
+ if (_helperProc) {
636
+ try {
637
+ return await _osascriptFastHelper(script, timeout);
638
+ } catch (err) {
639
+ // Retry once if the window ID became stale
640
+ if (isStaleWindowError(err) && SAFARI_PROFILE) {
641
+ const oldRef = _targetWindowRef;
642
+ await refreshTargetWindow(true);
643
+ if (_targetWindowRef !== oldRef) {
644
+ const retryScript = script.replace(new RegExp(oldRef.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), _targetWindowRef);
645
+ // Guard: helper may have died during the stale-window retry
646
+ if (!_helperProc) startHelper();
647
+ if (_helperProc) return await _osascriptFastHelper(retryScript, timeout);
648
+ return await osascript(retryScript, { timeout });
649
+ }
621
650
  }
651
+ throw err;
622
652
  }
623
- throw err;
653
+ }
654
+ return await osascript(script, { timeout });
655
+ } finally {
656
+ if (shouldGuardFocus && frontApp?.bundleId && frontApp.bundleId !== "com.apple.Safari") {
657
+ await restoreFocusIfStolen(frontApp.bundleId).catch(() => {});
624
658
  }
625
659
  }
626
- return osascript(script, { timeout });
627
660
  }
628
661
 
629
662
  function _osascriptFastHelper(script, timeout) {
@@ -972,12 +1005,9 @@ async function runJSLarge(js, { tabIndex, timeout = 30000 } = {}) {
972
1005
  return stdout.trim();
973
1006
  } finally {
974
1007
  unlink(tmpFile).catch(() => {});
975
- // Re-activate previous app if Safari stole focus (~1ms)
1008
+ // Awaited restore — caller must not return to user-space while Safari is still frontmost.
976
1009
  if (shouldGuard && frontApp?.bundleId && frontApp.bundleId !== 'com.apple.Safari') {
977
- const current = await _helperGetFrontApp();
978
- if (current?.bundleId === 'com.apple.Safari') {
979
- _helperActivateApp(frontApp.bundleId).catch(() => {});
980
- }
1010
+ await restoreFocusIfStolen(frontApp.bundleId).catch(() => {});
981
1011
  }
982
1012
  }
983
1013
  }
@@ -2685,12 +2715,10 @@ export async function screenshot({ fullPage = false } = {}) {
2685
2715
  );
2686
2716
  }
2687
2717
  }
2688
- // Re-activate previous app if screencapture stole focus (common on macOS Tahoe)
2718
+ // Re-activate previous app if screencapture stole focus (common on macOS Tahoe).
2719
+ // Centralized restore handles settle delay + hide fallback if activate is blocked.
2689
2720
  if (previousBundleId && previousBundleId !== "com.apple.Safari") {
2690
- const cur = await _helperGetFrontApp();
2691
- if (cur?.bundleId === "com.apple.Safari") {
2692
- await _helperActivateApp(previousBundleId).catch(() => {});
2693
- }
2721
+ await restoreFocusIfStolen(previousBundleId).catch(() => {});
2694
2722
  }
2695
2723
  // Compress: convert PNG to JPEG (50% quality) + resize to max 1200px width
2696
2724
  // Cuts ~600KB PNG → ~60KB JPEG — critical for staying under 20MB context limit