safari-mcp
Version:
Safari browser automation for AI agents — native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.
198 lines (193 loc) • 9.95 kB
JavaScript
// Content script — runs at document_start in MAIN world (before page scripts).
// Two responsibilities:
// 1. Monkey-patch attachShadow to capture CLOSED shadow roots (Reddit, etc.).
// 2. Pre-register a Trusted Types policy named "mcpBridge" BEFORE the page sets
// its own require-trusted-types-for directive. Our policy is then grandfathered
// and survives even on pages (Google Search Console, Google admin, modern banks)
// that block new policy creation after page load. MCP evaluate strategies
// consult `window.__mcpTrustedPolicy` first.
// Runs in MAIN world via manifest "world": "MAIN" — no script injection needed,
// so CSP cannot block it.
// Last-resort evaluate path for pages that stall scripting.executeScript outright
// (business.facebook.com: the injected evaluate never answers, so every evaluate died on
// the caller's timeout).
//
// This script is ALREADY in MAIN world from document_start, so nothing has to be
// injected to reach the page — that is the whole point. command-content.js lives in
// the ISOLATED world where it can talk to the background but where the page's CSP
// forbids eval; it hands the work here over postMessage, and we run it through the
// same grandfathered Trusted Types policy the injected strategy uses.
//
// Each request carries the evaluate's runId, and a run starts at most once per runId
// (see _evalRunner in background.js): if the injected runner already started it in this
// world we join its promise, if another world claimed it we stay out, else we claim it.
// The message tags and the guard below are new with that protocol, so a listener left
// over from an older build — which would start the script without looking — never sees
// these requests.
if (!window.__mcpEvalOnceBridge) {
window.__mcpEvalOnceBridge = true;
// window.postMessage, not CustomEvent: a CustomEvent's `detail` does not survive the
// ISOLATED→MAIN world boundary (it arrives as null), so the bridge received the event
// but never the script — it simply timed out. postMessage is structured-cloned across
// the boundary by design.
window.addEventListener("message", function (ev) {
if (ev.source !== window) return; // ignore other frames
var d = ev.data;
if (!d || d.__mcp !== "eval_once" || typeof d.id !== "string") return;
var id = d.id;
var key = "__mcp_run_" + id;
var reply = function (result) {
window.postMessage({ __mcp: "eval_once_res", id: id, result: result }, "*");
};
if (window[key]) { window[key].then(reply); return; }
var claim = "__mcp_claim_" + id;
if (!document.dispatchEvent(new Event(claim, { cancelable: true }))) return;
document.addEventListener(claim, function (e) { e.preventDefault(); });
var source = String(d.source);
var text = function (v) {
return v === undefined || v === null ? null : typeof v === "object" ? JSON.stringify(v) : String(v);
};
var fail = function (e) { return { ok: false, error: String((e && e.message) || e) }; };
var run = (async function () {
// Direct eval first. We are in MAIN world, so this runs under the PAGE's CSP —
// and business.facebook.com actually allows 'unsafe-eval'. Its script-src does
// carry a nonce, which is precisely what blocks the injected-<script> path below,
// so trying that first would fail on the very page this bridge exists for. The
// "0" probe tells a refused eval (nothing ran) from a script that throws by
// itself: that one ran, and must not run again through the <script> path.
var evalAllowed = true;
try { (0, eval)("0"); } catch (_e) { evalAllowed = false; }
if (evalAllowed) {
try { return { ok: true, value: text(await (0, eval)(source)) }; } catch (e) { return fail(e); }
}
// An inline script runs inside appendChild, so by the next line the hook has
// started it — or nothing ran. A nonce-based script-src (facebook.com, since it no
// longer allows 'unsafe-eval') takes it only with the page's nonce. It goes in as an
// expression first, so `document.title` answers as through eval; a statement list
// is a SyntaxError there, which runs nothing, and then goes in as a function body.
var hook = key + "_start";
var started = null;
window[hook] = function (fn) { started = (async function () { return fn(); })(); };
var nonced = document.querySelector("script[nonce]");
var bodies = ["return (" + source.replace(/[\s;]+$/, "") + "\n);", source];
for (var i = 0; i < bodies.length && !started; i++) {
try {
var code = "window[" + JSON.stringify(hook) + "](function(){" + bodies[i] + "\n})";
var s = document.createElement("script");
if (window.__mcpTrustedPolicy && typeof window.__mcpTrustedPolicy.createScript === "function") {
try { s.textContent = window.__mcpTrustedPolicy.createScript(code); }
catch (_e) { s.textContent = code; }
} else {
s.textContent = code;
}
if (nonced && nonced.nonce) s.nonce = nonced.nonce;
document.documentElement.appendChild(s);
s.remove();
} catch (_e) {
// Trusted Types refused the plain string: the script did not start.
}
}
delete window[hook];
if (!started) return { ok: false, csp: true, error: "the page's CSP blocks eval and inline scripts" };
try { return { ok: true, value: text(await started) }; } catch (e) { return fail(e); }
})();
Object.defineProperty(window, key, { value: run, configurable: true });
setTimeout(function () { delete window[key]; }, d.keepMs || 30000);
run.then(reply);
});
}
if (!window.__mcpShadowPatched) {
window.__mcpShadowPatched = true;
var _origAttachShadow = Element.prototype.attachShadow;
var _closedRoots = new WeakMap();
Element.prototype.attachShadow = function(init) {
var shadow = _origAttachShadow.call(this, init);
if (init && init.mode === "closed") {
_closedRoots.set(this, shadow);
}
return shadow;
};
// Expose getter for MCP tools (snapshot, deepQuery, click, fill).
// Non-enumerable + non-writable: pages that know the name can still call it
// (inherent to MAIN-world injection), but it doesn't surface in enumeration and —
// more importantly — page scripts can't REPLACE it to feed MCP fake shadow roots.
var _getShadowRoot = function(el) {
return el.shadowRoot || _closedRoots.get(el) || null;
};
try {
Object.defineProperty(window, "__mcpGetShadowRoot", {
value: _getShadowRoot, writable: false, enumerable: false, configurable: false
});
} catch (_e) {
window.__mcpGetShadowRoot = _getShadowRoot;
}
}
if (!window.__mcpTrustedPolicy && window.trustedTypes && typeof window.trustedTypes.createPolicy === "function") {
try {
// Register ONLY createScript — the single capability the bridge uses (background.js
// evaluate sets script.textContent via createScript). A world-accessible pass-through
// createHTML would let the page's own scripts wrap arbitrary HTML as trusted, defeating
// its Trusted-Types protection; createScriptURL is likewise unused. Least privilege.
window.__mcpTrustedPolicy = window.trustedTypes.createPolicy("mcpBridge", {
createScript: function (s) { return s; }
});
} catch (_e) {
// Page already restricts policies — rare since content script runs at document_start
// before page scripts. Leave undefined; evaluate fallbacks will probe other paths.
}
}
// Keep an automation tab rendering while it is hidden: a background tab, or every tab
// while the Mac's screen is locked. Safari then stops requestAnimationFrame entirely, and
// apps that paint a streamed answer on the next frame (Perplexity, 26.10.2026) freeze
// mid-answer until the tab is shown. Only a tab the extension owns switches this on
// (command-content.js asks the background); the user's own tabs keep the native
// behaviour, so their hidden tabs cost no CPU. Installed at document_start so the page's
// own scripts capture the wrapped functions.
if (!window.__mcpKeepRendering) {
(function () {
var on = false;
var nativeRaf = window.requestAnimationFrame && window.requestAnimationFrame.bind(window);
var nativeCaf = window.cancelAnimationFrame && window.cancelAnimationFrame.bind(window);
if (!nativeRaf || !nativeCaf) return;
var pending = new Map(); // id → { cb, nativeId, timer }
var nextId = 0;
function run(id) {
var p = pending.get(id);
if (!p) return;
pending.delete(id);
nativeCaf(p.nativeId);
if (p.timer) clearTimeout(p.timer);
p.cb(performance.now());
}
function arm(id) {
var p = pending.get(id);
if (p && !p.timer) p.timer = setTimeout(function () { run(id); }, 50);
}
window.requestAnimationFrame = function (cb) {
var id = ++nextId;
pending.set(id, { cb: cb, nativeId: nativeRaf(function () { run(id); }), timer: 0 });
if (on) arm(id);
return id;
};
window.cancelAnimationFrame = function (id) {
var p = pending.get(id);
if (!p) return;
pending.delete(id);
nativeCaf(p.nativeId);
if (p.timer) clearTimeout(p.timer);
};
function enable() {
if (on) return;
on = true;
pending.forEach(function (_p, id) { arm(id); });
try {
Object.defineProperty(document, "visibilityState", { configurable: true, get: function () { return "visible"; } });
Object.defineProperty(document, "hidden", { configurable: true, get: function () { return false; } });
} catch (_e) {}
}
window.addEventListener("message", function (e) {
if (e.source === window && e.data && e.data.__mcp === "keep_rendering_v1") enable();
});
window.__mcpKeepRendering = true;
})();
}