UNPKG

safari-mcp

Version:

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

212 lines (198 loc) • 8.28 kB
// worker-ledger.js — per-worker bookkeeping for the extension bridge. // // "3 extension workers connected but none proved profile" (#109) named nothing: not which // workers, not what each one announced, not what the profile probe answered them. The // only way to diagnose that outage was sqlite on Safari's extension storage. This ledger // keeps the last MAX_WORKERS workers with what each did, so the warning and safari_doctor // can say it instead. Pure data — no timers, no I/O — so it is unit-testable. export const MAX_WORKERS = 16; export function createLedger() { return new Map(); } function _entry(ledger, workerId, now) { let entry = ledger.get(workerId); if (!entry) { entry = { firstSeen: now, lastSeen: now, connects: 0, rejected: null, verdicts: [], verifiedAt: 0, }; ledger.set(workerId, entry); // ponytail: evict the oldest by lastSeen; a Safari with four profiles never exceeds // four live workers, so this only bounds a reload storm. while (ledger.size > MAX_WORKERS) { let oldestId = null; for (const [id, e] of ledger) if (oldestId === null || e.lastSeen < ledger.get(oldestId).lastSeen) oldestId = id; ledger.delete(oldestId); } } entry.lastSeen = now; return entry; } // A worker POSTed /connect. `rejected` is the {verifier, protocol} it announced when the // host answered 426, null when the announcement was accepted. export function noteConnect(ledger, workerId, { rejected = null, now = Date.now() } = {}) { const entry = _entry(ledger, workerId, now); entry.connects += 1; entry.rejected = rejected ? { ...rejected, at: now } : null; return entry; } // The host answered a /verify-profile probe: "match", "wrong:<window name>", "notfound" // or "error:<message>". Kept as the last three distinct verdicts. export function noteVerdict(ledger, workerId, verdict, now = Date.now()) { const entry = _entry(ledger, workerId, now); const text = String(verdict || "").slice(0, 160); const isNew = entry.verdicts.length === 0 || entry.verdicts[entry.verdicts.length - 1].text !== text; if (isNew) { entry.verdicts.push({ text, at: now }); if (entry.verdicts.length > 3) entry.verdicts.shift(); } else { entry.verdicts[entry.verdicts.length - 1].at = now; } return isNew; } export function noteVerified(ledger, workerId, now = Date.now()) { _entry(ledger, workerId, now).verifiedAt = now; } function _ago(then, now) { const s = Math.max(0, Math.round((now - then) / 1000)); return s < 90 ? `${s}s ago` : `${Math.round(s / 60)} min ago`; } // One line per worker, newest first. `activeWorkerId` marks the worker currently holding // the profile lease. export function summarize(ledger, { now = Date.now(), activeWorkerId = "" } = {}) { const lines = []; const entries = [...ledger.entries()].sort((a, b) => b[1].lastSeen - a[1].lastSeen); for (const [id, e] of entries) { const parts = [`connected ${e.connects}× (last ${_ago(e.lastSeen, now)})`]; if (e.rejected) { parts.push( `REJECTED 426 — announced verifier="${e.rejected.verifier || ""}" protocol="${e.rejected.protocol || ""}": a worker from an older build that Safari is still running` ); } else if (e.verdicts.length) { parts.push( `profile probe answered ${e.verdicts.map((v) => `${JSON.stringify(v.text)} (${_ago(v.at, now)})`).join(", then ")}` ); } else if (e.verifiedAt) { parts.push("verified from its stored identity without a probe"); } else { parts.push( "never asked for a profile probe (its stored identity already ruled this host out, or it went silent first)" ); } if (e.verifiedAt) parts.push(`verified ${_ago(e.verifiedAt, now)}`); if (id === activeWorkerId) parts.push("← holds the lease"); lines.push(`${id.slice(0, 8)}: ${parts.join("; ")}`); } return lines; } // Mirrors extension/background.js `_canonicalProfileName`: a stored identity of // "<profile> — <tab title>" reduces to the bare profile name. export function canonicalProfileName(value) { let name = String(value || "") .normalize("NFC") .trim(); if (!name || name === "notfound" || name === "__personal__") return ""; name = name.replace(/^wrong:/, "").trim(); const titleSeparator = name.indexOf(" — "); if (titleSeparator > 0) name = name.slice(0, titleSeparator); return name.normalize("NFC").trim(); } // storage.local persists JSON-encoded values; an unparseable cell is shown as-is. function _storedString(cell) { try { return String(JSON.parse(cell || '""')); } catch { return String(cell || ""); } } export const SAFARI_EXTENSION_STORAGE_GLOB = "Library/Containers/com.apple.Safari/Data/Library/WebKit/WebExtensions"; // Reads what every Safari profile's copy of the extension has persisted: its identity, // its last heartbeat and its badge status. Read-only (`sqlite3 -readonly`, and the -wal is // picked up). Throws only when Safari's container itself cannot be listed (typically // macOS denying this process access to it); a missing sqlite3 or a locked file just // skips that profile. `deps` is injectable for tests. export async function readProfileIdentities({ home = "", now = Date.now(), readdir = null, // async (dir) => string[] runSqlite = null, // async (dbPath) => "key|value\n…" extensionPrefix = "com.achiya-automation.safari-mcp.Extension", } = {}) { if (!home || !readdir || !runSqlite) return []; const root = `${home}/${SAFARI_EXTENSION_STORAGE_GLOB}`; const containers = await readdir(root); const out = []; for (const container of containers) { let names; try { names = await readdir(`${root}/${container}`); } catch { continue; } const dir = names.find((n) => n.startsWith(extensionPrefix)); if (!dir) continue; const db = `${root}/${container}/${dir}/LocalStorage.db`; let rows; try { rows = await runSqlite(db); } catch { continue; } const kv = {}; for (const line of String(rows || "").split("\n")) { const sep = line.indexOf("|"); if (sep > 0) kv[line.slice(0, sep)] = line.slice(sep + 1); } const identity = _storedString(kv.mcpVerifiedProfile); const status = _storedString(kv.mcpStatus); const heartbeat = Number.parseInt(kv._heartbeat || "", 10); out.push({ container, identity, canonical: canonicalProfileName(identity), status, heartbeatAgeMs: Number.isFinite(heartbeat) ? Math.max(0, now - heartbeat) : null, }); } return out; } export function describeProfileIdentities(list, { profile = "", error = null } = {}) { if (error) { const code = String(error.code || ""); return [ code === "EPERM" || code === "EACCES" ? `extension storage: macOS denied this process access to Safari's container (${code}) — grant Full Disk Access to ${process.ppid === 1 ? "node (this server runs under launchd)" : "the terminal/IDE that launched this server"} to see each profile's stored identity here` : `extension storage: not readable (${code || String(error.message || error).slice(0, 60)})`, ]; } if (!list.length) return [ "extension storage: no Safari profile has this extension's storage yet (or sqlite3 is missing)", ]; const lines = []; for (const p of list) { const alive = p.heartbeatAgeMs === null ? "no heartbeat" : p.heartbeatAgeMs < 90000 ? `heartbeat ${Math.round(p.heartbeatAgeMs / 1000)}s ago` : `heartbeat ${Math.round(p.heartbeatAgeMs / 60000)} min ago — worker parked or dead`; const stored = p.identity ? JSON.stringify(p.identity) : "(none — never proved a profile)"; const suffix = p.identity && p.identity !== p.canonical ? ` = ${JSON.stringify(p.canonical)} after canonicalization; an extension older than 2.10.24 cannot reduce this itself (#109) and rejects the profile` : ""; const mine = profile && p.canonical === profile ? " ← this host's profile" : ""; lines.push( `${p.container.slice(0, 8)}: stored identity ${stored}${suffix}; ${alive}; badge ${p.status || "unknown"}${mine}` ); } return lines; }