safari-mcp 2.15.11 → 2.16.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/README.md +2 -0
- package/extension/background.js +46 -2
- package/extension/manifest.json +1 -1
- package/index.js +6 -5
- package/ownership-state.js +25 -9
- package/package.json +1 -1
- package/safari.js +61 -25
package/README.md
CHANGED
|
@@ -334,6 +334,8 @@ This also drops process count sharply: ~17 node processes for 17 concurrent sess
|
|
|
334
334
|
|
|
335
335
|
`SAFARI_PROFILE` stays optional — leave it unset and sessions bind to your ordinary Safari windows, cookies and logins intact. Details in [docs/http-transport-design.md](docs/http-transport-design.md).
|
|
336
336
|
|
|
337
|
+
Prefer stdio (one process per agent) over a persistent daemon? That works too — isolation then comes from the process boundary itself. One caveat: if your client multiplexes agents through [mcporter](https://github.com/openclaw/mcporter), mcporter caches a single MCP client for all of them — whichever transport you pick — so the server never sees distinct sessions and per-session isolation can't engage. [mcporter-lanes](https://pi.dev/packages/mcporter-lanes) (a pi extension by [@maxim](https://github.com/maxim), born out of [#76](https://github.com/achiya-automation/safari-mcp/issues/76)) fixes this upstream: each agent session gets its own daemon dir — and therefore its own safari-mcp — with an idle timeout so processes don't pile up.
|
|
338
|
+
|
|
337
339
|
---
|
|
338
340
|
|
|
339
341
|
## Tools (97)
|
package/extension/background.js
CHANGED
|
@@ -1755,7 +1755,7 @@ async function handleCommand(type, payload) {
|
|
|
1755
1755
|
|
|
1756
1756
|
// --- Query All ---
|
|
1757
1757
|
case "query_all": {
|
|
1758
|
-
|
|
1758
|
+
const queryFn = (selector, limit) => {
|
|
1759
1759
|
const els = (window.__mcpDeepQueryAll || document.querySelectorAll.bind(document))(selector, limit);
|
|
1760
1760
|
const results = [];
|
|
1761
1761
|
for (let i = 0; i < Math.min(els.length, limit); i++) {
|
|
@@ -1766,10 +1766,27 @@ async function handleCommand(type, payload) {
|
|
|
1766
1766
|
text: (el.innerText || "").substring(0, 100),
|
|
1767
1767
|
href: el.href || "", value: el.value || "",
|
|
1768
1768
|
visible: r.width > 0 && r.height > 0,
|
|
1769
|
+
// Center point relative to the element's OWN frame viewport. In the main
|
|
1770
|
+
// frame that equals page-viewport coordinates; in a cross-origin iframe it
|
|
1771
|
+
// is iframe-relative — check `frame` and offset by the iframe's position
|
|
1772
|
+
// before clicking by coordinates.
|
|
1773
|
+
x: Math.round(r.left + r.width / 2),
|
|
1774
|
+
y: Math.round(r.top + r.height / 2),
|
|
1775
|
+
frame: location.href.substring(0, 120),
|
|
1769
1776
|
});
|
|
1770
1777
|
}
|
|
1771
1778
|
return JSON.stringify(results);
|
|
1772
|
-
}
|
|
1779
|
+
};
|
|
1780
|
+
|
|
1781
|
+
const mainResult = await execInTab(queryFn, [payload.selector, payload.limit || 20], tabId);
|
|
1782
|
+
// The main frame is authoritative when it matches. Cross-origin iframes (GHL's
|
|
1783
|
+
// workflow list, embedded editors) are invisible to it, so fall back the same
|
|
1784
|
+
// way click/fill already do rather than reporting "no matches".
|
|
1785
|
+
try {
|
|
1786
|
+
if (mainResult && JSON.parse(mainResult).length > 0) return mainResult;
|
|
1787
|
+
} catch { return mainResult; }
|
|
1788
|
+
const frameResult = await execAcrossFrames(queryFn, [payload.selector, payload.limit || 20], tabId);
|
|
1789
|
+
return frameResult || mainResult;
|
|
1773
1790
|
}
|
|
1774
1791
|
|
|
1775
1792
|
default:
|
|
@@ -2149,6 +2166,33 @@ async function execInAllFrames(func, args = [], tabId = null) {
|
|
|
2149
2166
|
}
|
|
2150
2167
|
}
|
|
2151
2168
|
|
|
2169
|
+
// Like execInAllFrames, but for functions that return a JSON array: it skips frames
|
|
2170
|
+
// that matched nothing instead of stopping at the first non-null result. The main
|
|
2171
|
+
// frame nearly always returns "[]" — non-null — which would otherwise mask every
|
|
2172
|
+
// cross-origin frame behind it.
|
|
2173
|
+
async function execAcrossFrames(func, args = [], tabId = null) {
|
|
2174
|
+
const id = tabId || (await getActiveTab()).id;
|
|
2175
|
+
try {
|
|
2176
|
+
const results = await browser.scripting.executeScript({
|
|
2177
|
+
target: { tabId: id, allFrames: true },
|
|
2178
|
+
world: "MAIN",
|
|
2179
|
+
func,
|
|
2180
|
+
args,
|
|
2181
|
+
});
|
|
2182
|
+
const merged = [];
|
|
2183
|
+
for (const r of results) {
|
|
2184
|
+
if (!r || r.result == null) continue;
|
|
2185
|
+
try {
|
|
2186
|
+
const parsed = JSON.parse(r.result);
|
|
2187
|
+
if (Array.isArray(parsed) && parsed.length) merged.push(...parsed);
|
|
2188
|
+
} catch { /* frame returned a non-array payload — ignore it */ }
|
|
2189
|
+
}
|
|
2190
|
+
return merged.length ? JSON.stringify(merged) : null;
|
|
2191
|
+
} catch {
|
|
2192
|
+
return null;
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2152
2196
|
async function waitForTabLoad(tabId, timeout = 30000) {
|
|
2153
2197
|
// Check if already complete BEFORE registering listeners (prevents missing instant-complete events)
|
|
2154
2198
|
try {
|
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.3",
|
|
6
6
|
"icons": {
|
|
7
7
|
"48": "images/icon-48.png",
|
|
8
8
|
"96": "images/icon-96.png",
|
package/index.js
CHANGED
|
@@ -164,11 +164,12 @@ function _startMemoryMonitor() {
|
|
|
164
164
|
const checkInterval = Math.min(MEMORY_CHECK_INTERVAL_MS, 30000); // Max 30s between checks
|
|
165
165
|
_memoryCheckTimer = setInterval(async () => {
|
|
166
166
|
try {
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
167
|
+
// Every instance may sweep — but only its OWN _openedTabs, and only one
|
|
168
|
+
// per cycle: _tryAcquireMemoryLock() below already serializes sweepers
|
|
169
|
+
// machine-wide. The old `_isExtensionHost` gate here was redundant with
|
|
170
|
+
// that lock and made the guard inert in multi-instance setups — only the
|
|
171
|
+
// port-9224 winner could ever sweep, and it can't reach tabs the other
|
|
172
|
+
// instances opened (#83).
|
|
172
173
|
const webkitMB = _getWebKitMemoryMB();
|
|
173
174
|
if (webkitMB <= 0) return;
|
|
174
175
|
|
package/ownership-state.js
CHANGED
|
@@ -36,16 +36,28 @@ export function _loadOwnershipFile() {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export function _saveOwnershipFile(urls) {
|
|
39
|
+
export function _saveOwnershipFile(urls, removed = []) {
|
|
40
40
|
try {
|
|
41
41
|
if (!existsSync(OWNERSHIP_DIR)) mkdirSync(OWNERSHIP_DIR, { recursive: true });
|
|
42
42
|
const now = Date.now();
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
//
|
|
43
|
+
// Merge with disk before writing (#82): this file is shared by every instance on the
|
|
44
|
+
// machine, and a snapshot of only THIS process's Set silently drops entries concurrent
|
|
45
|
+
// instances added after we hydrated. Union disk+local (newest ts wins), then apply this
|
|
46
|
+
// write's explicit removals so deletions propagate instead of being resurrected by the
|
|
47
|
+
// merge. _loadOwnershipFile() TTL-filters, so expired disk entries fall away here too.
|
|
48
|
+
// Residual race: two writers between read and rename can still drop one entry — that
|
|
49
|
+
// window is sub-millisecond (was: entire process lifetime) and losing an entry is
|
|
50
|
+
// fail-safe: ownership is lost, so a tool refuses; it never gains a user's tab.
|
|
51
|
+
const mergedTs = new Map();
|
|
52
|
+
for (const e of _loadOwnershipFile()) mergedTs.set(e.url, e.ts);
|
|
53
|
+
for (const url of urls) {
|
|
54
|
+
const localTs = _ownedTabTimestamps.get(url) ?? now;
|
|
55
|
+
mergedTs.set(url, Math.max(localTs, mergedTs.get(url) ?? 0));
|
|
56
|
+
}
|
|
57
|
+
for (const url of removed) mergedTs.delete(url);
|
|
58
|
+
const entries = Array.from(mergedTs, ([url, ts]) => ({ url, ts }));
|
|
59
|
+
// Atomic write (tmp + rename) — a partial write from one instance must never corrupt
|
|
60
|
+
// the JSON another instance reads.
|
|
49
61
|
const tmp = OWNERSHIP_FILE + ".tmp." + process.pid;
|
|
50
62
|
writeFileSync(tmp, JSON.stringify(entries), { mode: 0o600 });
|
|
51
63
|
renameSync(tmp, OWNERSHIP_FILE);
|
|
@@ -82,8 +94,12 @@ export function _touchOwned(ownedKey) {
|
|
|
82
94
|
return true;
|
|
83
95
|
}
|
|
84
96
|
export function _pruneExpiredOwnership() {
|
|
97
|
+
const before = new Set(_ownedTabURLs);
|
|
85
98
|
if (pruneExpired(_ownedTabURLs, _ownedTabTimestamps, OWNERSHIP_TTL_MS)) {
|
|
86
|
-
|
|
99
|
+
// Pass the pruned URLs as explicit removals so the merge-on-write in
|
|
100
|
+
// _saveOwnershipFile doesn't resurrect them from the disk copy.
|
|
101
|
+
const removed = [...before].filter((u) => !_ownedTabURLs.has(u));
|
|
102
|
+
_saveOwnershipFile(_ownedTabURLs, removed);
|
|
87
103
|
}
|
|
88
104
|
}
|
|
89
105
|
|
|
@@ -125,7 +141,7 @@ export function _removeOwnedURL(url) {
|
|
|
125
141
|
if (url) {
|
|
126
142
|
_ownedTabURLs.delete(url);
|
|
127
143
|
_ownedTabTimestamps.delete(url);
|
|
128
|
-
_saveOwnershipFile(_ownedTabURLs);
|
|
144
|
+
_saveOwnershipFile(_ownedTabURLs, [url]);
|
|
129
145
|
}
|
|
130
146
|
}
|
|
131
147
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.0",
|
|
4
4
|
"mcpName": "io.github.achiya-automation/safari-mcp",
|
|
5
5
|
"description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 96 tools via AppleScript + JavaScript.",
|
|
6
6
|
"type": "module",
|
package/safari.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import { execFile, spawn, spawnSync } from "node:child_process";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
|
-
import { tmpdir } from "node:os";
|
|
8
|
+
import { tmpdir, homedir } from "node:os";
|
|
9
9
|
import { join, dirname, resolve as resolvePath } from "node:path";
|
|
10
|
-
import { readFile, writeFile, unlink, appendFile } from "node:fs/promises";
|
|
10
|
+
import { readFile, writeFile, unlink, appendFile, mkdir } from "node:fs/promises";
|
|
11
11
|
import { readFileSync, realpathSync } from "node:fs";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { randomUUID } from "node:crypto";
|
|
@@ -261,6 +261,11 @@ function getTargetWindowRef() {
|
|
|
261
261
|
return _targetWindowRef || 'front window';
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
// Consecutive failed profile-window detections (#81) — drives the poll backoff,
|
|
265
|
+
// the subprocess-fallback cutoff, and the missing-window log rate limit.
|
|
266
|
+
let _profileMisses = 0;
|
|
267
|
+
let _lastMissingLogTime = 0;
|
|
268
|
+
|
|
264
269
|
async function refreshTargetWindow(force = false) {
|
|
265
270
|
if (!SAFARI_PROFILE) return;
|
|
266
271
|
const now = Date.now();
|
|
@@ -272,7 +277,12 @@ async function refreshTargetWindow(force = false) {
|
|
|
272
277
|
// The persistent helper occasionally returns '0|' for a window that genuinely
|
|
273
278
|
// exists (daemon timeout / restart race). Before concluding the window is
|
|
274
279
|
// missing, retry once with a plain osascript subprocess \u2014 slower but reliable.
|
|
275
|
-
|
|
280
|
+
// Once consecutive misses have established the window is genuinely ABSENT
|
|
281
|
+
// (not flakily undetected), skip the subprocess retry — an absent window
|
|
282
|
+
// returns '0|' every cycle, so the fallback would otherwise spawn an
|
|
283
|
+
// osascript process per poll, forever (#81). The first successful detection
|
|
284
|
+
// re-arms it for the flaky-helper case it was added for.
|
|
285
|
+
if (String(result).split('|')[0] === '0' && _profileMisses < 3) {
|
|
276
286
|
result = await osascript(detectScript).catch(() => '0|');
|
|
277
287
|
}
|
|
278
288
|
const [idStr, windowName] = String(result).split('|');
|
|
@@ -286,19 +296,35 @@ async function refreshTargetWindow(force = false) {
|
|
|
286
296
|
_targetWindowId = id;
|
|
287
297
|
_targetWindowCacheTime = now;
|
|
288
298
|
_profileWindowMissing = false;
|
|
299
|
+
_profileMisses = 0;
|
|
289
300
|
} else {
|
|
290
301
|
// Profile window not found — clear ref so getTargetWindowRef() will throw
|
|
291
302
|
_targetWindowRef = null;
|
|
292
303
|
_targetWindowId = null;
|
|
293
304
|
_targetWindowCacheTime = 0;
|
|
294
305
|
_profileWindowMissing = true;
|
|
295
|
-
|
|
306
|
+
_profileMisses++;
|
|
307
|
+
// Log on transition into the missing state, then at most once per 5 minutes —
|
|
308
|
+
// this steady state used to append one line per poll, unbounded (#81).
|
|
309
|
+
if (_profileMisses === 1 || now - _lastMissingLogTime > 300000) {
|
|
310
|
+
_lastMissingLogTime = now;
|
|
311
|
+
_logProfile(`WARNING: Profile "${SAFARI_PROFILE}" window not found — refusing to use front window`);
|
|
312
|
+
}
|
|
296
313
|
}
|
|
297
314
|
}
|
|
298
315
|
|
|
299
316
|
// Background verification: periodically check that cached window ID still belongs to profile
|
|
300
317
|
if (SAFARI_PROFILE) {
|
|
301
|
-
|
|
318
|
+
// Self-scheduling poll with exponential backoff (#81): 3s while the window is
|
|
319
|
+
// present (or flakily undetected), doubling per consecutive miss up to 60s
|
|
320
|
+
// while it is absent — a closed profile window is a steady state, and the
|
|
321
|
+
// fixed 3s cadence used to spawn an osascript subprocess per cycle, forever.
|
|
322
|
+
// The first successful detection resets to 3s, so rediscovery stays
|
|
323
|
+
// responsive: once the user opens the window, the next poll lands within 60s
|
|
324
|
+
// and everything after it is back on the 3s cadence.
|
|
325
|
+
const _POLL_BASE_MS = 3000;
|
|
326
|
+
const _POLL_MAX_MS = 60000;
|
|
327
|
+
const _pollOnce = async () => {
|
|
302
328
|
// No cached window (e.g. flaky detection at startup) — keep trying to
|
|
303
329
|
// rediscover it so the server self-heals instead of staying stuck.
|
|
304
330
|
if (!_targetWindowRef || !_targetWindowId) {
|
|
@@ -329,7 +355,18 @@ if (SAFARI_PROFILE) {
|
|
|
329
355
|
_targetWindowCacheTime = 0;
|
|
330
356
|
await refreshTargetWindow(true);
|
|
331
357
|
}
|
|
332
|
-
}
|
|
358
|
+
};
|
|
359
|
+
const _schedulePoll = () => {
|
|
360
|
+
const delay =
|
|
361
|
+
_profileMisses > 0
|
|
362
|
+
? Math.min(_POLL_BASE_MS * 2 ** Math.min(_profileMisses, 5), _POLL_MAX_MS)
|
|
363
|
+
: _POLL_BASE_MS;
|
|
364
|
+
setTimeout(async () => {
|
|
365
|
+
await _pollOnce().catch(() => {});
|
|
366
|
+
_schedulePoll();
|
|
367
|
+
}, delay);
|
|
368
|
+
};
|
|
369
|
+
_schedulePoll();
|
|
333
370
|
}
|
|
334
371
|
|
|
335
372
|
// Initialize profile window at startup (ES module top-level await)
|
|
@@ -519,10 +556,14 @@ async function _userIsActive() {
|
|
|
519
556
|
|
|
520
557
|
// Lightweight trace of every restore decision — confirms WHICH instance (pid)
|
|
521
558
|
// restored focus and whether the user-active guard vetoed it. Best-effort; never
|
|
522
|
-
// throws into the hot path. Tail
|
|
523
|
-
|
|
559
|
+
// throws into the hot path. Tail ~/.safari-mcp/restore-trace.log to watch live.
|
|
560
|
+
// Lives under ~/.safari-mcp, NOT __dirname (#81) — the package dir is wiped on
|
|
561
|
+
// reinstall and read-only in container/CI setups; mutable state doesn't belong there.
|
|
562
|
+
const _RESTORE_TRACE = join(homedir(), ".safari-mcp", "restore-trace.log");
|
|
524
563
|
function _traceRestore(savedBundleId, decision) {
|
|
525
|
-
|
|
564
|
+
mkdir(dirname(_RESTORE_TRACE), { recursive: true })
|
|
565
|
+
.then(() => appendFile(_RESTORE_TRACE, `${new Date().toISOString()} pid=${process.pid} saved=${savedBundleId} -> ${decision}\n`))
|
|
566
|
+
.catch(() => {});
|
|
526
567
|
}
|
|
527
568
|
|
|
528
569
|
function _helperHideSafari(timeout = 2000) {
|
|
@@ -3130,25 +3171,15 @@ export async function replaceEditorContent({ text }) {
|
|
|
3130
3171
|
|
|
3131
3172
|
export async function screenshot({ fullPage = false } = {}) {
|
|
3132
3173
|
await refreshTargetWindow();
|
|
3174
|
+
return _withTargetTabFronted(() => _screenshotFronted({ fullPage }));
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
async function _screenshotFronted({ fullPage }) {
|
|
3133
3178
|
const tmpFile = join(tmpdir(), `safari-screenshot-${Date.now()}.png`);
|
|
3134
3179
|
try {
|
|
3135
|
-
|
|
3136
|
-
let isBackgroundTab = false;
|
|
3137
|
-
if (_st().activeTabIndex) {
|
|
3138
|
-
try {
|
|
3139
|
-
const currentIdx = await osascriptFast(
|
|
3140
|
-
`tell application "Safari" to return index of current tab of ${getTargetWindowRef()}`
|
|
3141
|
-
);
|
|
3142
|
-
isBackgroundTab = Number(currentIdx) !== _st().activeTabIndex;
|
|
3143
|
-
} catch (_) {}
|
|
3144
|
-
}
|
|
3145
|
-
// When on a background tab, go straight to JS-based screenshot (no tab switch, no focus steal)
|
|
3146
|
-
const skipScreencapture = isBackgroundTab;
|
|
3147
|
-
|
|
3148
|
-
// Try screencapture — use osascript's do shell script to bypass VS Code permission issue
|
|
3149
|
-
const windowIdRaw = !skipScreencapture ? await osascript(
|
|
3180
|
+
const windowIdRaw = await osascript(
|
|
3150
3181
|
`tell application "Safari" to return id of ${getTargetWindowRef()}`
|
|
3151
|
-
).catch(() => null)
|
|
3182
|
+
).catch(() => null);
|
|
3152
3183
|
// Window IDs are OS-assigned integers — reject anything non-numeric before it reaches
|
|
3153
3184
|
// `do shell script "/usr/sbin/screencapture -l<id>"` (defense-in-depth against odd AppleScript stdout).
|
|
3154
3185
|
const windowId = windowIdRaw != null && /^\d+$/.test(String(windowIdRaw).trim()) ? String(windowIdRaw).trim() : null;
|
|
@@ -3267,6 +3298,11 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
3267
3298
|
// ========== ELEMENT SCREENSHOT ==========
|
|
3268
3299
|
|
|
3269
3300
|
export async function screenshotElement({ selector }) {
|
|
3301
|
+
await refreshTargetWindow();
|
|
3302
|
+
return _withTargetTabFronted(() => _screenshotElementFronted({ selector }));
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
async function _screenshotElementFronted({ selector }) {
|
|
3270
3306
|
const sel = escJsSingleQuote(selector);
|
|
3271
3307
|
// Use html2canvas-like approach: capture element via SVG foreignObject
|
|
3272
3308
|
const result = await runJS(
|