safari-mcp 2.12.0 → 2.14.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/README.md CHANGED
@@ -478,6 +478,14 @@ The recommended pattern for AI agents using Safari MCP:
478
478
  |------|-------------|
479
479
  | `safari_run_script` | Run multiple actions in a single call (batch) |
480
480
 
481
+ ### iOS & WebKit Validation (4)
482
+ | Tool | Description |
483
+ |------|-------------|
484
+ | `safari_inspect_viewport` | Validate the `<meta name=viewport>` tag for iOS Safari (device-width, zoom/WCAG, viewport-fit) |
485
+ | `safari_safe_area_insets` | Read live safe-area-inset values + viewport-fit / `env()` usage (notch / Dynamic Island) |
486
+ | `safari_check_pwa` | Audit iOS "Add to Home Screen" / PWA readiness (apple-touch-icon, manifest, theme-color, splash) |
487
+ | `safari_webkit_compat` | Check page CSS against this Safari via `CSS.supports()` — unsupported props, missing `-webkit-` prefixes, known quirks |
488
+
481
489
  </details>
482
490
 
483
491
  ---
@@ -67,8 +67,13 @@ function updateBadge(text) {
67
67
 
68
68
  // ========== HTTP LONG-POLLING TRANSPORT ==========
69
69
 
70
+ let _connecting = false; // re-entrancy lock — the startup promise and the alarm can race into connect()
70
71
  async function connect() {
71
72
  if (!_enabled) return;
73
+ // One connect at a time: two near-simultaneous calls (cold start + alarm wake)
74
+ // could each spawn a poll loop before the other assigned pollAbort.
75
+ if (_connecting) return;
76
+ _connecting = true;
72
77
  // Cancel any existing poll
73
78
  if (pollAbort) {
74
79
  try { pollAbort.abort(); } catch {}
@@ -91,6 +96,7 @@ async function connect() {
91
96
  if (!isCorrectProfile) {
92
97
  console.log(`Safari MCP: wrong profile — server wants "${data.profile}", disconnecting`);
93
98
  updateBadge("OFF");
99
+ _connecting = false;
94
100
  return; // Do NOT poll — let the correct profile's extension handle commands
95
101
  }
96
102
  // Notify server that we passed profile verification
@@ -104,6 +110,7 @@ async function connect() {
104
110
  _reconnectDelay = 3000; // Reset backoff on success
105
111
  updateBadge("ON");
106
112
  _startHeartbeat(); // Keep service worker alive between polls
113
+ _connecting = false;
107
114
  pollForCommands();
108
115
  return;
109
116
  }
@@ -113,6 +120,7 @@ async function connect() {
113
120
  isConnected = false;
114
121
  updateBadge("");
115
122
  scheduleReconnect();
123
+ _connecting = false;
116
124
  }
117
125
 
118
126
  function scheduleReconnect() {
@@ -206,6 +214,10 @@ async function handleCommand(type, payload) {
206
214
  throw new Error("Tab belongs to a different profile — refusing to operate on personal tabs");
207
215
  }
208
216
 
217
+ // Rehydrate owned-tab state after a possible service-worker restart BEFORE
218
+ // consulting the guard — an empty post-restart Map used to disable it entirely.
219
+ await _hydrateOwnedTabs();
220
+
209
221
  // ========== TAB OWNERSHIP GUARD ==========
210
222
  // Block write operations on tabs not opened by this session.
211
223
  // new_tab is always allowed (it creates owned tabs). Read-only ops are allowed on any tab.
@@ -347,7 +359,11 @@ async function handleCommand(type, payload) {
347
359
  // Strategy 2: Script element injection (works when inline scripts are allowed)
348
360
  const injectResult = await execInTab(async (script) => {
349
361
  return await new Promise((resolve) => {
350
- const id = "__mcp_eval_" + Date.now();
362
+ // Unpredictable key — a Date.now()-based name let a hostile page pre-seed
363
+ // window["__mcp_eval_<now>"] with a fabricated {done:true,v:...} result.
364
+ const id = "__mcp_eval_" + (crypto.randomUUID
365
+ ? crypto.randomUUID().replace(/-/g, "")
366
+ : Date.now().toString(36) + Math.random().toString(36).slice(2));
351
367
  window[id] = { done: false };
352
368
  const s = document.createElement("script");
353
369
  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};}";
@@ -1087,16 +1103,33 @@ async function handleCommand(type, payload) {
1087
1103
  }
1088
1104
 
1089
1105
  case "close_tab": {
1106
+ // ── Guard: never remove a window's LAST tab — doing so closes the window
1107
+ // (quitting Safari if it's the only one, or making a profile-targeted
1108
+ // window vanish). Per-window, not global, so other-profile windows don't
1109
+ // mask it. If the target window is down to one tab, blank it instead.
1110
+ // Mirrors safari.js closeTab().
1111
+ const _winQuery = _profileWindowId ? { windowId: _profileWindowId } : { currentWindow: true };
1112
+ const _winTabs = await browser.tabs.query(_winQuery);
1113
+ const _isLastTab = _winTabs.length <= 1;
1090
1114
  if (payload.index) {
1091
- const query = _profileWindowId ? { windowId: _profileWindowId } : { currentWindow: true };
1092
- const tabs = await browser.tabs.query(query);
1093
- const target = tabs[payload.index - 1];
1115
+ // Resolve the target from the SAME query as the last-tab count — a second
1116
+ // query here was a TOCTOU window (a tab opened/closed between the two awaits
1117
+ // could resolve the wrong tab or leave a stale last-tab verdict).
1118
+ const target = _winTabs[payload.index - 1];
1094
1119
  if (target) {
1095
1120
  _removeOwnedTab(sessionId, target.id);
1121
+ if (_isLastTab) {
1122
+ await browser.tabs.update(target.id, { url: "about:blank" });
1123
+ return "Last remaining tab blanked instead of closed (closing it would quit Safari)";
1124
+ }
1096
1125
  await browser.tabs.remove(target.id);
1097
1126
  }
1098
1127
  } else {
1099
1128
  _removeOwnedTab(sessionId, tabId);
1129
+ if (_isLastTab) {
1130
+ await browser.tabs.update(tabId, { url: "about:blank" });
1131
+ return "Last remaining tab blanked instead of closed (closing it would quit Safari)";
1132
+ }
1100
1133
  await browser.tabs.remove(tabId);
1101
1134
  }
1102
1135
  return "Tab closed";
@@ -1166,6 +1199,13 @@ async function handleCommand(type, payload) {
1166
1199
  }).catch(() => {});
1167
1200
  await browser.tabs.update(tabId, { url: payload.url });
1168
1201
  await waitForTabLoad(tabId, payload.timeout || 30000);
1202
+ // Update the session cache like every other navigation command — without this,
1203
+ // the next command within TAB_CACHE_MS resolved against the PRE-navigation URL
1204
+ // and could fall through to the user's active tab.
1205
+ try {
1206
+ const updated = await browser.tabs.get(tabId);
1207
+ _setSessionTab(sessionId, updated.id, updated.url);
1208
+ } catch {}
1169
1209
  const maxLen = payload.maxLength || 50000;
1170
1210
  const results = await browser.scripting.executeScript({
1171
1211
  target: { tabId },
@@ -1739,16 +1779,47 @@ const MAX_SESSIONS = 50; // Hard cap on session cache size
1739
1779
  // Prevents operating on user's tabs — only tabs created via new_tab are "owned".
1740
1780
  const _sessionOwnedTabs = new Map(); // sessionId → Set<tabId>
1741
1781
 
1782
+ // Persist owned-tab IDs in storage.session: it survives the frequent MV3
1783
+ // service-worker terminations (but clears when Safari quits, matching tab-ID
1784
+ // lifetime). Without this, every worker restart wiped the Map — and the
1785
+ // "no tabs owned yet" compatibility path then silently allowed write commands
1786
+ // on ANY tab, including the user's.
1787
+ const _OWNED_TABS_KEY = "mcpSessionOwnedTabs";
1788
+ let _ownedTabsHydrated = false;
1789
+ async function _hydrateOwnedTabs() {
1790
+ if (_ownedTabsHydrated) return;
1791
+ _ownedTabsHydrated = true;
1792
+ try {
1793
+ const data = (await browser.storage.session.get(_OWNED_TABS_KEY))?.[_OWNED_TABS_KEY];
1794
+ if (data) {
1795
+ for (const [sid, ids] of Object.entries(data)) {
1796
+ if (!_sessionOwnedTabs.has(sid)) _sessionOwnedTabs.set(sid, new Set());
1797
+ const set = _sessionOwnedTabs.get(sid);
1798
+ for (const id of ids) set.add(id);
1799
+ }
1800
+ }
1801
+ } catch {} // storage.session unavailable → behave as before (in-memory only)
1802
+ }
1803
+ function _persistOwnedTabs() {
1804
+ try {
1805
+ const obj = {};
1806
+ for (const [sid, set] of _sessionOwnedTabs) obj[sid] = [...set];
1807
+ browser.storage.session.set({ [_OWNED_TABS_KEY]: obj }).catch(() => {});
1808
+ } catch {}
1809
+ }
1810
+
1742
1811
  function _addOwnedTab(sessionId, tabId) {
1743
1812
  const sid = sessionId || _DEFAULT_SESSION;
1744
1813
  if (!_sessionOwnedTabs.has(sid)) _sessionOwnedTabs.set(sid, new Set());
1745
1814
  _sessionOwnedTabs.get(sid).add(tabId);
1815
+ _persistOwnedTabs();
1746
1816
  }
1747
1817
 
1748
1818
  function _removeOwnedTab(sessionId, tabId) {
1749
1819
  const sid = sessionId || _DEFAULT_SESSION;
1750
1820
  const set = _sessionOwnedTabs.get(sid);
1751
1821
  if (set) set.delete(tabId);
1822
+ _persistOwnedTabs();
1752
1823
  }
1753
1824
 
1754
1825
  function _isTabOwnedBySession(sessionId, tabId) {
@@ -1960,11 +2031,21 @@ async function getTargetTab(tabUrl, sessionId) {
1960
2031
  match = all.find(t => t.url && (t.url.startsWith(tabUrl) || tabUrl.startsWith(t.url.split("?")[0])));
1961
2032
  }
1962
2033
  if (match) {
2034
+ // A URL match in a DIFFERENT window must not silently retarget the profile
2035
+ // window — a URL collision with a tab in the user's personal window would
2036
+ // permanently redirect every subsequent command there. Adopt the match's
2037
+ // window only when the tracked profile window no longer exists.
2038
+ if (_profileWindowId && match.windowId !== _profileWindowId) {
2039
+ let profileWindowGone = false;
2040
+ try { await browser.windows.get(_profileWindowId); }
2041
+ catch { profileWindowGone = true; }
2042
+ if (!profileWindowGone) {
2043
+ throw new Error("Tab not found in the MCP profile window (a same-URL tab exists in another window — refusing to cross windows). Use safari_new_tab.");
2044
+ }
2045
+ console.log("Safari MCP: profile window gone — adopting", match.windowId);
2046
+ }
1963
2047
  _setSessionTab(sessionId, match.id, tabUrl);
1964
2048
  if (!_profileWindowId || match.windowId !== _profileWindowId) {
1965
- if (_profileWindowId && match.windowId !== _profileWindowId) {
1966
- console.log("Safari MCP: profile window changed:", _profileWindowId, "→", match.windowId);
1967
- }
1968
2049
  _profileWindowId = match.windowId;
1969
2050
  browser.storage.local.set({ mcpProfileWindowId: _profileWindowId }).catch(() => {});
1970
2051
  console.log("Safari MCP: profile windowId =", _profileWindowId);
@@ -20,10 +20,20 @@ if (!window.__mcpShadowPatched) {
20
20
  }
21
21
  return shadow;
22
22
  };
23
- // Expose getter for MCP tools (snapshot, deepQuery, click, fill)
24
- window.__mcpGetShadowRoot = function(el) {
23
+ // Expose getter for MCP tools (snapshot, deepQuery, click, fill).
24
+ // Non-enumerable + non-writable: pages that know the name can still call it
25
+ // (inherent to MAIN-world injection), but it doesn't surface in enumeration and —
26
+ // more importantly — page scripts can't REPLACE it to feed MCP fake shadow roots.
27
+ var _getShadowRoot = function(el) {
25
28
  return el.shadowRoot || _closedRoots.get(el) || null;
26
29
  };
30
+ try {
31
+ Object.defineProperty(window, "__mcpGetShadowRoot", {
32
+ value: _getShadowRoot, writable: false, enumerable: false, configurable: false
33
+ });
34
+ } catch (_e) {
35
+ window.__mcpGetShadowRoot = _getShadowRoot;
36
+ }
27
37
  }
28
38
 
29
39
  if (!window.__mcpTrustedPolicy && window.trustedTypes && typeof window.trustedTypes.createPolicy === "function") {