safari-mcp 2.10.1 → 2.10.3
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 +70 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.3",
|
|
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
|
@@ -166,6 +166,13 @@ let _activeTabURL = null; // URL-based tracking (stable even when tabs shift)
|
|
|
166
166
|
// _activeTabIndex or fail loudly — never silently target the user's tab.
|
|
167
167
|
let _lastNewTabAt = 0;
|
|
168
168
|
const NEW_TAB_GRACE_MS = 30000;
|
|
169
|
+
// Becomes true once safari_new_tab is called for the first time in this session.
|
|
170
|
+
// Once true, write operations (navigate/click/fill) MUST NOT fall back to
|
|
171
|
+
// "current tab of window" — that targets the USER'S active tab. The 30s grace
|
|
172
|
+
// window was insufficient: tab tracking can be lost much later in a session
|
|
173
|
+
// (e.g. tab ghost recovery in runJS), and silently falling back to the user's
|
|
174
|
+
// tab caused incidents where the user's working tab was overwritten.
|
|
175
|
+
let _hasOwnedTab = false;
|
|
169
176
|
let _lastResolveTime = 0; // Cache: skip resolve if verified recently
|
|
170
177
|
let _lastTabCount = null; // Track tab count for smart cache invalidation
|
|
171
178
|
let _activeTabMarker = null; // window.__mcpTabMarker — survives same-tab navigation, bulletproof tab identity
|
|
@@ -285,13 +292,18 @@ function isStaleWindowError(err) {
|
|
|
285
292
|
// target whatever tab the user is looking at when our cached index is lost.
|
|
286
293
|
function _assertNotFallingBackToUserTab(opName) {
|
|
287
294
|
if (_activeTabIndex) return; // we have a tracked index — fine
|
|
288
|
-
|
|
289
|
-
|
|
295
|
+
// If we ever opened our own tab in this session, we MUST NOT fall back to
|
|
296
|
+
// "current tab of window" — that's the USER'S active tab. Always throw,
|
|
297
|
+
// regardless of how long ago the tab was opened. The previous 30-second
|
|
298
|
+
// grace window was insufficient: long-running sessions (Reddit warmup,
|
|
299
|
+
// multi-step workflows) routinely exceed it, and the danger persists.
|
|
300
|
+
if (_hasOwnedTab) {
|
|
290
301
|
throw new Error(
|
|
291
|
-
`Tab tracking lost
|
|
292
|
-
`
|
|
302
|
+
`Tab tracking lost — refusing to ${opName} via fallback to "current tab of window" (would target the user's active tab). ` +
|
|
303
|
+
`This session previously opened its own tab via safari_new_tab; re-run safari_new_tab to recover, or call safari_list_tabs and safari_switch_tab to re-anchor to a known tab.`
|
|
293
304
|
);
|
|
294
305
|
}
|
|
306
|
+
// No tab ever owned by this session — fallback to front document is intentional.
|
|
295
307
|
}
|
|
296
308
|
|
|
297
309
|
function getFallbackTarget() {
|
|
@@ -779,7 +791,18 @@ async function runJS(js, { tabIndex, timeout = 15000 } = {}) {
|
|
|
779
791
|
return osascript(retryScript, { timeout });
|
|
780
792
|
}
|
|
781
793
|
}
|
|
782
|
-
// If still can't resolve
|
|
794
|
+
// If still can't resolve and we previously owned a tab — refuse to
|
|
795
|
+
// fall back to "current tab of window" (which is the USER'S active tab).
|
|
796
|
+
// Falling back would silently run our JS (potentially writes:
|
|
797
|
+
// document.title=, location.href=) on the user's working page.
|
|
798
|
+
if (_hasOwnedTab) {
|
|
799
|
+
throw new Error(
|
|
800
|
+
`Tab tracking lost during runJS — original tab ${idx} no longer exists, and URL-based resolution failed. ` +
|
|
801
|
+
`Refusing to fall back to "current tab of window" (would target user's active tab). ` +
|
|
802
|
+
`Call safari_new_tab to open a fresh tab and retry.`
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
// No owned tab in this session — front-document fallback is intentional.
|
|
783
806
|
const fallbackScript = `tell application "Safari" to do JavaScript "${escaped}" in current tab of ${getTargetWindowRef()}`;
|
|
784
807
|
console.error(`[Safari MCP] Falling back to current tab`);
|
|
785
808
|
if (fallbackScript.length < 50000) return osascriptFast(fallbackScript, { timeout });
|
|
@@ -800,6 +823,14 @@ async function runJSLarge(js, { tabIndex, timeout = 30000 } = {}) {
|
|
|
800
823
|
if (resolved) { idx = resolved; _lastResolveTime = Date.now(); }
|
|
801
824
|
}
|
|
802
825
|
if (!idx) idx = _activeTabIndex;
|
|
826
|
+
// If we previously owned a tab but lost tracking, refuse to target current tab
|
|
827
|
+
// (which is the USER'S active tab). Same protection as navigate/runJS fallback.
|
|
828
|
+
if (!idx && _hasOwnedTab) {
|
|
829
|
+
throw new Error(
|
|
830
|
+
`Tab tracking lost during runJSLarge — refusing to fall back to "current tab of window" (would target user's active tab). ` +
|
|
831
|
+
`Call safari_new_tab to open a fresh tab and retry.`
|
|
832
|
+
);
|
|
833
|
+
}
|
|
803
834
|
const target = idx
|
|
804
835
|
? `tab ${idx} of ${getTargetWindowRef()}`
|
|
805
836
|
: getFallbackTarget();
|
|
@@ -1330,6 +1361,35 @@ export async function fill({ selector, value, ref }) {
|
|
|
1330
1361
|
const val = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
|
|
1331
1362
|
const result = await runJS(
|
|
1332
1363
|
`(function(){try{var el=document.querySelector('${sel}');if(!el){var q=function(r){var a=r.querySelectorAll('*');for(var i=0;i<a.length;i++){if(a[i].shadowRoot){el=a[i].shadowRoot.querySelector('${sel}');if(el)return el;el=q(a[i].shadowRoot);if(el)return el;}}return null;};el=q(document);}if(!el)return 'Element not found: ${sel}';el.focus();if(el.isContentEditable||el.getAttribute('contenteditable')==='true'){` +
|
|
1364
|
+
// Quill editor detection (LinkedIn share composer in 2026 — they migrated from
|
|
1365
|
+
// ProseMirror, Slack message editor, many enterprise apps). The Quill instance is
|
|
1366
|
+
// attached to `.ql-container.__quill` (v2) or accessible via React Fiber's
|
|
1367
|
+
// memoizedProps/stateNode. Without proper API access, fill-by-DOM crashes Quill's
|
|
1368
|
+
// internal Delta state and dismisses the dialog (the v2.10.0 LinkedIn bug repro).
|
|
1369
|
+
`var qlEditor=el.classList&&el.classList.contains('ql-editor')?el:el.closest('.ql-editor');` +
|
|
1370
|
+
`if(qlEditor){` +
|
|
1371
|
+
`var qlContainer=qlEditor.closest('.ql-container')||qlEditor.parentElement;` +
|
|
1372
|
+
`var quill=(qlContainer&&qlContainer.__quill)||null;` +
|
|
1373
|
+
// Fallback: walk React Fiber to find the Quill instance attached as a prop or stateNode
|
|
1374
|
+
`if(!quill&&qlContainer){var qfk=Object.keys(qlContainer).find(function(k){return k.indexOf('__reactFiber')===0||k.indexOf('__reactInternalInstance')===0;});if(qfk){var qf=qlContainer[qfk];for(var qd=0;qd<25&&qf;qd++){var qp=qf.memoizedProps;if(qp&&qp.quill&&typeof qp.quill.setContents==='function'){quill=qp.quill;break;}var qsn=qf.stateNode;if(qsn&&qsn.quill&&typeof qsn.quill.setContents==='function'){quill=qsn.quill;break;}qf=qf.return;}}}` +
|
|
1375
|
+
`if(quill&&typeof quill.setContents==='function'){` +
|
|
1376
|
+
`try{` +
|
|
1377
|
+
// Convert escaped string back to real string for Quill API.
|
|
1378
|
+
// The val template var has \\n / \\\\ / \\' applied, so we need to unescape for setText.
|
|
1379
|
+
`var qlText='${val}'.replace(/\\\\n/g,'\\n').replace(/\\\\'/g,"'").replace(/\\\\\\\\/g,'\\\\');` +
|
|
1380
|
+
// setContents with a plain text Delta — bypasses clipboard, no synthetic events,
|
|
1381
|
+
// doesn't trigger LinkedIn's focusout-dismiss handler.
|
|
1382
|
+
`quill.setContents([{insert: qlText + '\\n'}], 'api');` +
|
|
1383
|
+
`quill.setSelection(qlText.length, 0, 'api');` +
|
|
1384
|
+
// Verify the text actually committed
|
|
1385
|
+
`var qlActual=quill.getText().replace(/\\n+$/,'');` +
|
|
1386
|
+
`if(qlActual.indexOf(qlText.substring(0, Math.min(20, qlText.length)))>=0){return 'Filled CE (Quill setContents)';}` +
|
|
1387
|
+
`}catch(_qE){}` +
|
|
1388
|
+
`}` +
|
|
1389
|
+
// Quill instance not found via direct or Fiber — route to native paste fallback.
|
|
1390
|
+
// Quill respects real isTrusted clipboard events, so CGEvent Cmd+V works.
|
|
1391
|
+
`return '__NATIVE_PASTE_DIALOG__';` +
|
|
1392
|
+
`}` +
|
|
1333
1393
|
// Lexical editor detection (LinkedIn share composer, modern Meta/Shopify apps).
|
|
1334
1394
|
// Three lookup strategies, cheapest first:
|
|
1335
1395
|
// A) [data-lexical-editor="true"] on ancestor with __lexicalEditor property.
|
|
@@ -2577,7 +2637,9 @@ export async function newTab(url = "") {
|
|
|
2577
2637
|
_activeTabURL = url || null;
|
|
2578
2638
|
_lastResolveTime = Date.now();
|
|
2579
2639
|
_lastTabCount = Number(tabCount); // Update tab count cache
|
|
2580
|
-
_lastNewTabAt = Date.now(); //
|
|
2640
|
+
_lastNewTabAt = Date.now(); // (legacy — grace window superseded by _hasOwnedTab)
|
|
2641
|
+
_hasOwnedTab = true; // Permanently true: this session has opened its own tab,
|
|
2642
|
+
// so write ops must NEVER fall back to the user's current tab.
|
|
2581
2643
|
// Set bulletproof tab marker — survives same-tab navigation, redirects, query changes (v2.8.3)
|
|
2582
2644
|
_activeTabMarker = `MCP_${SESSION_ID}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
2583
2645
|
await osascriptFast(
|
|
@@ -4185,6 +4247,8 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
|
|
|
4185
4247
|
let targetUrl = url;
|
|
4186
4248
|
if (!/^https?:\/\//i.test(targetUrl)) targetUrl = "https://" + targetUrl;
|
|
4187
4249
|
const safeUrl = targetUrl.replace(/"/g, '\\"');
|
|
4250
|
+
if (_activeTabURL) await resolveActiveTab();
|
|
4251
|
+
_assertNotFallingBackToUserTab('navigateAndRead');
|
|
4188
4252
|
const navTarget = _activeTabIndex ? `tab ${_activeTabIndex} of ${getTargetWindowRef()}` : getFallbackTarget();
|
|
4189
4253
|
await osascriptFast(`tell application "Safari" to set URL of ${navTarget} to "${safeUrl}"`);
|
|
4190
4254
|
_activeTabURL = targetUrl;
|