safari-mcp 2.6.0 → 2.7.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.
Files changed (38) hide show
  1. package/extension/background.js +22 -3
  2. package/index.js +95 -46
  3. package/package.json +12 -4
  4. package/safari.js +188 -120
  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
  }
@@ -2108,10 +2110,25 @@ async function waitForTabSettled(tabId, timeout = 3000) {
2108
2110
  // No else needed — if not loading, page is already settled
2109
2111
  }
2110
2112
 
2111
- // ========== KEEP-ALIVE VIA ALARMS ==========
2113
+ // ========== KEEP-ALIVE VIA ALARMS + HEARTBEAT ==========
2112
2114
  // 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.
2115
+ // Three-layer strategy:
2116
+ // 1. Active fetch() in pollForCommands() keeps the worker alive while connected
2117
+ // 2. Storage write every 20s keeps the worker alive between polls (Safari counts storage access as activity)
2118
+ // 3. browser.alarms (1 min minimum) re-wakes the worker if it was terminated
2119
+ let _heartbeatTimer = null;
2120
+ function _startHeartbeat() {
2121
+ if (_heartbeatTimer) return;
2122
+ _heartbeatTimer = setInterval(() => {
2123
+ if (_enabled) {
2124
+ browser.storage.local.set({ _heartbeat: Date.now() }).catch(() => {});
2125
+ }
2126
+ }, 20000); // Every 20s — keeps service worker alive between alarm intervals
2127
+ }
2128
+ function _stopHeartbeat() {
2129
+ if (_heartbeatTimer) { clearInterval(_heartbeatTimer); _heartbeatTimer = null; }
2130
+ }
2131
+
2115
2132
  browser.alarms.create("keepalive", { periodInMinutes: 1 });
2116
2133
  browser.alarms.onAlarm.addListener((alarm) => {
2117
2134
  if (alarm.name === "keepalive" || alarm.name === "reconnect") {
@@ -2119,6 +2136,8 @@ browser.alarms.onAlarm.addListener((alarm) => {
2119
2136
  if (!isConnected && _enabled && !_reconnectTimer) {
2120
2137
  scheduleReconnect();
2121
2138
  }
2139
+ // Restart heartbeat in case it was lost on worker restart
2140
+ if (_enabled && !_heartbeatTimer) _startHeartbeat();
2122
2141
  }
2123
2142
  });
2124
2143
 
package/index.js CHANGED
@@ -68,12 +68,23 @@ const _ownedTabURLs = new Set();
68
68
  function _isURLOwned(url) {
69
69
  if (!url) return false;
70
70
  if (_ownedTabURLs.has(url)) return true;
71
- // Match ignoring query params / fragments (URL may change slightly after load)
72
- const urlBase = url.split('?')[0].split('#')[0];
71
+ // Match ignoring query params / fragments / trailing slashes (URL may change slightly after load)
72
+ const normalize = (u) => u.split('?')[0].split('#')[0].replace(/\/+$/, '');
73
+ const urlBase = normalize(url);
73
74
  for (const owned of _ownedTabURLs) {
74
- const ownedBase = owned.split('?')[0].split('#')[0];
75
+ const ownedBase = normalize(owned);
75
76
  if (urlBase === ownedBase) return true;
76
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 {}
77
88
  return false;
78
89
  }
79
90
 
@@ -122,48 +133,73 @@ async function _cleanupTabs() {
122
133
  _openedTabs.clear();
123
134
  }
124
135
 
125
- // 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
126
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
+
127
174
  function _startMemoryMonitor() {
175
+ const checkInterval = Math.min(MEMORY_CHECK_INTERVAL_MS, 30000); // Max 30s between checks
128
176
  _memoryCheckTimer = setInterval(async () => {
129
177
  try {
130
- // Use pgrep + ps directly — avoids spawning bash, grep, awk pipeline
131
- let psOut = "0";
132
- try {
133
- const pids = execFileSync("pgrep", ["-Ef", "WebKit|WebContent"], { encoding: "utf8" }).trim();
134
- if (pids) {
135
- const pidList = pids.split("\n").join(",");
136
- psOut = execFileSync("ps", ["-p", pidList, "-o", "rss="], { encoding: "utf8" }).trim();
137
- const totalKB = psOut.split("\n").reduce((sum, l) => sum + (parseInt(l.trim(), 10) || 0), 0);
138
- 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();
139
186
  }
140
- } catch { psOut = "0"; }
141
- const webkitMB = parseFloat(psOut) || 0;
187
+ }
188
+
189
+ // Action threshold — close oldest tabs (up to 2) to recover memory
142
190
  if (webkitMB > WEBKIT_MEMORY_LIMIT_MB && _openedTabs.size > 1) {
143
- console.error(`[Safari MCP] ⚠️ WebKit using ${Math.round(webkitMB)}MB (limit: ${WEBKIT_MEMORY_LIMIT_MB}MB) — closing oldest MCP tab`);
144
- let oldestIdx = null, oldestTime = Infinity;
145
- for (const [idx, info] of _openedTabs) {
146
- if (info.openedAt < oldestTime) { oldestTime = info.openedAt; oldestIdx = idx; }
147
- }
148
- if (oldestIdx !== null) {
149
- try {
150
- // Re-resolve by URL before closing (indices shift as tabs change)
151
- const info = _openedTabs.get(oldestIdx);
152
- if (info?.url) {
153
- const tabs = await safari.listTabs();
154
- const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
155
- const match = parsed.find(t => t.url === info.url);
156
- if (match) {
157
- safari.setActiveTabIndex(match.index);
158
- await safari.closeTab();
159
- }
160
- }
161
- } catch {}
162
- _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
+ }
163
199
  }
164
200
  }
165
201
  } catch {}
166
- }, MEMORY_CHECK_INTERVAL_MS);
202
+ }, checkInterval);
167
203
  _memoryCheckTimer.unref();
