safari-mcp 2.17.1 → 2.18.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/extension/background.js +85 -9
- package/extension/command-content.js +138 -1
- package/extension/manifest.json +33 -9
- package/image-size.js +17 -0
- package/index.js +388 -70
- package/package.json +2 -1
package/extension/background.js
CHANGED
|
@@ -61,6 +61,30 @@ async function _bridgeFetch(url, init = {}) {
|
|
|
61
61
|
headers.set("X-Safari-MCP-Worker", _bridgeWorkerId);
|
|
62
62
|
return fetch(url, { ...init, headers });
|
|
63
63
|
}
|
|
64
|
+
const _CONTENT_WAKE_TOKEN_STORAGE_KEY = "mcpContentWakeTokenV1";
|
|
65
|
+
const _CONTENT_WAKE_LABEL = "safari-mcp-content-wakeup-v1";
|
|
66
|
+
async function _publishContentWakeToken() {
|
|
67
|
+
try {
|
|
68
|
+
const bridgeToken = await _bridgeAuthToken();
|
|
69
|
+
const encoder = new TextEncoder();
|
|
70
|
+
const key = await crypto.subtle.importKey(
|
|
71
|
+
"raw",
|
|
72
|
+
encoder.encode(bridgeToken),
|
|
73
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
74
|
+
false,
|
|
75
|
+
["sign"]
|
|
76
|
+
);
|
|
77
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(_CONTENT_WAKE_LABEL));
|
|
78
|
+
const wakeToken = Array.from(new Uint8Array(signature), (byte) =>
|
|
79
|
+
byte.toString(16).padStart(2, "0")
|
|
80
|
+
).join("");
|
|
81
|
+
if (!/^[0-9a-f]{64}$/.test(wakeToken)) throw new Error("invalid content wake token");
|
|
82
|
+
await browser.storage.local.set({ [_CONTENT_WAKE_TOKEN_STORAGE_KEY]: wakeToken });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
await browser.storage.local.remove(_CONTENT_WAKE_TOKEN_STORAGE_KEY).catch(() => {});
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
64
88
|
let _targetProfile = null; // Profile name from server (e.g. "Automations")
|
|
65
89
|
let _profileWindowId = null; // Discovered windowId for the profile (shared default)
|
|
66
90
|
// Per-session profile window. One Safari profile can hold SEVERAL windows once
|
|
@@ -419,6 +443,18 @@ let _startupReady = browser.storage.local.get("mcpEnabled").then(data => {
|
|
|
419
443
|
|
|
420
444
|
// Listen for messages from popup
|
|
421
445
|
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
446
|
+
// Safari can suspend an MV3 worker even while a content-script Port exists. A
|
|
447
|
+
// leader page therefore sends this fixed, authority-free message as a second
|
|
448
|
+
// keepalive signal. runtime.sendMessage is an extension event, so it wakes the
|
|
449
|
+
// worker on Safari versions where Port traffic alone does not. Never accept tab,
|
|
450
|
+
// URL, receipt, session, or command data through this path.
|
|
451
|
+
if (msg?.action === "mcpContentKeepalivePingV1") {
|
|
452
|
+
if (Object.keys(msg).length !== 1) return false;
|
|
453
|
+
if (!Number.isInteger(sender?.tab?.id)) return false;
|
|
454
|
+
if (!_enabled || _bridgeWorkerSuperseded || _bridgeWorkerRetiring) return false;
|
|
455
|
+
sendResponse({ ok: true });
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
422
458
|
if (msg.action === "setEnabled") {
|
|
423
459
|
_enabled = msg.enabled;
|
|
424
460
|
if (!_enabled) {
|
|
@@ -545,6 +581,14 @@ async function connect() {
|
|
|
545
581
|
signal: AbortSignal.timeout(1500),
|
|
546
582
|
});
|
|
547
583
|
if (_restartInvalidatedConnect(pollGeneration)) return;
|
|
584
|
+
if (res.status === 423) {
|
|
585
|
+
isConnected = false;
|
|
586
|
+
updateBadge("");
|
|
587
|
+
_stopHeartbeat();
|
|
588
|
+
scheduleReconnect(3000);
|
|
589
|
+
_connecting = false;
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
548
592
|
if (!res.ok) continue;
|
|
549
593
|
|
|
550
594
|
const data = await res.json().catch(() => ({}));
|
|
@@ -569,7 +613,7 @@ async function connect() {
|
|
|
569
613
|
isConnected = false;
|
|
570
614
|
updateBadge("");
|
|
571
615
|
_stopHeartbeat();
|
|
572
|
-
scheduleReconnect();
|
|
616
|
+
scheduleReconnect(3000);
|
|
573
617
|
_connecting = false;
|
|
574
618
|
return;
|
|
575
619
|
}
|
|
@@ -604,17 +648,22 @@ async function connect() {
|
|
|
604
648
|
_connecting = false;
|
|
605
649
|
}
|
|
606
650
|
|
|
607
|
-
function scheduleReconnect() {
|
|
651
|
+
function scheduleReconnect(delayOverrideMs = null) {
|
|
608
652
|
if (!_enabled || _bridgeWorkerSuperseded || _bridgeWorkerRetiring) return;
|
|
609
653
|
// Cancel any existing reconnect to prevent exponential growth
|
|
610
654
|
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
|
|
611
655
|
|
|
612
656
|
// Single timer with exponential backoff (3s → 6s → 12s → ... → 60s max)
|
|
657
|
+
const delay = Number.isFinite(delayOverrideMs)
|
|
658
|
+
? Math.max(0, Number(delayOverrideMs))
|
|
659
|
+
: _reconnectDelay;
|
|
613
660
|
_reconnectTimer = setTimeout(() => {
|
|
614
661
|
_reconnectTimer = null;
|
|
615
662
|
connect();
|
|
616
|
-
},
|
|
617
|
-
|
|
663
|
+
}, delay);
|
|
664
|
+
if (!Number.isFinite(delayOverrideMs)) {
|
|
665
|
+
_reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX);
|
|
666
|
+
}
|
|
618
667
|
|
|
619
668
|
// Alarm as backup — wakes terminated service worker (Safari minimum 1 minute)
|
|
620
669
|
try {
|
|
@@ -1038,11 +1087,17 @@ async function handleCommand(type, payload) {
|
|
|
1038
1087
|
);
|
|
1039
1088
|
if (!r || r.ok !== true) throw new Error((r && r.error) || "content bridge failed");
|
|
1040
1089
|
_injectionBlockedTabs.add(evalTabId);
|
|
1090
|
+
if (evalOrigin) _injectionBlockedOrigins.set(evalOrigin, Date.now() + _INJECTION_BLOCK_TTL_MS);
|
|
1041
1091
|
return r.value;
|
|
1042
1092
|
};
|
|
1043
|
-
|
|
1093
|
+
const evalOrigin = _originKey(targetTab?.url);
|
|
1094
|
+
const originBlocked = !!evalOrigin && (_injectionBlockedOrigins.get(evalOrigin) || 0) > Date.now();
|
|
1095
|
+
if (_injectionBlockedTabs.has(evalTabId) || originBlocked) {
|
|
1044
1096
|
try { return await viaBridge(); }
|
|
1045
|
-
catch (_e) {
|
|
1097
|
+
catch (_e) { // page changed — re-probe
|
|
1098
|
+
_injectionBlockedTabs.delete(evalTabId);
|
|
1099
|
+
if (evalOrigin) _injectionBlockedOrigins.delete(evalOrigin);
|
|
1100
|
+
}
|
|
1046
1101
|
}
|
|
1047
1102
|
|
|
1048
1103
|
// First contact with a hardened page: every execInTab strategy below will stall.
|
|
@@ -1153,14 +1208,23 @@ async function handleCommand(type, payload) {
|
|
|
1153
1208
|
// If it fails, fall back to AppleScript screencapture -l (which also doesn't steal focus).
|
|
1154
1209
|
// NEVER use browser.windows.update({ focused: true }) — it steals user's keyboard/mouse.
|
|
1155
1210
|
let captureWindowId = _profileWindowId || null;
|
|
1211
|
+
// captureVisibleTab only sees the window's selected tab, so ours must be selected for
|
|
1212
|
+
// the capture — but the selection belongs to the user when they browse this window.
|
|
1213
|
+
// Remember what they had selected and put it back (the AppleScript path already does
|
|
1214
|
+
// this via _withTargetTabFronted; this path used to leave our tab in front).
|
|
1215
|
+
let restoreTabId = null;
|
|
1156
1216
|
if (tabId) {
|
|
1157
1217
|
try {
|
|
1158
1218
|
const tabInfo = await browser.tabs.get(tabId);
|
|
1159
1219
|
captureWindowId = tabInfo.windowId;
|
|
1160
|
-
|
|
1161
|
-
|
|
1220
|
+
if (!tabInfo.active) {
|
|
1221
|
+
const [selected] = await browser.tabs.query({ active: true, windowId: tabInfo.windowId });
|
|
1222
|
+
if (selected && selected.id !== tabId) restoreTabId = selected.id;
|
|
1223
|
+
// Only selects the tab — does NOT bring the Safari window to the foreground
|
|
1224
|
+
await browser.tabs.update(tabId, { active: true });
|
|
1225
|
+
await new Promise(r => setTimeout(r, 150));
|
|
1226
|
+
}
|
|
1162
1227
|
} catch (_) {}
|
|
1163
|
-
await new Promise(r => setTimeout(r, 150));
|
|
1164
1228
|
}
|
|
1165
1229
|
// Use JPEG with quality 50 to reduce size (~600KB PNG → ~60KB JPEG)
|
|
1166
1230
|
try {
|
|
@@ -1178,6 +1242,8 @@ async function handleCommand(type, payload) {
|
|
|
1178
1242
|
}
|
|
1179
1243
|
// Any other error also falls back — better than stealing focus
|
|
1180
1244
|
return "__SCREENSHOT_PERMISSION_DENIED__";
|
|
1245
|
+
} finally {
|
|
1246
|
+
if (restoreTabId !== null) browser.tabs.update(restoreTabId, { active: true }).catch(() => {});
|
|
1181
1247
|
}
|
|
1182
1248
|
}
|
|
1183
1249
|
|
|
@@ -3668,6 +3734,15 @@ const MAIN_WORLD_INJECT_MS = 3000;
|
|
|
3668
3734
|
// pages fast instead of merely working. Cleared when the tab navigates or closes, so a
|
|
3669
3735
|
// page that stops blocking injection is re-probed rather than downgraded forever.
|
|
3670
3736
|
const _injectionBlockedTabs = new Set();
|
|
3737
|
+
// Same fact, keyed by origin: the per-tab set is cleared on every navigation, so a hardened
|
|
3738
|
+
// SPA (business.facebook.com) re-paid the ~10s probe on the first evaluate after EACH
|
|
3739
|
+
// navigate — 356 evaluates ≥10s in one week. An origin that blocked injection once is sent
|
|
3740
|
+
// straight to the bridge for a few hours; a bridge failure clears it and re-probes.
|
|
3741
|
+
const _injectionBlockedOrigins = new Map(); // origin → expiry (ms)
|
|
3742
|
+
const _INJECTION_BLOCK_TTL_MS = 6 * 60 * 60 * 1000;
|
|
3743
|
+
function _originKey(url) {
|
|
3744
|
+
try { return new URL(String(url || "")).origin; } catch { return ""; }
|
|
3745
|
+
}
|
|
3671
3746
|
function _withInjectionDeadline(promise, ms = MAIN_WORLD_INJECT_MS) {
|
|
3672
3747
|
let timer;
|
|
3673
3748
|
return Promise.race([
|
|
@@ -4308,6 +4383,7 @@ console.log("Safari MCP Bridge: service worker started");
|
|
|
4308
4383
|
updateBadge("");
|
|
4309
4384
|
// Wait for storage to load before connecting (prevents race condition with _enabled)
|
|
4310
4385
|
_startupReady.then(() => {
|
|
4386
|
+
_publishContentWakeToken().catch(() => {});
|
|
4311
4387
|
if (_enabled) connect();
|
|
4312
4388
|
else updateBadge("OFF");
|
|
4313
4389
|
}).catch(() => connect());
|
|
@@ -32,6 +32,10 @@ const _MCP_KEEPALIVE_LEASE_MS = 26000;
|
|
|
32
32
|
const _MCP_KEEPALIVE_RENEW_MS = 18000;
|
|
33
33
|
const _MCP_KEEPALIVE_PING_MS = 8000;
|
|
34
34
|
const _MCP_KEEPALIVE_RECONNECT_MS = 1500;
|
|
35
|
+
const _MCP_CONTENT_WAKE_TOKEN_KEY = "mcpContentWakeTokenV1";
|
|
36
|
+
const _MCP_CONTENT_WAKE_BRIDGE_KEY = "mcpBridgeUrl";
|
|
37
|
+
const _MCP_CONTENT_WAKE_RETRY_MS = 1500;
|
|
38
|
+
const _MCP_CONTENT_WAKE_PORTS = new Set(["9224", "9228", "9232", "9236"]);
|
|
35
39
|
const _mcpKeepaliveInstanceId = inheritedKeepaliveId || (() => {
|
|
36
40
|
const bytes = new Uint8Array(12);
|
|
37
41
|
crypto.getRandomValues(bytes);
|
|
@@ -47,6 +51,11 @@ let _mcpKeepaliveEnabledRevision = 0;
|
|
|
47
51
|
let _mcpKeepaliveLeader = false;
|
|
48
52
|
let _mcpKeepaliveClaiming = false;
|
|
49
53
|
let _mcpKeepaliveDisposed = false;
|
|
54
|
+
let _mcpContentWakeToken = "";
|
|
55
|
+
let _mcpContentWakeBridgeUrl = "";
|
|
56
|
+
let _mcpContentWakeAbort = null;
|
|
57
|
+
let _mcpContentWakeRetryTimer = null;
|
|
58
|
+
let _mcpContentWakeGeneration = 0;
|
|
50
59
|
|
|
51
60
|
function _isMcpKeepaliveTopFrame() {
|
|
52
61
|
try { return window.top === window; } catch (_) { return false; }
|
|
@@ -134,9 +143,110 @@ function _clearMcpKeepalivePingTimer() {
|
|
|
134
143
|
_mcpKeepalivePingTimer = null;
|
|
135
144
|
}
|
|
136
145
|
|
|
146
|
+
function _validMcpContentWakeToken(value) {
|
|
147
|
+
return /^[0-9a-f]{64}$/.test(String(value || "")) ? String(value) : "";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function _validMcpContentWakeBridgeUrl(value) {
|
|
151
|
+
try {
|
|
152
|
+
const url = new URL(String(value || ""));
|
|
153
|
+
if (url.protocol !== "http:") return "";
|
|
154
|
+
if (url.hostname !== "127.0.0.1" && url.hostname !== "localhost") return "";
|
|
155
|
+
if (!_MCP_CONTENT_WAKE_PORTS.has(url.port)) return "";
|
|
156
|
+
if (url.pathname !== "/" || url.search || url.hash) return "";
|
|
157
|
+
return url.origin;
|
|
158
|
+
} catch (_) {
|
|
159
|
+
return "";
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function _sendMcpRuntimeWake() {
|
|
164
|
+
try {
|
|
165
|
+
const wakeup = browser.runtime.sendMessage({ action: "mcpContentKeepalivePingV1" });
|
|
166
|
+
wakeup?.catch?.(() => {});
|
|
167
|
+
} catch (_) {}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _stopMcpContentWakeLoop() {
|
|
171
|
+
_mcpContentWakeGeneration += 1;
|
|
172
|
+
if (_mcpContentWakeRetryTimer) clearTimeout(_mcpContentWakeRetryTimer);
|
|
173
|
+
_mcpContentWakeRetryTimer = null;
|
|
174
|
+
const controller = _mcpContentWakeAbort;
|
|
175
|
+
_mcpContentWakeAbort = null;
|
|
176
|
+
if (controller) {
|
|
177
|
+
try { controller.abort(); } catch (_) {}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function _startMcpContentWakeLoop() {
|
|
182
|
+
if (
|
|
183
|
+
_mcpKeepaliveDisposed ||
|
|
184
|
+
!_mcpKeepaliveLeader ||
|
|
185
|
+
!_mcpKeepaliveEnabled ||
|
|
186
|
+
!_mcpContentWakeToken ||
|
|
187
|
+
!_mcpContentWakeBridgeUrl ||
|
|
188
|
+
_mcpContentWakeAbort ||
|
|
189
|
+
_mcpContentWakeRetryTimer
|
|
190
|
+
)
|
|
191
|
+
return;
|
|
192
|
+
const generation = ++_mcpContentWakeGeneration;
|
|
193
|
+
void _runMcpContentWakePoll(generation);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function _runMcpContentWakePoll(generation) {
|
|
197
|
+
if (
|
|
198
|
+
generation !== _mcpContentWakeGeneration ||
|
|
199
|
+
_mcpKeepaliveDisposed ||
|
|
200
|
+
!_mcpKeepaliveLeader ||
|
|
201
|
+
!_mcpKeepaliveEnabled ||
|
|
202
|
+
!_mcpContentWakeToken ||
|
|
203
|
+
!_mcpContentWakeBridgeUrl
|
|
204
|
+
)
|
|
205
|
+
return;
|
|
206
|
+
const controller = new AbortController();
|
|
207
|
+
_mcpContentWakeAbort = controller;
|
|
208
|
+
let succeeded = false;
|
|
209
|
+
try {
|
|
210
|
+
const response = await fetch(`${_mcpContentWakeBridgeUrl}/content-wakeup`, {
|
|
211
|
+
cache: "no-store",
|
|
212
|
+
credentials: "omit",
|
|
213
|
+
headers: { "X-Safari-MCP-Wakeup": _mcpContentWakeToken },
|
|
214
|
+
mode: "cors",
|
|
215
|
+
referrerPolicy: "no-referrer",
|
|
216
|
+
signal: controller.signal,
|
|
217
|
+
});
|
|
218
|
+
succeeded = response.status === 204;
|
|
219
|
+
} catch (_) {
|
|
220
|
+
succeeded = false;
|
|
221
|
+
} finally {
|
|
222
|
+
if (_mcpContentWakeAbort === controller) _mcpContentWakeAbort = null;
|
|
223
|
+
}
|
|
224
|
+
if (
|
|
225
|
+
generation !== _mcpContentWakeGeneration ||
|
|
226
|
+
_mcpKeepaliveDisposed ||
|
|
227
|
+
!_mcpKeepaliveLeader ||
|
|
228
|
+
!_mcpKeepaliveEnabled
|
|
229
|
+
)
|
|
230
|
+
return;
|
|
231
|
+
if (succeeded) {
|
|
232
|
+
_sendMcpRuntimeWake();
|
|
233
|
+
void _runMcpContentWakePoll(generation);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
_mcpContentWakeRetryTimer = setTimeout(() => {
|
|
237
|
+
_mcpContentWakeRetryTimer = null;
|
|
238
|
+
if (generation !== _mcpContentWakeGeneration) return;
|
|
239
|
+
void _runMcpContentWakePoll(generation);
|
|
240
|
+
}, _MCP_CONTENT_WAKE_RETRY_MS);
|
|
241
|
+
}
|
|
242
|
+
|
|
137
243
|
function _sendMcpKeepalivePing() {
|
|
138
244
|
if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return;
|
|
139
245
|
_clearMcpKeepalivePingTimer();
|
|
246
|
+
// A direct runtime message wakes Safari's MV3 worker. The Port remains useful as
|
|
247
|
+
// a low-overhead continuous lease, but real Safari can suspend the worker despite
|
|
248
|
+
// an open Port; using both paths avoids the observed 30-second poll gaps.
|
|
249
|
+
_sendMcpRuntimeWake();
|
|
140
250
|
const port = _openMcpKeepalivePort();
|
|
141
251
|
if (port) {
|
|
142
252
|
try { port.postMessage({ type: "ping" }); }
|
|
@@ -155,6 +265,7 @@ function _stopMcpKeepaliveLeadership() {
|
|
|
155
265
|
if (_mcpKeepaliveReconnectTimer) clearTimeout(_mcpKeepaliveReconnectTimer);
|
|
156
266
|
_mcpKeepaliveReconnectTimer = null;
|
|
157
267
|
_clearMcpKeepalivePingTimer();
|
|
268
|
+
_stopMcpContentWakeLoop();
|
|
158
269
|
_closeMcpKeepalivePort();
|
|
159
270
|
}
|
|
160
271
|
|
|
@@ -183,6 +294,7 @@ function _becomeMcpKeepaliveLeader() {
|
|
|
183
294
|
if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled || !_isMcpKeepaliveTopFrame()) return;
|
|
184
295
|
_mcpKeepaliveLeader = true;
|
|
185
296
|
_clearMcpKeepaliveLeaseTimer();
|
|
297
|
+
_startMcpContentWakeLoop();
|
|
186
298
|
_renewMcpKeepaliveLeaseAndPing();
|
|
187
299
|
}
|
|
188
300
|
|
|
@@ -270,9 +382,20 @@ async function _initializeMcpKeepalive() {
|
|
|
270
382
|
document.addEventListener("visibilitychange", _onMcpKeepaliveVisibilityChange);
|
|
271
383
|
browser.storage.onChanged.addListener(_onMcpKeepaliveStorageChanged);
|
|
272
384
|
const enabledRevision = _mcpKeepaliveEnabledRevision;
|
|
273
|
-
const stored = await browser.storage.local
|
|
385
|
+
const stored = await browser.storage.local
|
|
386
|
+
.get([
|
|
387
|
+
"mcpEnabled",
|
|
388
|
+
_MCP_KEEPALIVE_LEASE_KEY,
|
|
389
|
+
_MCP_CONTENT_WAKE_TOKEN_KEY,
|
|
390
|
+
_MCP_CONTENT_WAKE_BRIDGE_KEY,
|
|
391
|
+
])
|
|
392
|
+
.catch(() => ({}));
|
|
274
393
|
if (_mcpKeepaliveDisposed || enabledRevision !== _mcpKeepaliveEnabledRevision) return;
|
|
275
394
|
_mcpKeepaliveEnabled = stored.mcpEnabled !== false;
|
|
395
|
+
_mcpContentWakeToken = _validMcpContentWakeToken(stored[_MCP_CONTENT_WAKE_TOKEN_KEY]);
|
|
396
|
+
_mcpContentWakeBridgeUrl = _validMcpContentWakeBridgeUrl(
|
|
397
|
+
stored[_MCP_CONTENT_WAKE_BRIDGE_KEY]
|
|
398
|
+
);
|
|
276
399
|
if (_mcpKeepaliveEnabled) {
|
|
277
400
|
_considerMcpKeepaliveLeadership(stored[_MCP_KEEPALIVE_LEASE_KEY]);
|
|
278
401
|
}
|
|
@@ -280,6 +403,20 @@ async function _initializeMcpKeepalive() {
|
|
|
280
403
|
|
|
281
404
|
async function _onMcpKeepaliveStorageChanged(changes, areaName) {
|
|
282
405
|
if (_mcpKeepaliveDisposed || areaName !== "local") return;
|
|
406
|
+
if (changes[_MCP_CONTENT_WAKE_TOKEN_KEY] || changes[_MCP_CONTENT_WAKE_BRIDGE_KEY]) {
|
|
407
|
+
_stopMcpContentWakeLoop();
|
|
408
|
+
if (changes[_MCP_CONTENT_WAKE_TOKEN_KEY]) {
|
|
409
|
+
_mcpContentWakeToken = _validMcpContentWakeToken(
|
|
410
|
+
changes[_MCP_CONTENT_WAKE_TOKEN_KEY].newValue
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
if (changes[_MCP_CONTENT_WAKE_BRIDGE_KEY]) {
|
|
414
|
+
_mcpContentWakeBridgeUrl = _validMcpContentWakeBridgeUrl(
|
|
415
|
+
changes[_MCP_CONTENT_WAKE_BRIDGE_KEY].newValue
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
if (_mcpKeepaliveLeader) _startMcpContentWakeLoop();
|
|
419
|
+
}
|
|
283
420
|
if (changes.mcpEnabled) {
|
|
284
421
|
_mcpKeepaliveEnabledRevision += 1;
|
|
285
422
|
_mcpKeepaliveEnabled = changes.mcpEnabled.newValue !== false;
|
package/extension/manifest.json
CHANGED
|
@@ -2,25 +2,43 @@
|
|
|
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.8",
|
|
6
6
|
"icons": {
|
|
7
7
|
"48": "images/icon-48.png",
|
|
8
8
|
"96": "images/icon-96.png",
|
|
9
9
|
"128": "images/icon-128.png"
|
|
10
10
|
},
|
|
11
|
-
"background": {
|
|
11
|
+
"background": {
|
|
12
|
+
"scripts": [
|
|
13
|
+
"background.js"
|
|
14
|
+
],
|
|
15
|
+
"persistent": false
|
|
16
|
+
},
|
|
12
17
|
"action": {
|
|
13
18
|
"default_popup": "popup/popup.html",
|
|
14
|
-
"default_icon": {
|
|
19
|
+
"default_icon": {
|
|
20
|
+
"16": "images/icon-48.png",
|
|
21
|
+
"32": "images/icon-48.png"
|
|
22
|
+
}
|
|
15
23
|
},
|
|
16
24
|
"content_security_policy": {
|
|
17
25
|
"extension_pages": "script-src 'self'; connect-src 'self' http://localhost:9224 http://127.0.0.1:9224 http://localhost:9228 http://127.0.0.1:9228 http://localhost:9232 http://127.0.0.1:9232 http://localhost:9236 http://127.0.0.1:9236"
|
|
18
26
|
},
|
|
19
|
-
"permissions": [
|
|
20
|
-
|
|
27
|
+
"permissions": [
|
|
28
|
+
"activeTab",
|
|
29
|
+
"scripting",
|
|
30
|
+
"tabs",
|
|
31
|
+
"alarms",
|
|
32
|
+
"storage"
|
|
33
|
+
],
|
|
34
|
+
"host_permissions": [
|
|
35
|
+
"<all_urls>"
|
|
36
|
+
],
|
|
21
37
|
"content_scripts": [
|
|
22
38
|
{
|
|
23
|
-
"matches": [
|
|
39
|
+
"matches": [
|
|
40
|
+
"<all_urls>"
|
|
41
|
+
],
|
|
24
42
|
"exclude_matches": [
|
|
25
43
|
"*://*.linkedin.com/*",
|
|
26
44
|
"*://*.x.com/*",
|
|
@@ -31,13 +49,19 @@
|
|
|
31
49
|
"*://chat.google.com/*",
|
|
32
50
|
"*://meet.google.com/*"
|
|
33
51
|
],
|
|
34
|
-
"js": [
|
|
52
|
+
"js": [
|
|
53
|
+
"content.js"
|
|
54
|
+
],
|
|
35
55
|
"run_at": "document_start",
|
|
36
56
|
"world": "MAIN"
|
|
37
57
|
},
|
|
38
58
|
{
|
|
39
|
-
"matches": [
|
|
40
|
-
|
|
59
|
+
"matches": [
|
|
60
|
+
"<all_urls>"
|
|
61
|
+
],
|
|
62
|
+
"js": [
|
|
63
|
+
"command-content.js"
|
|
64
|
+
],
|
|
41
65
|
"run_at": "document_start"
|
|
42
66
|
}
|
|
43
67
|
]
|
package/image-size.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Pixel size of a JPEG straight from its SOF marker — enough to decide whether a screenshot
|
|
2
|
+
// needs downscaling without decoding it or pulling in an image dependency.
|
|
3
|
+
export function jpegSize(buf) {
|
|
4
|
+
if (!buf || buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null;
|
|
5
|
+
let i = 2;
|
|
6
|
+
while (i + 9 < buf.length) {
|
|
7
|
+
if (buf[i] !== 0xff) { i++; continue; }
|
|
8
|
+
const marker = buf[i + 1];
|
|
9
|
+
if (marker === 0xff) { i++; continue; } // fill byte
|
|
10
|
+
if (marker === 0x01 || marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; } // standalone markers
|
|
11
|
+
if (marker === 0xda || marker === 0xd9) return null; // scan data / EOI before any SOF
|
|
12
|
+
const isSOF = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
|
13
|
+
if (isSOF) return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
|
|
14
|
+
i += 2 + buf.readUInt16BE(i + 2);
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|