UNPKG

safari-mcp

Version:

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

95 lines (89 loc) • 5 kB
/** * Contexts that still run an older build (#143). * * Replacing the extension app does not stop the contexts Safari had already started, and Safari * keeps the context of every app generation it ever loaded (13.10.26: its process had the * resources of a dozen builds mapped, among them deleted ones). Each wakes every minute, verifies * and becomes the daemon's lease holder, each with its own in-memory receipts, so a lease that * moves between an old context and a new one loses the receipts the other minted. * * Reloading an old context does not remove it: it restarts the same old generation, so the stream * of old contexts never ends (45 reloads in 90 minutes). What the daemon controls is who leads. * A worker that announced no build never takes the lease while a current one has verified within * `LEASE_BUILD_PREFERENCE_MS`, and a current one takes it from an old holder at once. Reloading * is the fallback for when no current context is around at all: the reload restarts the context * from the app now on disk, and the replacement announces the build. * * A worker that announces any build is never reloaded here, so a newer extension talking to an * older daemon is left alone. The rate limit bounds the damage if a reload ever fails to change * the code the worker runs. */ /** Sent by the extension on `/connect` (`&build=`). Absent on every older build. */ export const EXTENSION_BUILD = "isolated-envelopes-1"; /** How long after a current context verified the daemon keeps older ones away from the lease. */ export const LEASE_BUILD_PREFERENCE_MS = 15 * 60_000; /** * How long the context that leads (the home context) may stay silent before another current * context may lead. Its instances wake on a one-minute alarm, so this is a little over one cycle. */ export const HOME_CONTEXT_ALIVE_MS = 100_000; /** * Whether the builds and contexts alone settle who may lead. The caller still applies its other * rules (a command in flight, a reload handoff) before acting on "take". * deny an older context while a current one is around, or a current context while another * current one (the home context, by extension origin) is alive: it never leads * take a current context against a holder from an older app: replace it, even while it polls * ordinary the usual rule decides (the holder has been quiet for a while) * * Several current contexts can be alive at once as well, and each has tab ids of its own: a receipt * resolves only in the context that minted it, and a write from another context erased it from the * shared envelope (13.10.26). Their requests carry different extension origins, so the first current * context to lead stays the one that leads while it is alive. * @param {{ candidateBuild: string, holderBuild: string, hasHolder: boolean, currentSeenRecently: boolean, * candidateOrigin?: string, homeOrigin?: string, homeAlive?: boolean }} s * @returns {"deny" | "take" | "ordinary"} */ export function leaseBuildVerdict({ candidateBuild, holderBuild, hasHolder, currentSeenRecently, candidateOrigin = "", homeOrigin = "", homeAlive = false, }) { if (!candidateBuild && currentSeenRecently) return "deny"; if (candidateBuild && homeAlive && homeOrigin && candidateOrigin !== homeOrigin) return "deny"; if (candidateBuild && hasHolder && !holderBuild) return "take"; return "ordinary"; } /** * `check` decides without recording anything; `commit` records an attempt once the reload is * really about to be sent. A "no" for busy or too soon is only for now: the daemon asks again on * every poll of the lease holder, which is what keeps a context that verified at a busy moment * from staying the lease holder for good. * @param {{ now?: () => number, minGapMs?: number, windowMs?: number, maxPerWindow?: number }} [options] * @returns {{ check: (worker: { build: string, active: boolean, busy: boolean }) => { reload: boolean, reason: string }, commit: () => void }} */ export function createStaleWorkerPolicy({ now = Date.now, minGapMs = 20_000, windowMs = 10 * 60_000, maxPerWindow = 10, } = {}) { /** @type {number[]} */ const attempts = []; const prune = (t) => { while (attempts.length && t - attempts[0] > windowMs) attempts.shift(); }; return { check({ build, active, busy }) { if (build) return { reload: false, reason: "announced a build" }; if (!active) return { reload: false, reason: "not the lease holder" }; const t = now(); prune(t); if (attempts.length >= maxPerWindow) return { reload: false, reason: "reload limit reached" }; if (busy) return { reload: false, reason: "a command is in flight" }; if (attempts.length && t - attempts[attempts.length - 1] < minGapMs) return { reload: false, reason: "reloaded one a moment ago" }; return { reload: true, reason: "no build announced" }; }, commit() { const t = now(); prune(t); attempts.push(t); }, }; }