import { setupMessageHandler, setRestoreReady } from "./messages"; import { setupAutoLock } from "./alarms"; import { setupSyncAlarm } from "./sync-alarm"; import { setupPasskeyProxy } from "./passkey"; import { setupCommands } from "./commands"; import { setupClipboardClearAlarm } from "./clipboard"; import { applyStorageHardening, restoreFromSession, isUnlocked, lock } from "./vault"; import { ensureSettingsLoaded } from "./vault"; import { isRecoveryConfirmPending } from "./recovery-pending"; import { denyAllPendingPasskeyRequests } from "./passkey"; import { hydratePersistedSyncState, setSyncDialTargetSelector, setSyncRunner, triggerSyncIfReachable, } from "./sync-trigger"; import { productionSyncRunner } from "./sync-runner"; import { drainAndRefreshLockedSyncSpool, refreshLockedSyncSpool, } from "./sync-vault-adapter"; import { setLockedSyncHooks } from "./vault"; import { repairPairedRevocationOverlaps } from "./paired-devices"; import { requestMeshRelayReconcile, selectProductionDialTargets, setupMeshRelayListener, } from "./mesh-relay-listener"; import { hardenExtensionStorage } from "./storage-access"; // Keep alive for MV3 service worker chrome.runtime.onInstalled.addListener(() => {}); // The chrome.idle.onStateChanged listener lives in ./alarms (the architectural // home for auto-lock timing). Keeping it in only one place avoids drift. // Lock-on-close via port disconnect. The popup opens a nonce'd port // ("popup-"); when that port disconnects (popup closed), lock if // lockOnClose is enabled. Previously also declared in the never-bundled // index.ts (deleted in LV-5) — this is now the single registration. // // LV-5: the lock port name carries a per-popup-open random suffix. Another // extension cannot open ports INTO this extension (onConnectExternal is not // registered), so the nonce is defense-in-depth — a port named exactly // "popup" from anything that isn't our popup no longer reliably triggers // deny-all + lock once the legacy window closes. const POPUP_PORT_PREFIX = "popup-"; chrome.runtime.onConnect.addListener((port) => { if (!port.name.startsWith(POPUP_PORT_PREFIX)) return; port.onDisconnect.addListener(async () => { await denyAllPendingPasskeyRequests(); const s = await ensureSettingsLoaded(); if (!isUnlocked() || s.lockOnClose !== true) return; if (await isRecoveryConfirmPending()) return; await lock(); }); }); export function start(): void { // PB-26-203: establish the storage trust boundary first, then decide // whether the session escrow may operate, then restore. The composed // promise is handed to the message layer as a gate; the handler itself is // still registered synchronously below (MV3 requirement). const hardening = hardenExtensionStorage(); setLockedSyncHooks({ refresh: refreshLockedSyncSpool, afterUnlock: drainAndRefreshLockedSyncSpool, }); // MV3: the chrome.runtime.onMessage listener MUST be registered during the // worker's initial SYNCHRONOUS evaluation. If registration is deferred behind // an await, a popup/content message that wakes a cold worker is dispatched // before the listener exists → "receiving end does not exist", and the popup // misreads the failed HAS_VAULT as "no vault" and shows onboarding. // // So: kick off session-restore, hand its promise to the message layer as a // gate, and register the handler synchronously. Unlock-dependent handlers // await the gate (see handleMessage), which preserves the SEV-3-B fix — no // spurious lock screen — without delaying listener registration. const restored = hardening .then((h) => applyStorageHardening(h.sessionEscrowAvailable)) .then(() => restoreFromSession()); setRestoreReady(restored); setupMessageHandler(); void setupAutoLock(); setupCommands(); // HV-2: clipboard auto-clear backstop must survive worker eviction; the // listener has to be registered during initial synchronous evaluation so // a cold worker woken BY the alarm still handles it. setupClipboardClearAlarm(); void setupPasskeyProxy(); // Wire the sync production runner into the dispatcher. Without this // `triggerSyncIfReachable` is a no-op (the runner slot starts null) — the // pairing/transport/coordinator stack ships dormant until this assignment. setSyncRunner(productionSyncRunner); setSyncDialTargetSelector(selectProductionDialTargets); setupMeshRelayListener(); // 10-minute background sync. Must be registered during initial synchronous // evaluation for the same reason as the clipboard alarm: a cold worker // woken BY the sync alarm needs the listener already attached. Setup is // idempotent — calling it after a SW revival is safe. setupSyncAlarm(); // First dial without waiting for the alarm tick. The runner is wired and // the vault is unlocked enough that listPairedDevices works — the in-flight // guard inside triggerSyncIfReachable handles the case where another path // (e.g. a fresh pairing completing on the same wake) is already running. void restored .then(() => requestMeshRelayReconcile()) .then(() => repairPairedRevocationOverlaps()) .then(() => hydratePersistedSyncState()) .then(() => triggerSyncIfReachable({ reason: "startup" })) .catch(() => undefined); } start();