safari-mcp 2.10.10 → 2.11.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.
- package/index.js +19 -2
- package/package.json +1 -1
- package/safari.js +395 -146
package/index.js
CHANGED
|
@@ -127,6 +127,20 @@ function _isURLOwned(url) {
|
|
|
127
127
|
return false;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
// Sentinel persisted when a blank tab (about:blank) is opened by this session.
|
|
131
|
+
// A blank tab has no unique URL to own, but ownership must still survive an MCP
|
|
132
|
+
// process restart (_openedTabs is in-memory only) — otherwise reopening blank
|
|
133
|
+
// tabs falsely trips the "no tabs opened yet" guard. The sentinel is never a
|
|
134
|
+
// real tab URL, so it cannot falsely match a user's page in _isURLOwned().
|
|
135
|
+
const BLANK_TAB_SENTINEL = "__mcp-blank-tab__";
|
|
136
|
+
|
|
137
|
+
function _markBlankTabOpened() {
|
|
138
|
+
if (!_ownedTabURLs.has(BLANK_TAB_SENTINEL)) {
|
|
139
|
+
_ownedTabURLs.add(BLANK_TAB_SENTINEL);
|
|
140
|
+
_saveOwnershipFile(_ownedTabURLs);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
130
144
|
function _addOwnedURL(url) {
|
|
131
145
|
if (url && url !== 'about:blank' && url !== 'favorites://') {
|
|
132
146
|
_ownedTabURLs.add(url);
|
|
@@ -697,7 +711,7 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
|
|
|
697
711
|
throw new Error(msg);
|
|
698
712
|
} else if (currentUrl && !_isURLOwned(currentUrl)) {
|
|
699
713
|
// about:blank tabs are owned if we have any tracked tabs (new_tab creates them at about:blank)
|
|
700
|
-
const isBlankOwned = (currentUrl === 'about:blank' || currentUrl === 'missing value') && _openedTabs.size > 0;
|
|
714
|
+
const isBlankOwned = (currentUrl === 'about:blank' || currentUrl === 'missing value') && (_openedTabs.size > 0 || _ownedTabURLs.has(BLANK_TAB_SENTINEL));
|
|
701
715
|
if (!isBlankOwned) {
|
|
702
716
|
const msg = `⚠️ Tab safety: refusing "${extensionType}" — current tab (${currentUrl}) was not opened by this MCP session. Use safari_new_tab or safari_switch_tab to target your own tab.`;
|
|
703
717
|
console.error(`[Safari MCP] ${msg}`);
|
|
@@ -1354,6 +1368,9 @@ server.tool(
|
|
|
1354
1368
|
_addOwnedURL(trackUrl);
|
|
1355
1369
|
if (url && url !== trackUrl) _addOwnedURL(url); // also own the requested URL (handles www redirects)
|
|
1356
1370
|
}
|
|
1371
|
+
// Blank tab (no URL requested): persist a restart-surviving ownership marker.
|
|
1372
|
+
const _effectiveNewURL = (result?.url && result.url !== 'about:blank' && result.url !== 'missing value') ? result.url : url;
|
|
1373
|
+
if (!_effectiveNewURL) _markBlankTabOpened();
|
|
1357
1374
|
return { content: [{ type: "text", text: typeof rawResult === 'string' ? rawResult : JSON.stringify(result) }] };
|
|
1358
1375
|
}
|
|
1359
1376
|
);
|
|
@@ -1384,7 +1401,7 @@ server.tool(
|
|
|
1384
1401
|
const target = parsed.find(t => t.index === index);
|
|
1385
1402
|
if (target && target.url && !_isURLOwned(target.url)) {
|
|
1386
1403
|
// about:blank / missing value tabs are owned if tracked in _openedTabs
|
|
1387
|
-
const isBlankOwned = (target.url === 'about:blank' || target.url === 'missing value') && _openedTabs.has(index);
|
|
1404
|
+
const isBlankOwned = (target.url === 'about:blank' || target.url === 'missing value') && (_openedTabs.has(index) || _ownedTabURLs.has(BLANK_TAB_SENTINEL));
|
|
1388
1405
|
if (!isBlankOwned) {
|
|
1389
1406
|
const msg = `⚠️ Tab safety: refusing switch_tab to index ${index} (${target.url}) — not opened by this MCP session. Use safari_new_tab to open your own tab.`;
|
|
1390
1407
|
console.error(`[Safari MCP] ${msg}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.1",
|
|
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) {
|
|
@@ -92,6 +107,15 @@ function cleanupHelper() {
|
|
|
92
107
|
// cleanupHelper runs via process.on("exit"), which fires when index.js calls process.exit().
|
|
93
108
|
process.on("exit", cleanupHelper);
|
|
94
109
|
process.on("uncaughtException", (err) => { console.error("Uncaught:", err); cleanupHelper(); process.exit(1); });
|
|
110
|
+
// Unhandled promise rejections must NOT terminate the server. A single failed
|
|
111
|
+
// async operation — e.g. a proxy fetch to the primary instance while it is
|
|
112
|
+
// mid-restart, or an aborted fetch timeout — would otherwise bubble to the
|
|
113
|
+
// uncaughtException handler above (Node's default for unhandled rejections) and
|
|
114
|
+
// exit the whole MCP process, disconnecting every concurrent session. Log and
|
|
115
|
+
// continue: the failed operation is localized, the process itself is healthy.
|
|
116
|
+
process.on("unhandledRejection", (reason) => {
|
|
117
|
+
console.error("[Safari MCP] Unhandled rejection (non-fatal, continuing):", (reason && reason.stack) || reason);
|
|
118
|
+
});
|
|
95
119
|
|
|
96
120
|
// ========== SAFARI RUNNING CHECK ==========
|
|
97
121
|
// Prevent AppleScript from auto-launching Safari when it's closed
|
|
@@ -320,6 +344,52 @@ function getFallbackTarget() {
|
|
|
320
344
|
return SAFARI_PROFILE ? `current tab of ${getTargetWindowRef()}` : "front document";
|
|
321
345
|
}
|
|
322
346
|
|
|
347
|
+
// ========== TAB IDENTITY MARKER + VISIBILITY SPOOF ==========
|
|
348
|
+
// Build the JS that stamps our identity onto a tab and keeps it rendering:
|
|
349
|
+
// - window.name : survives EVERY navigation (full loads, redirects,
|
|
350
|
+
// cross-origin). The browser preserves window.name by
|
|
351
|
+
// design — the bulletproof identity that index/URL lack.
|
|
352
|
+
// - window.__mcpTabMarker : survives SPA / same-document routing (secondary marker).
|
|
353
|
+
// - visibility spoof : forces document.visibilityState='visible' so a
|
|
354
|
+
// backgrounded tab keeps rendering. SPAs (e.g. the Meta
|
|
355
|
+
// developer console) blank their main content when hidden;
|
|
356
|
+
// with the user actively switching tabs our automation tab
|
|
357
|
+
// is constantly backgrounded, so without this its content
|
|
358
|
+
// never paints.
|
|
359
|
+
function _buildStampJS(marker) {
|
|
360
|
+
const m = String(marker).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
361
|
+
return "(function(){"
|
|
362
|
+
+ "try{window.name='" + m + "';}catch(e){}"
|
|
363
|
+
+ "try{window.__mcpTabMarker='" + m + "';}catch(e){}"
|
|
364
|
+
+ "try{if(!window.__mcpVisSpoof){window.__mcpVisSpoof=1;"
|
|
365
|
+
+ "Object.defineProperty(document,'visibilityState',{configurable:true,get:function(){return 'visible';}});"
|
|
366
|
+
+ "Object.defineProperty(document,'hidden',{configurable:true,get:function(){return false;}});"
|
|
367
|
+
+ "Object.defineProperty(document,'webkitVisibilityState',{configurable:true,get:function(){return 'visible';}});"
|
|
368
|
+
+ "Object.defineProperty(document,'webkitHidden',{configurable:true,get:function(){return false;}});"
|
|
369
|
+
+ "var s=function(e){e.stopImmediatePropagation();};"
|
|
370
|
+
+ "document.addEventListener('visibilitychange',s,true);"
|
|
371
|
+
+ "document.addEventListener('webkitvisibilitychange',s,true);"
|
|
372
|
+
+ "try{document.hasFocus=function(){return true;};}catch(e){}"
|
|
373
|
+
+ "}}catch(e){}"
|
|
374
|
+
+ "return '1';})()";
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Stamp identity marker + visibility spoof onto a specific tab.
|
|
378
|
+
// Identity-critical: a missed stamp loses the tab marker, so this is NOT best-effort —
|
|
379
|
+
// a daemon hiccup falls back to the reliable osascript subprocess.
|
|
380
|
+
async function _stampTab(idx) {
|
|
381
|
+
if (!idx || !_activeTabMarker) return;
|
|
382
|
+
const js = _buildStampJS(_activeTabMarker).replace(/"/g, '\\"');
|
|
383
|
+
const script = `tell application "Safari" to do JavaScript "${js}" in tab ${idx} of ${getTargetWindowRef()}`;
|
|
384
|
+
try {
|
|
385
|
+
await osascriptFast(script, { timeout: 5000 });
|
|
386
|
+
} catch {
|
|
387
|
+
// Daemon hiccup — retry via the reliable subprocess. Stamping is identity-critical
|
|
388
|
+
// (a missed stamp loses the tab marker), so it must not be silently best-effort.
|
|
389
|
+
await osascript(script, { timeout: 8000 }).catch(() => {});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
323
393
|
// Quick JS execution — exposed for smart-wait checks in index.js
|
|
324
394
|
export async function runJSQuick(js) { return runJS(js); }
|
|
325
395
|
|
|
@@ -339,7 +409,7 @@ export async function restoreFocusIfStolen(savedBundleId) {
|
|
|
339
409
|
}
|
|
340
410
|
|
|
341
411
|
function _helperHideSafari(timeout = 2000) {
|
|
342
|
-
return new Promise((resolve) => {
|
|
412
|
+
return _withHelperLock(() => new Promise((resolve) => {
|
|
343
413
|
if (!_helperProc || !_helperProc.stdin?.writable) { resolve(); return; }
|
|
344
414
|
let resolved = false;
|
|
345
415
|
const timer = setTimeout(() => { if (!resolved) { resolved = true; resolve(); } }, timeout);
|
|
@@ -352,11 +422,11 @@ function _helperHideSafari(timeout = 2000) {
|
|
|
352
422
|
_helperQueue.push(cb);
|
|
353
423
|
try { _helperProc.stdin.write('{"hideSafari":true}\n'); }
|
|
354
424
|
catch { clearTimeout(timer); resolve(); }
|
|
355
|
-
});
|
|
425
|
+
}));
|
|
356
426
|
}
|
|
357
427
|
|
|
358
428
|
function _helperActivateApp(bundleId, timeout = 2000) {
|
|
359
|
-
return new Promise((resolve) => {
|
|
429
|
+
return _withHelperLock(() => new Promise((resolve) => {
|
|
360
430
|
if (!_helperProc || !_helperProc.stdin?.writable) { resolve(); return; }
|
|
361
431
|
let resolved = false;
|
|
362
432
|
const timer = setTimeout(() => { if (!resolved) { resolved = true; resolve(); } }, timeout);
|
|
@@ -369,7 +439,7 @@ function _helperActivateApp(bundleId, timeout = 2000) {
|
|
|
369
439
|
_helperQueue.push(cb);
|
|
370
440
|
try { _helperProc.stdin.write(JSON.stringify({ activateApp: bundleId }) + '\n'); }
|
|
371
441
|
catch { clearTimeout(timer); resolve(); }
|
|
372
|
-
});
|
|
442
|
+
}));
|
|
373
443
|
}
|
|
374
444
|
|
|
375
445
|
export function setFocusGuard(active) { _focusGuardActive = active; }
|
|
@@ -382,40 +452,52 @@ export function setActiveTabURL(url) { _activeTabURL = url; _lastResolveTime = D
|
|
|
382
452
|
async function resolveActiveTab() {
|
|
383
453
|
if (!_activeTabURL && !_activeTabMarker) return _activeTabIndex;
|
|
384
454
|
|
|
385
|
-
// Strategy 1: window.
|
|
386
|
-
//
|
|
455
|
+
// Strategy 1: identity marker — window.name (survives ALL navigation: full loads,
|
|
456
|
+
// redirects, cross-origin) or window.__mcpTabMarker (survives SPA routing).
|
|
457
|
+
// One AppleScript call loops every tab internally: faster and far more reliable
|
|
458
|
+
// than N separate daemon round-trips (a daemon hiccup mid-scan used to silently
|
|
459
|
+
// mis-resolve to the user's tab).
|
|
387
460
|
if (_activeTabMarker) {
|
|
388
461
|
try {
|
|
389
462
|
const safeMarker = _activeTabMarker.replace(/'/g, "\\'");
|
|
390
|
-
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}
|
|
463
|
+
const check = `(function(){try{return (window.name==='${safeMarker}'||window.__mcpTabMarker==='${safeMarker}')?'1':'0'}catch(e){return '0'}})()`;
|
|
464
|
+
const scanScript = `tell application "Safari"
|
|
465
|
+
set w to ${getTargetWindowRef()}
|
|
466
|
+
set n to count of tabs of w
|
|
467
|
+
${_activeTabIndex ? `try
|
|
468
|
+
if n is greater than or equal to ${_activeTabIndex} then
|
|
469
|
+
if (do JavaScript "${check}" in tab ${_activeTabIndex} of w) is "1" then return ${_activeTabIndex}
|
|
470
|
+
end if
|
|
471
|
+
end try` : ''}
|
|
472
|
+
repeat with i from n to 1 by -1
|
|
473
|
+
try
|
|
474
|
+
if (do JavaScript "${check}" in tab i of w) is "1" then return i
|
|
475
|
+
end try
|
|
476
|
+
end repeat
|
|
477
|
+
return 0
|
|
478
|
+
end tell`;
|
|
479
|
+
// Fast daemon first; if it hiccups, retry once via reliable subprocess.
|
|
480
|
+
let res = await osascriptFast(scanScript).catch(() => null);
|
|
481
|
+
if (res === null) res = await osascript(scanScript).catch(() => null);
|
|
482
|
+
if (res !== null) {
|
|
483
|
+
const found = Number(String(res).trim());
|
|
484
|
+
if (found > 0) { _activeTabIndex = found; return found; }
|
|
485
|
+
// Reliable scan completed and the marker is on NO tab — it is genuinely
|
|
486
|
+
// gone (tab closed, or a site overwrote window.name). Drop it; the URL
|
|
487
|
+
// strategy below is the last chance before we fail safe.
|
|
488
|
+
_activeTabMarker = null;
|
|
412
489
|
}
|
|
413
|
-
//
|
|
414
|
-
_activeTabMarker = null;
|
|
490
|
+
// res === null → both attempts errored; can't verify — keep marker, fall through.
|
|
415
491
|
} catch { /* fall through to URL strategy */ }
|
|
416
492
|
}
|
|
417
493
|
|
|
418
|
-
if (!_activeTabURL)
|
|
494
|
+
if (!_activeTabURL) {
|
|
495
|
+
// No URL to resolve. If this session owns a tab but the marker is gone, we can
|
|
496
|
+
// no longer positively identify our tab — refuse to return a stale index that
|
|
497
|
+
// may now point at the user's tab. runJS will throw a clear re-anchor error.
|
|
498
|
+
if (_hasOwnedTab && !_activeTabMarker) { _activeTabIndex = null; }
|
|
499
|
+
return _activeTabIndex;
|
|
500
|
+
}
|
|
419
501
|
|
|
420
502
|
try {
|
|
421
503
|
const safeUrl = _activeTabURL.replace(/"/g, '\\"');
|
|
@@ -447,6 +529,13 @@ async function resolveActiveTab() {
|
|
|
447
529
|
const tabCount = Number(resultStr.split(':')[1]) || 1;
|
|
448
530
|
_lastTabCount = tabCount;
|
|
449
531
|
_activeTabURL = null;
|
|
532
|
+
if (_hasOwnedTab && !_activeTabMarker) {
|
|
533
|
+
// Identity fully lost: marker gone AND URL matches no tab. Returning the
|
|
534
|
+
// stale index could silently target the user's tab. Fail safe — drop it.
|
|
535
|
+
console.error('[Safari MCP] Tab identity lost (marker + URL unresolved) — clearing index to avoid targeting the user\'s tab');
|
|
536
|
+
_activeTabIndex = null;
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
450
539
|
if (_activeTabIndex && _activeTabIndex > tabCount) {
|
|
451
540
|
console.error(`[Safari MCP] Tab ghost proactive fix: index ${_activeTabIndex} > tabCount ${tabCount}, clamping to ${tabCount}`);
|
|
452
541
|
_activeTabIndex = tabCount;
|
|
@@ -538,7 +627,7 @@ async function osascriptFast(script, { timeout = 10000 } = {}) {
|
|
|
538
627
|
}
|
|
539
628
|
|
|
540
629
|
function _osascriptFastHelper(script, timeout) {
|
|
541
|
-
return new Promise((resolve, reject) => {
|
|
630
|
+
return _withHelperLock(() => new Promise((resolve, reject) => {
|
|
542
631
|
let resolved = false;
|
|
543
632
|
const timer = setTimeout(() => {
|
|
544
633
|
if (resolved) return;
|
|
@@ -586,7 +675,7 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
586
675
|
clearTimeout(timer);
|
|
587
676
|
reject(new Error("safari-helper write failed: " + writeErr.message));
|
|
588
677
|
}
|
|
589
|
-
});
|
|
678
|
+
}));
|
|
590
679
|
}
|
|
591
680
|
|
|
592
681
|
// ========== NATIVE CLICK VIA CGEVENT ==========
|
|
@@ -594,7 +683,7 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
594
683
|
// This produces isTrusted: true events — bypasses WAF protection (G2, etc.)
|
|
595
684
|
|
|
596
685
|
function _helperNativeClick(x, y, doubleClick = false, windowId = 0, timeout = 5000) {
|
|
597
|
-
return new Promise((resolve, reject) => {
|
|
686
|
+
return _withHelperLock(() => new Promise((resolve, reject) => {
|
|
598
687
|
if (!_helperProc) startHelper();
|
|
599
688
|
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
600
689
|
reject(new Error("safari-helper not available for native click"));
|
|
@@ -627,13 +716,13 @@ function _helperNativeClick(x, y, doubleClick = false, windowId = 0, timeout = 5
|
|
|
627
716
|
if (doubleClick) cmd.click.double = true;
|
|
628
717
|
if (windowId) cmd.click.windowId = windowId;
|
|
629
718
|
_helperProc.stdin.write(JSON.stringify(cmd) + "\n");
|
|
630
|
-
});
|
|
719
|
+
}));
|
|
631
720
|
}
|
|
632
721
|
|
|
633
722
|
// Sends a CGEvent hover command to the Swift helper daemon.
|
|
634
723
|
// Moves the cursor to (x, y), dwells to let tooltips render, optionally restores cursor.
|
|
635
724
|
function _helperNativeHover(x, y, windowId = 0, dwellMs = 500, restoreMouse = true, timeout = 10000) {
|
|
636
|
-
return new Promise((resolve, reject) => {
|
|
725
|
+
return _withHelperLock(() => new Promise((resolve, reject) => {
|
|
637
726
|
if (!_helperProc) startHelper();
|
|
638
727
|
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
639
728
|
reject(new Error("safari-helper not available for native hover"));
|
|
@@ -665,13 +754,13 @@ function _helperNativeHover(x, y, windowId = 0, dwellMs = 500, restoreMouse = tr
|
|
|
665
754
|
const cmd = { hover: { x, y, dwellMs, restoreMouse } };
|
|
666
755
|
if (windowId) cmd.hover.windowId = windowId;
|
|
667
756
|
_helperProc.stdin.write(JSON.stringify(cmd) + "\n");
|
|
668
|
-
});
|
|
757
|
+
}));
|
|
669
758
|
}
|
|
670
759
|
|
|
671
760
|
// Sends a CGEvent keyboard command to the Swift helper daemon.
|
|
672
761
|
// No focus stealing — sends key events directly to the target window via PID.
|
|
673
762
|
function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000) {
|
|
674
|
-
return new Promise((resolve, reject) => {
|
|
763
|
+
return _withHelperLock(() => new Promise((resolve, reject) => {
|
|
675
764
|
if (!_helperProc) startHelper();
|
|
676
765
|
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
677
766
|
reject(new Error("safari-helper not available for native keyboard"));
|
|
@@ -703,14 +792,14 @@ function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000
|
|
|
703
792
|
const cmd = { keyboard: { keyCode, flags } };
|
|
704
793
|
if (windowId) cmd.keyboard.windowId = windowId;
|
|
705
794
|
_helperProc.stdin.write(JSON.stringify(cmd) + "\n");
|
|
706
|
-
});
|
|
795
|
+
}));
|
|
707
796
|
}
|
|
708
797
|
|
|
709
798
|
// ========== NATIVE FOCUS OPERATIONS VIA DAEMON ==========
|
|
710
799
|
// Uses NSRunningApplication — ~0.1ms for get, ~1ms for activate (vs ~90ms AppleScript)
|
|
711
800
|
|
|
712
801
|
function _helperGetFrontApp(timeout = 2000) {
|
|
713
|
-
return new Promise((resolve) => {
|
|
802
|
+
return _withHelperLock(() => new Promise((resolve) => {
|
|
714
803
|
if (!_helperProc || !_helperProc.stdin?.writable) { resolve(null); return; }
|
|
715
804
|
let resolved = false;
|
|
716
805
|
const timer = setTimeout(() => { if (!resolved) { resolved = true; resolve(null); } }, timeout);
|
|
@@ -723,7 +812,7 @@ function _helperGetFrontApp(timeout = 2000) {
|
|
|
723
812
|
_helperQueue.push(cb);
|
|
724
813
|
try { _helperProc.stdin.write('{"getFrontApp":true}\n'); }
|
|
725
814
|
catch { clearTimeout(timer); resolve(null); }
|
|
726
|
-
});
|
|
815
|
+
}));
|
|
727
816
|
}
|
|
728
817
|
|
|
729
818
|
// Get Safari window bounds, toolbar height, and window ID for coordinate calculation
|
|
@@ -798,14 +887,16 @@ async function runJS(js, { tabIndex, timeout = 15000 } = {}) {
|
|
|
798
887
|
}
|
|
799
888
|
return await osascript(script, { timeout });
|
|
800
889
|
} catch (err) {
|
|
801
|
-
// Tab ghost recovery: "Can't get tab X" → re-resolve and retry once
|
|
890
|
+
// Tab ghost recovery: "Can't get tab X" → re-resolve and retry once.
|
|
891
|
+
// Match both apostrophes — Safari emits a typographic apostrophe (U+2019),
|
|
892
|
+
// so a plain "Can't" includes() check silently missed every ghost error.
|
|
802
893
|
const msg = err.message || '';
|
|
803
|
-
if (idx && (
|
|
894
|
+
if (idx && (/[Cc]an.t get tab/.test(msg) || msg.includes("-1728"))) {
|
|
804
895
|
console.error(`[Safari MCP] Tab ghost detected (tab ${idx}), re-resolving...`);
|
|
805
896
|
_lastResolveTime = 0; // Force re-resolve
|
|
806
897
|
_lastTabCount = null; // Invalidate tab count cache
|
|
807
898
|
_activeTabIndex = null;
|
|
808
|
-
if (_activeTabURL) {
|
|
899
|
+
if (_activeTabURL || _activeTabMarker) {
|
|
809
900
|
const newIdx = await resolveActiveTab();
|
|
810
901
|
if (newIdx && newIdx !== idx) {
|
|
811
902
|
console.error(`[Safari MCP] Tab ghost resolved: ${idx} → ${newIdx}`);
|
|
@@ -904,11 +995,22 @@ export async function navigate(url) {
|
|
|
904
995
|
// Resolve tab by URL first (in case indices shifted)
|
|
905
996
|
if (_activeTabURL) await resolveActiveTab();
|
|
906
997
|
_assertNotFallingBackToUserTab('navigate');
|
|
907
|
-
|
|
908
|
-
|
|
998
|
+
// Capture our tab index ONCE. Every internal runJS below targets it explicitly:
|
|
999
|
+
// re-resolving mid-navigation is unsafe — a cross-origin load transiently wipes
|
|
1000
|
+
// window.name (a browser privacy feature) and the tracked URL is stale until the
|
|
1001
|
+
// new page settles, so resolveActiveTab() would conclude "identity lost" and drop
|
|
1002
|
+
// the very tab we are navigating.
|
|
1003
|
+
const navIndex = _activeTabIndex;
|
|
1004
|
+
const navTarget = navIndex
|
|
1005
|
+
? `tab ${navIndex} of ${getTargetWindowRef()}`
|
|
909
1006
|
: getFallbackTarget();
|
|
910
1007
|
// Step 0: Suppress onbeforeunload dialogs (prevents blocking navigation)
|
|
911
|
-
await runJS("window.onbeforeunload=null", { timeout: 2000 }).catch(() => {});
|
|
1008
|
+
await runJS("window.onbeforeunload=null", { tabIndex: navIndex, timeout: 2000 }).catch(() => {});
|
|
1009
|
+
|
|
1010
|
+
// Pre-navigation URL, captured before Step 1. Lets the post-load check below
|
|
1011
|
+
// detect a `set URL` that silently no-ops (a cold or crashed Swift daemon):
|
|
1012
|
+
// the readyState poll would otherwise just see the OLD page still loaded.
|
|
1013
|
+
const preNavUrl = await runJS('location.href', { tabIndex: navIndex, timeout: 3000 }).catch(() => '');
|
|
912
1014
|
|
|
913
1015
|
// Step 1: Set URL via fast daemon (~5ms) — don't block daemon with polling
|
|
914
1016
|
await osascriptFast(
|
|
@@ -922,11 +1024,11 @@ export async function navigate(url) {
|
|
|
922
1024
|
for (let poll = 0; poll < 80; poll++) {
|
|
923
1025
|
await new Promise(r => setTimeout(r, 200));
|
|
924
1026
|
try {
|
|
925
|
-
const state = await runJS('document.readyState', { timeout: 5000 });
|
|
1027
|
+
const state = await runJS('document.readyState', { tabIndex: navIndex, timeout: 5000 });
|
|
926
1028
|
if (state === 'complete' || state === 'interactive') {
|
|
927
1029
|
result = await runJS(
|
|
928
1030
|
`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')})`,
|
|
929
|
-
{ timeout: 5000 }
|
|
1031
|
+
{ tabIndex: navIndex, timeout: 5000 }
|
|
930
1032
|
);
|
|
931
1033
|
if (state === 'complete') break;
|
|
932
1034
|
// interactive = DOM ready but resources still loading — wait a bit more
|
|
@@ -935,6 +1037,34 @@ export async function navigate(url) {
|
|
|
935
1037
|
} catch { /* page still loading, retry */ }
|
|
936
1038
|
}
|
|
937
1039
|
|
|
1040
|
+
// If the fast `set URL` above silently no-opped (cold/crashed daemon), the poll
|
|
1041
|
+
// just saw the OLD page already loaded. Detect that — the URL never left
|
|
1042
|
+
// preNavUrl — and retry once through the daemon-independent osascript path.
|
|
1043
|
+
let landedUrl = '';
|
|
1044
|
+
try { landedUrl = JSON.parse(result).url || ''; } catch { /* result not JSON */ }
|
|
1045
|
+
if (preNavUrl && preNavUrl !== targetUrl && (!landedUrl || landedUrl === preNavUrl)) {
|
|
1046
|
+
console.error('[Safari MCP] navigate: fast set-URL did not take effect — retrying via osascript subprocess');
|
|
1047
|
+
await osascript(`tell application "Safari" to set URL of ${navTarget} to "${safeUrl}"`, { timeout: 12000 });
|
|
1048
|
+
for (let rpoll = 0; rpoll < 80; rpoll++) {
|
|
1049
|
+
await new Promise(res => setTimeout(res, 200));
|
|
1050
|
+
try {
|
|
1051
|
+
const state = await runJS('document.readyState', { tabIndex: navIndex, timeout: 5000 });
|
|
1052
|
+
if (state === 'complete' || state === 'interactive') {
|
|
1053
|
+
result = await runJS('JSON.stringify({title:document.title,url:location.href})', { tabIndex: navIndex, timeout: 5000 });
|
|
1054
|
+
if (state === 'complete') break;
|
|
1055
|
+
if (rpoll > 10) break;
|
|
1056
|
+
}
|
|
1057
|
+
} catch { /* page still loading, retry */ }
|
|
1058
|
+
}
|
|
1059
|
+
// Still stuck on the pre-navigation URL → the navigation genuinely failed.
|
|
1060
|
+
// Throw rather than returning the stale page as a successful navigation.
|
|
1061
|
+
let retryUrl = '';
|
|
1062
|
+
try { retryUrl = JSON.parse(result).url || ''; } catch { /* result not JSON */ }
|
|
1063
|
+
if (retryUrl && retryUrl === preNavUrl) {
|
|
1064
|
+
throw new Error(`navigate failed: page stayed on ${preNavUrl} — Safari "set URL" to ${targetUrl} had no effect (Safari automation/daemon issue, retry exhausted)`);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
938
1068
|
// Inject click helpers in background (non-blocking, for subsequent clicks)
|
|
939
1069
|
_injectHelpersfast().catch(() => {});
|
|
940
1070
|
|
|
@@ -943,20 +1073,17 @@ export async function navigate(url) {
|
|
|
943
1073
|
const parsed = JSON.parse(result);
|
|
944
1074
|
if (parsed.blocked && url.startsWith("http://")) {
|
|
945
1075
|
const httpUrl = url.replace(/"/g, '\\"');
|
|
946
|
-
const navTarget = _activeTabIndex
|
|
947
|
-
? `tab ${_activeTabIndex} of ${getTargetWindowRef()}`
|
|
948
|
-
: `current tab of ${getTargetWindowRef()}`;
|
|
949
1076
|
await osascriptFast(
|
|
950
1077
|
`tell application "Safari" to set URL of ${navTarget} to "${httpUrl}"`
|
|
951
1078
|
);
|
|
952
|
-
// Poll readyState for HTTP retry (
|
|
1079
|
+
// Poll readyState for HTTP retry — target navIndex explicitly (no re-resolve)
|
|
953
1080
|
let retryResult = '{}';
|
|
954
1081
|
for (let rp = 0; rp < 40; rp++) {
|
|
955
1082
|
await new Promise(r => setTimeout(r, 300));
|
|
956
1083
|
try {
|
|
957
|
-
const rs = await runJS('document.readyState', { timeout: 5000 });
|
|
1084
|
+
const rs = await runJS('document.readyState', { tabIndex: navIndex, timeout: 5000 });
|
|
958
1085
|
if (rs === 'complete' || rs === 'interactive') {
|
|
959
|
-
retryResult = await runJS('JSON.stringify({title:document.title,url:location.href})', { timeout: 5000 });
|
|
1086
|
+
retryResult = await runJS('JSON.stringify({title:document.title,url:location.href})', { tabIndex: navIndex, timeout: 5000 });
|
|
960
1087
|
if (rs === 'complete') break;
|
|
961
1088
|
if (rp > 8) break;
|
|
962
1089
|
}
|
|
@@ -968,7 +1095,9 @@ export async function navigate(url) {
|
|
|
968
1095
|
const retryParsed = JSON.parse(retry);
|
|
969
1096
|
if (retryParsed.url) _activeTabURL = retryParsed.url;
|
|
970
1097
|
} catch {}
|
|
1098
|
+
_activeTabIndex = navIndex;
|
|
971
1099
|
_lastResolveTime = Date.now();
|
|
1100
|
+
await _stampTab(navIndex);
|
|
972
1101
|
return retry;
|
|
973
1102
|
}
|
|
974
1103
|
} catch (_) {}
|
|
@@ -980,18 +1109,14 @@ export async function navigate(url) {
|
|
|
980
1109
|
} catch {
|
|
981
1110
|
_activeTabURL = targetUrl;
|
|
982
1111
|
}
|
|
1112
|
+
_activeTabIndex = navIndex;
|
|
983
1113
|
_lastResolveTime = Date.now();
|
|
984
1114
|
|
|
985
|
-
// Re-
|
|
986
|
-
// window.
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
|
|
990
|
-
const safeMarker = _activeTabMarker.replace(/'/g, "\\'");
|
|
991
|
-
await osascriptFast(
|
|
992
|
-
`tell application "Safari" to do JavaScript "window.__mcpTabMarker='${safeMarker}'" in tab ${_activeTabIndex} of ${getTargetWindowRef()}`
|
|
993
|
-
).catch(() => {});
|
|
994
|
-
}
|
|
1115
|
+
// Re-stamp identity marker + visibility spoof onto the settled page. A cross-origin
|
|
1116
|
+
// navigation clears window.name, and any full load wipes __mcpTabMarker and the
|
|
1117
|
+
// visibility spoof — re-stamping keeps resolveActiveTab able to find this tab and
|
|
1118
|
+
// keeps the page rendering even while backgrounded.
|
|
1119
|
+
await _stampTab(navIndex);
|
|
995
1120
|
|
|
996
1121
|
return result;
|
|
997
1122
|
}
|
|
@@ -1025,6 +1150,8 @@ export async function reload(hardReload = false) {
|
|
|
1025
1150
|
{ timeout: 10000 }
|
|
1026
1151
|
);
|
|
1027
1152
|
try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
|
|
1153
|
+
// A reload destroys the JS context — re-stamp marker + visibility spoof.
|
|
1154
|
+
await _stampTab(_activeTabIndex);
|
|
1028
1155
|
return result;
|
|
1029
1156
|
}
|
|
1030
1157
|
|
|
@@ -1042,8 +1169,29 @@ export async function readPage({ selector, maxLength = 50000 } = {}) {
|
|
|
1042
1169
|
})()`
|
|
1043
1170
|
);
|
|
1044
1171
|
}
|
|
1172
|
+
// innerText needs a built render tree, which Safari may skip for a tab that has
|
|
1173
|
+
// never been foregrounded — it can come back near-empty even though the DOM is
|
|
1174
|
+
// fully present. Detect that and fall back to a layout-independent TreeWalker
|
|
1175
|
+
// text extraction so reads work on a background tab without ever taking focus.
|
|
1045
1176
|
return runJS(
|
|
1046
|
-
`
|
|
1177
|
+
`(function(){
|
|
1178
|
+
var max=${Number(maxLength)};
|
|
1179
|
+
var t=document.body.innerText||'';
|
|
1180
|
+
if(t.replace(/\\s/g,'').length<20){
|
|
1181
|
+
var parts=[];
|
|
1182
|
+
var w=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT,null);
|
|
1183
|
+
var n;
|
|
1184
|
+
while(n=w.nextNode()){
|
|
1185
|
+
var p=n.parentElement;if(!p)continue;
|
|
1186
|
+
var tag=p.tagName;
|
|
1187
|
+
if(tag==='SCRIPT'||tag==='STYLE'||tag==='NOSCRIPT'||tag==='TEMPLATE')continue;
|
|
1188
|
+
var s=(n.textContent||'').replace(/\\s+/g,' ').trim();
|
|
1189
|
+
if(s)parts.push(s);
|
|
1190
|
+
}
|
|
1191
|
+
t=parts.join('\\n');
|
|
1192
|
+
}
|
|
1193
|
+
return JSON.stringify({title:document.title,url:location.href,text:t.substring(0,max)});
|
|
1194
|
+
})()`
|
|
1047
1195
|
);
|
|
1048
1196
|
}
|
|
1049
1197
|
|
|
@@ -1136,25 +1284,33 @@ export async function click({ selector, text, x, y, ref }) {
|
|
|
1136
1284
|
// the native popup is dismissed → the JSC eval times out and the tool returns an
|
|
1137
1285
|
// error after seconds of hang. Detect early and return a clear directive instead.
|
|
1138
1286
|
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('|');}`;
|
|
1287
|
+
// Page fingerprint, captured synchronously on either side of the click. dispatchEvent
|
|
1288
|
+
// is synchronous, so anything that differs between before/after is a direct effect of
|
|
1289
|
+
// the click's own handlers — there is no window for ambient ad/lazy-load noise. Catches
|
|
1290
|
+
// DOM add/remove, class/attribute/text mutations, navigation, and focus changes.
|
|
1291
|
+
const FP = `(location.href+'|'+document.querySelectorAll('*').length+'|'+document.documentElement.innerHTML.length+'|'+(document.activeElement?(document.activeElement.tagName+'#'+(document.activeElement.id||'')):''))`;
|
|
1292
|
+
// Mirrors mcpClickWithReact (resolve → React-fiber click → synthetic fallback) but also
|
|
1293
|
+
// reports reactFired and whether the page observably changed — so click() can detect a
|
|
1294
|
+
// synthetic click that was silently ignored (isTrusted-gated handlers) and escalate.
|
|
1295
|
+
const coreJS = (finderExpr, notFound) =>
|
|
1296
|
+
`(function(){var el=${finderExpr};if(!el)return JSON.stringify({err:${JSON.stringify(notFound)}});${selectGuard}` +
|
|
1297
|
+
`var target=mcpResolveTarget(el)||el;var before=${FP};` +
|
|
1298
|
+
`var reactFired=false;try{reactFired=mcpReactClick(target);}catch(e){}` +
|
|
1299
|
+
`var anchor=target&&target.closest?target.closest('a[href]'):null;` +
|
|
1300
|
+
`if(!reactFired||anchor)mcpClick(target);` +
|
|
1301
|
+
`return JSON.stringify({tag:target.tagName,text:((target.innerText||target.textContent)||'').trim().substring(0,50),reactFired:!!reactFired,changed:before!==(${FP}),fp:before});})()`;
|
|
1302
|
+
|
|
1139
1303
|
let result;
|
|
1140
1304
|
if (ref) {
|
|
1141
|
-
result = await clickWithRetry(
|
|
1142
|
-
`(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)+'"'):'');})()`
|
|
1143
|
-
);
|
|
1305
|
+
result = await clickWithRetry(coreJS(`mcpFindRef('${ref}')`, `Element not found: ref=${ref}`));
|
|
1144
1306
|
} else if (selector) {
|
|
1145
1307
|
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1146
|
-
result = await clickWithRetry(
|
|
1147
|
-
`(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)+'"'):'');})()`
|
|
1148
|
-
);
|
|
1308
|
+
result = await clickWithRetry(coreJS(`mcpQuerySelectorDeep('${sel}')`, `Element not found: ${selector}`));
|
|
1149
1309
|
} else if (text) {
|
|
1150
1310
|
const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1151
|
-
result = await clickWithRetry(
|
|
1152
|
-
`(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)+'"';})()`
|
|
1153
|
-
);
|
|
1311
|
+
result = await clickWithRetry(coreJS(`mcpFindText('${safeText}',true)||mcpFindText('${safeText}',false)`, `Element not found with text: ${text}`));
|
|
1154
1312
|
} else if (x !== undefined && y !== undefined) {
|
|
1155
|
-
result = await clickWithRetry(
|
|
1156
|
-
`(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)})';})()`
|
|
1157
|
-
);
|
|
1313
|
+
result = await clickWithRetry(coreJS(`mcpElementFromPoint(${Number(x)},${Number(y)})`, `No element at (${Number(x)},${Number(y)})`));
|
|
1158
1314
|
} else {
|
|
1159
1315
|
throw new Error("click requires selector, text, or x/y coordinates");
|
|
1160
1316
|
}
|
|
@@ -1163,7 +1319,32 @@ export async function click({ selector, text, x, y, ref }) {
|
|
|
1163
1319
|
const detail = result.substring('__SELECT_GUARD__:'.length);
|
|
1164
1320
|
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.`);
|
|
1165
1321
|
}
|
|
1166
|
-
|
|
1322
|
+
|
|
1323
|
+
// Structured result from coreJS. Fall back to the raw string for forward-compat.
|
|
1324
|
+
let info;
|
|
1325
|
+
try { info = JSON.parse(result); } catch { return result; }
|
|
1326
|
+
if (info.err) return info.err;
|
|
1327
|
+
const label = info.tag + (info.text ? ` "${info.text}"` : '');
|
|
1328
|
+
|
|
1329
|
+
// A React handler fired, or the page observably changed → the click landed.
|
|
1330
|
+
if (info.reactFired || info.changed) return 'Clicked: ' + label;
|
|
1331
|
+
|
|
1332
|
+
// No React handler and no synchronous effect. Give an async handler a brief moment,
|
|
1333
|
+
// then re-check before deciding the click was truly ignored.
|
|
1334
|
+
await new Promise(r => setTimeout(r, 320));
|
|
1335
|
+
const afterFp = await runJS(FP).catch(() => null);
|
|
1336
|
+
if (afterFp != null && afterFp !== info.fp) return 'Clicked: ' + label;
|
|
1337
|
+
|
|
1338
|
+
// Synthetic events were ignored — the handler gates on event.isTrusted (Clutch, G2,
|
|
1339
|
+
// Cloudflare-class sites). Escalate to a real OS-level CGEvent click (isTrusted:true).
|
|
1340
|
+
// If a handler genuinely has no observable effect (pure analytics, slow >320ms async),
|
|
1341
|
+
// this re-fires it once via the native click — an accepted, low-cost trade-off.
|
|
1342
|
+
try {
|
|
1343
|
+
await nativeClick({ selector, text, x, y, ref });
|
|
1344
|
+
return 'Clicked (native fallback — synthetic click had no effect): ' + label;
|
|
1345
|
+
} catch (e) {
|
|
1346
|
+
return 'Clicked: ' + label + ' — ⚠️ synthetic click produced no detectable effect and the native fallback is unavailable (' + ((e && e.message) || e) + '). Retry with safari_native_click.';
|
|
1347
|
+
}
|
|
1167
1348
|
}
|
|
1168
1349
|
|
|
1169
1350
|
export async function doubleClick({ selector, x, y, ref }) {
|
|
@@ -2724,22 +2905,27 @@ export async function newTab(url = "") {
|
|
|
2724
2905
|
_lastNewTabAt = Date.now(); // (legacy — grace window superseded by _hasOwnedTab)
|
|
2725
2906
|
_hasOwnedTab = true; // Permanently true: this session has opened its own tab,
|
|
2726
2907
|
// so write ops must NEVER fall back to the user's current tab.
|
|
2727
|
-
// Set bulletproof tab marker —
|
|
2908
|
+
// Set bulletproof tab marker — stamped onto the tab by _stampTab() after load.
|
|
2909
|
+
// window.name survives ALL navigation (full loads, redirects, cross-origin);
|
|
2910
|
+
// __mcpTabMarker survives SPA routing.
|
|
2728
2911
|
_activeTabMarker = `MCP_${SESSION_ID}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
// Wait for page load if URL given
|
|
2912
|
+
// Wait for page load if URL given. Poll readyState from the Node side — Safari's
|
|
2913
|
+
// `do JavaScript` does NOT await async IIFEs, so an in-page wait loop returns
|
|
2914
|
+
// immediately without waiting. Stamping before the page settles loses the marker.
|
|
2733
2915
|
if (url) {
|
|
2734
|
-
|
|
2735
|
-
await
|
|
2736
|
-
|
|
2737
|
-
{ tabIndex: _activeTabIndex, timeout:
|
|
2738
|
-
|
|
2739
|
-
|
|
2916
|
+
for (let i = 0; i < 50; i++) {
|
|
2917
|
+
await new Promise(r => setTimeout(r, 200));
|
|
2918
|
+
try {
|
|
2919
|
+
const st = await runJS('document.readyState', { tabIndex: _activeTabIndex, timeout: 5000 });
|
|
2920
|
+
const href = await runJS('location.href', { tabIndex: _activeTabIndex, timeout: 5000 });
|
|
2921
|
+
if ((st === 'complete' || st === 'interactive') && href && href !== 'about:blank') break;
|
|
2922
|
+
} catch { /* tab still loading */ }
|
|
2923
|
+
}
|
|
2740
2924
|
} else {
|
|
2741
2925
|
await new Promise(r => setTimeout(r, 200));
|
|
2742
2926
|
}
|
|
2927
|
+
// Stamp identity marker + visibility spoof onto the loaded document.
|
|
2928
|
+
await _stampTab(_activeTabIndex);
|
|
2743
2929
|
const info = await runJS(`JSON.stringify({title:document.title,url:location.href,tabIndex:${_activeTabIndex}})`, { tabIndex: _activeTabIndex });
|
|
2744
2930
|
try {
|
|
2745
2931
|
const parsed = JSON.parse(info);
|
|
@@ -2769,6 +2955,13 @@ export async function closeTab() {
|
|
|
2769
2955
|
export async function switchTab(index) {
|
|
2770
2956
|
const idx = Number(index);
|
|
2771
2957
|
_activeTabIndex = idx;
|
|
2958
|
+
// Claiming this tab: stamp it with a FRESH identity marker so resolveActiveTab can
|
|
2959
|
+
// re-find it after the user shifts tab indices. A fresh marker (not a reused one)
|
|
2960
|
+
// ensures a previously-claimed tab — which still carries the old marker string —
|
|
2961
|
+
// is never mistaken for this one.
|
|
2962
|
+
_activeTabMarker = `MCP_${SESSION_ID}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
2963
|
+
_hasOwnedTab = true;
|
|
2964
|
+
await _stampTab(idx);
|
|
2772
2965
|
// Do NOT visually switch the tab — it brings the Safari window to foreground
|
|
2773
2966
|
// and interrupts the user. Visual switching only happens in screenshot() when needed.
|
|
2774
2967
|
// AppleScript `do JavaScript in tab N` works on background tabs without switching.
|
|
@@ -2782,89 +2975,145 @@ export async function switchTab(index) {
|
|
|
2782
2975
|
const parsed = JSON.parse(result);
|
|
2783
2976
|
_activeTabURL = parsed.url || null;
|
|
2784
2977
|
} catch {}
|
|
2978
|
+
_lastResolveTime = Date.now();
|
|
2785
2979
|
return result;
|
|
2786
2980
|
}
|
|
2787
2981
|
|
|
2788
2982
|
// ========== WAIT ==========
|
|
2789
2983
|
|
|
2790
2984
|
export async function waitFor({ selector, text, timeout = 10000 }) {
|
|
2791
|
-
//
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
const
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
${safeSelector ? `if (document.querySelector('${safeSelector}')) return 'Found: ${safeSelector}';` : ""}
|
|
2799
|
-
${safeText ? `if (document.body && document.body.innerText.includes('${safeText}')) return 'Found text: ${safeText}';` : ""}
|
|
2800
|
-
await new Promise(function(r){setTimeout(r, 50)});
|
|
2801
|
-
}
|
|
2802
|
-
return 'TIMEOUT';
|
|
2803
|
-
})()`,
|
|
2804
|
-
{ timeout: timeout + 2000 }
|
|
2805
|
-
);
|
|
2806
|
-
if (result === "TIMEOUT") {
|
|
2807
|
-
throw new Error(`Timeout waiting for ${selector || text} (${timeout}ms)`);
|
|
2985
|
+
// `do JavaScript` can't await, so an in-page async wait loop returns immediately
|
|
2986
|
+
// (handing back an unsettled promise) instead of waiting. The wait loop runs on
|
|
2987
|
+
// the Node side: each tick re-evaluates one SYNCHRONOUS check against the page.
|
|
2988
|
+
const safeSelector = selector ? selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'") : "";
|
|
2989
|
+
const safeText = text ? text.replace(/\\/g, "\\\\").replace(/'/g, "\\'") : "";
|
|
2990
|
+
if (!safeSelector && !safeText) {
|
|
2991
|
+
throw new Error("waitFor requires selector or text");
|
|
2808
2992
|
}
|
|
2809
|
-
|
|
2993
|
+
const checkJs = `(function(){` +
|
|
2994
|
+
(safeSelector ? `if(document.querySelector('${safeSelector}'))return 'Found: ${safeSelector}';` : "") +
|
|
2995
|
+
(safeText ? `if(document.body&&document.body.innerText.includes('${safeText}'))return 'Found text: ${safeText}';` : "") +
|
|
2996
|
+
`return '';})()`;
|
|
2997
|
+
const deadline = Date.now() + Number(timeout);
|
|
2998
|
+
while (Date.now() < deadline) {
|
|
2999
|
+
const hit = await runJS(checkJs, { timeout: 5000 }).catch(() => "");
|
|
3000
|
+
if (hit) return hit;
|
|
3001
|
+
await new Promise(r => setTimeout(r, 100));
|
|
3002
|
+
}
|
|
3003
|
+
throw new Error(`Timeout waiting for ${selector || text} (${timeout}ms)`);
|
|
2810
3004
|
}
|
|
2811
3005
|
|
|
2812
3006
|
// ========== EVALUATE ==========
|
|
2813
3007
|
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
var v = await(async function(){${js}})();
|
|
2825
|
-
return JSON.stringify({v: v !== undefined && v !== null ? (typeof v === 'object' ? JSON.stringify(v) : String(v)) : null});
|
|
2826
|
-
}catch(e){
|
|
2827
|
-
return JSON.stringify({e: e.message});
|
|
2828
|
-
}
|
|
2829
|
-
})()`,
|
|
2830
|
-
{ timeout: 35000 }
|
|
2831
|
-
);
|
|
2832
|
-
try {
|
|
2833
|
-
const parsed = JSON.parse(result);
|
|
2834
|
-
if (parsed.e) return `Error: ${parsed.e}`;
|
|
2835
|
-
return parsed.v !== undefined && parsed.v !== null ? String(parsed.v) : '(undefined)';
|
|
2836
|
-
} catch { return result || '(undefined)'; }
|
|
2837
|
-
}
|
|
2838
|
-
|
|
3008
|
+
// Build the expression to evaluate from a user script. Pure (no Safari calls) so
|
|
3009
|
+
// it can be unit-tested directly — see scripts/test-evaluate-wrapping.js.
|
|
3010
|
+
export function _buildEvalExpr(js) {
|
|
3011
|
+
// Async iff the *result* is a promise to wait on. `fetch(` alone is NOT async —
|
|
3012
|
+
// an un-awaited fetch is fire-and-forget; only await / .then() / a leading
|
|
3013
|
+
// `async` make the result thenable.
|
|
3014
|
+
const isAsync = /\bawait\b/.test(js) || /\.then\s*\(/.test(js) || /^async\b/.test(js);
|
|
3015
|
+
// Statement keywords: a script starting with one is never a bare expression,
|
|
3016
|
+
// and `return (<keyword> ...)` would be a syntax error.
|
|
3017
|
+
const NON_EXPR = /^(var|let|const|return|if|for|while|switch|try|do|throw)\b/;
|
|
2839
3018
|
const isIIFE = /^\((?:async\s+)?function/.test(js) || /^\((?:async\s+)?\(/.test(js);
|
|
2840
|
-
const isSimpleExpression = !js.includes(';') && !js.includes('\n') && !
|
|
2841
|
-
|
|
2842
|
-
|
|
3019
|
+
const isSimpleExpression = !js.includes(';') && !js.includes('\n') && !NON_EXPR.test(js);
|
|
3020
|
+
|
|
3021
|
+
let expr;
|
|
3022
|
+
if (isIIFE) {
|
|
3023
|
+
expr = js;
|
|
3024
|
+
} else if (isSimpleExpression) {
|
|
3025
|
+
// A bare expression — usable as-is for sync; async needs an awaiting wrapper.
|
|
3026
|
+
expr = isAsync ? `(async function(){return (${js})})()` : js;
|
|
3027
|
+
} else {
|
|
3028
|
+
// Multi-statement: prepend `return` to the last value-producing line when it
|
|
3029
|
+
// can safely take one; otherwise fall back to indirect-eval completion value.
|
|
2843
3030
|
const lines = js.split('\n');
|
|
2844
3031
|
let addedReturn = false;
|
|
2845
3032
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
2846
3033
|
const line = lines[i].trim();
|
|
2847
3034
|
if (!line || line.startsWith('//')) continue;
|
|
2848
|
-
if (line.startsWith('return ') || line.startsWith('return;')
|
|
2849
|
-
|
|
3035
|
+
if (line.startsWith('return ') || line.startsWith('return;') || line === 'return') {
|
|
3036
|
+
addedReturn = true; break;
|
|
3037
|
+
}
|
|
3038
|
+
// A block-closer (`}`, `})`, `})()`), a block body (ends with `}`) or a
|
|
3039
|
+
// statement keyword can't take a prepended `return`.
|
|
3040
|
+
if (line.startsWith('}') || line.endsWith('}') || NON_EXPR.test(line)) break;
|
|
2850
3041
|
lines[i] = 'return ' + lines[i];
|
|
2851
3042
|
addedReturn = true;
|
|
2852
3043
|
break;
|
|
2853
3044
|
}
|
|
2854
3045
|
if (addedReturn) {
|
|
2855
|
-
|
|
3046
|
+
expr = `(${isAsync ? 'async function' : 'function'}(){${lines.join('\n')}})()`;
|
|
3047
|
+
} else if (isAsync) {
|
|
3048
|
+
// No safe return slot — run the body in an async IIFE (value may be undefined).
|
|
3049
|
+
expr = `(async function(){${js}})()`;
|
|
2856
3050
|
} else {
|
|
2857
|
-
//
|
|
2858
|
-
//
|
|
2859
|
-
// eslint-disable-next-line no-eval
|
|
3051
|
+
// Indirect eval yields the completion value of an arbitrary statement list;
|
|
3052
|
+
// the catch re-runs the body plainly when a strict CSP blocks eval.
|
|
2860
3053
|
const escaped = js.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
2861
|
-
|
|
3054
|
+
expr = "(function(){ try { return (0,eval)('" + escaped + "') } catch(_e) { " + js.replace(/\n/g, ' ') + " } })()";
|
|
2862
3055
|
}
|
|
2863
3056
|
}
|
|
3057
|
+
return { isAsync, expr };
|
|
3058
|
+
}
|
|
2864
3059
|
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
3060
|
+
// Async scripts can't be awaited through AppleScript `do JavaScript` — it returns
|
|
3061
|
+
// the moment the synchronous portion finishes, handing back an unsettled Promise.
|
|
3062
|
+
// So the work is started fire-and-forget into a page global, then that global is
|
|
3063
|
+
// polled synchronously from the Node side (the same pattern navigate() uses).
|
|
3064
|
+
async function _evaluateAsync(expr) {
|
|
3065
|
+
// Token is identifier-safe (base36 → [0-9a-z], `_` prefix) so `window.<token>`
|
|
3066
|
+
// dot access needs no quoting/escaping through the AppleScript bridge.
|
|
3067
|
+
const token = '__mcpEval_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
3068
|
+
const slot = 'window.' + token;
|
|
3069
|
+
// A SYNC outer function installs the globals, starts the async work (NOT awaited
|
|
3070
|
+
// here — `do JavaScript` would not await it anyway) and returns immediately.
|
|
3071
|
+
const kickoff =
|
|
3072
|
+
`(function(){${slot}={done:false};(async function(){try{` +
|
|
3073
|
+
`var __v=await (${expr});` +
|
|
3074
|
+
`${slot}.val=(__v===undefined||__v===null)?null:(typeof __v==='object'?JSON.stringify(__v):String(__v));` +
|
|
3075
|
+
`}catch(__e){${slot}.err=(__e&&__e.message)||String(__e);}` +
|
|
3076
|
+
`finally{${slot}.done=true;}})();return 'ok';})()`;
|
|
3077
|
+
const started = await runJS(kickoff, { timeout: 10000 });
|
|
3078
|
+
if (started !== 'ok') {
|
|
3079
|
+
return typeof started === 'string' && started ? started : '(no return value)';
|
|
3080
|
+
}
|
|
3081
|
+
// Poll the result global until the async work settles (35s budget).
|
|
3082
|
+
const pollJs =
|
|
3083
|
+
`(function(){var s=${slot};if(!s)return '__MCP_GONE__';` +
|
|
3084
|
+
`if(!s.done)return '';return JSON.stringify({v:s.val,e:s.err});})()`;
|
|
3085
|
+
const deadline = Date.now() + 35000;
|
|
3086
|
+
let raw = '';
|
|
3087
|
+
while (Date.now() < deadline) {
|
|
3088
|
+
await new Promise(r => setTimeout(r, 120));
|
|
3089
|
+
raw = await runJS(pollJs, { timeout: 5000 }).catch(() => '');
|
|
3090
|
+
if (raw === '__MCP_GONE__') {
|
|
3091
|
+
return '(no return value — page navigated away during async evaluation)';
|
|
3092
|
+
}
|
|
3093
|
+
if (raw) break;
|
|
3094
|
+
}
|
|
3095
|
+
// Best-effort cleanup of the page global.
|
|
3096
|
+
runJS(`(function(){try{delete ${slot};}catch(__e){${slot}=undefined;}return '';})()`).catch(() => {});
|
|
3097
|
+
if (!raw) {
|
|
3098
|
+
throw new Error('safari_evaluate: async script did not settle within 35s');
|
|
3099
|
+
}
|
|
3100
|
+
try {
|
|
3101
|
+
const parsed = JSON.parse(raw);
|
|
3102
|
+
if (parsed.e) return 'Error: ' + parsed.e;
|
|
3103
|
+
return parsed.v !== undefined && parsed.v !== null ? String(parsed.v) : '(no return value)';
|
|
3104
|
+
} catch {
|
|
3105
|
+
return raw;
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
export async function evaluate({ script }) {
|
|
3110
|
+
const js = (script || '').trim();
|
|
3111
|
+
if (!js) return '(no return value)';
|
|
3112
|
+
const { isAsync, expr } = _buildEvalExpr(js);
|
|
3113
|
+
if (isAsync) return _evaluateAsync(expr);
|
|
3114
|
+
// Sync: a single `do JavaScript` over one expression. `do JavaScript` only
|
|
3115
|
+
// returns the value of a single expression, so the whole script is one IIFE.
|
|
3116
|
+
const wrappedJs = `(function(){ try { return (${expr}); } catch(__mcpErr) { return 'Error: ' + __mcpErr.message; } })()`;
|
|
2868
3117
|
if (process.env.MCP_DEBUG) console.error('[evaluate] wrapped:', wrappedJs.substring(0, 300));
|
|
2869
3118
|
const result = await runJS(wrappedJs);
|
|
2870
3119
|
if (result === null || result === undefined || result === '') {
|