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.
- package/extension/background.js +36 -5
- package/index.js +78 -38
- package/package.json +12 -4
- package/safari.js +203 -128
- package/xcode/Safari MCP/Safari MCP.xcodeproj/project.pbxproj +0 -991
- package/xcode/Safari MCP/Safari MCP.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -7
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AccentColor.colorset/Contents.json +0 -11
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/Contents.json +0 -98
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-128@1x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-128@2x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-16@1x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-16@2x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-256@1x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-256@2x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-32@1x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-32@2x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-512@1x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-512@2x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/universal-icon-1024@1x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/Contents.json +0 -6
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/LargeIcon.imageset/Contents.json +0 -21
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/LargeIcon.imageset/icon-128.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Resources/Base.lproj/Main.html +0 -20
- package/xcode/Safari MCP/Shared (App)/Resources/Icon.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Resources/Script.js +0 -24
- package/xcode/Safari MCP/Shared (App)/Resources/Style.css +0 -61
- package/xcode/Safari MCP/Shared (App)/ViewController.swift +0 -81
- package/xcode/Safari MCP/Shared (Extension)/SafariWebExtensionHandler.swift +0 -42
- package/xcode/Safari MCP/iOS (App)/AppDelegate.swift +0 -24
- package/xcode/Safari MCP/iOS (App)/Base.lproj/LaunchScreen.storyboard +0 -36
- package/xcode/Safari MCP/iOS (App)/Base.lproj/Main.storyboard +0 -38
- package/xcode/Safari MCP/iOS (App)/Info.plist +0 -27
- package/xcode/Safari MCP/iOS (App)/SceneDelegate.swift +0 -18
- package/xcode/Safari MCP/iOS (Extension)/Info.plist +0 -13
- package/xcode/Safari MCP/macOS (App)/AppDelegate.swift +0 -21
- package/xcode/Safari MCP/macOS (App)/Base.lproj/Main.storyboard +0 -124
- package/xcode/Safari MCP/macOS (App)/Info.plist +0 -8
- package/xcode/Safari MCP/macOS (Extension)/Info.plist +0 -13
package/extension/background.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
2114
|
-
//
|
|
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 —
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
}
|
|
142
|
-
|
|
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]
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
},
|
|
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
|
|
1269
|
+
// Wait for about:blank to resolve to actual URL — dynamic polling instead of fixed delay
|
|
1235
1270
|
if (tab.url === 'about:blank') {
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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.
|
|
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://
|
|
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"
|