safari-mcp 2.10.9 → 2.11.0
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/package.json +1 -1
- package/safari.js +408 -151
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
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
|
@@ -25,6 +25,21 @@ let _helperProc = null;
|
|
|
25
25
|
const _helperQueue = []; // callbacks waiting for responses
|
|
26
26
|
let _helperConsecutiveTimeouts = 0; // Track consecutive timeouts — only kill after 3
|
|
27
27
|
|
|
28
|
+
// ── Helper request serialization ──────────────────────────────────────────
|
|
29
|
+
// The Swift helper runs each request on its own background thread, so it can
|
|
30
|
+
// finish requests out of order. The Node side matches responses to callbacks by
|
|
31
|
+
// FIFO queue position — correct ONLY if at most one request is in flight at a
|
|
32
|
+
// time. This mutex enforces exactly that: every helper round-trip runs strictly
|
|
33
|
+
// one at a time. Helper calls are ~5ms so serializing costs nothing measurable,
|
|
34
|
+
// but it eliminates the response/callback desync that made tab resolution
|
|
35
|
+
// silently return the wrong tab when the user was browsing concurrently.
|
|
36
|
+
let _helperLock = Promise.resolve();
|
|
37
|
+
function _withHelperLock(makePromise) {
|
|
38
|
+
const result = _helperLock.then(makePromise, makePromise);
|
|
39
|
+
_helperLock = result.then(() => {}, () => {});
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
// Reject all pending callbacks when helper crashes
|
|
29
44
|
function _drainHelperQueue(reason) {
|
|
30
45
|
while (_helperQueue.length > 0) {
|
|
@@ -216,9 +231,14 @@ async function refreshTargetWindow(force = false) {
|
|
|
216
231
|
if (!force && _targetWindowRef && (now - _targetWindowCacheTime) < TARGET_WINDOW_CACHE_MS) return;
|
|
217
232
|
const safeProfile = SAFARI_PROFILE.replace(/"/g, '\\"');
|
|
218
233
|
// Find profile window by name AND verify the window ID still matches
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
234
|
+
const detectScript = `tell application "Safari"\n repeat with w in every window\n if name of w starts with "${safeProfile} \u2014" then return (id of w as text) & "|" & name of w\n end repeat\n return "0|"\nend tell`;
|
|
235
|
+
let result = await osascriptFast(detectScript).catch(() => '0|');
|
|
236
|
+
// The persistent helper occasionally returns '0|' for a window that genuinely
|
|
237
|
+
// exists (daemon timeout / restart race). Before concluding the window is
|
|
238
|
+
// missing, retry once with a plain osascript subprocess \u2014 slower but reliable.
|
|
239
|
+
if (String(result).split('|')[0] === '0') {
|
|
240
|
+
result = await osascript(detectScript).catch(() => '0|');
|
|
241
|
+
}
|
|
222
242
|
const [idStr, windowName] = String(result).split('|');
|
|
223
243
|
const id = Number(idStr);
|
|
224
244
|
if (id > 0) {
|
|
@@ -243,7 +263,12 @@ async function refreshTargetWindow(force = false) {
|
|
|
243
263
|
// Background verification: periodically check that cached window ID still belongs to profile
|
|
244
264
|
if (SAFARI_PROFILE) {
|
|
245
265
|
setInterval(async () => {
|
|
246
|
-
|
|
266
|
+
// No cached window (e.g. flaky detection at startup) — keep trying to
|
|
267
|
+
// rediscover it so the server self-heals instead of staying stuck.
|
|
268
|
+
if (!_targetWindowRef || !_targetWindowId) {
|
|
269
|
+
await refreshTargetWindow(true).catch(() => {});
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
247
272
|
try {
|
|
248
273
|
const name = await osascriptFast(
|
|
249
274
|
`tell application "Safari" to return name of ${_targetWindowRef}`
|
|
@@ -310,6 +335,52 @@ function getFallbackTarget() {
|
|
|
310
335
|
return SAFARI_PROFILE ? `current tab of ${getTargetWindowRef()}` : "front document";
|
|
311
336
|
}
|
|
312
337
|
|
|
338
|
+
// ========== TAB IDENTITY MARKER + VISIBILITY SPOOF ==========
|
|
339
|
+
// Build the JS that stamps our identity onto a tab and keeps it rendering:
|
|
340
|
+
// - window.name : survives EVERY navigation (full loads, redirects,
|
|
341
|
+
// cross-origin). The browser preserves window.name by
|
|
342
|
+
// design — the bulletproof identity that index/URL lack.
|
|
343
|
+
// - window.__mcpTabMarker : survives SPA / same-document routing (secondary marker).
|
|
344
|
+
// - visibility spoof : forces document.visibilityState='visible' so a
|
|
345
|
+
// backgrounded tab keeps rendering. SPAs (e.g. the Meta
|
|
346
|
+
// developer console) blank their main content when hidden;
|
|
347
|
+
// with the user actively switching tabs our automation tab
|
|
348
|
+
// is constantly backgrounded, so without this its content
|
|
349
|
+
// never paints.
|
|
350
|
+
function _buildStampJS(marker) {
|
|
351
|
+
const m = String(marker).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
352
|
+
return "(function(){"
|
|
353
|
+
+ "try{window.name='" + m + "';}catch(e){}"
|
|
354
|
+
+ "try{window.__mcpTabMarker='" + m + "';}catch(e){}"
|
|
355
|
+
+ "try{if(!window.__mcpVisSpoof){window.__mcpVisSpoof=1;"
|
|
356
|
+
+ "Object.defineProperty(document,'visibilityState',{configurable:true,get:function(){return 'visible';}});"
|
|
357
|
+
+ "Object.defineProperty(document,'hidden',{configurable:true,get:function(){return false;}});"
|
|
358
|
+
+ "Object.defineProperty(document,'webkitVisibilityState',{configurable:true,get:function(){return 'visible';}});"
|
|
359
|
+
+ "Object.defineProperty(document,'webkitHidden',{configurable:true,get:function(){return false;}});"
|
|
360
|
+
+ "var s=function(e){e.stopImmediatePropagation();};"
|
|
361
|
+
+ "document.addEventListener('visibilitychange',s,true);"
|
|
362
|
+
+ "document.addEventListener('webkitvisibilitychange',s,true);"
|
|
363
|
+
+ "try{document.hasFocus=function(){return true;};}catch(e){}"
|
|
364
|
+
+ "}}catch(e){}"
|
|
365
|
+
+ "return '1';})()";
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Stamp identity marker + visibility spoof onto a specific tab.
|
|
369
|
+
// Identity-critical: a missed stamp loses the tab marker, so this is NOT best-effort —
|
|
370
|
+
// a daemon hiccup falls back to the reliable osascript subprocess.
|
|
371
|
+
async function _stampTab(idx) {
|
|
372
|
+
if (!idx || !_activeTabMarker) return;
|
|
373
|
+
const js = _buildStampJS(_activeTabMarker).replace(/"/g, '\\"');
|
|
374
|
+
const script = `tell application "Safari" to do JavaScript "${js}" in tab ${idx} of ${getTargetWindowRef()}`;
|
|
375
|
+
try {
|
|
376
|
+
await osascriptFast(script, { timeout: 5000 });
|
|
377
|
+
} catch {
|
|
378
|
+
// Daemon hiccup — retry via the reliable subprocess. Stamping is identity-critical
|
|
379
|
+
// (a missed stamp loses the tab marker), so it must not be silently best-effort.
|
|
380
|
+
await osascript(script, { timeout: 8000 }).catch(() => {});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
313
384
|
// Quick JS execution — exposed for smart-wait checks in index.js
|
|
314
385
|
export async function runJSQuick(js) { return runJS(js); }
|
|
315
386
|
|
|
@@ -329,7 +400,7 @@ export async function restoreFocusIfStolen(savedBundleId) {
|
|
|
329
400
|
}
|
|
330
401
|
|
|
331
402
|
function _helperHideSafari(timeout = 2000) {
|
|
332
|
-
return new Promise((resolve) => {
|
|
403
|
+
return _withHelperLock(() => new Promise((resolve) => {
|
|
333
404
|
if (!_helperProc || !_helperProc.stdin?.writable) { resolve(); return; }
|
|
334
405
|
let resolved = false;
|
|
335
406
|
const timer = setTimeout(() => { if (!resolved) { resolved = true; resolve(); } }, timeout);
|
|
@@ -342,11 +413,11 @@ function _helperHideSafari(timeout = 2000) {
|
|
|
342
413
|
_helperQueue.push(cb);
|
|
343
414
|
try { _helperProc.stdin.write('{"hideSafari":true}\n'); }
|
|
344
415
|
catch { clearTimeout(timer); resolve(); }
|
|
345
|
-
});
|
|
416
|
+
}));
|
|
346
417
|
}
|
|
347
418
|
|
|
348
419
|
function _helperActivateApp(bundleId, timeout = 2000) {
|
|
349
|
-
return new Promise((resolve) => {
|
|
420
|
+
return _withHelperLock(() => new Promise((resolve) => {
|
|
350
421
|
if (!_helperProc || !_helperProc.stdin?.writable) { resolve(); return; }
|
|
351
422
|
let resolved = false;
|
|
352
423
|
const timer = setTimeout(() => { if (!resolved) { resolved = true; resolve(); } }, timeout);
|
|
@@ -359,7 +430,7 @@ function _helperActivateApp(bundleId, timeout = 2000) {
|
|
|
359
430
|
_helperQueue.push(cb);
|
|
360
431
|
try { _helperProc.stdin.write(JSON.stringify({ activateApp: bundleId }) + '\n'); }
|
|
361
432
|
catch { clearTimeout(timer); resolve(); }
|
|
362
|
-
});
|
|
433
|
+
}));
|
|
363
434
|
}
|
|
364
435
|
|
|
365
436
|
export function setFocusGuard(active) { _focusGuardActive = active; }
|
|
@@ -372,38 +443,52 @@ export function setActiveTabURL(url) { _activeTabURL = url; _lastResolveTime = D
|
|
|
372
443
|
async function resolveActiveTab() {
|
|
373
444
|
if (!_activeTabURL && !_activeTabMarker) return _activeTabIndex;
|
|
374
445
|
|
|
375
|
-
// Strategy 1: window.
|
|
376
|
-
//
|
|
377
|
-
|
|
446
|
+
// Strategy 1: identity marker — window.name (survives ALL navigation: full loads,
|
|
447
|
+
// redirects, cross-origin) or window.__mcpTabMarker (survives SPA routing).
|
|
448
|
+
// One AppleScript call loops every tab internally: faster and far more reliable
|
|
449
|
+
// than N separate daemon round-trips (a daemon hiccup mid-scan used to silently
|
|
450
|
+
// mis-resolve to the user's tab).
|
|
451
|
+
if (_activeTabMarker) {
|
|
378
452
|
try {
|
|
379
453
|
const safeMarker = _activeTabMarker.replace(/'/g, "\\'");
|
|
380
|
-
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
`
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
454
|
+
const check = `(function(){try{return (window.name==='${safeMarker}'||window.__mcpTabMarker==='${safeMarker}')?'1':'0'}catch(e){return '0'}})()`;
|
|
455
|
+
const scanScript = `tell application "Safari"
|
|
456
|
+
set w to ${getTargetWindowRef()}
|
|
457
|
+
set n to count of tabs of w
|
|
458
|
+
${_activeTabIndex ? `try
|
|
459
|
+
if n is greater than or equal to ${_activeTabIndex} then
|
|
460
|
+
if (do JavaScript "${check}" in tab ${_activeTabIndex} of w) is "1" then return ${_activeTabIndex}
|
|
461
|
+
end if
|
|
462
|
+
end try` : ''}
|
|
463
|
+
repeat with i from n to 1 by -1
|
|
464
|
+
try
|
|
465
|
+
if (do JavaScript "${check}" in tab i of w) is "1" then return i
|
|
466
|
+
end try
|
|
467
|
+
end repeat
|
|
468
|
+
return 0
|
|
469
|
+
end tell`;
|
|
470
|
+
// Fast daemon first; if it hiccups, retry once via reliable subprocess.
|
|
471
|
+
let res = await osascriptFast(scanScript).catch(() => null);
|
|
472
|
+
if (res === null) res = await osascript(scanScript).catch(() => null);
|
|
473
|
+
if (res !== null) {
|
|
474
|
+
const found = Number(String(res).trim());
|
|
475
|
+
if (found > 0) { _activeTabIndex = found; return found; }
|
|
476
|
+
// Reliable scan completed and the marker is on NO tab — it is genuinely
|
|
477
|
+
// gone (tab closed, or a site overwrote window.name). Drop it; the URL
|
|
478
|
+
// strategy below is the last chance before we fail safe.
|
|
479
|
+
_activeTabMarker = null;
|
|
400
480
|
}
|
|
401
|
-
//
|
|
402
|
-
_activeTabMarker = null;
|
|
481
|
+
// res === null → both attempts errored; can't verify — keep marker, fall through.
|
|
403
482
|
} catch { /* fall through to URL strategy */ }
|
|
404
483
|
}
|
|
405
484
|
|
|
406
|
-
if (!_activeTabURL)
|
|
485
|
+
if (!_activeTabURL) {
|
|
486
|
+
// No URL to resolve. If this session owns a tab but the marker is gone, we can
|
|
487
|
+
// no longer positively identify our tab — refuse to return a stale index that
|
|
488
|
+
// may now point at the user's tab. runJS will throw a clear re-anchor error.
|
|
489
|
+
if (_hasOwnedTab && !_activeTabMarker) { _activeTabIndex = null; }
|
|
490
|
+
return _activeTabIndex;
|
|
491
|
+
}
|
|
407
492
|
|
|
408
493
|
try {
|
|
409
494
|
const safeUrl = _activeTabURL.replace(/"/g, '\\"');
|
|
@@ -435,6 +520,13 @@ async function resolveActiveTab() {
|
|
|
435
520
|
const tabCount = Number(resultStr.split(':')[1]) || 1;
|
|
436
521
|
_lastTabCount = tabCount;
|
|
437
522
|
_activeTabURL = null;
|
|
523
|
+
if (_hasOwnedTab && !_activeTabMarker) {
|
|
524
|
+
// Identity fully lost: marker gone AND URL matches no tab. Returning the
|
|
525
|
+
// stale index could silently target the user's tab. Fail safe — drop it.
|
|
526
|
+
console.error('[Safari MCP] Tab identity lost (marker + URL unresolved) — clearing index to avoid targeting the user\'s tab');
|
|
527
|
+
_activeTabIndex = null;
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
438
530
|
if (_activeTabIndex && _activeTabIndex > tabCount) {
|
|
439
531
|
console.error(`[Safari MCP] Tab ghost proactive fix: index ${_activeTabIndex} > tabCount ${tabCount}, clamping to ${tabCount}`);
|
|
440
532
|
_activeTabIndex = tabCount;
|
|
@@ -526,7 +618,7 @@ async function osascriptFast(script, { timeout = 10000 } = {}) {
|
|
|
526
618
|
}
|
|
527
619
|
|
|
528
620
|
function _osascriptFastHelper(script, timeout) {
|
|
529
|
-
return new Promise((resolve, reject) => {
|
|
621
|
+
return _withHelperLock(() => new Promise((resolve, reject) => {
|
|
530
622
|
let resolved = false;
|
|
531
623
|
const timer = setTimeout(() => {
|
|
532
624
|
if (resolved) return;
|
|
@@ -574,7 +666,7 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
574
666
|
clearTimeout(timer);
|
|
575
667
|
reject(new Error("safari-helper write failed: " + writeErr.message));
|
|
576
668
|
}
|
|
577
|
-
});
|
|
669
|
+
}));
|
|
578
670
|
}
|
|
579
671
|
|
|
580
672
|
// ========== NATIVE CLICK VIA CGEVENT ==========
|
|
@@ -582,7 +674,7 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
582
674
|
// This produces isTrusted: true events — bypasses WAF protection (G2, etc.)
|
|
583
675
|
|
|
584
676
|
function _helperNativeClick(x, y, doubleClick = false, windowId = 0, timeout = 5000) {
|
|
585
|
-
return new Promise((resolve, reject) => {
|
|
677
|
+
return _withHelperLock(() => new Promise((resolve, reject) => {
|
|
586
678
|
if (!_helperProc) startHelper();
|
|
587
679
|
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
588
680
|
reject(new Error("safari-helper not available for native click"));
|
|
@@ -615,13 +707,13 @@ function _helperNativeClick(x, y, doubleClick = false, windowId = 0, timeout = 5
|
|
|
615
707
|
if (doubleClick) cmd.click.double = true;
|
|
616
708
|
if (windowId) cmd.click.windowId = windowId;
|
|
617
709
|
_helperProc.stdin.write(JSON.stringify(cmd) + "\n");
|
|
618
|
-
});
|
|
710
|
+
}));
|
|
619
711
|
}
|
|
620
712
|
|
|
621
713
|
// Sends a CGEvent hover command to the Swift helper daemon.
|
|
622
714
|
// Moves the cursor to (x, y), dwells to let tooltips render, optionally restores cursor.
|
|
623
715
|
function _helperNativeHover(x, y, windowId = 0, dwellMs = 500, restoreMouse = true, timeout = 10000) {
|
|
624
|
-
return new Promise((resolve, reject) => {
|
|
716
|
+
return _withHelperLock(() => new Promise((resolve, reject) => {
|
|
625
717
|
if (!_helperProc) startHelper();
|
|
626
718
|
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
627
719
|
reject(new Error("safari-helper not available for native hover"));
|
|
@@ -653,13 +745,13 @@ function _helperNativeHover(x, y, windowId = 0, dwellMs = 500, restoreMouse = tr
|
|
|
653
745
|
const cmd = { hover: { x, y, dwellMs, restoreMouse } };
|
|
654
746
|
if (windowId) cmd.hover.windowId = windowId;
|
|
655
747
|
_helperProc.stdin.write(JSON.stringify(cmd) + "\n");
|
|
656
|
-
});
|
|
748
|
+
}));
|
|
657
749
|
}
|
|
658
750
|
|
|
659
751
|
// Sends a CGEvent keyboard command to the Swift helper daemon.
|
|
660
752
|
// No focus stealing — sends key events directly to the target window via PID.
|
|
661
753
|
function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000) {
|
|
662
|
-
return new Promise((resolve, reject) => {
|
|
754
|
+
return _withHelperLock(() => new Promise((resolve, reject) => {
|
|
663
755
|
if (!_helperProc) startHelper();
|
|
664
756
|
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
665
757
|
reject(new Error("safari-helper not available for native keyboard"));
|
|
@@ -691,14 +783,14 @@ function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000
|
|
|
691
783
|
const cmd = { keyboard: { keyCode, flags } };
|
|
692
784
|
if (windowId) cmd.keyboard.windowId = windowId;
|
|
693
785
|
_helperProc.stdin.write(JSON.stringify(cmd) + "\n");
|
|
694
|
-
});
|
|
786
|
+
}));
|
|
695
787
|
}
|
|
696
788
|
|
|
697
789
|
// ========== NATIVE FOCUS OPERATIONS VIA DAEMON ==========
|
|
698
790
|
// Uses NSRunningApplication — ~0.1ms for get, ~1ms for activate (vs ~90ms AppleScript)
|
|
699
791
|
|
|
700
792
|
function _helperGetFrontApp(timeout = 2000) {
|
|
701
|
-
return new Promise((resolve) => {
|
|
793
|
+
return _withHelperLock(() => new Promise((resolve) => {
|
|
702
794
|
if (!_helperProc || !_helperProc.stdin?.writable) { resolve(null); return; }
|
|
703
795
|
let resolved = false;
|
|
704
796
|
const timer = setTimeout(() => { if (!resolved) { resolved = true; resolve(null); } }, timeout);
|
|
@@ -711,7 +803,7 @@ function _helperGetFrontApp(timeout = 2000) {
|
|
|
711
803
|
_helperQueue.push(cb);
|
|
712
804
|
try { _helperProc.stdin.write('{"getFrontApp":true}\n'); }
|
|
713
805
|
catch { clearTimeout(timer); resolve(null); }
|
|
714
|
-
});
|
|
806
|
+
}));
|
|
715
807
|
}
|
|
716
808
|
|
|
717
809
|
// Get Safari window bounds, toolbar height, and window ID for coordinate calculation
|
|
@@ -764,13 +856,18 @@ async function runJS(js, { tabIndex, timeout = 15000 } = {}) {
|
|
|
764
856
|
if (!idx && _activeTabIndex && _activeTabURL && (Date.now() - _lastResolveTime < RESOLVE_CACHE_MS)) {
|
|
765
857
|
// Recently verified and tab count unchanged — use cached index
|
|
766
858
|
idx = _activeTabIndex;
|
|
767
|
-
} else if (!idx && _activeTabURL
|
|
768
|
-
// Resolve by URL —
|
|
859
|
+
} else if (!idx && (_activeTabURL || _activeTabMarker)) {
|
|
860
|
+
// Resolve by URL or marker — the marker scan still finds an owned tab
|
|
861
|
+
// even after _activeTabURL has been cleared by a failed URL lookup.
|
|
769
862
|
const resolved = await resolveActiveTab();
|
|
770
863
|
if (resolved) { idx = resolved; _lastResolveTime = Date.now(); }
|
|
771
864
|
}
|
|
772
865
|
// ALWAYS fall back to _activeTabIndex — never clear it from resolve failures
|
|
773
866
|
if (!idx) idx = _activeTabIndex;
|
|
867
|
+
// Once this session owns a tab, never silently run on the user's current tab.
|
|
868
|
+
if (!idx && _hasOwnedTab && SAFARI_PROFILE) {
|
|
869
|
+
throw new Error('Tab tracking lost during runJS — refusing to target the user\'s current tab. Call safari_new_tab to reopen.');
|
|
870
|
+
}
|
|
774
871
|
const target = idx
|
|
775
872
|
? `tab ${idx} of ${getTargetWindowRef()}`
|
|
776
873
|
: getFallbackTarget();
|
|
@@ -781,14 +878,16 @@ async function runJS(js, { tabIndex, timeout = 15000 } = {}) {
|
|
|
781
878
|
}
|
|
782
879
|
return await osascript(script, { timeout });
|
|
783
880
|
} catch (err) {
|
|
784
|
-
// Tab ghost recovery: "Can't get tab X" → re-resolve and retry once
|
|
881
|
+
// Tab ghost recovery: "Can't get tab X" → re-resolve and retry once.
|
|
882
|
+
// Match both apostrophes — Safari emits a typographic apostrophe (U+2019),
|
|
883
|
+
// so a plain "Can't" includes() check silently missed every ghost error.
|
|
785
884
|
const msg = err.message || '';
|
|
786
|
-
if (idx && (
|
|
885
|
+
if (idx && (/[Cc]an.t get tab/.test(msg) || msg.includes("-1728"))) {
|
|
787
886
|
console.error(`[Safari MCP] Tab ghost detected (tab ${idx}), re-resolving...`);
|
|
788
887
|
_lastResolveTime = 0; // Force re-resolve
|
|
789
888
|
_lastTabCount = null; // Invalidate tab count cache
|
|
790
889
|
_activeTabIndex = null;
|
|
791
|
-
if (_activeTabURL) {
|
|
890
|
+
if (_activeTabURL || _activeTabMarker) {
|
|
792
891
|
const newIdx = await resolveActiveTab();
|
|
793
892
|
if (newIdx && newIdx !== idx) {
|
|
794
893
|
console.error(`[Safari MCP] Tab ghost resolved: ${idx} → ${newIdx}`);
|
|
@@ -887,11 +986,22 @@ export async function navigate(url) {
|
|
|
887
986
|
// Resolve tab by URL first (in case indices shifted)
|
|
888
987
|
if (_activeTabURL) await resolveActiveTab();
|
|
889
988
|
_assertNotFallingBackToUserTab('navigate');
|
|
890
|
-
|
|
891
|
-
|
|
989
|
+
// Capture our tab index ONCE. Every internal runJS below targets it explicitly:
|
|
990
|
+
// re-resolving mid-navigation is unsafe — a cross-origin load transiently wipes
|
|
991
|
+
// window.name (a browser privacy feature) and the tracked URL is stale until the
|
|
992
|
+
// new page settles, so resolveActiveTab() would conclude "identity lost" and drop
|
|
993
|
+
// the very tab we are navigating.
|
|
994
|
+
const navIndex = _activeTabIndex;
|
|
995
|
+
const navTarget = navIndex
|
|
996
|
+
? `tab ${navIndex} of ${getTargetWindowRef()}`
|
|
892
997
|
: getFallbackTarget();
|
|
893
998
|
// Step 0: Suppress onbeforeunload dialogs (prevents blocking navigation)
|
|
894
|
-
await runJS("window.onbeforeunload=null", { timeout: 2000 }).catch(() => {});
|
|
999
|
+
await runJS("window.onbeforeunload=null", { tabIndex: navIndex, timeout: 2000 }).catch(() => {});
|
|
1000
|
+
|
|
1001
|
+
// Pre-navigation URL, captured before Step 1. Lets the post-load check below
|
|
1002
|
+
// detect a `set URL` that silently no-ops (a cold or crashed Swift daemon):
|
|
1003
|
+
// the readyState poll would otherwise just see the OLD page still loaded.
|
|
1004
|
+
const preNavUrl = await runJS('location.href', { tabIndex: navIndex, timeout: 3000 }).catch(() => '');
|
|
895
1005
|
|
|
896
1006
|
// Step 1: Set URL via fast daemon (~5ms) — don't block daemon with polling
|
|
897
1007
|
await osascriptFast(
|
|
@@ -905,11 +1015,11 @@ export async function navigate(url) {
|
|
|
905
1015
|
for (let poll = 0; poll < 80; poll++) {
|
|
906
1016
|
await new Promise(r => setTimeout(r, 200));
|
|
907
1017
|
try {
|
|
908
|
-
const state = await runJS('document.readyState', { timeout: 5000 });
|
|
1018
|
+
const state = await runJS('document.readyState', { tabIndex: navIndex, timeout: 5000 });
|
|
909
1019
|
if (state === 'complete' || state === 'interactive') {
|
|
910
1020
|
result = await runJS(
|
|
911
1021
|
`JSON.stringify({title:document.title,url:location.href,blocked:document.title.includes('cannot open')||document.title.includes('\u05D0\u05D9\u05DF \u05D0\u05E4\u05E9\u05E8\u05D5\u05EA')})`,
|
|
912
|
-
{ timeout: 5000 }
|
|
1022
|
+
{ tabIndex: navIndex, timeout: 5000 }
|
|
913
1023
|
);
|
|
914
1024
|
if (state === 'complete') break;
|
|
915
1025
|
// interactive = DOM ready but resources still loading — wait a bit more
|
|
@@ -918,6 +1028,34 @@ export async function navigate(url) {
|
|
|
918
1028
|
} catch { /* page still loading, retry */ }
|
|
919
1029
|
}
|
|
920
1030
|
|
|
1031
|
+
// If the fast `set URL` above silently no-opped (cold/crashed daemon), the poll
|
|
1032
|
+
// just saw the OLD page already loaded. Detect that — the URL never left
|
|
1033
|
+
// preNavUrl — and retry once through the daemon-independent osascript path.
|
|
1034
|
+
let landedUrl = '';
|
|
1035
|
+
try { landedUrl = JSON.parse(result).url || ''; } catch { /* result not JSON */ }
|
|
1036
|
+
if (preNavUrl && preNavUrl !== targetUrl && (!landedUrl || landedUrl === preNavUrl)) {
|
|
1037
|
+
console.error('[Safari MCP] navigate: fast set-URL did not take effect — retrying via osascript subprocess');
|
|
1038
|
+
await osascript(`tell application "Safari" to set URL of ${navTarget} to "${safeUrl}"`, { timeout: 12000 });
|
|
1039
|
+
for (let rpoll = 0; rpoll < 80; rpoll++) {
|
|
1040
|
+
await new Promise(res => setTimeout(res, 200));
|
|
1041
|
+
try {
|
|
1042
|
+
const state = await runJS('document.readyState', { tabIndex: navIndex, timeout: 5000 });
|
|
1043
|
+
if (state === 'complete' || state === 'interactive') {
|
|
1044
|
+
result = await runJS('JSON.stringify({title:document.title,url:location.href})', { tabIndex: navIndex, timeout: 5000 });
|
|
1045
|
+
if (state === 'complete') break;
|
|
1046
|
+
if (rpoll > 10) break;
|
|
1047
|
+
}
|
|
1048
|
+
} catch { /* page still loading, retry */ }
|
|
1049
|
+
}
|
|
1050
|
+
// Still stuck on the pre-navigation URL → the navigation genuinely failed.
|
|
1051
|
+
// Throw rather than returning the stale page as a successful navigation.
|
|
1052
|
+
let retryUrl = '';
|
|
1053
|
+
try { retryUrl = JSON.parse(result).url || ''; } catch { /* result not JSON */ }
|
|
1054
|
+
if (retryUrl && retryUrl === preNavUrl) {
|
|
1055
|
+
throw new Error(`navigate failed: page stayed on ${preNavUrl} — Safari "set URL" to ${targetUrl} had no effect (Safari automation/daemon issue, retry exhausted)`);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
921
1059
|
// Inject click helpers in background (non-blocking, for subsequent clicks)
|
|
922
1060
|
_injectHelpersfast().catch(() => {});
|
|
923
1061
|
|
|
@@ -926,20 +1064,17 @@ export async function navigate(url) {
|
|
|
926
1064
|
const parsed = JSON.parse(result);
|
|
927
1065
|
if (parsed.blocked && url.startsWith("http://")) {
|
|
928
1066
|
const httpUrl = url.replace(/"/g, '\\"');
|
|
929
|
-
const navTarget = _activeTabIndex
|
|
930
|
-
? `tab ${_activeTabIndex} of ${getTargetWindowRef()}`
|
|
931
|
-
: `current tab of ${getTargetWindowRef()}`;
|
|
932
1067
|
await osascriptFast(
|
|
933
1068
|
`tell application "Safari" to set URL of ${navTarget} to "${httpUrl}"`
|
|
934
1069
|
);
|
|
935
|
-
// Poll readyState for HTTP retry (
|
|
1070
|
+
// Poll readyState for HTTP retry — target navIndex explicitly (no re-resolve)
|
|
936
1071
|
let retryResult = '{}';
|
|
937
1072
|
for (let rp = 0; rp < 40; rp++) {
|
|
938
1073
|
await new Promise(r => setTimeout(r, 300));
|
|
939
1074
|
try {
|
|
940
|
-
const rs = await runJS('document.readyState', { timeout: 5000 });
|
|
1075
|
+
const rs = await runJS('document.readyState', { tabIndex: navIndex, timeout: 5000 });
|
|
941
1076
|
if (rs === 'complete' || rs === 'interactive') {
|
|
942
|
-
retryResult = await runJS('JSON.stringify({title:document.title,url:location.href})', { timeout: 5000 });
|
|
1077
|
+
retryResult = await runJS('JSON.stringify({title:document.title,url:location.href})', { tabIndex: navIndex, timeout: 5000 });
|
|
943
1078
|
if (rs === 'complete') break;
|
|
944
1079
|
if (rp > 8) break;
|
|
945
1080
|
}
|
|
@@ -951,7 +1086,9 @@ export async function navigate(url) {
|
|
|
951
1086
|
const retryParsed = JSON.parse(retry);
|
|
952
1087
|
if (retryParsed.url) _activeTabURL = retryParsed.url;
|
|
953
1088
|
} catch {}
|
|
1089
|
+
_activeTabIndex = navIndex;
|
|
954
1090
|
_lastResolveTime = Date.now();
|
|
1091
|
+
await _stampTab(navIndex);
|
|
955
1092
|
return retry;
|
|
956
1093
|
}
|
|
957
1094
|
} catch (_) {}
|
|
@@ -963,18 +1100,14 @@ export async function navigate(url) {
|
|
|
963
1100
|
} catch {
|
|
964
1101
|
_activeTabURL = targetUrl;
|
|
965
1102
|
}
|
|
1103
|
+
_activeTabIndex = navIndex;
|
|
966
1104
|
_lastResolveTime = Date.now();
|
|
967
1105
|
|
|
968
|
-
// Re-
|
|
969
|
-
// window.
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
|
|
973
|
-
const safeMarker = _activeTabMarker.replace(/'/g, "\\'");
|
|
974
|
-
await osascriptFast(
|
|
975
|
-
`tell application "Safari" to do JavaScript "window.__mcpTabMarker='${safeMarker}'" in tab ${_activeTabIndex} of ${getTargetWindowRef()}`
|
|
976
|
-
).catch(() => {});
|
|
977
|
-
}
|
|
1106
|
+
// Re-stamp identity marker + visibility spoof onto the settled page. A cross-origin
|
|
1107
|
+
// navigation clears window.name, and any full load wipes __mcpTabMarker and the
|
|
1108
|
+
// visibility spoof — re-stamping keeps resolveActiveTab able to find this tab and
|
|
1109
|
+
// keeps the page rendering even while backgrounded.
|
|
1110
|
+
await _stampTab(navIndex);
|
|
978
1111
|
|
|
979
1112
|
return result;
|
|
980
1113
|
}
|
|
@@ -1008,6 +1141,8 @@ export async function reload(hardReload = false) {
|
|
|
1008
1141
|
{ timeout: 10000 }
|
|
1009
1142
|
);
|
|
1010
1143
|
try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
|
|
1144
|
+
// A reload destroys the JS context — re-stamp marker + visibility spoof.
|
|
1145
|
+
await _stampTab(_activeTabIndex);
|
|
1011
1146
|
return result;
|
|
1012
1147
|
}
|
|
1013
1148
|
|
|
@@ -1025,8 +1160,29 @@ export async function readPage({ selector, maxLength = 50000 } = {}) {
|
|
|
1025
1160
|
})()`
|
|
1026
1161
|
);
|
|
1027
1162
|
}
|
|
1163
|
+
// innerText needs a built render tree, which Safari may skip for a tab that has
|
|
1164
|
+
// never been foregrounded — it can come back near-empty even though the DOM is
|
|
1165
|
+
// fully present. Detect that and fall back to a layout-independent TreeWalker
|
|
1166
|
+
// text extraction so reads work on a background tab without ever taking focus.
|
|
1028
1167
|
return runJS(
|
|
1029
|
-
`
|
|
1168
|
+
`(function(){
|
|
1169
|
+
var max=${Number(maxLength)};
|
|
1170
|
+
var t=document.body.innerText||'';
|
|
1171
|
+
if(t.replace(/\\s/g,'').length<20){
|
|
1172
|
+
var parts=[];
|
|
1173
|
+
var w=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT,null);
|
|
1174
|
+
var n;
|
|
1175
|
+
while(n=w.nextNode()){
|
|
1176
|
+
var p=n.parentElement;if(!p)continue;
|
|
1177
|
+
var tag=p.tagName;
|
|
1178
|
+
if(tag==='SCRIPT'||tag==='STYLE'||tag==='NOSCRIPT'||tag==='TEMPLATE')continue;
|
|
1179
|
+
var s=(n.textContent||'').replace(/\\s+/g,' ').trim();
|
|
1180
|
+
if(s)parts.push(s);
|
|
1181
|
+
}
|
|
1182
|
+
t=parts.join('\\n');
|
|
1183
|
+
}
|
|
1184
|
+
return JSON.stringify({title:document.title,url:location.href,text:t.substring(0,max)});
|
|
1185
|
+
})()`
|
|
1030
1186
|
);
|
|
1031
1187
|
}
|
|
1032
1188
|
|
|
@@ -1119,25 +1275,33 @@ export async function click({ selector, text, x, y, ref }) {
|
|
|
1119
1275
|
// the native popup is dismissed → the JSC eval times out and the tool returns an
|
|
1120
1276
|
// error after seconds of hang. Detect early and return a clear directive instead.
|
|
1121
1277
|
const selectGuard = `if(el&&el.tagName==='SELECT'){var opts=[];for(var oi=0;oi<el.options.length&&opts.length<8;oi++){opts.push(el.options[oi].text||el.options[oi].value);}return '__SELECT_GUARD__:'+(el.id||el.name||'select')+': '+opts.join('|');}`;
|
|
1278
|
+
// Page fingerprint, captured synchronously on either side of the click. dispatchEvent
|
|
1279
|
+
// is synchronous, so anything that differs between before/after is a direct effect of
|
|
1280
|
+
// the click's own handlers — there is no window for ambient ad/lazy-load noise. Catches
|
|
1281
|
+
// DOM add/remove, class/attribute/text mutations, navigation, and focus changes.
|
|
1282
|
+
const FP = `(location.href+'|'+document.querySelectorAll('*').length+'|'+document.documentElement.innerHTML.length+'|'+(document.activeElement?(document.activeElement.tagName+'#'+(document.activeElement.id||'')):''))`;
|
|
1283
|
+
// Mirrors mcpClickWithReact (resolve → React-fiber click → synthetic fallback) but also
|
|
1284
|
+
// reports reactFired and whether the page observably changed — so click() can detect a
|
|
1285
|
+
// synthetic click that was silently ignored (isTrusted-gated handlers) and escalate.
|
|
1286
|
+
const coreJS = (finderExpr, notFound) =>
|
|
1287
|
+
`(function(){var el=${finderExpr};if(!el)return JSON.stringify({err:${JSON.stringify(notFound)}});${selectGuard}` +
|
|
1288
|
+
`var target=mcpResolveTarget(el)||el;var before=${FP};` +
|
|
1289
|
+
`var reactFired=false;try{reactFired=mcpReactClick(target);}catch(e){}` +
|
|
1290
|
+
`var anchor=target&&target.closest?target.closest('a[href]'):null;` +
|
|
1291
|
+
`if(!reactFired||anchor)mcpClick(target);` +
|
|
1292
|
+
`return JSON.stringify({tag:target.tagName,text:((target.innerText||target.textContent)||'').trim().substring(0,50),reactFired:!!reactFired,changed:before!==(${FP}),fp:before});})()`;
|
|
1293
|
+
|
|
1122
1294
|
let result;
|
|
1123
1295
|
if (ref) {
|
|
1124
|
-
result = await clickWithRetry(
|
|
1125
|
-
`(function(){var el=mcpFindRef('${ref}');if(!el)return 'Element not found: ref=${ref}';${selectGuard}var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+((target.innerText||target.textContent)?(' "'+(target.innerText||target.textContent).trim().substring(0,50)+'"'):'');})()`
|
|
1126
|
-
);
|
|
1296
|
+
result = await clickWithRetry(coreJS(`mcpFindRef('${ref}')`, `Element not found: ref=${ref}`));
|
|
1127
1297
|
} else if (selector) {
|
|
1128
1298
|
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1129
|
-
result = await clickWithRetry(
|
|
1130
|
-
`(function(){var el=mcpQuerySelectorDeep('${sel}');if(!el)return 'Element not found: ${sel}';${selectGuard}var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+((target.innerText||target.textContent)?(' "'+(target.innerText||target.textContent).trim().substring(0,50)+'"'):'');})()`
|
|
1131
|
-
);
|
|
1299
|
+
result = await clickWithRetry(coreJS(`mcpQuerySelectorDeep('${sel}')`, `Element not found: ${selector}`));
|
|
1132
1300
|
} else if (text) {
|
|
1133
1301
|
const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1134
|
-
result = await clickWithRetry(
|
|
1135
|
-
`(function(){var el=mcpFindText('${safeText}',true)||mcpFindText('${safeText}',false);if(!el)return 'Element not found with text: ${safeText}';${selectGuard}var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+' "'+((target.innerText||target.textContent)||'').trim().substring(0,50)+'"';})()`
|
|
1136
|
-
);
|
|
1302
|
+
result = await clickWithRetry(coreJS(`mcpFindText('${safeText}',true)||mcpFindText('${safeText}',false)`, `Element not found with text: ${text}`));
|
|
1137
1303
|
} else if (x !== undefined && y !== undefined) {
|
|
1138
|
-
result = await clickWithRetry(
|
|
1139
|
-
`(function(){var el=mcpElementFromPoint(${Number(x)},${Number(y)});if(!el)return 'No element at (${Number(x)},${Number(y)})';${selectGuard}var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+' at (${Number(x)},${Number(y)})';})()`
|
|
1140
|
-
);
|
|
1304
|
+
result = await clickWithRetry(coreJS(`mcpElementFromPoint(${Number(x)},${Number(y)})`, `No element at (${Number(x)},${Number(y)})`));
|
|
1141
1305
|
} else {
|
|
1142
1306
|
throw new Error("click requires selector, text, or x/y coordinates");
|
|
1143
1307
|
}
|
|
@@ -1146,7 +1310,32 @@ export async function click({ selector, text, x, y, ref }) {
|
|
|
1146
1310
|
const detail = result.substring('__SELECT_GUARD__:'.length);
|
|
1147
1311
|
throw new Error(`Target is a native <select> (${detail}). Use safari_select_option with a value matching one of the options instead — clicking it would open the OS picker and block.`);
|
|
1148
1312
|
}
|
|
1149
|
-
|
|
1313
|
+
|
|
1314
|
+
// Structured result from coreJS. Fall back to the raw string for forward-compat.
|
|
1315
|
+
let info;
|
|
1316
|
+
try { info = JSON.parse(result); } catch { return result; }
|
|
1317
|
+
if (info.err) return info.err;
|
|
1318
|
+
const label = info.tag + (info.text ? ` "${info.text}"` : '');
|
|
1319
|
+
|
|
1320
|
+
// A React handler fired, or the page observably changed → the click landed.
|
|
1321
|
+
if (info.reactFired || info.changed) return 'Clicked: ' + label;
|
|
1322
|
+
|
|
1323
|
+
// No React handler and no synchronous effect. Give an async handler a brief moment,
|
|
1324
|
+
// then re-check before deciding the click was truly ignored.
|
|
1325
|
+
await new Promise(r => setTimeout(r, 320));
|
|
1326
|
+
const afterFp = await runJS(FP).catch(() => null);
|
|
1327
|
+
if (afterFp != null && afterFp !== info.fp) return 'Clicked: ' + label;
|
|
1328
|
+
|
|
1329
|
+
// Synthetic events were ignored — the handler gates on event.isTrusted (Clutch, G2,
|
|
1330
|
+
// Cloudflare-class sites). Escalate to a real OS-level CGEvent click (isTrusted:true).
|
|
1331
|
+
// If a handler genuinely has no observable effect (pure analytics, slow >320ms async),
|
|
1332
|
+
// this re-fires it once via the native click — an accepted, low-cost trade-off.
|
|
1333
|
+
try {
|
|
1334
|
+
await nativeClick({ selector, text, x, y, ref });
|
|
1335
|
+
return 'Clicked (native fallback — synthetic click had no effect): ' + label;
|
|
1336
|
+
} catch (e) {
|
|
1337
|
+
return 'Clicked: ' + label + ' — ⚠️ synthetic click produced no detectable effect and the native fallback is unavailable (' + ((e && e.message) || e) + '). Retry with safari_native_click.';
|
|
1338
|
+
}
|
|
1150
1339
|
}
|
|
1151
1340
|
|
|
1152
1341
|
export async function doubleClick({ selector, x, y, ref }) {
|
|
@@ -2707,22 +2896,27 @@ export async function newTab(url = "") {
|
|
|
2707
2896
|
_lastNewTabAt = Date.now(); // (legacy — grace window superseded by _hasOwnedTab)
|
|
2708
2897
|
_hasOwnedTab = true; // Permanently true: this session has opened its own tab,
|
|
2709
2898
|
// so write ops must NEVER fall back to the user's current tab.
|
|
2710
|
-
// Set bulletproof tab marker —
|
|
2899
|
+
// Set bulletproof tab marker — stamped onto the tab by _stampTab() after load.
|
|
2900
|
+
// window.name survives ALL navigation (full loads, redirects, cross-origin);
|
|
2901
|
+
// __mcpTabMarker survives SPA routing.
|
|
2711
2902
|
_activeTabMarker = `MCP_${SESSION_ID}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
// Wait for page load if URL given
|
|
2903
|
+
// Wait for page load if URL given. Poll readyState from the Node side — Safari's
|
|
2904
|
+
// `do JavaScript` does NOT await async IIFEs, so an in-page wait loop returns
|
|
2905
|
+
// immediately without waiting. Stamping before the page settles loses the marker.
|
|
2716
2906
|
if (url) {
|
|
2717
|
-
|
|
2718
|
-
await
|
|
2719
|
-
|
|
2720
|
-
{ tabIndex: _activeTabIndex, timeout:
|
|
2721
|
-
|
|
2722
|
-
|
|
2907
|
+
for (let i = 0; i < 50; i++) {
|
|
2908
|
+
await new Promise(r => setTimeout(r, 200));
|
|
2909
|
+
try {
|
|
2910
|
+
const st = await runJS('document.readyState', { tabIndex: _activeTabIndex, timeout: 5000 });
|
|
2911
|
+
const href = await runJS('location.href', { tabIndex: _activeTabIndex, timeout: 5000 });
|
|
2912
|
+
if ((st === 'complete' || st === 'interactive') && href && href !== 'about:blank') break;
|
|
2913
|
+
} catch { /* tab still loading */ }
|
|
2914
|
+
}
|
|
2723
2915
|
} else {
|
|
2724
2916
|
await new Promise(r => setTimeout(r, 200));
|
|
2725
2917
|
}
|
|
2918
|
+
// Stamp identity marker + visibility spoof onto the loaded document.
|
|
2919
|
+
await _stampTab(_activeTabIndex);
|
|
2726
2920
|
const info = await runJS(`JSON.stringify({title:document.title,url:location.href,tabIndex:${_activeTabIndex}})`, { tabIndex: _activeTabIndex });
|
|
2727
2921
|
try {
|
|
2728
2922
|
const parsed = JSON.parse(info);
|
|
@@ -2752,6 +2946,13 @@ export async function closeTab() {
|
|
|
2752
2946
|
export async function switchTab(index) {
|
|
2753
2947
|
const idx = Number(index);
|
|
2754
2948
|
_activeTabIndex = idx;
|
|
2949
|
+
// Claiming this tab: stamp it with a FRESH identity marker so resolveActiveTab can
|
|
2950
|
+
// re-find it after the user shifts tab indices. A fresh marker (not a reused one)
|
|
2951
|
+
// ensures a previously-claimed tab — which still carries the old marker string —
|
|
2952
|
+
// is never mistaken for this one.
|
|
2953
|
+
_activeTabMarker = `MCP_${SESSION_ID}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
2954
|
+
_hasOwnedTab = true;
|
|
2955
|
+
await _stampTab(idx);
|
|
2755
2956
|
// Do NOT visually switch the tab — it brings the Safari window to foreground
|
|
2756
2957
|
// and interrupts the user. Visual switching only happens in screenshot() when needed.
|
|
2757
2958
|
// AppleScript `do JavaScript in tab N` works on background tabs without switching.
|
|
@@ -2765,89 +2966,145 @@ export async function switchTab(index) {
|
|
|
2765
2966
|
const parsed = JSON.parse(result);
|
|
2766
2967
|
_activeTabURL = parsed.url || null;
|
|
2767
2968
|
} catch {}
|
|
2969
|
+
_lastResolveTime = Date.now();
|
|
2768
2970
|
return result;
|
|
2769
2971
|
}
|
|
2770
2972
|
|
|
2771
2973
|
// ========== WAIT ==========
|
|
2772
2974
|
|
|
2773
2975
|
export async function waitFor({ selector, text, timeout = 10000 }) {
|
|
2774
|
-
//
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
const
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
${safeSelector ? `if (document.querySelector('${safeSelector}')) return 'Found: ${safeSelector}';` : ""}
|
|
2782
|
-
${safeText ? `if (document.body && document.body.innerText.includes('${safeText}')) return 'Found text: ${safeText}';` : ""}
|
|
2783
|
-
await new Promise(function(r){setTimeout(r, 50)});
|
|
2784
|
-
}
|
|
2785
|
-
return 'TIMEOUT';
|
|
2786
|
-
})()`,
|
|
2787
|
-
{ timeout: timeout + 2000 }
|
|
2788
|
-
);
|
|
2789
|
-
if (result === "TIMEOUT") {
|
|
2790
|
-
throw new Error(`Timeout waiting for ${selector || text} (${timeout}ms)`);
|
|
2976
|
+
// `do JavaScript` can't await, so an in-page async wait loop returns immediately
|
|
2977
|
+
// (handing back an unsettled promise) instead of waiting. The wait loop runs on
|
|
2978
|
+
// the Node side: each tick re-evaluates one SYNCHRONOUS check against the page.
|
|
2979
|
+
const safeSelector = selector ? selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'") : "";
|
|
2980
|
+
const safeText = text ? text.replace(/\\/g, "\\\\").replace(/'/g, "\\'") : "";
|
|
2981
|
+
if (!safeSelector && !safeText) {
|
|
2982
|
+
throw new Error("waitFor requires selector or text");
|
|
2791
2983
|
}
|
|
2792
|
-
|
|
2984
|
+
const checkJs = `(function(){` +
|
|
2985
|
+
(safeSelector ? `if(document.querySelector('${safeSelector}'))return 'Found: ${safeSelector}';` : "") +
|
|
2986
|
+
(safeText ? `if(document.body&&document.body.innerText.includes('${safeText}'))return 'Found text: ${safeText}';` : "") +
|
|
2987
|
+
`return '';})()`;
|
|
2988
|
+
const deadline = Date.now() + Number(timeout);
|
|
2989
|
+
while (Date.now() < deadline) {
|
|
2990
|
+
const hit = await runJS(checkJs, { timeout: 5000 }).catch(() => "");
|
|
2991
|
+
if (hit) return hit;
|
|
2992
|
+
await new Promise(r => setTimeout(r, 100));
|
|
2993
|
+
}
|
|
2994
|
+
throw new Error(`Timeout waiting for ${selector || text} (${timeout}ms)`);
|
|
2793
2995
|
}
|
|
2794
2996
|
|
|
2795
2997
|
// ========== EVALUATE ==========
|
|
2796
2998
|
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
var v = await(async function(){${js}})();
|
|
2808
|
-
return JSON.stringify({v: v !== undefined && v !== null ? (typeof v === 'object' ? JSON.stringify(v) : String(v)) : null});
|
|
2809
|
-
}catch(e){
|
|
2810
|
-
return JSON.stringify({e: e.message});
|
|
2811
|
-
}
|
|
2812
|
-
})()`,
|
|
2813
|
-
{ timeout: 35000 }
|
|
2814
|
-
);
|
|
2815
|
-
try {
|
|
2816
|
-
const parsed = JSON.parse(result);
|
|
2817
|
-
if (parsed.e) return `Error: ${parsed.e}`;
|
|
2818
|
-
return parsed.v !== undefined && parsed.v !== null ? String(parsed.v) : '(undefined)';
|
|
2819
|
-
} catch { return result || '(undefined)'; }
|
|
2820
|
-
}
|
|
2821
|
-
|
|
2999
|
+
// Build the expression to evaluate from a user script. Pure (no Safari calls) so
|
|
3000
|
+
// it can be unit-tested directly — see scripts/test-evaluate-wrapping.js.
|
|
3001
|
+
export function _buildEvalExpr(js) {
|
|
3002
|
+
// Async iff the *result* is a promise to wait on. `fetch(` alone is NOT async —
|
|
3003
|
+
// an un-awaited fetch is fire-and-forget; only await / .then() / a leading
|
|
3004
|
+
// `async` make the result thenable.
|
|
3005
|
+
const isAsync = /\bawait\b/.test(js) || /\.then\s*\(/.test(js) || /^async\b/.test(js);
|
|
3006
|
+
// Statement keywords: a script starting with one is never a bare expression,
|
|
3007
|
+
// and `return (<keyword> ...)` would be a syntax error.
|
|
3008
|
+
const NON_EXPR = /^(var|let|const|return|if|for|while|switch|try|do|throw)\b/;
|
|
2822
3009
|
const isIIFE = /^\((?:async\s+)?function/.test(js) || /^\((?:async\s+)?\(/.test(js);
|
|
2823
|
-
const isSimpleExpression = !js.includes(';') && !js.includes('\n') && !
|
|
2824
|
-
|
|
2825
|
-
|
|
3010
|
+
const isSimpleExpression = !js.includes(';') && !js.includes('\n') && !NON_EXPR.test(js);
|
|
3011
|
+
|
|
3012
|
+
let expr;
|
|
3013
|
+
if (isIIFE) {
|
|
3014
|
+
expr = js;
|
|
3015
|
+
} else if (isSimpleExpression) {
|
|
3016
|
+
// A bare expression — usable as-is for sync; async needs an awaiting wrapper.
|
|
3017
|
+
expr = isAsync ? `(async function(){return (${js})})()` : js;
|
|
3018
|
+
} else {
|
|
3019
|
+
// Multi-statement: prepend `return` to the last value-producing line when it
|
|
3020
|
+
// can safely take one; otherwise fall back to indirect-eval completion value.
|
|
2826
3021
|
const lines = js.split('\n');
|
|
2827
3022
|
let addedReturn = false;
|
|
2828
3023
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
2829
3024
|
const line = lines[i].trim();
|
|
2830
3025
|
if (!line || line.startsWith('//')) continue;
|
|
2831
|
-
if (line.startsWith('return ') || line.startsWith('return;')
|
|
2832
|
-
|
|
3026
|
+
if (line.startsWith('return ') || line.startsWith('return;') || line === 'return') {
|
|
3027
|
+
addedReturn = true; break;
|
|
3028
|
+
}
|
|
3029
|
+
// A block-closer (`}`, `})`, `})()`), a block body (ends with `}`) or a
|
|
3030
|
+
// statement keyword can't take a prepended `return`.
|
|
3031
|
+
if (line.startsWith('}') || line.endsWith('}') || NON_EXPR.test(line)) break;
|
|
2833
3032
|
lines[i] = 'return ' + lines[i];
|
|
2834
3033
|
addedReturn = true;
|
|
2835
3034
|
break;
|
|
2836
3035
|
}
|
|
2837
3036
|
if (addedReturn) {
|
|
2838
|
-
|
|
3037
|
+
expr = `(${isAsync ? 'async function' : 'function'}(){${lines.join('\n')}})()`;
|
|
3038
|
+
} else if (isAsync) {
|
|
3039
|
+
// No safe return slot — run the body in an async IIFE (value may be undefined).
|
|
3040
|
+
expr = `(async function(){${js}})()`;
|
|
2839
3041
|
} else {
|
|
2840
|
-
//
|
|
2841
|
-
//
|
|
2842
|
-
// eslint-disable-next-line no-eval
|
|
3042
|
+
// Indirect eval yields the completion value of an arbitrary statement list;
|
|
3043
|
+
// the catch re-runs the body plainly when a strict CSP blocks eval.
|
|
2843
3044
|
const escaped = js.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
2844
|
-
|
|
3045
|
+
expr = "(function(){ try { return (0,eval)('" + escaped + "') } catch(_e) { " + js.replace(/\n/g, ' ') + " } })()";
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
return { isAsync, expr };
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
// Async scripts can't be awaited through AppleScript `do JavaScript` — it returns
|
|
3052
|
+
// the moment the synchronous portion finishes, handing back an unsettled Promise.
|
|
3053
|
+
// So the work is started fire-and-forget into a page global, then that global is
|
|
3054
|
+
// polled synchronously from the Node side (the same pattern navigate() uses).
|
|
3055
|
+
async function _evaluateAsync(expr) {
|
|
3056
|
+
// Token is identifier-safe (base36 → [0-9a-z], `_` prefix) so `window.<token>`
|
|
3057
|
+
// dot access needs no quoting/escaping through the AppleScript bridge.
|
|
3058
|
+
const token = '__mcpEval_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
3059
|
+
const slot = 'window.' + token;
|
|
3060
|
+
// A SYNC outer function installs the globals, starts the async work (NOT awaited
|
|
3061
|
+
// here — `do JavaScript` would not await it anyway) and returns immediately.
|
|
3062
|
+
const kickoff =
|
|
3063
|
+
`(function(){${slot}={done:false};(async function(){try{` +
|
|
3064
|
+
`var __v=await (${expr});` +
|
|
3065
|
+
`${slot}.val=(__v===undefined||__v===null)?null:(typeof __v==='object'?JSON.stringify(__v):String(__v));` +
|
|
3066
|
+
`}catch(__e){${slot}.err=(__e&&__e.message)||String(__e);}` +
|
|
3067
|
+
`finally{${slot}.done=true;}})();return 'ok';})()`;
|
|
3068
|
+
const started = await runJS(kickoff, { timeout: 10000 });
|
|
3069
|
+
if (started !== 'ok') {
|
|
3070
|
+
return typeof started === 'string' && started ? started : '(no return value)';
|
|
3071
|
+
}
|
|
3072
|
+
// Poll the result global until the async work settles (35s budget).
|
|
3073
|
+
const pollJs =
|
|
3074
|
+
`(function(){var s=${slot};if(!s)return '__MCP_GONE__';` +
|
|
3075
|
+
`if(!s.done)return '';return JSON.stringify({v:s.val,e:s.err});})()`;
|
|
3076
|
+
const deadline = Date.now() + 35000;
|
|
3077
|
+
let raw = '';
|
|
3078
|
+
while (Date.now() < deadline) {
|
|
3079
|
+
await new Promise(r => setTimeout(r, 120));
|
|
3080
|
+
raw = await runJS(pollJs, { timeout: 5000 }).catch(() => '');
|
|
3081
|
+
if (raw === '__MCP_GONE__') {
|
|
3082
|
+
return '(no return value — page navigated away during async evaluation)';
|
|
2845
3083
|
}
|
|
3084
|
+
if (raw) break;
|
|
2846
3085
|
}
|
|
3086
|
+
// Best-effort cleanup of the page global.
|
|
3087
|
+
runJS(`(function(){try{delete ${slot};}catch(__e){${slot}=undefined;}return '';})()`).catch(() => {});
|
|
3088
|
+
if (!raw) {
|
|
3089
|
+
throw new Error('safari_evaluate: async script did not settle within 35s');
|
|
3090
|
+
}
|
|
3091
|
+
try {
|
|
3092
|
+
const parsed = JSON.parse(raw);
|
|
3093
|
+
if (parsed.e) return 'Error: ' + parsed.e;
|
|
3094
|
+
return parsed.v !== undefined && parsed.v !== null ? String(parsed.v) : '(no return value)';
|
|
3095
|
+
} catch {
|
|
3096
|
+
return raw;
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
2847
3099
|
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
3100
|
+
export async function evaluate({ script }) {
|
|
3101
|
+
const js = (script || '').trim();
|
|
3102
|
+
if (!js) return '(no return value)';
|
|
3103
|
+
const { isAsync, expr } = _buildEvalExpr(js);
|
|
3104
|
+
if (isAsync) return _evaluateAsync(expr);
|
|
3105
|
+
// Sync: a single `do JavaScript` over one expression. `do JavaScript` only
|
|
3106
|
+
// returns the value of a single expression, so the whole script is one IIFE.
|
|
3107
|
+
const wrappedJs = `(function(){ try { return (${expr}); } catch(__mcpErr) { return 'Error: ' + __mcpErr.message; } })()`;
|
|
2851
3108
|
if (process.env.MCP_DEBUG) console.error('[evaluate] wrapped:', wrappedJs.substring(0, 300));
|
|
2852
3109
|
const result = await runJS(wrappedJs);
|
|
2853
3110
|
if (result === null || result === undefined || result === '') {
|