safari-mcp
Version:
Safari browser automation for AI agents — native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.
6,971 lines • 364 kB
JavaScript
// Safari automation layer — dual engine:
// 1. Extension (WebSocket) — fastest (~5ms), native browser API, keeps logins
// 2. AppleScript + Swift daemon (~5ms) — keeps logins, always available
// Extension is preferred. AppleScript is fallback when extension is not connected.
import { execFile, spawn, spawnSync } from "node:child_process";
import { promisify } from "node:util";
import { tmpdir, homedir } from "node:os";
import { join, dirname, resolve as resolvePath } from "node:path";
import { readFile, writeFile, unlink, appendFile, mkdir } from "node:fs/promises";
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
import { VIEWPORT_SCRIPT, SAFE_AREA_SCRIPT, PWA_SCRIPT, WEBKIT_COMPAT_SCRIPT, ALL_SHEETS_FN } from "./injected-validators.js";
import { escJsSingleQuote, escAppleScriptString } from "./injected-escape.js";
import { currentSessionId } from "./session-context.js";
import { allowUserTabs } from "./ownership-state.js";
// Extension bridge is handled by index.js (WebSocket server on port 9223)
const execFileAsync = promisify(execFile);
const __dirname = dirname(fileURLToPath(import.meta.url));
// ========== STRING ESCAPING (single source of truth) ==========
// Escaping ORDER is security-relevant: the backslash MUST be escaped before the quote, or the
// backslash inserted in front of the quote gets doubled and the string breaks out. These helpers
// replace ~70 hand-inlined copies of the same recipe — fix the escaping once here, not at every
// call site. Verified equivalent to the inline pattern by test/escaping.test.mjs.
// Escaping helpers now live in ./injected-escape.js (pure + test-locked). Imported above
// for internal use; re-exported here so existing `from "./safari.js"` imports keep working.
export { escJsSingleQuote, escAppleScriptString };
// ========== SWIFT HELPER DAEMON ==========
// Persistent process — no subprocess spawn overhead (~5ms vs ~90ms)
let _helperProc = null;
const _helperQueue = []; // callbacks waiting for responses
let _helperConsecutiveTimeouts = 0; // Track consecutive timeouts — kill threshold lives where it's checked (currently 5)
// ── Helper request correlation ────────────────────────────────────────────
// The Swift helper finishes requests out of order on purpose: a native click must
// not wait behind a slow `do JavaScript`. Responses used to be matched to callbacks
// by FIFO queue position, which is correct ONLY with one request in flight — so a
// mutex serialized every round-trip. That mutex was also the ceiling: one heavy page
// froze every other check in the session for as long as it took, and a second Safari
// session looked completely dead meanwhile.
//
// Each request now carries an id the daemon echoes back, so replies are matched by
// identity and may overtake each other freely. The FIFO queue stays as the fallback
// path for a daemon binary older than this protocol (it echoes no id), and the mutex
// stays engaged until the running daemon has proven it speaks ids.
let _helperSeq = 0;
const _helperPending = new Map(); // request id → callback
let _helperSupportsIds = false; // flipped by the first id-matched reply
function _helperLine(payload, cb) {
const id = "r" + ++_helperSeq;
cb.__helperId = id;
_helperPending.set(id, cb);
return JSON.stringify({ ...payload, id }) + "\n";
}
function _forgetHelperCb(cb) {
if (cb?.__helperId) _helperPending.delete(cb.__helperId);
}
let _helperLock = Promise.resolve();
function _withHelperLock(makePromise) {
// Correlation by id makes serialization unnecessary — this is the line that lets
// several checks run at once. Until the daemon proves it echoes ids, stay serial:
// FIFO matching is only honest with a single request in flight.
if (_helperSupportsIds) return Promise.resolve().then(makePromise);
const result = _helperLock.then(makePromise, makePromise);
_helperLock = result.then(() => {}, () => {});
return result;
}
// A timed-out request keeps its slot: the daemon may still answer, and that late
// reply is proof of life. Swap both trackers to the replacement consumer so the FIFO
// queue stays aligned AND the id still resolves to something.
function _replaceHelperCb(cb, consumer) {
const idx = _helperQueue.indexOf(cb);
if (idx >= 0) _helperQueue[idx] = consumer;
if (cb?.__helperId) {
consumer.__helperId = cb.__helperId;
_helperPending.set(cb.__helperId, consumer);
}
}
// The write never reached the daemon, so no reply is coming for this request.
function _dropHelperCb(cb) {
const idx = _helperQueue.indexOf(cb);
if (idx >= 0) _helperQueue.splice(idx, 1);
_forgetHelperCb(cb);
}
// Reject all pending callbacks when helper crashes
function _drainHelperQueue(reason) {
const pending = [..._helperPending.values()];
_helperPending.clear();
while (_helperQueue.length > 0) {
const cb = _helperQueue.shift();
if (cb) {
const idx = pending.indexOf(cb);
if (idx >= 0) pending.splice(idx, 1);
cb(JSON.stringify({ error: reason }));
}
}
// Callbacks tracked only by id (the queue entry was already consumed) still need
// their rejection, or their caller hangs until its own timeout fires.
for (const cb of pending) cb(JSON.stringify({ error: reason }));
}
function startHelper() {
if (_helperProc) return; // idempotent — a respawn already won the race; don't orphan a daemon
const helperPath = join(__dirname, "safari-helper");
try {
_helperProc = spawn(helperPath, [], { stdio: ["pipe", "pipe", "ignore"] });
let _buf = "";
_helperProc.stdout.on("data", (chunk) => {
_buf += chunk.toString();
const lines = _buf.split("\n");
_buf = lines.pop(); // Keep incomplete line
for (const line of lines) {
if (!line.trim()) continue;
let replyId = null;
try {
const parsed = JSON.parse(line);
if (parsed && typeof parsed.id === "string") replyId = parsed.id;
} catch { /* not JSON — fall through to arrival order */ }
const keyed = replyId ? _helperPending.get(replyId) : null;
if (keyed) {
_helperPending.delete(replyId);
const qi = _helperQueue.indexOf(keyed);
if (qi >= 0) _helperQueue.splice(qi, 1);
_helperSupportsIds = true; // this daemon speaks the correlated protocol
keyed(line);
continue;
}
// Legacy daemon, or a reply whose request already timed out.
const cb = _helperQueue.shift();
if (cb) { _forgetHelperCb(cb); cb(line); }
}
});
_helperProc.on("error", () => { _drainHelperQueue("helper process error"); _helperProc = null; _scheduleRestart(); });
_helperProc.on("exit", (code) => { _drainHelperQueue("helper process exited (code " + code + ")"); _helperProc = null; _scheduleRestart(); });
} catch {
_helperProc = null;
}
}
startHelper();
// ========== AUTO-RESTART: recover from helper crashes ==========
let _restartCount = 0;
let _restartTimer = null;
let _shuttingDown = false;
function _scheduleRestart() {
if (_shuttingDown || _restartTimer) return;
_restartCount++;
// Exponential backoff: 500ms, 1s, 2s, 4s, max 10s
const delay = Math.min(500 * Math.pow(2, _restartCount - 1), 10000);
console.error(`safari-helper crashed (restart #${_restartCount}, retrying in ${delay}ms)`);
_restartTimer = setTimeout(() => {
_restartTimer = null;
if (!_shuttingDown && !_helperProc) {
startHelper();
// Reset restart count after 60s of stability
setTimeout(() => { if (_helperProc) _restartCount = 0; }, 60000);
}
}, delay);
}
// ========== CLEANUP: kill helper when parent process exits ==========
// Without this, safari-helper processes accumulate as zombies when MCP restarts
function cleanupHelper() {
_shuttingDown = true;
if (_restartTimer) { clearTimeout(_restartTimer); _restartTimer = null; }
if (_helperProc) {
try { _helperProc.kill("SIGTERM"); } catch (_) {}
_helperProc = null;
}
}
// Signal handlers (SIGINT/SIGTERM/SIGHUP) are registered in index.js only.
// cleanupHelper runs via process.on("exit"), which fires when index.js calls process.exit().
process.on("exit", cleanupHelper);
process.on("uncaughtException", (err) => { console.error("Uncaught:", err); cleanupHelper(); process.exit(1); });
// Unhandled promise rejections must NOT terminate the server. A single failed
// async operation — e.g. a proxy fetch to the primary instance while it is
// mid-restart, or an aborted fetch timeout — would otherwise bubble to the
// uncaughtException handler above (Node's default for unhandled rejections) and
// exit the whole MCP process, disconnecting every concurrent session. Log and
// continue: the failed operation is localized, the process itself is healthy.
process.on("unhandledRejection", (reason) => {
console.error("[Safari MCP] Unhandled rejection (non-fatal, continuing):", (reason && reason.stack) || reason);
});
// ========== SAFARI RUNNING CHECK ==========
// Prevent AppleScript from auto-launching Safari when it's closed
// ponytail: a "running" answer is reused for 1s — AppleScript waits poll every 100–200ms and each
// call spawned a pgrep. Ceiling: a Safari quit inside that second can let one script relaunch it.
let _safariRunningUntil = 0;
async function isSafariRunning() {
if (Date.now() < _safariRunningUntil) return true;
try {
const { stdout } = await execFileAsync("pgrep", ["-x", "Safari"], { timeout: 2000 });
const running = stdout.trim().length > 0;
if (running) _safariRunningUntil = Date.now() + 1000;
return running;
} catch {
return false; // pgrep exits 1 when no match
}
}
function safariNotRunningError() {
return new Error("Safari is not running. Open Safari manually before using Safari MCP tools.");
}
// ========== CLIPBOARD LOCK ==========
// Prevents concurrent clipboard operations from clobbering the user's clipboard.
// While locked, any new clipboard operation waits until the current one completes.
let _clipboardLocked = false;
let _clipboardRestoreTimer = null;
let _pendingClipboardRestore; // content stashed for a synchronous flush on shutdown (see flushClipboardRestore)
async function _acquireClipboardLock(timeoutMs = 10000) {
const start = Date.now();
while (_clipboardLocked) {
if (Date.now() - start > timeoutMs) {
throw new Error("Clipboard lock timeout — another operation is still using the clipboard. Try again shortly.");
}
await new Promise(r => setTimeout(r, 50));
}
_clipboardLocked = true;
}
function _releaseClipboardLock() {
_clipboardLocked = false;
}
// Save current clipboard and return it for later restore
async function _saveClipboard() {
try {
const { stdout } = await execFileAsync("pbpaste", []);
return stdout;
} catch { return null; }
}
// Write text to the macOS clipboard via pbcopy. Single place that handles EPIPE on the stdin
// stream (pbcopy exiting before it reads) — an unhandled 'error' there would otherwise reach
// uncaughtException and kill the whole server.
function _pbcopy(text) {
return new Promise((resolve, reject) => {
const proc = spawn("pbcopy", [], { stdio: ["pipe", "ignore", "ignore"] });
proc.stdin.on("error", reject);
proc.on("error", reject);
proc.on("close", resolve);
proc.stdin.write(text);
proc.stdin.end();
});
}
// Restore clipboard immediately (no async setTimeout leak)
async function _restoreClipboard(savedContent) {
if (savedContent === null) return;
try {
await _pbcopy(savedContent);
} catch {}
}
// ========== ACTIVE TAB TRACKING ==========
// Instead of visually switching tabs (which interrupts the user),
// we track which tab we're "working on" by URL (not index, because indices shift
// when the user opens/closes tabs). Before each operation we resolve the URL
// to the current index.
// ── Per-session tab state (see session-context.js) ──────────────────────────
// Was six module-global `let`s. In HTTP-daemon mode one process serves many Claude
// sessions and they overwrote each other's tab pointer → operations drifted onto the
// wrong (often the user's) tab. Now keyed per MCP session via _st(). Field notes:
// activeTabIndex — null = use front document (default)
// activeTabURL — URL-based tracking (stable even when tabs shift)
// hasOwnedTab — once true (after the session's first tab, whether AppleScript or the
// extension opened or picked it), write ops
// (navigate/click/fill) MUST NOT fall back to "current tab of window"
// (the USER'S tab). The 30s grace window was insufficient — tracking
// can be lost late in a session (e.g. tab ghost recovery in runJS);
// silent fallback overwrote the user's working tab in past incidents.
// lastResolveTime — cache: skip resolve if verified recently
// lastTabCount — track tab count for smart cache invalidation
// activeTabMarker — window.__mcpTabMarker; survives same-tab navigation, bulletproof id
// markerId — per-session unique id baked into the marker string
// tabFromExtension — the current tab is one the Safari extension opened or picked, so only
// the extension can put the marker on it (see resolveActiveTab)
// marking — that request while it runs, shared by parallel calls
const _sessions = new Map();
function _st() {
const sid = currentSessionId();
let s = _sessions.get(sid);
if (!s) {
s = { activeTabIndex: null, activeTabURL: null, hasOwnedTab: false,
lastResolveTime: 0, lastTabCount: null, activeTabMarker: null,
tabFromExtension: false, marking: null, markerId: randomUUID().slice(0, 8) };
_sessions.set(sid, s);
}
return s;
}
export function _dropSession(sid) { _sessions.delete(sid); } // called on MCP session close
const RESOLVE_CACHE_MS = 100; // Brief cache — was 500, reduced to catch tabs added by user/popups (v2.8.3 fix)
// ========== DIAGNOSTIC LOG ==========
// File-based log for profile/focus issues — survives MCP restart, visible to user
const _LOG_FILE = '/tmp/safari-mcp-profile.log';
function _logProfile(msg) {
const ts = new Date().toISOString().replace('T', ' ').substring(0, 19);
const line = `[${ts}] ${msg}\n`;
console.error(`[Safari MCP] ${msg}`);
appendFile(_LOG_FILE, line).catch(() => {});
}
// ========== PROFILE TARGETING ==========
// Set SAFARI_PROFILE env var to target a specific Safari profile window.
// Safari shows profile windows as: "ProfileName — Tab Title"
const SAFARI_PROFILE = process.env.SAFARI_PROFILE || null;
let _targetWindowRef = null; // null = not yet discovered. Updated by refreshTargetWindow()
let _targetWindowId = null; // Numeric window ID (for CGEvent window targeting)
let _targetWindowCacheTime = 0;
const TARGET_WINDOW_CACHE_MS = 1000; // Short cache — fast detection of window changes
let _profileWindowMissing = false; // True when profile window is not found
let _focusGuardActive = false; // True when an outer caller already handles focus save/restore
// Get the target window ref, falling back to 'front window' ONLY when no profile is configured
function getTargetWindowRef() {
if (SAFARI_PROFILE) {
if (!_targetWindowRef) {
throw new Error(`Safari profile "${SAFARI_PROFILE}" window not found. Open the "${SAFARI_PROFILE}" profile in Safari first.`);
}
return _targetWindowRef;
}
return _targetWindowRef || 'front window';
}
// Consecutive failed profile-window detections (#81) — drives the poll backoff,
// the subprocess-fallback cutoff, and the missing-window log rate limit.
let _profileMisses = 0;
let _lastMissingLogTime = 0;
async function refreshTargetWindow(force = false) {
if (!SAFARI_PROFILE) return;
const now = Date.now();
if (!force && _targetWindowRef && (now - _targetWindowCacheTime) < TARGET_WINDOW_CACHE_MS) return;
const safeProfile = SAFARI_PROFILE.replace(/"/g, '\\"');
// Find profile window by name AND verify the window ID still matches
const detectScript = `tell application "Safari"\n repeat with w in every window\n if name of w starts with "${safeProfile} \u2014" then return (id of w as text) & "|" & name of w\n end repeat\n return "0|"\nend tell`;
let result = await osascriptFast(detectScript).catch(() => '0|');
// The persistent helper occasionally returns '0|' for a window that genuinely
// exists (daemon timeout / restart race). Before concluding the window is
// missing, retry once with a plain osascript subprocess \u2014 slower but reliable.
// Once consecutive misses have established the window is genuinely ABSENT
// (not flakily undetected), skip the subprocess retry — an absent window
// returns '0|' every cycle, so the fallback would otherwise spawn an
// osascript process per poll, forever (#81). The first successful detection
// re-arms it for the flaky-helper case it was added for.
if (String(result).split('|')[0] === '0' && _profileMisses < 3) {
result = await osascript(detectScript).catch(() => '0|');
}
const [idStr, windowName] = String(result).split('|');
const id = Number(idStr);
if (id > 0) {
const newRef = `window id ${id}`;
if (_targetWindowRef && _targetWindowRef !== newRef) {
_logProfile(`Profile window changed: ${_targetWindowRef} → ${newRef} ("${windowName}")`);
}
_targetWindowRef = newRef;
_targetWindowId = id;
_targetWindowCacheTime = now;
_profileWindowMissing = false;
_profileMisses = 0;
} else {
// Profile window not found — clear ref so getTargetWindowRef() will throw
_targetWindowRef = null;
_targetWindowId = null;
_targetWindowCacheTime = 0;
_profileWindowMissing = true;
_profileMisses++;
_maybeOpenProfileWindow();
// Log on transition into the missing state, then at most once per 5 minutes —
// this steady state used to append one line per poll, unbounded (#81).
if (_profileMisses === 1 || now - _lastMissingLogTime > 300000) {
_lastMissingLogTime = now;
_logProfile(`WARNING: Profile "${SAFARI_PROFILE}" window not found — refusing to use front window`);
}
}
}
// ponytail: opt-in self-heal for the "profile window is closed" steady state (198 refusals in
// one log, every morning after a reboot). SAFARI_MCP_OPEN_WINDOW_CMD is run with the profile
// name once absence is established (3 consecutive misses), at most every 2 minutes; the poll
// below then rediscovers the window. The command must open the window WITHOUT focusing
// Safari (e.g. ~/bin/safari-bg-window, which presses the File-menu item via Accessibility).
const OPEN_WINDOW_CMD = process.env.SAFARI_MCP_OPEN_WINDOW_CMD || "";
let _lastOpenWindowAttempt = 0;
function _maybeOpenProfileWindow() {
if (!OPEN_WINDOW_CMD || !SAFARI_PROFILE || _profileMisses < 3) return;
const now = Date.now();
if (now - _lastOpenWindowAttempt < 120000) return;
_lastOpenWindowAttempt = now;
_logProfile(`Profile "${SAFARI_PROFILE}" window absent — running ${OPEN_WINDOW_CMD}`);
// The opener presses a File-menu item, which needs Safari running and NOT hidden
// (`open -g -j` leaves the menu bar inaccessible). Launch it in the background first.
const ensureRunning = execFileAsync("/usr/bin/pgrep", ["-x", "Safari"]).then(() => null, () =>
execFileAsync("/usr/bin/open", ["-g", "-a", "Safari"])
.then(() => new Promise((r) => setTimeout(r, 5000)))
.catch(() => null));
ensureRunning
.then(() => execFileAsync(OPEN_WINDOW_CMD, [SAFARI_PROFILE], { timeout: 30000 }))
.then(() => {
_profileMisses = 0; // re-arm the subprocess retry for the fresh window
setTimeout(() => { refreshTargetWindow(true).catch(() => {}); }, 3000).unref();
})
.catch((err) => _logProfile(`Open-window command failed: ${err.message}`));
}
// Background verification: periodically check that cached window ID still belongs to profile
if (SAFARI_PROFILE) {
// Self-scheduling poll with exponential backoff (#81): 15s while the window is
// present (or flakily undetected), doubling per consecutive miss up to 60s
// while it is absent — a closed profile window is a steady state, and the
// fixed 3s cadence used to spawn an osascript subprocess per cycle, forever.
// The first successful detection resets to 15s, so rediscovery stays
// responsive: once the user opens the window, the next poll lands within 60s
// and everything after it is back on the 15s cadence. This poll is only the
// background self-heal: every AppleScript caller re-validates the window itself
// (refreshTargetWindow, 1s cache), so 3s cost a pgrep + an Apple Event to
// Safari every 3 seconds per daemon for nothing.
const _POLL_BASE_MS = 15000;
const _POLL_MAX_MS = 60000;
const _pollOnce = async () => {
// No cached window (e.g. flaky detection at startup) — keep trying to
// rediscover it so the server self-heals instead of staying stuck.
if (!_targetWindowRef || !_targetWindowId) {
await refreshTargetWindow(true).catch(() => {});
return;
}
try {
// Read-only window-name query — opt out of focus-guard so a rare
// user-app-switch race never triggers the hide-fallback against them.
const name = await osascriptFast(
`tell application "Safari" to return name of ${_targetWindowRef}`,
{ noFocusGuard: true }
).catch(() => '');
if (name && !name.startsWith(`${SAFARI_PROFILE} \u2014`)) {
// Window ID no longer belongs to profile — invalidate cache immediately
_logProfile(`SAFETY: Window ${_targetWindowRef} no longer belongs to profile "${SAFARI_PROFILE}" (name: "${name}") — invalidating`);
_targetWindowRef = null;
_targetWindowId = null;
_targetWindowCacheTime = 0;
_profileWindowMissing = true;
// Try to rediscover immediately
await refreshTargetWindow(true);
}
} catch {
// Window might have been closed — invalidate and rediscover
_targetWindowRef = null;
_targetWindowId = null;
_targetWindowCacheTime = 0;
await refreshTargetWindow(true);
}
};
const _schedulePoll = () => {
const delay =
_profileMisses > 0
? Math.min(_POLL_BASE_MS * 2 ** Math.min(_profileMisses, 5), _POLL_MAX_MS)
: _POLL_BASE_MS;
setTimeout(async () => {
await _pollOnce().catch(() => {});
_schedulePoll();
}, delay);
};
_schedulePoll();
}
// Initialize profile window at startup (ES module top-level await)
if (SAFARI_PROFILE) {
// Run profile-window detection off the critical path so module init — and
// therefore the MCP initialize handshake — completes immediately. Tool calls
// that arrive before this finishes already trigger lazy refresh via
// getTargetWindowRef(), so correctness is preserved.
// Why this matters: a blocking `await refreshTargetWindow(true)` here could
// run >30s when Safari was busy or AppleScript was stalled, tripping Claude
// Code's 30s MCP timeout and leaving the conversation's tool catalog without
// safari tools until a new conversation is started.
(async () => {
await new Promise(r => setTimeout(r, 50)); // Let helper process initialize
await refreshTargetWindow(true);
if (_targetWindowRef) {
_logProfile(`Startup: Profile "${SAFARI_PROFILE}" → targeting ${_targetWindowRef}`);
} else {
_logProfile(`WARNING: Profile "${SAFARI_PROFILE}" window NOT found at startup`);
}
})();
}
// Detect stale window ID errors and invalidate cache
function isStaleWindowError(err) {
const msg = (err && (err.message || err.stderr || String(err))) || '';
return /window id \d+/.test(msg) && /(-1728|-10006)/.test(msg);
}
// Safe fallback target: when no tab index is known, use the profile window's current tab
// instead of "front document" which can target the user's personal profile window
// Throw if a write operation is about to fall back to the user's active tab
// during the new-tab grace window. Without this, navigate/fill/click silently
// target whatever tab the user is looking at when our cached index is lost.
function _assertNotFallingBackToUserTab(opName) {
if (_st().activeTabIndex) return; // we have a tracked index — fine
// If we ever opened our own tab in this session, we MUST NOT fall back to
// "current tab of window" — that's the USER'S active tab. Always throw,
// regardless of how long ago the tab was opened. The previous 30-second
// grace window was insufficient: long-running sessions (Reddit warmup,
// multi-step workflows) routinely exceed it, and the danger persists.
if (_st().hasOwnedTab) {
throw new Error(
`Tab tracking lost — refusing to ${opName} via fallback to "current tab of window" (would target the user's active tab). ` +
`This session previously opened its own tab via safari_new_tab; re-run safari_new_tab to recover, or call safari_list_tabs and safari_switch_tab to re-anchor to a tab this session opened.`
);
}
// No tab ever owned by this session — fallback to front document is intentional.
}
// The tab for a step that names its tab explicitly (a navigation or a poll across one, where the
// page load clears the marker), as { idx, win, marker, fp, op } for _inTab(): this session's tab,
// proven by its marker, with the window the proof found it in and the marker that proved it, or idx
// null for a session that never owned a tab, meaning the front document. An index read straight
// from the session state named whatever tab had shifted into it, and one addressed to 'front
// window' in each later script named that index in whatever window the user had brought to the
// front meanwhile. The marker is the one the scan found, not the session's after the scan's await:
// a parallel call of the same session can change that one. No fingerprint yet: the step's first
// script proves the tab by its marker, and `since` is set just before the step starts its load.
async function _sessionTab(opName) {
const { idx, win, marker } = await _resolveSessionTab();
_assertNotFallingBackToUserTab(opName);
return { idx: idx || null, win, marker: idx && win ? marker || null : null, fp: null, since: 0, op: opName };
}
function getFallbackTarget() {
return SAFARI_PROFILE ? `current tab of ${getTargetWindowRef()}` : "front document";
}
// ========== TAB IDENTITY MARKER + VISIBILITY SPOOF ==========
// Build the JS that stamps our identity onto a tab and keeps it rendering:
// - window.name : survives EVERY navigation (full loads, redirects,
// cross-origin). The browser preserves window.name by
// design — the bulletproof identity that index/URL lack.
// - window.__mcpTabMarker : survives SPA / same-document routing (secondary marker).
// - visibility spoof : forces document.visibilityState='visible' so a
// backgrounded tab keeps rendering. SPAs (e.g. the Meta
// developer console) blank their main content when hidden;
// with the user actively switching tabs our automation tab
// is constantly backgrounded, so without this its content
// never paints.
// `{ expr }` in place of a marker stamps the marker that page JavaScript evaluates to: switchTab()
// keeps the marker a tab already carries, which only the page can read.
function _buildStampJS(marker) {
const m = marker?.expr || "'" + String(marker).replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "'";
return "(function(){"
+ "try{window.name=" + m + ";}catch(e){}"
+ "try{window.__mcpTabMarker=" + m + ";}catch(e){}"
+ "try{if(!window.__mcpVisSpoof){window.__mcpVisSpoof=1;"
+ "Object.defineProperty(document,'visibilityState',{configurable:true,get:function(){return 'visible';}});"
+ "Object.defineProperty(document,'hidden',{configurable:true,get:function(){return false;}});"
+ "Object.defineProperty(document,'webkitVisibilityState',{configurable:true,get:function(){return 'visible';}});"
+ "Object.defineProperty(document,'webkitHidden',{configurable:true,get:function(){return false;}});"
+ "var s=function(e){e.stopImmediatePropagation();};"
+ "document.addEventListener('visibilitychange',s,true);"
+ "document.addEventListener('webkitvisibilitychange',s,true);"
+ "try{document.hasFocus=function(){return true;};}catch(e){}"
+ "}}catch(e){}"
+ "return '1';})()";
}
// Stamp identity marker + visibility spoof onto the tab a step proved (see _sessionTab), in the
// script that proves it again (_inTab). No proven window, no stamp: on 'front window' the marker
// went to the tab at that index in whatever window the user had brought forward, and a tab no
// script can prove is not stamped at all: the step fails instead. Identity-critical: a missed
// stamp loses the tab marker, so this is NOT best-effort — a daemon hiccup falls back to the
// reliable osascript subprocess.
async function _stampTab(tab) {
if (!tab?.idx || !tab.win || !tab.marker) return;
const stamp = (opts) => _inTab(tab, "''", { stamp: "always", ...opts });
await stamp({ timeout: 5000 })
.catch((err) => { if (err.tabUnproven) throw err; return stamp({ timeout: 8000, subprocess: true }); })
.catch((err) => { if (err.tabUnproven) throw err; });
}
// ========== PROVING THE TAB INSIDE EACH SCRIPT OF A STEP ==========
// A step proves the session's tab once, by the marker scan or the script that made the tab, then
// acts on `tab N of window id W` in more scripts: a load's probes and re-stamp, navigate's
// `set URL`, a click or a history move. AppleScript has no tab id, and a tab opened, closed or moved
// before it in that window (a link opened in a new tab from an earlier tab, a tab closed to its
// left and another opened, another session's tab cap closing its oldest tab) renumbers the window,
// so by the next script tab N can be the user's. Each of those scripts proves the tab again,
// inside itself, before it touches it:
// 1. the tab at N carries the session's marker, checked in the same `do JavaScript` that acts; or
// 2. another tab of the window carries it: the tab moved, and the script acts there instead; or
// 3. no tab of the window carries it, and the page at N is a document the step's own load brought:
// created after the step started its load (`since`; a cross-site load clears window.name, any
// load __mcpTabMarker), in a window that still looks the way the last script that proved the
// tab saw it: the same window, tab count and selected-or-not state of tab N, and the same URLs
// in the three tabs before it (compared considering case).
// Step 3 never takes a page carrying another session's marker, and never serves a script that runs
// before the step's load (`markerOnly`), whose page still carries the marker. The load polls stamp
// the marker back as soon as a script proves the page, so step 3 bridges the scripts between a load
// and the next poll. A page where no script runs answers missing value, which proves nothing.
// Failing all three, the script touches no tab and answers _TAB_UNPROVEN, and the step fails with
// "Tab tracking lost". The fingerprint catches a tab inserted anywhere before the session's tab or
// closed anywhere (the count), a tab closed to its left and another opened (the URLs before it
// shift), and a tab in another selected-or-not state landing in its place.
// ponytail: false positive — a tab opened, closed or selected in that window, or a URL change in
// one of the three tabs before the session's, between a load that cleared the marker and the next
// poll, fails the step closed; so does a cross-origin load that started more than a second before
// the step's write (a previous call's click, the page's own redirect) and commits while
// clickAndWait or fillAndSubmit waits, and a page the back/forward cache restores that the session
// never stamped (one from before it took the tab). A Cmd+T or another session's new tab at any other
// time does not: the marker decides. Miss — in that gap, while the window's fingerprint matches,
// any page at N whose document started loading after the step's write less a second passes: a tab
// created in the session's place, or one shifted into it (the session's tab closed or dragged
// right), including one that reloaded. The age test compares Node's wall clock with WebKit's
// timeOrigin, which WebKit derives at each read from a clock that stops in sleep: a wall-clock step
// back of more than a second during a step refuses the session's page, and a sleep or forward step
// of S lets a document up to S + 1 s older pass. And the proof and the action are two Apple events
// of one script (as in closeTabByMarker), so a tab moved in the milliseconds between them still
// shifts it.
const _TAB_UNPROVEN = "MCP_TAB_UNPROVEN";
const _TAB_BUSY = "MCP_TAB_BUSY";
// The AppleScript handler that reads the fingerprint of window `w` as its tab `i` sees it, or ""
// when the window has no tab i: "<window id>|<tab count>|<tab i selected>|<URL>|<URL>|<URL>", the URLs
// of the up to three tabs before tab i (missing value as "", cut at 200 characters). No linefeed.
// The window id keeps a script that osascript() retargets to another window from matching.
const _FINGERPRINT_HANDLER = `on mcpFp(w, i)
tell application "Safari"
set n to count of tabs of w
if i > n then return ""
set fp to ((id of w) as text) & "|" & (n as text) & "|" & ((visible of tab i of w) as text)
repeat with j from i - 3 to i - 1
if j > 0 then
set u to URL of tab j of w
if u is missing value then set u to ""
if (length of u) > 200 then set u to text 1 thru 200 of u
set fp to fp & "|" & u
end if
end repeat
return fp
end tell
end mcpFp`;
// The page JavaScript an _inTab() script runs: `js`, once the page proves the tab, answering
// "MCP_OK:" and its value as text ("MCP_OK:" alone when `js` throws, as runJS answered "" for a page
// script that threw: the tab is proven, and the script must not run it again elsewhere);
// _TAB_UNPROVEN, before anything else runs, when the page carries neither field of `marker` and
// either the fingerprint did not prove the tab (`positional` false), or the page carries another
// session's marker, or its document is older than `since` less a second of clock slack.
// `stamp` true stamps a page that lacks __mcpTabMarker (a document a load just brought), "always"
// stamps it in any case.
function _provenTabJS(marker, js, { positional, stamp, since = 0 }) {
const m = String(marker).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const stampJS = stamp === "always" ? `${_buildStampJS(marker)};` : stamp ? `if(c!==M){${_buildStampJS(marker)};}` : "";
const born = positional ? `var t=0;try{t=performance.timeOrigin||performance.timing.navigationStart||0}catch(e){}` : "";
const older = positional ? `||!(t>=${Number(since) - 1000})` : "";
return `(function(P){var M='${m}',n='',c='',own=false;` +
`try{n=String(window.name);c=String(window.__mcpTabMarker);own=n===M||c===M}catch(e){}${born}` +
`if(!own&&(!P||n.indexOf('MCP_')===0||c.indexOf('MCP_')===0${older}))return '${_TAB_UNPROVEN}';` +
`${stampJS}try{return 'MCP_OK:'+String(${js})}catch(e){return 'MCP_OK:'}})(${positional ? 1 : 0})`;
}
// JavaScript for a double-quoted `do JavaScript "..."` literal, escaped as runJS escapes it.
function _doJSLiteral(js) {
return js
.replace(/^\s*\/\/[^\n]*$/gm, '') // Strip // comment-only lines before flattening
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, " ")
.replace(/\r/g, "")
.replace(/\t/g, " ");
}
function _tabUnprovenError(op) {
const err = new Error(
`Tab tracking lost — ${op} could not prove the session's tab: no tab of its window carries this session's ` +
`marker, and nothing else proves which tab it is (a tab was opened, closed or moved in that window, a load ` +
`had just cleared the marker, or the page runs no script). ${op} touched no tab after that. If it had already ` +
`loaded a URL, clicked, submitted or moved in history, that happened in the session's tab: check the result ` +
`before repeating it. Call safari_new_tab to open a fresh tab.`
);
err.tabUnproven = true;
return err;
}
// Run `js` in the tab a step proved, `tab` = { idx, win, marker, fp, since, op } (see _sessionTab),
// in one AppleScript that proves the tab again first, as described above, and answer its value.
// `tab.idx` follows the tab to wherever the proof found it, and `tab.fp` becomes the fingerprint read
// there. `markerOnly`: only the marker proves the tab (steps 1 and 2), for the scripts that run
// before the step's page loads, whose page still carries it; step 3 also needs a fingerprint an
// earlier script of the step read and the time the step started its load. `then(target)`:
// AppleScript run on the proven tab after `js`, as in `set URL of ${target} to "…"`. A session that
// never owned a tab has no marker: its step runs as before, in the tab it tracks or the front
// document.
async function _inTab(tab, js, { markerOnly = false, stamp = false, then = null, timeout = 5000, subprocess = false } = {}) {
const run = subprocess ? osascript : osascriptFast;
if (!tab.idx || !tab.win || !tab.marker) {
// A session that owns a tab gets all three from the scan that proved it; missing one, nothing
// proves the tab, and the old way (by index, or the front document) would guess.
if (_st().hasOwnedTab) throw _tabUnprovenError(tab.op);
if (then) {
const target = tab.idx ? `tab ${tab.idx} of ${tab.win || getTargetWindowRef()}` : getFallbackTarget();
return run(`tell application "Safari" to ${then(target)}`, { timeout });
}
return runJS(js, { tabIndex: tab.idx, win: tab.win, timeout });
}
// A script that runs before the step's load stamps the page it is about to leave, once the marker
// has proved it: a page the back/forward cache brings back later then carries __mcpTabMarker
// (window.name may not survive the restore), and its old document proves it where step 3 cannot.
const byMarker = _doJSLiteral(_provenTabJS(tab.marker, js, { positional: false, stamp: stamp || markerOnly }));
const positional = markerOnly || !tab.fp || !tab.since ? "" : ` else if not skipped then
set k to ${tab.idx}
considering case
if my mcpFp(w, ${tab.idx}) is "${String(tab.fp).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}" then set r to do JavaScript "${_doJSLiteral(_provenTabJS(tab.marker, js, { positional: true, stamp, since: tab.since }))}" in tab ${tab.idx} of w
end considering
`;
// The first `do JavaScript` goes to whatever tab sits at N: an error or a reply with no value
// there falls through to the search, as a refusal does, but a timeout ends the script within the
// call's own budget, so a script Node gave up on never goes on to act minutes later. Each check of
// the search is bounded, so a tab that does not answer (a pending dialog, a hung page) costs a
// second, not the script. A tab to the right of N that did not answer may be the session's tab
// moved there, which the fingerprint cannot see: then step 3 is not taken, and the script answers
// _TAB_BUSY, which the load polls retry.
const script = `${_FINGERPRINT_HANDLER}
tell application "Safari"
set w to ${tab.win}
set k to ${tab.idx}
set r to missing value
if k is less than or equal to (count of tabs of w) then
try
with timeout of ${Math.max(1, Math.ceil(timeout / 1000))} seconds
set r to (do JavaScript "${byMarker}" in tab k of w) as text
end timeout
on error errMsg number errNum
if errNum is -1712 then error errMsg number errNum
end try
end if
if r does not start with "MCP_OK:" then
set k to 0
set skipped to false
repeat with i from (count of tabs of w) to 1 by -1
try
with timeout of 1 second
if (do JavaScript "${_doJSLiteral(_markerCheckJS(tab.marker))}" in tab i of w) is "1" then
set k to i
exit repeat
end if
end timeout
on error number errNum
if errNum is -1712 and i > ${tab.idx} then set skipped to true
end try
end repeat
if k > 0 then
set r to do JavaScript "${byMarker}" in tab k of w
${positional} end if
if r does not start with "MCP_OK:" then
if skipped then return "${_TAB_BUSY}"
return "${_TAB_UNPROVEN}"
end if
end if
${then ? ` ${then("tab k of w")}\n` : ""} return (k as text) & linefeed & (my mcpFp(w, k)) & linefeed & r
end tell`;
const res = String(await run(script, { timeout }));
if (res === _TAB_BUSY) {
throw new Error(`${tab.op}: a tab of the session's window did not answer, so its tab cannot be proven yet; retry`);
}
// "<index>\n<fingerprint>\nMCP_OK:<value>", positively; anything else proves nothing.
const proven = /^(\d+)\n([^\n]*)\nMCP_OK:([\s\S]*)$/.exec(res);
if (!proven) throw _tabUnprovenError(tab.op);
tab.idx = Number(proven[1]);
tab.fp = proven[2];
return proven[3];
}
// Quick JS execution — exposed for smart-wait checks in index.js
export async function runJSQuick(js) { return runJS(js); }
// Run a SYNCHRONOUS function body in the page and return its raw result string. The body
// must `return JSON.stringify(...)` (AppleScript `do JavaScript` can't await). This is the
// single home for the `(function(){ … })()` wrapper that ~50 extractors hand-write; output
// is byte-identical to `runJS(\`(function(){BODY})()\`)` — it only removes the boilerplate.
function evalReturningJSON(body, opts) {
return runJS(`(function(){${body}})()`, opts);
}
// ========== FOCUS PRESERVATION ==========
// Safari AppleScript can steal focus (bring Safari window to front), especially
// on macOS Tahoe where window-mutation commands trigger an implicit activate.
// Strategy: 1) read frontmost from daemon (~0.1ms), 2) try to re-activate previous
// app, 3) settle 5ms (Tahoe needs time to honor the activate), 4) verify, and
// 5) fall back to hiding Safari if activate didn't take.
export async function saveFrontmostApp() {
const app = await _helperGetFrontApp();
return app?.bundleId || null;
}
export async function restoreFocusIfStolen(savedBundleId, opStartMs = Date.now() - 2500) {
// Show-intent agents (SAFARI_MCP_RAISE_ON_NAVIGATE=1) WANT Safari to keep
// the foreground after their actions — restoring would undo the very raise
// they asked for. The background-operation posture is unchanged by default.
if (RAISE_ON_NAVIGATE) { _traceRestore(savedBundleId, "skip-show-intent"); return; }
if (!savedBundleId || savedBundleId === "com.apple.Safari") return;
let current = await _helperGetFrontApp();
if (current?.bundleId !== "com.apple.Safari") return;
// GUARD FIRST — check the user BEFORE any activate. If they switched into Safari
// while the op was in flight, restoring the previous app rips Safari out from
// under them — the "VS Code keeps jumping in front every few seconds" bug.
// The OLD guard vetoed on ANY input in the last 2.5s, so a user typing in another
// app was read as "working in Safari" and the steal was left in place: Safari
// stayed in front and swallowed their keystrokes (most skip-user-active lines in
// restore-trace.log, 23.10.26). Typing can't switch apps; only a click or a
// modifier chord (Cmd+Tab, Spotlight) can. So veto only on those since the op began.
if (await _userSwitchedAppsSince(opStartMs)) { _traceRestore(savedBundleId, "skip-user-switched"); return; }
_traceRestore(savedBundleId, "restore");
// Bring previous app back. Await so the caller doesn't hand control back to
// user-space while Safari is still frontmost.
await _helperActivateApp(savedBundleId).catch(() => {});
// Settle window — NSRunningApplication.activate() is async at the OS level and
// reliably takes tens of ms to take effect. The old 5ms was far too short: the
// verify below almost always still saw Safari frontmost and wrongly fired the
// hide fallback, even though activate() was about to land. Give it a real
// chance before deciding activate "failed".
await new Promise(r => setTimeout(r, 120));
current = await _helperGetFrontApp();
if (current?.bundleId === "com.apple.Safari") {
// Safari still frontmost after activate. The OLD code HID Safari here — but
// hiding is destructive: it pulls the WHOLE app off-screen. It fired whenever
// the user had switched into Safari themselves while a background agent's op
// was in flight, and recurred even with an idle-guard because a user passively
// VIEWING Safari exceeds the idle threshold. Several autonomous background
// instances (Daily-RC → codex computer-use) share this one profile window, so
// the race was constant. NEVER hide. Non-destructive only: if the user is
// interacting, they own the foreground — leave it. Otherwise one more activate
// attempt, then give up and leave Safari frontmost (the user clicks back —
// vastly better than Safari vanishing).
if (await _userSwitchedAppsSince(opStartMs)) { _traceRestore(savedBundleId, "skip-user-switched-late"); return; }
await _helperActivateApp(savedBundleId).catch(() => {});
}
}
// Seconds since the user's last HID input (keyboard or mouse), via IOKit.
// `-r -d 1` roots the dump at IOHIDSystem and keeps it shallow (~13ms, ~4KB) —
// note: a plain `-d 1` measures depth from the registry root and never reaches
// IOHIDSystem, returning no HIDIdleTime. Returns Infinity on any failure so
// callers fail toward "user is idle" (preserving the pre-existing hide behavior).
async function _userIdleSeconds() {
try {
const { stdout } = await execFileAsync("/usr/sbin/ioreg", ["-c", "IOHIDSystem", "-r", "-d", "1"], { timeout: 1500 });
const m = stdout.match(/"HIDIdleTime"\s*=\s*(\d+)/);
if (!m) return Infinity;
return parseInt(m[1], 10) / 1e9; // nanoseconds → seconds
} catch { return Infinity; }
}
// True when the user interacted within the last ~2.5s — used to avoid hiding
// Safari out from under a user who just brought it to the front themselves.
async function _userIsActive() {
return (await _userIdleSeconds()) < 2.5;
}
// True when, since opStartMs, the user did something that can switch apps: a mouse
// button (click on a window or the Dock) or a modifier key (Cmd+Tab, Spotlight).
// Plain typing doesn't count. CGEventSourceSecondsSinceLastEventType via JXA
// (~50ms, only reached when Safari is already frontmost after an op). Event types:
// 1 leftMouseDown, 3 rightMouseDown, 25 otherMouseDown, 12 flagsChanged.
// On failure falls back to the old any-input guard.
// ponytail: a trackpad space-swipe into Safari isn't a click — it reads as "ours" and gets restored once.
async function _userSwitchedAppsSince(opStartMs) {
const windowSec = (Date.now() - opStartMs) / 1000 + 0.25;
try {
const { stdout } = await execFileAsync("/usr/bin/osascript", ["-l", "JavaScript", "-e",
'ObjC.import("CoreGraphics");var f=$.CGEventSourceSecondsSinceLastEventType;Math.min(f(1,1),f(1,3),f(1,25),f(1,12))'],
{ timeout: 1500 });
const ago = parseFloat(stdout);
if (!Number.isFinite(ago)) return _userIsActive();
return ago < windowSec;
} catch { return _userIsActive(); }
}
// Lightweight trace of every restore decision — confirms WHICH instance (pid)
// restored focus and whether the user-active guard vetoed it. Best-effort; never
// throws into the hot path. Tail ~/.safari-mcp/restore-trace.log to watch live.
// Lives under ~/.safari-mcp, NOT __dirname (#81) — the package dir is wiped on
// reinstall and read-only in container/CI setups; mutable state doesn't belong there.
const _RESTORE_TRACE = join(homedir(), ".safari-mcp", "restore-trace.log");
function _traceRestore(savedBundleId, decision) {
mkdir(dirname(_RESTORE_TRACE), { recursive: true })
.then(() => appendFile(_RESTORE_TRACE, `${new Date().toISOString()} pid=${process.pid} saved=${savedBundleId} -> ${decision}\n`))
.catch(() => {});
}
function _helperHideSafari(timeout = 2000) {
return _withHelperLock(() => new Promise((resolve) => {
if (!_helperProc || !_helperProc.stdin?.writable) { resolve(); return; }
let resolved = false;
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
_replaceHelperCb(cb, () => {});
resolve();
}
}, timeout);
function cb() {
if (resolved) return;
resolved = true;
clearTimeout(timer);
resolve();
}
_helperQueue.push(cb);
try { _helperProc.stdin.write(_helperLine({ hideSafari: true }, cb)); }
catch {
_dropHelperCb(cb);
clearTimeout(timer);
resolve();
}
}));
}
function _helperActivateApp(bundleId, timeout = 2000) {
return _withHelperLock(() => new Promise((resolve) => {
if (!_helperProc || !_helperProc.stdin?.writable) { resolve(); return; }
let resolved = false;
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
_replaceHelperCb(cb, () => {});
resolve();
}
}, timeout);
function cb() {
if (resolved) return;
resolved = true;
clearTimeout(timer);
resolve();
}
_helperQueue.push(cb);
try { _helperProc.stdin.write(_helperLine({ activateApp: bundleId }, cb)); }
catch {
_dropHelperCb(cb);
clearTimeout(timer);
resolve();
}
}));
}
export function setFocusGuard(active) { _focusGuardActive = active; }
export function getActiveTabIndex() { return _st().activeTabIndex; }
// Whether this session has opened or claimed a tab of its own (index.js asks before a write).
export function hasOwnedTab() { return _st().hasOwnedTab; }
export function setActiveTabIndex(idx) { _st().activeTabIndex = idx; }
export function getActiveTabURL() { return _st().activeTabURL; }
export function setActiveTabURL(url) { _st().activeTabURL = url; _st().lastResolveTime = Date.now(); }
export function getActiveTabMarker() { return _st().activeTabMarker; }
// A tab adopted from the user (#92) carries a marker of its own family, MCP_A<markerId>_. The session
// acts on it like one of its own tabs, and nothing closes it. The marker, not the URL, says the tab
// was adopted, so that holds wherever the tab navigates, and says nothing about another tab on the
// same URL. markerId is lowercase hex, so no session's own prefix, MCP_<markerId>_, starts with MCP_A.
function _adoptedMarkerPrefix() { return `MCP_A${_st().markerId}_`; }
export function isActiveTabAdopted() { return String(_st().activeTabMarker || "").startsWith(_adoptedMarkerPrefix()); }
// index.js calls this when the Safari extension, not this module, opened or picked the
// session's tab. The index the extension reports is a position in the extension's window,
// which need not be AppleScript's front window, and a tab the user closes or drags moves
// another tab (theirs) into that position. It proves nothing, so the session tracks no index
// until resolveActiveTab() finds the marker the extension puts on the tab. The URL stays for
// index.js's ownership checks; one the extension did not report stays unknown rather than
// naming the previous tab. The old marker has to go: it names the last tab AppleScript opened
// or claimed. The session owns a tab from here on, as it does after newTab(): once it cannot
// prove where this one is, the fallbacks refuse instead of running in the front document.
export function setActiveTabFromExtension(_reportedIndex, url) {
const s = _st();
s.activeTabIndex = null;
s.activeTabURL = url || null;
s.activeTabMarker = null;
s.marking = null;
s.hasOwnedTab = true;
s.tabFromExtension = true;
s.lastResolveTime = Date.now();
}
// How index.js has the Safari extension put a marker on the tab it opened or picked:
// `(marker) => Promise<boolean>`. The extension knows that tab by its id and AppleScript only
// by its position, so this is the only way such a tab can be proven. Unset, it stays unproven.
let _markTabViaExtension = null;
export function setExtensionTabMarker(fn) { _markTabViaExtension = fn; }
// `window id N` for the window id an AppleScript reported, or null when it reported none.
// A tab's index is a position in one window, so each later script that acts on an index a script
// proved addresses that window by its id. 'front window' names whichever window is in front when
// each script runs: once the user brought another window forward, the index named their tab.
function _windowById(id) {
const n = Number(id);
return Number.isInteger(n) && n > 0 ? `window id ${n}` : null;
}
// Page JavaScript that answers "1" when the page carries exactly `marker`: in window.name, or in
// window.__mcpTabMarker once a page has taken window.name over.
function _markerCheckJS(marker) {
const safeMarker = String(marker).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
return `(function(){try{return (window.name==='${safeMarker}'||window.__mcpTabMarker==='${safeMarker}')?'1':'0'}catch(e){return '0'}})()`;
}
// Scan the target window (or `inWin`, a `window id N`) for the tab carrying `marker`, trying tab
// `hint` first. Returns { idx, win }: that tab's index, 0 when the scan completed and no tab
// carries the marker, and the window scanned (see _windowById); or null when the scan could not
// be completed.
async function _scanForMarker(marker, hint, inWin = null) {
try {
const check = _markerCheckJS(marker);
// One AppleScript call loops every tab internally: faster and far more reliable than N
// separate daemon round-trips (a daemon hiccup mid-scan used to mis-resolve to the user's tab).
const scanScript = `tell application "Safari"
set w to ${inWin || getTargetWindowRef()}
set wid to (id of w) as text
set n to count of tabs of w
${hint ? `try
if n is greater than or equal to ${hint} then
if (do JavaScript "${check}" in tab ${hint} of w) is "1" then return wid & ":${hint}"
end if
end try` : ''}
repeat with i from n to 1 by -1
try
if (do JavaScript "${check}" in tab i of w) is "1" then return wid & ":" & i
end try
end repeat
return wid & ":0"
end tell`;
// Fast daemon first; a hiccup retries once through the reliable subprocess.
let res = await osascriptFast(scanScript).catch(() => null);
if (res === null) res = await osascript(scanScript).catch(() => null);
if (res === null) return null;
// "<window id>:<index>". An answer without the window it scanned proves nothing: the index
// would name a tab of whichever window is in front for the next script.
const m = /^(\d+):(\d+)$/.exec(String(res).trim());
const win = m && _windowById(m[1]);
return win ? { idx: Number(m[2]), win } : null;
} catch {
return null; // the target window is gone (a named profile's window closed)
}
}
// Find the tab carrying an EXACT identity marker. Returns its index, or null when the
// marker is on no tab — or when the scan itself could not be completed.
//
// resolveActiveTab() resolves *this session's current* tab, and can have the extension mark it
// again; this one takes the marker as an argument and has no fallback. A tab recorded minutes
// earlier has shifted its index and may have navigated, so a URL match can land on a tab the
// USER opened on the same URL (#112, the same principle as #68). An index it returns is proof
// only for the script that found it: a close goes through closeTabByMarker(), which finds the
// tab in the same script.
export async function findTabByMarker(marker) {
if (!marker) return null;
return (await _scanForMarker(marker))?.idx || null;
}
// The window safari_wait_for_new_tab watches when AppleScript lists it: the one this session's tab
// is in, proven by its marker in the target window as every step proves it, or null for a session
// that has no tab of its own yet. The window in front was the user's whenever theirs was in front:
// the wait missed the popup the session's page opened and claimed a tab the user opened there. So a
// session whose tab is not proven there is refused, and no scan here drops its marker.
export async function sessionTabWindow() {
const s = _st();
if (!s.hasOwnedTab) return null;
if (!s.activeTabMarker) {
throw new Error(
"Tab safety: AppleScript has no marker to find this session's tab by (the Safari extension opened it and has " +
"not marked it, or this session lost track of it), so it cannot tell which window to watch for the new tab. " +
"Check the extension with safari_doctor, re-anchor with safari_list_tabs and safari_switch_tab, or open a tab with safari_new_tab."
);
}
const found = await _scanForMarker(s.activeTabMarker, s.activeTabIndex);
if (found?.idx) return found.win;
if (!found) {
// The scan answers null for every AppleScript error: name the one no retry gets past.
if (!(await isSafariRunning())) throw safariNotRunningError();
throw new Error("Tab safety: Safari did not finish the scan for this session's tab (busy, or its window gone), so this wait claimed nothing.");
}
throw new Error(
"Tab safety: this session's tab is not in the Safari window in front, so AppleScript cannot tell which window to " +
"watch for the new tab, and watching the one in front could claim a tab of yours. Check the Safari extension with " +
"safari_doctor; without it, the window with this session's tab has to be in front (if it is, re-anchor with " +
"safari_switch_tab or open a tab with safari_new_tab)."
);
}
// Page JavaScript that is true when the page carries a marker starting with `prefix`, in window.name
// or, once a page has taken window.name over, in window.__mcpTabMarker. Every marker the session
// stamps starts with its markerId: MCP_<markerId>_ on a tab it opened, on one it switched to, and on
// one the extension marked for it (resolveActiveTab), and MCP_A<markerId>_ on one it adopted. A switch
// by index has no other proof that the tab is the session's: an index names whatever tab sits there,
// and the user can have the same URL open.
function _markerPrefixTestJS(prefix) {
return `(function(p){try{return String(window.name).indexOf(p)===0||String(window.__mcpTabMarker).indexOf(p)===0}catch(e){return false}})('${prefix}')`;
}
// Page JavaScript that evaluates to the marker starting with `prefix` that the page carries, window.name's
// before window.__mcpTabMarker's, or to '' when it carries none. Only a whole marker counts, letters,
// digits and `_` as every marker is minted: a page can write anything after the prefix, a quote
// included, and a marker kept from here goes into the AppleScript source of every later scan.
function _markerWithPrefixJS(prefix) {
return `(function(p){function m(v){v=String(v);return v.indexOf(p)===0&&/^[A-Za-z0-9_]+$/.test(v)?v:''}try{return m(window.name)||m(window.__mcpTabMarker)}catch(e){return ''}})('${prefix}')`;
}
// Page JavaScript that is true when the page carries a marker of another MCP session: one starting
// with MCP_ but with neither of this session's prefixes, `own` (MCP_<markerId>_) and `adopted`
// (MCP_A<markerId>_). Another client of the HTTP daemon, or a server before a restart, stamped it.
function _otherSessionMarkerTestJS(own, adopted) {
return `(function(p,q){function t(v){v=String(v);return v.indexOf('MCP_')===0&&v.indexOf(p)!==0&&v.indexOf(q)!==0}try{return t(window.name)||t(window.__mcpTabMarker)}catch(e){return false}})('${own}','${adopted}')`;
}
// The index of this session's tab, or null. See _resolveSessionTab().
async function resolveActiveTab() {
return (await _resolveSessionTab()).idx;
}
// This session's tab as { idx, win }: its index, or null, and the window the marker scan found it
// in (see _windowById), which a caller that acts on the index in a later script addresses.
//
// A session that has owned a tab acts only on a tab it can prove is its own, and the proof is its
// identity marker (window.name, or window.__mcpTabMarker after SPA routing), found on the tab now.
// Nothing else counts. An index is a position: a tab the user closes or drags, or another front
// window, puts one of their tabs there. A URL can be open in the user's tabs too: a prefix match
// on a site root, or the domain anywhere in their URL, picked those, and nothing checked the tab
// again when the script ran. A tab the extension opened or picked starts with no marker, so the
// extension puts one on it when AppleScript first needs the tab, and again once a cross-site load
// has cleared window.name. With no proof this returns null, and the callers refuse rather than run
// in the front document.
//
// A session that never owned a tab keeps what it tracks, which in the default mode is nothing:
// null means the front document, the page the user is looking at.
// `elsewhere`, when given, answers the other windows (`window id N`) the tab may be in, asked
// only once the target window has no tab carrying the marker. A named profile can hold several
// windows and the target window is only the first of them, while the extension opens or picks
// the session's tab in any of them. The marker proves the tab in whichever window it is found.
async function _resolveSessionTab({ elsewhere = null } = {}) {
const s = _st();
if (s.hasOwnedTab) {
let marked = false;
for (;;) {
if (!s.activeTabMarker && s.tabFromExtension && _markTabViaExtension && !marked) {
marked = true;
// Parallel calls share one request: a second marker would replace the first on the tab
// while the first call still scans for it. If the session moves to another tab meanwhile,
// the request is dropped, so the previous tab's marker never becomes the current one's.
if (!s.marking) {
const marker = `MCP_${s.markerId}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
const marking = _markTabViaExtension(marker).catch(() => false).then((ok) => {
if (s.marking !== marking) return;
s.marking = null;
if (ok && s.tabFromExtension) s.activeTabMarker = marker;
});
s.marking = marking;
}
await s.marking;
}
if (!s.activeTabMarker) break;
// The marker this scan looks for proves the tab, whatever a parallel call of the session
// makes of s.activeTabMarker while the scan runs.
const marker = s.activeTabMarker;
let found = await _scanForMarker(marker, s.activeTabIndex);
if (found && !found.idx && elsewhere) {
for (const win of await elsewhere()) {
const there = win !== found.win && await _scanForMarker(marker, s.activeTabIndex, win);
if (there?.idx) { found = there; break; }
}
}
if (found?.idx) { s.activeTabIndex = found.idx; return { ...found, marker }; }
// The scan did not complete: keep the marker for the next call, but prove nothing now.
if (!found) break;
// No tab carries it: the tab closed, a cross-site load or the page itself replaced
// window.name, or it is in another window. Only the extension can mark its tab again.
if (s.activeTabMarker === marker) s.activeTabMarker = null;
}
console.error("[Safari MCP] This session's tab is not proven (no tab in the window carries its marker) — refusing to act on a tab by position or URL");
s.activeTabIndex = null;
return { idx: null, win: null };
}
return { idx: await _resolveTrackedTab(s), win: null };
}
// The index a session that never owned a tab tracks, found again by its URL in the target window.
async function _resolveTrackedTab(s) {
if (!s.activeTabURL) return s.activeTabIndex;
try {
const safeUrl = _st().activeTabURL.replace(/"/g, '\\"');
const domain = _st().activeTabURL.replace(/^https?:\/\//, '').split('/')[0].replace(/"/g, '\\"');
// Single AppleScript call: verify current index, then search by URL, then by domain
// Also returns tabCount so we can clamp stale indices
const result = await osascriptFast(
`tell application "Safari"
set w to ${getTargetWindowRef()}
set tabCount to count of tabs of w
${_st().activeTabIndex ? `try
if tabCount >= ${_st().activeTabIndex} then
if URL of tab ${_st().activeTabIndex} of w starts with "${safeUrl}" then return ${_st().activeTabIndex}
end if
end try` : ''}
repeat with i from tabCount to 1 by -1
if URL of tab i of w starts with "${safeUrl}" then return i
end repeat
repeat with i from tabCount to 1 by -1
if URL of tab i of w contains "${domain}" then return -(i)
end repeat
return "0:" & tabCount
end tell`
);
// Parse result — can be "N" (found) or "0:tabCount" (not found)
const resultStr = String(result);
if (resultStr.includes(':')) {
// Not found.
const tabCount = Number(resultStr.split(':')[1]) || 1;
_st().lastTabCount = tabCount;
_st().activeTabURL = null;
if (_st().activeTabIndex && _st().activeTabIndex > tabCount) {
// Our index is past the end of the window: the user closed a tab or tore one
// into its own window, so the index no longer names any tab — least of all ours.
// This used to clamp to `tabCount` ("tab ghost proactive fix"), which silently
// retargeted us at the LAST tab in the window — the user's. That predates the
// identity marker (clamp: Mar 31, marker: v2.8.3 Apr 14) and guessed. Fail closed;
// callers re-anchor via _assertNotFallingBackToUserTab's "re-run safari_new_tab" error.
console.error(`[Safari MCP] Tab identity lost (index ${_st().activeTabIndex} > tabCount ${tabCount}) — clearing index to avoid targeting the user's tab`);
_st().activeTabIndex = null;
return null;
}
return _st().activeTabIndex;
}
const num = Number(result);
if (num > 0) {
_st().activeTabIndex = num;
return num;
}
if (num < 0) {
// Domain match (negative = partial match)
_st().activeTabIndex = -num;
return -num;
}
_st().activeTabURL = null;
return _st().activeTabIndex;
} catch {
return _st().activeTabIndex;
}
}
// ========== FAST OSASCRIPT VIA TEMP FILE ==========
// osascript -i persistent process doesn't work reliably with pipes.
// Instead, we use execFile for every call (~80ms each).
// Optimization: for runJS we write to temp file and execute (avoids arg escaping).
// Run AppleScript — uses execFile (safe, isolated, for complex scripts)
async function osascript(script, { timeout = 10000 } = {}) {
if (!(await isSafariRunning())) throw safariNotRunningError();
// Save frontmost app via daemon BEFORE subprocess (~0.1ms)
// Skip if an outer caller (extensionOrFallback) already handles focus
const shouldGuardFocus = !_focusGuardActive;
const opStart = Date.now();
const frontApp = shouldGuardFocus ? await _helperGetFrontApp() : null;
try {
const { stdout } = await execFileAsync("osascript", ["-e", script], {
timeout,
maxBuffer: 10 * 1024 * 1024,
});
return stdout.trim();
} catch (err) {
// Retry once if the window ID became stale (window reopened/changed)
if (isStaleWindowError(err) && SAFARI_PROFILE) {
const oldRef = _targetWindowRef;
await refreshTargetWindow(true);
if (_targetWindowRef !== oldRef) {
const retryScript = script.replace(new RegExp(oldRef.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), _targetWindowRef);
const { stdout } = await execFileAsync("osascript", ["-e", retryScript], { timeout, maxBuffer: 10 * 1024 * 1024 });
return stdout.trim();
}
}
throw new Error(`AppleScript error: ${err.stderr || err.message}`);
} finally {
// Awaited restore — caller must not return to user-space while Safari is still frontmost.
if (shouldGuardFocus && frontApp?.bundleId && frontApp.bundleId !== 'com.apple.Safari') {
await restoreFocusIfStolen(frontApp.bundleId, opStart).catch(() => {});
}
}
}
// osascriptFast: uses persistent Swift daemon (~5ms) — 18x faster than subprocess (~90ms)
async function osascriptFast(script, { timeout = 10000, noFocusGuard = false } = {}) {
if (!(await isSafariRunning())) throw safariNotRunningError();
if (!_helperProc) startHelper();
// Focus guard — Tahoe AppleScript can implicitly activate Safari (especially
// on window-mutating commands: set URL / set bounds / set current tab).
// Skip if an outer caller (extensionOrFallback / runJSLarge / osascript)
// already handles focus, or if the caller knows the script is read-only and
// not worth the round-trip overhead (e.g. background polling every 3s).
const shouldGuardFocus = !_focusGuardActive && !noFocusGuard;
const opStart = Date.now();
const frontApp = shouldGuardFocus ? await _helperGetFrontApp() : null;
try {
if (_helperProc) {
try {
return await _osascriptFastHelper(script, timeout);
} catch (err) {
// Retry once if the window ID became stale
if (isStaleWindowError(err) && SAFARI_PROFILE) {
const oldRef = _targetWindowRef;
await refreshTargetWindow(true);
if (_targetWindowRef !== oldRef) {
const retryScript = script.replace(new RegExp(oldRef.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), _targetWindowRef);
// Guard: helper may have died during the stale-window retry
if (!_helperProc) startHelper();
if (_helperProc) return await _osascriptFastHelper(retryScript, timeout);
return await osascript(retryScript, { timeout });
}
}
throw err;
}
}
return await osascript(script, { timeout });
} finally {
if (shouldGuardFocus && frontApp?.bundleId && frontApp.bundleId !== "com.apple.Safari") {
await restoreFocusIfStolen(frontApp.bundleId, opStart).catch(() => {});
}
}
}
function _osascriptFastHelper(script, timeout) {
return _withHelperLock(() => new Promise((resolve, reject) => {
let resolved = false;
const timer = setTimeout(() => {
if (resolved) return;
resolved = true;
// DON'T remove from queue — replace with a proof-of-life consumer to maintain
// FIFO order. A heavy page (e.g. the Airtable SPA) makes `do JavaScript` legitimately
// slow: the call exceeds `timeout`ms, but the helper IS alive and emits its response a
// moment later. That late response proves liveness — so when it arrives we reset the
// consecutive-timeout counter instead of discarding the signal. Killing the daemon on a
// slow page is pointless (the fresh daemon hits the same slow page) and disruptive.
// Only a helper that NEVER replies (truly hung) accumulates timeouts with no late reply.
_replaceHelperCb(cb, () => { _helperConsecutiveTimeouts = 0; }); // late reply ⇒ alive
_helperConsecutiveTimeouts++;
if (_helperConsecutiveTimeouts >= 5) {
console.error(`[Safari MCP] safari-helper: ${_helperConsecutiveTimeouts} consecutive timeouts with no late replies — killing daemon`);
_helperProc?.kill();
_helperProc = null;
_helperConsecutiveTimeouts = 0;
// The killed proc's 'exit' handler also schedules a restart; guard the timer so only
// one respawn wins (startHelper is now idempotent too) — no second, orphaned daemon.
setTimeout(() => { if (!_shuttingDown && !_helperProc) startHelper(); }, 100);
}
reject(new Error("safari-helper timeout"));
}, timeout);
function cb(line) {
if (resolved) return;
resolved = true;
clearTimeout(timer);
_helperConsecutiveTimeouts = 0;
try {
const parsed = JSON.parse(line);
if (parsed.error) reject(new Error(parsed.error));
else resolve(parsed.result ?? "");
} catch {
resolve(line);
}
}
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
clearTimeout(timer);
reject(new Error("safari-helper not available"));
return;
}
_helperQueue.push(cb);
try {
_helperProc.stdin.write(_helperLine({ script }, cb));
} catch (writeErr) {
_dropHelperCb(cb);
clearTimeout(timer);
reject(new Error("safari-helper write failed: " + writeErr.message));
}
}));
}
// Ask the helper for its permission state (CGEvent posting + screen capture) without
// acting. Resolves the FULL parsed object ({accessibility, screenRecording}); doubles as
// a daemon liveness probe. Used by doctor() (issue #29/#14/#15).
function _helperPreflight(timeout = 3000) {
return _withHelperLock(() => new Promise((resolve, reject) => {
if (!_helperProc) startHelper();
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
reject(new Error("safari-helper not available"));
return;
}
let resolved = false;
const timer = setTimeout(() => {
if (resolved) return;
resolved = true;
_replaceHelperCb(cb, () => {}); // no-op consumer for a late reply
reject(new Error("preflight timeout"));
}, timeout);
function cb(line) {
if (resolved) return;
resolved = true;
clearTimeout(timer);
try { resolve(JSON.parse(line)); }
catch { reject(new Error("unparseable preflight reply")); }
}
_helperQueue.push(cb);
try {
_helperProc.stdin.write(_helperLine({ preflight: true }, cb));
} catch (writeErr) {
_dropHelperCb(cb);
clearTimeout(timer);
reject(new Error("preflight write failed: " + writeErr.message));
}
}));
}
// ========== SCREEN LOCK GATE ==========
// A locked screen (the lid shut on an awake Mac, or the lock screen up) routes every OS-level
// event to loginwindow: CGEvent clicks and keys land on the lock screen — keystrokes even in its
// password field — while the native tools used to report "clicked"/"typed". In-page tools keep
// working while locked, so every native path fails fast here and points there instead.
export const SCREEN_LOCKED_MSG =
"SCREEN_LOCKED: the Mac's screen is locked, so OS-level (native) input would land on the lock screen " +
"instead of Safari. In-page tools still work while locked: use safari_click / safari_fill / " +
"safari_type_text / safari_press_key, and safari_upload_file without forceNative.";
export async function isScreenLocked() {
try {
const { stdout } = await execFileAsync("/usr/sbin/ioreg", ["-n", "Root", "-d1", "-r", "-a"], { timeout: 2000 });
const matches = [...String(stdout).matchAll(/<key>\s*(?:CGSSessionScreenIsLocked|IOConsoleLocked)\s*<\/key>\s*<(true|false)\s*\/>/g)];
if (matches.length) return matches.some(match => match[1] === "true");
// ioreg omits the key on an unlocked session. A present but unreadable value or
// a broken probe must fail toward blocked input, never toward CGEvent.
return /<key>\s*(?:CGSSessionScreenIsLocked|IOConsoleLocked)\s*<\/key>/.test(String(stdout));
} catch {
return true;
}
}
export async function assertScreenUnlocked(probe = isScreenLocked) {
if (await probe()) throw new Error(SCREEN_LOCKED_MSG);
}
// ========== NATIVE CLICK VIA CGEVENT ==========
// Sends a CGEvent click command to the Swift helper daemon.
// This produces isTrusted: true events — bypasses WAF protection (G2, etc.)
// Guarded at the root: a CGEvent hits the window's SELECTED tab, so every caller — not just
// nativeClick — must have our tab selected first. _withTargetTabFronted is re-entrant, so the
// public native* wrappers (which front the tab before measuring coordinates) cost nothing extra.
function _helperNativeClick(x, y, doubleClick = false, windowId = 0, timeout = 5000) {
return _withTargetTabFronted(() => _helperNativeClickRaw(x, y, doubleClick, windowId, timeout));
}
async function _helperNativeClickRaw(x, y, doubleClick = false, windowId = 0, timeout = 5000) {
await assertScreenUnlocked();
return _withHelperLock(() => new Promise((resolve, reject) => {
if (!_helperProc) startHelper();
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
reject(new Error("safari-helper not available for native click"));
return;
}
let resolved = false;
const timer = setTimeout(() => {
if (resolved) return;
resolved = true;
_replaceHelperCb(cb, () => {}); // No-op consumer for late response
reject(new Error("native click timeout"));
}, timeout);
function cb(line) {
if (resolved) return;
resolved = true;
clearTimeout(timer);
try {
const parsed = JSON.parse(line);
if (parsed.error) reject(new Error(parsed.error));
else resolve(parsed.result ?? "");
} catch {
resolve(line);
}
}
_helperQueue.push(cb);
const cmd = { click: { x, y } };
if (doubleClick) cmd.click.double = true;
if (windowId) cmd.click.windowId = windowId;
try {
_helperProc.stdin.write(_helperLine(cmd, cb));
} catch (e) {
// EPIPE if the daemon died in the gap after the writable check — splice our callback
// out so it can't consume the NEXT command's response (FIFO desync), then reject.
_dropHelperCb(cb);
clearTimeout(timer);
resolved = true;
reject(e);
}
}));
}
// Sends a CGEvent hover command to the Swift helper daemon.
// Moves the cursor to (x, y), dwells to let tooltips render, optionally restores cursor.
async function _helperNativeHover(x, y, windowId = 0, dwellMs = 500, restoreMouse = true, timeout = 10000) {
await assertScreenUnlocked();
return _withHelperLock(() => new Promise((resolve, reject) => {
if (!_helperProc) startHelper();
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
reject(new Error("safari-helper not available for native hover"));
return;
}
let resolved = false;
const timer = setTimeout(() => {
if (resolved) return;
resolved = true;
_replaceHelperCb(cb, () => {});
reject(new Error("native hover timeout"));
}, timeout);
function cb(line) {
if (resolved) return;
resolved = true;
clearTimeout(timer);
try {
const parsed = JSON.parse(line);
if (parsed.error) reject(new Error(parsed.error));
else resolve(parsed.result ?? "");
} catch {
resolve(line);
}
}
_helperQueue.push(cb);
const cmd = { hover: { x, y, dwellMs, restoreMouse } };
if (windowId) cmd.hover.windowId = windowId;
try {
_helperProc.stdin.write(_helperLine(cmd, cb));
} catch (e) {
// EPIPE if the daemon died in the gap after the writable check — splice our callback
// out so it can't consume the NEXT command's response (FIFO desync), then reject.
_dropHelperCb(cb);
clearTimeout(timer);
resolved = true;
reject(e);
}
}));
}
// Sends a CGEvent keyboard command to the Swift helper daemon.
// No focus stealing — sends key events directly to the target window via PID.
function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000) {
return _withTargetTabFronted(() => _helperNativeKeyboardRaw(keyCode, flags, windowId, timeout));
}
async function _helperNativeKeyboardRaw(keyCode, flags = [], windowId = 0, timeout = 5000) {
await assertScreenUnlocked();
return _withHelperLock(() => new Promise((resolve, reject) => {
if (!_helperProc) startHelper();
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
reject(new Error("safari-helper not available for native keyboard"));
return;
}
let resolved = false;
const timer = setTimeout(() => {
if (resolved) return;
resolved = true;
_replaceHelperCb(cb, () => {}); // No-op consumer for late response
reject(new Error("native keyboard timeout"));
}, timeout);
function cb(line) {
if (resolved) return;
resolved = true;
clearTimeout(timer);
try {
const parsed = JSON.parse(line);
if (parsed.error) reject(new Error(parsed.error));
else resolve(parsed.result ?? "");
} catch {
resolve(line);
}
}
_helperQueue.push(cb);
const cmd = { keyboard: { keyCode, flags } };
if (windowId) cmd.keyboard.windowId = windowId;
try {
_helperProc.stdin.write(_helperLine(cmd, cb));
} catch (e) {
// EPIPE if the daemon died in the gap after the writable check — splice our callback
// out so it can't consume the NEXT command's response (FIFO desync), then reject.
_dropHelperCb(cb);
clearTimeout(timer);
resolved = true;
reject(e);
}
}));
}
// ========== NATIVE FOCUS OPERATIONS VIA DAEMON ==========
// Uses NSRunningApplication — ~0.1ms for get, ~1ms for activate (vs ~90ms AppleScript)
function _helperGetFrontApp(timeout = 2000) {
return _withHelperLock(() => new Promise((resolve) => {
if (!_helperProc || !_helperProc.stdin?.writable) { resolve(null); return; }
let resolved = false;
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
_replaceHelperCb(cb, () => {});
resolve(null);
}
}, timeout);
function cb(line) {
if (resolved) return;
resolved = true;
clearTimeout(timer);
try { resolve(JSON.parse(line)); } catch { resolve(null); }
}
_helperQueue.push(cb);
try { _helperProc.stdin.write(_helperLine({ getFrontApp: true }, cb)); }
catch {
_dropHelperCb(cb);
clearTimeout(timer);
resolve(null);
}
}));
}
// Get Safari window bounds, toolbar height, and window ID for coordinate calculation
async function _getSafariWindowGeometry() {
// A locus-pinned native click measures the window the session's tab actually lives
// in — not the first window matching the profile name, which with several profile
// windows open produced coordinates for one window and a click in another.
if (_pinnedWindowOverride) {
const windowRef = _pinnedWindowOverride.winRef;
const boundsResult = await osascriptFast(
`tell application "Safari"\n set b to bounds of ${windowRef}\n set wid to id of ${windowRef}\n return (item 1 of b as text) & "," & (item 2 of b as text) & "," & (item 3 of b as text) & "," & (item 4 of b as text) & "," & (wid as text)\nend tell`
);
const parts = boundsResult.split(",").map(s => Number(s.trim()));
if (parts.length === 5 && !parts.some(isNaN)) {
let toolbarHeight = 74;
try {
const chromeStr = await runJS(`(window.outerHeight - window.innerHeight) + ''`);
const chrome = Number(chromeStr);
if (Number.isFinite(chrome) && chrome >= 50 && chrome <= 200) toolbarHeight = chrome;
} catch (_e) { /* keep fallback */ }
return {
windowX: parts[0], windowY: parts[1], windowRight: parts[2], windowBottom: parts[3],
toolbarHeight, windowId: parts[4],
};
}
// fall through to the name-based path if the pinned window vanished mid-call
}
await refreshTargetWindow();
const windowRef = getTargetWindowRef();
// Get window bounds + window ID via the helper daemon. The daemon is TCC-granted
// under safari-helper's STABLE path, so this never falls back to osascript-under-claude
// — which re-prompts for Apple Events on every claude version bump (the binary lives in
// a version-numbered folder). The script returns a plain comma-joined string (not a list),
// which the daemon's stringValue handles fine. osascriptFast itself falls back to a fresh
// osascript subprocess only if the daemon is dead — a rare hiccup, not the steady state.
const boundsResult = await osascriptFast(
`tell application "Safari"\n set b to bounds of ${windowRef}\n set wid to id of ${windowRef}\n return (item 1 of b as text) & "," & (item 2 of b as text) & "," & (item 3 of b as text) & "," & (item 4 of b as text) & "," & (wid as text)\nend tell`
);
// boundsResult = "x1, y1, x2, y2, windowId"
const parts = boundsResult.split(",").map(s => Number(s.trim()));
if (parts.length !== 5 || parts.some(isNaN)) {
throw new Error("Failed to parse Safari window geometry: " + boundsResult);
}
// Dynamic toolbar height: outerHeight - innerHeight gives total chrome above content
// (title bar + URL bar + tab strip + optional bookmarks bar). Hardcoded 74 was wrong
// for modern Safari (Sequoia+) where chrome is ~90px. Fall back to 74 if JS unreachable.
let toolbarHeight = 74;
try {
const chromeStr = await runJS(`(window.outerHeight - window.innerHeight) + ''`);
const chrome = Number(chromeStr);
if (Number.isFinite(chrome) && chrome >= 50 && chrome <= 200) toolbarHeight = chrome;
} catch (_e) { /* keep fallback */ }
return {
windowX: parts[0],
windowY: parts[1],
windowRight: parts[2],
windowBottom: parts[3],
toolbarHeight,
// CGWindow ID for background click targeting (no mouse move, no focus steal)
windowId: parts[4]
};
}
// ========== NATIVE-EVENT TAB GUARD ==========
// A CGEvent is delivered to a WINDOW, and the window routes it to whichever tab is
// SELECTED — there is no per-tab CGEvent target. runJS does not have this problem: it
// reaches background tabs through AppleScript. So every native_* op has a split brain —
// coordinates measured against OUR tab, event delivered to the USER'S tab. A native click
// aimed at our tab 8 landed on the user's tab 4 (facebook.com/groups) instead: silently
// wrong, and an OS-level click on a tab we never owned.
//
// Fix: select the target tab for the duration of the event, then put the user's tab back.
// `set current tab` switches within the window only — it never calls `activate`, so Safari
// is not raised and foreground focus is not stolen. If we own no tab, or already hold the
// selection, this is a no-op and no switch happens.
const NATIVE_TAB_SETTLE_MS = 120; // let the selected tab paint before the event lands
// Re-entrancy: the guard sits on both the public native* fns (so coordinates are measured
// against a fronted tab) and the low-level _helperNative* calls (so no caller can slip past
// it). A multi-event op like replaceEditorContent — Cmd+A then Cmd+V — must not flip tabs
// between keystrokes, so nested calls run inside the selection the outermost one took.
let _tabFrontedDepth = 0;
async function _withTargetTabFronted(fn) {
if (_tabFrontedDepth > 0) return await fn(); // already inside a fronted section
// Only a tab this session can prove is its own gets the event. Without that proof the event
// would land in whatever tab is selected — the user's.
const idx = _st().hasOwnedTab ? await resolveActiveTab() : _st().activeTabIndex;
if (!idx && _st().hasOwnedTab) {
throw new Error("Tab tracking lost — refusing to act in the selected tab (native input, screenshot): it is the user's. Call safari_new_tab to reopen.");
}
if (!idx) return await fn(); // no owned tab — nothing to front
const winRef = getTargetWindowRef();
let prev;
try {
prev = Number(
await osascriptFast(`tell application "Safari" to tell ${winRef} to return (index of current tab) as text`)
);
} catch (_e) {
// Can't read the selection — switching blind risks stranding the user on our tab.
// Deliver the event as-is rather than leave the selection somewhere they didn't put it.
return await fn();
}
if (!Number.isFinite(prev)) return await fn();
// Already selected? Then no switch and no restore — but still mark the section, so nested
// _helperNative* calls skip re-checking the selection on every keystroke.
const mustSwitch = prev !== idx;
if (mustSwitch) {
await osascriptFast(`tell application "Safari" to tell ${winRef} to set current tab to tab ${idx}`);
await new Promise((r) => setTimeout(r, NATIVE_TAB_SETTLE_MS));
}
_tabFrontedDepth++;
try {
return await fn();
} finally {
_tabFrontedDepth--;
// Always hand the user's tab back — even if the event threw.
if (mustSwitch) {
try {
await osascriptFast(`tell application "Safari" to tell ${winRef} to set current tab to tab ${prev}`);
} catch (_e) { /* restore is best-effort; never mask the real result */ }
}
}
}
// Atomic tab-identity guard, as a JS prefix. Belongs here and not at a call site: it is the
// only check that is independent of how `idx` was resolved, so every path that builds a
// targeted `do JavaScript` needs it — most of all the retry paths, which run precisely when
// the index has already proven untrustworthy. Empty only when the caller named a tab
// explicitly. See #64.
function _tabIdentityGuard(explicitTabIndex) {
if (explicitTabIndex) return '';
const marker = _st().activeTabMarker;
// No marker → this session owns no tab, so the target is the front document: the tab the
// user is looking at. That fallback is intentional for a session that genuinely never
// opened one — but "owns no tab" is also what a *re-initialised* session reports. In
// HTTP-daemon mode a dropped transport makes the client re-init, which mints a new
// MCP session id, and `_st()` hands it a fresh empty state: `hasOwnedTab` false,
// `activeTabMarker` null. Every fail-closed branch keys on `hasOwnedTab`, so they all read
// false at exactly the moment an agent is mid-task and already owns a tab — and the next
// op runs, unguarded, on the user's current page.
//
// The session state died; the marker on the tab did not. A front document carrying any
// `MCP_` marker is a tab some MCP session opened and this one does not own, so it is
// never a legitimate implicit target — refuse it. An unmarked front document stays
// reachable, which keeps "read the page I'm looking at" working for real fresh sessions.
if (!marker) {
return `if(typeof window.name==='string'&&window.name.indexOf('MCP_')===0){throw new Error('MCP_FOREIGN_TAB')};`;
}
return `if(window.name!=='${marker}'&&window.__mcpTabMarker!=='${marker}'){throw new Error('MCP_WRONG_TAB')};`;
}
// A tripped foreign-tab guard is terminal: with no marker of our own there is nothing to
// re-resolve to, and retrying is the guess the guard just refused.
function _foreignTabError(where, cause) {
return new Error(
`Tab tracking lost during ${where} — the target is a tab opened by another MCP session ` +
`that this session does not own (atomic guard fail-closed). This session holds no tab ` +
`marker, which is also what a session re-initialised after a transport drop reports. ` +
`Call safari_new_tab to open a tab this session owns.`,
cause ? { cause } : undefined
);
}
// Run JavaScript in Safari — fastest path, no focus stealing
// Uses osascriptFast (persistent process, ~5ms) for short scripts,
// falls back to osascript (~80ms) for long scripts that exceed stdin limits
// An explicit `tabIndex` names a tab of `win` (`window id N`) when the caller proved it there,
// else of the target window. `elsewhere`: see _resolveSessionTab.
async function runJS(js, { tabIndex, win, timeout = 15000, elsewhere = null } = {}) {
await refreshTargetWindow();
const escaped = js
.replace(/^\s*\/\/[^\n]*$/gm, '') // Strip // comment-only lines before flattening
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, " ")
.replace(/\r/g, "")
.replace(/\t/g, " ");
// Resolve tab: explicit tabIndex > the session's tab, proven by its marker > (a session that
// never owned a tab) its recently verified or URL-tracked index > front document
let idx = tabIndex;
let idxWin = tabIndex ? win : null;
if (!idx && !_st().hasOwnedTab && _st().activeTabIndex && _st().activeTabURL && (Date.now() - _st().lastResolveTime < RESOLVE_CACHE_MS)) {
// Recently verified and tab count unchanged — use cached index. Never for a session that
// has owned a tab: its index counts only once a marker scan has just found the tab there
// (resolveActiveTab checks that index first, so re-resolving is cheap), which also closes
// the ~100ms window in which a user tab-shift could leave it pointing at the user's tab.
idx = _st().activeTabIndex;
} else if (!idx && (_st().hasOwnedTab || _st().activeTabURL)) {
const resolved = await _resolveSessionTab({ elsewhere });
if (resolved.idx) { idx = resolved.idx; idxWin = resolved.win; _st().lastResolveTime = Date.now(); }
}
// A session that never owned a tab falls back to the index it tracks (usually none: the front
// document). An owned session's index is set only by a marker scan, and cleared when one fails.
if (!idx) idx = _st().activeTabIndex;
// Once this session owns a tab, never silently run on the user's current tab —
// regardless of SAFARI_PROFILE. Without a profile getFallbackTarget() returns
// "front document" (the user's active tab), so the guard matters MOST there.
// Mirrors runJSLarge and the ghost-recovery guard below, which key on _st().hasOwnedTab alone.
if (!idx && _st().hasOwnedTab) {
throw new Error('Tab tracking lost during runJS — refusing to target the user\'s current tab. Call safari_new_tab to reopen.');
}
const target = idx
? `tab ${idx} of ${idxWin || getTargetWindowRef()}`
: getFallbackTarget();
// NEVER activate or raise Safari — that steals the user's foreground. JS runs in the
// target tab in the background regardless of window stacking. (rAF/timers stay frozen
// while the window is occluded by macOS; that is a deliberate trade-off — measuring an
// occluded window's frame rate is not worth stealing focus. Measure when it's visible.)
// Atomic tab-identity guard: prepend a JS check that throws MCP_WRONG_TAB if the target tab
// does not carry OUR marker. Makes the op fail-closed at EXECUTION time regardless of how
// `idx` resolved — a drift to the user's tab (shared profile window) throws instead of
// running on their page. Skipped when the caller passed an explicit tabIndex.
const _guard = _tabIdentityGuard(tabIndex);
const script = `tell application "Safari" to do JavaScript "${_guard}${escaped}" in ${target}`;
try {
if (script.length < 50000) {
return await osascriptFast(script, { timeout });
}
return await osascript(script, { timeout });
} catch (err) {
// Atomic guard tripped: op reached a tab WITHOUT our marker (drift to the user's tab in
// the shared profile window). Re-resolve via a full marker scan and retry once on the
// correct tab; if unresolved, fail closed rather than touch the user's tab.
const _gmsg = err.message || '';
if (_gmsg.includes('MCP_FOREIGN_TAB')) throw _foreignTabError('runJS', err);
if (_guard && _gmsg.includes('MCP_WRONG_TAB')) {
console.error('[Safari MCP] Atomic guard tripped — re-resolving marked tab via full scan');
_st().lastResolveTime = 0; _st().activeTabIndex = null;
const { idx: _reIdx, win: _reWin } = (_st().activeTabURL || _st().activeTabMarker) ? await _resolveSessionTab() : { idx: null, win: null };
if (_reIdx) {
const _gs = `tell application "Safari" to do JavaScript "${_guard}${escaped}" in tab ${_reIdx} of ${_reWin || getTargetWindowRef()}`;
if (_gs.length < 50000) return await osascriptFast(_gs, { timeout });
return await osascript(_gs, { timeout });
}
throw new Error('Tab tracking lost — marked tab not found (atomic guard fail-closed). Call safari_new_tab to reopen.');
}
// Tab ghost recovery: "Can't get tab X" → re-resolve and retry once.
// Match both apostrophes — Safari emits a typographic apostrophe (U+2019),
// so a plain "Can't" includes() check silently missed every ghost error.
const msg = err.message || '';
if (idx && (/[Cc]an.t get tab/.test(msg) || msg.includes("-1728"))) {
console.error(`[Safari MCP] Tab ghost detected (tab ${idx}), re-resolving...`);
_st().lastResolveTime = 0; // Force re-resolve
_st().lastTabCount = null; // Invalidate tab count cache
_st().activeTabIndex = null;
if (_st().activeTabURL || _st().activeTabMarker) {
const { idx: newIdx, win: newWin } = await _resolveSessionTab();
if (newIdx && newIdx !== idx) {
console.error(`[Safari MCP] Tab ghost resolved: ${idx} → ${newIdx}`);
const newTarget = `tab ${newIdx} of ${newWin || getTargetWindowRef()}`;
// Keep the guard on the retry. `newIdx` came from a re-resolve triggered by the
// index being wrong; dropping the check here made the least trustworthy path the
// only unchecked one (#64).
const retryScript = `tell application "Safari" to do JavaScript "${_guard}${escaped}" in ${newTarget}`;
if (retryScript.length < 50000) return osascriptFast(retryScript, { timeout });
return osascript(retryScript, { timeout });
}
}
// If still can't resolve and we previously owned a tab — refuse to
// fall back to "current tab of window" (which is the USER'S active tab).
// Falling back would silently run our JS (potentially writes:
// document.title=, location.href=) on the user's working page.
if (_st().hasOwnedTab) {
throw new Error(
`Tab tracking lost during runJS — original tab ${idx} no longer exists, and URL-based resolution failed. ` +
`Refusing to fall back to "current tab of window" (would target user's active tab). ` +
`Call safari_new_tab to open a fresh tab and retry.`
);
}
// No owned tab in this session — front-document fallback is intentional.
const fallbackScript = `tell application "Safari" to do JavaScript "${escaped}" in current tab of ${getTargetWindowRef()}`;
console.error(`[Safari MCP] Falling back to current tab`);
if (fallbackScript.length < 50000) return osascriptFast(fallbackScript, { timeout });
return osascript(fallbackScript, { timeout });
}
throw err;
}
}
// Run large JavaScript via temp file — bypasses osascript arg length limit (~260KB)
// Used for operations that embed file data (upload, paste image)
async function runJSLarge(js, { tabIndex, timeout = 30000, elsewhere = null } = {}) {
await refreshTargetWindow();
// Resolve tab the same way runJS does — verify cached index via URL
let idx = tabIndex;
let idxWin = null;
// Match runJS: a session that has owned a tab always resolves — only its marker proves where
// the tab is, and skipping the scan meant large-payload ops (upload/paste) could target a
// stale index.
if (!idx && (_st().hasOwnedTab || (_st().activeTabURL && _st().activeTabURL !== 'about:blank'))) {
const resolved = await _resolveSessionTab({ elsewhere });
if (resolved.idx) { idx = resolved.idx; idxWin = resolved.win; _st().lastResolveTime = Date.now(); }
}
if (!idx) idx = _st().activeTabIndex;
// If we previously owned a tab but lost tracking, refuse to target current tab
// (which is the USER'S active tab). Same protection as navigate/runJS fallback.
if (!idx && _st().hasOwnedTab) {
throw new Error(
`Tab tracking lost during runJSLarge — refusing to fall back to "current tab of window" (would target user's active tab). ` +
`Call safari_new_tab to open a fresh tab and retry.`
);
}
const target = idx
? `tab ${idx} of ${idxWin || getTargetWindowRef()}`
: getFallbackTarget();
// Write AppleScript to temp file — the JS is embedded inside the AppleScript
const escaped = js
.replace(/^\s*\/\/[^\n]*$/gm, '') // Strip // comment-only lines before flattening
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, " ")
.replace(/\r/g, "")
.replace(/\t/g, " ");
// Same execution-time identity check runJS gets. These are the upload / paste-image ops —
// the largest blast radius in the toolset — and they were the one targeted path with no
// guard at all (#64).
const appleScript = `tell application "Safari" to do JavaScript "${_tabIdentityGuard(tabIndex)}${escaped}" in ${target}`;
const tmpFile = join(tmpdir(), `safari-mcp-${Date.now()}.scpt`);
await writeFile(tmpFile, appleScript, "utf8");
// Save frontmost app via daemon before subprocess execution
// Skip if outer caller already handles focus
const shouldGuard = !_focusGuardActive;
const opStart = Date.now();
const frontApp = shouldGuard ? await _helperGetFrontApp() : null;
try {
const { stdout } = await execFileAsync("osascript", [tmpFile], {
timeout,
maxBuffer: 10 * 1024 * 1024,
});
return stdout.trim();
} catch (err) {
// No retry here, unlike runJS: these payloads carry file data, and re-running one on a
// freshly resolved tab is exactly the guess this guard exists to prevent.
if ((err.message || '').includes('MCP_FOREIGN_TAB')) throw _foreignTabError('runJSLarge', err);
if ((err.message || '').includes('MCP_WRONG_TAB')) {
throw new Error('Tab tracking lost during runJSLarge — target tab does not carry this session\'s marker (atomic guard fail-closed). Call safari_new_tab to reopen.', { cause: err });
}
throw err;
} finally {
unlink(tmpFile).catch(() => {});
// Awaited restore — caller must not return to user-space while Safari is still frontmost.
if (shouldGuard && frontApp?.bundleId && frontApp.bundleId !== 'com.apple.Safari') {
await restoreFocusIfStolen(frontApp.bundleId, opStart).catch(() => {});
}
}
}
// ========== NAVIGATION ==========
// Opt-in: agents whose intent is SHOWING a page (voice assistants, demos)
// can ask navigation to bring the Safari window forward. Off by default —
// the server's background-operation posture is unchanged.
const RAISE_ON_NAVIGATE = process.env.SAFARI_MCP_RAISE_ON_NAVIGATE === "1";
async function raiseWindowForShow() {
if (!RAISE_ON_NAVIGATE) return;
await osascript(
`tell application "Safari"\nactivate\ntry\nset index of front window to 1\nend try\nend tell`,
{ timeout: 5000 }
).catch(() => {});
}
export async function navigate(url) {
await refreshTargetWindow();
await raiseWindowForShow();
let targetUrl = url;
if (!/^https?:\/\//i.test(targetUrl)) {
targetUrl = "https://" + targetUrl;
}
// Escape backslash first, then quotes; strip CR/LF — a newline would break out of
// the AppleScript string literal and allow AppleScript injection.
const safeUrl = targetUrl.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]/g, '');
// Prove our tab ONCE by its marker (indices shift), with the window the proof found it in, and
// pin every script below to that tab of that window: re-resolving mid-navigation is unsafe — a
// cross-origin load transiently wipes window.name (a browser privacy feature) and the tracked
// URL is stale until the new page settles, so resolveActiveTab() would conclude "identity lost"
// and drop the very tab we are navigating — and 'front window' would put every later step,
// `set URL` and the marker stamp included, in a window the user brought forward meanwhile.
// A position is not an identity either: each script proves the tab again before it touches it
// (_inTab), by the marker while the old page still carries it — so a tab opened or closed
// before ours is followed, never navigated — and across the load by the marker it stamps back.
const tab = await _sessionTab('navigate');
const setURL = (u) => (target) => `set URL of ${target} to "${u}"`;
// Step 0: Suppress onbeforeunload dialogs (prevents blocking navigation)
await _inTab(tab, "window.onbeforeunload=null", { markerOnly: true, timeout: 2000 }).catch(() => {});
// Pre-navigation URL, captured before Step 1. Lets the post-load check below
// detect a `set URL` that silently no-ops (a cold or crashed Swift daemon):
// the readyState poll would otherwise just see the OLD page still loaded.
const preNavUrl = await _inTab(tab, 'location.href', { markerOnly: true, timeout: 3000 }).catch(() => '');
// Step 1: Set URL via fast daemon (~5ms) — don't block daemon with polling. In the script that
// checks the page still carries the session's marker: the tab it sets is the session's. From
// here on, a page without the marker is taken by position only when its document is newer.
tab.since = Date.now();
await _inTab(tab, "''", { markerOnly: true, then: setURL(safeUrl), timeout: 10000 });
// Optimistically track the destination NOW. The async load below can take seconds
// on a heavy SPA; if any step throws mid-load, resolveActiveTab() can still re-find
// this tab by URL instead of clearing the index and locking the session out of its
// own tab. Corrected to the real landed URL once the page settles (below).
_st().activeTabURL = targetUrl;
_st().activeTabIndex = tab.idx;
_st().lastResolveTime = Date.now();
// about:blank and any already-loaded page report readyState 'complete' the instant
// we poll, BEFORE the async set-URL takes effect — so breaking on readyState alone
// mistakes the stale/blank page for a finished navigation (root cause of false
// "set URL had no effect" failures navigating a fresh tab to an SPA). Only settle
// once the URL has actually LEFT preNavUrl (a same-URL reload is exempt).
const _isReload = !!preNavUrl && preNavUrl === targetUrl;
const _settled = (state, landed) => {
if (state !== 'complete' && state !== 'interactive') return false;
if (_isReload) return true;
if (!landed || landed === 'about:blank') return false;
return landed !== preNavUrl;
};
const _probeUrl = (json) => { try { return JSON.parse(json).url || ''; } catch { return ''; } };
// Step 2: Poll readyState synchronously from Node.js side
// (AppleScript do JavaScript doesn't await async Promises — returns immediately).
// Every poll stamps a page that lost the marker, so a load leaves the tab unmarked for one
// poll at most; a tab no poll can prove ends the navigation (it is not "still loading").
let result = '{}';
for (let poll = 0; poll < 80; poll++) {
await new Promise(r => setTimeout(r, 200));
try {
const state = await _inTab(tab, 'document.readyState', { stamp: true, timeout: 5000 });
if (state === 'complete' || state === 'interactive') {
result = await _inTab(
tab,
`JSON.stringify({title:document.title,url:location.href,blocked:document.title.includes('cannot open')||document.title.includes('\u05D0\u05D9\u05DF \u05D0\u05E4\u05E9\u05E8\u05D5\u05EA')})`,
{ stamp: true, timeout: 5000 }
);
if (_settled(state, _probeUrl(result))) {
if (state === 'complete') break;
// interactive = DOM ready but resources still loading — wait a bit more
if (poll > 10) break; // Don't wait forever for 'complete' if interactive after 2s
}
// else: new URL not in effect yet (stale/blank page) — keep polling
}
} catch (err) {
if (err.tabUnproven) throw err;
/* page still loading, retry */
}
}
// If the fast `set URL` above silently no-opped (cold/crashed daemon), the poll
// just saw the OLD page already loaded. Detect that — the URL never left
// preNavUrl — and retry once through the daemon-independent osascript path.
let landedUrl = _probeUrl(result);
if (preNavUrl && preNavUrl !== targetUrl && (!landedUrl || landedUrl === preNavUrl || landedUrl === 'about:blank')) {
console.error('[Safari MCP] navigate: fast set-URL did not take effect — retrying via osascript subprocess');
// The page never left, so it still carries the marker, which alone may prove where it is.
await _inTab(tab, "''", { markerOnly: true, then: setURL(safeUrl), timeout: 12000, subprocess: true });
for (let rpoll = 0; rpoll < 80; rpoll++) {
await new Promise(res => setTimeout(res, 200));
try {
const state = await _inTab(tab, 'document.readyState', { stamp: true, timeout: 5000 });
if (state === 'complete' || state === 'interactive') {
const probe = await _inTab(tab, 'JSON.stringify({title:document.title,url:location.href})', { stamp: true, timeout: 5000 });
if (_settled(state, _probeUrl(probe))) {
result = probe;
if (state === 'complete') break;
if (rpoll > 10) break;
}
}
} catch (err) {
if (err.tabUnproven) throw err;
/* page still loading, retry */
}
}
// Last chance: the daemon may have applied the set URL only AFTER our polls ran.
// Re-read the live URL directly before declaring failure.
let retryUrl = _probeUrl(result);
if (!retryUrl || retryUrl === preNavUrl || retryUrl === 'about:blank') {
const liveUrl = await _inTab(tab, 'location.href', { stamp: true, timeout: 5000 }).catch((err) => {
if (err.tabUnproven) throw err;
return '';
});
if (liveUrl && liveUrl !== preNavUrl && liveUrl !== 'about:blank') {
result = JSON.stringify({ title: '', url: liveUrl });
retryUrl = liveUrl;
}
}
// Still stuck on the pre-navigation URL → the navigation genuinely failed.
if (retryUrl && retryUrl === preNavUrl) {
// Preserve tab tracking (index + the page actually showing) so the session can
// recover via switch_tab / re-navigate instead of being locked out of its tab.
_st().activeTabURL = preNavUrl;
_st().activeTabIndex = tab.idx;
_st().lastResolveTime = Date.now();
throw new Error(`navigate failed: page stayed on ${preNavUrl} — Safari "set URL" to ${targetUrl} had no effect (Safari automation/daemon issue, retry exhausted)`);
}
}
// If HTTPS failed and original was HTTP, try original HTTP URL
try {
const parsed = JSON.parse(result);
if (parsed.blocked && url.startsWith("http://")) {
const httpUrl = url.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]/g, '');
// The poll that read the blocked page stamped it: the marker alone may prove where it is.
await _inTab(tab, "''", { markerOnly: true, then: setURL(httpUrl), timeout: 10000 });
// Poll readyState for HTTP retry — the tab the step proved (no re-resolve)
let retryResult = '{}';
for (let rp = 0; rp < 40; rp++) {
await new Promise(r => setTimeout(r, 300));
try {
const rs = await _inTab(tab, 'document.readyState', { stamp: true, timeout: 5000 });
if (rs === 'complete' || rs === 'interactive') {
retryResult = await _inTab(tab, 'JSON.stringify({title:document.title,url:location.href})', { stamp: true, timeout: 5000 });
if (rs === 'complete') break;
if (rp > 8) break;
}
} catch (err) {
if (err.tabUnproven) throw err;
/* retry */
}
}
const retry = retryResult;
// Update URL tracking with actual URL after HTTP retry
try {
const retryParsed = JSON.parse(retry);
if (retryParsed.url) _st().activeTabURL = retryParsed.url;
} catch {}
_st().activeTabIndex = tab.idx;
_st().lastResolveTime = Date.now();
await _stampTab(tab);
_injectHelpersAfterLoad(tab.idx, tab.win);
return retry;
}
} catch (err) {
if (err?.tabUnproven) throw err;
}
// Update URL tracking after navigation (non-blocked path)
try {
const parsed = JSON.parse(result);
_st().activeTabURL = parsed.url || targetUrl;
} catch {
_st().activeTabURL = targetUrl;
}
_st().activeTabIndex = tab.idx;
_st().lastResolveTime = Date.now();
// Re-stamp identity marker + visibility spoof onto the settled page. A cross-origin
// navigation clears window.name, and any full load wipes __mcpTabMarker and the
// visibility spoof — re-stamping keeps resolveActiveTab able to find this tab and
// keeps the page rendering even while backgrounded.
await _stampTab(tab);
_injectHelpersAfterLoad(tab.idx, tab.win);
return result;
}
// Inject click helpers in background (non-blocking, for subsequent clicks), once the page is
// re-stamped, into the tab the navigation proved, behind the marker check. A marker scan of its
// own found no marker while a cross-site load had cleared window.name, or once the user had brought
// another window forward, and dropped the session's. A session that never owned a tab injects into
// the page in front, as before.
function _injectHelpersAfterLoad(idx, win) {
Promise.resolve()
.then(() => (idx && win
? osascriptFast(`tell application "Safari" to do JavaScript "${_tabIdentityGuard()}${_HELPERS_ESCAPED}" in tab ${idx} of ${win}`, { timeout: 15000 })
: _injectHelpersfast()))
.catch((err) => console.error(`[Safari MCP] background helper injection skipped: ${err.message}`));
}
// Poll document.readyState from the Node side and return {title,url[,text]} once the
// page settles. `do JavaScript` returns immediately and never awaits an async IIFE
// (see _evaluateAsync), so any in-page `await` loop is fire-and-forget — page-load
// waits MUST be driven from Node. Shared by goBack/goForward/reload/navigateAndRead/fillAndSubmit.
// Runs in the tab the step proved (`tab`, see _sessionTab), and every poll proves it again and
// stamps a page that lost the marker (_inTab), so the step keeps its tab across a cross-site load
// and the window's fingerprint stands in for the marker for one poll at most.
async function _pollReadyAndRead(tab, { maxLength } = {}) {
const readExpr = maxLength != null
? `JSON.stringify({title:document.title,url:location.href,text:document.body?document.body.innerText.substring(0,${Number(maxLength)}):''})`
: `JSON.stringify({title:document.title,url:location.href})`;
let result = '{}';
for (let poll = 0; poll < 60; poll++) {
await new Promise(r => setTimeout(r, poll < 10 ? 200 : 500));
try {
const state = await _inTab(tab, 'document.readyState', { stamp: true, timeout: 5000 });
if (state === 'complete' || state === 'interactive') {
result = await _inTab(tab, readExpr, { stamp: true, timeout: 5000 });
if (state === 'complete') break;
if (poll > 10) break; // interactive after ~2s is good enough
}
} catch (err) {
if (err.tabUnproven) throw err;
/* page still loading, retry */
}
}
return result;
}
export async function goBack() {
await refreshTargetWindow();
const tab = await _sessionTab('goBack');
// history.back() is synchronous; the page-load wait is polled from Node (see _pollReadyAndRead).
// It runs in the page that still carries the session's marker, checked in the same script.
tab.since = Date.now();
await _inTab(tab, "history.back()", { markerOnly: true, timeout: 5000 });
const result = await _pollReadyAndRead(tab);
try { const p = JSON.parse(result); if (p.url) _st().activeTabURL = p.url; } catch {}
// A page the back/forward cache restored keeps __mcpTabMarker but not always window.name: stamp
// both, as reload() does, so a later same-site load does not lose the marker.
await _stampTab(tab);
return result;
}
export async function goForward() {
await refreshTargetWindow();
const tab = await _sessionTab('goForward');
tab.since = Date.now();
await _inTab(tab, "history.forward()", { markerOnly: true, timeout: 5000 });
const result = await _pollReadyAndRead(tab);
try { const p = JSON.parse(result); if (p.url) _st().activeTabURL = p.url; } catch {}
await _stampTab(tab);
return result;
}
export async function reload(hardReload = false) {
await refreshTargetWindow();
const tab = await _sessionTab('reload');
// Reload destroys JS context — fire it, then poll readyState from Node.
tab.since = Date.now();
await _inTab(tab, hardReload ? "location.reload(true)" : "location.reload()", { markerOnly: true, timeout: 15000 });
await new Promise((r) => setTimeout(r, 100)); // Brief wait for reload to start
const result = await _pollReadyAndRead(tab);
try { const p = JSON.parse(result); if (p.url) _st().activeTabURL = p.url; } catch {}
// A reload destroys the JS context — re-stamp marker + visibility spoof.
await _stampTab(tab);
return result;
}
// ========== PAGE INFO ==========
export async function readPage({ selector, maxLength = 50000 } = {}) {
if (selector) {
const sel = escJsSingleQuote(selector);
return runJS(
`(function(){
var el = document.querySelector('${sel}');
if (!el) return 'Element not found: ${sel}';
if (el.value !== undefined && el.value !== '') return el.value.substring(0,${Number(maxLength)});
return (el.innerText || el.textContent || '').substring(0,${Number(maxLength)});
})()`
);
}
// innerText needs a built render tree, which Safari may skip for a tab that has
// never been foregrounded — it can come back near-empty even though the DOM is
// fully present. Detect that and fall back to a layout-independent TreeWalker
// text extraction so reads work on a background tab without ever taking focus.
//
// And the TIME dimension: an SPA mid-render has nothing for either reader —
// innerText and the walker both see an empty body until the framework paints.
// Rather than report emptiness as truth, give the page up to ~2s to produce
// any text before returning the (honestly) empty read.
const readOnce = () => runJS(
`(function(){
var max=${Number(maxLength)};
var t=document.body.innerText||'';
if(t.replace(/\\s/g,'').length<20){
var parts=[];
var w=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT,null);
var n;
while(n=w.nextNode()){
var p=n.parentElement;if(!p)continue;
var tag=p.tagName;
if(tag==='SCRIPT'||tag==='STYLE'||tag==='NOSCRIPT'||tag==='TEMPLATE')continue;
var s=(n.textContent||'').replace(/\\s+/g,' ').trim();
if(s)parts.push(s);
}
t=parts.join('\\n');
}
return JSON.stringify({title:document.title,url:location.href,text:t.substring(0,max)});
})()`
);
for (let i = 0; ; i++) {
const out = await readOnce();
try {
const parsed = JSON.parse(out);
if ((parsed.text || "").trim().length > 0 || i >= 3) return out;
} catch { return out; }
await new Promise((r) => setTimeout(r, 700));
}
}
export async function getPageSource({ maxLength = 200000 } = {}) {
return runJS(`document.documentElement.outerHTML.substring(0,${Number(maxLength)})`);
}
// ========== CLICK ==========
// Inject click helpers ONCE per page (cached on window.__mcp)
// Includes: mcpClick (full event sequence), mcpReactClick (React Fiber), mcpFindText (fast TreeWalker)
// Loaded from mcp-helpers.js at startup — enables syntax highlighting, linting, and easier maintenance
const INJECT_MCP_HELPERS = readFileSync(join(__dirname, 'mcp-helpers.js'), 'utf8');
// NOTE: ~300 lines of legacy inline helpers were here — now in mcp-helpers.js (deleted in v2.4.0)
// Precomputed escaped helper string — avoids re-escaping ~4KB on every injection call
const _HELPERS_ESCAPED = INJECT_MCP_HELPERS
.replace(/^\s*\/\/[^\n]*$/gm, '')
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, " ")
.replace(/\r/g, "")
.replace(/\t/g, " ");
// Fast helper injection — uses precomputed escaped string + daemon directly
// Skips runJS overhead (escaping, tab resolution) since we already have the escaped string
async function _injectHelpersfast() {
await refreshTargetWindow();
let idx = _st().activeTabIndex;
let win = null;
// Re-verify identity via the tab marker before trusting the cached index. This path
// used _st().activeTabIndex directly, so a user tab-shift (which changes which tab lives at
// that index) would inject helpers into the USER's tab. resolveActiveTab() scans for our
// marker and is fail-closed (returns null when identity is lost), so the guard below
// then throws instead of hitting the user's tab.
if (_st().hasOwnedTab) {
({ idx, win } = await _resolveSessionTab());
}
// Same guard as runJS: once this session has owned a tab, NEVER fall back to
// "front document" — that's the user's active tab, and injecting the helpers
// there is script injection into a page the user is working in.
if (!idx && _st().hasOwnedTab) {
throw new Error("Tab tracking lost — refusing to inject helpers into the front (user) tab.");
}
const target = idx
? `tab ${idx} of ${win || getTargetWindowRef()}`
: getFallbackTarget();
const script = `tell application "Safari" to do JavaScript "${_HELPERS_ESCAPED}" in ${target}`;
return osascriptFast(script, { timeout: 15000 });
}
// Ensure helpers are injected — verify critical functions exist, reset version if partial
// Cache: skip ensureHelpers check if we already injected on this URL recently
let _helpersInjectedForUrl = null;
let _helpersInjectedAt = 0;
const HELPERS_CACHE_MS = 10000; // Re-verify every 10s max
async function ensureHelpers() {
// Skip check if we recently verified helpers on the same URL
const now = Date.now();
if (_st().activeTabURL && _helpersInjectedForUrl === _st().activeTabURL && (now - _helpersInjectedAt) < HELPERS_CACHE_MS) return;
// Check if helpers are actually present (not just version flag)
const check = await runJS("(typeof mcpClickWithReact==='function'&&typeof mcpFindText==='function'&&typeof mcpReactSelectSet==='function')?'ok':'missing'").catch(() => 'missing');
if (check === 'ok') {
_helpersInjectedForUrl = _st().activeTabURL;
_helpersInjectedAt = now;
return;
}
// Reset version to force re-injection
await runJS("window.__mcpVersion=0").catch(() => {});
// Use precomputed escaped string + osascriptFast directly (~5ms vs ~80ms subprocess)
const result = await _injectHelpersfast().catch(err => 'INJECT_ERR:' + err.message);
if (typeof result === 'string' && result.startsWith('INJECT_ERR:')) {
throw new Error('ensureHelpers failed: ' + result);
}
_helpersInjectedForUrl = _st().activeTabURL;
_helpersInjectedAt = now;
}
// Try tiny click first (~200B). If helpers missing, inject once and retry.
async function clickWithRetry(js) {
try {
const result = await runJS(js);
if (result && (result.includes('mcpClick is not defined') || result.includes('mcpFindText is not defined'))) {
await ensureHelpers();
return runJS(js);
}
return result;
} catch (err) {
if (err.message && (err.message.includes('mcpClick') || err.message.includes('mcpFindText') || err.message.includes('not defined'))) {
await ensureHelpers();
return runJS(js);
}
throw err;
}
}
export async function click({ selector, text, x, y, ref }) {
await ensureHelpers();
// Native <select> elements look like custom dropdowns on LinkedIn etc., but they
// hand off to the OS-level option list. .click() on them blocks AppleScript until
// the native popup is dismissed → the JSC eval times out and the tool returns an
// error after seconds of hang. Detect early and return a clear directive instead.
const selectGuard = `if(el&&el.tagName==='SELECT'){var opts=[];for(var oi=0;oi<el.options.length&&opts.length<8;oi++){opts.push(el.options[oi].text||el.options[oi].value);}return '__SELECT_GUARD__:'+(el.id||el.name||'select')+': '+opts.join('|');}`;
// Page fingerprint, captured synchronously on either side of the click. dispatchEvent
// is synchronous, so anything that differs between before/after is a direct effect of
// the click's own handlers — there is no window for ambient ad/lazy-load noise. Catches
// DOM add/remove, class/attribute/text mutations, navigation, and focus changes.
const FP = `(location.href+'|'+document.querySelectorAll('*').length+'|'+document.documentElement.innerHTML.length+'|'+(document.activeElement?(document.activeElement.tagName+'#'+(document.activeElement.id||'')):''))`;
// Mirrors mcpClickWithReact (resolve → React-fiber click → synthetic fallback) but also
// reports reactFired and whether the page observably changed — so click() can detect a
// synthetic click that was silently ignored (isTrusted-gated handlers) and escalate.
const coreJS = (finderExpr, notFound) =>
`(function(){var el=${finderExpr};if(!el)return JSON.stringify({err:${JSON.stringify(notFound)}});${selectGuard}` +
`var target=mcpResolveTarget(el)||el;var before=${FP};` +
`var reactFired=false;try{reactFired=mcpReactClick(target);}catch(e){}` +
`var anchor=target&&target.closest?target.closest('a[href]'):null;` +
`if(!reactFired||anchor)mcpClick(target);` +
`return JSON.stringify({tag:target.tagName,text:((target.innerText||target.textContent)||'').trim().substring(0,50),reactFired:!!reactFired,changed:before!==(${FP}),fp:before});})()`;
let result;
if (ref) {
result = await clickWithRetry(coreJS(`mcpFindRef('${ref}')`, `Element not found: ref=${ref}`));
} else if (selector) {
const sel = escJsSingleQuote(selector);
result = await clickWithRetry(coreJS(`mcpQuerySelectorDeep('${sel}')`, `Element not found: ${selector}`));
} else if (text) {
const safeText = escJsSingleQuote(text);
result = await clickWithRetry(coreJS(`mcpFindText('${safeText}',true)||mcpFindText('${safeText}',false)`, `Element not found with text: ${text}`));
} else if (x !== undefined && y !== undefined) {
result = await clickWithRetry(coreJS(`mcpElementFromPoint(${Number(x)},${Number(y)})`, `No element at (${Number(x)},${Number(y)})`));
} else {
throw new Error("click requires selector, text, or x/y coordinates");
}
if (typeof result === 'string' && result.startsWith('__SELECT_GUARD__:')) {
const detail = result.substring('__SELECT_GUARD__:'.length);
throw new Error(`Target is a native <select> (${detail}). Use safari_select_option with a value matching one of the options instead — clicking it would open the OS picker and block.`);
}
// Structured result from coreJS. Fall back to the raw string for forward-compat.
let info;
try { info = JSON.parse(result); } catch { return result; }
if (info.err) return info.err;
const label = info.tag + (info.text ? ` "${info.text}"` : '');
// A React handler fired, or the page observably changed → the click landed.
if (info.reactFired || info.changed) return 'Clicked: ' + label;
// No React handler and no synchronous effect. Give an async handler a brief moment,
// then re-check before deciding the click was truly ignored.
await new Promise(r => setTimeout(r, 320));
const afterFp = await runJS(FP).catch(() => null);
if (afterFp != null && afterFp !== info.fp) return 'Clicked: ' + label;
// Synthetic events were ignored — the handler gates on event.isTrusted (Clutch, G2,
// Cloudflare-class sites). Escalate to a real OS-level CGEvent click (isTrusted:true).
// If a handler genuinely has no observable effect (pure analytics, slow >320ms async),
// this re-fires it once via the native click — an accepted, low-cost trade-off.
try {
await nativeClick({ selector, text, x, y, ref });
return 'Clicked (native fallback — synthetic click had no effect): ' + label;
} catch (e) {
return 'Clicked: ' + label + ' — ⚠️ synthetic click produced no detectable effect and the native fallback is unavailable (' + ((e && e.message) || e) + '). Retry with safari_native_click.';
}
}
export async function doubleClick({ selector, x, y, ref }) {
if (ref) selector = refSelector(ref);
if (selector) {
const sel = escJsSingleQuote(selector);
return runJS(
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found: ${sel}';el.scrollIntoView({block:'center'});el.dispatchEvent(new MouseEvent('dblclick',{bubbles:true,cancelable:true}));return 'Double-clicked: '+el.tagName;})()`
);
}
if (x !== undefined && y !== undefined) {
return runJS(
`(function(){var el=document.elementFromPoint(${Number(x)},${Number(y)});if(!el)return 'No element at (${x},${y})';el.dispatchEvent(new MouseEvent('dblclick',{bubbles:true,cancelable:true}));return 'Double-clicked: '+el.tagName+' at (${x},${y})';})()`
);
}
throw new Error("doubleClick requires selector or x/y coordinates");
}
export async function rightClick({ selector, x, y }) {
if (selector) {
const sel = escJsSingleQuote(selector);
return runJS(
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found: ${sel}';el.scrollIntoView({block:'center'});el.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,cancelable:true,button:2}));return 'Right-clicked: '+el.tagName;})()`
);
}
if (x !== undefined && y !== undefined) {
return runJS(
`(function(){var el=document.elementFromPoint(${Number(x)},${Number(y)});if(!el)return 'No element at (${x},${y})';el.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,cancelable:true,button:2}));return 'Right-clicked: '+el.tagName+' at (${x},${y})';})()`
);
}
throw new Error("rightClick requires selector or x/y coordinates");
}
// ========== NATIVE CLICK (OS-level CGEvent — produces isTrusted: true) ==========
// Unlike JS clicks (dispatchEvent/element.click), CGEvent clicks are real OS input.
// Sites with WAF protection (G2, Cloudflare, etc.) that check isTrusted will accept these.
// Trade-off: this moves the physical mouse cursor and requires Safari to be visible.
export async function nativeClick(args) {
// Helpers are only needed to LOCATE an element (ref/selector/text). With explicit
// x/y there is nothing to look up, and demanding injection anyway made native_click
// unusable on exactly the pages that need it: business.facebook.com stalls injection,
// so ensureHelpers threw INJECT_ERR and the OS-level click — the one thing React's
// isTrusted-gated handlers do accept — never got to fire.
const needsLookup = !!(args?.ref || args?.selector || args?.text);
if (needsLookup) await ensureHelpers();
// When the extension told us exactly which window+tab holds this session's page,
// pin everything to it. Otherwise fall back to the profile-name lookup — which
// picks the FIRST matching window and silently misses when the profile has several.
if (args?.locus) {
const pinned = await _pinWindowFromLocus(args.locus);
if (pinned) {
const out = await _withPinnedTabFronted(pinned, () => _nativeClickImpl(args));
return `${out} [pinned win ${pinned.winId} tab ${pinned.tabIndex}]`;
}
return `${await _withTargetTabFronted(() => _nativeClickImpl(args))} [locus unmatched: win ${args.locus.windowId} tab ${args.locus.index}]`;
}
// The event is routed to the window's SELECTED tab, so ours has to be it. See _withTargetTabFronted.
return await _withTargetTabFronted(() => _nativeClickImpl(args));
}
// Translate an extension locus ({windowId (extension-side), index, url, title}) into an
// AppleScript window id. Extension window ids and AppleScript window ids are different
// namespaces, so match by content: the window whose tab at `index` carries the locus URL
// (or title). Returns {winRef, winId, tabIndex} or null when nothing matches.
async function _pinWindowFromLocus(locus) {
const wanted = String(locus.url || "");
const wantedTitle = String(locus.title || "");
const idx = Number(locus.index);
if (!Number.isFinite(idx) || idx < 1) return null;
const wantCount = Number(locus.windowTabCount) || 0;
// Also carry the window's tab COUNT. URL and title both go stale on an SPA, and index
// alone is ambiguous across windows — but (index, tabCount) together pin one window in
// practice, and it is the only signal here that a page cannot rewrite.
const probe = `tell application "Safari"
set out to ""
repeat with w in every window
try
set c to (count of tabs of w)
if c >= ${idx} then
set t to tab ${idx} of w
set out to out & (id of w as text) & (ASCII character 31) & (URL of t as text) & (ASCII character 31) & (name of t as text) & (ASCII character 31) & (c as text) & (ASCII character 30)
end if
end try
end repeat
return out
end tell`;
let raw;
try { raw = await osascriptFast(probe); } catch (_e) { return null; }
const rows = String(raw || "").split("\u001E").filter(Boolean).map(r => r.split("\u001F"));
if (!rows.length) return null;
const sg = locus.screenGeom;
if (sg && Number.isFinite(sg.sx) && rows.length > 1) {
const boundsProbe = `tell application "Safari"
set out to ""
repeat with w in every window
try
set b to bounds of w
set out to out & (id of w as text) & (ASCII character 31) & (item 1 of b as text) & (ASCII character 31) & (item 2 of b as text) & (ASCII character 30)
end try
end repeat
return out
end tell`;
try {
const braw = await osascriptFast(boundsProbe);
const brows = String(braw || "").split("\u001E").filter(Boolean).map(r => r.split("\u001F"));
const chrome = (sg.oh && sg.ih) ? (sg.oh - sg.ih) : 90;
const m = brows.find(([, bx, by]) =>
Math.abs(Number(bx) - sg.sx) <= 4 && Math.abs(Number(by) - (sg.sy - chrome)) <= 8);
if (m) return { winRef: `window id ${m[0]}`, winId: Number(m[0]), tabIndex: idx };
} catch (_e) { /* fall through to content matching */ }
}
// Match on URL, then title. An SPA rewrites its URL while you work, so neither is
// reliable on its own — but when only ONE window even HAS a tab at this index, that
// is the window and no content match is needed. That last case is the common one,
// and without it the whole pin failed and fell back to the first-window-by-name
// guess, which is exactly the bug this exists to fix.
const byCount = wantCount ? rows.filter(([, , , c]) => Number(c) === wantCount) : [];
let hit = rows.find(([, u]) => wanted && u === wanted)
|| rows.find(([, u]) => wanted && wanted.length > 30 && u && u.startsWith(wanted.split("?")[0]))
|| rows.find(([, , t]) => wantedTitle && t === wantedTitle)
|| (byCount.length === 1 ? byCount[0] : null)
|| (rows.length === 1 ? rows[0] : null);
if (!hit) return null;
return { winRef: `window id ${hit[0]}`, winId: Number(hit[0]), tabIndex: idx };
}
// An `elsewhere` for runJS/runJSLarge (see _resolveSessionTab): the window `locate()` (index.js
// asking the extension for this session's tab) says holds the tab. Asked at most once, and only
// when the target window has no tab carrying the marker; no answer leaves the scan as it was.
function _locusWindows(locate) {
if (!locate) return null;
let wins = null;
return () => (wins ??= Promise.resolve()
.then(locate)
.then((locus) => (locus ? _pinWindowFromLocus(locus) : null))
.then((pinned) => (pinned ? [pinned.winRef] : []), () => []));
}
// Like _withTargetTabFronted, but for an explicit window+tab. Selects the session's tab
// in ITS OWN window (not whichever window matched the profile name first), runs fn, and
// restores the previous selection.
async function _withPinnedTabFronted(pinned, fn) {
const { winRef, tabIndex } = pinned;
let prev = null;
try {
prev = Number(await osascriptFast(`tell application "Safari" to tell ${winRef} to return (index of current tab) as text`));
} catch (_e) { /* window may refuse — run anyway; the click itself will fail loudly */ }
const mustSwitch = Number.isFinite(prev) && prev !== tabIndex;
if (mustSwitch) {
await osascriptFast(`tell application "Safari" to tell ${winRef} to set current tab to tab ${tabIndex}`);
await new Promise((r) => setTimeout(r, NATIVE_TAB_SETTLE_MS));
}
// On macOS 26 a window-targeted CGEvent silently no-ops when the target window is
// NOT the frontmost Safari window (issue #29 — verified live: the math was right,
// the event was delivered, the checkbox never toggled; a front-window click landed).
// Raise the pinned window within Safari for the duration of the click. This does not
// steal app focus — Safari's window order changes, the active app does not.
let prevFrontWinId = null;
try {
prevFrontWinId = Number(await osascriptFast(`tell application "Safari" to return (id of front window) as text`));
} catch (_e) { /* leave as-is */ }
const mustRaise = Number.isFinite(prevFrontWinId) && prevFrontWinId !== pinned.winId;
if (mustRaise) {
await osascriptFast(`tell application "Safari" to tell ${winRef} to set index to 1`);
await new Promise((r) => setTimeout(r, NATIVE_TAB_SETTLE_MS));
}
// A CGEvent goes to the FRONTMOST APPLICATION, not to the window id we aim at.
// Measured 23.8.26: WhatsApp was frontmost, so every "successful" native click was
// delivered to WhatsApp — the page never saw it. Raising Safari's own window (above)
// does not change which app is frontmost; Safari itself has to be activated.
// This is the one moment the tool legitimately needs the foreground, and the saved
// app is handed back in the finally below.
const savedApp = await saveFrontmostApp().catch(() => null);
const mustActivate = savedApp !== "com.apple.Safari";
if (mustActivate) {
await _helperActivateApp("com.apple.Safari").catch(() => {});
await new Promise((r) => setTimeout(r, 150)); // activate() is async at the OS level
}
// With Safari genuinely frontmost and the right window raised, use the GLOBAL event
// tap instead of postToPid. Verified 23.8.26 on macOS 26: a window-targeted
// postToPid click reports success and never reaches the page — proven on a plain
// <a> in example.com with a click listener armed, which never fired. The global tap
// moves the cursor, clicks, and restores it — the same path a person's click takes,
// and the only one that actually lands. This is why the pin has to raise Safari.
_pinnedForceGlobalTap = true;
// _nativeClickImpl reads geometry via _getSafariWindowGeometry(), which resolves the
// profile window by name. Override the resolution for the duration of this call so
// geometry, toolbar math, and the CGEvent all target the pinned window.
const prevOverride = _pinnedWindowOverride;
_pinnedWindowOverride = pinned;
try {
return await fn();
} finally {
_pinnedWindowOverride = prevOverride;
_pinnedForceGlobalTap = false;
if (mustSwitch) {
try { await osascriptFast(`tell application "Safari" to tell ${winRef} to set current tab to tab ${prev}`); }
catch (_e) { /* restore is best-effort */ }
}
// Hand the previous front window back — the user (or another session) had it up.
if (mustRaise && Number.isFinite(prevFrontWinId)) {
try { await osascriptFast(`tell application "Safari" to tell window id ${prevFrontWinId} to set index to 1`); }
catch (_e) { /* restore is best-effort */ }
}
// And give the foreground back to whatever app had it. restoreFocusIfStolen keeps
// its own guard: if the user has been interacting, it leaves Safari alone rather
// than yanking the window out from under them.
if (mustActivate && savedApp) {
try { await restoreFocusIfStolen(savedApp); } catch (_e) { /* best-effort */ }
}
}
}
let _pinnedWindowOverride = null;
// Set only inside a pinned click, where Safari has been raised to the front. Tells the
// click layer to use the global event tap (windowId 0) instead of postToPid.
let _pinnedForceGlobalTap = false;
async function _nativeClickImpl({ selector, text, x, y, ref, doubleClick = false }) {
// Step 1: Get element's viewport coordinates via JavaScript
let viewportCoords;
if (ref || selector || text) {
let jsExpr;
if (ref) {
jsExpr = `(function(){
var el = mcpFindRef('${ref}');
if (!el) return JSON.stringify({error: 'Element not found: ref=${ref}'});
el.scrollIntoView({block:'center', behavior:'instant'});
var rect = el.getBoundingClientRect();
return JSON.stringify({
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
tag: el.tagName,
text: (el.innerText || el.textContent || '').trim().substring(0, 50)
});
})()`;
} else if (selector) {
const sel = escJsSingleQuote(selector);
jsExpr = `(function(){
var el = document.querySelector('${sel}');
if (!el) return JSON.stringify({error: 'Element not found: ${sel}'});
el.scrollIntoView({block:'center', behavior:'instant'});
var rect = el.getBoundingClientRect();
return JSON.stringify({
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
tag: el.tagName,
text: (el.innerText || el.textContent || '').trim().substring(0, 50)
});
})()`;
} else {
const safeText = escJsSingleQuote(text);
jsExpr = `(function(){
var el = mcpFindText('${safeText}', true) || mcpFindText('${safeText}', false);
if (!el) return JSON.stringify({error: 'Element not found with text: ${safeText}'});
el.scrollIntoView({block:'center', behavior:'instant'});
var rect = el.getBoundingClientRect();
return JSON.stringify({
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
tag: el.tagName,
text: (el.innerText || el.textContent || '').trim().substring(0, 50)
});
})()`;
}
const result = await runJS(jsExpr);
try {
viewportCoords = JSON.parse(result);
} catch {
throw new Error("Failed to get element coordinates: " + result);
}
if (viewportCoords.error) {
throw new Error(viewportCoords.error);
}
} else if (x !== undefined && y !== undefined) {
// Direct viewport coordinates provided
viewportCoords = { x: Number(x), y: Number(y), tag: 'point', text: '' };
} else {
throw new Error("nativeClick requires selector, text, ref, or x/y coordinates");
}
// Step 2: Get Safari window position and toolbar geometry
const geo = await _getSafariWindowGeometry();
// Step 3: Calculate absolute screen coordinates
// screenX = windowLeft + viewportX
// screenY = windowTop + toolbarHeight + viewportY
const screenX = geo.windowX + viewportCoords.x;
const screenY = geo.windowY + geo.toolbarHeight + viewportCoords.y;
// Sanity check: ensure coordinates are within the window bounds
if (screenX < geo.windowX || screenX > geo.windowRight ||
screenY < geo.windowY || screenY > geo.windowBottom) {
console.error(`[Safari MCP] nativeClick: coords (${screenX},${screenY}) outside window bounds (${geo.windowX},${geo.windowY})-(${geo.windowRight},${geo.windowBottom}). Proceeding anyway.`);
}
// Step 4: Perform the native click via CGEvent (targeted to specific window — no mouse move, no focus steal)
// MUST have windowId — legacy path (windowId=0) moves mouse and may steal focus
if (!geo.windowId) throw new Error("Cannot native-click without Safari window ID — would move mouse and steal focus");
// windowId 0 selects the global tap. Only a pinned click may ask for it: that path
// has already raised Safari and its own window, so moving the cursor is expected and
// the focus is handed back afterwards.
await _helperNativeClick(screenX, screenY, doubleClick, _pinnedForceGlobalTap ? 0 : geo.windowId);
const clickType = doubleClick ? 'Native double-clicked' : 'Native clicked';
const label = viewportCoords.tag + (viewportCoords.text ? ` "${viewportCoords.text}"` : '');
return `${clickType}: ${label} at screen (${screenX},${screenY})`;
}
// ========== NATIVE HOVER (OS-level CGEvent mouse move — triggers real :hover and mouseenter) ==========
// JS-dispatched mouseenter events work for most React components, but some UIs
// (Discord sidebar, virtualized CSS :hover tooltips, custom portal-rendered
// tooltips) only respond to a real OS-level cursor position. This function
// moves the physical cursor to the target, dwells for tooltips to render,
// then optionally restores the cursor to its original position.
export async function nativeHover(args) {
await ensureHelpers();
return await _withTargetTabFronted(() => _nativeHoverImpl(args));
}
async function _nativeHoverImpl({ selector, text, x, y, ref, dwellMs = 500, restoreMouse = true }) {
// Step 1: Get element's viewport coordinates via JavaScript
let viewportCoords;
if (ref || selector || text) {
let jsExpr;
if (ref) {
jsExpr = `(function(){
var el = mcpFindRef('${ref}');
if (!el) return JSON.stringify({error: 'Element not found: ref=${ref}'});
el.scrollIntoView({block:'center', behavior:'instant'});
var rect = el.getBoundingClientRect();
return JSON.stringify({
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
tag: el.tagName,
text: (el.innerText || el.textContent || '').trim().substring(0, 50)
});
})()`;
} else if (selector) {
const sel = escJsSingleQuote(selector);
jsExpr = `(function(){
var el = document.querySelector('${sel}');
if (!el) return JSON.stringify({error: 'Element not found: ${sel}'});
el.scrollIntoView({block:'center', behavior:'instant'});
var rect = el.getBoundingClientRect();
return JSON.stringify({
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
tag: el.tagName,
text: (el.innerText || el.textContent || '').trim().substring(0, 50)
});
})()`;
} else {
const safeText = escJsSingleQuote(text);
jsExpr = `(function(){
var el = mcpFindText('${safeText}', true) || mcpFindText('${safeText}', false);
if (!el) return JSON.stringify({error: 'Element not found with text: ${safeText}'});
el.scrollIntoView({block:'center', behavior:'instant'});
var rect = el.getBoundingClientRect();
return JSON.stringify({
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
tag: el.tagName,
text: (el.innerText || el.textContent || '').trim().substring(0, 50)
});
})()`;
}
const result = await runJS(jsExpr);
try {
viewportCoords = JSON.parse(result);
} catch {
throw new Error("Failed to get element coordinates: " + result);
}
if (viewportCoords.error) {
throw new Error(viewportCoords.error);
}
} else if (x !== undefined && y !== undefined) {
viewportCoords = { x: Number(x), y: Number(y), tag: 'point', text: '' };
} else {
throw new Error("nativeHover requires selector, text, ref, or x/y coordinates");
}
const geo = await _getSafariWindowGeometry();
const screenX = geo.windowX + viewportCoords.x;
const screenY = geo.windowY + geo.toolbarHeight + viewportCoords.y;
if (!geo.windowId) throw new Error("Cannot native-hover without Safari window ID — would move mouse and steal focus");
await _helperNativeHover(screenX, screenY, geo.windowId, dwellMs, restoreMouse);
const label = viewportCoords.tag + (viewportCoords.text ? ` "${viewportCoords.text}"` : '');
return `Native hovered: ${label} at screen (${screenX},${screenY}) for ${dwellMs}ms${restoreMouse ? ' (mouse restored)' : ''}`;
}
// ========== FORM INPUT ==========
export async function fill({ selector, value, ref }) {
if (ref) selector = refSelector(ref);
if (!selector) throw new Error("fill requires selector or ref");
const sel = escJsSingleQuote(selector);
// Proper escaping order: backslashes first, then quotes
const val = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
// Same value encoded for the Lexical JSON literal (Strategy 2): JSON-escape first
// (quotes/newlines/backslashes), then escape for the surrounding single-quoted JS
// string. `val` alone broke the JSON on any double-quote, and parseEditorState's
// silent catch made the whole strategy vanish without a trace.
const lexVal = JSON.stringify(String(value)).slice(1, -1).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const result = await runJS(
`(function(){try{var el=document.querySelector('${sel}');if(!el){var q=function(r){var a=r.querySelectorAll('*');for(var i=0;i<a.length;i++){if(a[i].shadowRoot){el=a[i].shadowRoot.querySelector('${sel}');if(el)return el;el=q(a[i].shadowRoot);if(el)return el;}}return null;};el=q(document);}if(!el)return 'Element not found: ${sel}';el.focus();if(el.isContentEditable||el.getAttribute('contenteditable')==='true'){` +
// Quill editor detection (LinkedIn share composer in 2026 — they migrated from
// ProseMirror, Slack message editor, many enterprise apps). The Quill instance is
// attached to `.ql-container.__quill` (v2) or accessible via React Fiber's
// memoizedProps/stateNode. Without proper API access, fill-by-DOM crashes Quill's
// internal Delta state and dismisses the dialog (the v2.10.0 LinkedIn bug repro).
`var qlEditor=el.classList&&el.classList.contains('ql-editor')?el:el.closest('.ql-editor');` +
`if(qlEditor){` +
`var qlContainer=qlEditor.closest('.ql-container')||qlEditor.parentElement;` +
`var quill=(qlContainer&&qlContainer.__quill)||null;` +
// Fallback: walk React Fiber to find the Quill instance attached as a prop or stateNode
`if(!quill&&qlContainer){var qfk=Object.keys(qlContainer).find(function(k){return k.indexOf('__reactFiber')===0||k.indexOf('__reactInternalInstance')===0;});if(qfk){var qf=qlContainer[qfk];for(var qd=0;qd<25&&qf;qd++){var qp=qf.memoizedProps;if(qp&&qp.quill&&typeof qp.quill.setContents==='function'){quill=qp.quill;break;}var qsn=qf.stateNode;if(qsn&&qsn.quill&&typeof qsn.quill.setContents==='function'){quill=qsn.quill;break;}qf=qf.return;}}}` +
`if(quill&&typeof quill.setContents==='function'){` +
`try{` +
// Convert escaped string back to real string for Quill API.
// The val template var has \\n / \\\\ / \\' applied, so we need to unescape for setText.
`var qlText='${val}'.replace(/\\\\n/g,'\\n').replace(/\\\\'/g,"'").replace(/\\\\\\\\/g,'\\\\');` +
// setContents with a plain text Delta — bypasses clipboard, no synthetic events,
// doesn't trigger LinkedIn's focusout-dismiss handler.
`quill.setContents([{insert: qlText + '\\n'}], 'api');` +
`quill.setSelection(qlText.length, 0, 'api');` +
// Verify the text actually committed
`var qlActual=quill.getText().replace(/\\n+$/,'');` +
`if(qlActual.indexOf(qlText.substring(0, Math.min(20, qlText.length)))>=0){return 'Filled CE (Quill setContents)';}` +
`}catch(_qE){}` +
`}` +
// Quill instance not found via direct or Fiber — route to native paste fallback.
// Quill respects real isTrusted clipboard events, so CGEvent Cmd+V works.
`return '__NATIVE_PASTE_DIALOG__';` +
`}` +
// Lexical editor detection (LinkedIn share composer, modern Meta/Shopify apps).
// Three lookup strategies, cheapest first:
// A) [data-lexical-editor="true"] on ancestor with __lexicalEditor property.
// B) Walk DOM ancestors for __lexicalEditor (nested-config wrappers).
// C) React Fiber walk — LinkedIn sometimes obfuscates __lexicalEditor, so we
// locate the editor by duck-typing: any object on props/state/stateNode
// that exposes both parseEditorState AND setEditorState is the editor.
// Once found, two fill strategies, both pure DOM (no CGEvent, no focus shift):
// 1) InputEvent('beforeinput', insertFromPaste + DataTransfer) — Lexical's
// handleBeforeInput reads dataTransfer and commits via editor.update().
// 2) parseEditorState() + setEditorState() with a minimal paragraph doc —
// used when Lexical ignores synthetic paste events.
`var lexEl=el.closest('[data-lexical-editor="true"]');var lex=(lexEl&&lexEl.__lexicalEditor)||null;` +
`if(!lex){var lexCur=el;for(var lexI=0;lexI<15&&lexCur;lexI++){if(lexCur.__lexicalEditor){lexEl=lexCur;lex=lexCur.__lexicalEditor;break;}lexCur=lexCur.parentElement;}}` +
`if(!lex){var lexHost=lexEl||el;var lexFk=null;for(var lfk in lexHost){if(lfk.indexOf('__reactFiber')===0||lfk.indexOf('__reactInternalInstance')===0){lexFk=lfk;break;}}` +
`if(lexFk){var lexF=lexHost[lexFk];var lexIsEd=function(o){return o&&typeof o.parseEditorState==='function'&&typeof o.setEditorState==='function';};` +
`for(var lexD=0;lexD<30&&lexF;lexD++){var lp=lexF.memoizedProps;if(lp){for(var lpk in lp){if(lexIsEd(lp[lpk])){lex=lp[lpk];break;}}}` +
`if(!lex){var ls=lexF.memoizedState;while(ls){if(lexIsEd(ls.memoizedState)){lex=ls.memoizedState;break;}ls=ls.next;}}` +
`if(!lex){var lsn=lexF.stateNode;if(lsn){if(lexIsEd(lsn))lex=lsn;else if(lsn.editor&&lexIsEd(lsn.editor))lex=lsn.editor;}}` +
`if(lex)break;lexF=lexF.return;}}}` +
`if(lex){if(!lexEl){lexEl=lex._rootElement||el;}` +
// Strategy 1: beforeinput with insertFromPaste + DataTransfer
`try{lexEl.focus();var lexSel=window.getSelection();if(lexSel){var lexRng=document.createRange();lexRng.selectNodeContents(lexEl);lexSel.removeAllRanges();lexSel.addRange(lexRng);}var lexDt=new DataTransfer();lexDt.setData('text/plain','${val}');var lexBi=new InputEvent('beforeinput',{inputType:'insertFromPaste',dataTransfer:lexDt,bubbles:true,cancelable:true});var lexCancelled=!lexEl.dispatchEvent(lexBi);if(lexCancelled||lexEl.textContent.indexOf('${val.substring(0, 20)}')>=0){return 'Filled CE (Lexical beforeinput paste)';}}catch(lexE1){}` +
// Strategy 2: parseEditorState + setEditorState (direct state replacement)
`try{var lexJson='{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"${lexVal}","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"textFormat":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}';var lexNs=lex.parseEditorState(lexJson);lex.setEditorState(lexNs);if(typeof lex.focus==='function')lex.focus();return 'Filled CE (Lexical setEditorState)';}catch(lexE2){}` +
`}` +
// ProseMirror detection
`var pm=el.closest('.ProseMirror')||el.querySelector('.ProseMirror');if(pm){try{var v=null;if(pm.pmViewDesc&&pm.pmViewDesc.view)v=pm.pmViewDesc.view;else if(pm.cmView&&pm.cmView.view)v=pm.cmView.view;else{var keys=Object.keys(pm);for(var ki=0;ki<keys.length;ki++){var o=pm[keys[ki]];if(o&&o.state&&o.dispatch){v=o;break;}}}` +
// React Fiber walk for ProseMirror view (LinkedIn, Tiptap-React)
`if(!v){var fk=Object.keys(pm).find(function(k){return k.startsWith('__reactFiber$')||k.startsWith('__reactInternalInstance$');});if(fk){var fiber=pm[fk];for(var d=0;d<20&&fiber;d++){var props=fiber.memoizedProps||(fiber.stateNode&&fiber.stateNode.props);if(props){var pv=props.editorView||props.view;if(pv&&pv.state&&pv.dispatch){v=pv;break;}}fiber=fiber.return;}}}` +
`if(v&&v.state&&v.dispatch){try{var doc=v.state.doc;var hasContent=doc.textContent&&doc.textContent.trim().length>0;if(hasContent){var endPos=doc.content.size>1?doc.content.size-1:doc.content.size;v.dispatch(v.state.tr.insertText(' ${val}',endPos));}else{var tr=v.state.tr;tr.replaceWith(0,doc.content.size,v.state.schema.text('${val}'));v.dispatch(tr);}v.focus();` +
// Verify the dispatch actually committed — LinkedIn's PM sometimes accepts the
// tr but rolls it back on the next tick (its readOnly plugin reasserts state).
// If the doc text doesn't contain our value within 50ms, route to native paste.
`var pmCheck=(v.state.doc.textContent||'')+' '+(pm.textContent||'');` +
`if(pmCheck.indexOf('${val.substring(0, Math.min(20, val.length))}')>=0){return 'Filled CE (ProseMirror)';}` +
`}catch(_pmE){}}}catch(e){}}` +
// ProseMirror detected but no view — in a dialog (LinkedIn share composer), go to
// native paste (real CGEvent Cmd+V) so ProseMirror's isTrusted-gated paste handler
// accepts the insertion and the dialog doesn't dismiss. Outside a dialog fall back
// to char-by-char beforeinput (works on Discord-style PM without dismissal risk).
`if(pm&&!v){var inDlg=!!el.closest('[role="dialog"]');if(inDlg){return '__NATIVE_PASTE_DIALOG__';}return '__PM_CHARBYCHAR__';}` +
// Closure/Medium detection — signal for native paste.
// Match any of: closure_uid_ key on element, medium.com hostname, ancestor with
// closure markers, or window.goog.events/goog.editor.Plugin globals (broader catch
// for Closure-built editors outside Medium).
`var isClosure=false;` +
`try{` +
`if(Object.keys(el).some(function(k){return k.indexOf('closure_uid_')===0||k.indexOf('closure_lm_')===0;}))isClosure=true;` +
`else if(location.hostname.indexOf('medium.com')>=0)isClosure=true;` +
`else if(typeof goog!=='undefined'&&goog&&(goog.events||goog.editor))isClosure=true;` +
`else{var clCur=el.parentElement,clHop=0;while(clCur&&clHop<10){if(Object.keys(clCur).some(function(k){return k.indexOf('closure_uid_')===0;})){isClosure=true;break;}clCur=clCur.parentElement;clHop++;}}` +
`}catch(_clE){}` +
`if(isClosure){return '__CLOSURE_NATIVE_PASTE__';}` +
// Synthetic ClipboardEvent paste — works on ProseMirror, TipTap, Slate, and most modern editors
// that don't respond to execCommand but DO handle paste events
// Pre-clear: select all + delete BEFORE paste. Some editors (X's tweetTextarea_0
// when it has URL-prefilled content from /intent/post?text=, Quill in some configs)
// append paste content to selection instead of replacing — explicit delete avoids
// the duplication seen when the textarea was pre-populated by URL parameters.
`try{el.focus();var sel2=window.getSelection();if(sel2.rangeCount){var rng=document.createRange();rng.selectNodeContents(el);sel2.removeAllRanges();sel2.addRange(rng);document.execCommand('delete',false,null);}var dt=new DataTransfer();dt.setData('text/plain','${val}');var pe=new ClipboardEvent('paste',{bubbles:true,cancelable:true,clipboardData:dt});var handled=!el.dispatchEvent(pe);if(handled||el.textContent.indexOf('${val.substring(0, 20)}')>=0){return 'Filled CE (synthetic paste)';}}catch(ep){}` +
// Synthetic paste did not verify — in a dialog, route to native paste (CGEvent Cmd+V)
// to produce real isTrusted:true events. Avoids LinkedIn-style dialog dismissal too.
`if(!!el.closest('[role="dialog"]')){return '__NATIVE_PASTE_DIALOG__';}` +
// Default contenteditable: selectAll+delete+insert. No blur — blur dismisses dialogs
// and nearby popovers; React only needs 'input' to see the change.
`document.execCommand('selectAll');document.execCommand('delete');document.execCommand('insertText',false,'${val}');el.dispatchEvent(new Event('input',{bubbles:true}));return 'Filled contenteditable';}` +
// Standard input/textarea with _valueTracker.
// • focus() FIRST — Formik/HubSpot read focused state during onChange validation.
// • InputEvent with inputType:'insertReplacementText' + data — React 18 RSC, Next.js,
// and Featured.com require a real InputEvent (not plain Event) to update store state.
// • composed:true on every event — pierces Shadow DOM (Reddit, web components).
// • blur/focusout SUPPRESSED inside dialogs — blur dismisses LinkedIn share composer
// and many MUI/Radix dialogs. React only needs 'input' to see the change anyway.
// • Post-fill verification — if el.value !== expected after dispatch, signal the wrapper
// to try native CGEvent paste fallback (real isTrusted events).
`var inDlg=!!(el.closest&&el.closest('[role="dialog"]'));` +
`var t=el._valueTracker;if(t)t.setValue('');` +
`var proto=el.tagName==='TEXTAREA'?window.HTMLTextAreaElement.prototype:window.HTMLInputElement.prototype;` +
`var s=Object.getOwnPropertyDescriptor(proto,'value');` +
`if(s&&s.set){s.set.call(el,'${val}');}else{el.value='${val}';}` +
`try{el.dispatchEvent(new InputEvent('input',{inputType:'insertReplacementText',data:'${val}',bubbles:true,composed:true,cancelable:true}));}catch(_iE){el.dispatchEvent(new Event('input',{bubbles:true,composed:true}));}` +
`el.dispatchEvent(new Event('change',{bubbles:true,composed:true}));` +
`if(!inDlg){el.dispatchEvent(new Event('blur',{bubbles:true,composed:true}));el.dispatchEvent(new Event('focusout',{bubbles:true,composed:true}));el.focus();}` +
`var actual=(el.value!==undefined?el.value:'');` +
`if(actual!=='${val}'){return '__FILL_VALUE_MISMATCH__:'+actual.substring(0,80);}` +
`return 'Filled: '+actual.substring(0,50);}catch(e){return 'ERR: '+e.message;}})()`
);
// ProseMirror editor with no view access: use char-by-char with beforeinput events
if (result === "__PM_CHARBYCHAR__") {
const rawValue = value;
const lines = rawValue.split('\n');
const charInserts = lines.map((line, i) => {
const escaped = line.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
let cmds = '';
if (i > 0) {
cmds += `t.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',keyCode:13,code:'Enter',bubbles:true,cancelable:true}));`;
cmds += `t.dispatchEvent(new InputEvent('beforeinput',{inputType:'insertParagraph',bubbles:true,cancelable:true}));`;
cmds += `document.execCommand('insertParagraph');`;
cmds += `t.dispatchEvent(new InputEvent('input',{inputType:'insertParagraph',bubbles:true}));`;
cmds += `t.dispatchEvent(new KeyboardEvent('keyup',{key:'Enter',keyCode:13,code:'Enter',bubbles:true}));`;
}
if (escaped.length > 0) {
// Insert text in one beforeinput + execCommand, then fire input
cmds += `t.dispatchEvent(new InputEvent('beforeinput',{data:'${escaped}',inputType:'insertText',bubbles:true,cancelable:true}));`;
cmds += `document.execCommand('insertText',false,'${escaped}');`;
cmds += `t.dispatchEvent(new InputEvent('input',{data:'${escaped}',inputType:'insertText',bubbles:true}));`;
}
return cmds;
}).join('');
const fillResult = await runJS(
`(function(){` +
`var el=document.querySelector('${escJsSingleQuote(selector)}');` +
`if(!el)return 'Element not found';` +
`el.focus();el.click();` +
`var t=document.activeElement||el;` +
charInserts +
// Verification: count actual text length vs expected. If char-by-char dropped
// intermediate paragraphs (Hashnode-style Tiptap with markdown-like chars at
// line starts: `>`, `**`, `[`), fall back to execCommand('insertHTML') with
// paragraph-wrapped HTML.
`var actualLen=(el.innerText||'').length;` +
`var expectedLen=${value.length};` +
`if(actualLen<expectedLen*0.6){` +
// Clear and re-fill via insertHTML
`var sel=window.getSelection();var r=document.createRange();r.selectNodeContents(el);sel.removeAllRanges();sel.addRange(r);` +
`document.execCommand('delete',false,null);` +
// Build paragraph HTML from the original value
`var paras=${JSON.stringify(value)}.split(/\\n\\n+/).filter(function(p){return p.trim().length>0;});` +
`var html=paras.map(function(p){var safe=p.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\\n/g,'<br>');return '<p>'+safe+'</p>';}).join('');` +
`el.focus();var sel2=window.getSelection();var r2=document.createRange();r2.selectNodeContents(el);r2.collapse(true);sel2.removeAllRanges();sel2.addRange(r2);` +
`document.execCommand('insertHTML',false,html);` +
`return 'Filled CE (ProseMirror insertHTML fallback, '+(el.innerText||'').length+'/'+expectedLen+')';` +
`}` +
`return 'Filled CE (ProseMirror char-by-char)';` +
`})()`
);
return fillResult;
}
// Closure/Medium editor: insert line-by-line via execCommand in small batches
// No focus stealing, no System Events, no clipboard manipulation.
// Medium's Closure editor accepts execCommand('insertText') for individual lines
// and execCommand('insertParagraph') for line breaks — the key is doing it
// within a single do-JavaScript call so the mutation observer stays in sync.
if (result === "__CLOSURE_NATIVE_PASTE__") {
const rawValue = value;
// Split into lines, escape each for JS string
const lines = rawValue.split('\n');
// Build a JS script that inserts line by line
const lineInserts = lines.map((line, i) => {
const escaped = line.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
if (i === 0) {
return escaped.length > 0 ? `document.execCommand('insertText',false,'${escaped}');` : '';
}
return `document.execCommand('insertParagraph');` +
(escaped.length > 0 ? `document.execCommand('insertText',false,'${escaped}');` : '');
}).join('');
const fillResult = await runJS(
`(function(){` +
`var el=document.querySelector('${escJsSingleQuote(selector)}');` +
`if(!el)return 'Element not found';` +
`el.focus();el.click();` +
`var sel=window.getSelection();if(sel.rangeCount){var r=document.createRange();r.selectNodeContents(el);r.collapse(false);sel.removeAllRanges();sel.addRange(r);}` +
lineInserts +
`return 'Filled CE (Closure line-by-line)';` +
`})()`
);
return fillResult;
}
// Standard input/textarea path returned with el.value mismatched against expected —
// typically Next.js RSC (Featured.com) or HubSpot Formik silently ignored the property
// descriptor setter. Fall back to native CGEvent Cmd+V paste — real keyboard events
// route through the framework's normal input pipeline.
if (typeof result === 'string' && result.startsWith('__FILL_VALUE_MISMATCH__')) {
const sel = escJsSingleQuote(selector);
await runJS(
`(function(){var el=document.querySelector('${sel}');if(!el)return 'not-found';` +
`el.focus();if('select' in el){el.select();}else if(el.setSelectionRange){el.setSelectionRange(0,(el.value||'').length);}` +
`return 'focused';})()`
);
await new Promise(r => setTimeout(r, 30));
try {
await _nativeTypeViaClipboard(value);
return `Filled (native paste fallback after value-mismatch, ${value.length} chars)`;
} catch (e) {
return `${result} (native paste fallback failed: ${e.message})`;
}
}
// ProseMirror/contenteditable inside a dialog (LinkedIn share composer is the canonical
// case). Synthetic events get rejected by isTrusted-gated paste handlers, and stray blur
// events dismiss the dialog. Use CGEvent Cmd+V — real paste, isTrusted:true, windowed so
// no focus steal. Requires the element to be focused + have a collapsed selection at end.
if (result === "__NATIVE_PASTE_DIALOG__") {
const sel = escJsSingleQuote(selector);
await runJS(
`(function(){var el=document.querySelector('${sel}');if(!el)return 'not-found';` +
`el.focus();` +
// Select all existing content so Cmd+V replaces (not appends)
`var s=window.getSelection();if(s){var r=document.createRange();r.selectNodeContents(el);s.removeAllRanges();s.addRange(r);}` +
`return 'focused';})()`
);
await new Promise(r => setTimeout(r, 50));
try {
await _nativeTypeViaClipboard(value);
return `Filled CE (native paste in dialog, ${value.length} chars)`;
} catch (e) {
return `ERR native paste in dialog: ${e.message}`;
}
}
return result;
}
// Verify the framework-level state of an editor or input matches `expected`.
// Modern editors (ProseMirror, Lexical, Closure, React-controlled inputs) maintain
// state separately from the DOM — `.value` or `.textContent` can show the new text
// while the framework's internal store still holds the old value, so a Submit click
// sends the stale data. Call this after `fill` and BEFORE `click`-Submit.
//
// Returns JSON: { match: boolean, mode: 'input'|'prosemirror'|'lexical'|'closure'|'contenteditable',
// actual: string, expected: string, hint?: string }
export async function verifyState({ selector, expected, ref }) {
if (ref) selector = refSelector(ref);
if (!selector) throw new Error("verifyState requires selector or ref");
const sel = escJsSingleQuote(selector);
const exp = String(expected || '').replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
const expSnippet = String(expected || '').substring(0, 30).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
return runJS(
`(function(){
var el = document.querySelector('${sel}');
if (!el && window.mcpQuerySelectorDeep) el = window.mcpQuerySelectorDeep('${sel}');
if (!el) return JSON.stringify({ match: false, mode: 'not-found', actual: '', expected: '${exp}' });
var expected = '${exp}';
var snippet = '${expSnippet}';
// ProseMirror — check view state, not DOM
var pm = el.closest && el.closest('.ProseMirror') || el.querySelector && el.querySelector('.ProseMirror');
if (pm) {
var v = (pm.pmViewDesc && pm.pmViewDesc.view) || (pm.cmView && pm.cmView.view) || null;
if (!v) {
var keys = Object.keys(pm);
for (var ki = 0; ki < keys.length; ki++) { var o = pm[keys[ki]]; if (o && o.state && o.dispatch) { v = o; break; } }
}
if (!v) {
var fk = Object.keys(pm).find(function(k){return k.indexOf('__reactFiber')===0||k.indexOf('__reactInternalInstance')===0;});
if (fk) { var fb = pm[fk]; for (var d=0; d<20 && fb; d++){ var pp=fb.memoizedProps||(fb.stateNode&&fb.stateNode.props); if (pp){ var pv=pp.editorView||pp.view; if (pv&&pv.state){ v=pv; break; }} fb=fb.return; }}
}
var pmText = v && v.state && v.state.doc ? (v.state.doc.textContent || '') : (pm.textContent || '');
return JSON.stringify({ match: pmText.indexOf(snippet) >= 0, mode: 'prosemirror', actual: pmText.substring(0, 200), expected: expected.substring(0, 200) });
}
// Lexical — read editor state
var lexEl = el.closest && el.closest('[data-lexical-editor="true"]');
var lex = lexEl && lexEl.__lexicalEditor;
if (!lex) {
var lc = el; for (var li = 0; li < 15 && lc; li++) { if (lc.__lexicalEditor) { lex = lc.__lexicalEditor; break; } lc = lc.parentElement; }
}
if (lex && typeof lex.getEditorState === 'function') {
try {
var lexText = '';
lex.getEditorState().read(function(){
lexText = (lexEl || el).textContent || '';
});
return JSON.stringify({ match: lexText.indexOf(snippet) >= 0, mode: 'lexical', actual: lexText.substring(0, 200), expected: expected.substring(0, 200) });
} catch (_lE) {}
}
// Closure (Medium and similar) — check element textContent (Closure mutates DOM directly)
var isClosure = Object.keys(el).some(function(k){return k.indexOf('closure_uid_')===0;}) || location.hostname.indexOf('medium.com') >= 0;
if (isClosure) {
var clText = el.textContent || '';
return JSON.stringify({ match: clText.indexOf(snippet) >= 0, mode: 'closure', actual: clText.substring(0, 200), expected: expected.substring(0, 200) });
}
// Generic contenteditable
if (el.isContentEditable) {
var ceText = el.textContent || '';
return JSON.stringify({ match: ceText.indexOf(snippet) >= 0, mode: 'contenteditable', actual: ceText.substring(0, 200), expected: expected.substring(0, 200) });
}
// Standard input/textarea/select
var actualVal = el.value !== undefined ? String(el.value) : '';
var match = actualVal === expected;
var hint = '';
// Detect React store/_valueTracker mismatch — common Featured.com / Next.js RSC bug
if (!match && el._valueTracker && typeof el._valueTracker.getValue === 'function') {
var tracked = el._valueTracker.getValue();
if (tracked !== actualVal) hint = 'React _valueTracker out of sync (tracked=' + JSON.stringify(tracked.substring(0, 60)) + ')';
}
return JSON.stringify({ match: match, mode: 'input', actual: actualVal.substring(0, 200), expected: expected.substring(0, 200), hint: hint });
})()`
);
}
export async function clearField({ selector }) {
const sel = escJsSingleQuote(selector);
return runJS(
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found: ${sel}';if(el.isContentEditable){el.focus();document.execCommand('selectAll');document.execCommand('delete');el.dispatchEvent(new Event('input',{bubbles:true}));return 'Cleared (contenteditable)';}var t=el._valueTracker;if(t)t.setValue('x');var p=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;var d=Object.getOwnPropertyDescriptor(p,'value');if(d&&d.set)d.set.call(el,'');else el.value='';el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));el.dispatchEvent(new Event('blur',{bubbles:true}));return 'Cleared';})()`
);
}
export async function selectOption({ selector, value, ref }) {
// ref/deep finder: native <select> elements inside same-origin iframes or shadow
// DOM are invisible to a top-frame document.querySelector. mcpFindRef (snapshot ref)
// and mcpQuerySelectorDeep traverse those roots — the same finders click() uses.
await ensureHelpers();
const val = String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
let finder;
if (ref) {
finder = `mcpFindRef('${escJsSingleQuote(ref)}')`;
} else if (selector) {
const sel = escJsSingleQuote(selector);
finder = `(document.querySelector('${sel}')||mcpQuerySelectorDeep('${sel}'))`;
} else {
throw new Error("selectOption requires 'ref' or 'selector'");
}
return runJS(
`(function(){var el=${finder};if(!el)return 'Element not found';el.focus();var t=el._valueTracker;if(t)t.setValue('');var d=Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype,'value');if(d&&d.set){d.set.call(el,'${val}');}else{el.value='${val}';}var m=false;for(var i=0;i<el.options.length;i++){if(el.options[i].value==='${val}'){el.selectedIndex=i;m=true;break;}}if(!m||el.value!=='${val}'){var norm=function(s){return s.replace(/[\\u200B-\\u200F\\u202A-\\u202E\\u2066-\\u2069\\uFEFF]/g,'').replace(/[\\u2010-\\u2015\\u2212\\uFE58\\uFE63\\uFF0D]/g,'-').replace(/\\s*-\\s*/g,'-').replace(/\\s+/g,' ').trim();};var cv=norm('${val}');for(var i=0;i<el.options.length;i++){if(norm(el.options[i].value)===cv||norm(el.options[i].text)===cv){el.selectedIndex=i;if(d&&d.set){d.set.call(el,el.options[i].value);}else{el.value=el.options[i].value;}m=true;break;}}if(!m){for(var i=0;i<el.options.length;i++){var nv=norm(el.options[i].value),nt=norm(el.options[i].text);if(nv.indexOf(cv)>=0||nt.indexOf(cv)>=0||cv.indexOf(nv)>=0||cv.indexOf(nt)>=0){if(i===0&&el.options.length>1)continue;el.selectedIndex=i;if(d&&d.set){d.set.call(el,el.options[i].value);}else{el.value=el.options[i].value;}m=true;break;}}}}el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));el.dispatchEvent(new Event('blur',{bubbles:true}));return 'Selected: '+el.value+' (index '+el.selectedIndex+')';})()`
);
}
// React-Select v5 / Radix-style controlled-select bypass.
// Walks React fiber up from the target element to find a Select component
// (props.options + props.onChange) and calls onChange directly — no menu UI.
// Use when safari_click on the chevron/option fails (Cloudflare token form,
// portal-rendered selects that intercept synthetic events).
export async function reactSelectSet({ selector, ref, value }) {
await ensureHelpers();
if (value === undefined || value === null) throw new Error("reactSelectSet requires 'value' (option label)");
let finder;
if (ref) {
const safeRef = escJsSingleQuote(ref);
finder = `mcpFindRef('${safeRef}')`;
} else if (selector) {
const sel = escJsSingleQuote(selector);
finder = `mcpQuerySelectorDeep('${sel}')`;
} else {
throw new Error("reactSelectSet requires 'ref' or 'selector'");
}
const safeValue = String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const js = `(function(){var el=${finder};if(!el)return JSON.stringify({ok:false,error:'element not found'});return window.mcpReactSelectSet(el,'${safeValue}');})()`;
return runJS(js);
}
export async function reactSelectListOptions({ selector, ref }) {
await ensureHelpers();
let finder;
if (ref) {
const safeRef = escJsSingleQuote(ref);
finder = `mcpFindRef('${safeRef}')`;
} else if (selector) {
const sel = escJsSingleQuote(selector);
finder = `mcpQuerySelectorDeep('${sel}')`;
} else {
throw new Error("reactSelectListOptions requires 'ref' or 'selector'");
}
const js = `(function(){var el=${finder};if(!el)return JSON.stringify({ok:false,error:'element not found'});return window.mcpReactSelectListOptions(el);})()`;
return runJS(js);
}
export async function fillForm({ fields }) {
// Single JS call for ALL fields (instead of N separate osascript calls).
// Same React-state-sync logic as `fill`: _valueTracker reset, prototype-correct setter,
// InputEvent with inputType, composed events for shadow DOM, dialog-aware blur.
const fieldsJSON = JSON.stringify(fields.map(f => ({
s: escJsSingleQuote(f.selector),
v: f.value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n"),
})));
return runJS(
`(function(){
var fields = ${fieldsJSON};
var results = [];
fields.forEach(function(f) {
var el = document.querySelector(f.s);
if (!el) {
var roots = window.mcpCollectRoots ? window.mcpCollectRoots() : [document];
for (var ri = 0; ri < roots.length && !el; ri++) {
try { el = roots[ri].querySelector(f.s); } catch (_e) {}
}
}
if (!el) { results.push('Not found: ' + f.s); return; }
el.focus();
var inDlg = !!(el.closest && el.closest('[role="dialog"]'));
if (el.isContentEditable) {
try {
var sel = window.getSelection();
if (sel) { var rng = document.createRange(); rng.selectNodeContents(el); sel.removeAllRanges(); sel.addRange(rng); }
document.execCommand('delete');
} catch (_seE) {}
document.execCommand('insertText', false, f.v);
el.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
} else {
var t = el._valueTracker; if (t) t.setValue('');
var proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype :
el.tagName === 'SELECT' ? window.HTMLSelectElement.prototype :
window.HTMLInputElement.prototype;
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
if (setter && setter.set) setter.set.call(el, f.v);
else el.value = f.v;
try { el.dispatchEvent(new InputEvent('input', { inputType: 'insertReplacementText', data: f.v, bubbles: true, composed: true, cancelable: true })); }
catch (_iE) { el.dispatchEvent(new Event('input', { bubbles: true, composed: true })); }
el.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
if (!inDlg) {
el.dispatchEvent(new Event('blur', { bubbles: true, composed: true }));
el.dispatchEvent(new Event('focusout', { bubbles: true, composed: true }));
}
}
var actual = el.value !== undefined ? String(el.value) : (el.textContent || '');
var match = actual === f.v || actual.indexOf(f.v.substring(0, 20)) >= 0;
results.push((match ? 'Filled' : 'PARTIAL') + ': ' + el.tagName + ' with "' + f.v.substring(0, 30) + '"');
});
return results.join('\\n');
})()`
);
}
// ========== KEYBOARD ==========
// JS key names for KeyboardEvent
const jsKeyMap = {
enter: "Enter", return: "Enter", tab: "Tab", escape: "Escape", space: " ",
delete: "Backspace", backspace: "Backspace", up: "ArrowUp", down: "ArrowDown",
left: "ArrowLeft", right: "ArrowRight", home: "Home", end: "End",
pageup: "PageUp", pagedown: "PageDown",
f1: "F1", f2: "F2", f3: "F3", f4: "F4", f5: "F5", f6: "F6",
};
// macOS virtual key codes for CGEvent keyboard (used by _helperNativeKeyboard).
// These are the HID-level codes that postToPid uses, NOT JS keyCode values.
const macKeyCodeMap = {
enter: 36, return: 36, "numpad-enter": 76,
tab: 48, space: 49, delete: 51, backspace: 51, escape: 53,
up: 126, "arrowup": 126, down: 125, "arrowdown": 125,
left: 123, "arrowleft": 123, right: 124, "arrowright": 124,
home: 115, end: 119, pageup: 116, pagedown: 121,
f1: 122, f2: 120, f3: 99, f4: 118, f5: 96, f6: 97,
a: 0, s: 1, d: 2, f: 3, h: 4, g: 5, z: 6, x: 7, c: 8, v: 9,
b: 11, q: 12, w: 13, e: 14, r: 15, y: 16, t: 17,
"1": 18, "2": 19, "3": 20, "4": 21, "6": 22, "5": 23,
"=": 24, "9": 25, "7": 26, "-": 27, "8": 28, "0": 29,
"]": 30, o: 31, u: 32, "[": 33, i: 34, p: 35,
l: 37, j: 38, "'": 39, k: 40, ";": 41, "\\": 42,
",": 43, "/": 44, n: 45, m: 46, ".": 47, "`": 50,
};
// Native keyboard via CGEvent — sends a single key (with optional modifiers)
// to the Safari window WITHOUT activating Safari or moving the mouse.
// This produces isTrusted:true events that bypass React trust checks (Discord ProseMirror,
// Slack virtualized editors, etc.) without any focus stealing. Requires Safari window ID.
// Native type via clipboard paste (CGEvent Cmd+V targeted to Safari window).
// Inserts text into ANY editor (ProseMirror, Slate, Draft.js, regular inputs,
// contenteditable, cross-origin iframes) by going through the real paste pipeline.
// Unlike safari_fill's synthetic paste, this updates the framework's internal
// model state, so subsequent operations (like pressing Enter to submit in Discord)
// see the text as "really there".
export async function nativeType(args) {
await ensureHelpers();
if (!args || !args.value) throw new Error("nativeType requires 'value'");
return await _withTargetTabFronted(() => _nativeTypeImpl(args));
}
async function _nativeTypeImpl({ value, selector, ref }) {
// Focus the target element if selector/ref provided
if (ref || selector) {
const sel = ref ? refSelector(ref) : escJsSingleQuote(selector);
await runJS(`(function(){ var el=document.querySelector('${sel}'); if(el){el.focus();el.click();} return el?'focused':'not found'; })()`);
await new Promise(r => setTimeout(r, 50));
}
return await _nativeTypeViaClipboard(value);
}
export async function nativeKeyboard(args) {
await ensureHelpers();
if (!args || !args.key) throw new Error("nativeKeyboard requires 'key'");
return await _withTargetTabFronted(() => _nativeKeyboardImpl(args));
}
async function _nativeKeyboardImpl({ key, modifiers = [] }) {
const k = String(key).toLowerCase();
const keyCode = macKeyCodeMap[k];
if (keyCode === undefined) {
throw new Error(`nativeKeyboard: unsupported key "${key}". Supported: ${Object.keys(macKeyCodeMap).join(", ")}`);
}
const geo = await _getSafariWindowGeometry();
if (!geo.windowId) throw new Error("Cannot native-key without Safari window ID — would steal focus");
const normalized = (modifiers || []).map(m => String(m).toLowerCase());
await _helperNativeKeyboard(keyCode, normalized, geo.windowId);
const modsLabel = normalized.length ? normalized.join("+") + "+" : "";
return `Native key: ${modsLabel}${k} (CGEvent to window ${geo.windowId}, no focus steal)`;
}
// System Events key codes — only used for paste_image, upload_file, save_pdf
// (functions that truly require OS-level UI interaction)
export async function pressKey({ key, modifiers = [] }) {
const hasCmdOrCtrl = modifiers.some((m) => ["cmd", "ctrl"].includes(m.toLowerCase()));
const hasShift = modifiers.some((m) => m.toLowerCase() === "shift");
const k = key.toLowerCase();
// Try to handle EVERYTHING via JavaScript — no System Events, no focus stealing
if (hasCmdOrCtrl) {
// Map Cmd/Ctrl shortcuts to JS execCommand equivalents
const jsShortcuts = {
a: "document.execCommand('selectAll')",
c: `(function(){
var sel = window.getSelection();
if (sel.toString()) { navigator.clipboard.writeText(sel.toString()).catch(function(){}); }
return 'Copied';
})()`,
x: "document.execCommand('cut')",
z: hasShift ? "document.execCommand('redo')" : "document.execCommand('undo')",
b: "document.execCommand('bold')",
i: "document.execCommand('italic')",
u: "document.execCommand('underline')",
};
if (jsShortcuts[k]) {
await runJS(jsShortcuts[k]);
return `Pressed: ${modifiers.join("+")}+${key} (via JS)`;
}
// Cmd+V (paste) — read clipboard via AppleScript (no activate!), inject via JS
if (k === "v") {
// Cross-origin iframe: JS can't paste into it, use CGEvent Cmd+V (no focus steal)
const activeTag = await runJS(`document.activeElement ? document.activeElement.tagName : ''`);
if (activeTag === 'IFRAME') {
// MUST have windowId — windowId=0 would steal focus and move mouse
const geo = await _getSafariWindowGeometry();
if (!geo.windowId) throw new Error("Cannot paste into iframe without Safari window ID — would steal focus");
await _helperNativeKeyboard(9, ["cmd"], geo.windowId);
await new Promise(r => setTimeout(r, 100)); // 100ms is enough for Cmd+V to process
return `Pressed: ${modifiers.join("+")}+v (CGEvent Cmd+V into iframe, no focus steal)`;
}
const clipText = await osascript(`the clipboard as text`).catch(() => "");
if (clipText) {
const escaped = clipText.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
await runJS(
`(function(){
var el = document.activeElement;
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) {
var start = el.selectionStart, end = el.selectionEnd;
var val = el.value;
el.value = val.substring(0, start) + '${escaped}' + val.substring(end);
el.selectionStart = el.selectionEnd = start + '${escaped}'.length;
el.dispatchEvent(new Event('input', {bubbles:true}));
el.dispatchEvent(new Event('change', {bubbles:true}));
return 'Pasted (input)';
}
// ProseMirror: use native API to ensure state updates
var pm = el && el.closest && el.closest('.ProseMirror');
if (pm) {
var v = null;
if (pm.pmViewDesc && pm.pmViewDesc.view) v = pm.pmViewDesc.view;
else { var keys = Object.keys(pm); for (var i=0;i<keys.length;i++) { var o=pm[keys[i]]; if(o&&o.state&&o.dispatch){v=o;break;} } }
if (v && v.dispatch) {
v.dispatch(v.state.tr.insertText('${escaped}'));
v.focus();
return 'Pasted (ProseMirror)';
}
}
// Default: execCommand
document.execCommand('insertText', false, '${escaped}');
return 'Pasted';
})()`
);
}
return `Pressed: ${modifiers.join("+")}+v (via JS, no focus steal)`;
}
// Other Cmd shortcuts — dispatch JS KeyboardEvent
await runJS(
`(function(){
var el = document.activeElement || document.body;
var e = new KeyboardEvent('keydown', {key:'${k}',code:'Key${k.toUpperCase()}',metaKey:true,ctrlKey:false,bubbles:true,cancelable:true});
el.dispatchEvent(e);
el.dispatchEvent(new KeyboardEvent('keyup', {key:'${k}',metaKey:true,bubbles:true}));
return 'Pressed';
})()`
);
return `Pressed: ${modifiers.join("+")}+${key}`;
}
// Non-modifier keys: pure JavaScript (no System Events)
const jsKey = jsKeyMap[k] || key;
const safeKey = escJsSingleQuote(jsKey);
// W3C `code` values: special keys are NOT "Key"-prefixed ("Enter", "ArrowUp", ...);
// only letters are ("KeyA"), digits are "Digit1". Apps that route on event.code
// (Notion, Monaco, Google Docs) ignore a bogus "KeyEnter".
const jsCodeMap = {
"Enter": "Enter", "Tab": "Tab", "Escape": "Escape", " ": "Space",
"Backspace": "Backspace", "Delete": "Delete",
"ArrowUp": "ArrowUp", "ArrowDown": "ArrowDown", "ArrowLeft": "ArrowLeft", "ArrowRight": "ArrowRight",
"Home": "Home", "End": "End", "PageUp": "PageUp", "PageDown": "PageDown",
"F1": "F1", "F2": "F2", "F3": "F3", "F4": "F4", "F5": "F5", "F6": "F6",
};
const jsCode = jsCodeMap[jsKey]
|| (/^[a-z]$/i.test(jsKey) ? "Key" + jsKey.toUpperCase()
: /^[0-9]$/.test(jsKey) ? "Digit" + jsKey
: jsKey);
const safeCode = escJsSingleQuote(jsCode);
const shiftKey = hasShift;
const altKey = modifiers.some((m) => m.toLowerCase() === "alt");
const result = await runJS(
`(function(){
var el = document.activeElement || document.body;
var opts = {key:'${safeKey}',code:'${safeCode}',bubbles:true,cancelable:true,shiftKey:${shiftKey},altKey:${altKey}};
var down = new KeyboardEvent('keydown', opts);
var prevented = !el.dispatchEvent(down);
if (!prevented) {
if ('${safeKey}' === 'Enter') {
if (el.tagName === 'INPUT') { el.form && el.form.dispatchEvent(new Event('submit',{bubbles:true})); }
else if (el.tagName === 'TEXTAREA') { document.execCommand('insertLineBreak'); }
else if (el.isContentEditable && ${shiftKey}) { document.execCommand('insertLineBreak'); }
// ContentEditable + Enter (no Shift): do NOT insertLineBreak.
// Modern editors (Discord Slate, Slack, Notion, Medium) handle Enter
// in their own keydown listener to trigger submit/send/newBlock.
// insertLineBreak would double-act: the app submits AND we add a newline.
} else if ('${safeKey}' === 'Tab') {
var focusable = [...document.querySelectorAll('input,textarea,select,button,a,[tabindex]')].filter(function(e){return e.tabIndex>=0;});
var idx = focusable.indexOf(el);
var next = ${shiftKey} ? focusable[idx-1] : focusable[idx+1];
if (next) next.focus();
} else if ('${safeKey}' === 'Backspace') {
document.execCommand('delete');
} else if ('${safeKey}' === 'Escape') {
el.blur();
}
}
el.dispatchEvent(new KeyboardEvent('keyup', opts));
// ContentEditable + Enter: check if the app actually handled it.
// If editor content didn't change (no submit, no newline), the JS
// keydown was ignored (isTrusted:false). Signal for native fallback.
if ('${safeKey}' === 'Enter' && el.isContentEditable && !${shiftKey} && !prevented) {
return '__ENTER_NOT_HANDLED__';
}
return 'OK';
})()`
);
// Fallback for ContentEditable Enter that wasn't handled by JS keydown:
// apps like Discord/Slack require isTrusted:true. Briefly activate Safari
// (~50ms), send real keystroke, then immediately restore the previous
// frontmost app. The visual flash is imperceptible (<100ms total).
if (result === '__ENTER_NOT_HANDLED__') {
return `Pressed: enter (JS keydown dispatched but not handled — the app likely requires isTrusted:true. Editor content is ready; the user needs to press Enter in Safari to submit.)`;
}
return `Pressed: ${modifiers.length ? modifiers.join("+") + "+" : ""}${key}`;
}
// ========== NATIVE TYPE VIA CLIPBOARD PASTE ==========
// Uses OS-level clipboard + CGEvent Cmd+V to insert text. Produces a REAL paste event
// that ProseMirror/Slate/Draft.js process through their native paste handlers, updating
// internal model state (not just the DOM). Also works for cross-origin iframes.
// NO focus steal — sends CGEvent Cmd+V targeted to Safari window ID.
//
// Why this matters: synthetic DOM manipulation (safari_fill's "synthetic paste") writes
// to the DOM but doesn't update React/ProseMirror state. Discord's onSubmit reads from
// state, not DOM, so Enter submits empty. This function fixes that by going through the
// real paste pipeline.
async function _nativeTypeViaClipboard(text) {
await _acquireClipboardLock();
let savedClipboard;
try {
// Save current clipboard
savedClipboard = await _saveClipboard();
// Set clipboard to our text via pipe (safe from shell injection)
await _pbcopy(text);
// Paste via CGEvent Cmd+V targeted to Safari window — NO activate, NO focus steal
// MUST have windowId — global CGEvent (windowId=0) would steal focus and move mouse
const geo = await _getSafariWindowGeometry();
if (!geo.windowId) {
throw new Error("Cannot native-paste without Safari window ID — would steal focus");
}
// keyCode 9 = V key, flags: ["cmd"]
await _helperNativeKeyboard(9, ["cmd"], geo.windowId);
// Wait for paste to settle
await new Promise(r => setTimeout(r, 100)); // 100ms is enough for Cmd+V to process
return `Typed ${text.length} chars (native paste into iframe)`;
} finally {
// ALWAYS restore the user's clipboard + release the lock — even if the daemon died
// mid-paste — so the user never silently inherits the tool's pasted text.
if (savedClipboard !== undefined) await _restoreClipboard(savedClipboard).catch(() => {});
if (_clipboardLocked) _releaseClipboardLock();
}
}
export async function typeText({ text, selector, ref }) {
if (ref) selector = refSelector(ref);
if (selector) {
const sel = escJsSingleQuote(selector);
await runJS(`document.querySelector('${sel}')?.focus()`);
// Quick poll for focus to settle (was 200ms fixed sleep)
await new Promise((r) => setTimeout(r, 30));
}
// Cross-origin iframe detection: JS can't access content inside cross-origin iframes.
// When activeElement is an IFRAME, use native clipboard paste via System Events.
const activeTag = await runJS(`document.activeElement ? document.activeElement.tagName : ''`);
if (activeTag === 'IFRAME') {
return await _nativeTypeViaClipboard(text);
}
// Use execCommand("insertText") — the ONLY approach that works for BOTH:
// 1. Regular inputs/textareas (execCommand works natively)
// 2. ContentEditable (ProseMirror/Draft.js/Slate) — execCommand causes real DOM mutation
// → MutationObserver fires → framework detects change → state updates
// InputEvent dispatch does NOT work because it doesn't cause real DOM mutations.
const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n");
const result = await runJS(
`(function(){var el=document.activeElement;if(!el)return 'No focused element';` +
// ProseMirror: use native API
`var pm=el.closest&&el.closest('.ProseMirror');if(pm){try{var v=null;if(pm.pmViewDesc&&pm.pmViewDesc.view)v=pm.pmViewDesc.view;else{var keys=Object.keys(pm);for(var ki=0;ki<keys.length;ki++){var o=pm[keys[ki]];if(o&&o.state&&o.dispatch){v=o;break;}}}if(v&&v.dispatch){var tr=v.state.tr.insertText('${safeText}');v.dispatch(tr);v.focus();return 'Typed ${text.length} chars (ProseMirror)';}}catch(e){}}` +
// Closure/Medium: char-by-char with keyboard events + Enter handling
`var isClosure=el.isContentEditable&&(Object.keys(el).some(function(k){return k.startsWith('closure_uid_');})||location.hostname.includes('medium.com'));` +
`if(isClosure){var txt='${safeText}';for(var i=0;i<txt.length;i++){var target=document.activeElement||el;var ch=txt[i];if(ch==='\\n'){target.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',keyCode:13,bubbles:true}));document.execCommand('insertParagraph');target.dispatchEvent(new KeyboardEvent('keyup',{key:'Enter',keyCode:13,bubbles:true}));continue;}var kc=ch.charCodeAt(0);target.dispatchEvent(new KeyboardEvent('keydown',{key:ch,keyCode:kc,bubbles:true}));document.execCommand('insertText',false,ch);target.dispatchEvent(new InputEvent('input',{data:ch,inputType:'insertText',bubbles:true}));target.dispatchEvent(new KeyboardEvent('keyup',{key:ch,keyCode:kc,bubbles:true}));}return 'Typed ${text.length} chars (Closure char-by-char)';}` +
// Typeahead/combobox (LinkedIn Ember, ARIA autocomplete): needs real per-char key
// events so the widget runs its async server search. execCommand's single InputEvent
// opens the menu (aria-expanded=true) but never fires the fetch → empty option list.
`var isCombo=('value' in el)&&(el.getAttribute('role')==='combobox'||el.getAttribute('aria-autocomplete')||el.hasAttribute('aria-controls'));` +
`if(isCombo){var tc='${safeText}';for(var j=0;j<tc.length;j++){var cc=tc[j];var kk=cc.charCodeAt(0);el.dispatchEvent(new KeyboardEvent('keydown',{key:cc,keyCode:kk,bubbles:true}));document.execCommand('insertText',false,cc);el.dispatchEvent(new InputEvent('input',{data:cc,inputType:'insertText',bubbles:true}));el.dispatchEvent(new KeyboardEvent('keyup',{key:cc,keyCode:kk,bubbles:true}));}return 'Typed '+${text.length}+' chars (combobox char-by-char)';}` +
// Default: execCommand
`var ok=document.execCommand('insertText',false,'${safeText}');if(ok)return 'Typed '+${text.length}+' chars';` +
// Fallback for inputs where execCommand failed
`if('value' in el){var start=el.selectionStart||0;el.value=el.value.substring(0,start)+'${safeText}'+el.value.substring(el.selectionEnd||start);el.selectionStart=el.selectionEnd=start+${text.length};el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));return 'Typed '+${text.length}+' chars via value set';}return 'Could not type';})()`
);
// If JS typing failed and we're somehow in an iframe context, try native fallback
if (result === 'Could not type') {
return await _nativeTypeViaClipboard(text);
}
return result;
}
// ========== EDITOR SUPPORT (Monaco, CodeMirror) ==========
// Replace all content in a code editor (Monaco, CodeMirror, or ace)
// Used when typeText/fill can't handle the editor
export async function replaceEditorContent({ text }) {
// Escape for embedding in JS string
const safeText = text
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/\n/g, '\\n')
.replace(/\r/g, '');
// PREFLIGHT: Two-channel Monaco sync — update the model (visual) AND call
// the React wrapper's onChange (state). Airtable wraps Monaco in a React
// component whose "Finish editing" save reads from React state, not from
// the Monaco model. So setValue alone is "visually correct, stale on save".
// We walk the React fiber up from .monaco-editor until we find a component
// whose memoizedProps contain an `onChange` function + a `value` field,
// then call onChange(text) to sync React. Works for Airtable-style embeds
// and is a no-op on plain Monaco (VS Code web, GitHub) where setValue
// already covers the state.
const preflightFirstLine = escJsSingleQuote(text.split('\n')[0].slice(0, 20));
const monacoPreflight = await runJS(
`(function(){
var m = (typeof monaco !== 'undefined') ? monaco : window.monaco;
if (!m || !m.editor) return JSON.stringify({kind:'not-monaco'});
var hasTextarea = !!document.querySelector('.monaco-editor textarea.inputarea');
var models = []; try { models = m.editor.getModels() || []; } catch(e){}
if (!models.length) return JSON.stringify({kind:'no-model', hasTextarea: hasTextarea});
// Locate the React wrapper component that owns this Monaco embed.
// Prefer an editable wrapper (isReadOnly !== true); fall back to any
// wrapper if only a read-only preview exists (Airtable before Edit).
function findFiber(el) {
if (!el) return null;
var ks = Object.keys(el);
for (var j = 0; j < ks.length; j++) {
if (ks[j].indexOf('__reactFiber') === 0) return el[ks[j]];
}
return null;
}
function findWrapperWithOnChange(fiber) {
var c = fiber;
for (var i = 0; i < 40 && c; i++) {
var p = c.memoizedProps;
if (p && typeof p.onChange === 'function' && ('value' in p)) return c;
c = c.return;
}
return null;
}
var els = document.querySelectorAll('.monaco-editor');
var editableWrapper = null, anyWrapper = null;
for (var i = els.length - 1; i >= 0; i--) {
var f = findFiber(els[i].parentElement);
if (!f) continue;
var w = findWrapperWithOnChange(f);
if (!w) continue;
if (!anyWrapper) anyWrapper = w;
if (w.memoizedProps.isReadOnly !== true) { editableWrapper = w; break; }
}
var wrapper = editableWrapper || anyWrapper;
// Step 1: Monaco model setValue — updates visual + fires onDidChangeModelContent
try { models[models.length - 1].setValue('${safeText}'); }
catch(e) { return JSON.stringify({kind:'setValue-err', hasTextarea: hasTextarea, err: String(e && e.message)}); }
// Step 2: Sync React state via wrapper.onChange (if wrapper found).
// onChange may be silently guarded (e.g. Airtable's readOnly check).
// We only treat sync as real when the wrapper's value prop actually
// changes OR a sibling/parent expression state reflects the update
// on the next render tick.
var reactSynced = false;
var reactGuarded = false;
if (wrapper) {
var prevValue = wrapper.memoizedProps.value;
try {
wrapper.memoizedProps.onChange('${safeText}');
// Re-fetch the fiber's memoizedProps (may have been swapped by React)
var after = wrapper.alternate ? wrapper.alternate.memoizedProps : wrapper.memoizedProps;
var nowValue = (after && 'value' in after) ? after.value : wrapper.memoizedProps.value;
reactSynced = (nowValue !== prevValue);
// If value didn't change, onChange was a no-op (guarded by isReadOnly
// or permission check). Signal this so caller falls back to native paste.
reactGuarded = !reactSynced;
} catch(e) { reactSynced = false; reactGuarded = true; }
}
// Step 3: Verify DOM reflects the new content
var firstDom = document.querySelector('.monaco-editor .view-line');
var domTxt = firstDom ? firstDom.textContent : '';
var expected = '${preflightFirstLine}';
var domOk = expected.length < 5 || domTxt.indexOf(expected) !== -1;
return JSON.stringify({
kind: 'monaco',
domOk: domOk,
reactSynced: reactSynced,
reactGuarded: reactGuarded,
hasWrapper: !!wrapper,
editable: !!editableWrapper,
hasTextarea: hasTextarea,
models: models.length
});
})()`
);
let mpParsed = null;
try { mpParsed = JSON.parse(monacoPreflight); } catch(e) {}
// Fully synced: Monaco model + React state both updated.
if (mpParsed && mpParsed.kind === 'monaco' && mpParsed.domOk && mpParsed.reactSynced) {
return 'Monaco(model+react): replaced ' + text.split('\n').length + ' lines';
}
// Plain Monaco embed (no React wrapper) — setValue is sufficient.
if (mpParsed && mpParsed.kind === 'monaco' && mpParsed.domOk && !mpParsed.hasWrapper) {
return 'Monaco(model): replaced ' + text.split('\n').length + ' lines';
}
// Airtable-style embed where React fiber sync failed (wrapper.onChange was
// a no-op, or the site guards writes by permission/readOnly). Fall back to
// native clipboard paste via CGEvent: forces readOnly=false first, activates
// Safari, focuses the textarea, and sends Cmd+A/Cmd+V targeted to the window.
if (mpParsed && mpParsed.kind === 'monaco' && mpParsed.hasTextarea) {
const savedFrontApp = await saveFrontmostApp();
try {
// Step 1: Install editor capture hook (if not already) + try to find visible editable editor
await runJS(
`(function(){
if (!window.__mcpEditorHook) {
window.__mcpEditorHook = true;
window.__mcpCapturedEditors = [];
try { monaco.editor.onDidCreateEditor(function(e){window.__mcpCapturedEditors.push(e);}); } catch(e){}
}
// Force readOnly=false on all existing editors (harmless if already false)
try {
(window.__mcpCapturedEditors || []).forEach(function(e){
try { if (e.updateOptions) e.updateOptions({readOnly: false}); } catch(_){}
});
} catch(e){}
return 'hook-installed';
})()`
);
// Step 2: Activate Safari to frontmost — required for CGEvent keyboard to reach web content
await _helperActivateApp("com.apple.Safari");
await new Promise(r => setTimeout(r, 300));
// Step 3: Focus Monaco's input textarea
await runJS(`(function(){var t=document.querySelector('.monaco-editor textarea.inputarea');if(t){t.focus();return 'focused';}return 'no-textarea';})()`);
await new Promise(r => setTimeout(r, 100));
// Step 4: Cmd+A + Cmd+V via GLOBAL CGEvent (cghidEventTap) — reaches web content
// reliably since Safari is frontmost
await _helperNativeKeyboard(0, ["cmd"], 0); // Cmd+A global
await new Promise(r => setTimeout(r, 100));
// Write clipboard + paste via global CGEvent (not windowed)
await _acquireClipboardLock();
try {
const savedClip = await _saveClipboard();
await _pbcopy(text);
await _helperNativeKeyboard(9, ["cmd"], 0); // Cmd+V global
await new Promise(r => setTimeout(r, 200));
await _restoreClipboard(savedClip);
} finally {
_releaseClipboardLock();
}
await new Promise(r => setTimeout(r, 300));
// Restore original front app
if (savedFrontApp) await restoreFocusIfStolen(savedFrontApp);
return 'Monaco(native-paste): replaced ' + text.split('\n').length + ' lines';
} catch(e) {
if (savedFrontApp) await restoreFocusIfStolen(savedFrontApp).catch(() => {});
// Fall through to remaining editor-type checks
}
}
const result = await runJS(
`(function(){
// Monaco editor (Airtable, VS Code web, GitHub)
// Try both global 'monaco' and window.monaco — some sites expose one but not the other
var m = (typeof monaco !== 'undefined') ? monaco : window.monaco;
if (m && m.editor) {
// Try getModels first (works on Airtable and most Monaco embeds)
try {
var models = m.editor.getModels();
if (models && models.length > 0) {
models[models.length - 1].setValue('${safeText}');
return 'Monaco(model): replaced ' + '${safeText}'.split('\\n').length + ' lines';
}
} catch(e) {}
// Try getEditors (standard Monaco API)
try {
var eds = m.editor.getEditors();
if (eds && eds.length > 0) {
eds[eds.length - 1].setValue('${safeText}');
return 'Monaco(editor): replaced ' + '${safeText}'.split('\\n').length + ' lines';
}
} catch(e) {}
}
// CodeMirror 6 (uses EditorView stored on DOM element)
var cmEls = document.querySelectorAll('.cm-editor');
for (var i = cmEls.length - 1; i >= 0; i--) {
var view = cmEls[i].cmView;
if (view && view.view) {
var v = view.view;
v.dispatch({changes: {from: 0, to: v.state.doc.length, insert: '${safeText}'}});
return 'CodeMirror6: replaced ' + '${safeText}'.split('\\n').length + ' lines';
}
}
// CodeMirror 5
var CM5 = (typeof CodeMirror !== 'undefined') ? CodeMirror : window.CodeMirror;
if (CM5) {
var cm5 = document.querySelector('.CodeMirror');
if (cm5 && cm5.CodeMirror) {
cm5.CodeMirror.setValue('${safeText}');
return 'CodeMirror5: replaced ' + '${safeText}'.split('\\n').length + ' lines';
}
}
// Ace editor
var aceRef = (typeof ace !== 'undefined') ? ace : window.ace;
if (aceRef) {
var aceEls = document.querySelectorAll('.ace_editor');
if (aceEls.length > 0) {
var aceEd = aceRef.edit(aceEls[aceEls.length - 1]);
aceEd.setValue('${safeText}', -1);
return 'Ace: replaced ' + '${safeText}'.split('\\n').length + ' lines';
}
}
// ProseMirror (LinkedIn, Medium, Notion, HackerNoon)
var pmEl = document.querySelector('.ProseMirror');
if (pmEl) {
// Strategy 1: Native API via view.dispatch (most reliable)
try {
var view = pmEl.pmViewDesc && pmEl.pmViewDesc.view;
if (view && view.state && view.dispatch) {
var state = view.state;
var tr = state.tr.replaceWith(0, state.doc.content.size,
state.schema.text ? state.schema.text('${safeText}') : state.schema.node('paragraph', null, state.schema.text('${safeText}')));
view.dispatch(tr);
view.focus();
return 'ProseMirror(API): replaced';
}
} catch(e) {}
// Strategy 2: execCommand fallback
try {
pmEl.focus();
document.execCommand('selectAll');
document.execCommand('insertText', false, '${safeText}');
return 'ProseMirror(execCommand): replaced';
} catch(e) {}
}
// Fallback: contentEditable — try clipboard paste first, then delete+insert
var el = document.activeElement;
if (!el || !el.isContentEditable) {
el = document.querySelector('[contenteditable="true"]');
if (el) el.focus();
}
if (el && el.isContentEditable) {
// Try clipboard paste (safe for Closure/Medium/unknown editors)
try {
document.execCommand('selectAll');
var dt = new DataTransfer();
dt.setData('text/plain', '${safeText}');
var pe = new ClipboardEvent('paste', {bubbles:true,cancelable:true,clipboardData:dt});
var handled = !el.dispatchEvent(pe);
if (handled) return 'ContentEditable(paste): replaced';
} catch(e) {}
// Fallback: delete then insert (don't combine selectAll+insertText)
document.execCommand('selectAll');
document.execCommand('delete');
document.execCommand('insertText', false, '${safeText}');
return 'ContentEditable: replaced';
}
return 'No code editor found';
})()`
, { timeout: 15000 }
);
return result;
}
// ========== SCREENSHOT ==========
export async function screenshot({ fullPage = false } = {}) {
await refreshTargetWindow();
return _withTargetTabFronted(() => _screenshotFronted({ fullPage }));
}
async function _screenshotFronted({ fullPage }) {
const tmpFile = join(tmpdir(), `safari-screenshot-${Date.now()}.png`);
try {
const windowIdRaw = await osascript(
`tell application "Safari" to return id of ${getTargetWindowRef()}`
).catch(() => null);
// Window IDs are OS-assigned integers — reject anything non-numeric before it reaches
// `do shell script "/usr/sbin/screencapture -l<id>"` (defense-in-depth against odd AppleScript stdout).
const windowId = windowIdRaw != null && /^\d+$/.test(String(windowIdRaw).trim()) ? String(windowIdRaw).trim() : null;
// On macOS Tahoe, screencapture -l may briefly steal focus.
// Save frontmost app via daemon so we can hide Safari if it stole focus.
const shotStart = Date.now();
let previousBundleId = null;
if (windowId) {
const fa = await _helperGetFrontApp();
previousBundleId = fa?.bundleId || null;
}
if (windowId) {
try {
if (fullPage) {
const bounds = await osascript(
`tell application "Safari" to return bounds of ${getTargetWindowRef()}`
);
const dims = await runJS("JSON.stringify({h:document.documentElement.scrollHeight,w:document.documentElement.scrollWidth})");
const { h, w } = JSON.parse(dims);
await osascript(
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, ${Number(w)}, ${Math.min(Number(h) + 100, 5000)}}`
);
try {
await new Promise((r) => setTimeout(r, 500));
// Route capture through the TCC-granted helper (NSAppleScript → do shell script):
// under the launchd daemon, node has no Screen Recording grant — the helper does.
await osascriptFast(
`do shell script "/usr/sbin/screencapture -l${windowId} -o -x '${tmpFile}'"`,
{ timeout: 15000 }
);
} finally {
// Always restore bounds — even if screencapture fails
await osascript(
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${bounds}}`
).catch(() => {});
}
} else {
// Try direct execFile first (works if VS Code has Screen Recording permission)
try {
await execFileAsync("/usr/sbin/screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
const testData = await readFile(tmpFile);
if (testData.length < 100) throw new Error("empty");
} catch (_) {
// Fallback: do shell script via the TCC-granted helper (node lacks Screen Recording under launchd)
await osascriptFast(
`do shell script "/usr/sbin/screencapture -l${windowId} -o -x '${tmpFile}'"`,
{ timeout: 15000 }
);
}
}
// Re-activate previous app if screencapture stole focus (common on macOS Tahoe).
// Centralized restore handles settle delay + hide fallback if activate is blocked.
if (previousBundleId && previousBundleId !== "com.apple.Safari") {
await restoreFocusIfStolen(previousBundleId, shotStart).catch(() => {});
}
// Compress: convert PNG to JPEG (50% quality) + resize to max 1200px width
// Cuts ~600KB PNG → ~60KB JPEG — critical for staying under 20MB context limit
const jpgFile = tmpFile.replace(/\.png$/, '.jpg');
try {
await execFileAsync("sips", [
"-s", "format", "jpeg",
"-s", "formatOptions", "50",
"--resampleWidth", "1200",
tmpFile, "--out", jpgFile
], { timeout: 5000 });
const jpgData = await readFile(jpgFile);
await unlink(tmpFile).catch(() => {});
await unlink(jpgFile).catch(() => {});
if (jpgData.length > 100) return jpgData.toString("base64");
} catch (_) {
// sips failed — fall back to original PNG
await unlink(jpgFile).catch(() => {});
}
const data = await readFile(tmpFile);
await unlink(tmpFile).catch(() => {});
if (data.length > 100) return data.toString("base64");
} catch (_) {
// screencapture failed, fall through to JS method
}
}
// Fallback: full-screen capture + crop to the window rect.
//
// macOS 26 (Tahoe) broke `screencapture -l<windowId>` for Safari: it exits 1
// with "could not create image from window" for EVERY window, while a plain
// full-screen `screencapture -x` from the same process succeeds. So this is
// not a TCC problem — safari_doctor reports Screen Recording as granted and
// is telling the truth. Diagnosed 17.08.2026, when the by-id path started
// failing mid-session and the misleading "permission may have been lost"
// error below sent the investigation after a permission that was never gone.
//
// Crop math: AppleScript bounds are in points, the capture is in pixels.
// devicePixelRatio from the page gives the backing-scale factor.
if (windowId) {
try {
// A locked screen captures as solid black. Say so instead of returning a
// black rectangle the caller has to guess at.
if (await isScreenLocked()) {
throw new Error("SCREEN_LOCKED");
}
const boundsRaw = await osascript(
`tell application "Safari" to return bounds of ${getTargetWindowRef()}`
);
const [x1, y1, x2, y2] = String(boundsRaw).split(",").map((v) => parseInt(v.trim(), 10));
const dpr = Math.max(1, Math.round(Number(await runJS("window.devicePixelRatio")) || 1));
const w = (x2 - x1) * dpr, h = (y2 - y1) * dpr;
if ([x1, y1, x2, y2].every(Number.isFinite) && w > 0 && h > 0) {
const fullFile = tmpFile.replace(/\.png$/, "-full.png");
const cropFile = tmpFile.replace(/\.png$/, "-crop.jpg");
try {
// Direct first (VS Code / terminal grant), helper second (launchd daemon has no grant of its own)
await execFileAsync("/usr/sbin/screencapture", ["-x", fullFile], { timeout: 15000 })
.catch(() => osascriptFast(`do shell script "/usr/sbin/screencapture -x '${fullFile}'"`, { timeout: 15000 }));
await execFileAsync("sips", [
"-c", String(h), String(w), "--cropOffset", String(y1 * dpr), String(x1 * dpr),
fullFile, "--out", cropFile,
], { timeout: 10000 });
await execFileAsync("sips", [
"-s", "format", "jpeg", "-s", "formatOptions", "50", "--resampleWidth", "1200",
cropFile, "--out", cropFile,
], { timeout: 10000 });
const cropped = await readFile(cropFile);
if (cropped.length > 100) return cropped.toString("base64");
} finally {
await unlink(fullFile).catch(() => {});
await unlink(cropFile).catch(() => {});
}
}
} catch (e) {
if (e && e.message === "SCREEN_LOCKED") {
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.)");
}
// crop path failed too — fall through to the JS canvas method
}
}
// Fallback: JS-based screenshot via canvas (no permissions needed)
const dataUrl = await runJS(
`(async function(){` +
`var c=document.createElement('canvas');var ctx=c.getContext('2d');` +
`c.width=window.innerWidth;c.height=${fullPage ? 'document.documentElement.scrollHeight' : 'window.innerHeight'};` +
`var svg='<svg xmlns="http://www.w3.org/2000/svg" width="'+c.width+'" height="'+c.height+'">' +` +
`'<foreignObject width="100%" height="100%">' +` +
`'<div xmlns="http://www.w3.org/1999/xhtml">' + document.documentElement.outerHTML + '</div>' +` +
`'</foreignObject></svg>';` +
`var blob=new Blob([svg],{type:'image/svg+xml'});` +
`var url=URL.createObjectURL(blob);` +
`var img=new Image();` +
`return new Promise(function(resolve){` +
`img.onload=function(){ctx.drawImage(img,0,0);resolve(c.toDataURL('image/png').split(',')[1]);};` +
`img.onerror=function(){resolve('FALLBACK_TEXT')};` +
`img.src=url;});})()`,
{ timeout: 30000 }
);
// canvas/SVG returns a Promise `do JavaScript` can't await → guard against the
// unsettled "[object Promise]"/empty value and only return a real base64 PNG.
const looksBase64 = typeof dataUrl === 'string' && dataUrl.length > 100 && /^[A-Za-z0-9+/]+={0,2}$/.test(dataUrl.slice(0, 120));
if (looksBase64) {
return dataUrl;
}
// Final fallback: throw with clear message for the retry logic in index.js
throw new Error("screencapture failed — Screen Recording permission may have been lost. Grant permission in System Settings → Privacy & Security → Screen & System Audio Recording, then restart Safari.");
} finally {
await unlink(tmpFile).catch(() => {});
}
}
// ========== ELEMENT SCREENSHOT ==========
export async function screenshotElement({ selector }) {
await refreshTargetWindow();
return _withTargetTabFronted(() => _screenshotElementFronted({ selector }));
}
async function _screenshotElementFronted({ selector }) {
const sel = escJsSingleQuote(selector);
// Use html2canvas-like approach: capture element via SVG foreignObject
const result = await runJS(
`(async function(){
var el = document.querySelector('${sel}');
if (!el) return 'Element not found: ${sel}';
var rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return 'Element has no dimensions';
// Scroll element into view
el.scrollIntoView({block:'center'});
await new Promise(r => setTimeout(r, 100));
rect = el.getBoundingClientRect();
// Use canvas + drawImage from window screenshot approach
var c = document.createElement('canvas');
c.width = Math.ceil(rect.width * window.devicePixelRatio);
c.height = Math.ceil(rect.height * window.devicePixelRatio);
var ctx = c.getContext('2d');
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
// Clone element to avoid cross-origin issues
var clone = el.cloneNode(true);
var styles = window.getComputedStyle(el);
var wrapper = document.createElement('div');
wrapper.style.cssText = 'position:absolute;left:-99999px;top:0;width:'+rect.width+'px;height:'+rect.height+'px;overflow:hidden;background:'+styles.backgroundColor;
wrapper.appendChild(clone);
document.body.appendChild(wrapper);
// Serialize to SVG foreignObject
var html = new XMLSerializer().serializeToString(wrapper);
document.body.removeChild(wrapper);
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="'+rect.width+'" height="'+rect.height+'">' +
'<foreignObject width="100%" height="100%">' + html + '</foreignObject></svg>';
var blob = new Blob([svg], {type:'image/svg+xml;charset=utf-8'});
var url = URL.createObjectURL(blob);
var img = new Image();
return new Promise(function(resolve){
img.onload = function(){
ctx.drawImage(img, 0, 0, rect.width, rect.height);
URL.revokeObjectURL(url);
resolve(c.toDataURL('image/png').split(',')[1]);
};
img.onerror = function(){ resolve('SVG_RENDER_FAILED'); };
img.src = url;
});
})()`,
{ timeout: 15000 }
);
// The canvas/SVG path returns a Promise that `do JavaScript` can't await (so it yields
// "[object Promise]"/empty), and foreignObject can't render cross-origin images/fonts.
// Treat anything that isn't a valid base64 PNG as a render failure and fall through to
// the reliable screencapture+crop path.
const looksBase64 = typeof result === 'string' && result.length > 100 && /^[A-Za-z0-9+/]+={0,2}$/.test(result.slice(0, 120));
if (!looksBase64) {
// Fallback: use screencapture + crop
const tmpFile = join(tmpdir(), `safari-el-${Date.now()}.png`);
let cropFile = null;
try {
const windowIdRaw = await osascript(`tell application "Safari" to return id of ${getTargetWindowRef()}`).catch(() => null);
const windowId = windowIdRaw != null && /^\d+$/.test(String(windowIdRaw).trim()) ? String(windowIdRaw).trim() : null;
if (!windowId) throw new Error("Cannot get Safari window ID");
// Get element bounds relative to screen
const bounds = await runJS(
`(function(){var el=document.querySelector('${sel}');if(!el)return '';var r=el.getBoundingClientRect();return JSON.stringify({x:Math.round(r.x),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height),dpr:(window.devicePixelRatio||1)});})()`
);
if (!bounds) throw new Error(typeof result === 'string' && result.startsWith('Element') ? result : 'Element not found for screenshot');
// Full window screenshot then crop with sips. Direct execFile works when this process
// has Screen Recording (stdio mode); under the launchd daemon it doesn't — fall back
// to the TCC-granted helper.
try {
await execFileAsync("/usr/sbin/screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
} catch {
await osascriptFast(
`do shell script "/usr/sbin/screencapture -l${windowId} -o -x '${tmpFile}'"`,
{ timeout: 15000 }
);
}
const { x, y, w, h, dpr = 1 } = JSON.parse(bounds);
// Use sips to crop (macOS built-in). Use the DYNAMIC toolbar height — Sequoia+ chrome is
// ~90px, not 74, so a hardcoded 74 left element screenshots vertically offset. Fall back
// to 74 only if geometry can't be read.
let toolbarHeight = 74;
try { const g = await _getSafariWindowGeometry(); if (g?.toolbarHeight) toolbarHeight = g.toolbarHeight; } catch {}
// screencapture writes the window PNG at PHYSICAL resolution (2× on Retina), but the
// bounds + toolbar height are CSS points. Scale every crop dimension by devicePixelRatio
// so the crop lands on the right physical pixels instead of a half-size top-left region.
const sw = Math.round(w * dpr);
const sh = Math.round(h * dpr);
const sx = Math.round(x * dpr);
const sy = Math.round((y + toolbarHeight) * dpr);
cropFile = join(tmpdir(), `safari-el-crop-${Date.now()}.png`);
await execFileAsync("sips", [
"-c", String(sh), String(sw),
"--cropOffset", String(sy), String(sx),
tmpFile, "--out", cropFile
]);
const data = await readFile(cropFile);
await unlink(tmpFile).catch(() => {});
await unlink(cropFile).catch(() => {});
if (data.length > 100) return data.toString("base64");
} catch (e) {
await unlink(tmpFile).catch(() => {});
if (cropFile) await unlink(cropFile).catch(() => {}); // captured path — old code rebuilt it with the wrong timestamp and leaked it
throw new Error(`Element screenshot failed: ${e.message}`);
}
}
return result;
}
// ========== SCROLL ==========
export async function scroll({ direction = "down", amount = 500 }) {
const y = direction === "up" ? -Number(amount) : Number(amount);
// Single call: scroll + return position
return runJS(
`(function(){window.scrollBy(0,${y});return 'Scrolled ${direction} ${amount}px. Position: '+JSON.stringify({x:window.scrollX,y:window.scrollY,height:document.documentElement.scrollHeight});})()`
);
}
export async function scrollTo({ x = 0, y = 0 }) {
return runJS(`(function(){window.scrollTo(${Number(x)},${Number(y)});return 'Scrolled to (${x},${y})';})()`);
}
// ========== TAB MANAGEMENT ==========
export async function listTabs() {
await refreshTargetWindow();
// Re-resolve when EITHER the URL or the tab marker is tracked — the URL may be
// cleared after a redirect while the marker is still valid; resetting the index
// then caused spurious "tab tracking lost" errors. Only a session with no
// tracking at all should reset.
if (_st().activeTabURL || _st().activeTabMarker) {
await resolveActiveTab();
} else {
_st().activeTabIndex = null;
}
const result = await osascript(
`tell application "Safari"
set output to ""
set tabIndex to 1
repeat with t in every tab of ${getTargetWindowRef()}
if tabIndex > 1 then set output to output & linefeed
set output to output & (tabIndex as text) & (ASCII character 9) & name of t & (ASCII character 9) & URL of t
set tabIndex to tabIndex + 1
end repeat
return output
end tell`
);
if (!result.trim()) return JSON.stringify([]);
const tabs = result.split("\n").map((line) => {
const parts = line.split("\t");
return { index: parseInt(parts[0]), title: parts[1] || "", url: parts[2] || "" };
});
return JSON.stringify(tabs, null, 2);
}
// One window's tabs, for a caller that compares listings over time: `win` pins the window
// (`window id N`), and with none the target window is listed. The answer names the window it
// listed, so the next listing and a claim address that window. Listings of 'front window' read
// whichever window was in front at each call, and a tab of one window looked new in the other.
// Returns { win, tabs: [{ index, title, url }] }.
export async function listWindowTabs(win) {
if (win && !/^window id \d+$/.test(win)) throw new Error("listWindowTabs: win must be a `window id N` reference");
await refreshTargetWindow();
const result = await osascript(`tell application "Safari"
set w to ${win || getTargetWindowRef()}
set output to (id of w) as text
set tabIndex to 1
repeat with t in every tab of w
set output to output & linefeed & (tabIndex as text) & (ASCII character 9) & name of t & (ASCII character 9) & URL of t
set tabIndex to tabIndex + 1
end repeat
return output
end tell`);
const [id, ...lines] = String(result).split("\n");
return {
win: _windowById(id),
tabs: lines.filter((line) => line.trim()).map((line) => {
const parts = line.split("\t");
return { index: parseInt(parts[0]), title: parts[1] || "", url: parts[2] || "" };
}),
};
}
// `onMarker(marker)` hears the marker that names the new tab as soon as the tab exists, before
// anything below can fail: comparing the session's marker before and after the call is misled by
// a parallel call of the same session.
export async function newTab(url = "", { onMarker } = {}) {
await refreshTargetWindow();
const safeUrl = escAppleScriptString(url); // url defaults to "" → escAppleScriptString("") === ""
// A blank tab opens on about:blank, not on Safari's new-tab page (the Start Page runs no page
// script, so no marker could ever prove the tab); `url` stays "", so nothing waits for a load.
const props = ` with properties {URL:"${url ? safeUrl : "about:blank"}"}`;
// Bulletproof tab marker. window.name survives ALL navigation (full loads, redirects,
// cross-origin); __mcpTabMarker survives SPA routing. Minted before the tab exists so the script
// that makes it can stamp it, but it becomes the session's marker (onMarker, the state below) only
// once the tab exists, so a scan run meanwhile never looks for one no tab carries yet.
const marker = `MCP_${_st().markerId}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
// The script that makes the tab reports where it put it: its window's id, its index there, read
// off the tab it made, and the window's fingerprint as that tab sees it (see _inTab), the proof of
// the tab until a marker is on it. Until then nothing else names the tab, so every script below
// addresses that window by id. A later `count of tabs` named the tab the user had just opened, and
// 'front window' names whichever window is in front when each script runs: one the user brought
// forward while the page loaded took the probes, the marker and the URL read, and with the marker
// every later write, into their tab. The report is "" when the tab was made but its place could not
// be read, which must not send the catch below to make another; that tab gets no marker either.
// Then the script stamps the marker on the tab, so a tab the user or another session opens next to
// it before the next script runs is told from it by the marker, not by position. `t` is a position
// too, so the stamp takes only a page as fresh as the one just made: about:blank, carrying no
// window.name and no marker. The stamp is bounded, and the script's timeout covers it: a new tab
// slow to answer must not make the catch below open a second tab.
const report = ` try
set rep to ((id of w) as text) & ":" & ((index of t) as text) & linefeed & (my mcpFp(w, index of t))
on error
return ""
end try
try
with timeout of 2 seconds
do JavaScript "${_doJSLiteral(`(location.href==='about:blank'&&!window.name&&!window.__mcpTabMarker)?${_buildStampJS(marker)}:''`)}" in t
end timeout
end try
return rep`;
const since = Date.now();
let made;
try {
made = await osascript(`${_FINGERPRINT_HANDLER}
tell application "Safari"
set w to ${getTargetWindowRef()}
tell w
set userTab to current tab
set t to make new tab${props}
set current tab to userTab
end tell
${report}
end tell`, { timeout: 13000 });
} catch {
if (SAFARI_PROFILE) {
// Profile mode: create tab inside the profile window, never use make new document (opens in front/personal window)
made = await osascript(`${_FINGERPRINT_HANDLER}
tell application "Safari"
set w to ${getTargetWindowRef()}
tell w to set t to make new tab${props}
${report}
end tell`, { timeout: 13000 });
} else {
// No window to open a tab in: a new window, whose tab is the new document.
made = await osascript(`${_FINGERPRINT_HANDLER}
tell application "Safari"
make new document${props}
set w to front window
set t to current tab of w
${report}
end tell`, { timeout: 13000 });
}
}
const at = /^(\d+):(\d+)\n([^\n]+)$/.exec(String(made).trim());
const win = at && _windowById(at[1]);
const idx = at ? Number(at[2]) : 0;
// Permanently true: this session has opened its own tab, so write ops must NEVER fall back to
// the user's current tab — even when the tab below cannot be told from theirs.
_st().hasOwnedTab = true;
if (!win || !(idx > 0)) {
throw new Error(
"Tab tracking lost — Safari opened a new tab but did not report which one, so this session " +
"cannot tell it from your tabs and marked none. Call safari_new_tab again."
);
}
onMarker?.(marker);
// Every script below proves the tab again before it touches it (_inTab): by the marker, wherever
// the tab is now, or, right after its page replaced the one the marker was on, by the window
// looking as it did. A probe that finds a page without the marker stamps it back.
const tab = { idx, win, marker, fp: at[3], since, op: "newTab" };
let info = null;
let failure = null;
try {
// Wait for page load if URL given. Poll readyState from the Node side — Safari's
// `do JavaScript` does NOT await async IIFEs, so an in-page wait loop returns
// immediately without waiting.
if (url) {
for (let i = 0; i < 50; i++) {
await new Promise(r => setTimeout(r, 200));
try {
const probe = await _inTab(tab, "document.readyState+' '+location.href", { stamp: true });
const cut = probe.indexOf(" ");
const st = probe.slice(0, cut);
const href = probe.slice(cut + 1);
if ((st === 'complete' || st === 'interactive') && href && href !== 'about:blank') break;
} catch (err) {
if (err.tabUnproven) throw err;
/* tab still loading */
}
}
} else {
await new Promise(r => setTimeout(r, 200));
}
// Stamp identity marker + visibility spoof onto the loaded document, and read the page, in one
// script. Identity-critical, like _stampTab(): a daemon hiccup retries through the reliable
// subprocess, and stamping the same marker twice is harmless.
const stampAndRead = (opts) =>
_inTab(tab, "JSON.stringify({title:document.title,url:location.href})", { stamp: "always", ...opts });
const page = JSON.parse(await stampAndRead({ timeout: 5000 }).catch((err) => {
if (err.tabUnproven) throw err;
return stampAndRead({ timeout: 15000, subprocess: true });
}));
info = JSON.stringify({ title: page.title, url: page.url, tabIndex: tab.idx });
} catch (err) {
failure = err;
}
// The session's current tab from here on, whether or not the stamp got through: only the
// marker, found on the tab, proves it is this one. A tab no script could prove keeps no new
// marker, and nothing is stamped on the tab that took its place.
Object.assign(_st(), {
activeTabIndex: tab.idx, activeTabURL: url || null, activeTabMarker: marker,
tabFromExtension: false, lastResolveTime: Date.now(),
});
if (failure?.tabUnproven) throw failure;
if (info === null) {
throw new Error(
"Tab tracking lost — the new tab could not be marked as this session's, so nothing proves which " +
`tab it is. Call safari_new_tab again. Safari said: ${failure?.message || failure}`,
{ cause: failure }
);
}
try {
const parsed = JSON.parse(info);
if (parsed.url && parsed.url !== 'about:blank') _st().activeTabURL = parsed.url;
} catch {}
return info;
}
// A tab index this session can prove it owns, or null. Destructive paths only: they may
// never guess, so "can't prove it" has to read as null rather than as the front document.
// The proof is the identity marker, found wherever the tab is now (surviving the index
// shifts of #54): the rule the tab cap and shutdown cleanup follow too (#112). Not
// resolveActiveTab(), which can answer without one — a URL prefix, a domain, the bare index.
async function _provenOwnTabIndex() {
return findTabByMarker(_st().activeTabMarker);
}
// Close the tab carrying `marker`, found and closed in ONE script. An index one script proved and
// a later script closed named whatever tab sat there by then: a close in between (another
// eviction, the extension, the user, a popup) renumbers the window, and another front window
// renames it, so the close took the user's tab. The window's last tab is blanked instead of closed,
// as closeTab() does. Returns "closed", "blanked", or null when no tab in the target window carries
// the marker. A tab adopted from the user (MCP_A…) is never closed (#92).
// ponytail: the check and `close tab i` are still two Apple events, so a close another process
// lands between them (two instances cleaning up at once) can still shift the index; closing by a
// per-call title nonce (`close (every tab of w whose name is …)`) would make it one.
export async function closeTabByMarker(marker) {
if (!marker || String(marker).startsWith("MCP_A")) return null;
await refreshTargetWindow();
const closed = await osascript(`tell application "Safari"
set w to ${getTargetWindowRef()}
set n to count of tabs of w
repeat with i from n to 1 by -1
set hit to false
try
set hit to ((do JavaScript "${_markerCheckJS(marker)}" in tab i of w) is "1")
end try
if hit then
if n is 1 then
set URL of tab i of w to "about:blank"
return "blanked"
end if
close tab i of w
return "closed"
end if
end repeat
return ""
end tell`);
return closed === "closed" || closed === "blanked" ? closed : null;
}
// `explicitIndex` — a tab the caller already proved is ours. An index a caller merely named
// proves nothing (closeOwnTab), and one proved by an earlier script can name another tab by now:
// with no index, the session's own tab is proven and closed by its marker in one script.
export async function closeTab(explicitIndex) {
// The index is written into AppleScript source below, so nothing but a tab number may get
// there: a string index from run_script carried statements of its own, `do shell script` too.
if (explicitIndex !== undefined && !(Number.isInteger(explicitIndex) && explicitIndex > 0)) {
throw new Error("closeTab: explicitIndex must be a positive integer");
}
// A tab adopted from the user (#92) is writable, never disposable (#68), whatever it shows by now.
if (!explicitIndex && isActiveTabAdopted()) {
throw new Error(
`Tab safety: refusing to close this tab — it was adopted from you via SAFARI_MCP_ALLOW_USER_TABS, ` +
`not opened by this MCP session. Close it yourself, or open your own tab with safari_new_tab.`
);
}
await refreshTargetWindow();
// ── Guard: close nothing this session cannot prove it owns. There is deliberately no
// front-document fallback here, unlike the read paths: `close current tab of window` is
// the tab the USER is looking at whenever our index is unknown — and "unknown" is exactly
// what a session re-initialised after a transport drop reports, mid-task, while a tab of
// its own is still open. That fail-open destroyed a user's tab (#68, the destructive
// sibling of #64). An unmarked front document stays readable for a genuinely fresh
// session, because a bad read costs information; a bad close costs their work.
if (!explicitIndex) {
const closed = await closeTabByMarker(_st().activeTabMarker);
if (!closed) {
throw new Error(
`Tab tracking lost — refusing to close a tab this session cannot prove it opened ` +
`(closing "current tab of window" would close the user's active tab). ` +
`Call safari_new_tab to open a tab this session owns, or safari_list_tabs and ` +
`safari_switch_tab to re-anchor to a tab this session opened.`
);
}
_st().activeTabIndex = null;
_st().activeTabURL = null;
_st().lastTabCount = null;
_st().lastResolveTime = 0;
if (closed === "blanked") {
return "Window's last tab blanked instead of closed (closing it would shut the window / quit Safari)";
}
// The tab is gone, so its marker is too — keeping it would let a later resolve
// match a stale identity, or have the extension mark another tab in its place.
_st().activeTabMarker = null;
_st().tabFromExtension = false;
return "Tab closed";
}
const idx = explicitIndex;
// ── Guard: never close a window's LAST tab. Closing it shuts the window —
// which quits Safari if it's the only window, AND (for profile-targeted
// instances) makes the target window vanish so every later op throws
// "profile window not found". It also wedges a 0-tab "ghost" window that
// resists `close`. Per-window (not global): with SAFARI_PROFILE set, several
// skills race closes on the same profile window while other-profile windows
// exist, so a global count would wrongly allow shutting the profile window.
// If the target window is down to one tab, blank it instead of closing.
try {
const _winTabs = parseInt(
await osascript(`tell application "Safari" to return (count of tabs of ${getTargetWindowRef()})`),
10
);
if (Number.isFinite(_winTabs) && _winTabs <= 1) {
// Blanking is destructive too — it throws away whatever page is loaded — so it
// targets the proven index, never the front document.
await osascript(
`tell application "Safari" to set URL of tab ${idx} of ${getTargetWindowRef()} to "about:blank"`
);
_st().activeTabIndex = null;
_st().activeTabURL = null;
_st().lastTabCount = null;
_st().lastResolveTime = 0;
return "Window's last tab blanked instead of closed (closing it would shut the window / quit Safari)";
}
} catch { /* count check failed — fall through to normal close */ }
await osascript(
`tell application "Safari" to close tab ${idx} of ${getTargetWindowRef()}`
);
if (idx === _st().activeTabIndex) {
_st().activeTabIndex = null;
_st().activeTabURL = null;
// The tab is gone, so its marker is too — keeping it would let a later resolve
// match a stale identity, or have the extension mark another tab in its place.
_st().activeTabMarker = null;
_st().tabFromExtension = false;
}
_st().lastTabCount = null; // Invalidate — tab count changed
_st().lastResolveTime = 0; // Force re-resolve on next operation
return "Tab closed";
}
// A close that names its tab by index: run_script's closeTab. That index is the caller's,
// not a tab this module proved. Handed to closeTab() as `explicitIndex`, it closed whatever
// tab sat there, the user's included, while the ownership guard had checked the current tab.
// Only the tab carrying this session's marker closes here, and only when it is the one named.
export async function closeOwnTab(index) {
if (index != null && Number(index) !== (await _provenOwnTabIndex())) {
throw new Error(
`Tab safety: refusing to close tab ${index} — AppleScript proves a tab only by the marker ` +
`this session stamped on it, and tab ${index} does not carry it.`
);
}
return closeTab();
}
// A switch takes a tab that carries a marker of this session. `adopt` also takes one that does not:
// an adoption (#92), stamped with the session's adoption marker. `claim` takes it as the session's
// own: the tab safari_wait_for_new_tab saw open. Only those callers pass them. A tab that carries the
// session's adoption marker keeps that family whatever the caller passes, so no switch turns a tab
// adopted from the user into one the session can close. `win` (`window id N`) names the window the
// caller saw the tab in; without it, the index is one of the target window. `expectUrl` is the URL the
// caller's listing saw at that index: a tab that closed to its left since slid another tab, the user's
// included, under the index, so the switch refuses (`moved`) before it stamps anything when the tab
// shows another page, and the caller lists again.
export async function switchTab(index, { adopt = false, claim = false, win, expectUrl } = {}) {
const idx = Number(index);
// A switch by receipt alone used to arrive here with no index at all, and claimed tab NaN
// under a marker stamped on no tab.
if (!Number.isInteger(idx) || idx < 1) throw new Error("switchTab needs the tab's index (a positive integer)");
if (win && !/^window id \d+$/.test(win)) throw new Error("switchTab: win must be a `window id N` reference");
// A tab that carries a whole marker of the session's own family (MCP_<markerId>_) keeps it. The
// server records a tab AppleScript opened by the marker stamped on it then, and the tab cap, the
// memory sweep, shutdown cleanup and a close forgetting its tab all find the tab by that marker: a
// fresh one stamped here left the record naming no tab, so the tab escaped all four and its URL
// stayed claimed. A tab carrying the session's adoption marker gets a fresh adoption marker at every
// switch, and any other tab a FRESH own marker, never one the session stamped before: the tab
// carrying that one would be taken for this one. So a switch never puts one marker on two tabs.
const ownPrefix = `MCP_${_st().markerId}_`;
const marker = `${ownPrefix}${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
const adoptedMarker = `MCP_A${marker.slice(4)}`;
// The marker makes the tab the session's own from then on: writes go there, and closeTab() closes
// the tab carrying it. It used to go on whatever tab sat at the index, the user's included, since
// run_script checked nothing first and safari_switch_tab checks only the tab's URL, which their tab
// on a page the session also opened passes. So the tab has to carry a marker of this session
// already, and one script in the page checks that, reads the title and URL, and only then stamps:
// no tab closed or dragged in between can put another tab under the stamp, and a switch that fails
// leaves both the tab and the session state as they were.
// Do NOT visually switch the tab — it brings the Safari window to foreground
// and interrupts the user. Visual switching only happens in screenshot() when needed.
// AppleScript `do JavaScript in tab N` works on background tabs without switching.
// `a`: the tab carries the session's adoption marker, `o`: one of its own markers, `w`: that marker
// when it is a whole one (_markerWithPrefixJS), `k`: the marker the tab keeps or gets. A claim takes
// no tab another MCP session marked (`x`): its marker would give way to this session's own, which
// the tab cap, the sweep, cleanup and closes act on, and the other session would lose the tab, or a
// tab it adopted from the user would become closable.
// JSON.stringify makes the URL a JS string literal whatever it holds; its quotes and backslashes are
// escaped once more for AppleScript below.
const moved = expectUrl == null ? "" : `if(location.href!==${JSON.stringify(String(expectUrl))})return 'moved';`;
const js = `(function(){${moved}var a=${_markerPrefixTestJS(_adoptedMarkerPrefix())},o=${_markerPrefixTestJS(ownPrefix)},w=${_markerWithPrefixJS(ownPrefix)},` +
`x=${_otherSessionMarkerTestJS(ownPrefix, _adoptedMarkerPrefix())};if(${claim}&&x)return 'other';` +
`if(!a&&!o&&!${adopt || claim})return '';var f=a||${adopt}&&!o,k=f?'${adoptedMarker}':w||'${marker}';` +
`var r=JSON.stringify({title:document.title,url:location.href,adopted:f,marker:k});` +
`${_buildStampJS({ expr: "k" })};return r;})()`;
const script = `tell application "Safari" to do JavaScript "${js.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}" in tab ${idx} of ${win || getTargetWindowRef()}`;
// Fast daemon first; a hiccup retries once through the reliable subprocess (a second run stamps the same marker).
const result = String(await osascriptFast(script, { timeout: 5000 }).catch(() => osascript(script, { timeout: 8000 }))).trim();
// `moved` and `otherSession` let safari_wait_for_new_tab pass over the tab and keep waiting for its own.
if (result === "moved") {
throw Object.assign(new Error(
`Tab safety: refusing to ${claim ? "claim" : "switch to"} tab ${idx} — it no longer shows the page the listing saw ` +
`(a tab closed or moved in between), so it may be another tab.`
), { moved: true });
}
if (result === "other") {
throw Object.assign(new Error(
`Tab safety: refusing to claim tab ${idx} — another MCP session's marker is on it (a tab it opened, or one it ` +
`adopted from you), and a claim never takes a tab from another session.`
), { otherSession: true });
}
if (!result) {
throw Object.assign(new Error(
`Tab safety: refusing to switch to tab ${idx} — it carries no marker of this session, and AppleScript has ` +
`no other proof that this session opened it (a URL is none: the same page can be open in one of your tabs). ` +
`Open a tab with safari_new_tab, switch to a tab the Safari extension opened once safari_doctor shows the ` +
`extension connected, or set SAFARI_MCP_ALLOW_USER_TABS=1 so that safari_switch_tab adopts a tab you already had open.`
), { unproven: true });
}
let page;
try {
page = JSON.parse(result);
} catch {
throw new Error(`switchTab: no page script runs in tab ${idx}, so no marker can find it there`);
}
// The page answered with the marker it kept, and a page can answer anything. Every later scan
// writes that marker into AppleScript source, so it has to be one the session could have minted.
const kept = page.adopted ? adoptedMarker : String(page.marker);
if (!page.adopted && !(kept.startsWith(ownPrefix) && /^\w+$/.test(kept))) {
throw new Error(`Tab safety: refusing to switch to tab ${idx} — its page answered with a marker this session never minted`);
}
// The session finds this tab again by that marker (resolveActiveTab scans for it); the URL is for
// index.js's ownership checks.
Object.assign(_st(), {
activeTabIndex: idx, activeTabMarker: kept, activeTabURL: page.url || null,
hasOwnedTab: true, tabFromExtension: false, lastResolveTime: Date.now(),
});
return JSON.stringify({ title: page.title, url: page.url });
}
// ========== WAIT ==========
export async function waitFor({ selector, text, timeout = 10000 }) {
// `do JavaScript` can't await, so an in-page async wait loop returns immediately
// (handing back an unsettled promise) instead of waiting. The wait loop runs on
// the Node side: each tick re-evaluates one SYNCHRONOUS check against the page.
const safeSelector = selector ? escJsSingleQuote(selector) : "";
const safeText = text ? escJsSingleQuote(text) : "";
if (!safeSelector && !safeText) {
throw new Error("waitFor requires selector or text");
}
const checkJs = `(function(){` +
(safeSelector ? `if(document.querySelector('${safeSelector}'))return 'Found: ${safeSelector}';` : "") +
(safeText ? `if(document.body&&document.body.innerText.includes('${safeText}'))return 'Found text: ${safeText}';` : "") +
`return '';})()`;
const deadline = Date.now() + Number(timeout);
while (Date.now() < deadline) {
const hit = await runJS(checkJs, { timeout: 5000 }).catch(() => "");
if (hit) return hit;
await new Promise(r => setTimeout(r, 100));
}
throw new Error(`Timeout waiting for ${selector || text} (${timeout}ms)`);
}
// ========== EVALUATE ==========
// Index of the last `;` that ends a top-level statement — skips `;` inside strings,
// template literals and parens/brackets/braces (e.g. a `for (;;)` header). Returns
// -1 when there is no top-level statement separator.
function _lastTopLevelSemicolon(s) {
let depth = 0, inStr = false, quote = '', last = -1;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (inStr) {
if (c === '\\') { i++; continue; }
if (c === quote) inStr = false;
continue;
}
if (c === '"' || c === "'" || c === '`') { inStr = true; quote = c; }
else if (c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') depth--;
else if (c === ';' && depth === 0) last = i;
}
return last;
}
// Build the expression to evaluate from a user script. Pure (no Safari calls) so
// it can be unit-tested directly — see scripts/test-evaluate-wrapping.js.
export function _buildEvalExpr(js) {
// Async iff the *result* is a promise to wait on. `fetch(` alone is NOT async —
// an un-awaited fetch is fire-and-forget; only await / .then() / a leading
// `async` make the result thenable.
const isAsync = /\bawait\b/.test(js) || /\.then\s*\(/.test(js) || /^async\b/.test(js);
// Statement keywords: a script starting with one is never a bare expression,
// and `return (<keyword> ...)` would be a syntax error.
const NON_EXPR = /^(var|let|const|return|if|for|while|switch|try|do|throw)\b/;
const isIIFE = /^\((?:async\s+)?function/.test(js) || /^\((?:async\s+)?\(/.test(js);
const isSimpleExpression = !js.includes(';') && !js.includes('\n') && !NON_EXPR.test(js);
let expr;
if (isIIFE) {
expr = js;
} else if (isSimpleExpression) {
// A bare expression — usable as-is for sync; async needs an awaiting wrapper.
expr = isAsync ? `(async function(){return (${js})})()` : js;
} else {
// Multi-statement: prepend `return` to the last value-producing line when it
// can safely take one; otherwise fall back to indirect-eval completion value.
const lines = js.split('\n');
let addedReturn = false;
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim();
if (!line || line.startsWith('//')) continue;
if (line.startsWith('return ') || line.startsWith('return;') || line === 'return') {
addedReturn = true; break;
}
// A block-closer (`}`, `})`, `})()`), a block body (ends with `}`) or a
// statement keyword can't take a prepended `return`.
if (line.startsWith('}') || line.endsWith('}') || NON_EXPR.test(line)) break;
lines[i] = 'return ' + lines[i];
addedReturn = true;
break;
}
if (addedReturn) {
expr = `(${isAsync ? 'async function' : 'function'}(){${lines.join('\n')}})()`;
} else if (isAsync) {
// No newline gave a return slot — typically a single-line `const x = await …; expr`.
// Split at the last top-level `;`: if a bare expression follows it, that becomes the
// awaited result. Otherwise run the body as-is (value may be undefined).
const semi = _lastTopLevelSemicolon(js);
const tail = semi >= 0 ? js.slice(semi + 1).trim() : '';
if (tail && !tail.startsWith('}') && !NON_EXPR.test(tail)) {
expr = `(async function(){${js.slice(0, semi + 1)} return (${tail}); })()`;
} else {
expr = `(async function(){${js}})()`;
}
} else {
// Indirect eval yields the completion value of an arbitrary statement list. The
// plain body is the fallback for a strict CSP that refuses eval — probed with "0",
// so a script that threw by itself (it ran) is not run a second time.
const escaped = js.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
expr = "(function(){ var __mcpEvalOk = true; try { (0,eval)('0') } catch(_e) { __mcpEvalOk = false } " +
"if (__mcpEvalOk) return (0,eval)('" + escaped + "'); " + js.replace(/\n/g, ' ') + " })()";
}
}
return { isAsync, expr };
}
// Async scripts can't be awaited through AppleScript `do JavaScript` — it returns
// the moment the synchronous portion finishes, handing back an unsettled Promise.
// So the work is started fire-and-forget into a page global, then that global is
// polled synchronously from the Node side (the same pattern navigate() uses).
//
// Token is identifier-safe (base36 → [0-9a-z], `_` prefix) so `window.<token>`
// dot access needs no quoting/escaping through the AppleScript bridge.
function _newEvalSlot() {
return 'window.__mcpEval_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
}
// Page-side statement that awaits `valueJs` and records the outcome in `slot`.
function _settleIntoSlot(slot, valueJs) {
return `(async function(){try{var __v=await (${valueJs});` +
`${slot}.val=(__v===undefined||__v===null)?null:(typeof __v==='object'?JSON.stringify(__v):String(__v));` +
`}catch(__e){${slot}.err=(__e&&__e.message)||String(__e);}` +
`finally{${slot}.done=true;}})();`;
}
async function _evaluateAsync(expr) {
const slot = _newEvalSlot();
// A SYNC outer function installs the globals, starts the async work (NOT awaited
// here — `do JavaScript` would not await it anyway) and returns immediately.
const kickoff = `(function(){${slot}={done:false};${_settleIntoSlot(slot, expr)}return 'ok';})()`;
const started = await runJS(kickoff, { timeout: 10000 });
if (started !== 'ok') {
return typeof started === 'string' && started ? started : '(no return value)';
}
return _awaitEvalSlot(slot);
}
// Poll the result global until the async work settles (35s budget).
async function _awaitEvalSlot(slot) {
const pollJs =
`(function(){var s=${slot};if(!s)return '__MCP_GONE__';` +
`if(!s.done)return '';return JSON.stringify({v:s.val,e:s.err});})()`;
const deadline = Date.now() + 35000;
let raw = '';
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 120));
raw = await runJS(pollJs, { timeout: 5000 }).catch(() => '');
if (raw === '__MCP_GONE__') {
return '(no return value — page navigated away during async evaluation)';
}
if (raw) break;
}
// Best-effort cleanup of the page global.
runJS(`(function(){try{delete ${slot};}catch(__e){${slot}=undefined;}return '';})()`).catch(() => {});
if (!raw) {
throw new Error('safari_evaluate: async script did not settle within 35s');
}
try {
const parsed = JSON.parse(raw);
if (parsed.e) return 'Error: ' + parsed.e;
return parsed.v !== undefined && parsed.v !== null ? String(parsed.v) : '(no return value)';
} catch {
return raw;
}
}
// Sync: a single `do JavaScript` over one expression — `do JavaScript` only returns the
// value of a single expression, so the whole script is one IIFE. The regex async-sniff in
// _buildEvalExpr only catches a literal await/.then/async and misses scripts whose *value*
// is a thenable (`Promise.resolve(5)`, an async IIFE with no inner await). Such a value is
// awaited in place into a slot: the script already ran, and evaluating it again — as this
// used to on "[object Promise]" — repeated every side effect in it.
export function _buildSyncEvalJs(expr, slot) {
return `(function(){ try { var __r = (${expr}); ` +
`if (__r && typeof __r.then === 'function') { ${slot}={done:false}; ${_settleIntoSlot(slot, '__r')} return '__MCP_EVAL_PENDING__'; } ` +
`return __r; } catch(__mcpErr) { return 'Error: ' + __mcpErr.message; } })()`;
}
export async function evaluate({ script }) {
const js = (script || '').trim();
if (!js) return '(no return value)';
const { isAsync, expr } = _buildEvalExpr(js);
if (isAsync) return _evaluateAsync(expr);
const slot = _newEvalSlot();
const wrappedJs = _buildSyncEvalJs(expr, slot);
if (process.env.MCP_DEBUG) console.error('[evaluate] wrapped:', wrappedJs.substring(0, 300));
const result = await runJS(wrappedJs);
if (typeof result === 'string' && result.trim() === '__MCP_EVAL_PENDING__') return _awaitEvalSlot(slot);
if (result === null || result === undefined || result === '') {
return '(no return value)';
}
return result;
}
// ========== ELEMENT INFO ==========
export async function getElementInfo({ selector }) {
const sel = escJsSingleQuote(selector);
return runJS(
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found';var r=el.getBoundingClientRect();return JSON.stringify({tag:el.tagName,text:el.textContent.trim().substring(0,200),href:el.href||'',value:el.value||'',visible:r.width>0&&r.height>0,rect:{x:Math.round(r.x),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height)},attrs:Object.fromEntries([...el.attributes].map(function(a){return[a.name,a.value.substring(0,100)]}))})})()`
);
}
export async function querySelectorAll({ selector, limit = 20 }) {
const sel = escJsSingleQuote(selector);
return runJS(
`JSON.stringify([...document.querySelectorAll('${sel}')].slice(0,${Number(limit)}).map(function(el,i){return{index:i,tag:el.tagName,text:el.textContent.trim().substring(0,100),href:el.href||undefined,value:el.value||undefined}}))`
);
}
// ========== HOVER ==========
export async function hover({ selector, x, y, ref }) {
if (ref) selector = refSelector(ref);
if (selector) {
const sel = escJsSingleQuote(selector);
return runJS(
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found';el.scrollIntoView({block:'center'});el.dispatchEvent(new MouseEvent('mouseover',{bubbles:true}));el.dispatchEvent(new MouseEvent('mouseenter',{bubbles:true}));return 'Hovered: '+el.tagName;})()`
);
}
if (x !== undefined && y !== undefined) {
return runJS(
`(function(){var el=document.elementFromPoint(${Number(x)},${Number(y)});if(!el)return 'No element';el.dispatchEvent(new MouseEvent('mouseover',{bubbles:true}));el.dispatchEvent(new MouseEvent('mouseenter',{bubbles:true}));return 'Hovered: '+el.tagName+' at (${Number(x)},${Number(y)})';})()`
);
}
throw new Error("hover requires selector or x/y coordinates");
}
// ========== DIALOG HANDLING ==========
export async function handleDialog({ action = "accept", text }) {
if (text !== undefined) {
const safeText = escJsSingleQuote(text);
await runJS(
`window.__mcp_dialog_response='${safeText}';window.__origPrompt=window.prompt;window.prompt=function(){var r=window.__mcp_dialog_response;window.prompt=window.__origPrompt;return r;}`
);
}
if (action === "accept") {
await runJS(
"window.__origConfirm=window.__origConfirm||window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return true;};window.__origAlert=window.__origAlert||window.alert;window.alert=function(){window.alert=window.__origAlert;};"
);
} else {
await runJS(
"window.__origConfirm=window.__origConfirm||window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return false;};"
);
}
return `Dialog handler set: ${action}${text ? ' with "' + text + '"' : ""}`;
}
// ========== WINDOW ==========
export async function resizeWindow({ width, height }) {
await refreshTargetWindow();
await osascript(
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, ${Number(width)}, ${Number(height)}}`
);
return `Resized to ${width}x${height}`;
}
// ========== COOKIES & STORAGE ==========
// The storage tools below are plain page JavaScript (document.cookie, localStorage,
// sessionStorage). Until 2.21.13 they went straight to AppleScript, so a stuck Apple
// Events channel failed them with `Safari profile "X" window not found` while
// safari_evaluate, list_tabs and run_script kept answering through the extension
// (geo-audit, 2026-09-20). index.js points this hook at the same extension-first
// ladder safari_evaluate uses; the fallback it hands back is the original runJS.
// Without a runner (tests, direct imports) pageJS is runJS.
let _pageJSRunner = null;
export function setPageJSRunner(fn) { _pageJSRunner = typeof fn === "function" ? fn : null; }
function pageJS(js, fallback = () => runJS(js)) {
return _pageJSRunner ? _pageJSRunner(js, fallback) : fallback();
}
export async function getCookies() {
return pageJS("document.cookie");
}
export async function getLocalStorage({ key }) {
if (key) {
const safeKey = escJsSingleQuote(key);
return pageJS(`localStorage.getItem('${safeKey}')`);
}
return pageJS(
"JSON.stringify(Object.fromEntries(Object.keys(localStorage).map(function(k){var v=localStorage.getItem(k);return[k,v==null?null:v.substring(0,200)]})))"
);
}
// ========== NETWORK (via Performance API) ==========
export async function getNetworkRequests({ limit = 50 } = {}) {
return runJS(
`JSON.stringify(performance.getEntriesByType('resource').slice(-${Number(limit)}).map(function(r){return{name:r.name,type:r.initiatorType,duration:Math.round(r.duration),size:r.transferSize||0}}))`
);
}
// ========== DRAG ==========
export async function drag({ sourceSelector, targetSelector, sourceX, sourceY, targetX, targetY }) {
if (sourceSelector && targetSelector) {
const srcSel = escJsSingleQuote(sourceSelector);
const tgtSel = escJsSingleQuote(targetSelector);
return runJS(
`(function(){` +
`var src=document.querySelector('${srcSel}');var tgt=document.querySelector('${tgtSel}');` +
`if(!src)return 'Source not found: ${srcSel}';if(!tgt)return 'Target not found: ${tgtSel}';` +
`var sr=src.getBoundingClientRect();var tr=tgt.getBoundingClientRect();` +
`var sx=sr.x+sr.width/2,sy=sr.y+sr.height/2,tx=tr.x+tr.width/2,ty=tr.y+tr.height/2;` +
`var dt=new DataTransfer();` +
`src.dispatchEvent(new DragEvent('dragstart',{clientX:sx,clientY:sy,bubbles:true,cancelable:true,dataTransfer:dt}));` +
`src.dispatchEvent(new MouseEvent('mousedown',{clientX:sx,clientY:sy,bubbles:true}));` +
`src.dispatchEvent(new MouseEvent('mousemove',{clientX:sx,clientY:sy,bubbles:true}));` +
`tgt.dispatchEvent(new DragEvent('dragover',{clientX:tx,clientY:ty,bubbles:true,cancelable:true,dataTransfer:dt}));` +
`tgt.dispatchEvent(new MouseEvent('mousemove',{clientX:tx,clientY:ty,bubbles:true}));` +
`tgt.dispatchEvent(new MouseEvent('mouseup',{clientX:tx,clientY:ty,bubbles:true}));` +
`tgt.dispatchEvent(new DragEvent('drop',{clientX:tx,clientY:ty,bubbles:true,cancelable:true,dataTransfer:dt}));` +
`src.dispatchEvent(new DragEvent('dragend',{bubbles:true}));` +
`return 'Dragged from '+src.tagName+' to '+tgt.tagName;})()`
);
}
if (sourceX !== undefined && sourceY !== undefined && targetX !== undefined && targetY !== undefined) {
return runJS(
`(function(){` +
`var src=document.elementFromPoint(${Number(sourceX)},${Number(sourceY)});` +
`if(!src)return 'No element at source';` +
`src.dispatchEvent(new MouseEvent('mousedown',{clientX:${Number(sourceX)},clientY:${Number(sourceY)},bubbles:true}));` +
`src.dispatchEvent(new MouseEvent('mousemove',{clientX:${Number(sourceX)},clientY:${Number(sourceY)},bubbles:true}));` +
`document.elementFromPoint(${Number(targetX)},${Number(targetY)})?.dispatchEvent(new MouseEvent('mousemove',{clientX:${Number(targetX)},clientY:${Number(targetY)},bubbles:true}));` +
`document.elementFromPoint(${Number(targetX)},${Number(targetY)})?.dispatchEvent(new MouseEvent('mouseup',{clientX:${Number(targetX)},clientY:${Number(targetY)},bubbles:true}));` +
`return 'Dragged from (${Number(sourceX)},${Number(sourceY)}) to (${Number(targetX)},${Number(targetY)})';})()`
);
}
throw new Error("drag requires sourceSelector+targetSelector or sourceX/Y+targetX/Y");
}
// ========== FILE PATH SAFETY ==========
// Prevent reading sensitive system files via upload/paste tools
function _validateFilePath(filePath) {
const resolved = resolvePath(filePath);
// resolve() already collapses "..", so checking the RESOLVED path for it is a dead no-op.
// Reject traversal sequences in the RAW input instead; the allowlist below is the real guard.
if (/(^|[/\\])\.\.([/\\]|$)/.test(filePath)) throw new Error("Path traversal not allowed: " + filePath);
const blocked = ['.ssh', '.gnupg', '.aws', '.config/gcloud', 'credentials', '.env', '.npmrc', '.netrc', 'id_rsa', 'id_ed25519', '.keychain'];
// Resolve symlinks when the target exists, so a symlink under /Users/ pointing at /etc
// can't slip past the allowlist. Falls back to the lexical path when the file doesn't
// exist yet (e.g. savePDF's output path).
let real = resolved;
try { real = realpathSync(resolved); } catch { /* not created yet — keep lexical path */ }
for (const checkPath of new Set([resolved, real])) {
const lower = checkPath.toLowerCase();
for (const b of blocked) {
if (lower.includes(b)) throw new Error("Blocked: sensitive path " + filePath);
}
// realpathSync resolves /var/folders/... to /private/var/folders/... on macOS —
// without the /private form an EXISTING file under /var/folders was rejected.
if (!checkPath.startsWith('/Users/') && !checkPath.startsWith('/tmp/') && !checkPath.startsWith('/var/folders/') && !checkPath.startsWith('/private/tmp/') && !checkPath.startsWith('/private/var/folders/')) {
throw new Error("File path must be under /Users/, /tmp/, or /var/folders/: " + filePath);
}
}
}
// ========== UPLOAD FILE ==========
// How many previews of ingested files the page shows: object-URL / data-URL images and videos,
// background images, and SVG <image> elements. Google Business Profile draws its preview as
// <svg role=img><image href="blob:…">, which went uncounted, so a real upload read as a ghost
// pickup and was escalated to a native dialog that added a second copy. -1: the page was unreadable.
const _PREVIEW_COUNT_JS = `function(){try{
var n=document.querySelectorAll('img[src^="blob:"], img[src^="data:image"], video[src^="blob:"]').length;
var all=document.querySelectorAll('div,span,a,figure');
for(var i=0;i<all.length;i++){var b=all[i].style&&all[i].style.backgroundImage||'';if(b.indexOf('blob:')>-1||b.indexOf('data:image')>-1)n++;}
var svg=document.querySelectorAll('image');
for(var j=0;j<svg.length;j++){var h=svg[j].getAttribute('href')||svg[j].getAttributeNS('http://www.w3.org/1999/xlink','href')||'';if(h.indexOf('blob:')===0||h.indexOf('data:image')===0)n++;}
return n;}catch(_){return -1;}}`;
// `locate`: how index.js asks the extension where this session's tab is (see _locusWindows).
export async function uploadFile({ selector, filePath, forceNative = false, verifyPreview = false, locate = null }) {
_validateFilePath(filePath);
const elsewhere = _locusWindows(locate);
// Read file in Node.js, send as base64 to Safari JS, create File + DataTransfer
// NO file dialog, NO System Events, NO focus stealing
// Safety: close any open file dialog first (in case Claude clicked the input before calling this)
await osascript(
`tell application "System Events"
tell process "Safari"
repeat with w in every window
if exists sheet 1 of w then
try
click button "Cancel" of sheet 1 of w
on error
try
click button "\u05D1\u05D9\u05D8\u05D5\u05DC" of sheet 1 of w -- "Cancel" in Hebrew locale
on error
key code 53
end try
end try
exit repeat
end if
end repeat
end tell
end tell`
).catch(() => {}); // Ignore if no dialog open
const sel = escJsSingleQuote(selector);
const { basename, extname } = await import("node:path");
let fileName = basename(filePath);
let ext = extname(filePath).toLowerCase().replace(".", "");
let resolvedPath = filePath;
// Auto-convert images that the target input rejects.
// Quora is the canonical case — declares accept="image/png,image/jpeg" and silently
// ignores webp drops. Convert webp/heic to PNG via macOS sips (no extra deps).
const imageFormats = new Set(['webp', 'heic', 'heif', 'tiff', 'tif']);
if (imageFormats.has(ext)) {
const accept = await runJS(
`(function(){var el=document.querySelector('${sel}');if(!el){var roots=window.mcpCollectRoots?window.mcpCollectRoots():[document];for(var i=0;i<roots.length;i++){el=roots[i].querySelector('${sel}');if(el)break;}}return el?(el.getAttribute('accept')||''):'';})()`,
{ elsewhere }
).catch(() => '');
const acceptStr = String(accept || '').toLowerCase();
const accepted = !acceptStr ||
acceptStr.includes('image/*') ||
acceptStr.includes(`image/${ext}`) ||
acceptStr.includes(`.${ext}`);
if (!accepted) {
const tmpPng = join(tmpdir(), `safari-mcp-upload-${Date.now()}.png`);
try {
await execFileAsync('sips', ['-s', 'format', 'png', resolvedPath, '--out', tmpPng], { timeout: 15000 });
resolvedPath = tmpPng;
fileName = basename(tmpPng);
ext = 'png';
} catch (sipsErr) {
console.error(`[Safari MCP] sips conversion failed (${ext}→png): ${sipsErr.message}. Continuing with original.`);
}
}
}
// Read file as base64
const fileData = await readFile(resolvedPath);
const base64 = fileData.toString("base64");
// Determine MIME type
const mimeMap = {
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif",
webp: "image/webp", svg: "image/svg+xml", pdf: "application/pdf",
mp4: "video/mp4", mp3: "audio/mpeg", txt: "text/plain", csv: "text/csv",
json: "application/json", zip: "application/zip", doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
};
const mime = mimeMap[ext] || "application/octet-stream";
const safeName = escJsSingleQuote(fileName);
// forceNative: skip synthetic injection entirely. Attempting it first would leave a stray
// media item behind on sites that register the pickup in their UI without ingesting it
// (Google Business Profile), so the native dialog would then add a SECOND copy.
const result = forceNative ? '' : await runJSLarge(
`(function(){
// Deep query: main document → shadow DOM → iframes
function deepQuery(sel) {
var el = document.querySelector(sel);
if (el) return el;
var all = document.querySelectorAll('*');
for (var i = 0; i < all.length; i++) {
var sr = all[i].shadowRoot;
if (sr) { el = sr.querySelector(sel); if (el) return el; }
}
var iframes = document.querySelectorAll('iframe');
for (var i = 0; i < iframes.length; i++) {
try { var doc = iframes[i].contentDocument; if (doc) { el = doc.querySelector(sel); if (el) return el; } } catch(_) {}
}
return null;
}
var el = deepQuery('${sel}');
if (!el) return 'Element not found: ${sel}';
// Decode base64 to binary
var b64 = '${base64}';
var binary = atob(b64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
var file = new File([bytes], '${safeName}', { type: '${mime}' });
var dt = new DataTransfer();
dt.items.add(file);
// Counts the visual proof that a site actually ingested the file: object-URL previews
// it created for it. Ghost pickups (see Strategy 1) leave this at the pre-upload value.
var __mcpPreviewCount = ${_PREVIEW_COUNT_JS};
// Baseline BEFORE any dispatch — after the change event a site that works has already
// painted its preview, so sampling then makes an honest pickup look identical to a ghost.
var __mcpPreviewsBefore = __mcpPreviewCount();
// Strategy 1: Direct files assignment (works on most inputs)
try { el.files = dt.files; } catch(_) {}
if (el.files && el.files.length > 0) {
el.dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new Event('input', { bubbles: true }));
// el.files being set proves the DOM accepted the handle — NOT that the site ingested it.
// Google Business Profile is the canonical liar: it flips its UI to "image attached"
// yet renders no preview and publishes the post with no image (2026-08-23). Report the
// preview count so the Node side can escalate when the caller asked to verify.
return 'Uploaded: ${safeName} (' + Math.round(bytes.length / 1024) + ' KB, verified ' + el.files.length + ' file(s)) [previews=' + __mcpPreviewsBefore + ']';
}
// Strategy 2: Drop event on the input or its container (works when files property is read-only)
var dropTarget = el.closest('[class*="upload"], [class*="drop"], [class*="file"]') || el.parentElement || el;
var dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt });
dropTarget.dispatchEvent(new DragEvent('dragenter', { bubbles: true, dataTransfer: dt }));
dropTarget.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt }));
dropTarget.dispatchEvent(dropEvent);
el.dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new Event('input', { bubbles: true }));
// No await here: do-JavaScript cannot await a Promise, and the drop events above
// already dispatched synchronously. A framework that needs a tick is surfaced by the hint below.
// Re-check after drop
if (el.files && el.files.length > 0) {
return 'Uploaded via drop: ${safeName} (' + Math.round(bytes.length / 1024) + ' KB, verified ' + el.files.length + ' file(s))';
}
// Check if any new images/files appeared on the page after the drop
var newImgs = document.querySelectorAll('img[src*="blob:"], img[src*="data:"], [style*="background-image"]');
var hint = newImgs.length > 0 ? ' (detected ' + newImgs.length + ' blob/data images on page — upload likely succeeded)' : '';
return 'Upload attempted: ${safeName} (' + Math.round(bytes.length / 1024) + ' KB) — drop event dispatched. el.files is empty (normal for custom upload handlers).' + hint + ' Verify with safari_snapshot.';
})()`,
{ timeout: 30000, elsewhere }
);
// Strategy 3: synthetic injection (files=/drop) is silently rejected by isTrusted-gated
// or custom upload handlers — GitHub's <file-attachment>, some WAF-fronted forms. Escalate
// to a REAL native file dialog: a CGEvent click opens the OS NSOpenPanel, then System Events
// types the path. That is an isTrusted:true selection, which those handlers accept.
let finalResult = result;
// verifyPreview: the caller states this upload must produce a visible preview (an image
// going into a composer). Strategy 1 can "succeed" while the site ingested nothing, so
// re-read the preview count after giving the page a beat to react; if it never moved, the
// pickup was a ghost and only a real (isTrusted) OS selection will do.
let ghostPickup = false;
if (verifyPreview && !forceNative && /verified [1-9]\d* file/i.test(result)) {
const before = Number((result.match(/\[previews=(-?\d+)\]/) || [])[1] ?? -1);
const countPreviews = async () => Number(
await runJS(`(${_PREVIEW_COUNT_JS})()`, { elsewhere }).catch(() => -1)
);
// Locked screen: every window is occluded, Safari throttles the page, and the site can take
// 30–90 s to render the preview (GBP, 26.9.2026) — while the native-dialog escalation cannot
// run at all. So poll for up to 90 s instead of judging after 1.4 s.
const locked = await isScreenLocked();
const deadline = Date.now() + (locked && before >= 0 ? 90000 : 0);
let after;
do {
await new Promise((r) => setTimeout(r, locked ? 2000 : 1400));
after = await countPreviews();
} while (Date.now() < deadline && !(before >= 0 && after > before));
if (before >= 0 && after >= 0 && after <= before) {
ghostPickup = true;
console.error(`[Safari MCP] upload_file: ghost pickup on ${sel} (previews ${before}→${after}) — escalating to native dialog`);
}
}
if (forceNative || ghostPickup) {
try {
finalResult = await _nativeFileUpload(sel, resolvedPath, safeName);
if (ghostPickup) finalResult = `${finalResult} [escalated: synthetic pickup produced no preview]`;
} catch (nativeErr) {
finalResult = `${ghostPickup ? "NO PREVIEW — the page never showed the file. " : ""}${result} | Native file-dialog ${forceNative ? '(forced)' : '(ghost-pickup escalation)'} failed: ${nativeErr.message}`;
}
} else if (/verified 0 file|Upload attempted|el\.files is empty/i.test(result)) {
// Some composers (Google Business Profile, 26.10.2026) read the file and clear the input at
// once, so "verified 0" can be a success. With verifyPreview, a preview that appears settles
// it; on a locked screen that is the only check left, as the native dialog cannot open.
const before = Number((result.match(/\[previews=(-?\d+)\]/) || [])[1] ?? -1);
if (verifyPreview && before >= 0) {
const locked = await isScreenLocked();
const deadline = Date.now() + (locked ? 90000 : 3000);
let after = -1;
do {
await new Promise((r) => setTimeout(r, 2000));
after = Number(await runJS(`(${_PREVIEW_COUNT_JS})()`, { elsewhere }).catch(() => -1));
} while (Date.now() < deadline && !(after > before));
if (after > before) return `${result} | preview appeared (${before}→${after}): the page read the file and cleared the input`;
}
try {
finalResult = await _nativeFileUpload(sel, resolvedPath, safeName);
} catch (nativeErr) {
finalResult = `${result} | Native file-dialog fallback failed: ${nativeErr.message}`;
}
}
// Clean up the temp PNG produced by any sips image conversion above (was leaked before).
if (resolvedPath !== filePath) await unlink(resolvedPath).catch(() => {});
return finalResult;
}
// ========== NATIVE FILE UPLOAD (real NSOpenPanel — isTrusted, works on GitHub etc.) ==========
// Fallback for uploadFile when synthetic injection is rejected by isTrusted-gated / custom
// upload handlers (GitHub <file-attachment>, WAF forms). Momentarily makes the (usually
// hidden) <input type=file> clickable, native-clicks it to open the OS file dialog, then
// drives the dialog via System Events (Cmd+Shift+G → path → Return → Return). `sel` is
// already escJsSingleQuote-escaped by the caller.
async function _nativeFileUpload(sel, absPath, safeName) {
// Gate first: step 1 restyles the input into an invisible full-size overlay.
await assertScreenUnlocked();
// 1. Make the input clickable (hidden inputs have no hit-box) and read its viewport centre.
const coordsJson = await runJS(
`(function(){var el=document.querySelector('${sel}');if(!el){var all=document.querySelectorAll('*');for(var i=0;i<all.length;i++){var sr=all[i].shadowRoot;if(sr){el=sr.querySelector('${sel}');if(el)break;}}}if(!el)return JSON.stringify({error:'Element not found: ${sel}'});el.setAttribute('data-mcp-oldstyle',el.getAttribute('style')||'');el.style.cssText='position:fixed !important;top:42% !important;left:38% !important;width:320px !important;height:120px !important;opacity:0.02 !important;z-index:2147483647 !important;display:block !important;visibility:visible !important;pointer-events:auto !important';var r=el.getBoundingClientRect();return JSON.stringify({x:Math.round(r.left+r.width/2),y:Math.round(r.top+r.height/2)});})()`
);
let c;
try { c = JSON.parse(coordsJson); } catch { throw new Error("coord parse failed: " + coordsJson); }
if (c.error) throw new Error(c.error);
let dlgStatus;
let filesLen;
try {
dlgStatus = await _clickInputAndDriveDialog(c, absPath);
} finally {
// 4. Restore the input's original style — also when the click or the dialog threw, or the
// page keeps an invisible full-size overlay that swallows clicks.
filesLen = await runJS(
`(function(){var el=document.querySelector('${sel}');if(!el)return 'gone';var old=el.getAttribute('data-mcp-oldstyle');if(old!==null){if(old){el.setAttribute('style',old);}else{el.removeAttribute('style');}el.removeAttribute('data-mcp-oldstyle');}return String(el.files?el.files.length:0);})()`
).catch(() => "?");
}
// A dialog that never opened selected nothing; reporting "Uploaded" here sent callers on as if it had.
if (/sheet=false/.test(dlgStatus)) {
throw new Error(`the OS file dialog never opened (${dlgStatus}, input.files=${filesLen}) — nothing was selected`);
}
return `Uploaded via native file dialog (isTrusted): ${safeName} — input.files=${filesLen}, ${dlgStatus}. Real OS selection; accepted by isTrusted-gated handlers (GitHub etc.). Verify with safari_snapshot.`;
}
// Steps 2–3 of _nativeFileUpload: a native click on the restyled input opens the NSOpenPanel,
// then System Events drives it. Returns the dialog status ("sheet=true|false" or "osaerr:…").
async function _clickInputAndDriveDialog(c, absPath) {
// 2. Native (CGEvent, isTrusted:true) click on the input → opens the OS file dialog.
const geo = await _getSafariWindowGeometry();
if (!geo.windowId) throw new Error("no Safari window id (cannot native-click without focus steal)");
const screenX = geo.windowX + c.x;
const screenY = geo.windowY + geo.toolbarHeight + c.y;
// Safari must already be frontmost when the file input is clicked, otherwise the OS file
// dialog either does not open or opens unfocused. Activate with noFocusGuard so it STAYS
// frontmost (the normal osascript focus-guard would immediately restore focus elsewhere).
await osascriptFast('tell application "Safari" to activate', { noFocusGuard: true }).catch(() => {});
await new Promise((r) => setTimeout(r, 450));
// A targeted (windowId) click does NOT open the OS file dialog — that requires a real,
// hardware-like click that physically moves the cursor. Use the legacy path (windowId=0)
// for THIS click only; it briefly moves the mouse to the input, which opens the NSOpenPanel.
await _helperNativeClick(screenX, screenY, false, 0);
// 3. Drive the NSOpenPanel: wait for the sheet, Cmd+Shift+G, type the absolute path, confirm.
const pathEsc = absPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
// Activate + drive the dialog in ONE osascript so Safari stays frontmost throughout (a
// separate activate call would be undone by osascript()'s focus-guard restore). Generous
// timeout: the sheet-wait + Cmd+Shift+G + typing a long path can exceed the 10s default.
const status = await osascript(
`tell application "Safari" to activate
delay 0.3
tell application "System Events"
tell process "Safari"
set _t to 0
repeat until (exists sheet 1 of window 1) or _t > 25
delay 0.1
set _t to _t + 1
end repeat
set _hadSheet to (exists sheet 1 of window 1)
delay 0.25
keystroke "g" using {command down, shift down}
delay 0.45
keystroke "${pathEsc}"
delay 0.35
key code 36
delay 0.6
key code 36
return "sheet=" & _hadSheet
end tell
end tell`,
{ timeout: 30000 }
).catch((e) => "osaerr:" + e.message);
await new Promise((r) => setTimeout(r, 900));
return status;
}
// ========== PASTE IMAGE FROM FILE ==========
// `locate`: as for uploadFile.
export async function pasteImageFromFile({ filePath, locate = null }) {
_validateFilePath(filePath);
// Paste image via JS ClipboardEvent — NO clipboard touch, NO System Events, NO focus steal
const { extname } = await import("node:path");
const ext = extname(filePath).toLowerCase().replace(".", "");
const mimeMap = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" };
const mime = mimeMap[ext] || "image/png";
// Read image as base64
const fileData = await readFile(filePath);
const base64 = fileData.toString("base64");
const fileName = escJsSingleQuote(filePath.split("/").pop());
// Use runJSLarge — images are often >260KB as base64
const result = await runJSLarge(
`(function(){
var el = document.activeElement;
if (!el) return 'No focused element';
// Decode base64 to blob
var b64 = '${base64}';
var binary = atob(b64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
var blob = new Blob([bytes], { type: '${mime}' });
var file = new File([blob], '${fileName}', { type: '${mime}' });
// Method 1: Synthetic paste event with DataTransfer (works on Medium, dev.to, etc.)
var dt = new DataTransfer();
dt.items.add(file);
var pasteEvent = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true });
var handled = el.dispatchEvent(pasteEvent);
// Method 2: If paste didn't work, try drop event (works on drag-drop zones)
if (!handled || !document.querySelector('img[src^="blob:"],img[src^="data:"]')) {
var dropDt = new DataTransfer();
dropDt.items.add(file);
var dropEvent = new DragEvent('drop', { dataTransfer: dropDt, bubbles: true, cancelable: true });
el.dispatchEvent(new DragEvent('dragenter', { dataTransfer: dropDt, bubbles: true }));
el.dispatchEvent(new DragEvent('dragover', { dataTransfer: dropDt, bubbles: true }));
el.dispatchEvent(dropEvent);
}
return 'Pasted image: ${fileName} (' + Math.round(bytes.length / 1024) + ' KB)';
})()`,
{ timeout: 30000, elsewhere: _locusWindows(locate) }
);
return result;
}
// ========== EMULATE (VIEWPORT) ==========
// Captured from Mobile Safari on the iOS/iPadOS 27.0 simulator (Xcode 27, 2026-09-15). Since
// iOS 26 Safari freezes the OS token (18_6 at 26.0, 18_7 from 26.2) and only Version/ moves.
// iPadOS requests desktop sites, so an iPad sends the Mac UA and platform "MacIntel"; pages tell
// it apart by navigator.maxTouchPoints, which the override sets too.
const UA_IOS_27 = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Mobile/15E148 Safari/604.1";
const UA_IPADOS_27 = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Safari/605.1.15";
const _iphone = (width, height) => ({ width, height, ua: UA_IOS_27, platform: "iPhone", touchPoints: 5 });
const _ipad = (width, height) => ({ width, height, ua: UA_IPADOS_27, touchPoints: 5 });
// Portrait CSS viewport (screen points). The iphone-14* / ipad / ipad-pro names predate iOS 27
// and stay so existing callers keep working.
export const EMULATION_DEVICES = {
"iphone-18-pro": _iphone(402, 874),
"iphone-18-pro-max": _iphone(440, 956),
"iphone-air": _iphone(420, 912),
"iphone-17": _iphone(402, 874),
"iphone-17e": _iphone(390, 844),
"iphone-16": _iphone(393, 852),
"iphone-14": _iphone(390, 844),
"iphone-14-pro-max": _iphone(430, 932),
"ipad": _ipad(820, 1180),
"ipad-air-11": _ipad(820, 1180),
"ipad-air-13": _ipad(1024, 1366),
"ipad-pro-11": _ipad(834, 1210),
"ipad-pro-13": _ipad(1032, 1376),
"ipad-pro": _ipad(1024, 1366),
"pixel-7": { width: 412, height: 915, ua: "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" },
"galaxy-s24": { width: 412, height: 915, ua: "Mozilla/5.0 (Linux; Android 14; SM-S921B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" },
};
// Pure. An unknown name used to fall through to a bare 375×812 window with no UA while the
// result still echoed the requested device — refuse it instead.
export function resolveEmulation({ device, width, height, userAgent } = {}) {
if (device) {
const name = String(device).trim().toLowerCase();
if (!Object.hasOwn(EMULATION_DEVICES, name)) {
throw new Error(`Unknown device "${device}". Available: ${Object.keys(EMULATION_DEVICES).join(", ")} — or pass width/height (and userAgent).`);
}
return { name, ...EMULATION_DEVICES[name] };
}
const size = (v, fallback) => (Number(v) > 0 ? Math.round(Number(v)) : fallback);
return { name: "custom", width: size(width, 375), height: size(height, 812), ua: userAgent || "" };
}
// Pure. The getters carry a marker so resetEmulation() removes only what emulate() added.
export function buildNavigatorOverrideJS({ ua, platform, touchPoints } = {}) {
const props = [];
if (ua) props.push(`['userAgent','${escJsSingleQuote(ua)}']`);
if (platform) props.push(`['platform','${escJsSingleQuote(platform)}']`);
if (touchPoints != null) props.push(`['maxTouchPoints',${Number(touchPoints)}]`);
if (!props.length) return "";
return `(function(){[${props.join(",")}].forEach(function(p){var g=function(){return p[1]};g.__mcpEmulated=true;Object.defineProperty(navigator,p[0],{get:g,configurable:true})});return navigator.userAgent})()`;
}
export const RESET_NAVIGATOR_JS = "(function(){['userAgent','platform','maxTouchPoints'].forEach(function(k){var d=Object.getOwnPropertyDescriptor(navigator,k);if(d&&d.get&&d.get.__mcpEmulated)delete navigator[k]});return navigator.userAgent})()";
export async function emulate(args) {
const target = resolveEmulation(args);
await refreshTargetWindow();
const winRef = getTargetWindowRef();
const st = _st();
// Remember the user's window once, so reset_emulation puts it back instead of forcing 1440×900.
// Comma-joined explicitly: the helper daemon returns "" for an AppleScript list.
if (!st.preEmulationBounds) {
const b = String(await osascriptFast(
`tell application "Safari"\n set b to bounds of ${winRef}\n return (item 1 of b as text) & "," & (item 2 of b as text) & "," & (item 3 of b as text) & "," & (item 4 of b as text)\nend tell`
).catch(() => "")).trim();
if (/^-?\d+,-?\d+,-?\d+,-?\d+$/.test(b)) st.preEmulationBounds = b;
}
// Size from the measured browser chrome, so the page viewport — not the window — matches.
let chrome = 90;
try {
const c = Number(await runJS("(window.outerHeight - window.innerHeight) + ''"));
if (Number.isFinite(c) && c >= 50 && c <= 200) chrome = c;
} catch (_e) { /* keep the Safari 26/27 default */ }
await osascriptFast(`tell application "Safari" to set bounds of ${winRef} to {0, 0, ${target.width}, ${target.height + chrome}}`);
// No reload after this: a reload gives the page a fresh navigator and silently drops the
// override — which is what the old reload-to-apply step did to every emulation.
const overrideJS = buildNavigatorOverrideJS(target);
if (overrideJS) await runJS(overrideJS);
const actual = await _relayoutAndMeasure();
const notes = [];
if (overrideJS) notes.push("navigator.userAgent/platform/maxTouchPoints are overridden for this page's JavaScript only: the HTTP User-Agent header is unchanged, and a reload or navigation clears the override — call safari_emulate again after navigating.");
if (actual && actual[0] > target.width) notes.push(`Safari kept the window wider than ${target.width}px (page viewport ${actual[0]}px).`);
if (actual && actual[1] < target.height) notes.push(`The screen is too short for a ${target.height}px viewport — the page gets ${actual[1]}px.`);
return JSON.stringify({ device: target.name, viewport: { requested: [target.width, target.height], actual }, userAgent: target.ua || "(unchanged)", notes });
}
// A background tab keeps its old layout — innerWidth, media queries, even outerWidth — until
// it is shown (seen on Safari 27: 1512px after a resize to 402 until the tab was fronted). Show
// ours for a moment (selection restored, Safari never activated) so the new size sticks.
async function _relayoutAndMeasure() {
let size = null;
await _withTargetTabFronted(async () => {
await new Promise((r) => setTimeout(r, 250));
try { size = JSON.parse(await runJS("JSON.stringify([window.innerWidth, window.innerHeight])")); } catch (_e) { /* reported as null */ }
});
return size;
}
export async function resetEmulation() {
await refreshTargetWindow();
const st = _st();
const bounds = st.preEmulationBounds || "0,0,1440,900";
st.preEmulationBounds = null;
await osascriptFast(`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${bounds}}`);
// Deleting the marked getters restores Navigator.prototype's — no reload, so page state survives.
await runJS(RESET_NAVIGATOR_JS);
const viewport = await _relayoutAndMeasure();
return `Emulation reset: window bounds {${bounds}}, viewport ${viewport ? viewport.join("×") : "unknown"}, navigator overrides removed`;
}
// ========== CONSOLE CAPTURE ==========
export async function startConsoleCapture() {
await runJS(
"if(!window.__mcp_console){window.__mcp_console=[];var orig={log:console.log,warn:console.warn,error:console.error,info:console.info};['log','warn','error','info'].forEach(function(level){console[level]=function(){window.__mcp_console.push({level:level,message:[].slice.call(arguments).map(String).join(' '),time:Date.now()});if(window.__mcp_console.length>2000)window.__mcp_console.shift();orig[level].apply(console,arguments);};});window.addEventListener('error',function(e){window.__mcp_console.push({level:'error',message:e.message,time:Date.now()});if(window.__mcp_console.length>2000)window.__mcp_console.shift();});}"
);
return "Console capture started";
}
export async function getConsoleMessages() {
return runJS("JSON.stringify(window.__mcp_console||[])");
}
export async function clearConsoleCapture() {
return runJS("window.__mcp_console=[]; 'Console cleared'");
}
// ========== PDF SAVE ==========
export async function savePDF({ path: pdfPath }) {
await refreshTargetWindow();
_validateFilePath(pdfPath); // allowlist (/Users//tmp//var-folders) + block sensitive paths — prevents arbitrary overwrite
// NO app focus stealing — tab selection, window resize and screencapture all stay
// inside our window; the user's frontmost app is never activated.
const tmpPng = join(tmpdir(), `safari-mcp-pdf-${Date.now()}.png`);
// screencapture -l grabs the window's SELECTED tab — front ours for the duration
// (selection restored by _withTargetTabFronted), otherwise the PDF shows whatever
// tab the user last left selected in that window.
await _withTargetTabFronted(async () => {
// Step 1: Get full page dimensions
const dims = await runJS("JSON.stringify({h:document.documentElement.scrollHeight,w:document.documentElement.scrollWidth})");
const { h, w } = JSON.parse(dims);
// Step 2: Save current bounds and resize to capture full page
const origBounds = await osascript(
`tell application "Safari" to return bounds of ${getTargetWindowRef()}`
);
const captureHeight = Math.min(Number(h) + 100, 16000);
await osascript(
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, ${Number(w)}, ${captureHeight}}`
);
await new Promise(r => setTimeout(r, 500)); // Let page reflow
// Step 3: Take screenshot via screencapture -l (window-targeted, NO focus steal)
const windowIdRaw = await osascript(
`tell application "Safari" to return id of ${getTargetWindowRef()}`
);
const windowId = windowIdRaw != null && /^\d+$/.test(String(windowIdRaw).trim()) ? String(windowIdRaw).trim() : null;
if (!windowId) throw new Error("Cannot get Safari window ID for PDF capture");
try {
// Route capture through the TCC-granted helper (NSAppleScript → do shell script):
// under the launchd daemon, node has no Screen Recording grant — the helper does.
await osascriptFast(
`do shell script "/usr/sbin/screencapture -l${windowId} -o -x '${tmpPng}'"`,
{ timeout: 15000 }
);
} catch (err) {
// Restore bounds on failure
await osascript(`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${origBounds}}`).catch(() => {});
throw new Error(`PDF screenshot capture failed: ${err.message}`);
}
// Step 4: Restore original bounds
await osascript(
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${origBounds}}`
).catch(() => {});
});
// Step 5: Convert screenshot to PDF with sips (macOS built-in). Replaces the previous
// python3+Quartz converter: current macOS ships pyobjc for neither the system nor the
// homebrew python3, so any bare `python3` died with ModuleNotFoundError: Quartz.
try {
await execFileAsync("/usr/bin/sips", ["-s", "format", "pdf", tmpPng, "--out", pdfPath], { timeout: 15000 });
} catch (err) {
throw new Error(`PDF conversion failed: ${err.message}`);
} finally {
unlink(tmpPng).catch(() => {});
}
return `PDF saved to: ${pdfPath} (image-based, no focus stealing)`;
}
// ========== SNAPSHOT — ref-based interaction (like Chrome DevTools MCP) ==========
// Assigns numeric refs to interactive/visible elements so Claude can say "click ref 5"
// instead of guessing CSS selectors. Much faster, no hallucination risk.
let _snapshotGen = 0;
// getNextSnapshotGen is used by the MCP tool path (index.js) to reserve a gen for the extension.
// If the extension fails and falls back to takeSnapshot(), takeSnapshot uses _snapshotGen directly
// (which was already incremented by getNextSnapshotGen) — so no double-increment occurs.
export function getNextSnapshotGen() { return _snapshotGen++; }
export async function takeSnapshot({ selector, _gen } = {}) {
// Use provided gen (from tool path) or allocate a new one (direct call)
const gen = _gen != null ? _gen : _snapshotGen++;
const root = selector ? `document.querySelector('${escJsSingleQuote(selector)}')` : "document.body";
const result = await runJS(
`(function(){
var gen = ${gen};
var id = 0;
var lines = [];
// Clear old refs
document.querySelectorAll('[data-mcp-ref]').forEach(function(el){ el.removeAttribute('data-mcp-ref'); });
function getRole(el) {
var role = el.getAttribute('role');
if (role) return role;
var tag = el.tagName.toLowerCase();
var map = {
a:'link', button:'button', input:'textbox', textarea:'textbox',
select:'combobox', img:'img', h1:'heading', h2:'heading', h3:'heading',
h4:'heading', h5:'heading', h6:'heading', nav:'navigation', main:'main',
header:'banner', footer:'contentinfo', form:'form', table:'table',
tr:'row', th:'columnheader', td:'cell', ul:'list', ol:'list', li:'listitem',
dialog:'dialog', details:'group', summary:'button', label:'label',
iframe:'document', video:'video', audio:'audio', canvas:'canvas',
progress:'progressbar', meter:'meter'
};
if (tag === 'input') {
var type = (el.type || 'text').toLowerCase();
if (type === 'checkbox') return 'checkbox';
if (type === 'radio') return 'radio';
if (type === 'submit' || type === 'button') return 'button';
if (type === 'file') return 'file';
if (type === 'range') return 'slider';
return 'textbox';
}
return map[tag] || null;
}
function getName(el) {
var ariaLabel = el.getAttribute('aria-label');
if (ariaLabel) return ariaLabel;
var ariaLabelledBy = el.getAttribute('aria-labelledby');
if (ariaLabelledBy) {
var ref = document.getElementById(ariaLabelledBy);
if (ref) return ref.textContent.trim().substring(0,80);
}
if (el.tagName === 'IMG') return el.alt || '';
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') {
var label = el.closest('label') || (el.id && document.querySelector('label[for=\"'+el.id+'\"]'));
if (label) return label.textContent.trim().substring(0,80);
if (el.placeholder) return el.placeholder;
if (el.name) return el.name;
}
if (el.title) return el.title;
// For links/buttons, use text content
if (['A','BUTTON','LABEL','SUMMARY'].includes(el.tagName)) {
return el.textContent.trim().substring(0,80);
}
return '';
}
function isInteractive(el) {
var tag = el.tagName;
if (['A','BUTTON','INPUT','TEXTAREA','SELECT','SUMMARY','DETAILS'].includes(tag)) return true;
if (el.getAttribute('role')) return true;
if (el.getAttribute('tabindex') !== null) return true;
if (el.onclick || el.getAttribute('onclick')) return true;
if (el.isContentEditable) return true;
return false;
}
function isStyleVisible(el) {
var style = window.getComputedStyle(el);
if (!style || style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') return false;
if (el.getAttribute('aria-hidden') === 'true') return false;
return true;
}
function isVisible(el) {
if (!isStyleVisible(el)) return false;
var r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
}
function walk(el, depth) {
if (depth > 20 || id > 800) return;
if (!isStyleVisible(el)) return;
var role = getRole(el);
var interactive = isInteractive(el);
var isHeading = /^H[1-6]$/.test(el.tagName);
var isText = !role && el.children.length === 0 && el.textContent.trim().length > 0 && el.textContent.trim().length < 200;
var visible = isVisible(el);
// Include: interactive elements, headings, images, text nodes with content
if (visible && (role || interactive || isHeading || isText)) {
var ref = gen + '_' + (id++);
el.setAttribute('data-mcp-ref', ref);
var rect=el.getBoundingClientRect();
var meta={tag:el.tagName};var nm=getName(el);if(nm)meta.text=nm.substring(0,80);
if(el.id)meta.id=el.id;
if(el.getAttribute('name'))meta.nameAttr=el.getAttribute('name');
var _ti=el.getAttribute('data-testid');if(_ti)meta.testid=_ti;
if(el.href)meta.href=el.href;
var _al=el.getAttribute('aria-label');if(_al)meta.al=_al;
if(el.placeholder)meta.ph=el.placeholder;
meta.cx=Math.round(window.scrollX+rect.left+rect.width/2);
meta.cy=Math.round(window.scrollY+rect.top+rect.height/2);
window.__mcpRefs[ref]=meta;
var indent = ' '.repeat(depth);
var line = indent + 'ref=' + ref + ' ';
if (role) line += role;
else if (isText) line += 'text';
else line += el.tagName.toLowerCase();
var name = getName(el);
if (name) line += ' "' + name.replace(/"/g, "'") + '"';
// Value for inputs
if (el.value !== undefined && el.value !== '' && el.tagName !== 'BUTTON') {
line += ' value="' + String(el.value).substring(0,50).replace(/"/g, "'") + '"';
}
// Checked state
if (el.checked) line += ' checked';
// Disabled
if (el.disabled) line += ' disabled';
// Required
if (el.required) line += ' required';
// Selected (option)
if (el.selected) line += ' selected';
// Expanded (details, aria-expanded)
if (el.open !== undefined) line += el.open ? ' expanded' : ' collapsed';
if (el.getAttribute('aria-expanded') === 'true') line += ' expanded';
if (el.getAttribute('aria-expanded') === 'false') line += ' collapsed';
// Heading level
if (isHeading) line += ' level=' + el.tagName[1];
// Focusable
if (el.tabIndex >= 0) line += ' focusable';
// Link href
if (el.tagName === 'A' && el.href) line += ' href="' + el.href.substring(0,100) + '"';
// Content editable
if (el.isContentEditable && el.getAttribute('contenteditable') !== 'inherit') line += ' editable';
lines.push(line);
}
// Recurse into children
for (var i = 0; i < el.children.length; i++) {
walk(el.children[i], depth + (role ? 1 : 0));
}
if (el.shadowRoot) {
for (var j = 0; j < el.shadowRoot.children.length; j++) {
walk(el.shadowRoot.children[j], depth + (role ? 1 : 0));
}
}
}
window.__mcpRefs = {};
window.__mcpRefsTime = Date.now();
var root = ${root};
if (!root) return 'Element not found';
walk(root, 0);
return lines.join('\\n');
})()`
);
return result;
}
// Click/fill/type by ref — resolves data-mcp-ref attribute
export function refSelector(ref) {
return `[data-mcp-ref="${ref}"]`;
}
// ========== RUN SCRIPT (multi-step automation in one call) ==========
// Execute multiple safari.js operations in a single tool call
// Avoids round-trip overhead of calling tools one by one
// script is a JSON array of steps: [{action: "navigate", args: {url: "..."}}, {action: "click", args: {selector: "..."}}, ...]
export async function runScript({ steps, onStep, actions: overrides = {} }) {
const results = [];
for (const step of steps) {
const { action, args = {} } = step;
// Safety callback (tab-ownership, wired by index.js) runs OUTSIDE the per-step
// try/catch: a refusal must abort the whole batch, not be recorded as a step
// error and silently continue to the next step.
if (onStep) onStep(action, args);
try {
// Map action names to safari.js functions
const actions = {
navigate: (a) => navigate(a.url),
reload: (a) => reload(a.hard ?? a.hardReload ?? false),
newTab: (a) => newTab(a.url || ""),
closeTab: (a) => closeOwnTab(a.index),
switchTab: (a) => switchTab(a.index),
navigateAndRead: (a) => navigateAndRead(a.url, a),
click, doubleClick, rightClick, fill, clearField, typeText,
pressKey, scroll, scrollTo, scrollToElement, readPage, getPageSource,
screenshot, screenshotElement, evaluate, waitFor, waitForTime, hover,
selectOption, fillForm, fillAndSubmit, clickAndWait,
goBack, goForward, listTabs,
getLocalStorage, setLocalStorage, deleteLocalStorage,
getSessionStorage, setSessionStorage, deleteSessionStorage,
getCookies, setCookie, deleteCookies, getElementInfo, querySelectorAll,
extractTables, extractMeta, extractImages, extractLinks,
analyzePage, detectForms, getAccessibilityTree, getPerformanceMetrics,
// Previously-missing actions — these tools existed but couldn't be batched.
verifyState, reactSelectSet, reactSelectListOptions,
nativeClick, nativeHover, nativeType, nativeKeyboard,
replaceEditorContent, uploadFile, mockNetworkRoute,
};
// index.js takes over an action where it has more to go on (switchTab, getReceipt: the extension;
// newTab and closeTab: the tab ownership it keeps).
const fn = overrides[action] || actions[action];
if (!fn) {
results.push({ action, error: `Unknown action: ${action}` });
continue;
}
const result = await fn(args);
results.push({ action, result: typeof result === "string" ? result.substring(0, 2000) : result });
} catch (err) {
results.push({ action, error: err.message });
// A switch that did not happen stops the batch: the steps after it were meant for that tab,
// and would run in the current one. getReceipt switches too, to the tab its receipt names.
if (action === "switchTab" || action === "getReceipt") break;
}
}
return JSON.stringify(results);
}
// ========== ACCESSIBILITY SNAPSHOT ==========
export async function getAccessibilityTree({ selector, maxDepth = 5 }) {
const sel = selector ? `'${escJsSingleQuote(selector)}'` : "null";
return runJS(
`(function(){
function buildTree(el, depth) {
if (!el || depth > ${Number(maxDepth)}) return null;
var role = el.getAttribute('role') || el.tagName.toLowerCase();
var ariaLabel = el.getAttribute('aria-label') || '';
var ariaDescribedBy = el.getAttribute('aria-describedby') || '';
var ariaExpanded = el.getAttribute('aria-expanded');
var ariaChecked = el.getAttribute('aria-checked');
var ariaSelected = el.getAttribute('aria-selected');
var ariaDisabled = el.getAttribute('aria-disabled');
var ariaHidden = el.getAttribute('aria-hidden');
var tabIndex = el.tabIndex;
var text = '';
if (el.childNodes.length === 1 && el.childNodes[0].nodeType === 3) {
text = el.childNodes[0].textContent.trim().substring(0, 100);
}
var node = { role: role };
if (ariaLabel) node.name = ariaLabel;
if (text) node.text = text;
if (el.id) node.id = el.id;
if (ariaExpanded !== null) node.expanded = ariaExpanded;
if (ariaChecked !== null) node.checked = ariaChecked;
if (ariaSelected !== null) node.selected = ariaSelected;
if (ariaDisabled !== null) node.disabled = ariaDisabled;
if (ariaHidden === 'true') node.hidden = true;
if (tabIndex >= 0) node.focusable = true;
if (el.tagName === 'A' && el.href) node.href = el.href;
if (el.tagName === 'IMG') { node.alt = el.alt || '(missing)'; node.src = el.src; }
if (['INPUT','TEXTAREA','SELECT'].includes(el.tagName)) {
node.type = el.type || el.tagName.toLowerCase();
node.value = (el.value || '').substring(0, 100);
if (el.required) node.required = true;
if (el.placeholder) node.placeholder = el.placeholder;
}
var children = [];
for (var i = 0; i < el.children.length; i++) {
if (el.children[i].getAttribute('aria-hidden') === 'true') continue;
var child = buildTree(el.children[i], depth + 1);
if (child) children.push(child);
}
if (children.length > 0) node.children = children;
return node;
}
var root = ${sel} ? document.querySelector(${sel}) : document.body;
if (!root) return JSON.stringify({ error: 'Element not found' });
return JSON.stringify(buildTree(root, 0));
})()`,
{ timeout: 30000 }
);
}
// ========== COOKIE CRUD ==========
export async function setCookie({ name, value, domain, path: cookiePath, expires, secure, sameSite, httpOnly }) {
const safeName = escJsSingleQuote(name);
const safeValue = escJsSingleQuote(value);
// Every interpolated attribute goes through escJsSingleQuote — path/domain/expires
// used to be embedded raw and then "escaped" with a quote-only replace (no
// backslash-first), which both broke legitimate values and re-opened the literal.
let cookie = `${safeName}=${safeValue}`;
if (cookiePath) cookie += `; path=${escJsSingleQuote(cookiePath)}`;
if (domain) cookie += `; domain=${escJsSingleQuote(domain)}`;
if (expires) cookie += `; expires=${escJsSingleQuote(expires)}`;
if (secure) cookie += '; secure';
if (sameSite) cookie += `; samesite=${sameSite}`;
return pageJS(`document.cookie='${cookie}'; 'Cookie set: ${safeName}'`);
}
export async function deleteCookies({ name, all }) {
if (all) {
return pageJS(
`(function(){var cookies=document.cookie.split(';');var count=0;cookies.forEach(function(c){var name=c.split('=')[0].trim();document.cookie=name+'=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/';count++;});return 'Deleted '+count+' cookies';})()`
);
}
if (name) {
const safeName = escJsSingleQuote(name);
return pageJS(
`document.cookie='${safeName}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/'; 'Deleted cookie: ${safeName}'`
);
}
throw new Error("deleteCookies requires name or all:true");
}
// ========== SESSION STORAGE ==========
export async function getSessionStorage({ key }) {
if (key) {
const safeKey = escJsSingleQuote(key);
return pageJS(`sessionStorage.getItem('${safeKey}')`);
}
return pageJS(
"JSON.stringify(Object.fromEntries(Object.keys(sessionStorage).map(function(k){var v=sessionStorage.getItem(k);return[k,v==null?null:v.substring(0,200)]})))"
);
}
export async function setSessionStorage({ key, value }) {
const safeKey = escJsSingleQuote(key);
const safeValue = escJsSingleQuote(value);
return pageJS(`sessionStorage.setItem('${safeKey}','${safeValue}'); 'Set sessionStorage: ${safeKey}'`);
}
export async function setLocalStorage({ key, value }) {
const safeKey = escJsSingleQuote(key);
const safeValue = escJsSingleQuote(value);
return pageJS(`localStorage.setItem('${safeKey}','${safeValue}'); 'Set localStorage: ${safeKey}'`);
}
export async function deleteLocalStorage({ key }) {
if (key) {
const safeKey = escJsSingleQuote(key);
return pageJS(`localStorage.removeItem('${safeKey}'); 'Deleted localStorage: ${safeKey}'`);
}
return pageJS("var n=localStorage.length; localStorage.clear(); 'Cleared localStorage: '+n+' items'");
}
export async function deleteSessionStorage({ key }) {
if (key) {
const safeKey = escJsSingleQuote(key);
return pageJS(`sessionStorage.removeItem('${safeKey}'); 'Deleted sessionStorage: ${safeKey}'`);
}
return pageJS("var n=sessionStorage.length; sessionStorage.clear(); 'Cleared sessionStorage: '+n+' items'");
}
// Export all storage state (cookies + localStorage + sessionStorage) as JSON
export async function exportStorageState() {
return pageJS(
`JSON.stringify({
url: location.href,
cookies: document.cookie,
localStorage: Object.fromEntries(Object.keys(localStorage).map(function(k){return[k,localStorage.getItem(k)]})),
sessionStorage: Object.fromEntries(Object.keys(sessionStorage).map(function(k){return[k,sessionStorage.getItem(k)]}))
})`
);
}
// Import storage state from JSON
export async function importStorageState({ state }) {
const parsed = typeof state === "string" ? JSON.parse(state) : state;
const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "");
const cmds = [];
// Cookies must be set one at a time — document.cookie only accepts one cookie per assignment
if (parsed.cookies) {
const cookiePairs = String(parsed.cookies).split(/;\s*/);
for (const pair of cookiePairs) {
if (pair.trim()) cmds.push(`document.cookie='${esc(pair.trim())}'`);
}
}
if (parsed.localStorage) {
for (const [k, v] of Object.entries(parsed.localStorage)) {
cmds.push(`localStorage.setItem('${esc(k)}','${esc(v)}')`);
}
}
if (parsed.sessionStorage) {
for (const [k, v] of Object.entries(parsed.sessionStorage)) {
cmds.push(`sessionStorage.setItem('${esc(k)}','${esc(v)}')`);
}
}
// Use runJSLarge for large sessions (many cookies/localStorage keys can exceed 260KB limit of runJS)
const script = cmds.join(";") + "; 'Imported ' + " + cmds.length + " + ' items'";
return pageJS(script, () => (script.length > 200000 ? runJSLarge(script, { timeout: 30000 }) : runJS(script)));
}
// ========== CLIPBOARD ==========
export async function clipboardRead() {
// Acquire lock to avoid reading during a write/restore cycle
await _acquireClipboardLock(3000); // Short timeout — reads are fast
try {
const text = await execFileAsync("pbpaste", []);
return text.stdout;
} catch {
return "(clipboard empty or contains non-text data)";
} finally {
_releaseClipboardLock();
}
}
export async function clipboardWrite({ text, restore = true }) {
await _acquireClipboardLock();
try {
// Save current clipboard
const oldClipboard = restore ? await _saveClipboard() : null;
// Use spawn + stdin pipe — safe from shell injection (no shell involved)
await _pbcopy(text);
// Restore clipboard after 2 seconds (reduced from 5s — shorter exposure window)
if (restore && oldClipboard !== null) {
if (_clipboardRestoreTimer) clearTimeout(_clipboardRestoreTimer);
// Stash the content so flushClipboardRestore() can restore it synchronously if the
// process is signalled to exit inside this 2s window — otherwise the user is left
// holding the tool's pasted text (violates the clipboard-safety guarantee).
_pendingClipboardRestore = oldClipboard;
_clipboardRestoreTimer = setTimeout(async () => {
await _restoreClipboard(oldClipboard);
_pendingClipboardRestore = undefined;
_clipboardRestoreTimer = null;
_releaseClipboardLock();
}, 2000);
return `Copied ${text.length} chars to clipboard (will restore in 2s)`;
}
_releaseClipboardLock();
return `Copied ${text.length} chars to clipboard`;
} catch (err) {
_releaseClipboardLock();
throw err;
}
}
// Synchronously flush a pending clipboard restore — called from the shutdown handler so the
// user never inherits the tool's pasted text if the process exits inside the 2s restore
// window. Uses spawnSync (blocking) because we're on the exit path and can't await a Promise.
export function flushClipboardRestore() {
if (_clipboardRestoreTimer) {
clearTimeout(_clipboardRestoreTimer);
_clipboardRestoreTimer = null;
}
if (_pendingClipboardRestore === undefined) return;
const content = _pendingClipboardRestore;
_pendingClipboardRestore = undefined;
try {
spawnSync("pbcopy", [], { input: content });
} catch { /* best effort — we're exiting anyway */ }
_releaseClipboardLock();
}
// ========== NETWORK MOCKING ==========
// Intercept fetch/XHR requests matching a URL pattern and return mock responses
export async function mockNetworkRoute({ urlPattern, response }) {
// Escape backslash FIRST, then quotes — the reverse order double-escapes the quote
// (\' → \\') and breaks out of the JS string literal.
const safePattern = escJsSingleQuote(urlPattern);
const safeBody = (response.body || "").replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n");
const status = Number(response.status) || 200;
// contentType reaches the injected JS literal — escape it like every other field.
const contentType = escJsSingleQuote(response.contentType || "application/json");
return runJS(
`(function(){
if (!window.__mcp_mocks) window.__mcp_mocks = [];
window.__mcp_mocks.push({pattern: '${safePattern}', status: ${status}, body: '${safeBody}', contentType: '${contentType}'});
// Patch fetch (once)
if (!window.__mcp_fetch_patched) {
window.__mcp_fetch_patched = true;
var origFetch = window.fetch;
window.fetch = function(url, opts) {
var reqUrl = typeof url === 'string' ? url : url.url;
var mock = window.__mcp_mocks.find(function(m) {
return reqUrl.includes(m.pattern) || new RegExp(m.pattern).test(reqUrl);
});
if (mock) {
return Promise.resolve(new Response(mock.body, {
status: mock.status,
headers: {'Content-Type': mock.contentType}
}));
}
return origFetch.apply(this, arguments);
};
// Patch XHR
var origOpen = XMLHttpRequest.prototype.open;
var origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url) {
this.__mcp_url = url;
this.__mcp_method = method;
return origOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
var mock = (window.__mcp_mocks || []).find(function(m) {
return this.__mcp_url && (this.__mcp_url.includes(m.pattern) || new RegExp(m.pattern).test(this.__mcp_url));
}.bind(this));
if (mock) {
Object.defineProperty(this, 'status', {get: function(){return mock.status;}});
Object.defineProperty(this, 'responseText', {get: function(){return mock.body;}});
Object.defineProperty(this, 'response', {get: function(){return mock.body;}});
Object.defineProperty(this, 'readyState', {get: function(){return 4;}});
this.dispatchEvent(new Event('readystatechange'));
this.dispatchEvent(new Event('load'));
return;
}
return origSend.apply(this, arguments);
};
}
return 'Mock added: ' + '${safePattern}' + ' → ' + ${status} + ' (' + window.__mcp_mocks.length + ' total mocks)';
})()`
);
}
// Remove all network mocks
export async function clearNetworkMocks() {
return runJS(
"window.__mcp_mocks=[]; 'All network mocks cleared'"
);
}
// ========== WAIT FOR TIME ==========
export async function waitForTime({ ms }) {
const capped = Math.min(Number(ms) || 0, 60000); // Cap at 60 seconds
await new Promise((r) => setTimeout(r, capped));
return capped < Number(ms) ? `Waited ${capped}ms (capped from ${ms}ms — max 60s)` : `Waited ${ms}ms`;
}
// ========== NETWORK CAPTURE (Detailed) ==========
export async function startNetworkCapture() {
await runJS(
`if(!window.__mcp_network){window.__mcp_network=[];
var origFetch=window.fetch;
window.fetch=function(){var url=arguments[0];var opts=arguments[1]||{};var start=Date.now();
return origFetch.apply(this,arguments).then(function(resp){
var entry={url:typeof url==='string'?url:url.url,method:opts.method||'GET',status:resp.status,statusText:resp.statusText,
type:'fetch',duration:Date.now()-start,headers:Object.fromEntries([...resp.headers.entries()].slice(0,20)),time:new Date().toISOString()};
window.__mcp_network.push(entry);if(window.__mcp_network.length>5000)window.__mcp_network.shift();return resp;
}).catch(function(err){
window.__mcp_network.push({url:typeof url==='string'?url:url.url,method:opts.method||'GET',error:err.message,type:'fetch',time:new Date().toISOString()});if(window.__mcp_network.length>5000)window.__mcp_network.shift();
throw err;
});
};
var origXHR=XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open=function(method,url){
this.__mcp_method=method;this.__mcp_url=url;this.__mcp_start=Date.now();
this.addEventListener('load',function(){
window.__mcp_network.push({url:this.__mcp_url,method:this.__mcp_method,status:this.status,statusText:this.statusText,
type:'xhr',duration:Date.now()-this.__mcp_start,responseSize:this.responseText.length,time:new Date().toISOString()});if(window.__mcp_network.length>5000)window.__mcp_network.shift();
});
this.addEventListener('error',function(){
window.__mcp_network.push({url:this.__mcp_url,method:this.__mcp_method,error:'Network error',type:'xhr',time:new Date().toISOString()});if(window.__mcp_network.length>5000)window.__mcp_network.shift();
});
return origXHR.apply(this,arguments);
};}`
);
return "Network capture started (fetch + XHR interception)";
}
export async function clearNetworkCapture() {
return runJS("window.__mcp_network=[]; 'Network capture cleared'");
}
export async function getNetworkDetails({ limit = 50, filter } = {}) {
const filterStr = filter ? `.filter(function(r){return r.url.includes('${escJsSingleQuote(filter)}')})` : "";
return runJS(
`JSON.stringify((window.__mcp_network||[])${filterStr}.slice(-${Number(limit)}))`
);
}
// ========== PERFORMANCE METRICS ==========
export async function getPerformanceMetrics() {
return runJS(
`(function(){
var nav = performance.getEntriesByType('navigation')[0] || {};
var paint = performance.getEntriesByType('paint');
var fcp = paint.find(function(p){return p.name==='first-contentful-paint'});
var lcp = null;
try {
var entries = performance.getEntriesByType('largest-contentful-paint');
if (entries.length) lcp = entries[entries.length - 1];
} catch(e) {}
var cls = 0;
try {
var entries = performance.getEntriesByType('layout-shift');
entries.forEach(function(e){ if (!e.hadRecentInput) cls += e.value; });
} catch(e) {}
var resources = performance.getEntriesByType('resource');
var totalTransfer = resources.reduce(function(sum, r){ return sum + (r.transferSize || 0); }, 0);
return JSON.stringify({
navigation: {
dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
tcp: Math.round(nav.connectEnd - nav.connectStart),
ttfb: Math.round(nav.responseStart - nav.requestStart),
download: Math.round(nav.responseEnd - nav.responseStart),
domInteractive: Math.round(nav.domInteractive),
domComplete: Math.round(nav.domComplete),
loadEvent: Math.round(nav.loadEventEnd),
},
webVitals: {
fcp: fcp ? Math.round(fcp.startTime) : null,
lcp: lcp ? Math.round(lcp.startTime) : null,
cls: Math.round(cls * 1000) / 1000,
},
resources: {
total: resources.length,
totalTransferKB: Math.round(totalTransfer / 1024),
byType: resources.reduce(function(acc, r) {
var type = r.initiatorType || 'other';
if (!acc[type]) acc[type] = { count: 0, sizeKB: 0 };
acc[type].count++;
acc[type].sizeKB += Math.round((r.transferSize || 0) / 1024);
return acc;
}, {}),
},
memory: window.performance.memory ? {
usedMB: Math.round(performance.memory.usedJSHeapSize / 1048576),
totalMB: Math.round(performance.memory.totalJSHeapSize / 1048576),
limitMB: Math.round(performance.memory.jsHeapSizeLimit / 1048576),
} : null,
});
})()`
);
}
// ========== NETWORK THROTTLING ==========
export async function throttleNetwork({ profile, latency, downloadKbps, uploadKbps }) {
const profiles = {
"slow-3g": { latency: 2000, download: 50, upload: 50 },
"fast-3g": { latency: 560, download: 150, upload: 75 },
"4g": { latency: 170, download: 400, upload: 150 },
offline: { latency: 0, download: 0, upload: 0 },
};
const p = profile ? profiles[profile.toLowerCase()] : null;
const lat = p ? p.latency : (latency || 0);
const dl = p ? p.download : (downloadKbps || 0);
if (profile === "offline") {
await runJS(
`window.__mcp_throttle={active:true,profile:'offline'};
var origFetch=window.__mcp_origFetch||window.fetch;
window.__mcp_origFetch=origFetch;
window.fetch=function(){return Promise.reject(new TypeError('Network request failed (simulated offline)'));};`
);
return "Network throttled: offline";
}
if (lat > 0) {
await runJS(
`window.__mcp_throttle={active:true,profile:'${profile || "custom"}',latency:${lat},downloadKbps:${dl}};
var origFetch=window.__mcp_origFetch||window.fetch;
window.__mcp_origFetch=origFetch;
window.fetch=function(){var args=arguments;return new Promise(function(resolve){
setTimeout(function(){resolve(origFetch.apply(window,args));},${lat});
});};`
);
return JSON.stringify({ profile: profile || "custom", latency: lat, downloadKbps: dl });
}
// Reset
await runJS(
`if(window.__mcp_origFetch){window.fetch=window.__mcp_origFetch;delete window.__mcp_origFetch;}
delete window.__mcp_throttle; 'Throttle removed'`
);
return "Network throttle removed";
}
// ========== CONSOLE FILTER ==========
export async function getConsoleByLevel({ level }) {
const safeLevel = escJsSingleQuote(level);
return runJS(
`JSON.stringify((window.__mcp_console||[]).filter(function(m){return m.level==='${safeLevel}'}))`
);
}
// ========== DATA EXTRACTION ==========
export async function extractTables({ selector, limit = 10 }) {
const sel = selector ? `'${escJsSingleQuote(selector)}'` : "'table'";
return runJS(
`(function(){
var tables = [...document.querySelectorAll(${sel})].slice(0, ${Number(limit)});
return JSON.stringify(tables.map(function(table, ti) {
var headers = [...table.querySelectorAll('thead th, thead td, tr:first-child th')].map(function(th){ return th.textContent.trim(); });
var rows = [...table.querySelectorAll('tbody tr, tr')].slice(headers.length ? 0 : 1).map(function(tr) {
return [...tr.querySelectorAll('td, th')].map(function(td){ return td.textContent.trim().substring(0, 200); });
});
return { index: ti, headers: headers, rows: rows.slice(0, 100), rowCount: rows.length };
}));
})()`
);
}
export async function extractMeta() {
return runJS(
`(function(){
var meta = {};
meta.title = document.title;
meta.description = (document.querySelector('meta[name="description"]') || {}).content || '';
meta.canonical = (document.querySelector('link[rel="canonical"]') || {}).href || '';
meta.robots = (document.querySelector('meta[name="robots"]') || {}).content || '';
meta.viewport = (document.querySelector('meta[name="viewport"]') || {}).content || '';
meta.charset = (document.querySelector('meta[charset]') || {}).getAttribute('charset') || document.characterSet;
meta.language = document.documentElement.lang || '';
meta.og = {};
document.querySelectorAll('meta[property^="og:"]').forEach(function(m) {
meta.og[m.getAttribute('property').replace('og:','')] = m.content;
});
meta.twitter = {};
document.querySelectorAll('meta[name^="twitter:"]').forEach(function(m) {
meta.twitter[m.getAttribute('name').replace('twitter:','')] = m.content;
});
meta.jsonLd = [...document.querySelectorAll('script[type="application/ld+json"]')].map(function(s) {
try { return JSON.parse(s.textContent); } catch(e) { return null; }
}).filter(Boolean);
meta.alternateLanguages = [...document.querySelectorAll('link[rel="alternate"][hreflang]')].map(function(l) {
return { lang: l.hreflang, href: l.href };
});
meta.feeds = [...document.querySelectorAll('link[type="application/rss+xml"], link[type="application/atom+xml"]')].map(function(l) {
return { title: l.title, href: l.href, type: l.type };
});
return JSON.stringify(meta);
})()`
);
}
export async function extractImages({ limit = 50 }) {
return runJS(
`JSON.stringify([...document.querySelectorAll('img')].slice(0,${Number(limit)}).map(function(img){
var r = img.getBoundingClientRect();
return {
src: img.src, alt: img.alt || '(missing)', width: img.naturalWidth, height: img.naturalHeight,
displayWidth: Math.round(r.width), displayHeight: Math.round(r.height),
loading: img.loading || 'eager', srcset: img.srcset || '',
inViewport: r.top < window.innerHeight && r.bottom > 0,
decoded: img.complete,
};
}))`
);
}
export async function extractLinks({ limit = 100, filter }) {
const filterStr = filter
? `.filter(function(a){return a.href.includes('${escJsSingleQuote(filter)}')||a.textContent.includes('${escJsSingleQuote(filter)}')})`
: "";
return runJS(
`JSON.stringify([...document.querySelectorAll('a[href]')]${filterStr}.slice(0,${Number(limit)}).map(function(a){
return {
href: a.href, text: a.textContent.trim().substring(0,100),
rel: a.rel || '', target: a.target || '',
isExternal: a.hostname !== location.hostname,
isNofollow: a.rel.includes('nofollow'),
};
}))`
);
}
// ========== GEOLOCATION OVERRIDE ==========
export async function overrideGeolocation({ latitude, longitude, accuracy = 100 }) {
return runJS(
`navigator.geolocation.getCurrentPosition = function(success) {
success({ coords: { latitude: ${Number(latitude)}, longitude: ${Number(longitude)}, accuracy: ${Number(accuracy)}, altitude: null, altitudeAccuracy: null, heading: null, speed: null }, timestamp: Date.now() });
};
navigator.geolocation.watchPosition = function(success) {
success({ coords: { latitude: ${Number(latitude)}, longitude: ${Number(longitude)}, accuracy: ${Number(accuracy)}, altitude: null, altitudeAccuracy: null, heading: null, speed: null }, timestamp: Date.now() });
return 1;
};
'Geolocation set to: ${Number(latitude)}, ${Number(longitude)}'`
);
}
// ========== COMPUTED STYLES ==========
export async function getComputedStyles({ selector, properties }) {
const sel = escJsSingleQuote(selector);
const propsFilter = properties
? `.filter(function(p){return [${properties.map((p) => `'${String(p).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`).join(",")}].includes(p)})`
: "";
return runJS(
`(function(){
var el = document.querySelector('${sel}');
if (!el) return JSON.stringify({ error: 'Element not found' });
var styles = window.getComputedStyle(el);
var result = {};
var props = [...styles]${propsFilter};
props.forEach(function(p) { result[p] = styles.getPropertyValue(p); });
return JSON.stringify(result);
})()`
);
}
// ========== INDEXEDDB ==========
export async function getIndexedDB({ dbName, storeName, limit = 20 }) {
const safeDb = escJsSingleQuote(dbName);
const safeStore = escJsSingleQuote(storeName);
// `do JavaScript` can't await a Promise — route async work through the Node-side poller.
return _evaluateAsync(
`(async function(){
return new Promise(function(resolve, reject) {
var request = indexedDB.open('${safeDb}');
request.onerror = function() { resolve(JSON.stringify({ error: 'Cannot open database: ${safeDb}' })); };
request.onsuccess = function(e) {
var db = e.target.result;
if (!db.objectStoreNames.contains('${safeStore}')) {
resolve(JSON.stringify({ error: 'Store not found: ${safeStore}', stores: [...db.objectStoreNames] }));
db.close(); return;
}
var tx = db.transaction('${safeStore}', 'readonly');
var store = tx.objectStore('${safeStore}');
var results = [];
var cursor = store.openCursor();
cursor.onsuccess = function(e) {
var c = e.target.result;
if (c && results.length < ${Number(limit)}) { results.push({ key: c.key, value: c.value }); c.continue(); }
else { resolve(JSON.stringify({ database: '${safeDb}', store: '${safeStore}', count: results.length, records: results })); db.close(); }
};
cursor.onerror = function() { resolve(JSON.stringify({ error: 'Cursor error' })); db.close(); };
};
});
})()`
);
}
export async function listIndexedDBs() {
return _evaluateAsync(
`(async function(){
try {
var dbs = await indexedDB.databases();
return JSON.stringify(dbs.map(function(db){ return { name: db.name, version: db.version }; }));
} catch(e) {
return JSON.stringify({ error: 'indexedDB.databases() not supported, try getIndexedDB with a known db name' });
}
})()`
);
}
// ========== CSS COVERAGE ==========
export async function getCSSCoverage() {
return evalReturningJSON(
`
${ALL_SHEETS_FN}
var results = [];
var sheets = allSheets();
for (var i = 0; i < sheets.length; i++) {
var sheet = sheets[i];
try {
var rules = sheet.cssRules || sheet.rules;
var total = rules.length;
var used = 0;
var unused = [];
for (var j = 0; j < rules.length; j++) {
var rule = rules[j];
if (rule.selectorText) {
try {
if (document.querySelector(rule.selectorText)) { used++; }
else { unused.push(rule.selectorText); }
} catch(e) { used++; }
} else { used++; }
}
results.push({
href: sheet.href || (sheet.ownerNode ? '(inline)' : '(adopted)'),
totalRules: total,
usedRules: used,
unusedRules: total - used,
coveragePercent: total > 0 ? Math.round(used / total * 100) : 100,
unusedSelectors: unused.slice(0, 20),
});
} catch(e) {
results.push({ href: sheet.href || (sheet.ownerNode ? '(inline)' : '(adopted)'), error: 'CORS blocked' });
}
}
return JSON.stringify(results);
`
);
}
// ========== WEBKIT / iOS WEB-DEV VALIDATION ==========
// Validate <meta name="viewport"> against iOS Safari best practices.
// Pure read-only DOM inspection — returns parsed attrs + severity-tagged issues.
export async function inspectViewport() {
return runJS(VIEWPORT_SCRIPT);
}
// Read live CSS safe-area-inset values via a hidden probe element, check
// viewport-fit=cover, and scan stylesheets for env(safe-area-inset-*) usage.
export async function getSafeAreaInsets() {
return runJS(SAFE_AREA_SCRIPT);
}
// Audit the page for iOS "Add to Home Screen" / PWA readiness.
export async function checkPWA() {
return runJS(PWA_SCRIPT);
}
// Check every CSS property used on the page against THIS Safari via
// CSS.supports() — no regex guessing, tested in the live engine.
export async function checkWebKitCompat() {
return runJS(WEBKIT_COMPAT_SCRIPT);
}
// ========== DOCTOR (PREFLIGHT DIAGNOSTICS) ==========
// One-shot check of the whole macOS permission + daemon chain, so the
// "it doesn't work even with permissions granted" failures (#14/#15/#29)
// surface as one actionable checklist instead of scattered cryptic errors.
// ========== macOS NATIVE-INPUT COMPAT ==========
// CGEvent.postToPid (native clicks/keys/hover) can silently no-op on macOS 26+ (Tahoe) even
// with Accessibility granted — the events are accepted by the API but never cross into Safari's
// WebContent process (issue #29). performNativeClick now works around this by pressing through
// the Accessibility API first, so CLICKS are covered; native keyboard and hover still travel the
// CGEvent path and stay affected. doctor() prints the OS version so a bug report carries the
// single most relevant fact, and points at what is still risky rather than at a phantom
// permission grant.
// Pure (no I/O) so it's unit-tested directly — see test/macos-compat.test.mjs.
export function macosCompatNote(productVersion) {
const raw = String(productVersion ?? "").trim();
const major = parseInt(raw.split(".")[0], 10);
if (!Number.isFinite(major)) {
return {
version: "unknown",
major: null,
risky: false,
line: "macOS version: unknown (sw_vers gave no parseable version)",
};
}
const risky = major >= 26;
const line = risky
? `macOS ${raw} ⚠ raw CGEvent input is filtered on macOS 26+ (issue #29) — clicks are covered: safari_native_click presses via Accessibility first. Native keyboard/hover still ride CGEvent, so prefer safari_evaluate or safari_click for those on trust-gated forms.`
: `macOS ${raw} — CGEvent native input supported.`;
return { version: raw, major, risky, line };
}
// A Homebrew upgrade deletes the node binary a long-running server still executes, and System
// Settings cannot grant anything to a file that is gone. Seen on 9.10.26: node 26.9.0 was replaced
// by 26.11.0 under a LaunchAgent daemon started two days earlier, and doctor went from 6/6 to 3/6
// (Apple Events -1743, both helper permissions false). Only a restart onto the current node helps.
export function staleNodeNote(execPath = process.execPath, exists = existsSync) {
if (exists(execPath)) return null;
return `⚠ This server still runs node from ${execPath}, which no longer exists: node was upgraded or removed while the server kept running. ` +
"macOS permission checks can fail for a binary that is gone, and System Settings cannot grant anything to it. " +
"Restart the server on the current node, then grant the permissions to the new binary if macOS asks.";
}
export async function doctor() {
const checks = [];
const add = (ok, label, detail, fix) => checks.push({ ok, label, detail, fix: ok ? null : fix });
// 1. Safari running
let safariUp = false;
try {
const { stdout } = await execFileAsync("pgrep", ["-x", "Safari"], { timeout: 2000 });
safariUp = stdout.trim().length > 0;
} catch { safariUp = false; }
add(safariUp, "Safari running", safariUp ? "Safari process is up" : "Safari is not running", "Open Safari, then retry.");
// 2. Apple Events / Automation — the bridge every AppleScript tool uses
let aeOk = false, aeDetail = "";
try {
const out = await osascript(`tell application "Safari" to return (count of windows) as string`, { timeout: 5000 });
aeOk = /^\d+$/.test(String(out).trim());
aeDetail = aeOk ? `OK (${String(out).trim()} window(s) visible)` : `unexpected reply: ${String(out).slice(0, 60)}`;
} catch (e) {
const m = e.message || "";
if (m.includes("-1743") || /not authoriz/i.test(m)) aeDetail = "Automation permission denied (-1743)";
else if (m.includes("-600") || /isn.t running/i.test(m)) aeDetail = "Safari not running";
else aeDetail = m.slice(0, 80);
}
add(aeOk, "Apple Events / Automation", aeDetail,
"System Settings > Privacy & Security > Automation → enable Safari for your terminal/host app; and Safari > Settings > Developer > Allow JavaScript from Apple Events.");
// 3-5. Native helper daemon + Accessibility + Screen Recording (one preflight round-trip)
let pf = null, pfErr = "";
try { pf = await _helperPreflight(); } catch (e) { pfErr = e.message || String(e); }
// A single 3s probe is a load test, not a health test: under heavy system pressure (swap
// thrash, a dozen concurrent hosts) the helper answers late and doctor turns one slow reply
// into THREE hard failures telling the user to reinstall and re-grant permissions that were
// never revoked. Retry once with a wider window before believing the daemon is down.
if (!pf) {
try { pf = await _helperPreflight(8000); pfErr = ""; } catch (e) { pfErr = e.message || String(e); }
}
add(!!pf, "Native helper daemon", pf ? "safari-helper responding" : `not responding: ${pfErr}`,
"It auto-restarts; if this persists, reinstall safari-mcp.");
add(!!pf && pf.accessibility === true, "Accessibility (native clicks)",
pf ? (pf.accessibility ? "CGEvent posting permitted" : "NOT permitted — native clicks silently no-op (the #29 root cause)") : "unknown (helper not responding)",
"System Settings > Privacy & Security > Accessibility (named \"Device Control and Data Access\" on macOS 27) → enable safari-helper, then retry.");
// macOS attributes the capture to whoever launched this process: the terminal/IDE for a
// stdio server, but node itself under launchd (a LaunchAgent daemon has ppid 1) — and a
// Homebrew node upgrade changes that path, so the grant has to follow the new binary.
add(!!pf && pf.screenRecording === true, "Screen Recording (screenshots)",
pf ? (pf.screenRecording ? "permitted" : "NOT permitted — screenshots taken through AppleScript and safari_save_pdf fail (extension screenshots don't need it)") : "unknown (helper not responding)",
`System Settings > Privacy & Security > Screen & System Audio Recording → enable ${process.ppid !== 1 ? "the terminal/IDE that launched this server" : staleNodeNote() ? "the node this server runs on after a restart (see the warning above)" : `node at ${process.execPath} (this server runs under launchd)`}.`);
// 6. Helper codesign identity — a stale/ad-hoc id breaks the Accessibility grant on reinstall
let idOk = false, idDetail = "";
const helperPath = join(__dirname, "safari-helper");
try {
const res = await execFileAsync("codesign", ["-d", "--verbose=2", helperPath], { timeout: 4000 })
.catch((e) => ({ stdout: "", stderr: e.stderr || "" }));
const text = (res.stdout || "") + (res.stderr || "");
const m = /Identifier=(.+)/.exec(text);
const id = m ? m[1].trim() : "(unknown)";
idOk = id === "com.achiya-automation.safari-mcp";
idDetail = idOk ? `stable identifier: ${id}` : `unstable identifier "${id}" — Accessibility grant won't persist across reinstalls`;
} catch (e) { idDetail = "could not read codesign identity: " + (e.message || "").slice(0, 60); }
add(idOk, "Helper codesign identity", idDetail,
`Re-sign: codesign -s - -f --identifier com.achiya-automation.safari-mcp --entitlements safari-helper.entitlements "${helperPath}"`);
// macOS version — the single most relevant fact for #29-class "native clicks silently fail"
// reports. Best-effort: sw_vers is macOS-only and absent in sandboxes/CI, so never block doctor.
let osLine = null;
try {
const { stdout } = await execFileAsync("sw_vers", ["-productVersion"], { timeout: 2000 });
osLine = macosCompatNote(stdout).line;
} catch { /* sw_vers unavailable — skip the line, the permission checks still stand */ }
const passed = checks.filter((c) => c.ok).length;
const lines = [`Safari MCP doctor — ${passed}/${checks.length} checks passed`, ""];
if (osLine) lines.push(osLine, "");
const nodeNote = staleNodeNote();
if (nodeNote) lines.push(nodeNote, "");
// Not a pass/fail check — a state the user has to be able to see here rather than dig out
// of the host's env, so "why did it touch my tab" has an answer in the same report (#92).
lines.push(
allowUserTabs()
? "ℹ️ Tab adoption (SAFARI_MCP_ALLOW_USER_TABS): ON — safari_switch_tab may adopt a tab you already had open. safari_close_tab still refuses an adopted tab."
: "ℹ️ Tab adoption (SAFARI_MCP_ALLOW_USER_TABS): off (default) — the session acts only on tabs it opened itself.",
"",
);
for (const c of checks) {
lines.push(`${c.ok ? "✅" : "❌"} ${c.label}: ${c.detail}`);
if (!c.ok && c.fix) lines.push(` → ${c.fix}`);
}
return lines.join("\n");
}
// ========== FORM AUTO-DETECT ==========
export async function detectForms() {
return runJS(
`(function(){
var forms = [...document.querySelectorAll('form')];
if (forms.length === 0) {
var inputs = document.querySelectorAll('input, textarea, select');
if (inputs.length > 0) {
return JSON.stringify([{
index: 0, action: '(no form tag)', method: '', fields: [...inputs].slice(0, 30).map(function(el) {
return { tag: el.tagName, type: el.type || '', name: el.name || '', id: el.id || '',
placeholder: el.placeholder || '', required: el.required, value: (el.value || '').substring(0, 50),
selector: el.id ? '#' + el.id : (el.name ? '[name="' + el.name + '"]' : el.tagName.toLowerCase() + '[type="' + el.type + '"]') };
})
}]);
}
return JSON.stringify([]);
}
return JSON.stringify(forms.map(function(form, i) {
var fields = [...form.querySelectorAll('input, textarea, select')].map(function(el) {
return { tag: el.tagName, type: el.type || '', name: el.name || '', id: el.id || '',
placeholder: el.placeholder || '', required: el.required, value: (el.value || '').substring(0, 50),
selector: el.id ? '#' + el.id : (el.name ? '[name="' + el.name + '"]' : el.tagName.toLowerCase()) };
});
return { index: i, action: form.action || '', method: form.method || 'GET', id: form.id || '',
fieldCount: fields.length, fields: fields.slice(0, 30),
hasSubmit: !!form.querySelector('[type="submit"], button:not([type])') };
}));
})()`
);
}
// ========== SCROLL TO ELEMENT ==========
export async function scrollToElement({ selector, text, block = "center", timeout = 10000 }) {
if (selector) {
const sel = escJsSingleQuote(selector);
return runJS(
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found: ${sel}';el.scrollIntoView({behavior:'smooth',block:'${block}'});var r=el.getBoundingClientRect();return 'Scrolled to: '+el.tagName+' at y='+Math.round(r.y);})()`
);
}
if (text) {
// Virtual DOM scroll: scroll down repeatedly until text appears (for Airtable, etc.).
// Each check+scroll is one synchronous step; the loop is driven from Node because
// `do JavaScript` can't await an in-page delay (see _evaluateAsync).
const safeText = escJsSingleQuote(text);
const safeBlock = String(block).replace(/[^a-z]/gi, '') || 'center';
const tab = await _sessionTab('scrollToElement');
const stepJs =
`(function(){` +
`var scrollable=document.querySelector('[class*="grid"],[class*="virtual"],[class*="scroll"],[role="grid"],[role="table"]')||document.scrollingElement||document.documentElement;` +
`var tw=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT,null);` +
`while(tw.nextNode()){if(tw.currentNode.textContent.trim().includes('${safeText}')){var el=tw.currentNode.parentElement;el.scrollIntoView({behavior:'smooth',block:'${safeBlock}'});return 'Found and scrolled to: "'+el.textContent.trim().substring(0,50)+'"';}}` +
`var curY=scrollable.scrollTop;scrollable.scrollBy(0,500);return 'SCROLL:'+curY;})()`;
const deadline = Date.now() + Number(timeout);
let lastY = -1;
while (Date.now() < deadline) {
// Each step scrolls in the page only once that page proves it is the session's (_inTab).
const r = await _inTab(tab, stepJs, { markerOnly: true, timeout: 5000 }).catch((err) => {
if (err.tabUnproven) throw err;
return '';
});
if (typeof r === 'string' && r.startsWith('Found')) return r;
if (typeof r === 'string' && r.startsWith('SCROLL:')) {
const curY = parseInt(r.slice(7), 10);
if (curY === lastY) return `Text not found: ${text} (scrolled to bottom)`;
lastY = curY;
}
await new Promise(res => setTimeout(res, 300));
}
return `Timeout: text not found within ${timeout}ms`;
}
throw new Error("scrollToElement requires selector or text");
}
// ========== COMBO TOOLS (multi-step operations in a single call) ==========
// Navigate + wait + read — the most common 3-step workflow
export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
await raiseWindowForShow();
await refreshTargetWindow();
// Prove the tab once, and have every script prove it again before it touches it (see navigate()).
const tab = await _sessionTab('navigateAndRead');
// Suppress onbeforeunload dialogs (same as navigate())
await _inTab(tab, "window.onbeforeunload=null", { markerOnly: true, timeout: 2000 }).catch(() => {});
let targetUrl = url;
if (!/^https?:\/\//i.test(targetUrl)) targetUrl = "https://" + targetUrl;
// Escape backslash first, then quotes; strip CR/LF — a newline would break out of
// the AppleScript string literal and allow AppleScript injection.
const safeUrl = targetUrl.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]/g, '');
tab.since = Date.now();
await _inTab(tab, "''", { markerOnly: true, then: (target) => `set URL of ${target} to "${safeUrl}"`, timeout: 10000 });
_st().activeTabURL = targetUrl;
// Poll readyState from Node, then read — `do JavaScript` can't await an async IIFE.
const navResult = await _pollReadyAndRead(tab, { maxLength });
// Update _st().activeTabURL with the actual URL after navigation
try {
const parsed = JSON.parse(navResult);
if (parsed.url && parsed.url !== 'about:blank') _st().activeTabURL = parsed.url;
} catch {}
return navResult;
}
// Click + wait for navigation or element — common after clicking a link/button
export async function clickAndWait({ selector, text, waitFor: waitSelector, timeout = 10000 }) {
const esc = (s) => escJsSingleQuote(s);
const safeSel = selector ? esc(selector) : "";
const safeText = text ? esc(text) : "";
const safeWait = waitSelector ? esc(waitSelector) : "";
const tab = await _sessionTab('clickAndWait');
// Step 1: find + click — fully synchronous, so it runs inside one `do JavaScript`, which runs it
// only in a page that still carries the session's marker (_inTab).
tab.since = Date.now();
const clickResult = await _inTab(
tab,
`(function(){
var el;
${safeSel ? `el = document.querySelector('${safeSel}');` : ""}
${safeText && !safeSel ? `
el = [...document.querySelectorAll('a,button,[role=button],label,[onclick]')].find(function(e){return e.textContent.trim().includes('${safeText}');});
if(!el) el = [...document.querySelectorAll('*')].filter(function(e){var r=e.getBoundingClientRect();return r.width>0&&r.height>0&&e.textContent.trim().includes('${safeText}');}).sort(function(a,b){return a.textContent.length-b.textContent.length;})[0];
` : ""}
if(!el) return JSON.stringify({error:'Element not found'});
el.scrollIntoView({block:'center'});
el.click();
return JSON.stringify({clicked:el.tagName+' "'+el.textContent.trim().substring(0,50)+'"'});
})()`,
{ markerOnly: true, timeout: 10000 }
);
let clickedInfo = '';
try { const c = JSON.parse(clickResult); if (c.error) return clickResult; clickedInfo = c.clicked || ''; } catch {}
// Step 2: wait from Node — `do JavaScript` can't await an in-page loop.
const deadline = Date.now() + Number(timeout);
await new Promise(r => setTimeout(r, 300));
while (Date.now() < deadline) {
try {
if (safeWait) {
if (await _inTab(tab, `document.querySelector('${safeWait}')?'1':''`, { stamp: true, timeout: 5000 }) === '1') break;
} else if (await _inTab(tab, 'document.readyState', { stamp: true, timeout: 5000 }) === 'complete') {
break;
}
} catch (err) {
if (err.tabUnproven) throw err;
/* page navigating */
}
await new Promise(r => setTimeout(r, 200));
}
const final = await _inTab(tab, `JSON.stringify({title:document.title,url:location.href})`, { stamp: true, timeout: 5000 });
try { const p = JSON.parse(final); p.clicked = clickedInfo; return JSON.stringify(p); } catch { return final; }
}
// Fill form + submit — common for login, search, etc.
export async function fillAndSubmit({ fields, submitSelector }) {
// A load the form's own change or blur events start is the step's own load too.
const since = Date.now();
await fillForm({ fields });
const tab = await _sessionTab('fillAndSubmit');
// The submit click runs in the tab the polls below watch, in a page that still carries the
// session's marker (_inTab). A click that proved its tab by a scan of its own could submit in the
// tab at one index while the polls read the tab at another.
tab.since = since;
if (submitSelector) {
const sel = escJsSingleQuote(submitSelector);
await _inTab(tab,
`(function(){var el=document.querySelector('${sel}');if(el)el.click();})()`,
{ markerOnly: true, timeout: 15000 }
);
} else {
// Auto-find and click submit button
await _inTab(tab,
`(function(){var btn=document.querySelector('[type=submit],button:not([type])');if(btn)btn.click();})()`,
{ markerOnly: true, timeout: 15000 }
);
}
// Wait for navigation/reload — polled from Node (`do JavaScript` can't await).
await new Promise(r => setTimeout(r, 300));
return _pollReadyAndRead(tab);
}
// Full page analysis — extracts everything in ONE call
export async function analyzePage() {
return runJS(
`(function(){
var result = {};
result.title = document.title;
result.url = location.href;
result.meta = {};
result.meta.description = (document.querySelector('meta[name="description"]')||{}).content||'';
result.meta.canonical = (document.querySelector('link[rel="canonical"]')||{}).href||'';
result.meta.robots = (document.querySelector('meta[name="robots"]')||{}).content||'';
result.meta.og = {};
document.querySelectorAll('meta[property^="og:"]').forEach(function(m){result.meta.og[m.getAttribute('property').replace('og:','')]=m.content;});
result.headings = {};
for(var i=1;i<=3;i++){result.headings['h'+i]=[...document.querySelectorAll('h'+i)].map(function(h){return h.textContent.trim().substring(0,100);});}
result.links = {internal:0,external:0,nofollow:0};
document.querySelectorAll('a[href]').forEach(function(a){
if(a.hostname===location.hostname)result.links.internal++;
else result.links.external++;
if(a.rel&&a.rel.includes('nofollow'))result.links.nofollow++;
});
result.images = {total:document.querySelectorAll('img').length,withoutAlt:[...document.querySelectorAll('img:not([alt]),img[alt=""]')].length};
result.forms = document.querySelectorAll('form').length;
result.text = document.body.innerText.substring(0,5000);
return JSON.stringify(result);
})()`
);
}
// ========== GRACEFUL SHUTDOWN ==========
// NOTE: Signal handlers are registered at the top of the file (cleanupHelper + process.exit).
// _drainHelperQueue is called from cleanupHelper via process.on("exit").
process.on("exit", () => { _drainHelperQueue("shutting down"); });