entry.ts
1import { setupMessageHandler, setRestoreReady } from "./messages";2import { setupAutoLock } from "./alarms";3import { setupSyncAlarm } from "./sync-alarm";4import { setupPasskeyProxy } from "./passkey";5import { setupCommands } from "./commands";6import { setupClipboardClearAlarm } from "./clipboard";7import { applyStorageHardening, restoreFromSession, isUnlocked, lock } from "./vault";8import { ensureSettingsLoaded } from "./vault";9import { isRecoveryConfirmPending } from "./recovery-pending";10import { denyAllPendingPasskeyRequests } from "./passkey";11import {12 hydratePersistedSyncState,13 setSyncDialTargetSelector,14 setSyncRunner,15 triggerSyncIfReachable,16} from "./sync-trigger";17import { productionSyncRunner } from "./sync-runner";18import {19 drainAndRefreshLockedSyncSpool,20 refreshLockedSyncSpool,21} from "./sync-vault-adapter";22import { setLockedSyncHooks } from "./vault";23import { repairPairedRevocationOverlaps } from "./paired-devices";24import {25 requestMeshRelayReconcile,26 selectProductionDialTargets,27 setupMeshRelayListener,28} from "./mesh-relay-listener";29import { hardenExtensionStorage } from "./storage-access";3031// Keep alive for MV3 service worker32chrome.runtime.onInstalled.addListener(() => {});3334// The chrome.idle.onStateChanged listener lives in ./alarms (the architectural35// home for auto-lock timing). Keeping it in only one place avoids drift.3637// Lock-on-close via port disconnect. The popup opens a nonce'd port38// ("popup-<uuid>"); when that port disconnects (popup closed), lock if39// lockOnClose is enabled. Previously also declared in the never-bundled40// index.ts (deleted in LV-5) — this is now the single registration.41//42// LV-5: the lock port name carries a per-popup-open random suffix. Another43// extension cannot open ports INTO this extension (onConnectExternal is not44// registered), so the nonce is defense-in-depth — a port named exactly45// "popup" from anything that isn't our popup no longer reliably triggers46// deny-all + lock once the legacy window closes.47const POPUP_PORT_PREFIX = "popup-";4849chrome.runtime.onConnect.addListener((port) => {50 if (!port.name.startsWith(POPUP_PORT_PREFIX)) return;51 const senderUrl = port.sender?.url;52 const extensionOrigin = new URL(chrome.runtime.getURL("")).origin;53 if (port.sender?.tab || !senderUrl || new URL(senderUrl).origin !== extensionOrigin) return;54 port.onDisconnect.addListener(async () => {55 await denyAllPendingPasskeyRequests();56 const s = await ensureSettingsLoaded();57 if (!isUnlocked() || s.lockOnClose !== true) return;58 if (await isRecoveryConfirmPending()) return;59 await lock();60 });61});6263export function start(): void {64 // PB-26-203: establish the storage trust boundary first, then decide65 // whether the session escrow may operate, then restore. The composed66 // promise is handed to the message layer as a gate; the handler itself is67 // still registered synchronously below (MV3 requirement).68 const hardening = hardenExtensionStorage();69 setLockedSyncHooks({70 refresh: refreshLockedSyncSpool,71 afterUnlock: drainAndRefreshLockedSyncSpool,72 });73 // MV3: the chrome.runtime.onMessage listener MUST be registered during the74 // worker's initial SYNCHRONOUS evaluation. If registration is deferred behind75 // an await, a popup/content message that wakes a cold worker is dispatched76 // before the listener exists → "receiving end does not exist", and the popup77 // misreads the failed HAS_VAULT as "no vault" and shows onboarding.78 //79 // So: kick off session-restore, hand its promise to the message layer as a80 // gate, and register the handler synchronously. Unlock-dependent handlers81 // await the gate (see handleMessage), which preserves the SEV-3-B fix — no82 // spurious lock screen — without delaying listener registration.83 const restored = hardening84 .then((h) => applyStorageHardening(h.sessionEscrowAvailable))85 .then(() => restoreFromSession());86 setRestoreReady(restored);87 setupMessageHandler();88 void setupAutoLock();89 setupCommands();90 // HV-2: clipboard auto-clear backstop must survive worker eviction; the91 // listener has to be registered during initial synchronous evaluation so92 // a cold worker woken BY the alarm still handles it.93 setupClipboardClearAlarm();94 void setupPasskeyProxy();95 // Wire the sync production runner into the dispatcher. Without this96 // `triggerSyncIfReachable` is a no-op (the runner slot starts null) — the97 // pairing/transport/coordinator stack ships dormant until this assignment.98 setSyncRunner(productionSyncRunner);99 setSyncDialTargetSelector(selectProductionDialTargets);100 setupMeshRelayListener();101 // 10-minute background sync. Must be registered during initial synchronous102 // evaluation for the same reason as the clipboard alarm: a cold worker103 // woken BY the sync alarm needs the listener already attached. Setup is104 // idempotent — calling it after a SW revival is safe.105 setupSyncAlarm();106 // First dial without waiting for the alarm tick. The runner is wired and107 // the vault is unlocked enough that listPairedDevices works — the in-flight108 // guard inside triggerSyncIfReachable handles the case where another path109 // (e.g. a fresh pairing completing on the same wake) is already running.110 void restored111 .then(() => requestMeshRelayReconcile())112 .then(() => repairPairedRevocationOverlaps())113 .then(() => hydratePersistedSyncState())114 .then(() => triggerSyncIfReachable({ reason: "startup" }))115 .catch(() => undefined);116}117118start();