safari-mcp 2.6.1 → 2.7.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.
Files changed (38) hide show
  1. package/extension/background.js +36 -5
  2. package/index.js +78 -38
  3. package/package.json +12 -4
  4. package/safari.js +203 -128
  5. package/xcode/Safari MCP/Safari MCP.xcodeproj/project.pbxproj +0 -991
  6. package/xcode/Safari MCP/Safari MCP.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -7
  7. package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AccentColor.colorset/Contents.json +0 -11
  8. package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/Contents.json +0 -98
  9. package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-128@1x.png +0 -0
  10. package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-128@2x.png +0 -0
  11. package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-16@1x.png +0 -0
  12. package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-16@2x.png +0 -0
  13. package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-256@1x.png +0 -0
  14. package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-256@2x.png +0 -0
  15. package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-32@1x.png +0 -0
  16. package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-32@2x.png +0 -0
  17. package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-512@1x.png +0 -0
  18. package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-512@2x.png +0 -0
  19. package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/universal-icon-1024@1x.png +0 -0
  20. package/xcode/Safari MCP/Shared (App)/Assets.xcassets/Contents.json +0 -6
  21. package/xcode/Safari MCP/Shared (App)/Assets.xcassets/LargeIcon.imageset/Contents.json +0 -21
  22. package/xcode/Safari MCP/Shared (App)/Assets.xcassets/LargeIcon.imageset/icon-128.png +0 -0
  23. package/xcode/Safari MCP/Shared (App)/Resources/Base.lproj/Main.html +0 -20
  24. package/xcode/Safari MCP/Shared (App)/Resources/Icon.png +0 -0
  25. package/xcode/Safari MCP/Shared (App)/Resources/Script.js +0 -24
  26. package/xcode/Safari MCP/Shared (App)/Resources/Style.css +0 -61
  27. package/xcode/Safari MCP/Shared (App)/ViewController.swift +0 -81
  28. package/xcode/Safari MCP/Shared (Extension)/SafariWebExtensionHandler.swift +0 -42
  29. package/xcode/Safari MCP/iOS (App)/AppDelegate.swift +0 -24
  30. package/xcode/Safari MCP/iOS (App)/Base.lproj/LaunchScreen.storyboard +0 -36
  31. package/xcode/Safari MCP/iOS (App)/Base.lproj/Main.storyboard +0 -38
  32. package/xcode/Safari MCP/iOS (App)/Info.plist +0 -27
  33. package/xcode/Safari MCP/iOS (App)/SceneDelegate.swift +0 -18
  34. package/xcode/Safari MCP/iOS (Extension)/Info.plist +0 -13
  35. package/xcode/Safari MCP/macOS (App)/AppDelegate.swift +0 -21
  36. package/xcode/Safari MCP/macOS (App)/Base.lproj/Main.storyboard +0 -124
  37. package/xcode/Safari MCP/macOS (App)/Info.plist +0 -8
  38. package/xcode/Safari MCP/macOS (Extension)/Info.plist +0 -13
@@ -37,6 +37,7 @@ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
37
37
  if (pollAbort) { try { pollAbort.abort(); } catch {} pollAbort = null; }
38
38
  if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
39
39
  _reconnectDelay = 3000;
40
+ _stopHeartbeat();
40
41
  updateBadge("OFF");
41
42
  } else {
42
43
  updateBadge("");
@@ -102,6 +103,7 @@ async function connect() {
102
103
  isConnected = true;
103
104
  _reconnectDelay = 3000; // Reset backoff on success
104
105
  updateBadge("ON");
106
+ _startHeartbeat(); // Keep service worker alive between polls
105
107
  pollForCommands();
106
108
  return;
107
109
  }
@@ -183,7 +185,9 @@ async function executeAndReply(msg) {
183
185
  body: JSON.stringify(response),
184
186
  signal: AbortSignal.timeout(5000),
185
187
  });
186
- } catch {}
188
+ } catch (err) {
189
+ console.warn("Safari MCP Bridge: failed to send result to server:", err.message);
190
+ }
187
191
  }
188
192
 
189
193
  // ========== COMMAND HANDLERS ==========
@@ -1723,6 +1727,10 @@ browser.tabs.onRemoved.addListener((tabId) => {
1723
1727
  cache.tabUrl = null;
1724
1728
  }
1725
1729
  }
