safari-mcp 2.15.12 → 2.16.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/extension/background.js +46 -2
- package/extension/manifest.json +1 -1
- package/package.json +1 -1
- package/safari.js +69 -16
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.1",
|
|
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
|
@@ -3171,25 +3171,15 @@ export async function replaceEditorContent({ text }) {
|
|
|
3171
3171
|
|
|
3172
3172
|
export async function screenshot({ fullPage = false } = {}) {
|
|
3173
3173
|
await refreshTargetWindow();
|
|
3174
|
+
return _withTargetTabFronted(() => _screenshotFronted({ fullPage }));
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
async function _screenshotFronted({ fullPage }) {
|
|
3174
3178
|
const tmpFile = join(tmpdir(), `safari-screenshot-${Date.now()}.png`);
|
|
3175
3179
|
try {
|
|
3176
|
-
|
|
3177
|
-
let isBackgroundTab = false;
|
|
3178
|
-
if (_st().activeTabIndex) {
|
|
3179
|
-
try {
|
|
3180
|
-
const currentIdx = await osascriptFast(
|
|
3181
|
-
`tell application "Safari" to return index of current tab of ${getTargetWindowRef()}`
|
|
3182
|
-
);
|
|
3183
|
-
isBackgroundTab = Number(currentIdx) !== _st().activeTabIndex;
|
|
3184
|
-
} catch (_) {}
|
|
3185
|
-
}
|
|
3186
|
-
// When on a background tab, go straight to JS-based screenshot (no tab switch, no focus steal)
|
|
3187
|
-
const skipScreencapture = isBackgroundTab;
|
|
3188
|
-
|
|
3189
|
-
// Try screencapture — use osascript's do shell script to bypass VS Code permission issue
|
|
3190
|
-
const windowIdRaw = !skipScreencapture ? await osascript(
|
|
3180
|
+
const windowIdRaw = await osascript(
|
|
3191
3181
|
`tell application "Safari" to return id of ${getTargetWindowRef()}`
|
|
3192
|
-
).catch(() => null)
|
|
3182
|
+
).catch(() => null);
|
|
3193
3183
|
// Window IDs are OS-assigned integers — reject anything non-numeric before it reaches
|
|
3194
3184
|
// `do shell script "/usr/sbin/screencapture -l<id>"` (defense-in-depth against odd AppleScript stdout).
|
|
3195
3185
|
const windowId = windowIdRaw != null && /^\d+$/.test(String(windowIdRaw).trim()) ? String(windowIdRaw).trim() : null;
|
|
@@ -3272,6 +3262,64 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
3272
3262
|
}
|
|
3273
3263
|
}
|
|
3274
3264
|
|
|
3265
|
+
// Fallback: full-screen capture + crop to the window rect.
|
|
3266
|
+
//
|
|
3267
|
+
// macOS 26 (Tahoe) broke `screencapture -l<windowId>` for Safari: it exits 1
|
|
3268
|
+
// with "could not create image from window" for EVERY window, while a plain
|
|
3269
|
+
// full-screen `screencapture -x` from the same process succeeds. So this is
|
|
3270
|
+
// not a TCC problem — safari_doctor reports Screen Recording as granted and
|
|
3271
|
+
// is telling the truth. Diagnosed 17.08.2026, when the by-id path started
|
|
3272
|
+
// failing mid-session and the misleading "permission may have been lost"
|
|
3273
|
+
// error below sent the investigation after a permission that was never gone.
|
|
3274
|
+
//
|
|
3275
|
+
// Crop math: AppleScript bounds are in points, the capture is in pixels.
|
|
3276
|
+
// devicePixelRatio from the page gives the backing-scale factor.
|
|
3277
|
+
if (windowId) {
|
|
3278
|
+
try {
|
|
3279
|
+
// A locked screen captures as solid black. Say so instead of returning a
|
|
3280
|
+
// black rectangle the caller has to guess at.
|
|
3281
|
+
const locked = await execFileAsync("/bin/sh", ["-c",
|
|
3282
|
+
"ioreg -n Root -d1 -r -a 2>/dev/null | grep -c CGSSessionScreenIsLocked || true"])
|
|
3283
|
+
.then((r) => String(r.stdout).trim() !== "0").catch(() => false);
|
|
3284
|
+
if (locked) {
|
|
3285
|
+
throw new Error("SCREEN_LOCKED");
|
|
3286
|
+
}
|
|
3287
|
+
const boundsRaw = await osascript(
|
|
3288
|
+
`tell application "Safari" to return bounds of ${getTargetWindowRef()}`
|
|
3289
|
+
);
|
|
3290
|
+
const [x1, y1, x2, y2] = String(boundsRaw).split(",").map((v) => parseInt(v.trim(), 10));
|
|
3291
|
+
const dpr = Math.max(1, Math.round(Number(await runJS("window.devicePixelRatio")) || 1));
|
|
3292
|
+
const w = (x2 - x1) * dpr, h = (y2 - y1) * dpr;
|
|
3293
|
+
if ([x1, y1, x2, y2].every(Number.isFinite) && w > 0 && h > 0) {
|
|
3294
|
+
const fullFile = tmpFile.replace(/\.png$/, "-full.png");
|
|
3295
|
+
const cropFile = tmpFile.replace(/\.png$/, "-crop.jpg");
|
|
3296
|
+
try {
|
|
3297
|
+
// Direct first (VS Code / terminal grant), helper second (launchd daemon has no grant of its own)
|
|
3298
|
+
await execFileAsync("/usr/sbin/screencapture", ["-x", fullFile], { timeout: 15000 })
|
|
3299
|
+
.catch(() => osascriptFast(`do shell script "/usr/sbin/screencapture -x '${fullFile}'"`, { timeout: 15000 }));
|
|
3300
|
+
await execFileAsync("sips", [
|
|
3301
|
+
"-c", String(h), String(w), "--cropOffset", String(y1 * dpr), String(x1 * dpr),
|
|
3302
|
+
fullFile, "--out", cropFile,
|
|
3303
|
+
], { timeout: 10000 });
|
|
3304
|
+
await execFileAsync("sips", [
|
|
3305
|
+
"-s", "format", "jpeg", "-s", "formatOptions", "50", "--resampleWidth", "1200",
|
|
3306
|
+
cropFile, "--out", cropFile,
|
|
3307
|
+
], { timeout: 10000 });
|
|
3308
|
+
const cropped = await readFile(cropFile);
|
|
3309
|
+
if (cropped.length > 100) return cropped.toString("base64");
|
|
3310
|
+
} finally {
|
|
3311
|
+
await unlink(fullFile).catch(() => {});
|
|
3312
|
+
await unlink(cropFile).catch(() => {});
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
} catch (e) {
|
|
3316
|
+
if (e && e.message === "SCREEN_LOCKED") {
|
|
3317
|
+
throw new Error("screenshot unavailable — the screen is locked, so the capture would be solid black. Unlock the Mac and retry. (Text-based tools — safari_snapshot / safari_read_page / safari_evaluate — work regardless.)");
|
|
3318
|
+
}
|
|
3319
|
+
// crop path failed too — fall through to the JS canvas method
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3275
3323
|
// Fallback: JS-based screenshot via canvas (no permissions needed)
|
|
3276
3324
|
const dataUrl = await runJS(
|
|
3277
3325
|
`(async function(){` +
|
|
@@ -3308,6 +3356,11 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
3308
3356
|
// ========== ELEMENT SCREENSHOT ==========
|
|
3309
3357
|
|
|
3310
3358
|
export async function screenshotElement({ selector }) {
|
|
3359
|
+
await refreshTargetWindow();
|
|
3360
|
+
return _withTargetTabFronted(() => _screenshotElementFronted({ selector }));
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
async function _screenshotElementFronted({ selector }) {
|
|
3311
3364
|
const sel = escJsSingleQuote(selector);
|
|
3312
3365
|
// Use html2canvas-like approach: capture element via SVG foreignObject
|
|
3313
3366
|
const result = await runJS(
|