safari-mcp 2.11.6 โ†’ 2.11.9

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 (3) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/safari.js +82 -44
package/README.md CHANGED
@@ -46,7 +46,7 @@ Your AI drives the **Safari you're already logged into** โ€” Gmail, GitHub, Ahre
46
46
 
47
47
  Native WebKit. ~60% less CPU. Background operation. 80 tools. One `npx` command. macOS only.
48
48
 
49
- > ๐Ÿ“ฐ **Featured on HackerNoon:** [I Had to Reverse-Engineer React, Shadow DOM, and CSP to Automate Safari Without Chrome](https://hackernoon.com/i-had-to-reverse-engineer-react-shadow-dom-and-csp-to-automate-safari-without-chrome)
49
+ > ๐Ÿ“ฐ **Featured on freeCodeCamp:** [How to Connect Your AI Coding Agent to a Browser on macOS](https://www.freecodecamp.org/news/how-to-connect-your-ai-coding-agent-to-a-browser-on-macos/) ยท [HackerNoon: Reverse-Engineering React, Shadow DOM, and CSP](https://hackernoon.com/i-had-to-reverse-engineer-react-shadow-dom-and-csp-to-automate-safari-without-chrome)
50
50
 
51
51
  ---
52
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.11.6",
3
+ "version": "2.11.9",
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
@@ -304,13 +307,23 @@ if (SAFARI_PROFILE) {
304
307
 
305
308
  // Initialize profile window at startup (ES module top-level await)
306
309
  if (SAFARI_PROFILE) {
307
- await new Promise(r => setTimeout(r, 50)); // Let helper process initialize
308
- await refreshTargetWindow(true);
309
- if (_targetWindowRef) {
310
- _logProfile(`Startup: Profile "${SAFARI_PROFILE}" โ†’ targeting ${_targetWindowRef}`);
311
- } else {
312
- _logProfile(`WARNING: Profile "${SAFARI_PROFILE}" window NOT found at startup`);
313
- }
310
+ // Run profile-window detection off the critical path so module init โ€” and
311
+ // therefore the MCP initialize handshake โ€” completes immediately. Tool calls
312
+ // that arrive before this finishes already trigger lazy refresh via
313
+ // getTargetWindowRef(), so correctness is preserved.
314
+ // Why this matters: a blocking `await refreshTargetWindow(true)` here could
315
+ // run >30s when Safari was busy or AppleScript was stalled, tripping Claude
316
+ // Code's 30s MCP timeout and leaving the conversation's tool catalog without
317
+ // safari tools until a new conversation is started.
318
+ (async () => {
319
+ await new Promise(r => setTimeout(r, 50)); // Let helper process initialize
320
+ await refreshTargetWindow(true);
321
+ if (_targetWindowRef) {
322
+ _logProfile(`Startup: Profile "${SAFARI_PROFILE}" โ†’ targeting ${_targetWindowRef}`);
323
+ } else {
324
+ _logProfile(`WARNING: Profile "${SAFARI_PROFILE}" window NOT found at startup`);
325
+ }
326
+ })();
314
327
  }
315
328
 
316
329
  // Detect stale window ID errors and invalidate cache
@@ -394,17 +407,35 @@ async function _stampTab(idx) {
394
407
  export async function runJSQuick(js) { return runJS(js); }
395
408
 
396
409
  // ========== 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.
410
+ // Safari AppleScript can steal focus (bring Safari window to front), especially
411
+ // on macOS Tahoe where window-mutation commands trigger an implicit activate.
412
+ // Strategy: 1) read frontmost from daemon (~0.1ms), 2) try to re-activate previous
413
+ // app, 3) settle 5ms (Tahoe needs time to honor the activate), 4) verify, and
414
+ // 5) fall back to hiding Safari if activate didn't take.
399
415
  export async function saveFrontmostApp() {
400
416
  const app = await _helperGetFrontApp();
401
417
  return app?.bundleId || null;
402
418
  }
403
419
  export async function restoreFocusIfStolen(savedBundleId) {
404
420
  if (!savedBundleId || savedBundleId === "com.apple.Safari") return;
405
- const current = await _helperGetFrontApp();
421
+ let current = await _helperGetFrontApp();
422
+ if (current?.bundleId !== "com.apple.Safari") return;
423
+
424
+ // Primary: bring previous app back. Await so the caller doesn't return
425
+ // control to user-space while Safari is still frontmost.
426
+ await _helperActivateApp(savedBundleId).catch(() => {});
427
+
428
+ // Tahoe settle window โ€” NSRunningApplication.activate() is async at the OS
429
+ // level; without this brief wait the verify check below races and reports a
430
+ // false success.
431
+ await new Promise(r => setTimeout(r, 5));
432
+
433
+ current = await _helperGetFrontApp();
406
434
  if (current?.bundleId === "com.apple.Safari") {
407
- await _helperActivateApp(savedBundleId);
435
+ // Activate did not take effect (Safari's window-server policy can block it
436
+ // under Tahoe). Hide Safari as a last-resort fallback โ€” the OS auto-picks
437
+ // the next app, which is reliably the one we saved.
438
+ await _helperHideSafari().catch(() => {});
408
439
  }
409
440
  }
410
441
 
@@ -590,40 +621,52 @@ async function osascript(script, { timeout = 10000 } = {}) {
590
621
  }
591
622
  throw new Error(`AppleScript error: ${err.stderr || err.message}`);
592
623
  } finally {
593
- // Re-activate previous app if Safari stole focus (~1ms)
624
+ // Awaited restore โ€” caller must not return to user-space while Safari is still frontmost.
594
625
  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
- }
626
+ await restoreFocusIfStolen(frontApp.bundleId).catch(() => {});
599
627
  }
600
628
  }
601
629
  }
