safari-mcp 2.22.5 → 2.22.6
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/index.js +309 -115
- package/ownership-state.js +31 -12
- package/package.json +1 -1
- package/safari.js +543 -177
package/index.js
CHANGED
|
@@ -228,7 +228,7 @@ async function _evictOldestTab(sessionId) {
|
|
|
228
228
|
let open = mine.length;
|
|
229
229
|
for (const [key, info] of mine) {
|
|
230
230
|
if (open < MAX_TABS) break;
|
|
231
|
-
const outcome = await _closeTrackedTab(info);
|
|
231
|
+
const outcome = await _oneCloseAtATime(() => _closeTrackedTab(info));
|
|
232
232
|
// It may still be open: keep counting it and retry it at the next new tab. Close the next
|
|
233
233
|
// only through the extension, which fails fast: an AppleScript close that failed, a page that
|
|
234
234
|
// never answers the marker check, would cost every tracked tab its own timeout.
|
|
@@ -267,6 +267,59 @@ function _untrackClosedTab({ receipt = "", marker = "" }) {
|
|
|
267
267
|
}
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
// One tab close at a time in this process. closeTabByMarker() checks a tab's marker and closes the
|
|
271
|
+
// tab in one script, but as two AppleEvents, and a close sent in between renumbered the window: the
|
|
272
|
+
// index the check found then named the next tab, the user's included. Two tab-cap evictions at once
|
|
273
|
+
// could do it (safari_new_tab and run_script newTab, in parallel), as could an eviction next to
|
|
274
|
+
// safari_close_tab. An extension close goes by tab id, but it renumbers the window all the same.
|
|
275
|
+
// ponytail: this orders the closes of one server process (every session of an HTTP daemon); a close
|
|
276
|
+
// by another MCP process can still land between those two events. A slow extension close holds up
|
|
277
|
+
// the rest, shutdown cleanup included, which then leaves the tabs open after its 3s.
|
|
278
|
+
let _closeQueue = Promise.resolve();
|
|
279
|
+
function _oneCloseAtATime(close) {
|
|
280
|
+
const run = _closeQueue.then(() => close());
|
|
281
|
+
_closeQueue = run.catch(() => {});
|
|
282
|
+
return run;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A URL a call takes the session's tab to, and with no scheme its https:// form, which is what
|
|
286
|
+
// safari.js navigate() loads. Strings only: run_script step args are not validated. Closing a tab
|
|
287
|
+
// opened on the URL releases both forms (_claimedForms in ownership-state.js).
|
|
288
|
+
function _claimURL(url) {
|
|
289
|
+
if (typeof url !== "string" || !url) return;
|
|
290
|
+
_addOwnedURL(url);
|
|
291
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) _addOwnedURL("https://" + url);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Open a tab through AppleScript as one of the session's own: its URL claimed, and the tab tracked
|
|
295
|
+
// by the marker safari.newTab() mints for it, so the session's tab cap, shutdown cleanup and the
|
|
296
|
+
// memory sweep count and close it, and a close forgets it. The URL the caller asked for stays the
|
|
297
|
+
// session's current one wherever the page redirected: the site chose the redirect's target.
|
|
298
|
+
// newTab() names the marker (onMarker) as soon as the tab exists, and can still throw after that,
|
|
299
|
+
// when marking or reading the new tab fails; the tab is claimed and tracked then too. Left out, it
|
|
300
|
+
// outlived the cap and cleanup, and the next write was refused in it as "not opened by this MCP
|
|
301
|
+
// session". A newTab() that threw before the tab existed named no marker. The marker newTab()
|
|
302
|
+
// names, not the session's after the call: a parallel call of the same session changes that one,
|
|
303
|
+
// and a switch that adopted the user's tab (SAFARI_MCP_ALLOW_USER_TABS) meanwhile got the user's
|
|
304
|
+
// tab recorded among the ones the cap and cleanup close.
|
|
305
|
+
async function _openTabViaAppleScript(url) {
|
|
306
|
+
const mySession = `${SESSION_ID}:${currentSessionId()}`;
|
|
307
|
+
let minted = null;
|
|
308
|
+
try {
|
|
309
|
+
return await safari.newTab(url, { onMarker: (marker) => { minted = marker; } });
|
|
310
|
+
} finally {
|
|
311
|
+
if (minted) {
|
|
312
|
+
_trackTab(safari.getActiveTabIndex(), url, mySession, minted); // the index is only a hint
|
|
313
|
+
if (!url || url === "about:blank") {
|
|
314
|
+
_markBlankTabOpened();
|
|
315
|
+
} else {
|
|
316
|
+
safari.setActiveTabURL(url);
|
|
317
|
+
_claimURL(url);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
270
323
|
// Only the extension can tell which tab a receipt names: AppleScript proves a tab by the
|
|
271
324
|
// marker stamped on it (#112), and a tab the extension opened carries none. A close or switch
|
|
272
325
|
// by receipt whose extension attempt failed fell back to the session's current tab instead,
|
|
@@ -296,7 +349,7 @@ async function _cleanupTabs() {
|
|
|
296
349
|
// with no index would close only this session's active tab (#68).
|
|
297
350
|
for (const info of [..._openedTabs.values()]) {
|
|
298
351
|
try {
|
|
299
|
-
await safari.closeTabByMarker(info.marker);
|
|
352
|
+
await _oneCloseAtATime(() => safari.closeTabByMarker(info.marker));
|
|
300
353
|
} catch {}
|
|
301
354
|
}
|
|
302
355
|
_openedTabs.clear();
|
|
@@ -362,7 +415,7 @@ async function _closeOldestMCPTab() {
|
|
|
362
415
|
if (oldestIdx !== null) {
|
|
363
416
|
// By its identity, like the tab cap: an extension-opened tab carries a receipt, not a
|
|
364
417
|
// marker, and a marker-only close forgot it here without freeing anything.
|
|
365
|
-
await _closeTrackedTab(_openedTabs.get(oldestIdx));
|
|
418
|
+
await _oneCloseAtATime(() => _closeTrackedTab(_openedTabs.get(oldestIdx)));
|
|
366
419
|
_untrackTab(oldestIdx);
|
|
367
420
|
}
|
|
368
421
|
}
|
|
@@ -1815,6 +1868,21 @@ function _aliasReceipt(oldToken, newToken) {
|
|
|
1815
1868
|
_receiptAliases.set(oldToken, newToken);
|
|
1816
1869
|
if (_receiptAliases.size > 500) _receiptAliases.delete(_receiptAliases.keys().next().value);
|
|
1817
1870
|
}
|
|
1871
|
+
|
|
1872
|
+
// The origin a receipt is valid on: the page the extension reported with it (_sanitizeTabResult).
|
|
1873
|
+
// safari_navigate rotates a receipt only when the tab lands off that origin. Judged by the URL
|
|
1874
|
+
// safari.js holds instead (another tab's when the call names its tab by receipt, unknown after a
|
|
1875
|
+
// switch the extension served, stale after a navigation it served), a navigate that stayed on the
|
|
1876
|
+
// receipt's origin counted as a change and retired the receipt the caller kept.
|
|
1877
|
+
const _receiptOrigins = new Map(); // token → origin
|
|
1878
|
+
function _noteReceiptOrigin(receipt, url) {
|
|
1879
|
+
const token = _receiptToken(receipt);
|
|
1880
|
+
const origin = _originOf(url);
|
|
1881
|
+
if (!token || !origin) return;
|
|
1882
|
+
_receiptOrigins.set(token, origin);
|
|
1883
|
+
if (_receiptOrigins.size > 500) _receiptOrigins.delete(_receiptOrigins.keys().next().value);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1818
1886
|
function _receiptToken(value) {
|
|
1819
1887
|
const raw = String(value || "");
|
|
1820
1888
|
let token = "";
|
|
@@ -1890,6 +1958,8 @@ function _sanitizeTabResult(value) {
|
|
|
1890
1958
|
if (!normalized || typeof normalized !== "object") return normalized;
|
|
1891
1959
|
const safeUrl = _safeUrlForOutput(normalized.safeUrl || normalized.url || normalized.requestedUrl || "");
|
|
1892
1960
|
const receipt = _receiptToken(normalized.receipt || normalized.receiptUrl || "");
|
|
1961
|
+
// The extension hands every receipt out here, with the page it is valid on.
|
|
1962
|
+
_noteReceiptOrigin(receipt, safeUrl);
|
|
1893
1963
|
return {
|
|
1894
1964
|
...(normalized.index !== undefined ? { index: normalized.index } : {}),
|
|
1895
1965
|
...(normalized.tabIndex !== undefined ? { tabIndex: normalized.tabIndex } : {}),
|
|
@@ -1997,12 +2067,20 @@ async function _runExtensionBatchAction(action, args = {}) {
|
|
|
1997
2067
|
return _sanitizeTabResult(await extensionOrFallback("list_tabs", {}, () => safari.listTabs()));
|
|
1998
2068
|
|
|
1999
2069
|
case "getReceipt": {
|
|
2000
|
-
|
|
2070
|
+
// A receipt that is not one (a token cut short, the advice's "<old receipt>" left in) fell
|
|
2071
|
+
// through to the current tab's receipt, and the step rotated that tab instead.
|
|
2072
|
+
if (args.receipt && !_receiptToken(args.receipt)) {
|
|
2073
|
+
throw new Error("Tab safety: getReceipt requires an extension-issued receipt");
|
|
2074
|
+
}
|
|
2075
|
+
const previous = _receiptToken(args.receipt || _getActiveReceipt());
|
|
2076
|
+
// The payload names the receipt aliased below. extensionOrFallback would otherwise attach the
|
|
2077
|
+
// session's receipt as it stands after its own awaits, which a call running alongside can change.
|
|
2001
2078
|
const value = normalize(await extensionOrFallback(
|
|
2002
|
-
"get_tab_receipt", {
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2079
|
+
"get_tab_receipt", previous ? { receipt: previous } : {},
|
|
2080
|
+
() => {
|
|
2081
|
+
throw new Error("getReceipt: the Safari extension did not hand back a new receipt (the server log has its " +
|
|
2082
|
+
"reason; safari_doctor shows whether it is connected). AppleScript touched no tab.");
|
|
2083
|
+
}
|
|
2006
2084
|
));
|
|
2007
2085
|
const safeValue = _sanitizeTabResult(value);
|
|
2008
2086
|
// The rotated tab is now the current one. Its URL stays unknown in safari.js: a receipt
|
|
@@ -2051,7 +2129,6 @@ async function _runExtensionBatchAction(action, args = {}) {
|
|
|
2051
2129
|
case "navigate": {
|
|
2052
2130
|
const url = String(args.url || "");
|
|
2053
2131
|
if (!url) throw new Error("navigate requires url");
|
|
2054
|
-
_addOwnedURL(url);
|
|
2055
2132
|
const raw = await extensionOrFallback(
|
|
2056
2133
|
"navigate", { url, timeout: args.timeout },
|
|
2057
2134
|
() => safari.navigate(url)
|
|
@@ -2062,7 +2139,6 @@ async function _runExtensionBatchAction(action, args = {}) {
|
|
|
2062
2139
|
case "navigateAndRead": {
|
|
2063
2140
|
const url = String(args.url || "");
|
|
2064
2141
|
if (!url) throw new Error("navigateAndRead requires url");
|
|
2065
|
-
_addOwnedURL(url);
|
|
2066
2142
|
const raw = await extensionOrFallback(
|
|
2067
2143
|
"navigate_and_read", { url, maxLength: args.maxLength, timeout: args.timeout },
|
|
2068
2144
|
async () => { await safari.navigate(url); return safari.readPage({ maxLength: args.maxLength }); }
|
|
@@ -2214,8 +2290,8 @@ async function _runExtensionBatchAction(action, args = {}) {
|
|
|
2214
2290
|
// Tab-ownership assertion — shared by extensionOrFallback AND the tools that bypass it
|
|
2215
2291
|
// (safari_run_script, native_*). Throws if the operation would land on a tab this MCP
|
|
2216
2292
|
// session didn't open. Read-only / tab-management ops (in _noOwnershipCheck) are exempt.
|
|
2217
|
-
//
|
|
2218
|
-
//
|
|
2293
|
+
// A session with no tab of its own may only read: without one, AppleScript acts on the
|
|
2294
|
+
// front document, the tab the user is looking at.
|
|
2219
2295
|
function _assertTabOwnership(opType, extensionPayload = {}) {
|
|
2220
2296
|
if (_noOwnershipCheck.has(opType)) return;
|
|
2221
2297
|
// Closing is the one op the opt-in never unlocks (#92, condition 2). Adoption makes a
|
|
@@ -2232,26 +2308,36 @@ function _assertTabOwnership(opType, extensionPayload = {}) {
|
|
|
2232
2308
|
// presented after a stateless reconnect; only the extension can validate its exact
|
|
2233
2309
|
// tab binding, freshness, digest, and origin.
|
|
2234
2310
|
if (_preferAppleScript && _receiptToken(extensionPayload.receipt || _getActiveReceipt())) return;
|
|
2311
|
+
// A rotation that names its receipt touches no page and never falls back to AppleScript, so the
|
|
2312
|
+
// extension alone judges it, as a profile does every receipt. Without a profile, the URL safari.js
|
|
2313
|
+
// holds can be where a redirect took the tab during an AppleScript load, which the session never
|
|
2314
|
+
// registered, or, after safari_navigate, the URL the tab left, which the navigation released: this
|
|
2315
|
+
// check refused the rotation on both.
|
|
2316
|
+
if (opType === "get_tab_receipt" && _receiptToken(extensionPayload.receipt)) return;
|
|
2235
2317
|
const currentUrl = safari.getActiveTabURL();
|
|
2236
2318
|
// An adopted tab (#92) is the session's target even though the session opened nothing.
|
|
2237
2319
|
if (safari.isActiveTabAdopted()) {
|
|
2238
2320
|
console.error(`[Safari MCP] "${opType}" on ${_safeUrlForOutput(currentUrl)} (user tab, opted-in)`);
|
|
2239
2321
|
return;
|
|
2240
2322
|
}
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2323
|
+
// Asked of THIS session: whether it opened, claimed or adopted a tab (safari.js records that per
|
|
2324
|
+
// session, for AppleScript's tabs and the extension's alike), or names one by the receipt passed
|
|
2325
|
+
// with this call — a session with no state of its own yet, such as a subagent or the same agent
|
|
2326
|
+
// after a reconnect. The extension applies the same rule on its own path (handleCommand in
|
|
2327
|
+
// extension/background.js). It was asked of _ownedTabURLs and _openedTabs, which hold every
|
|
2328
|
+
// session's tabs in this process, and through owned-tabs.json every process's, so a tab opened
|
|
2329
|
+
// anywhere on the machine let a session that had none write, into the user's tab.
|
|
2330
|
+
if (!_receiptToken(extensionPayload.receipt) && !safari.hasOwnedTab()) {
|
|
2331
|
+
const msg = `⚠️ Tab safety: refusing "${opType}" — this MCP session has no tab of its own, so it would act on the tab in front, which is the user's. Open one with safari_new_tab, or pass the receipt of a tab you opened, with the call or to safari_switch_tab (a receipt survives reconnects and restarts).`;
|
|
2244
2332
|
console.error(`[Safari MCP] ${msg}`);
|
|
2245
2333
|
throw new Error(msg);
|
|
2246
2334
|
}
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
throw new Error(msg);
|
|
2254
|
-
}
|
|
2335
|
+
// A blank tab has no URL to own; the current tab of a session that has one is its own.
|
|
2336
|
+
const blank = currentUrl === 'about:blank' || currentUrl === 'missing value';
|
|
2337
|
+
if (currentUrl && !blank && !_isURLOwned(currentUrl)) {
|
|
2338
|
+
const msg = `⚠️ Tab safety: refusing "${opType}" — current tab (${_safeUrlForOutput(currentUrl)}) was not opened by this MCP session. Use safari_new_tab or safari_switch_tab to target your own tab.`;
|
|
2339
|
+
console.error(`[Safari MCP] ${msg}`);
|
|
2340
|
+
throw new Error(msg);
|
|
2255
2341
|
}
|
|
2256
2342
|
}
|
|
2257
2343
|
|
|
@@ -2261,6 +2347,11 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
|
|
|
2261
2347
|
_refuseUnnamedTab(extensionType, extensionPayload);
|
|
2262
2348
|
// Tab-ownership guard — extracted to _assertTabOwnership so run_script / native_* share it.
|
|
2263
2349
|
_assertTabOwnership(extensionType, extensionPayload);
|
|
2350
|
+
// A navigation claims its destination before it starts, so a load that throws halfway still
|
|
2351
|
+
// leaves the tab reachable (safari_switch_tab recovery). Only once the guard has passed: the
|
|
2352
|
+
// callers used to claim it first, and the destination vouched for itself — on a clean machine
|
|
2353
|
+
// it was the one owned URL the guard looked for, and a refused call left it in owned-tabs.json.
|
|
2354
|
+
if (extensionType === "navigate" || extensionType === "navigate_and_read") _addOwnedURL(extensionPayload.url);
|
|
2264
2355
|
|
|
2265
2356
|
// Safari may terminate an otherwise healthy profile worker between two tool
|
|
2266
2357
|
// calls. If no command has been sent yet, waiting for its verified reconnect is
|
|
@@ -2346,8 +2437,10 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
|
|
|
2346
2437
|
// the extension's tab-id proof and could switch to or mutate a user's tab.
|
|
2347
2438
|
if (String(err?.message || err).includes("Tab safety:")) {
|
|
2348
2439
|
if (/not valid for this origin/.test(err.message)) {
|
|
2440
|
+
// The receipt goes in "args": the tool's schema drops a key beside "action", and
|
|
2441
|
+
// getReceipt then rotated the session's current tab, or was refused after a reconnect.
|
|
2349
2442
|
err.message += " Likely cause: the tab moved to another origin after the receipt was minted (redirect/login). " +
|
|
2350
|
-
"Rotate it with safari_run_script [{\"action\":\"getReceipt\",\"receipt\":\"<old receipt>\"}], or reopen with safari_new_tab.";
|
|
2443
|
+
"Rotate it with safari_run_script [{\"action\":\"getReceipt\",\"args\":{\"receipt\":\"<old receipt>\"}}], or reopen with safari_new_tab.";
|
|
2351
2444
|
}
|
|
2352
2445
|
throw err;
|
|
2353
2446
|
}
|
|
@@ -2460,11 +2553,7 @@ server.tool(
|
|
|
2460
2553
|
throw new Error(`safari_navigate only opens http(s) URLs in a Safari profile ("${url.slice(0, 40)}" would leave the tab unreachable). To free the tab, use safari_close_tab.`);
|
|
2461
2554
|
}
|
|
2462
2555
|
const oldUrl = safari.getActiveTabURL();
|
|
2463
|
-
//
|
|
2464
|
-
// tab, so the target URL is ours even if navigate() throws mid-load on a slow SPA.
|
|
2465
|
-
// Without this, a slow/failed navigate left the new URL unowned and locked the
|
|
2466
|
-
// switch_tab recovery out — the very recovery the lock error tells you to use.
|
|
2467
|
-
_addOwnedURL(url);
|
|
2556
|
+
// extensionOrFallback claims the destination for this session once its guard passes.
|
|
2468
2557
|
let result = await extensionOrFallback(
|
|
2469
2558
|
"navigate", { url, ..._explicitReceipt({ receipt }) },
|
|
2470
2559
|
() => safari.navigate(url)
|
|
@@ -2477,7 +2566,7 @@ server.tool(
|
|
|
2477
2566
|
// hand back the new receipt instead of letting the caller discover the trap.
|
|
2478
2567
|
const landed = result && typeof result === "object" ? result.url : "";
|
|
2479
2568
|
const usedReceipt = _receiptToken(receipt || _getActiveReceipt());
|
|
2480
|
-
if (landed && _originOf(landed) !== _originOf(oldUrl) && usedReceipt) {
|
|
2569
|
+
if (landed && _originOf(landed) !== (_receiptOrigins.get(usedReceipt) || _originOf(oldUrl)) && usedReceipt) {
|
|
2481
2570
|
try {
|
|
2482
2571
|
const fresh = _sanitizeTabResult(await extensionOrFallback(
|
|
2483
2572
|
"get_tab_receipt", { ..._explicitReceipt({ receipt }) }, () => null
|
|
@@ -2580,13 +2669,10 @@ server.tool(
|
|
|
2580
2669
|
maxLength: z.coerce.number().optional().describe("Max chars to return (default: 50000)"),
|
|
2581
2670
|
timeout: z.coerce.number().optional().describe("Load timeout in ms (default: 30000)"),
|
|
2582
2671
|
},
|
|
2583
|
-
async ({ url, maxLength, timeout }) => {
|
|
2672
|
+
async ({ url, maxLength, timeout, receipt }) => {
|
|
2584
2673
|
const oldUrl = safari.getActiveTabURL();
|
|
2585
|
-
// Pre-register destination as owned BEFORE navigating (see safari_navigate) so a
|
|
2586
|
-
// slow/throwing navigate on a heavy SPA cannot lock switch_tab recovery out.
|
|
2587
|
-
_addOwnedURL(url);
|
|
2588
2674
|
const result = await extensionOrFallback(
|
|
2589
|
-
"navigate_and_read", { url, maxLength, timeout },
|
|
2675
|
+
"navigate_and_read", { url, maxLength, timeout, ..._explicitReceipt({ receipt }) },
|
|
2590
2676
|
async () => {
|
|
2591
2677
|
await safari.navigate(url);
|
|
2592
2678
|
return safari.readPage({ maxLength });
|
|
@@ -2848,8 +2934,11 @@ server.tool(
|
|
|
2848
2934
|
value: z.string().describe("Option value or visible label to select"),
|
|
2849
2935
|
},
|
|
2850
2936
|
async (args) => {
|
|
2851
|
-
// The
|
|
2852
|
-
|
|
2937
|
+
// The receipt first: for a caller with no state of its own it is what names the tab, and the
|
|
2938
|
+
// guard has to see it. The engine calls below can bypass extensionOrFallback — assert
|
|
2939
|
+
// ownership here.
|
|
2940
|
+
const pin = _explicitReceipt(args);
|
|
2941
|
+
_assertTabOwnership("select_option", pin);
|
|
2853
2942
|
// ref path: resolve via mcpFindRef (reaches iframes/shadow DOM) on the AppleScript
|
|
2854
2943
|
// engine — the extension's select_option handler is selector-only.
|
|
2855
2944
|
if (args.ref) {
|
|
@@ -2857,7 +2946,7 @@ server.tool(
|
|
|
2857
2946
|
return { content: [{ type: "text", text: typeof refResult === 'string' ? refResult : JSON.stringify(refResult) }] };
|
|
2858
2947
|
}
|
|
2859
2948
|
let result = await extensionOrFallback(
|
|
2860
|
-
"select_option", { selector: args.selector, value: args.value, ...
|
|
2949
|
+
"select_option", { selector: args.selector, value: args.value, ...pin },
|
|
2861
2950
|
() => safari.selectOption(args)
|
|
2862
2951
|
);
|
|
2863
2952
|
// If extension returned "Selected: " with empty/default value, fuzzy match may have failed.
|
|
@@ -3112,7 +3201,7 @@ server.tool(
|
|
|
3112
3201
|
let viaAppleScript = false;
|
|
3113
3202
|
const rawResult = await extensionOrFallback(
|
|
3114
3203
|
"new_tab", { url: requestedUrl },
|
|
3115
|
-
() => { viaAppleScript = true; return
|
|
3204
|
+
() => { viaAppleScript = true; return _openTabViaAppleScript(requestedUrl); }
|
|
3116
3205
|
);
|
|
3117
3206
|
// AppleScript fallback returns a JSON string; extension returns an object — normalize
|
|
3118
3207
|
let result = rawResult;
|
|
@@ -3124,9 +3213,8 @@ server.tool(
|
|
|
3124
3213
|
// remains internal and is never copied into the MCP response.
|
|
3125
3214
|
if (safeResult?.tabIndex) {
|
|
3126
3215
|
safari.setActiveTabIndex(safeResult.tabIndex);
|
|
3127
|
-
//
|
|
3128
|
-
|
|
3129
|
-
_trackTab(safeResult.tabIndex, requestedUrl, mySession, viaAppleScript ? safari.getActiveTabMarker() : "", safeResult.receipt);
|
|
3216
|
+
// A tab AppleScript opened is tracked by _openTabViaAppleScript, by its marker.
|
|
3217
|
+
if (!viaAppleScript) _trackTab(safeResult.tabIndex, requestedUrl, mySession, "", safeResult.receipt);
|
|
3130
3218
|
// The new tab is the current one, with or without a receipt.
|
|
3131
3219
|
_setActiveReceipt(safeResult.receipt);
|
|
3132
3220
|
}
|
|
@@ -3162,18 +3250,23 @@ server.tool(
|
|
|
3162
3250
|
// tab's receipt, and the tab the caller was still working in closed instead of the one it named.
|
|
3163
3251
|
const token = supplied ? _receiptToken(supplied) : _getActiveReceipt();
|
|
3164
3252
|
if (supplied && !token) return errorResult("Tab safety: invalid tab receipt");
|
|
3165
|
-
|
|
3166
|
-
const marker = safari.getActiveTabMarker();
|
|
3253
|
+
let closesCurrent = false, marker = null;
|
|
3167
3254
|
let viaAppleScript = false;
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
()
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3255
|
+
// One close at a time (_oneCloseAtATime). Which tab is current is read when this close's turn
|
|
3256
|
+
// comes: a parallel call can switch tabs while it waits, and AppleScript closes the one current then.
|
|
3257
|
+
const result = await _oneCloseAtATime(() => {
|
|
3258
|
+
closesCurrent = !token || token === _getActiveReceipt();
|
|
3259
|
+
marker = safari.getActiveTabMarker();
|
|
3260
|
+
return extensionOrFallback(
|
|
3261
|
+
"close_tab",
|
|
3262
|
+
token ? { receipt: token } : {},
|
|
3263
|
+
() => {
|
|
3264
|
+
if (token) throw _receiptNeedsExtension("close_tab");
|
|
3265
|
+
viaAppleScript = true;
|
|
3266
|
+
return safari.closeTab();
|
|
3267
|
+
}
|
|
3268
|
+
);
|
|
3269
|
+
});
|
|
3177
3270
|
// The extension closed the receipt's tab; AppleScript, the tab carrying the session's marker.
|
|
3178
3271
|
_untrackClosedTab(viaAppleScript ? { marker } : { receipt: token });
|
|
3179
3272
|
// Closing another tab by its receipt leaves the current one current.
|
|
@@ -3271,16 +3364,19 @@ server.tool(
|
|
|
3271
3364
|
return claimed;
|
|
3272
3365
|
}
|
|
3273
3366
|
);
|
|
3274
|
-
// Adopted just now, or earlier: switchTab() keeps an adopted tab
|
|
3367
|
+
// Adopted just now, or earlier: switchTab() keeps an adopted tab in the adoption family.
|
|
3275
3368
|
const adopted = viaAppleScript && safari.isActiveTabAdopted();
|
|
3276
3369
|
const safeResult = _sanitizeTabResult(result);
|
|
3277
3370
|
// Sync safari.js state so AppleScript fallback targets the correct tab
|
|
3278
3371
|
const resolvedIndex = safeResult?.tabIndex || index;
|
|
3279
3372
|
if (resolvedIndex) safari.setActiveTabIndex(resolvedIndex);
|
|
3280
|
-
if (safeResult?.safeUrl) safari.setActiveTabURL(safeResult.safeUrl);
|
|
3281
|
-
// …including the marker, which still names the last tab AppleScript claimed.
|
|
3373
|
+
if (viaAppleScript && safeResult?.safeUrl) safari.setActiveTabURL(safeResult.safeUrl);
|
|
3374
|
+
// …including the marker, which still names the last tab AppleScript claimed. The URL is left
|
|
3375
|
+
// unknown, as run_script's switchTab leaves it: the extension reports where the tab is now,
|
|
3376
|
+
// which after a redirect or a login bounce is a page this session never registered, and the
|
|
3377
|
+
// ownership guard refused every write after it, though the extension had proven the tab by its id.
|
|
3282
3378
|
if (!viaAppleScript && safeResult && typeof safeResult === "object") {
|
|
3283
|
-
safari.setActiveTabFromExtension(resolvedIndex,
|
|
3379
|
+
safari.setActiveTabFromExtension(resolvedIndex, null);
|
|
3284
3380
|
}
|
|
3285
3381
|
if (safeResult?.receipt || token) _setActiveReceipt(safeResult?.receipt || token);
|
|
3286
3382
|
// A switch by INDEX gets no receipt back, and the session was still holding the one it
|
|
@@ -3326,39 +3422,50 @@ server.tool(
|
|
|
3326
3422
|
},
|
|
3327
3423
|
async ({ timeout, urlContains }) => {
|
|
3328
3424
|
const timeoutMs = timeout || 10000;
|
|
3329
|
-
// Every listing says where it came from. AppleScript lists one window
|
|
3330
|
-
//
|
|
3331
|
-
//
|
|
3332
|
-
//
|
|
3333
|
-
// comparison over instead of comparing tabs
|
|
3425
|
+
// Every listing says where it came from. AppleScript lists one window by id (`win`): the one
|
|
3426
|
+
// this session's own tab is in, proven by its marker when AppleScript first lists, and a claim
|
|
3427
|
+
// through AppleScript goes to that window. Listings of whichever window was in front made a
|
|
3428
|
+
// tab of the user's other window look new, and the claim stamped the session's own marker on
|
|
3429
|
+
// it. A listing from the other source starts the comparison over instead of comparing tabs
|
|
3430
|
+
// across them.
|
|
3334
3431
|
let win = null;
|
|
3335
3432
|
const list = async () => {
|
|
3336
3433
|
let via = "extension";
|
|
3337
3434
|
const raw = await extensionOrFallback("list_tabs", {}, async () => {
|
|
3338
3435
|
via = "applescript";
|
|
3436
|
+
if (!win) win = await safari.sessionTabWindow();
|
|
3339
3437
|
const listed = await safari.listWindowTabs(win);
|
|
3340
3438
|
win = listed.win;
|
|
3341
3439
|
return listed.tabs;
|
|
3342
3440
|
});
|
|
3343
3441
|
return { via, tabs: typeof raw === "string" ? JSON.parse(raw) : raw };
|
|
3344
3442
|
};
|
|
3443
|
+
// A listed tab's URL: AppleScript lists `url`, the extension `safeUrl` (origin and path).
|
|
3444
|
+
const urlOf = (t) => t.url ?? t.safeUrl;
|
|
3345
3445
|
// Switch to the new tab and own it for THIS session, else the next interaction trips the
|
|
3346
3446
|
// tab-safety guard. Its marker names it only if AppleScript made the switch (and stamped
|
|
3347
|
-
// it); the extension answers with the tab's receipt instead.
|
|
3348
|
-
//
|
|
3349
|
-
// the extension's window,
|
|
3447
|
+
// it); the extension answers with the tab's receipt instead. A tab AppleScript listed is
|
|
3448
|
+
// claimed through AppleScript, in the pinned window, at once: its index is a position there,
|
|
3449
|
+
// not in the extension's window, and waiting out the extension's switch_tab first (30 s
|
|
3450
|
+
// while its worker is busy) left time for another tab to slide under that index. AppleScript
|
|
3451
|
+
// never claims a tab only the extension's listing saw.
|
|
3350
3452
|
const adopt = async (t, via) => {
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
()
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3453
|
+
const viaAppleScript = via === "applescript";
|
|
3454
|
+
let switched;
|
|
3455
|
+
if (viaAppleScript) {
|
|
3456
|
+
if (!win) throw new Error("Tab safety: AppleScript did not say which window it listed, so it cannot tell which tab opened, and this session claimed none. The tab stays open, and another safari_wait_for_new_tab will not report it: that call starts from a listing that already has it.");
|
|
3457
|
+
switched = _sanitizeTabResult(await safari.switchTab(t.index, { claim: true, win, expectUrl: t.url }));
|
|
3458
|
+
} else {
|
|
3459
|
+
switched = _sanitizeTabResult(await extensionOrFallback("switch_tab", { index: t.index }, () => {
|
|
3460
|
+
throw new Error("Tab safety: a new tab opened, but the Safari extension could not switch to it and AppleScript cannot tell which tab of its own window that is, so this session did not claim it. The tab stays open, and another safari_wait_for_new_tab will not report it: that call starts from a listing that already has it. Check the extension with safari_doctor.");
|
|
3461
|
+
}));
|
|
3462
|
+
// The extension switches to whatever tab sits at the index when it runs the command, and
|
|
3463
|
+
// answers "Tab not found" as if it had switched: a tab that closed or moved in between
|
|
3464
|
+
// handed the session another tab of its own, or none, under the new tab's name.
|
|
3465
|
+
if (switched?.safeUrl !== _safeUrlForOutput(urlOf(t))) {
|
|
3466
|
+
throw Object.assign(new Error("Tab safety: the Safari extension switched to another tab than the one the listing saw (a tab closed or moved in between), so this session did not claim it."), { moved: true });
|
|
3360
3467
|
}
|
|
3361
|
-
|
|
3468
|
+
}
|
|
3362
3469
|
safari.setActiveTabIndex(t.index);
|
|
3363
3470
|
safari.setActiveTabURL(t.url);
|
|
3364
3471
|
if (!viaAppleScript) safari.setActiveTabFromExtension(t.index, t.url);
|
|
@@ -3369,49 +3476,102 @@ server.tool(
|
|
|
3369
3476
|
_trackTab(t.index, t.url, `${SESSION_ID}:${currentSessionId()}`, viaAppleScript ? safari.getActiveTabMarker() : "", switched?.receipt);
|
|
3370
3477
|
}
|
|
3371
3478
|
_setActiveReceipt(switched?.receipt);
|
|
3372
|
-
return { content: [{ type: "text", text: `Found new tab: ${t.title} (${t
|
|
3479
|
+
return { content: [{ type: "text", text: `Found new tab: ${t.title} (${urlOf(t)})` }] };
|
|
3480
|
+
};
|
|
3481
|
+
// Whether every tab of `was` is still there on its URL, in order, in `is`: only then can a tab
|
|
3482
|
+
// `is` added be told from the old ones. The first `index:url` pair not seen before was often a
|
|
3483
|
+
// tab of the user's that had navigated, or had moved when another closed, and the claim stamped
|
|
3484
|
+
// the session's own marker on it.
|
|
3485
|
+
// ponytail: tabs are told apart by URL alone (AppleScript has no tab id and no opener), so any
|
|
3486
|
+
// tab the user opens in the window during the wait counts as new, a new tab that opens on the
|
|
3487
|
+
// very URL a tab of the user's leaves in the same poll can pass for it (as can a tab of the
|
|
3488
|
+
// user's that lands on the URL of a new tab closing next to it, while another tab opens further
|
|
3489
|
+
// along, which leaves the same listings as that new tab staying put), and the claim stamps
|
|
3490
|
+
// whatever tab sits at the listed index a script later unless switchTab checks `expectUrl`.
|
|
3491
|
+
const intact = (was, is) => {
|
|
3492
|
+
const seen = new Set(was.map(urlOf));
|
|
3493
|
+
const kept = is.filter((t) => seen.has(urlOf(t)));
|
|
3494
|
+
return kept.length === was.length && kept.every((t, i) => urlOf(t) === urlOf(was[i]));
|
|
3495
|
+
};
|
|
3496
|
+
// Where the new tabs `last` showed are in `is`: each still on its URL, shown by no more and no
|
|
3497
|
+
// fewer tabs than before, and in its place while the other tabs only navigated (as many tabs as
|
|
3498
|
+
// before), or still there while the others only closed (the rest on their URLs, in order) and
|
|
3499
|
+
// none next to it. A tab that went next to a new one could instead have landed on its URL as the
|
|
3500
|
+
// new one went, and a tab that joins it on its URL could be taken for it; a neighbour counts as
|
|
3501
|
+
// still there only while as many tabs as before show its URL, or a twin could stand in for it.
|
|
3502
|
+
// Null when unsure.
|
|
3503
|
+
const stillNew = (last, urls, is) => {
|
|
3504
|
+
const count = (tabs, u) => tabs.filter((t) => urlOf(t) === u).length;
|
|
3505
|
+
const at = (tabs, u) => tabs.findIndex((t) => urlOf(t) === u);
|
|
3506
|
+
if (urls.some((u) => count(is, u) !== count(last, u))) return null;
|
|
3507
|
+
if (is.length === last.length) return urls.some((u) => at(is, u) !== at(last, u)) ? null : urls.map((u) => is[at(is, u)]);
|
|
3508
|
+
const there = new Set();
|
|
3509
|
+
let j = 0;
|
|
3510
|
+
last.forEach((t, k) => { if (j < is.length && urlOf(is[j]) === urlOf(t)) { there.add(k); j++; } });
|
|
3511
|
+
const stays = (k) => count(is, urlOf(last[k])) === count(last, urlOf(last[k]));
|
|
3512
|
+
const neighbours = (u) => [at(last, u) - 1, at(last, u) + 1].every((k) => k < 0 || k >= last.length || (there.has(k) && stays(k)));
|
|
3513
|
+
return j === is.length && urls.every(neighbours) ? urls.map((u) => is[at(is, u)]) : null;
|
|
3373
3514
|
};
|
|
3374
3515
|
// Get current tab list
|
|
3375
3516
|
let before = await list();
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3517
|
+
// Every URL a baseline showed. A new tab is one on a URL none of them showed, so a tab that
|
|
3518
|
+
// left the window and came back (reopened, or dragged out and in again) is not new either.
|
|
3519
|
+
const shown = new Set(before.tabs.map(urlOf));
|
|
3520
|
+
// The new tabs not claimed yet (still on about:blank, not matching urlContains, or refused),
|
|
3521
|
+
// by URL, and the listing that showed them; and whether any tab opened at all.
|
|
3522
|
+
let pending = { last: before.tabs, urls: [] };
|
|
3523
|
+
let opened = false;
|
|
3524
|
+
|
|
3525
|
+
// Poll for new tab — detect by count increase + tabs on new URLs
|
|
3379
3526
|
const deadline = Date.now() + timeoutMs;
|
|
3380
3527
|
while (Date.now() < deadline) {
|
|
3381
3528
|
await new Promise(r => setTimeout(r, 500));
|
|
3382
|
-
const now = await list()
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3529
|
+
const now = await list().catch((err) => {
|
|
3530
|
+
// A listing that fails (the window pin refused, AppleScript failed) takes the wait's record
|
|
3531
|
+
// of a tab that opened with it: say so, as the timeout would.
|
|
3532
|
+
if (opened && err instanceof Error) err.message += " A tab opened during the wait; if it is still open, another safari_wait_for_new_tab will not report it.";
|
|
3533
|
+
throw err;
|
|
3534
|
+
});
|
|
3535
|
+
// A listing where a tab closed or navigated becomes the baseline, so a tab of the user's that
|
|
3536
|
+
// navigated meanwhile is not new once one opens; so does one from the other source. A new
|
|
3537
|
+
// tab not claimed yet stays out of it while the other tabs only navigated in place or only
|
|
3538
|
+
// closed: taken into it, it was never new again, and the wait timed out on a popup that
|
|
3539
|
+
// was still loading when another tab changed. Any other change, or one as a tab opened,
|
|
3540
|
+
// takes every tab in: held against the older baseline, whichever of two tabs that changed
|
|
3541
|
+
// navigated first looked new, the user's too.
|
|
3542
|
+
if (now.via !== before.via || !intact(before.tabs, now.tabs)) {
|
|
3543
|
+
const same = now.via === before.via;
|
|
3544
|
+
const kept = !pending.urls.length ? [] : same ? stillNew(pending.last, pending.urls, now.tabs) : null;
|
|
3545
|
+
// Only AppleScript lists one window by id: the extension can list whichever window is in front.
|
|
3546
|
+
if (same && now.via === "applescript" && now.tabs.length > before.tabs.length) opened = true;
|
|
3547
|
+
before = { ...now, tabs: now.tabs.filter((t) => !kept?.includes(t)) };
|
|
3548
|
+
for (const t of before.tabs) shown.add(urlOf(t));
|
|
3549
|
+
pending = { last: now.tabs, urls: kept ? pending.urls : [] };
|
|
3386
3550
|
continue;
|
|
3387
3551
|
}
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
}
|
|
3406
|
-
continue;
|
|
3407
|
-
}
|
|
3408
|
-
if (urlContains && !tab.url.includes(urlContains)) continue;
|
|
3409
|
-
return await adopt(tab, now.via);
|
|
3410
|
-
}
|
|
3552
|
+
const old = new Set(before.tabs.map(urlOf));
|
|
3553
|
+
pending = { last: now.tabs, urls: now.tabs.map(urlOf).filter((u) => !old.has(u)) };
|
|
3554
|
+
if (pending.urls.length) opened = true;
|
|
3555
|
+
for (const tab of now.tabs) {
|
|
3556
|
+
// A new tab still on about:blank (an OAuth popup before it loads) is claimed at a later
|
|
3557
|
+
// poll, once it shows its URL: a later listing found it again by index, and by then that
|
|
3558
|
+
// could be another tab.
|
|
3559
|
+
if (urlOf(tab) === 'about:blank' || shown.has(urlOf(tab))) continue;
|
|
3560
|
+
if (urlContains && !String(urlOf(tab)).includes(urlContains)) continue;
|
|
3561
|
+
try {
|
|
3562
|
+
return await adopt(tab, now.via);
|
|
3563
|
+
} catch (err) {
|
|
3564
|
+
// A tab another session opened in this window meanwhile, whose marker the claim refuses
|
|
3565
|
+
// to overwrite (`otherSession`), or a tab that no longer shows the page the listing saw
|
|
3566
|
+
// (`moved`: one closed in between and slid another under the index). Neither is the
|
|
3567
|
+
// tab this waits for, and a later listing can still show it.
|
|
3568
|
+
if (!err?.otherSession && !err?.moved) throw err;
|
|
3411
3569
|
}
|
|
3412
3570
|
}
|
|
3413
3571
|
}
|
|
3414
|
-
return { content: [{ type: "text", text:
|
|
3572
|
+
return { content: [{ type: "text", text: opened
|
|
3573
|
+
? "TIMEOUT: a tab opened during the wait, but none this call could claim: it could not be told apart from a tab that changed at the same time, urlContains ruled it out, or its claim was refused. If it is still open, another safari_wait_for_new_tab will not report it: open it again during a new wait, or ask the user."
|
|
3574
|
+
: "TIMEOUT: no new tab appeared" }] };
|
|
3415
3575
|
}
|
|
3416
3576
|
);
|
|
3417
3577
|
|
|
@@ -3689,7 +3849,7 @@ server.tool(
|
|
|
3689
3849
|
|
|
3690
3850
|
server.tool(
|
|
3691
3851
|
"safari_run_script",
|
|
3692
|
-
"Batch Safari actions in one MCP session. Named profiles use the verified extension and support: newTab, switchTab, getReceipt, listTabs, closeTab, navigate, navigateAndRead, readPage, snapshot, getElementInfo, querySelectorAll, waitFor, waitForTime, click, clickAndOpenPopup, fill, fillForm, clearField, typeText, selectOption, pressKey, scroll, scrollTo, scrollToElement, hover, evaluate, reload, goBack, goForward. switchTab accepts an index or an opaque receipt; a valid receipt recovers the exact owned tab across Safari windows without focusing one. clickAndOpenPopup takes exactly one selector or snapshot ref, targets one exact frame, performs one click, captures a blocked HTTP(S) window.open, and opens it as a background tab without focusing Safari; it refuses CAPTCHA/challenge targets and never returns URL query/hash data. Non-profile mode retains the legacy action set; there, closeTab and switchTab take a tab index, not a receipt
|
|
3852
|
+
"Batch Safari actions in one MCP session. Named profiles use the verified extension and support: newTab, switchTab, getReceipt, listTabs, closeTab, navigate, navigateAndRead, readPage, snapshot, getElementInfo, querySelectorAll, waitFor, waitForTime, click, clickAndOpenPopup, fill, fillForm, clearField, typeText, selectOption, pressKey, scroll, scrollTo, scrollToElement, hover, evaluate, reload, goBack, goForward. switchTab accepts an index or an opaque receipt; a valid receipt recovers the exact owned tab across Safari windows without focusing one. clickAndOpenPopup takes exactly one selector or snapshot ref, targets one exact frame, performs one click, captures a blocked HTTP(S) window.open, and opens it as a background tab without focusing Safari; it refuses CAPTCHA/challenge targets and never returns URL query/hash data. Non-profile mode retains the legacy action set plus getReceipt; there, closeTab and switchTab take a tab index, not a receipt, and getReceipt needs the tab's receipt in args. After a cross-origin redirect, rotate the tab's receipt with {\"action\":\"getReceipt\",\"args\":{\"receipt\":\"<old receipt>\"}}.",
|
|
3693
3853
|
{
|
|
3694
3854
|
steps: z.array(z.object({
|
|
3695
3855
|
action: z.string().describe("Action name (e.g. 'navigate', 'click', 'fill')"),
|
|
@@ -3702,15 +3862,16 @@ server.tool(
|
|
|
3702
3862
|
// AppleScript and loses the verified profile/tab-id boundary; they use the
|
|
3703
3863
|
// extension-only dispatcher below.
|
|
3704
3864
|
if (!process.env.SAFARI_PROFILE) {
|
|
3865
|
+
const mySession = `${SESSION_ID}:${currentSessionId()}`;
|
|
3705
3866
|
const onStep = (action, stepArgs) => {
|
|
3706
|
-
|
|
3867
|
+
// getReceipt is guarded in extensionOrFallback, which sees the receipt the step names and leaves
|
|
3868
|
+
// a rotation that names one to the extension. The check below is given no payload, so it would not
|
|
3869
|
+
// see that receipt and would judge the URL safari.js holds: after a redirect during an AppleScript
|
|
3870
|
+
// load, one the session never registered.
|
|
3871
|
+
if (action === "getReceipt") return;
|
|
3707
3872
|
if (action === "navigate" || action === "navigateAndRead") {
|
|
3708
3873
|
_assertTabOwnership(`run_script:${action}`);
|
|
3709
|
-
|
|
3710
|
-
if (url) {
|
|
3711
|
-
_addOwnedURL(url);
|
|
3712
|
-
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) _addOwnedURL("https://" + url);
|
|
3713
|
-
}
|
|
3874
|
+
_claimURL(stepArgs?.url);
|
|
3714
3875
|
return;
|
|
3715
3876
|
}
|
|
3716
3877
|
// These steps run through AppleScript here, which cannot tell which tab a receipt names:
|
|
@@ -3747,7 +3908,40 @@ server.tool(
|
|
|
3747
3908
|
return _runExtensionBatchAction("switchTab", { index: Number(index) });
|
|
3748
3909
|
}
|
|
3749
3910
|
};
|
|
3750
|
-
|
|
3911
|
+
// A newTab step opens its tab as safari_new_tab does: under the session's tab cap, which first
|
|
3912
|
+
// closes the session's oldest tab when it already has MAX_TABS, and as one of the session's
|
|
3913
|
+
// own tabs, claimed and tracked (_openTabViaAppleScript). It used to claim nothing, so every
|
|
3914
|
+
// write step after newTab{url} was refused on the session's own tab, and to track nothing, so
|
|
3915
|
+
// a batch's tabs escaped the cap, shutdown cleanup and the memory sweep.
|
|
3916
|
+
const newTab = async ({ url }) => {
|
|
3917
|
+
const evicted = await _evictOldestTab(mySession);
|
|
3918
|
+
const result = await _openTabViaAppleScript(String(url || ""));
|
|
3919
|
+
if (!evicted) return result;
|
|
3920
|
+
// Name the tab the cap closed, as safari_new_tab does.
|
|
3921
|
+
let tab = {};
|
|
3922
|
+
try { tab = JSON.parse(result); } catch {}
|
|
3923
|
+
return { ...tab, ..._evictionReport(evicted) };
|
|
3924
|
+
};
|
|
3925
|
+
// A closeTab step forgets the tab it closed, as safari_close_tab does after an AppleScript
|
|
3926
|
+
// close: closeOwnTab() closes only the tab that carries the session's marker. One close at a
|
|
3927
|
+
// time (_oneCloseAtATime).
|
|
3928
|
+
const closeTab = ({ index }) => _oneCloseAtATime(async () => {
|
|
3929
|
+
const marker = safari.getActiveTabMarker();
|
|
3930
|
+
const result = await safari.closeOwnTab(index);
|
|
3931
|
+
_untrackClosedTab({ marker });
|
|
3932
|
+
return result;
|
|
3933
|
+
});
|
|
3934
|
+
// Only the extension can rotate a receipt, and the legacy action table had no getReceipt, so
|
|
3935
|
+
// the step the origin refusal advises came back "Unknown action" and the tab stayed stranded.
|
|
3936
|
+
// It has to name its tab here: this batch's AppleScript steps (newTab, switchTab) move to a
|
|
3937
|
+
// tab without touching the session's receipt, which then names a tab the batch has left.
|
|
3938
|
+
const getReceipt = (stepArgs) => {
|
|
3939
|
+
if (!stepArgs?.receipt) {
|
|
3940
|
+
throw new Error("Tab safety: without SAFARI_PROFILE, getReceipt needs the receipt of the tab it rotates, in args.receipt.");
|
|
3941
|
+
}
|
|
3942
|
+
return _runExtensionBatchAction("getReceipt", stepArgs);
|
|
3943
|
+
};
|
|
3944
|
+
return textResult(await safari.runScript({ steps, onStep, actions: { switchTab, newTab, closeTab, getReceipt } }));
|
|
3751
3945
|
}
|
|
3752
3946
|
|
|
3753
3947
|
// Keep every step in one MCP request/session and route it through
|