168
204
  }
169
205
 
@@ -1116,21 +1152,29 @@ server.tool(
1116
1152
  }
1117
1153
  }
1118
1154
 
1119
- const result = await extensionOrFallback(
1155
+ const rawResult = await extensionOrFallback(
1120
1156
  "new_tab", { url },
1121
1157
  () => safari.newTab(url)
1122
1158
  );
1159
+ // AppleScript fallback returns a JSON string; extension returns an object — normalize
1160
+ let result = rawResult;
1161
+ if (typeof rawResult === 'string') {
1162
+ try { result = JSON.parse(rawResult); } catch {}
1163
+ }
1123
1164
  // Sync safari.js tracking when extension handled new_tab
1124
1165
  if (result?.tabIndex) {
1125
1166
  safari.setActiveTabIndex(result.tabIndex);
1126
1167
  _trackTab(result.tabIndex, url);
1127
1168
  }
1128
- if (result?.url) {
1129
- safari.setActiveTabURL(result.url);
1169
+ if (result?.url || url) {
1170
+ // Prefer requested URL over about:blank for tracking (page hasn't loaded yet)
1171
+ const trackUrl = (!result?.url || result.url === 'about:blank') && url ? url : result.url;
1172
+ safari.setActiveTabURL(trackUrl);
1130
1173
  // Also register actual URL (may differ from requested due to redirects)
1131
- _addOwnedURL(result.url);
1174
+ _addOwnedURL(trackUrl);
1175
+ if (url && url !== trackUrl) _addOwnedURL(url); // also own the requested URL (handles www redirects)
1132
1176
  }
1133
- return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
1177
+ return { content: [{ type: "text", text: typeof rawResult === 'string' ? rawResult : JSON.stringify(result) }] };
1134
1178
  }
1135
1179
  );
1136
1180
 
@@ -1222,12 +1266,17 @@ server.tool(
1222
1266
  // Find the new tab(s) — could be about:blank initially (OAuth popups)
1223
1267
  for (const tab of nowTabs) {
1224
1268
  if (!beforeIds.has(`${tab.index}:${tab.url}`)) {
1225
- // 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
1226
1270
  if (tab.url === 'about:blank') {
1227
- await new Promise(r => setTimeout(r, 1000));
1228
- const refreshed = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
1229
- const refreshedTabs = typeof refreshed === 'string' ? JSON.parse(refreshed) : refreshed;
1230
- 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
+ }
1231
1280
  if (resolved && resolved.url !== 'about:blank') {
1232
1281
  if (urlContains && !resolved.url.includes(urlContains)) continue;
1233
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.0",
3
+ "version": "2.7.0",
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"