602
630
 
603
631
  // osascriptFast: uses persistent Swift daemon (~5ms) โ€” 18x faster than subprocess (~90ms)
604
- async function osascriptFast(script, { timeout = 10000 } = {}) {
632
+ async function osascriptFast(script, { timeout = 10000, noFocusGuard = false } = {}) {
605
633
  if (!(await isSafariRunning())) throw safariNotRunningError();
606
634
  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 });
635
+
636
+ // Focus guard โ€” Tahoe AppleScript can implicitly activate Safari (especially
637
+ // on window-mutating commands: set URL / set bounds / set current tab).
638
+ // Skip if an outer caller (extensionOrFallback / runJSLarge / osascript)
639
+ // already handles focus, or if the caller knows the script is read-only and
640
+ // not worth the round-trip overhead (e.g. background polling every 3s).
641
+ const shouldGuardFocus = !_focusGuardActive && !noFocusGuard;
642
+ const frontApp = shouldGuardFocus ? await _helperGetFrontApp() : null;
643
+
644
+ try {
645
+ if (_helperProc) {
646
+ try {
647
+ return await _osascriptFastHelper(script, timeout);
648
+ } catch (err) {
649
+ // Retry once if the window ID became stale
650
+ if (isStaleWindowError(err) && SAFARI_PROFILE) {
651
+ const oldRef = _targetWindowRef;
652
+ await refreshTargetWindow(true);
653
+ if (_targetWindowRef !== oldRef) {
654
+ const retryScript = script.replace(new RegExp(oldRef.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), _targetWindowRef);
655
+ // Guard: helper may have died during the stale-window retry
656
+ if (!_helperProc) startHelper();
657
+ if (_helperProc) return await _osascriptFastHelper(retryScript, timeout);
658
+ return await osascript(retryScript, { timeout });
659
+ }
621
660
  }
661
+ throw err;
622
662
  }
623
- throw err;
663
+ }
664
+ return await osascript(script, { timeout });
665
+ } finally {
666
+ if (shouldGuardFocus && frontApp?.bundleId && frontApp.bundleId !== "com.apple.Safari") {
667
+ await restoreFocusIfStolen(frontApp.bundleId).catch(() => {});
624
668
  }
625
669
  }
626
- return osascript(script, { timeout });
627
670
  }
628
671
 
629
672
  function _osascriptFastHelper(script, timeout) {
@@ -972,12 +1015,9 @@ async function runJSLarge(js, { tabIndex, timeout = 30000 } = {}) {
972
1015
  return stdout.trim();
973
1016
  } finally {
974
1017
  unlink(tmpFile).catch(() => {});
975
- // Re-activate previous app if Safari stole focus (~1ms)
1018
+ // Awaited restore โ€” caller must not return to user-space while Safari is still frontmost.
976
1019
  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
- }
1020
+ await restoreFocusIfStolen(frontApp.bundleId).catch(() => {});
981
1021
  }
982
1022
  }
983
1023
  }
@@ -2685,12 +2725,10 @@ export async function screenshot({ fullPage = false } = {}) {
2685
2725
  );
2686
2726
  }
2687
2727
  }
2688
- // Re-activate previous app if screencapture stole focus (common on macOS Tahoe)
2728
+ // Re-activate previous app if screencapture stole focus (common on macOS Tahoe).
2729
+ // Centralized restore handles settle delay + hide fallback if activate is blocked.
2689
2730
  if (previousBundleId && previousBundleId !== "com.apple.Safari") {
2690
- const cur = await _helperGetFrontApp();
2691
- if (cur?.bundleId === "com.apple.Safari") {
2692
- await _helperActivateApp(previousBundleId).catch(() => {});
2693
- }
2731
+ await restoreFocusIfStolen(previousBundleId).catch(() => {});
2694
2732
  }
2695
2733
  // Compress: convert PNG to JPEG (50% quality) + resize to max 1200px width
2696
2734
  // Cuts ~600KB PNG โ†’ ~60KB JPEG โ€” critical for staying under 20MB context limit