1730
+ // Also remove from owned tabs — prevents stale ownership on externally closed tabs
1731
+ for (const [sid, ownedSet] of _sessionOwnedTabs) {
1732
+ ownedSet.delete(tabId);
1733
+ }
1726
1734
  });
1727
1735
 
1728
1736
  // Verify this extension instance is running in the expected profile.
@@ -1772,7 +1780,13 @@ async function _verifyProfileMatch(expectedProfile) {
1772
1780
  }
1773
1781
 
1774
1782
  if (verifyRes && verifyRes.ok) {
1775
- const result = await verifyRes.json();
1783
+ let result;
1784
+ try {
1785
+ result = await verifyRes.json();
1786
+ } catch {
1787
+ console.warn("Safari MCP: profile verification response invalid JSON — rejecting");
1788
+ return false;
1789
+ }
1776
1790
  if (result.match) {
1777
1791
  await browser.storage.local.set({ mcpVerifiedProfile: expectedProfile });
1778
1792
  return true;
@@ -2108,10 +2122,25 @@ async function waitForTabSettled(tabId, timeout = 3000) {
2108
2122
  // No else needed — if not loading, page is already settled
2109
2123
  }
2110
2124
 
2111
- // ========== KEEP-ALIVE VIA ALARMS ==========
2125
+ // ========== KEEP-ALIVE VIA ALARMS + HEARTBEAT ==========
2112
2126
  // Safari kills service workers after ~30s of inactivity.
2113
- // browser.alarms re-wakes the worker and reconnects if needed.
2114
- // The active fetch() in pollForCommands() keeps the worker alive while connected.
2127
+ // Three-layer strategy:
2128
+ // 1. Active fetch() in pollForCommands() keeps the worker alive while connected
2129
+ // 2. Storage write every 20s keeps the worker alive between polls (Safari counts storage access as activity)
2130
+ // 3. browser.alarms (1 min minimum) re-wakes the worker if it was terminated
2131
+ let _heartbeatTimer = null;
2132
+ function _startHeartbeat() {
2133
+ if (_heartbeatTimer) return;
2134
+ _heartbeatTimer = setInterval(() => {
2135
+ if (_enabled) {
2136
+ browser.storage.local.set({ _heartbeat: Date.now() }).catch(() => {});
2137
+ }
2138
+ }, 20000); // Every 20s — keeps service worker alive between alarm intervals
2139
+ }
2140
+ function _stopHeartbeat() {
2141
+ if (_heartbeatTimer) { clearInterval(_heartbeatTimer); _heartbeatTimer = null; }
2142
+ }
2143
+
2115
2144
  browser.alarms.create("keepalive", { periodInMinutes: 1 });
2116
2145
  browser.alarms.onAlarm.addListener((alarm) => {
2117
2146
  if (alarm.name === "keepalive" || alarm.name === "reconnect") {
@@ -2119,6 +2148,8 @@ browser.alarms.onAlarm.addListener((alarm) => {
2119
2148
  if (!isConnected && _enabled && !_reconnectTimer) {
2120
2149
  scheduleReconnect();
2121
2150
  }
2151
+ // Restart heartbeat in case it was lost on worker restart
2152
+ if (_enabled && !_heartbeatTimer) _startHeartbeat();
2122
2153
  }
2123
2154
  });
2124
2155
 
package/index.js CHANGED
@@ -75,6 +75,16 @@ function _isURLOwned(url) {
75
75
  const ownedBase = normalize(owned);
76
76
  if (urlBase === ownedBase) return true;
77
77
  }
78
+ // Same-origin redirect: if a tab navigated from an owned URL to a different path
79
+ // on the same origin (e.g. /login/device → /login/device/select_account), it's still ours
80
+ try {
81
+ const urlOrigin = new URL(url).origin;
82
+ for (const owned of _ownedTabURLs) {
83
+ try {
84
+ if (new URL(owned).origin === urlOrigin && urlBase.startsWith(normalize(owned))) return true;
85
+ } catch {}
86
+ }
87
+ } catch {}
78
88
  return false;
79
89
  }
80
90
 
@@ -123,48 +133,73 @@ async function _cleanupTabs() {
123
133
  _openedTabs.clear();
124
134
  }
125
135
 
126
- // Periodic memory check — if WebKit exceeds limit, close oldest MCP tabs
136
+ // Periodic memory check — proactive monitoring with warning + action thresholds
137
+ const WEBKIT_WARNING_THRESHOLD_MB = Math.round(WEBKIT_MEMORY_LIMIT_MB * 0.7); // 70% = warning
127
138
  let _memoryCheckTimer = null;
139
+ let _lastMemoryWarningTime = 0;
140
+
141
+ function _getWebKitMemoryMB() {
142
+ try {
143
+ const pids = execFileSync("pgrep", ["-Ef", "WebKit|WebContent"], { encoding: "utf8" }).trim();
144
+ if (!pids) return 0;
145
+ const pidList = pids.split("\n").join(",");
146
+ const psOut = execFileSync("ps", ["-p", pidList, "-o", "rss="], { encoding: "utf8" }).trim();
147
+ const totalKB = psOut.split("\n").reduce((sum, l) => sum + (parseInt(l.trim(), 10) || 0), 0);
148
+ return totalKB / 1024;
149
+ } catch { return 0; }
150
+ }
151
+
152
+ async function _closeOldestMCPTab() {
153
+ let oldestIdx = null, oldestTime = Infinity;
154
+ for (const [idx, info] of _openedTabs) {
155
+ if (info.openedAt < oldestTime) { oldestTime = info.openedAt; oldestIdx = idx; }
156
+ }
157
+ if (oldestIdx !== null) {
158
+ try {
159
+ const info = _openedTabs.get(oldestIdx);
160
+ if (info?.url) {
161
+ const tabs = await safari.listTabs();
162
+ const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
163
+ const match = parsed.find(t => t.url === info.url);
164
+ if (match) {
165
+ safari.setActiveTabIndex(match.index);
166
+ await safari.closeTab();
167
+ }
168
+ }
169
+ } catch {}
170
+ _untrackTab(oldestIdx);
171
+ }
172
+ }
173
+
128
174
  function _startMemoryMonitor() {
175
+ const checkInterval = Math.min(MEMORY_CHECK_INTERVAL_MS, 30000); // Max 30s between checks
129
176
  _memoryCheckTimer = setInterval(async () => {
130
177
  try {
131
- // Use pgrep + ps directly — avoids spawning bash, grep, awk pipeline
132
- let psOut = "0";
133
- try {
134
- const pids = execFileSync("pgrep", ["-Ef", "WebKit|WebContent"], { encoding: "utf8" }).trim();
135
- if (pids) {
136
- const pidList = pids.split("\n").join(",");
137
- psOut = execFileSync("ps", ["-p", pidList, "-o", "rss="], { encoding: "utf8" }).trim();
138
- const totalKB = psOut.split("\n").reduce((sum, l) => sum + (parseInt(l.trim(), 10) || 0), 0);
139
- psOut = String(totalKB / 1024);
178
+ const webkitMB = _getWebKitMemoryMB();
179
+ if (webkitMB <= 0) return;
180
+
181
+ // Warning threshold — log only (once per 5 min)
182
+ if (webkitMB > WEBKIT_WARNING_THRESHOLD_MB && webkitMB <= WEBKIT_MEMORY_LIMIT_MB) {
183
+ if (Date.now() - _lastMemoryWarningTime > 300000) {
184
+ console.error(`[Safari MCP] ⚠️ WebKit memory warning: ${Math.round(webkitMB)}MB (threshold: ${WEBKIT_WARNING_THRESHOLD_MB}MB, limit: ${WEBKIT_MEMORY_LIMIT_MB}MB) — ${_openedTabs.size} MCP tabs open`);
185
+ _lastMemoryWarningTime = Date.now();
140
186
  }
141
- } catch { psOut = "0"; }
142
- const webkitMB = parseFloat(psOut) || 0;
187
+ }
188
+
189
+ // Action threshold — close oldest tabs (up to 2) to recover memory
143
190
  if (webkitMB > WEBKIT_MEMORY_LIMIT_MB && _openedTabs.size > 1) {
144
- console.error(`[Safari MCP] ⚠️ WebKit using ${Math.round(webkitMB)}MB (limit: ${WEBKIT_MEMORY_LIMIT_MB}MB) — closing oldest MCP tab`);
145
- let oldestIdx = null, oldestTime = Infinity;
146
- for (const [idx, info] of _openedTabs) {
147
- if (info.openedAt < oldestTime) { oldestTime = info.openedAt; oldestIdx = idx; }
148
- }
149
- if (oldestIdx !== null) {
150
- try {
151
- // Re-resolve by URL before closing (indices shift as tabs change)
152
- const info = _openedTabs.get(oldestIdx);
153
- if (info?.url) {
154
- const tabs = await safari.listTabs();
155
- const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
156
- const match = parsed.find(t => t.url === info.url);
157
- if (match) {
158
- safari.setActiveTabIndex(match.index);
159
- await safari.closeTab();
160
- }
161
- }
162
- } catch {}
163
- _untrackTab(oldestIdx);
191
+ console.error(`[Safari MCP] 🔴 WebKit over limit: ${Math.round(webkitMB)}MB — closing oldest MCP tabs`);
192
+ await _closeOldestMCPTab();
193
+ // If still over limit and have more tabs, close another
194
+ if (_openedTabs.size > 1) {
195
+ const afterMB = _getWebKitMemoryMB();
196
+ if (afterMB > WEBKIT_MEMORY_LIMIT_MB) {
197
+ await _closeOldestMCPTab();
198
+ }
164
199
  }
165
200
  }
166
201
  } catch {}
167
- }, MEMORY_CHECK_INTERVAL_MS);
202
+ }, checkInterval);
168
203
  _memoryCheckTimer.unref();
169
204
  }
170
205
 
@@ -1231,12 +1266,17 @@ server.tool(
1231
1266
  // Find the new tab(s) — could be about:blank initially (OAuth popups)
1232
1267
  for (const tab of nowTabs) {
1233
1268
  if (!beforeIds.has(`${tab.index}:${tab.url}`)) {
1234
- // Wait briefly for about:blank to resolve to actual URL
1269
+ // Wait for about:blank to resolve to actual URL — dynamic polling instead of fixed delay
1235
1270
  if (tab.url === 'about:blank') {
1236
- await new Promise(r => setTimeout(r, 1000));
1237
- const refreshed = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
1238
- const refreshedTabs = typeof refreshed === 'string' ? JSON.parse(refreshed) : refreshed;
1239
- const resolved = refreshedTabs.find(t => t.index === tab.index);
1271
+ let resolved = null;
1272
+ for (let attempt = 0; attempt < 10; attempt++) {
1273
+ await new Promise(r => setTimeout(r, 300)); // 300ms intervals, max 3s total
1274
+ const refreshed = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
1275
+ const refreshedTabs = typeof refreshed === 'string' ? JSON.parse(refreshed) : refreshed;
1276
+ resolved = refreshedTabs.find(t => t.index === tab.index);
1277
+ if (resolved && resolved.url !== 'about:blank') break;
1278
+ resolved = null;
1279
+ }
1240
1280
  if (resolved && resolved.url !== 'about:blank') {
1241
1281
  if (urlContains && !resolved.url.includes(urlContains)) continue;
1242
1282
  await extensionOrFallback("switch_tab", { index: resolved.index }, () => safari.switchTab(resolved.index));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.6.1",
3
+ "version": "2.7.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",
@@ -28,11 +28,20 @@
28
28
  "web-automation",
29
29
  "anthropic",
30
30
  "windsurf",
31
- "continue-dev"
31
+ "continue-dev",
32
+ "devtools",
33
+ "testing",
34
+ "scraping",
35
+ "screenshot",
36
+ "ai-automation"
32
37
  ],
33
38
  "author": "Achiya Automation <info@achiya-automation.com>",
34
39
  "license": "MIT",
35
- "homepage": "https://github.com/achiya-automation/safari-mcp",
40
+ "homepage": "https://achiya-automation.com",
41
+ "funding": {
42
+ "type": "github",
43
+ "url": "https://github.com/sponsors/achiya-automation"
44
+ },
36
45
  "repository": {
37
46
  "type": "git",
38
47
  "url": "https://github.com/achiya-automation/safari-mcp.git"
@@ -53,7 +62,6 @@
53
62
  "safari-helper",
54
63
  "safari-helper.swift",
55
64
  "extension/",
56
- "xcode/",
57
65
  "glama.json",
58
66
  "LICENSE",
59
67
  "README.md"