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.
- package/README.md +1 -1
- package/package.json +1 -1
- 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
|
|
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.
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|