UNPKG

safari-mcp

Version:

Safari browser automation for AI agents — native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.

810 lines (753 loc) • 32.5 kB
// Isolated-world command bridge for DOM interactions. // // Safari sometimes turns an exception from a PAGE click listener into an opaque // `JavaScript extension error` for browser.scripting.executeScript(), aborting the // background command. A persistent content script shares the DOM and event path, // while page-listener exceptions stay in the page console. // Register on every execution. Safari can preserve the isolated-world globals while // invalidating the old runtime listener during an extension update; a persistent // boolean guard would then falsely say the bridge is installed. Hot reload can also // leave the old listener alive, so every listener belongs to a revocable generation: // only the newest generation may execute a command, even if removing the old runtime // listener fails because its extension runtime has already been invalidated. // Safari may unload an MV3 service worker even while it owns an HTTP long-poll. A // content script lives with the page, so elect ONE top-frame page to send a narrow // extension-Port ping inside Safari's roughly 30-second idle window. Visible pages // take priority; when Safari marks every background-app page hidden, a short storage // lease provides one fallback leader. Non-leaders have no Port and do no periodic // storage work. The shared record and ping contain no URL, DOM data, receipt, or other // authority. (() => { const previousKeepaliveState = globalThis.__mcpKeepaliveState; const inheritedKeepaliveId = /^[0-9a-f]{24}$/.test(String(previousKeepaliveState?.leaseId || "")) ? previousKeepaliveState.leaseId : ""; try { previousKeepaliveState?.cleanup?.(); } catch (_) {} const _MCP_KEEPALIVE_PORT_NAME = "mcp-content-keepalive-v1"; const _MCP_KEEPALIVE_LEASE_KEY = "mcpContentKeepaliveLeaseV1"; const _MCP_KEEPALIVE_LEASE_MS = 26000; const _MCP_KEEPALIVE_RENEW_MS = 18000; const _MCP_KEEPALIVE_PING_MS = 8000; const _MCP_KEEPALIVE_RECONNECT_MS = 1500; const _MCP_CONTENT_WAKE_TOKEN_KEY = "mcpContentWakeTokenV1"; const _MCP_CONTENT_WAKE_BRIDGE_KEY = "mcpBridgeUrl"; const _MCP_CONTENT_WAKE_RETRY_MS = 1500; const _MCP_CONTENT_WAKE_PORTS = new Set(["9224", "9228", "9232", "9236"]); const _mcpKeepaliveInstanceId = inheritedKeepaliveId || (() => { const bytes = new Uint8Array(12); crypto.getRandomValues(bytes); return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); })(); let _mcpKeepalivePort = null; let _mcpKeepaliveReconnectTimer = null; let _mcpKeepaliveLeaseTimer = null; let _mcpKeepaliveLeaseTimerDueAt = 0; let _mcpKeepalivePingTimer = null; let _mcpKeepaliveEnabled = true; let _mcpKeepaliveEnabledRevision = 0; let _mcpKeepaliveLeader = false; let _mcpKeepaliveClaiming = false; let _mcpKeepaliveDisposed = false; let _mcpContentWakeToken = ""; let _mcpContentWakeBridgeUrl = ""; let _mcpContentWakeAbort = null; let _mcpContentWakeRetryTimer = null; let _mcpContentWakeGeneration = 0; function _isMcpKeepaliveTopFrame() { try { return window.top === window; } catch (_) { return false; } } function _mcpKeepaliveVisible() { return document.visibilityState === "visible"; } function _validMcpKeepaliveLease(value, now = Date.now()) { return value && typeof value.id === "string" && /^[0-9a-f]{24}$/.test(value.id) && Number.isFinite(value.expiresAt) && value.expiresAt > now && value.expiresAt <= now + (_MCP_KEEPALIVE_LEASE_MS * 2) ? value : null; } function _clearMcpKeepaliveLeaseTimer() { if (_mcpKeepaliveLeaseTimer) clearTimeout(_mcpKeepaliveLeaseTimer); _mcpKeepaliveLeaseTimer = null; _mcpKeepaliveLeaseTimerDueAt = 0; } function _scheduleMcpKeepaliveLeaseCheck(delayMs) { if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled || !_isMcpKeepaliveTopFrame()) return; const boundedDelay = Math.max(0, Math.min(Number(delayMs) || 0, _MCP_KEEPALIVE_LEASE_MS)); const dueAt = Date.now() + boundedDelay; if (_mcpKeepaliveLeaseTimer && _mcpKeepaliveLeaseTimerDueAt <= dueAt) return; _clearMcpKeepaliveLeaseTimer(); _mcpKeepaliveLeaseTimerDueAt = dueAt; _mcpKeepaliveLeaseTimer = setTimeout(() => { if (_mcpKeepaliveDisposed) return; _mcpKeepaliveLeaseTimer = null; _mcpKeepaliveLeaseTimerDueAt = 0; if (_mcpKeepaliveLeader) _renewMcpKeepaliveLeaseAndPing(); else _attemptMcpKeepaliveClaim(); }, boundedDelay); } function _scheduleMcpKeepaliveReconnect() { if (_mcpKeepaliveDisposed || _mcpKeepaliveReconnectTimer || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return; _mcpKeepaliveReconnectTimer = setTimeout(() => { _mcpKeepaliveReconnectTimer = null; if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return; const port = _openMcpKeepalivePort(); if (port) { try { port.postMessage({ type: "ping" }); } catch (_) { if (_mcpKeepalivePort === port) _mcpKeepalivePort = null; try { port.disconnect(); } catch (_) {} _scheduleMcpKeepaliveReconnect(); } } }, _MCP_KEEPALIVE_RECONNECT_MS); } function _openMcpKeepalivePort() { if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return null; if (_mcpKeepalivePort) return _mcpKeepalivePort; try { const port = browser.runtime.connect({ name: _MCP_KEEPALIVE_PORT_NAME }); _mcpKeepalivePort = port; port.onDisconnect.addListener(() => { if (_mcpKeepaliveDisposed || _mcpKeepalivePort !== port) return; _mcpKeepalivePort = null; _scheduleMcpKeepaliveReconnect(); }); return port; } catch (_) { _scheduleMcpKeepaliveReconnect(); return null; } } function _closeMcpKeepalivePort() { const port = _mcpKeepalivePort; _mcpKeepalivePort = null; if (port) { try { port.disconnect(); } catch (_) {} } } function _clearMcpKeepalivePingTimer() { if (_mcpKeepalivePingTimer) clearTimeout(_mcpKeepalivePingTimer); _mcpKeepalivePingTimer = null; } function _validMcpContentWakeToken(value) { return /^[0-9a-f]{64}$/.test(String(value || "")) ? String(value) : ""; } function _validMcpContentWakeBridgeUrl(value) { try { const url = new URL(String(value || "")); if (url.protocol !== "http:") return ""; if (url.hostname !== "127.0.0.1" && url.hostname !== "localhost") return ""; if (!_MCP_CONTENT_WAKE_PORTS.has(url.port)) return ""; if (url.pathname !== "/" || url.search || url.hash) return ""; return url.origin; } catch (_) { return ""; } } function _sendMcpRuntimeWake() { try { const wakeup = browser.runtime.sendMessage({ action: "mcpContentKeepalivePingV1" }); wakeup?.catch?.(() => {}); } catch (_) {} } function _stopMcpContentWakeLoop() { _mcpContentWakeGeneration += 1; if (_mcpContentWakeRetryTimer) clearTimeout(_mcpContentWakeRetryTimer); _mcpContentWakeRetryTimer = null; const controller = _mcpContentWakeAbort; _mcpContentWakeAbort = null; if (controller) { try { controller.abort(); } catch (_) {} } } function _startMcpContentWakeLoop() { if ( _mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled || !_mcpContentWakeToken || !_mcpContentWakeBridgeUrl || _mcpContentWakeAbort || _mcpContentWakeRetryTimer ) return; const generation = ++_mcpContentWakeGeneration; void _runMcpContentWakePoll(generation); } async function _runMcpContentWakePoll(generation) { if ( generation !== _mcpContentWakeGeneration || _mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled || !_mcpContentWakeToken || !_mcpContentWakeBridgeUrl ) return; const controller = new AbortController(); _mcpContentWakeAbort = controller; let succeeded = false; try { const response = await fetch(`${_mcpContentWakeBridgeUrl}/content-wakeup`, { cache: "no-store", credentials: "omit", headers: { "X-Safari-MCP-Wakeup": _mcpContentWakeToken }, mode: "cors", referrerPolicy: "no-referrer", signal: controller.signal, }); succeeded = response.status === 204; } catch (_) { succeeded = false; } finally { if (_mcpContentWakeAbort === controller) _mcpContentWakeAbort = null; } if ( generation !== _mcpContentWakeGeneration || _mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled ) return; if (succeeded) { _sendMcpRuntimeWake(); void _runMcpContentWakePoll(generation); return; } _mcpContentWakeRetryTimer = setTimeout(() => { _mcpContentWakeRetryTimer = null; if (generation !== _mcpContentWakeGeneration) return; void _runMcpContentWakePoll(generation); }, _MCP_CONTENT_WAKE_RETRY_MS); } function _sendMcpKeepalivePing() { if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return; _clearMcpKeepalivePingTimer(); // A direct runtime message wakes Safari's MV3 worker. The Port remains useful as // a low-overhead continuous lease, but real Safari can suspend the worker despite // an open Port; using both paths avoids the observed 30-second poll gaps. _sendMcpRuntimeWake(); const port = _openMcpKeepalivePort(); if (port) { try { port.postMessage({ type: "ping" }); } catch (_) { if (_mcpKeepalivePort === port) _mcpKeepalivePort = null; try { port.disconnect(); } catch (_) {} _scheduleMcpKeepaliveReconnect(); } } _mcpKeepalivePingTimer = setTimeout(_sendMcpKeepalivePing, _MCP_KEEPALIVE_PING_MS); } function _stopMcpKeepaliveLeadership() { _mcpKeepaliveLeader = false; _clearMcpKeepaliveLeaseTimer(); if (_mcpKeepaliveReconnectTimer) clearTimeout(_mcpKeepaliveReconnectTimer); _mcpKeepaliveReconnectTimer = null; _clearMcpKeepalivePingTimer(); _stopMcpContentWakeLoop(); _closeMcpKeepalivePort(); } async function _renewMcpKeepaliveLeaseAndPing() { if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled || !_isMcpKeepaliveTopFrame()) return; const stored = await browser.storage.local.get(_MCP_KEEPALIVE_LEASE_KEY).catch(() => ({})); if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return; const current = _validMcpKeepaliveLease(stored?.[_MCP_KEEPALIVE_LEASE_KEY]); if (!current || current.id !== _mcpKeepaliveInstanceId) { _stopMcpKeepaliveLeadership(); _considerMcpKeepaliveLeadership(current); return; } const lease = { id: _mcpKeepaliveInstanceId, expiresAt: Date.now() + _MCP_KEEPALIVE_LEASE_MS, visible: _mcpKeepaliveVisible(), }; await browser.storage.local.set({ [_MCP_KEEPALIVE_LEASE_KEY]: lease }).catch(() => {}); if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return; _sendMcpKeepalivePing(); _scheduleMcpKeepaliveLeaseCheck(_MCP_KEEPALIVE_RENEW_MS); } function _becomeMcpKeepaliveLeader() { if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled || !_isMcpKeepaliveTopFrame()) return; _mcpKeepaliveLeader = true; _clearMcpKeepaliveLeaseTimer(); _startMcpContentWakeLoop(); _renewMcpKeepaliveLeaseAndPing(); } async function _attemptMcpKeepaliveClaim() { if (_mcpKeepaliveDisposed || _mcpKeepaliveClaiming || _mcpKeepaliveLeader || !_mcpKeepaliveEnabled || !_isMcpKeepaliveTopFrame()) return; _mcpKeepaliveClaiming = true; try { const stored = await browser.storage.local.get(_MCP_KEEPALIVE_LEASE_KEY).catch(() => ({})); if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled) return; const current = _validMcpKeepaliveLease(stored?.[_MCP_KEEPALIVE_LEASE_KEY]); const mayPreemptHidden = _mcpKeepaliveVisible() && current && current.visible !== true; if (current && current.id !== _mcpKeepaliveInstanceId && !mayPreemptHidden) { const jitter = _mcpKeepaliveVisible() ? Math.random() * 100 : 250 + Math.random() * 1000; _scheduleMcpKeepaliveLeaseCheck(current.expiresAt - Date.now() + jitter); return; } const candidate = { id: _mcpKeepaliveInstanceId, expiresAt: Date.now() + _MCP_KEEPALIVE_LEASE_MS, visible: _mcpKeepaliveVisible(), }; const storedCandidate = await browser.storage.local .set({ [_MCP_KEEPALIVE_LEASE_KEY]: candidate }) .then(() => true) .catch(() => false); if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled) return; if (!storedCandidate) { _scheduleMcpKeepaliveLeaseCheck(1000 + Math.random() * 1000); return; } // Storage writes are serialized but not compare-and-swap. A short settle window // lets simultaneous candidates overwrite one another; only the final stored id // is allowed to open a Port. await new Promise((resolve) => setTimeout(resolve, 75 + Math.random() * 75)); if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled) return; const verified = await browser.storage.local.get(_MCP_KEEPALIVE_LEASE_KEY).catch(() => ({})); if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled) return; if (verified?.[_MCP_KEEPALIVE_LEASE_KEY]?.id === _mcpKeepaliveInstanceId) { _becomeMcpKeepaliveLeader(); return; } _considerMcpKeepaliveLeadership(verified?.[_MCP_KEEPALIVE_LEASE_KEY]); } finally { _mcpKeepaliveClaiming = false; } } function _considerMcpKeepaliveLeadership(knownLease) { if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled || !_isMcpKeepaliveTopFrame()) return; const current = _validMcpKeepaliveLease(knownLease); if (current?.id === _mcpKeepaliveInstanceId) { if (!_mcpKeepaliveLeader && !_mcpKeepaliveClaiming) _becomeMcpKeepaliveLeader(); return; } if (current) { if (_mcpKeepaliveVisible() && current.visible !== true) { _scheduleMcpKeepaliveLeaseCheck(Math.random() * 100); } else { const jitter = _mcpKeepaliveVisible() ? Math.random() * 100 : 250 + Math.random() * 1000; _scheduleMcpKeepaliveLeaseCheck(current.expiresAt - Date.now() + jitter); } return; } // Let a visible page win first. If Safari reports every background-app page as // hidden, randomized fallback candidates still converge on one verified lease. const initialDelay = _mcpKeepaliveVisible() ? Math.random() * 100 : 1000 + Math.random() * 1500; _scheduleMcpKeepaliveLeaseCheck(initialDelay); } function _onMcpKeepaliveVisibilityChange() { if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled) return; if (_mcpKeepaliveLeader) { _renewMcpKeepaliveLeaseAndPing(); } else { browser.storage.local.get(_MCP_KEEPALIVE_LEASE_KEY) .then((stored) => _considerMcpKeepaliveLeadership(stored?.[_MCP_KEEPALIVE_LEASE_KEY])) .catch(() => {}); } } async function _initializeMcpKeepalive() { if (_mcpKeepaliveDisposed || !_isMcpKeepaliveTopFrame()) return; document.addEventListener("visibilitychange", _onMcpKeepaliveVisibilityChange); browser.storage.onChanged.addListener(_onMcpKeepaliveStorageChanged); const enabledRevision = _mcpKeepaliveEnabledRevision; const stored = await browser.storage.local .get([ "mcpEnabled", _MCP_KEEPALIVE_LEASE_KEY, _MCP_CONTENT_WAKE_TOKEN_KEY, _MCP_CONTENT_WAKE_BRIDGE_KEY, ]) .catch(() => ({})); if (_mcpKeepaliveDisposed || enabledRevision !== _mcpKeepaliveEnabledRevision) return; _mcpKeepaliveEnabled = stored.mcpEnabled !== false; _mcpContentWakeToken = _validMcpContentWakeToken(stored[_MCP_CONTENT_WAKE_TOKEN_KEY]); _mcpContentWakeBridgeUrl = _validMcpContentWakeBridgeUrl( stored[_MCP_CONTENT_WAKE_BRIDGE_KEY] ); if (_mcpKeepaliveEnabled) { _considerMcpKeepaliveLeadership(stored[_MCP_KEEPALIVE_LEASE_KEY]); } } async function _onMcpKeepaliveStorageChanged(changes, areaName) { if (_mcpKeepaliveDisposed || areaName !== "local") return; if (changes[_MCP_CONTENT_WAKE_TOKEN_KEY] || changes[_MCP_CONTENT_WAKE_BRIDGE_KEY]) { _stopMcpContentWakeLoop(); if (changes[_MCP_CONTENT_WAKE_TOKEN_KEY]) { _mcpContentWakeToken = _validMcpContentWakeToken( changes[_MCP_CONTENT_WAKE_TOKEN_KEY].newValue ); } if (changes[_MCP_CONTENT_WAKE_BRIDGE_KEY]) { _mcpContentWakeBridgeUrl = _validMcpContentWakeBridgeUrl( changes[_MCP_CONTENT_WAKE_BRIDGE_KEY].newValue ); } if (_mcpKeepaliveLeader) _startMcpContentWakeLoop(); } if (changes.mcpEnabled) { _mcpKeepaliveEnabledRevision += 1; _mcpKeepaliveEnabled = changes.mcpEnabled.newValue !== false; if (!_mcpKeepaliveEnabled) { _stopMcpKeepaliveLeadership(); return; } } if (changes[_MCP_KEEPALIVE_LEASE_KEY]) { const next = _validMcpKeepaliveLease(changes[_MCP_KEEPALIVE_LEASE_KEY].newValue); if (_mcpKeepaliveLeader && next?.id !== _mcpKeepaliveInstanceId) { _stopMcpKeepaliveLeadership(); } _considerMcpKeepaliveLeadership(next); return; } if (changes.mcpEnabled && _mcpKeepaliveEnabled) { const stored = await browser.storage.local.get(_MCP_KEEPALIVE_LEASE_KEY).catch(() => ({})); if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled) return; _considerMcpKeepaliveLeadership(stored?.[_MCP_KEEPALIVE_LEASE_KEY]); } } function _cleanupMcpKeepalive() { _mcpKeepaliveDisposed = true; _mcpKeepaliveEnabled = false; _stopMcpKeepaliveLeadership(); document.removeEventListener("visibilitychange", _onMcpKeepaliveVisibilityChange); try { browser.storage.onChanged.removeListener(_onMcpKeepaliveStorageChanged); } catch (_) {} } globalThis.__mcpKeepaliveState = { leaseId: _mcpKeepaliveInstanceId, cleanup: _cleanupMcpKeepalive, }; _initializeMcpKeepalive(); })(); (() => { const previousCommandState = globalThis.__mcpContentCommandState; const commandEvent = browser.runtime.onMessage; const commandState = { active: true, event: commandEvent, listener: null, cleanup() { this.active = false; try { this.event.removeListener(this.listener); } catch (_) {} }, }; const listener = (message, _sender, sendResponse) => { if (!commandState.active || globalThis.__mcpContentCommandState !== commandState) return false; if (!message) return false; if (message.type === "mcp-content-click") { sendResponse(handleClick(message.payload || {})); return true; } if (message.type === "mcp-content-fill") { sendResponse(handleFill(message.payload || {})); return true; } // Last-resort path for evaluate. On hardened SPAs (business.facebook.com) the // scripting.executeScript answer never arrives, while this already-injected listener // keeps answering — clicks kept working there the whole time. Nothing is injected // here, so there is no injection to stall on. The type is new with the runId // protocol: a bridge left over from an older build must not take these, because it // would start the script without checking the claim. if (message.type === "mcp-content-eval-once") { // The script is often async, so this must resolve before answering — returning the // promise itself serialised to "{}" and silently produced an empty result. handleEval(message.payload || {}).then(sendResponse); return true; } // Lets the background tell "no bridge in this tab" (nothing can have run) from a // bridge that took an evaluate and then failed (the script may have run). if (message.type === "mcp-content-ping") { sendResponse(true); return true; } return false; }; commandState.listener = listener; browser.runtime.onMessage.addListener(listener); globalThis.__mcpContentCommandState = commandState; if (previousCommandState && previousCommandState !== commandState) { previousCommandState.cleanup?.(); } })(); // We are in the ISOLATED world: we can talk to the background, but the PAGE's CSP // governs eval here, and a hardened page (business.facebook.com) forbids it — an // earlier attempt with new Function() died exactly there. content.js is already in // MAIN world from document_start and holds a grandfathered Trusted Types policy, so // hand the work to it and wait for its reply. The id is the evaluate's runId: content.js // joins a run the injected runner already started instead of starting it again. function handleEval(payload) { return new Promise((resolve) => { const id = String(payload.id || ""); const source = String(payload.source || ""); let settled = false; const finish = (result) => { if (settled) return; settled = true; window.removeEventListener("message", onReply); clearTimeout(fallback); clearTimeout(bound); resolve(result); }; function onReply(ev) { if (ev.source !== window) return; const d = ev.data; if (!d || d.__mcp !== "eval_once_res" || d.id !== id) return; finish(d.result && typeof d.result === "object" ? d.result : { ok: false, error: "malformed bridge reply" }); } // No reply means no content.js in MAIN — the manifest leaves it out of LinkedIn, X and // the Google apps — so after 4s the script runs here, under the PAGE's CSP. Unless a // world already claimed the runId: that run is under way, and its answer comes from // content.js or from the injected runner, never from a second start. const fallback = setTimeout(() => { const claim = "__mcp_claim_" + id; if (!document.dispatchEvent(new Event(claim, { cancelable: true }))) return; document.addEventListener(claim, (e) => e.preventDefault()); try { (0, eval)("0"); } catch (_) { finish({ ok: false, csp: true, error: "eval is blocked by the page's CSP" }); return; } (async () => (0, eval)(source))().then( (v) => { if (v === undefined || v === null) return finish({ ok: true, value: null }); if (typeof v === "object") { try { return finish({ ok: true, value: JSON.stringify(v) }); } catch { return finish({ ok: true, value: String(v) }); } } finish({ ok: true, value: String(v) }); }, (err) => finish({ ok: false, error: (err && err.message) || String(err) }) ); }, 4000); // The background has stopped waiting by now; free the listener without an answer. const bound = setTimeout(() => finish({ pending: true }), (Number(payload.budgetMs) || 25000) + 1000); // postMessage, not CustomEvent: a CustomEvent's detail arrives as null in the MAIN // world, so the listener there fired with nothing to run. window.addEventListener("message", onReply); window.postMessage({ __mcp: "eval_once", id, source, keepMs: payload.keepMs }, "*"); }); } function handleFill(payload) { try { let el = null; if (payload.ref) { el = document.querySelector('[data-mcp-ref="' + cssString(payload.ref) + '"]'); } else if (payload.selector) { try { el = document.querySelector(payload.selector); } catch (_) {} } if (!el) return { ok: false, result: "Element not found" }; if (el.disabled || el.getAttribute("aria-disabled") === "true") { return { ok: false, result: "Element is DISABLED \u2014 cannot fill" }; } const value = String(payload.value ?? ""); el.focus(); if (el.isContentEditable) { el.textContent = value; safeDispatch(el, new InputEvent("input", { bubbles: true, composed: true, inputType: "insertText", data: value, })); return { ok: true, result: "Filled contenteditable: " + value.length + " chars" }; } if (el.tagName === "SELECT") { el.value = value; safeDispatch(el, new Event("input", { bubbles: true, composed: true })); safeDispatch(el, new Event("change", { bubbles: true, composed: true })); return { ok: true, result: "Filled SELECT: " + value }; } if (!("value" in el)) return { ok: false, result: "Element is not fillable" }; const prototype = el.tagName === "TEXTAREA" ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; if (setter) setter.call(el, value); else el.value = value; try { if (el._valueTracker && typeof el._valueTracker.setValue === "function") { el._valueTracker.setValue(""); } } catch (_) {} safeDispatch(el, new InputEvent("input", { bubbles: true, composed: true, inputType: "insertText", data: value, })); safeDispatch(el, new Event("change", { bubbles: true, composed: true })); return { ok: true, result: "Filled " + el.tagName + ": " + value.length + " chars" }; } catch (error) { return { ok: false, result: "Content fill failed: " + (error && error.message ? error.message : String(error)) }; } } function handleClick(payload) { try { const { selector, text, x, y, ref } = payload; let el = null; if (ref) { el = deepQuery('[data-mcp-ref="' + cssString(ref) + '"]'); } else if (selector) { el = deepQuery(selector); } else if (text) { el = deepFind( "button,a,[role='button'],[role='link'],[role='tab'],[role='combobox'],input[type='submit'],input[type='button']", (candidate) => isVisible(candidate) && (candidate.innerText || candidate.textContent || "").trim() === text ); if (!el) { for (const root of rootsIncludingOpenShadow()) { const treeRoot = root === document ? document.body : root; if (!treeRoot) continue; const walker = document.createTreeWalker(treeRoot, NodeFilter.SHOW_TEXT); while (walker.nextNode()) { const value = (walker.currentNode.textContent || "").trim(); if (value === text && isVisible(walker.currentNode.parentElement)) { el = walker.currentNode.parentElement; break; } } if (el) break; } } } else if (x !== undefined && y !== undefined) { el = deepElementFromPoint(Number(x), Number(y)); } if (!el) return { ok: false, result: "Element not found" }; if (!isVisible(el)) return { ok: false, result: "Element not visible" }; if (el.disabled || el.getAttribute("aria-disabled") === "true") { return { ok: false, result: "Element is DISABLED \u2014 cannot click" }; } el.scrollIntoView({ block: "center", inline: "center" }); const rect = el.getBoundingClientRect(); const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; let from = deepElementFromPoint(cx, cy); if (!from || !(from === el || el.contains(from))) from = el; const anchor = el.closest ? el.closest("a[href]") : null; const href = anchor && anchor.href && !anchor.href.startsWith("javascript:") ? anchor.href : ""; const beforeUrl = location.href; const common = { bubbles: true, cancelable: true, composed: true, view: window, clientX: cx, clientY: cy, button: 0, detail: 1, }; // Meta's rel=dialog handler specifically validates a leaf event.target. It does // not need (and on Safari is harmed by) a synthetic pointer prelude. if (anchor && anchor.getAttribute("rel") === "dialog") { const notPrevented = from.dispatchEvent(new MouseEvent("click", { ...common, buttons: 0 })); return { ok: true, result: notPrevented ? "rel=dialog handler did not claim click" : "Clicked rel=dialog link", }; } const pointer = { ...common, pointerId: 1, pointerType: "mouse", isPrimary: true, width: 1, height: 1, pressure: 0.5, }; safeDispatch(from, new PointerEvent("pointerover", { ...pointer, buttons: 0 })); safeDispatch(from, new MouseEvent("mouseover", { ...common, buttons: 0 })); safeDispatch(from, new PointerEvent("pointerenter", { ...pointer, buttons: 0 })); safeDispatch(from, new MouseEvent("mouseenter", { ...common, buttons: 0 })); safeDispatch(from, new PointerEvent("pointermove", { ...pointer, buttons: 0 })); safeDispatch(from, new MouseEvent("mousemove", { ...common, buttons: 0 })); safeDispatch(from, new PointerEvent("pointerdown", { ...pointer, buttons: 1 })); safeDispatch(from, new MouseEvent("mousedown", { ...common, buttons: 1 })); try { el.focus({ preventScroll: true }); } catch (_) { try { el.focus(); } catch (_) {} } safeDispatch(from, new PointerEvent("pointerup", { ...pointer, buttons: 0, pressure: 0 })); safeDispatch(from, new MouseEvent("mouseup", { ...common, buttons: 0 })); const notPrevented = from.dispatchEvent(new MouseEvent("click", { ...common, buttons: 0 })); const target = anchor ? (anchor.getAttribute("target") || "") : ""; if (href && notPrevented && location.href === beforeUrl && (!target || target === "_self")) { return { ok: true, result: "__MCP_NAVIGATE__:" + href }; } const form = el.closest ? el.closest("form") : null; if (form && (el.type === "submit" || (el.tagName === "BUTTON" && el.type !== "button" && el.type !== "reset"))) { try { form.requestSubmit ? form.requestSubmit(el.type === "submit" ? el : undefined) : form.submit(); } catch (_) {} } return { ok: true, result: "Clicked: " + el.tagName + (el.textContent ? ' "' + el.textContent.trim().substring(0, 50) + '"' : ""), }; } catch (error) { return { ok: false, result: "Content click failed: " + (error && error.message ? error.message : String(error)) }; } } function cssString(value) { return String(value).replace(/["\\]/g, "\\$&"); } function rootsIncludingOpenShadow(root = document, roots = []) { roots.push(root); let descendants = []; try { descendants = root.querySelectorAll("*"); } catch (_) {} for (const descendant of descendants) { let shadow = null; try { shadow = descendant.shadowRoot; } catch (_) {} if (shadow) rootsIncludingOpenShadow(shadow, roots); } return roots; } function deepQuery(selector) { return deepFind(selector, () => true); } function deepFind(selector, predicate, root = document) { let candidates; try { candidates = root.querySelectorAll(selector); } catch (_) { return null; } for (const candidate of candidates) { if (predicate(candidate)) return candidate; } let descendants = []; try { descendants = root.querySelectorAll("*"); } catch (_) {} for (const descendant of descendants) { let shadow = null; try { shadow = descendant.shadowRoot; } catch (_) {} if (!shadow) continue; const nested = deepFind(selector, predicate, shadow); if (nested) return nested; } return null; } function deepElementFromPoint(x, y) { let root = document; let deepest = null; for (let depth = 0; depth < 32; depth += 1) { let hit = null; try { hit = root.elementFromPoint(x, y); } catch (_) {} if (!hit || hit === deepest) break; deepest = hit; let shadow = null; try { shadow = hit.shadowRoot; } catch (_) {} if (!shadow || typeof shadow.elementFromPoint !== "function") break; root = shadow; } return deepest; } function isVisible(el) { if (!el) return false; const style = getComputedStyle(el); const rect = el.getBoundingClientRect(); return style.display !== "none" && style.visibility !== "hidden" && parseFloat(style.opacity || "1") !== 0 && rect.width > 0 && rect.height > 0; } function safeDispatch(el, event) { try { return el.dispatchEvent(event); } catch (_) { return true; } } // Expose only to this extension's isolated world. The background worker uses these // after an extension update when Safari refuses to return a response from a listener // added to an already-open tab; it schedules the call after executeScript has returned. globalThis.__mcpHandleContentClick = handleClick; globalThis.__mcpHandleContentFill = handleFill; // Ask once whether the background owns this tab; if it does, tell content.js (MAIN // world) to keep the page rendering while hidden. A blank tab is made first and owned // before it navigates, so the page's first load already answers yes; the retries cover // a worker that is still waking up. The message carries no data either way. (() => { try { if (window.top !== window) return; } catch (_) { return; } const ask = (left) => { let sent; try { sent = browser.runtime.sendMessage({ action: "mcpTabOwnedV1" }); } catch (_) { return; } const retry = () => { if (left > 0) setTimeout(() => ask(left - 1), 1500); }; Promise.resolve(sent).then((r) => { if (r && r.owned === true) window.postMessage({ __mcp: "keep_rendering_v1" }, "*"); else if (!r || r.owned !== false) retry(); }, retry); }; ask(3); })();