safari-mcp 2.18.1 → 2.19.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 -0
- package/extension/background.js +44 -1
- package/extension/manifest.json +1 -1
- package/index.js +126 -7
- package/package.json +1 -1
- package/safari.js +29 -0
package/README.md
CHANGED
|
@@ -356,6 +356,9 @@ Prefer stdio (one process per agent) over a persistent daemon? That works too
|
|
|
356
356
|
| `SAFARI_MCP_HTTP_PORT` | `9225` | Port for that daemon. |
|
|
357
357
|
| `SAFARI_PROFILE` | unset | Bind sessions to a named Safari profile. Unset = your ordinary windows. |
|
|
358
358
|
| `SAFARI_MCP_RAISE_ON_NAVIGATE` | off | Let navigation bring Safari to the front, and stop the focus guard from putting your previous app back. |
|
|
359
|
+
| `SAFARI_MCP_SCREENSHOT_MAX_WIDTH` | unset | Downscale every `safari_screenshot` to this pixel width (Retina captures are 2× the viewport). Per-call `maxWidth` overrides it. |
|
|
360
|
+
| `SAFARI_MCP_KEEPALIVE_TAB` | off | Keep one daemon-served page open in the profile window so Safari never parks the extension worker between commands. |
|
|
361
|
+
| `SAFARI_MCP_OPEN_WINDOW_CMD` | unset | Command run with the profile name when the profile window is absent (e.g. after a reboot). Must open the window without focusing Safari. |
|
|
359
362
|
|
|
360
363
|
`SAFARI_MCP_RAISE_ON_NAVIGATE=1` is for agents whose whole point is *showing* you a page — a voice assistant answering "open YouTube", a demo driver. Everything else should leave it off: by default Safari MCP works in the background and hands focus back to whatever app you were using, so an agent can drive a page while you keep typing somewhere else.
|
|
361
364
|
|
package/extension/background.js
CHANGED
|
@@ -452,6 +452,13 @@ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
|
452
452
|
if (Object.keys(msg).length !== 1) return false;
|
|
453
453
|
if (!Number.isInteger(sender?.tab?.id)) return false;
|
|
454
454
|
if (!_enabled || _bridgeWorkerSuperseded || _bridgeWorkerRetiring) return false;
|
|
455
|
+
// A page pinging while this worker sits in reconnect backoff is the earliest sign that
|
|
456
|
+
// the bridge is reachable again (the daemon releases wake polls while it has no verified
|
|
457
|
+
// worker). Retry now instead of waiting out a backoff of up to a minute.
|
|
458
|
+
if (!isConnected && !_connecting) {
|
|
459
|
+
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
|
|
460
|
+
connect();
|
|
461
|
+
}
|
|
455
462
|
sendResponse({ ok: true });
|
|
456
463
|
return false;
|
|
457
464
|
}
|
|
@@ -725,7 +732,16 @@ async function pollForCommands(pollGeneration) {
|
|
|
725
732
|
_bridgeFetch(`${bridgeUrl}/heartbeat`, { method: "POST" }).catch(() => {});
|
|
726
733
|
}, 5000);
|
|
727
734
|
await previousExecution.catch(() => {});
|
|
728
|
-
|
|
735
|
+
// 7.9.26: a command whose handleCommand never settles (a tab that never answers,
|
|
736
|
+
// a runtime call that hangs) used to park this loop for good: the beat kept the
|
|
737
|
+
// host believing we were alive, /poll went silent for hours, connectToServer
|
|
738
|
+
// waited on the same claim, and only a Safari restart brought the profile back.
|
|
739
|
+
// Bound it: past the host's hard ceiling we answer with an error, release the
|
|
740
|
+
// claim and keep polling; the abandoned promise may settle later into the void.
|
|
741
|
+
await Promise.race([
|
|
742
|
+
executeAndReply(msg, bridgeUrl),
|
|
743
|
+
_wedgedCommandGuard(msg, bridgeUrl),
|
|
744
|
+
]);
|
|
729
745
|
}
|
|
730
746
|
} finally {
|
|
731
747
|
if (beat) clearInterval(beat);
|
|
@@ -757,6 +773,33 @@ async function pollForCommands(pollGeneration) {
|
|
|
757
773
|
}
|
|
758
774
|
}
|
|
759
775
|
|
|
776
|
+
// Ceiling for one command. The host's own hard deadline is max(4×timeout, 180s) and its
|
|
777
|
+
// longest tool timeout is 75s, so nothing legitimate is still awaited after five minutes.
|
|
778
|
+
const _WEDGED_COMMAND_MS = 330000;
|
|
779
|
+
function _wedgedCommandGuard(msg, bridgeUrl) {
|
|
780
|
+
return new Promise((resolve) => {
|
|
781
|
+
setTimeout(async () => {
|
|
782
|
+
console.log("Safari MCP: command wedged, releasing the poll loop", msg && msg.type);
|
|
783
|
+
try {
|
|
784
|
+
await _bridgeFetch(`${bridgeUrl}/result`, {
|
|
785
|
+
method: "POST",
|
|
786
|
+
headers: { "Content-Type": "application/json" },
|
|
787
|
+
body: JSON.stringify({
|
|
788
|
+
type: "response",
|
|
789
|
+
id: msg.id,
|
|
790
|
+
result: null,
|
|
791
|
+
error: `command ${msg.type} did not settle within ${_WEDGED_COMMAND_MS}ms`,
|
|
792
|
+
}),
|
|
793
|
+
signal: AbortSignal.timeout(5000),
|
|
794
|
+
});
|
|
795
|
+
} catch (e) {
|
|
796
|
+
/* the host most likely expired it already */
|
|
797
|
+
}
|
|
798
|
+
resolve();
|
|
799
|
+
}, _WEDGED_COMMAND_MS);
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
760
803
|
// ========== SHARED: Execute command and send response ==========
|
|
761
804
|
|
|
762
805
|
async function executeAndReply(msg, bridgeUrl = HTTP_URL) {
|
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.10",
|
|
6
6
|
"icons": {
|
|
7
7
|
"48": "images/icon-48.png",
|
|
8
8
|
"96": "images/icon-96.png",
|
package/index.js
CHANGED
|
@@ -417,6 +417,10 @@ const _HTTP_WORKER_TTL_MS = 60 * 1000;
|
|
|
417
417
|
const _HTTP_RELOAD_HANDOFF_TTL_MS = 30 * 1000;
|
|
418
418
|
const _HTTP_WORKER_SUCCESSOR_GRACE_MS = 20 * 1000;
|
|
419
419
|
const _HTTP_WORKER_DISCONNECT_MS = 30 * 1000;
|
|
420
|
+
// Safari parks an idle profile worker every ~30s and its 1-minute alarm wakes it. Declaring
|
|
421
|
+
// it gone in between bought nothing (a profile has no fallback) and cost a full
|
|
422
|
+
// re-verification per minute — 61 flaps in two hours. Wait one alarm cycle plus margin.
|
|
423
|
+
const _HTTP_WORKER_PARK_GRACE_MS = 75 * 1000;
|
|
420
424
|
let _extensionLastPollTime = 0;
|
|
421
425
|
let _extensionLastHeartbeat = 0;
|
|
422
426
|
let _reloadHttpWorkerHandoff = null;
|
|
@@ -742,6 +746,16 @@ try {
|
|
|
742
746
|
return;
|
|
743
747
|
}
|
|
744
748
|
|
|
749
|
+
// GET /keepalive — a plain page for the keepalive tab (SAFARI_MCP_KEEPALIVE_TAB=1).
|
|
750
|
+
// Any http(s) page of the profile hosts the content script, whose wake long-poll and
|
|
751
|
+
// runtime pings are what keep Safari's MV3 worker from parking; this page exists so
|
|
752
|
+
// there is always one, even after every owned tab was closed or Safari was relaunched.
|
|
753
|
+
if (req.method === "GET" && req.url.split("?")[0].split("#")[0] === "/keepalive") {
|
|
754
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
755
|
+
res.end(KEEPALIVE_HTML);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
745
759
|
// Every Safari-extension HTTP endpoint is authenticated in addition to its
|
|
746
760
|
// CORS check. Origin alone is not authority: another extension has its own
|
|
747
761
|
// extension-scheme origin, and a local process can forge or omit Origin.
|
|
@@ -880,6 +894,7 @@ try {
|
|
|
880
894
|
_extensionConnected = true;
|
|
881
895
|
console.error("[Safari MCP] Extension connected via HTTP polling");
|
|
882
896
|
}
|
|
897
|
+
_scheduleKeepaliveTab();
|
|
883
898
|
}
|
|
884
899
|
if (!process.env.SAFARI_PROFILE) _extensionLastPollTime = Date.now();
|
|
885
900
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -893,15 +908,23 @@ try {
|
|
|
893
908
|
const heartbeatingWorkerId = _requireActiveHttpWorker(req, res);
|
|
894
909
|
if (!heartbeatingWorkerId) return;
|
|
895
910
|
_extensionLastHeartbeat = Date.now();
|
|
896
|
-
_extensionLastPollTime = Date.now();
|
|
897
911
|
// Re-arm in-flight deadlines: the worker told us it is still on the command.
|
|
898
912
|
// hardDeadline inside armTimer keeps this from extending indefinitely.
|
|
913
|
+
let liveInFlight = false;
|
|
899
914
|
for (const pending of _pendingRequests.values()) {
|
|
900
915
|
if (pending.dispatchedWorkerId !== heartbeatingWorkerId) continue;
|
|
901
916
|
if (!pending.armTimer || Date.now() >= pending.hardDeadline) continue;
|
|
917
|
+
liveInFlight = true;
|
|
902
918
|
clearTimeout(pending.timer);
|
|
903
919
|
pending.timer = pending.armTimer();
|
|
904
920
|
}
|
|
921
|
+
// A beat proves liveness only while the worker is on a command we still wait for.
|
|
922
|
+
// 7.9.26: a worker wedged inside one command (its handleCommand never settled) kept
|
|
923
|
+
// beating every 5s for hours after that command's hard deadline had expired; each
|
|
924
|
+
// beat refreshed the stale clock, so "HTTP poll timeout" never fired, no successor
|
|
925
|
+
// could take the lease, and every tool call died with "Extension timeout". Once
|
|
926
|
+
// nothing dispatched to this worker is in flight, its beats no longer count as polls.
|
|
927
|
+
if (liveInFlight) _extensionLastPollTime = Date.now();
|
|
905
928
|
res.writeHead(204);
|
|
906
929
|
res.end();
|
|
907
930
|
return;
|
|
@@ -933,6 +956,7 @@ try {
|
|
|
933
956
|
console.error("[Safari MCP] Extension connected and profile-verified via HTTP polling");
|
|
934
957
|
}
|
|
935
958
|
_extensionLastPollTime = Date.now();
|
|
959
|
+
_scheduleKeepaliveTab();
|
|
936
960
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
937
961
|
res.end(JSON.stringify({ status: "verified" }));
|
|
938
962
|
return;
|
|
@@ -1164,8 +1188,16 @@ async function _proxyToExtension(type, payload, timeoutMs = 30000) {
|
|
|
1164
1188
|
// Detect stale HTTP connection (no poll in 30s = disconnected)
|
|
1165
1189
|
// Only applies to primary instance (extension host) — not proxy mode
|
|
1166
1190
|
const _staleHttpTimer = setInterval(() => {
|
|
1191
|
+
// After a daemon restart the profile worker can sit in its reconnect backoff for minutes
|
|
1192
|
+
// (measured 9 min on 4.9.26) while pages of the profile are already long-polling us for
|
|
1193
|
+
// wake-ups. Releasing those polls makes each page ping the worker, and a pinged worker
|
|
1194
|
+
// retries /connect at once (extension ≥2.10.9) instead of waiting out the backoff.
|
|
1195
|
+
if (_isExtensionHost && process.env.SAFARI_PROFILE && !_profileExtensionVerified && _contentWakePolls.size) {
|
|
1196
|
+
_releaseContentWakePolls();
|
|
1197
|
+
}
|
|
1167
1198
|
if (_isExtensionHost && _extensionConnected && !_extensionWs && _extensionLastPollTime > 0) {
|
|
1168
1199
|
if (Date.now() - _extensionLastPollTime > _HTTP_WORKER_DISCONNECT_MS) {
|
|
1200
|
+
if (process.env.SAFARI_PROFILE && Date.now() - _extensionLastPollTime <= _HTTP_WORKER_PARK_GRACE_MS) return;
|
|
1169
1201
|
// A command already delivered to this worker is an at-most-once fence.
|
|
1170
1202
|
// Its own bounded request timer releases the fence; disconnect cleanup must
|
|
1171
1203
|
// not make an ordinary successor eligible while the old worker may finish it.
|
|
@@ -1195,6 +1227,18 @@ _staleHttpTimer.unref(); // stale-detection must not keep the Node process aliv
|
|
|
1195
1227
|
|
|
1196
1228
|
// ========== SHARED EXTENSION LOGIC ==========
|
|
1197
1229
|
|
|
1230
|
+
// A worker that keeps beating but never finishes a command holds the profile lease
|
|
1231
|
+
// hostage. Releasing it is the same bookkeeping the stale timer does on poll timeout.
|
|
1232
|
+
function _dropWedgedHttpWorker(reason) {
|
|
1233
|
+
if (!_extensionConnected && !_activeHttpWorkerId) return;
|
|
1234
|
+
_extensionConnected = false;
|
|
1235
|
+
_activeHttpWorkerId = "";
|
|
1236
|
+
_connectingHttpWorkers.clear();
|
|
1237
|
+
_reloadHttpWorkerHandoff = null;
|
|
1238
|
+
_drainOnDisconnect(`wedged worker: ${reason}`);
|
|
1239
|
+
console.error(`[Safari MCP] Extension worker dropped as wedged (${reason})`);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1198
1242
|
// Drain pending requests and command queue on disconnect — allows fast fallback to AppleScript
|
|
1199
1243
|
function _drainOnDisconnect(reason) {
|
|
1200
1244
|
if (process.env.SAFARI_PROFILE) {
|
|
@@ -1277,6 +1321,7 @@ function sendToExtension(type, payload = {}, timeoutMs = 30000) {
|
|
|
1277
1321
|
// hardDeadline is the ceiling, so a worker that beats forever cannot hang us.
|
|
1278
1322
|
const hardDeadline = Date.now() + Math.max(timeoutMs * 4, 180000);
|
|
1279
1323
|
const expire = () => {
|
|
1324
|
+
const pending = _pendingRequests.get(id);
|
|
1280
1325
|
_pendingRequests.delete(id);
|
|
1281
1326
|
if (reloadHandoff) _cancelReloadHttpWorkerHandoff(reloadHandoff);
|
|
1282
1327
|
// Also drop it from the HTTP poll queue — otherwise the extension could poll this command
|
|
@@ -1284,6 +1329,15 @@ function sendToExtension(type, payload = {}, timeoutMs = 30000) {
|
|
|
1284
1329
|
const qi = _commandQueue.findIndex(c => c.id === id);
|
|
1285
1330
|
if (qi >= 0) _commandQueue.splice(qi, 1);
|
|
1286
1331
|
reject(new Error(`Extension timeout after ${timeoutMs}ms`));
|
|
1332
|
+
// The worker took this command and never answered, even past the hard ceiling: it is
|
|
1333
|
+
// wedged (see /heartbeat). Drop its lease now instead of waiting for the stale timer,
|
|
1334
|
+
// so a fresh worker — or Safari re-spawning this one — can connect at once.
|
|
1335
|
+
if (
|
|
1336
|
+
pending && pending.dispatchedWorkerId && pending.dispatchedWorkerId === _activeHttpWorkerId &&
|
|
1337
|
+
Date.now() >= pending.hardDeadline
|
|
1338
|
+
) {
|
|
1339
|
+
_dropWedgedHttpWorker(`${type} exceeded its hard deadline`);
|
|
1340
|
+
}
|
|
1287
1341
|
};
|
|
1288
1342
|
const armTimer = () => setTimeout(expire, Math.min(timeoutMs, Math.max(0, hardDeadline - Date.now())));
|
|
1289
1343
|
_pendingRequests.set(id, {
|
|
@@ -1329,6 +1383,44 @@ const _commandTimeouts = {
|
|
|
1329
1383
|
// that typically arrive first after idle must outlive that cycle instead of failing at 30s.
|
|
1330
1384
|
if (process.env.SAFARI_PROFILE) { _commandTimeouts.new_tab = 75000; _commandTimeouts.list_tabs = 75000; }
|
|
1331
1385
|
|
|
1386
|
+
// ========== KEEPALIVE TAB (opt-in) ==========
|
|
1387
|
+
// Safari parks an idle extension worker; the first command afterwards then waits for the
|
|
1388
|
+
// 1-minute alarm (measured: new_tab 18s, or a hard failure before the queue fix). A page of
|
|
1389
|
+
// the profile that carries the content script relays wake-ups instantly and keeps the worker
|
|
1390
|
+
// alive — so keep exactly one such page open, served by this daemon itself.
|
|
1391
|
+
const KEEPALIVE_TAB_ENABLED = process.env.SAFARI_MCP_KEEPALIVE_TAB === "1";
|
|
1392
|
+
const KEEPALIVE_HTML = '<!doctype html><html lang="he" dir="rtl"><head><meta charset="utf-8"><title>Safari MCP · keepalive</title><meta name="robots" content="noindex"><style>body{font-family:-apple-system,system-ui,sans-serif;margin:3rem auto;max-width:36rem;padding:0 1rem;color:#1a2230;background:#f3f5f8;line-height:1.6}code{background:#ddeff2;padding:.1em .4em;border-radius:3px}</style></head><body><h1>Safari MCP</h1><p>הטאב הזה משאיר את ההרחבה ערה ברקע, כדי שהפקודה הראשונה אחרי הפסקה תרוץ מיד. אפשר להשאיר אותו פתוח; אם סוגרים אותו, הוא ייפתח שוב לבד.</p><p>לביטול: <code>SAFARI_MCP_KEEPALIVE_TAB=0</code> בהגדרות הדמון.</p></body></html>';
|
|
1393
|
+
const KEEPALIVE_URL = `http://127.0.0.1:${HTTP_PORT}/keepalive`;
|
|
1394
|
+
const KEEPALIVE_SESSION = `${SESSION_ID}:keepalive`;
|
|
1395
|
+
let _keepaliveBusy = false;
|
|
1396
|
+
let _keepaliveTimer = null;
|
|
1397
|
+
async function _ensureKeepaliveTab() {
|
|
1398
|
+
if (!KEEPALIVE_TAB_ENABLED || _keepaliveBusy || !_isExtensionHost || !_extensionConnected) return;
|
|
1399
|
+
if (_preferAppleScript && !_profileExtensionVerified) return;
|
|
1400
|
+
_keepaliveBusy = true;
|
|
1401
|
+
try {
|
|
1402
|
+
const tabs = await sendToExtension("list_tabs", { sessionId: KEEPALIVE_SESSION }, 30000);
|
|
1403
|
+
const list = Array.isArray(tabs) ? tabs : [];
|
|
1404
|
+
// No window at all → new_tab would have to create one, and a WebExtension-created
|
|
1405
|
+
// window is not guaranteed to land in this profile. Wait for a real window instead.
|
|
1406
|
+
if (!list.length) return;
|
|
1407
|
+
if (list.some((t) => String(t?.safeUrl || t?.url || "").startsWith(KEEPALIVE_URL))) return;
|
|
1408
|
+
await sendToExtension("new_tab", { url: KEEPALIVE_URL, sessionId: KEEPALIVE_SESSION }, 45000);
|
|
1409
|
+
console.error(`[Safari MCP] keepalive tab opened (${KEEPALIVE_URL})`);
|
|
1410
|
+
} catch (err) {
|
|
1411
|
+
console.error(`[Safari MCP] keepalive tab check skipped: ${err.message}`);
|
|
1412
|
+
} finally {
|
|
1413
|
+
_keepaliveBusy = false;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
function _scheduleKeepaliveTab(delayMs = 3000) {
|
|
1417
|
+
if (!KEEPALIVE_TAB_ENABLED) return;
|
|
1418
|
+
if (_keepaliveTimer) clearTimeout(_keepaliveTimer);
|
|
1419
|
+
_keepaliveTimer = setTimeout(() => { _keepaliveTimer = null; void _ensureKeepaliveTab(); }, delayMs);
|
|
1420
|
+
_keepaliveTimer.unref();
|
|
1421
|
+
}
|
|
1422
|
+
if (KEEPALIVE_TAB_ENABLED) setInterval(() => void _ensureKeepaliveTab(), 10 * 60 * 1000).unref();
|
|
1423
|
+
|
|
1332
1424
|
// Commands where null result means failure (should fall back to AppleScript)
|
|
1333
1425
|
const _nullMeansFailure = new Set([
|
|
1334
1426
|
"click", "double_click", "right_click", "fill",
|
|
@@ -1426,11 +1518,26 @@ function _safeUrlForOutput(rawUrl) {
|
|
|
1426
1518
|
}
|
|
1427
1519
|
}
|
|
1428
1520
|
|
|
1521
|
+
// Receipts this daemon rotated on the caller's behalf (a cross-origin safari_navigate) stay
|
|
1522
|
+
// usable under their old name: callers routinely keep the first receipt they saw and ignore
|
|
1523
|
+
// the fresh one in the navigate result (nine consecutive "not valid for this origin" failures
|
|
1524
|
+
// in one session on 4.9.26). Whoever held the old capability held the tab.
|
|
1525
|
+
const _receiptAliases = new Map(); // old token → newer token
|
|
1526
|
+
function _aliasReceipt(oldToken, newToken) {
|
|
1527
|
+
if (!oldToken || !newToken || oldToken === newToken) return;
|
|
1528
|
+
_receiptAliases.set(oldToken, newToken);
|
|
1529
|
+
if (_receiptAliases.size > 500) _receiptAliases.delete(_receiptAliases.keys().next().value);
|
|
1530
|
+
}
|
|
1429
1531
|
function _receiptToken(value) {
|
|
1430
1532
|
const raw = String(value || "");
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1533
|
+
let token = "";
|
|
1534
|
+
if (/^[A-Za-z0-9_-]{24,}$/.test(raw)) token = raw;
|
|
1535
|
+
else {
|
|
1536
|
+
const legacy = raw.match(/(?:[?#&])mcp-tab=([A-Za-z0-9_-]{24,})(?:[&#]|$)/);
|
|
1537
|
+
token = legacy ? legacy[1] : "";
|
|
1538
|
+
}
|
|
1539
|
+
for (let hops = 0; token && _receiptAliases.has(token) && hops < 8; hops++) token = _receiptAliases.get(token);
|
|
1540
|
+
return token;
|
|
1434
1541
|
}
|
|
1435
1542
|
|
|
1436
1543
|
// A caller-supplied receipt is the documented way for a stateless caller to name its
|
|
@@ -1936,12 +2043,17 @@ server.tool(
|
|
|
1936
2043
|
// origin", ~80×/week). The caller chose the destination, so rotating here is safe —
|
|
1937
2044
|
// hand back the new receipt instead of letting the caller discover the trap.
|
|
1938
2045
|
const landed = result && typeof result === "object" ? result.url : "";
|
|
1939
|
-
|
|
2046
|
+
const usedReceipt = _receiptToken(receipt || _getActiveReceipt());
|
|
2047
|
+
if (landed && _originOf(landed) !== _originOf(oldUrl) && usedReceipt) {
|
|
1940
2048
|
try {
|
|
1941
2049
|
const fresh = _sanitizeTabResult(await extensionOrFallback(
|
|
1942
2050
|
"get_tab_receipt", { ..._explicitReceipt({ receipt }) }, () => null
|
|
1943
2051
|
));
|
|
1944
|
-
if (fresh?.receipt) {
|
|
2052
|
+
if (fresh?.receipt) {
|
|
2053
|
+
_aliasReceipt(usedReceipt, fresh.receipt);
|
|
2054
|
+
_setActiveReceipt(fresh.receipt);
|
|
2055
|
+
result = { ...result, receipt: fresh.receipt };
|
|
2056
|
+
}
|
|
1945
2057
|
} catch { /* the navigation itself succeeded — a failed rotation must not fail the tool */ }
|
|
1946
2058
|
}
|
|
1947
2059
|
return textResult(result);
|
|
@@ -2568,6 +2680,7 @@ server.tool(
|
|
|
2568
2680
|
// session now gets its own MAX_TABS and can only evict its own.
|
|
2569
2681
|
const mySession = `${SESSION_ID}:${currentSessionId()}`;
|
|
2570
2682
|
const myTabs = [..._openedTabs].filter(([, info]) => (info.sessionId || "") === mySession);
|
|
2683
|
+
let evictedTab = null;
|
|
2571
2684
|
if (myTabs.length >= MAX_TABS) {
|
|
2572
2685
|
let oldestIdx = null, oldestTime = Infinity;
|
|
2573
2686
|
for (const [idx, info] of myTabs) {
|
|
@@ -2583,6 +2696,7 @@ server.tool(
|
|
|
2583
2696
|
);
|
|
2584
2697
|
} catch {}
|
|
2585
2698
|
_untrackTab(oldestIdx);
|
|
2699
|
+
evictedTab = oldestIdx;
|
|
2586
2700
|
}
|
|
2587
2701
|
}
|
|
2588
2702
|
|
|
@@ -2610,7 +2724,12 @@ server.tool(
|
|
|
2610
2724
|
}
|
|
2611
2725
|
if (!requestedUrl) _markBlankTabOpened();
|
|
2612
2726
|
if (safeResult?.receipt) _setActiveReceipt(safeResult.receipt);
|
|
2613
|
-
|
|
2727
|
+
// The eviction used to be stderr-only, so a session learned about its closed tab from
|
|
2728
|
+
// the next "receipt … stale" error. Say it in the response instead.
|
|
2729
|
+
const out = evictedTab !== null && safeResult && typeof safeResult === "object"
|
|
2730
|
+
? { ...safeResult, evictedTab, note: `Tab cap ${MAX_TABS}/session reached — your oldest tab #${evictedTab} was closed. Close tabs you are done with (safari_close_tab).` }
|
|
2731
|
+
: safeResult;
|
|
2732
|
+
return { content: [{ type: "text", text: typeof out === 'string' ? out : JSON.stringify(out) }] };
|
|
2614
2733
|
}
|
|
2615
2734
|
);
|
|
2616
2735
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.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",
|
package/safari.js
CHANGED
|
@@ -304,6 +304,7 @@ async function refreshTargetWindow(force = false) {
|
|
|
304
304
|
_targetWindowCacheTime = 0;
|
|
305
305
|
_profileWindowMissing = true;
|
|
306
306
|
_profileMisses++;
|
|
307
|
+
_maybeOpenProfileWindow();
|
|
307
308
|
// Log on transition into the missing state, then at most once per 5 minutes —
|
|
308
309
|
// this steady state used to append one line per poll, unbounded (#81).
|
|
309
310
|
if (_profileMisses === 1 || now - _lastMissingLogTime > 300000) {
|
|
@@ -313,6 +314,34 @@ async function refreshTargetWindow(force = false) {
|
|
|
313
314
|
}
|
|
314
315
|
}
|
|
315
316
|
|
|
317
|
+
// ponytail: opt-in self-heal for the "profile window is closed" steady state (198 refusals in
|
|
318
|
+
// one log, every morning after a reboot). SAFARI_MCP_OPEN_WINDOW_CMD is run with the profile
|
|
319
|
+
// name once absence is established (3 consecutive misses), at most every 2 minutes; the poll
|
|
320
|
+
// below then rediscovers the window. The command must open the window WITHOUT focusing
|
|
321
|
+
// Safari (e.g. ~/bin/safari-bg-window, which presses the File-menu item via Accessibility).
|
|
322
|
+
const OPEN_WINDOW_CMD = process.env.SAFARI_MCP_OPEN_WINDOW_CMD || "";
|
|
323
|
+
let _lastOpenWindowAttempt = 0;
|
|
324
|
+
function _maybeOpenProfileWindow() {
|
|
325
|
+
if (!OPEN_WINDOW_CMD || !SAFARI_PROFILE || _profileMisses < 3) return;
|
|
326
|
+
const now = Date.now();
|
|
327
|
+
if (now - _lastOpenWindowAttempt < 120000) return;
|
|
328
|
+
_lastOpenWindowAttempt = now;
|
|
329
|
+
_logProfile(`Profile "${SAFARI_PROFILE}" window absent — running ${OPEN_WINDOW_CMD}`);
|
|
330
|
+
// The opener presses a File-menu item, which needs Safari running and NOT hidden
|
|
331
|
+
// (`open -g -j` leaves the menu bar inaccessible). Launch it in the background first.
|
|
332
|
+
const ensureRunning = execFileAsync("/usr/bin/pgrep", ["-x", "Safari"]).then(() => null, () =>
|
|
333
|
+
execFileAsync("/usr/bin/open", ["-g", "-a", "Safari"])
|
|
334
|
+
.then(() => new Promise((r) => setTimeout(r, 5000)))
|
|
335
|
+
.catch(() => null));
|
|
336
|
+
ensureRunning
|
|
337
|
+
.then(() => execFileAsync(OPEN_WINDOW_CMD, [SAFARI_PROFILE], { timeout: 30000 }))
|
|
338
|
+
.then(() => {
|
|
339
|
+
_profileMisses = 0; // re-arm the subprocess retry for the fresh window
|
|
340
|
+
setTimeout(() => { refreshTargetWindow(true).catch(() => {}); }, 3000).unref();
|
|
341
|
+
})
|
|
342
|
+
.catch((err) => _logProfile(`Open-window command failed: ${err.message}`));
|
|
343
|
+
}
|
|
344
|
+
|
|
316
345
|
// Background verification: periodically check that cached window ID still belongs to profile
|
|
317
346
|
if (SAFARI_PROFILE) {
|
|
318
347
|
// Self-scheduling poll with exponential backoff (#81): 3s while the window is
|