safari-mcp 2.17.0 → 2.17.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/README.md +3 -8
- package/extension/background.js +24 -0
- package/extension/command-content.js +384 -71
- package/extension/manifest.json +1 -1
- package/index.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,14 +15,9 @@
|
|
|
15
15
|
[](https://opensource.org/licenses/MIT)
|
|
16
16
|
[](https://www.apple.com/macos/)
|
|
17
17
|
|
|
18
|
-
[
|
|
19
|
-
[
|
|
20
|
-
[
|
|
21
|
-
[](https://mcpscoreboard.com/server/05977769-8762-4e89-aff3-a0c5776843bb/)
|
|
22
|
-
|
|
23
|
-
<a href="vscode:mcp/install?%7B%22safari-mcp%22%3A%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D%7D"><img src="https://img.shields.io/badge/VS_Code-Install_Server-0078d4?logo=visual-studio-code&logoColor=white" alt="Install in VS Code"></a>
|
|
24
|
-
<a href="https://insiders.vscode.dev/redirect?url=vscode-insiders:mcp/install?%7B%22safari-mcp%22%3A%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D%7D"><img src="https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?logo=visual-studio-code&logoColor=white" alt="Install in VS Code Insiders"></a>
|
|
25
|
-
<a href="cursor://anysphere.cursor-deeplink/mcp/install?name=safari-mcp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D"><img src="https://img.shields.io/badge/Cursor-Install_Server-f97316?logo=cursor&logoColor=white" alt="Install in Cursor"></a>
|
|
18
|
+
[Install in VS Code](vscode:mcp/install?%7B%22safari-mcp%22%3A%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D%7D) ·
|
|
19
|
+
[VS Code Insiders](https://insiders.vscode.dev/redirect?url=vscode-insiders:mcp/install?%7B%22safari-mcp%22%3A%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D%7D) ·
|
|
20
|
+
[Install in Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=safari-mcp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D)
|
|
26
21
|
|
|
27
22
|
**97 tools** · **No Chrome/Puppeteer/Playwright needed** · **~5ms per command** · **60% less CPU than Chrome**
|
|
28
23
|
|
package/extension/background.js
CHANGED
|
@@ -428,6 +428,7 @@ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
|
428
428
|
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
|
|
429
429
|
_reconnectDelay = 3000;
|
|
430
430
|
_stopHeartbeat();
|
|
431
|
+
_disconnectContentKeepalivePort();
|
|
431
432
|
updateBadge("OFF");
|
|
432
433
|
} else {
|
|
433
434
|
updateBadge("");
|
|
@@ -449,9 +450,31 @@ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
|
449
450
|
// it into a command channel: accept only the exact name, require a real sender tab,
|
|
450
451
|
// inspect only the fixed ping type, and never receive or return tab/session data.
|
|
451
452
|
const _CONTENT_KEEPALIVE_PORT_NAME = "mcp-content-keepalive-v1";
|
|
453
|
+
let _contentKeepalivePort = null;
|
|
454
|
+
function _disconnectContentKeepalivePort() {
|
|
455
|
+
const port = _contentKeepalivePort;
|
|
456
|
+
_contentKeepalivePort = null;
|
|
457
|
+
if (port) {
|
|
458
|
+
try { port.disconnect(); } catch (_) {}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
452
461
|
browser.runtime.onConnect.addListener((port) => {
|
|
453
462
|
if (port?.name !== _CONTENT_KEEPALIVE_PORT_NAME) return;
|
|
454
463
|
if (!Number.isInteger(port?.sender?.tab?.id)) return;
|
|
464
|
+
if (!_enabled || _bridgeWorkerSuperseded || _bridgeWorkerRetiring) {
|
|
465
|
+
try { port.disconnect(); } catch (_) {}
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
// The content-side lease should already converge on one sender. Enforce the same
|
|
469
|
+
// invariant here so a brief storage-election race cannot leave many live Ports.
|
|
470
|
+
if (_contentKeepalivePort && _contentKeepalivePort !== port) {
|
|
471
|
+
try { port.disconnect(); } catch (_) {}
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
_contentKeepalivePort = port;
|
|
475
|
+
port.onDisconnect.addListener(() => {
|
|
476
|
+
if (_contentKeepalivePort === port) _contentKeepalivePort = null;
|
|
477
|
+
});
|
|
455
478
|
port.onMessage.addListener((message) => {
|
|
456
479
|
if (message?.type !== "ping") return;
|
|
457
480
|
// Intentionally empty: delivery of this event is the keepalive.
|
|
@@ -622,6 +645,7 @@ async function pollForCommands(pollGeneration) {
|
|
|
622
645
|
isConnected = false;
|
|
623
646
|
updateBadge("");
|
|
624
647
|
_stopHeartbeat();
|
|
648
|
+
_disconnectContentKeepalivePort();
|
|
625
649
|
return;
|
|
626
650
|
}
|
|
627
651
|
if (res.status === 401 || res.status === 403 || res.status === 409) {
|
|
@@ -7,38 +7,110 @@
|
|
|
7
7
|
|
|
8
8
|
// Register on every execution. Safari can preserve the isolated-world globals while
|
|
9
9
|
// invalidating the old runtime listener during an extension update; a persistent
|
|
10
|
-
// boolean guard then falsely
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// boolean guard would then falsely say the bridge is installed. Hot reload can also
|
|
11
|
+
// leave the old listener alive, so every listener belongs to a revocable generation:
|
|
12
|
+
// only the newest generation may execute a command, even if removing the old runtime
|
|
13
|
+
// listener fails because its extension runtime has already been invalidated.
|
|
13
14
|
|
|
14
15
|
// Safari may unload an MV3 service worker even while it owns an HTTP long-poll. A
|
|
15
|
-
// content script lives with the page, so
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
16
|
+
// content script lives with the page, so elect ONE top-frame page to send a narrow
|
|
17
|
+
// extension-Port ping inside Safari's roughly 30-second idle window. Visible pages
|
|
18
|
+
// take priority; when Safari marks every background-app page hidden, a short storage
|
|
19
|
+
// lease provides one fallback leader. Non-leaders have no Port and do no periodic
|
|
20
|
+
// storage work. The shared record and ping contain no URL, DOM data, receipt, or other
|
|
21
|
+
// authority.
|
|
22
|
+
(() => {
|
|
23
|
+
const previousKeepaliveState = globalThis.__mcpKeepaliveState;
|
|
24
|
+
const inheritedKeepaliveId = /^[0-9a-f]{24}$/.test(String(previousKeepaliveState?.leaseId || ""))
|
|
25
|
+
? previousKeepaliveState.leaseId
|
|
26
|
+
: "";
|
|
27
|
+
try { previousKeepaliveState?.cleanup?.(); } catch (_) {}
|
|
28
|
+
|
|
20
29
|
const _MCP_KEEPALIVE_PORT_NAME = "mcp-content-keepalive-v1";
|
|
21
|
-
const
|
|
30
|
+
const _MCP_KEEPALIVE_LEASE_KEY = "mcpContentKeepaliveLeaseV1";
|
|
31
|
+
const _MCP_KEEPALIVE_LEASE_MS = 26000;
|
|
32
|
+
const _MCP_KEEPALIVE_RENEW_MS = 18000;
|
|
33
|
+
const _MCP_KEEPALIVE_PING_MS = 8000;
|
|
22
34
|
const _MCP_KEEPALIVE_RECONNECT_MS = 1500;
|
|
35
|
+
const _mcpKeepaliveInstanceId = inheritedKeepaliveId || (() => {
|
|
36
|
+
const bytes = new Uint8Array(12);
|
|
37
|
+
crypto.getRandomValues(bytes);
|
|
38
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
39
|
+
})();
|
|
23
40
|
let _mcpKeepalivePort = null;
|
|
24
41
|
let _mcpKeepaliveReconnectTimer = null;
|
|
25
|
-
let
|
|
42
|
+
let _mcpKeepaliveLeaseTimer = null;
|
|
43
|
+
let _mcpKeepaliveLeaseTimerDueAt = 0;
|
|
44
|
+
let _mcpKeepalivePingTimer = null;
|
|
45
|
+
let _mcpKeepaliveEnabled = true;
|
|
46
|
+
let _mcpKeepaliveEnabledRevision = 0;
|
|
47
|
+
let _mcpKeepaliveLeader = false;
|
|
48
|
+
let _mcpKeepaliveClaiming = false;
|
|
49
|
+
let _mcpKeepaliveDisposed = false;
|
|
50
|
+
|
|
51
|
+
function _isMcpKeepaliveTopFrame() {
|
|
52
|
+
try { return window.top === window; } catch (_) { return false; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _mcpKeepaliveVisible() {
|
|
56
|
+
return document.visibilityState === "visible";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function _validMcpKeepaliveLease(value, now = Date.now()) {
|
|
60
|
+
return value && typeof value.id === "string" && /^[0-9a-f]{24}$/.test(value.id)
|
|
61
|
+
&& Number.isFinite(value.expiresAt) && value.expiresAt > now
|
|
62
|
+
&& value.expiresAt <= now + (_MCP_KEEPALIVE_LEASE_MS * 2)
|
|
63
|
+
? value
|
|
64
|
+
: null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function _clearMcpKeepaliveLeaseTimer() {
|
|
68
|
+
if (_mcpKeepaliveLeaseTimer) clearTimeout(_mcpKeepaliveLeaseTimer);
|
|
69
|
+
_mcpKeepaliveLeaseTimer = null;
|
|
70
|
+
_mcpKeepaliveLeaseTimerDueAt = 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function _scheduleMcpKeepaliveLeaseCheck(delayMs) {
|
|
74
|
+
if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled || !_isMcpKeepaliveTopFrame()) return;
|
|
75
|
+
const boundedDelay = Math.max(0, Math.min(Number(delayMs) || 0, _MCP_KEEPALIVE_LEASE_MS));
|
|
76
|
+
const dueAt = Date.now() + boundedDelay;
|
|
77
|
+
if (_mcpKeepaliveLeaseTimer && _mcpKeepaliveLeaseTimerDueAt <= dueAt) return;
|
|
78
|
+
_clearMcpKeepaliveLeaseTimer();
|
|
79
|
+
_mcpKeepaliveLeaseTimerDueAt = dueAt;
|
|
80
|
+
_mcpKeepaliveLeaseTimer = setTimeout(() => {
|
|
81
|
+
if (_mcpKeepaliveDisposed) return;
|
|
82
|
+
_mcpKeepaliveLeaseTimer = null;
|
|
83
|
+
_mcpKeepaliveLeaseTimerDueAt = 0;
|
|
84
|
+
if (_mcpKeepaliveLeader) _renewMcpKeepaliveLeaseAndPing();
|
|
85
|
+
else _attemptMcpKeepaliveClaim();
|
|
86
|
+
}, boundedDelay);
|
|
87
|
+
}
|
|
26
88
|
|
|
27
89
|
function _scheduleMcpKeepaliveReconnect() {
|
|
28
|
-
if (_mcpKeepaliveReconnectTimer) return;
|
|
90
|
+
if (_mcpKeepaliveDisposed || _mcpKeepaliveReconnectTimer || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return;
|
|
29
91
|
_mcpKeepaliveReconnectTimer = setTimeout(() => {
|
|
30
92
|
_mcpKeepaliveReconnectTimer = null;
|
|
31
|
-
|
|
93
|
+
if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return;
|
|
94
|
+
const port = _openMcpKeepalivePort();
|
|
95
|
+
if (port) {
|
|
96
|
+
try { port.postMessage({ type: "ping" }); }
|
|
97
|
+
catch (_) {
|
|
98
|
+
if (_mcpKeepalivePort === port) _mcpKeepalivePort = null;
|
|
99
|
+
try { port.disconnect(); } catch (_) {}
|
|
100
|
+
_scheduleMcpKeepaliveReconnect();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
32
103
|
}, _MCP_KEEPALIVE_RECONNECT_MS);
|
|
33
104
|
}
|
|
34
105
|
|
|
35
106
|
function _openMcpKeepalivePort() {
|
|
107
|
+
if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return null;
|
|
36
108
|
if (_mcpKeepalivePort) return _mcpKeepalivePort;
|
|
37
109
|
try {
|
|
38
110
|
const port = browser.runtime.connect({ name: _MCP_KEEPALIVE_PORT_NAME });
|
|
39
111
|
_mcpKeepalivePort = port;
|
|
40
112
|
port.onDisconnect.addListener(() => {
|
|
41
|
-
if (_mcpKeepalivePort !== port) return;
|
|
113
|
+
if (_mcpKeepaliveDisposed || _mcpKeepalivePort !== port) return;
|
|
42
114
|
_mcpKeepalivePort = null;
|
|
43
115
|
_scheduleMcpKeepaliveReconnect();
|
|
44
116
|
});
|
|
@@ -49,58 +121,248 @@ function _openMcpKeepalivePort() {
|
|
|
49
121
|
}
|
|
50
122
|
}
|
|
51
123
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
124
|
+
function _closeMcpKeepalivePort() {
|
|
125
|
+
const port = _mcpKeepalivePort;
|
|
126
|
+
_mcpKeepalivePort = null;
|
|
127
|
+
if (port) {
|
|
128
|
+
try { port.disconnect(); } catch (_) {}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _clearMcpKeepalivePingTimer() {
|
|
133
|
+
if (_mcpKeepalivePingTimer) clearTimeout(_mcpKeepalivePingTimer);
|
|
134
|
+
_mcpKeepalivePingTimer = null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function _sendMcpKeepalivePing() {
|
|
138
|
+
if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return;
|
|
139
|
+
_clearMcpKeepalivePingTimer();
|
|
140
|
+
const port = _openMcpKeepalivePort();
|
|
141
|
+
if (port) {
|
|
142
|
+
try { port.postMessage({ type: "ping" }); }
|
|
143
|
+
catch (_) {
|
|
144
|
+
if (_mcpKeepalivePort === port) _mcpKeepalivePort = null;
|
|
145
|
+
try { port.disconnect(); } catch (_) {}
|
|
146
|
+
_scheduleMcpKeepaliveReconnect();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
_mcpKeepalivePingTimer = setTimeout(_sendMcpKeepalivePing, _MCP_KEEPALIVE_PING_MS);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function _stopMcpKeepaliveLeadership() {
|
|
153
|
+
_mcpKeepaliveLeader = false;
|
|
154
|
+
_clearMcpKeepaliveLeaseTimer();
|
|
155
|
+
if (_mcpKeepaliveReconnectTimer) clearTimeout(_mcpKeepaliveReconnectTimer);
|
|
156
|
+
_mcpKeepaliveReconnectTimer = null;
|
|
157
|
+
_clearMcpKeepalivePingTimer();
|
|
158
|
+
_closeMcpKeepalivePort();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function _renewMcpKeepaliveLeaseAndPing() {
|
|
162
|
+
if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled || !_isMcpKeepaliveTopFrame()) return;
|
|
163
|
+
const stored = await browser.storage.local.get(_MCP_KEEPALIVE_LEASE_KEY).catch(() => ({}));
|
|
164
|
+
if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return;
|
|
165
|
+
const current = _validMcpKeepaliveLease(stored?.[_MCP_KEEPALIVE_LEASE_KEY]);
|
|
166
|
+
if (!current || current.id !== _mcpKeepaliveInstanceId) {
|
|
167
|
+
_stopMcpKeepaliveLeadership();
|
|
168
|
+
_considerMcpKeepaliveLeadership(current);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const lease = {
|
|
172
|
+
id: _mcpKeepaliveInstanceId,
|
|
173
|
+
expiresAt: Date.now() + _MCP_KEEPALIVE_LEASE_MS,
|
|
174
|
+
visible: _mcpKeepaliveVisible(),
|
|
175
|
+
};
|
|
176
|
+
await browser.storage.local.set({ [_MCP_KEEPALIVE_LEASE_KEY]: lease }).catch(() => {});
|
|
177
|
+
if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return;
|
|
178
|
+
_sendMcpKeepalivePing();
|
|
179
|
+
_scheduleMcpKeepaliveLeaseCheck(_MCP_KEEPALIVE_RENEW_MS);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function _becomeMcpKeepaliveLeader() {
|
|
183
|
+
if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled || !_isMcpKeepaliveTopFrame()) return;
|
|
184
|
+
_mcpKeepaliveLeader = true;
|
|
185
|
+
_clearMcpKeepaliveLeaseTimer();
|
|
186
|
+
_renewMcpKeepaliveLeaseAndPing();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function _attemptMcpKeepaliveClaim() {
|
|
190
|
+
if (_mcpKeepaliveDisposed || _mcpKeepaliveClaiming || _mcpKeepaliveLeader || !_mcpKeepaliveEnabled || !_isMcpKeepaliveTopFrame()) return;
|
|
191
|
+
_mcpKeepaliveClaiming = true;
|
|
55
192
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
193
|
+
const stored = await browser.storage.local.get(_MCP_KEEPALIVE_LEASE_KEY).catch(() => ({}));
|
|
194
|
+
if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled) return;
|
|
195
|
+
const current = _validMcpKeepaliveLease(stored?.[_MCP_KEEPALIVE_LEASE_KEY]);
|
|
196
|
+
const mayPreemptHidden = _mcpKeepaliveVisible() && current && current.visible !== true;
|
|
197
|
+
if (current && current.id !== _mcpKeepaliveInstanceId && !mayPreemptHidden) {
|
|
198
|
+
const jitter = _mcpKeepaliveVisible() ? Math.random() * 100 : 250 + Math.random() * 1000;
|
|
199
|
+
_scheduleMcpKeepaliveLeaseCheck(current.expiresAt - Date.now() + jitter);
|
|
63
200
|
return;
|
|
64
201
|
}
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
202
|
+
const candidate = {
|
|
203
|
+
id: _mcpKeepaliveInstanceId,
|
|
204
|
+
expiresAt: Date.now() + _MCP_KEEPALIVE_LEASE_MS,
|
|
205
|
+
visible: _mcpKeepaliveVisible(),
|
|
206
|
+
};
|
|
207
|
+
const storedCandidate = await browser.storage.local
|
|
208
|
+
.set({ [_MCP_KEEPALIVE_LEASE_KEY]: candidate })
|
|
209
|
+
.then(() => true)
|
|
210
|
+
.catch(() => false);
|
|
211
|
+
if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled) return;
|
|
212
|
+
if (!storedCandidate) {
|
|
213
|
+
_scheduleMcpKeepaliveLeaseCheck(1000 + Math.random() * 1000);
|
|
214
|
+
return;
|
|
72
215
|
}
|
|
216
|
+
// Storage writes are serialized but not compare-and-swap. A short settle window
|
|
217
|
+
// lets simultaneous candidates overwrite one another; only the final stored id
|
|
218
|
+
// is allowed to open a Port.
|
|
219
|
+
await new Promise((resolve) => setTimeout(resolve, 75 + Math.random() * 75));
|
|
220
|
+
if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled) return;
|
|
221
|
+
const verified = await browser.storage.local.get(_MCP_KEEPALIVE_LEASE_KEY).catch(() => ({}));
|
|
222
|
+
if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled) return;
|
|
223
|
+
if (verified?.[_MCP_KEEPALIVE_LEASE_KEY]?.id === _mcpKeepaliveInstanceId) {
|
|
224
|
+
_becomeMcpKeepaliveLeader();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
_considerMcpKeepaliveLeadership(verified?.[_MCP_KEEPALIVE_LEASE_KEY]);
|
|
73
228
|
} finally {
|
|
74
|
-
|
|
229
|
+
_mcpKeepaliveClaiming = false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function _considerMcpKeepaliveLeadership(knownLease) {
|
|
234
|
+
if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled || !_isMcpKeepaliveTopFrame()) return;
|
|
235
|
+
const current = _validMcpKeepaliveLease(knownLease);
|
|
236
|
+
if (current?.id === _mcpKeepaliveInstanceId) {
|
|
237
|
+
if (!_mcpKeepaliveLeader && !_mcpKeepaliveClaiming) _becomeMcpKeepaliveLeader();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (current) {
|
|
241
|
+
if (_mcpKeepaliveVisible() && current.visible !== true) {
|
|
242
|
+
_scheduleMcpKeepaliveLeaseCheck(Math.random() * 100);
|
|
243
|
+
} else {
|
|
244
|
+
const jitter = _mcpKeepaliveVisible() ? Math.random() * 100 : 250 + Math.random() * 1000;
|
|
245
|
+
_scheduleMcpKeepaliveLeaseCheck(current.expiresAt - Date.now() + jitter);
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
75
248
|
}
|
|
249
|
+
// Let a visible page win first. If Safari reports every background-app page as
|
|
250
|
+
// hidden, randomized fallback candidates still converge on one verified lease.
|
|
251
|
+
const initialDelay = _mcpKeepaliveVisible()
|
|
252
|
+
? Math.random() * 100
|
|
253
|
+
: 1000 + Math.random() * 1500;
|
|
254
|
+
_scheduleMcpKeepaliveLeaseCheck(initialDelay);
|
|
76
255
|
}
|
|
77
256
|
|
|
78
|
-
|
|
79
|
-
|
|
257
|
+
function _onMcpKeepaliveVisibilityChange() {
|
|
258
|
+
if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled) return;
|
|
259
|
+
if (_mcpKeepaliveLeader) {
|
|
260
|
+
_renewMcpKeepaliveLeaseAndPing();
|
|
261
|
+
} else {
|
|
262
|
+
browser.storage.local.get(_MCP_KEEPALIVE_LEASE_KEY)
|
|
263
|
+
.then((stored) => _considerMcpKeepaliveLeadership(stored?.[_MCP_KEEPALIVE_LEASE_KEY]))
|
|
264
|
+
.catch(() => {});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function _initializeMcpKeepalive() {
|
|
269
|
+
if (_mcpKeepaliveDisposed || !_isMcpKeepaliveTopFrame()) return;
|
|
270
|
+
document.addEventListener("visibilitychange", _onMcpKeepaliveVisibilityChange);
|
|
271
|
+
browser.storage.onChanged.addListener(_onMcpKeepaliveStorageChanged);
|
|
272
|
+
const enabledRevision = _mcpKeepaliveEnabledRevision;
|
|
273
|
+
const stored = await browser.storage.local.get(["mcpEnabled", _MCP_KEEPALIVE_LEASE_KEY]).catch(() => ({}));
|
|
274
|
+
if (_mcpKeepaliveDisposed || enabledRevision !== _mcpKeepaliveEnabledRevision) return;
|
|
275
|
+
_mcpKeepaliveEnabled = stored.mcpEnabled !== false;
|
|
276
|
+
if (_mcpKeepaliveEnabled) {
|
|
277
|
+
_considerMcpKeepaliveLeadership(stored[_MCP_KEEPALIVE_LEASE_KEY]);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
80
280
|
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
281
|
+
async function _onMcpKeepaliveStorageChanged(changes, areaName) {
|
|
282
|
+
if (_mcpKeepaliveDisposed || areaName !== "local") return;
|
|
283
|
+
if (changes.mcpEnabled) {
|
|
284
|
+
_mcpKeepaliveEnabledRevision += 1;
|
|
285
|
+
_mcpKeepaliveEnabled = changes.mcpEnabled.newValue !== false;
|
|
286
|
+
if (!_mcpKeepaliveEnabled) {
|
|
287
|
+
_stopMcpKeepaliveLeadership();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
86
290
|
}
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
291
|
+
if (changes[_MCP_KEEPALIVE_LEASE_KEY]) {
|
|
292
|
+
const next = _validMcpKeepaliveLease(changes[_MCP_KEEPALIVE_LEASE_KEY].newValue);
|
|
293
|
+
if (_mcpKeepaliveLeader && next?.id !== _mcpKeepaliveInstanceId) {
|
|
294
|
+
_stopMcpKeepaliveLeadership();
|
|
295
|
+
}
|
|
296
|
+
_considerMcpKeepaliveLeadership(next);
|
|
297
|
+
return;
|
|
90
298
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (message.type === "mcp-content-eval") {
|
|
96
|
-
// The relayed function is often async (the evaluate handler awaits inside it), so
|
|
97
|
-
// this must resolve before answering — returning the promise itself serialised to
|
|
98
|
-
// "{}" and silently produced an empty result.
|
|
99
|
-
handleEval(message.payload || {}).then(sendResponse);
|
|
100
|
-
return true;
|
|
299
|
+
if (changes.mcpEnabled && _mcpKeepaliveEnabled) {
|
|
300
|
+
const stored = await browser.storage.local.get(_MCP_KEEPALIVE_LEASE_KEY).catch(() => ({}));
|
|
301
|
+
if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled) return;
|
|
302
|
+
_considerMcpKeepaliveLeadership(stored?.[_MCP_KEEPALIVE_LEASE_KEY]);
|
|
101
303
|
}
|
|
102
|
-
|
|
103
|
-
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function _cleanupMcpKeepalive() {
|
|
307
|
+
_mcpKeepaliveDisposed = true;
|
|
308
|
+
_mcpKeepaliveEnabled = false;
|
|
309
|
+
_stopMcpKeepaliveLeadership();
|
|
310
|
+
document.removeEventListener("visibilitychange", _onMcpKeepaliveVisibilityChange);
|
|
311
|
+
try { browser.storage.onChanged.removeListener(_onMcpKeepaliveStorageChanged); } catch (_) {}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
globalThis.__mcpKeepaliveState = {
|
|
315
|
+
leaseId: _mcpKeepaliveInstanceId,
|
|
316
|
+
cleanup: _cleanupMcpKeepalive,
|
|
317
|
+
};
|
|
318
|
+
_initializeMcpKeepalive();
|
|
319
|
+
})();
|
|
320
|
+
|
|
321
|
+
(() => {
|
|
322
|
+
const previousCommandState = globalThis.__mcpContentCommandState;
|
|
323
|
+
const commandEvent = browser.runtime.onMessage;
|
|
324
|
+
const commandState = {
|
|
325
|
+
active: true,
|
|
326
|
+
event: commandEvent,
|
|
327
|
+
listener: null,
|
|
328
|
+
cleanup() {
|
|
329
|
+
this.active = false;
|
|
330
|
+
try { this.event.removeListener(this.listener); } catch (_) {}
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const listener = (message, _sender, sendResponse) => {
|
|
335
|
+
if (!commandState.active || globalThis.__mcpContentCommandState !== commandState) return false;
|
|
336
|
+
if (!message) return false;
|
|
337
|
+
if (message.type === "mcp-content-click") {
|
|
338
|
+
sendResponse(handleClick(message.payload || {}));
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
if (message.type === "mcp-content-fill") {
|
|
342
|
+
sendResponse(handleFill(message.payload || {}));
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
// Last-resort path for evaluate. On hardened SPAs (business.facebook.com) BOTH
|
|
346
|
+
// scripting.executeScript worlds hang forever instead of rejecting, while this
|
|
347
|
+
// already-injected listener keeps answering — clicks kept working there the whole
|
|
348
|
+
// time. Nothing is injected here, so there is no injection to stall on.
|
|
349
|
+
if (message.type === "mcp-content-eval") {
|
|
350
|
+
// The relayed function is often async (the evaluate handler awaits inside it), so
|
|
351
|
+
// this must resolve before answering — returning the promise itself serialised to
|
|
352
|
+
// "{}" and silently produced an empty result.
|
|
353
|
+
handleEval(message.payload || {}).then(sendResponse);
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
return false;
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
commandState.listener = listener;
|
|
360
|
+
browser.runtime.onMessage.addListener(listener);
|
|
361
|
+
globalThis.__mcpContentCommandState = commandState;
|
|
362
|
+
if (previousCommandState && previousCommandState !== commandState) {
|
|
363
|
+
previousCommandState.cleanup?.();
|
|
364
|
+
}
|
|
365
|
+
})();
|
|
104
366
|
|
|
105
367
|
// We are in the ISOLATED world: we can talk to the background, but the PAGE's CSP
|
|
106
368
|
// governs eval here, and a hardened page (business.facebook.com) forbids it — an
|
|
@@ -217,31 +479,31 @@ function handleClick(payload) {
|
|
|
217
479
|
let el = null;
|
|
218
480
|
|
|
219
481
|
if (ref) {
|
|
220
|
-
el =
|
|
482
|
+
el = deepQuery('[data-mcp-ref="' + cssString(ref) + '"]');
|
|
221
483
|
} else if (selector) {
|
|
222
|
-
|
|
484
|
+
el = deepQuery(selector);
|
|
223
485
|
} else if (text) {
|
|
224
|
-
|
|
225
|
-
"button,a,[role='button'],[role='link'],[role='tab'],[role='combobox'],input[type='submit'],input[type='button']"
|
|
486
|
+
el = deepFind(
|
|
487
|
+
"button,a,[role='button'],[role='link'],[role='tab'],[role='combobox'],input[type='submit'],input[type='button']",
|
|
488
|
+
(candidate) => isVisible(candidate) && (candidate.innerText || candidate.textContent || "").trim() === text
|
|
226
489
|
);
|
|
227
|
-
for (const candidate of candidates) {
|
|
228
|
-
if (isVisible(candidate) && (candidate.innerText || candidate.textContent || "").trim() === text) {
|
|
229
|
-
el = candidate;
|
|
230
|
-
break;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
490
|
if (!el) {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
491
|
+
for (const root of rootsIncludingOpenShadow()) {
|
|
492
|
+
const treeRoot = root === document ? document.body : root;
|
|
493
|
+
if (!treeRoot) continue;
|
|
494
|
+
const walker = document.createTreeWalker(treeRoot, NodeFilter.SHOW_TEXT);
|
|
495
|
+
while (walker.nextNode()) {
|
|
496
|
+
const value = (walker.currentNode.textContent || "").trim();
|
|
497
|
+
if (value === text && isVisible(walker.currentNode.parentElement)) {
|
|
498
|
+
el = walker.currentNode.parentElement;
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
240
501
|
}
|
|
502
|
+
if (el) break;
|
|
241
503
|
}
|
|
242
504
|
}
|
|
243
505
|
} else if (x !== undefined && y !== undefined) {
|
|
244
|
-
el =
|
|
506
|
+
el = deepElementFromPoint(Number(x), Number(y));
|
|
245
507
|
}
|
|
246
508
|
|
|
247
509
|
if (!el) return { ok: false, result: "Element not found" };
|
|
@@ -254,7 +516,7 @@ function handleClick(payload) {
|
|
|
254
516
|
const rect = el.getBoundingClientRect();
|
|
255
517
|
const cx = rect.left + rect.width / 2;
|
|
256
518
|
const cy = rect.top + rect.height / 2;
|
|
257
|
-
let from =
|
|
519
|
+
let from = deepElementFromPoint(cx, cy);
|
|
258
520
|
if (!from || !(from === el || el.contains(from))) from = el;
|
|
259
521
|
|
|
260
522
|
const anchor = el.closest ? el.closest("a[href]") : null;
|
|
@@ -315,6 +577,57 @@ function cssString(value) {
|
|
|
315
577
|
return String(value).replace(/["\\]/g, "\\$&");
|
|
316
578
|
}
|
|
317
579
|
|
|
580
|
+
function rootsIncludingOpenShadow(root = document, roots = []) {
|
|
581
|
+
roots.push(root);
|
|
582
|
+
let descendants = [];
|
|
583
|
+
try { descendants = root.querySelectorAll("*"); } catch (_) {}
|
|
584
|
+
for (const descendant of descendants) {
|
|
585
|
+
let shadow = null;
|
|
586
|
+
try { shadow = descendant.shadowRoot; } catch (_) {}
|
|
587
|
+
if (shadow) rootsIncludingOpenShadow(shadow, roots);
|
|
588
|
+
}
|
|
589
|
+
return roots;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function deepQuery(selector) {
|
|
593
|
+
return deepFind(selector, () => true);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function deepFind(selector, predicate, root = document) {
|
|
597
|
+
let candidates;
|
|
598
|
+
try { candidates = root.querySelectorAll(selector); }
|
|
599
|
+
catch (_) { return null; }
|
|
600
|
+
for (const candidate of candidates) {
|
|
601
|
+
if (predicate(candidate)) return candidate;
|
|
602
|
+
}
|
|
603
|
+
let descendants = [];
|
|
604
|
+
try { descendants = root.querySelectorAll("*"); } catch (_) {}
|
|
605
|
+
for (const descendant of descendants) {
|
|
606
|
+
let shadow = null;
|
|
607
|
+
try { shadow = descendant.shadowRoot; } catch (_) {}
|
|
608
|
+
if (!shadow) continue;
|
|
609
|
+
const nested = deepFind(selector, predicate, shadow);
|
|
610
|
+
if (nested) return nested;
|
|
611
|
+
}
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function deepElementFromPoint(x, y) {
|
|
616
|
+
let root = document;
|
|
617
|
+
let deepest = null;
|
|
618
|
+
for (let depth = 0; depth < 32; depth += 1) {
|
|
619
|
+
let hit = null;
|
|
620
|
+
try { hit = root.elementFromPoint(x, y); } catch (_) {}
|
|
621
|
+
if (!hit || hit === deepest) break;
|
|
622
|
+
deepest = hit;
|
|
623
|
+
let shadow = null;
|
|
624
|
+
try { shadow = hit.shadowRoot; } catch (_) {}
|
|
625
|
+
if (!shadow || typeof shadow.elementFromPoint !== "function") break;
|
|
626
|
+
root = shadow;
|
|
627
|
+
}
|
|
628
|
+
return deepest;
|
|
629
|
+
}
|
|
630
|
+
|
|
318
631
|
function isVisible(el) {
|
|
319
632
|
if (!el) return false;
|
|
320
633
|
const style = getComputedStyle(el);
|
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.10.
|
|
5
|
+
"version": "2.10.4",
|
|
6
6
|
"icons": {
|
|
7
7
|
"48": "images/icon-48.png",
|
|
8
8
|
"96": "images/icon-96.png",
|
package/index.js
CHANGED
|
@@ -2371,8 +2371,8 @@ server.tool(
|
|
|
2371
2371
|
// tracked index is not proof either: `_openedTabs` is keyed by INDEX
|
|
2372
2372
|
// and Safari renumbers every index whenever any tab closes, so a stale
|
|
2373
2373
|
// key can point straight at a user's tab. Require BOTH — the index we
|
|
2374
|
-
// opened AND the origin we opened it on. That
|
|
2375
|
-
// the extension
|
|
2374
|
+
// opened AND the origin we opened it on. That mirrors the origin
|
|
2375
|
+
// boundary the extension keeps on its own receipts, so nothing that
|
|
2376
2376
|
// would survive the extension's check is lost here, and the AppleScript
|
|
2377
2377
|
// fallback (which has no ownership check of its own) stays guarded.
|
|
2378
2378
|
const trackedOrigin = _originOf(_openedTabs.get(index)?.url);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.17.
|
|
3
|
+
"version": "2.17.1",
|
|
4
4
|
"mcpName": "io.github.achiya-automation/safari-mcp",
|
|
5
5
|
"description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 97 tools via AppleScript + JavaScript.",
|
|
6
6
|
"type": "module",
|