safari-mcp 2.10.0 → 2.10.2
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/extension/background.js +8 -2
- package/extension/content.js +21 -4
- package/extension/manifest.json +1 -1
- package/package.json +1 -1
- package/safari.js +64 -0
package/extension/background.js
CHANGED
|
@@ -342,9 +342,15 @@ async function handleCommand(type, payload) {
|
|
|
342
342
|
window[id] = { done: false };
|
|
343
343
|
const s = document.createElement("script");
|
|
344
344
|
const code = "try{var __r=(function(){" + script + "})();if(__r&&typeof __r.then==='function'){__r.then(function(v){window['" + id + "']={done:true,v:v};}).catch(function(e){window['" + id + "']={done:true,e:e.message};});}else{window['" + id + "']={done:true,v:__r};}}catch(e){window['" + id + "']={done:true,e:e.message};}";
|
|
345
|
-
|
|
345
|
+
// Prefer the policy pre-registered by content.js at document_start —
|
|
346
|
+
// pages that block new policy creation post-load (GSC, modern Google admin)
|
|
347
|
+
// still accept ours because it was grandfathered in before their CSP applied.
|
|
348
|
+
if (window.__mcpTrustedPolicy && typeof window.__mcpTrustedPolicy.createScript === "function") {
|
|
349
|
+
try { s.textContent = window.__mcpTrustedPolicy.createScript(code); }
|
|
350
|
+
catch (_) { s.textContent = code; }
|
|
351
|
+
} else if (window.trustedTypes && window.trustedTypes.createPolicy) {
|
|
346
352
|
try {
|
|
347
|
-
const policy = window.trustedTypes.createPolicy("
|
|
353
|
+
const policy = window.trustedTypes.createPolicy("mcpEval_" + Date.now(), { createScript: (s) => s });
|
|
348
354
|
s.textContent = policy.createScript(code);
|
|
349
355
|
} catch (_) { s.textContent = code; }
|
|
350
356
|
} else {
|
package/extension/content.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
// Content script — runs at document_start in MAIN world (before page scripts)
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Content script — runs at document_start in MAIN world (before page scripts).
|
|
2
|
+
// Two responsibilities:
|
|
3
|
+
// 1. Monkey-patch attachShadow to capture CLOSED shadow roots (Reddit, etc.).
|
|
4
|
+
// 2. Pre-register a Trusted Types policy named "mcpBridge" BEFORE the page sets
|
|
5
|
+
// its own require-trusted-types-for directive. Our policy is then grandfathered
|
|
6
|
+
// and survives even on pages (Google Search Console, Google admin, modern banks)
|
|
7
|
+
// that block new policy creation after page load. MCP evaluate strategies
|
|
8
|
+
// consult `window.__mcpTrustedPolicy` first.
|
|
5
9
|
// Runs in MAIN world via manifest "world": "MAIN" — no script injection needed,
|
|
6
10
|
// so CSP cannot block it.
|
|
7
11
|
|
|
@@ -21,3 +25,16 @@ if (!window.__mcpShadowPatched) {
|
|
|
21
25
|
return el.shadowRoot || _closedRoots.get(el) || null;
|
|
22
26
|
};
|
|
23
27
|
}
|
|
28
|
+
|
|
29
|
+
if (!window.__mcpTrustedPolicy && window.trustedTypes && typeof window.trustedTypes.createPolicy === "function") {
|
|
30
|
+
try {
|
|
31
|
+
window.__mcpTrustedPolicy = window.trustedTypes.createPolicy("mcpBridge", {
|
|
32
|
+
createScript: function (s) { return s; },
|
|
33
|
+
createScriptURL: function (s) { return s; },
|
|
34
|
+
createHTML: function (s) { return s; }
|
|
35
|
+
});
|
|
36
|
+
} catch (_e) {
|
|
37
|
+
// Page already restricts policies — rare since content script runs at document_start
|
|
38
|
+
// before page scripts. Leave undefined; evaluate fallbacks will probe other paths.
|
|
39
|
+
}
|
|
40
|
+
}
|
package/extension/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Safari MCP Bridge",
|
|
4
4
|
"description": "Connects AI agents to Safari — fast JS execution, screenshots, clicks with your real cookies/logins",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.10.1",
|
|
6
6
|
"icons": {
|
|
7
7
|
"48": "images/icon-48.png",
|
|
8
8
|
"96": "images/icon-96.png",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.2",
|
|
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
|
@@ -158,6 +158,14 @@ async function _restoreClipboard(savedContent) {
|
|
|
158
158
|
// to the current index.
|
|
159
159
|
let _activeTabIndex = null; // null = use front document (default)
|
|
160
160
|
let _activeTabURL = null; // URL-based tracking (stable even when tabs shift)
|
|
161
|
+
// Timestamp of most recent new_tab — within this grace window we MUST NOT fall
|
|
162
|
+
// back to "current tab of window" (which is the USER'S active tab). New tabs
|
|
163
|
+
// often start at about:blank if their URL fails to load (file://, blocked, etc.),
|
|
164
|
+
// and the URL-based marker resolution can fail until the page actually loads.
|
|
165
|
+
// During the grace window, navigate/click/fill operations either use the cached
|
|
166
|
+
// _activeTabIndex or fail loudly — never silently target the user's tab.
|
|
167
|
+
let _lastNewTabAt = 0;
|
|
168
|
+
const NEW_TAB_GRACE_MS = 30000;
|
|
161
169
|
let _lastResolveTime = 0; // Cache: skip resolve if verified recently
|
|
162
170
|
let _lastTabCount = null; // Track tab count for smart cache invalidation
|
|
163
171
|
let _activeTabMarker = null; // window.__mcpTabMarker — survives same-tab navigation, bulletproof tab identity
|
|
@@ -272,6 +280,20 @@ function isStaleWindowError(err) {
|
|
|
272
280
|
|
|
273
281
|
// Safe fallback target: when no tab index is known, use the profile window's current tab
|
|
274
282
|
// instead of "front document" which can target the user's personal profile window
|
|
283
|
+
// Throw if a write operation is about to fall back to the user's active tab
|
|
284
|
+
// during the new-tab grace window. Without this, navigate/fill/click silently
|
|
285
|
+
// target whatever tab the user is looking at when our cached index is lost.
|
|
286
|
+
function _assertNotFallingBackToUserTab(opName) {
|
|
287
|
+
if (_activeTabIndex) return; // we have a tracked index — fine
|
|
288
|
+
const sinceNew = Date.now() - _lastNewTabAt;
|
|
289
|
+
if (sinceNew < NEW_TAB_GRACE_MS) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`Tab tracking lost within ${Math.round(sinceNew/1000)}s of safari_new_tab — refusing to ${opName} via fallback (would target the user's active tab). ` +
|
|
292
|
+
`Re-run safari_new_tab and retry.`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
275
297
|
function getFallbackTarget() {
|
|
276
298
|
return SAFARI_PROFILE ? `current tab of ${getTargetWindowRef()}` : "front document";
|
|
277
299
|
}
|
|
@@ -826,6 +848,7 @@ export async function navigate(url) {
|
|
|
826
848
|
const safeUrl = targetUrl.replace(/"/g, '\\"');
|
|
827
849
|
// Resolve tab by URL first (in case indices shifted)
|
|
828
850
|
if (_activeTabURL) await resolveActiveTab();
|
|
851
|
+
_assertNotFallingBackToUserTab('navigate');
|
|
829
852
|
const navTarget = _activeTabIndex
|
|
830
853
|
? `tab ${_activeTabIndex} of ${getTargetWindowRef()}`
|
|
831
854
|
: getFallbackTarget();
|
|
@@ -904,6 +927,17 @@ export async function navigate(url) {
|
|
|
904
927
|
}
|
|
905
928
|
_lastResolveTime = Date.now();
|
|
906
929
|
|
|
930
|
+
// Re-inject the tab marker — navigation creates a new JS context which wipes the
|
|
931
|
+
// window.__mcpTabMarker we set in newTab. Without re-injection, resolveActiveTab's
|
|
932
|
+
// marker-based search fails on the very next call and falls back to URL search,
|
|
933
|
+
// which can mis-target if the tab is still loading or shows an error page.
|
|
934
|
+
if (_activeTabIndex && _activeTabMarker) {
|
|
935
|
+
const safeMarker = _activeTabMarker.replace(/'/g, "\\'");
|
|
936
|
+
await osascriptFast(
|
|
937
|
+
`tell application "Safari" to do JavaScript "window.__mcpTabMarker='${safeMarker}'" in tab ${_activeTabIndex} of ${getTargetWindowRef()}`
|
|
938
|
+
).catch(() => {});
|
|
939
|
+
}
|
|
940
|
+
|
|
907
941
|
return result;
|
|
908
942
|
}
|
|
909
943
|
|
|
@@ -1296,6 +1330,35 @@ export async function fill({ selector, value, ref }) {
|
|
|
1296
1330
|
const val = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
|
|
1297
1331
|
const result = await runJS(
|
|
1298
1332
|
`(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'){` +
|
|
1333
|
+
// Quill editor detection (LinkedIn share composer in 2026 — they migrated from
|
|
1334
|
+
// ProseMirror, Slack message editor, many enterprise apps). The Quill instance is
|
|
1335
|
+
// attached to `.ql-container.__quill` (v2) or accessible via React Fiber's
|
|
1336
|
+
// memoizedProps/stateNode. Without proper API access, fill-by-DOM crashes Quill's
|
|
1337
|
+
// internal Delta state and dismisses the dialog (the v2.10.0 LinkedIn bug repro).
|
|
1338
|
+
`var qlEditor=el.classList&&el.classList.contains('ql-editor')?el:el.closest('.ql-editor');` +
|
|
1339
|
+
`if(qlEditor){` +
|
|
1340
|
+
`var qlContainer=qlEditor.closest('.ql-container')||qlEditor.parentElement;` +
|
|
1341
|
+
`var quill=(qlContainer&&qlContainer.__quill)||null;` +
|
|
1342
|
+
// Fallback: walk React Fiber to find the Quill instance attached as a prop or stateNode
|
|
1343
|
+
`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;}}}` +
|
|
1344
|
+
`if(quill&&typeof quill.setContents==='function'){` +
|
|
1345
|
+
`try{` +
|
|
1346
|
+
// Convert escaped string back to real string for Quill API.
|
|
1347
|
+
// The val template var has \\n / \\\\ / \\' applied, so we need to unescape for setText.
|
|
1348
|
+
`var qlText='${val}'.replace(/\\\\n/g,'\\n').replace(/\\\\'/g,"'").replace(/\\\\\\\\/g,'\\\\');` +
|
|
1349
|
+
// setContents with a plain text Delta — bypasses clipboard, no synthetic events,
|
|
1350
|
+
// doesn't trigger LinkedIn's focusout-dismiss handler.
|
|
1351
|
+
`quill.setContents([{insert: qlText + '\\n'}], 'api');` +
|
|
1352
|
+
`quill.setSelection(qlText.length, 0, 'api');` +
|
|
1353
|
+
// Verify the text actually committed
|
|
1354
|
+
`var qlActual=quill.getText().replace(/\\n+$/,'');` +
|
|
1355
|
+
`if(qlActual.indexOf(qlText.substring(0, Math.min(20, qlText.length)))>=0){return 'Filled CE (Quill setContents)';}` +
|
|
1356
|
+
`}catch(_qE){}` +
|
|
1357
|
+
`}` +
|
|
1358
|
+
// Quill instance not found via direct or Fiber — route to native paste fallback.
|
|
1359
|
+
// Quill respects real isTrusted clipboard events, so CGEvent Cmd+V works.
|
|
1360
|
+
`return '__NATIVE_PASTE_DIALOG__';` +
|
|
1361
|
+
`}` +
|
|
1299
1362
|
// Lexical editor detection (LinkedIn share composer, modern Meta/Shopify apps).
|
|
1300
1363
|
// Three lookup strategies, cheapest first:
|
|
1301
1364
|
// A) [data-lexical-editor="true"] on ancestor with __lexicalEditor property.
|
|
@@ -2543,6 +2606,7 @@ export async function newTab(url = "") {
|
|
|
2543
2606
|
_activeTabURL = url || null;
|
|
2544
2607
|
_lastResolveTime = Date.now();
|
|
2545
2608
|
_lastTabCount = Number(tabCount); // Update tab count cache
|
|
2609
|
+
_lastNewTabAt = Date.now(); // Start grace window — block fallback to user's tab
|
|
2546
2610
|
// Set bulletproof tab marker — survives same-tab navigation, redirects, query changes (v2.8.3)
|
|
2547
2611
|
_activeTabMarker = `MCP_${SESSION_ID}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
2548
2612
|
await osascriptFast(
|