import { createEffect, createMemo, createSignal, For, lazy, onCleanup, onMount, Show, Suspense } from "solid-js"; import { storageGet, storageRemove, storageSet } from "../shared/storage"; import { sendMessage } from "../shared/bridge"; import { ACCENT_COLORS } from "../shared/types"; import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types"; import { isPasskeyOnly } from "../shared/types"; import { hasCompletedInitialSync, INITIAL_SYNC_PENDING_DEVICE_KEY, type InitialSyncDevice, } from "../shared/initial-sync"; import { LockView, RESTORE_SOURCE_STORAGE_KEY, type RestoreSource } from "./views/Lock"; import { WhatsNewView } from "./views/WhatsNew"; import { shouldShowWhatsNew } from "../shared/release-notes"; import { ONBOARDING_CARD_STORAGE_KEY, ONBOARDING_LAST_CARD, Onboarding, } from "./views/Onboarding"; const ONBOARDING_EXIT_STORAGE_KEY = "peach_onboarding_exit"; import { NotesMode } from "./views/NotesMode"; import { ProCelebration } from "./views/ProCelebration"; import { InitialSyncView } from "./views/InitialSyncView"; import { VaultView, type SortOrder } from "./views/Vault"; import { EntryView } from "./views/Entry"; import { AddView } from "./views/Add"; import { PeachWordmark } from "./components/PeachMark"; import { PasskeyApprovalView } from "./views/PasskeyApproval"; import { extractDomain } from "../shared/domain"; import { syncIndicator, syncIndicatorLabel, type SyncOutcomeSummary, } from "../shared/sync-outcome"; import { popupCopy } from "./copy"; import { DEFAULT_BINDINGS, formatKeybinding, getActiveBindings, matchKeybinding, SHORTCUT_DESCRIPTORS, type Keybinding, type ShortcutAction, } from "../shared/shortcuts"; import { generateTOTP } from "../crypto/totp"; export { sendMessage } from "../shared/bridge"; const ImportView = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView }))); const ExportView = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView }))); const SettingsView = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView }))); const HealthView = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView }))); const TrashView = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView }))); const PanicView = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView }))); const PairDeviceView = lazy(() => import("./views/PairDevice").then(m => ({ default: m.PairDeviceView }))); const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView }))); const CodexRestoreView = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView }))); const AdoptVaultView = lazy(() => import("./views/AdoptVaultView").then(m => ({ default: m.AdoptVaultView }))); type View = | "lock" | "pro-celebration" | "whats-new" | "entry" | "list" | "add" | "settings" | "pair-device" | "import" | "export" | "health" | "trash" | "panic" | "adopting" | "initial-sync" | "codex-generate" | "codex-restore"; type BootState = "loading" | "ready"; const INITIAL_SYNC_POLL_MS = 500; const INITIAL_SYNC_SLOW_MS = 15_000; let shellRingAnimation: Animation | null = null; export function triggerRing() { const shell = document.getElementById("hd-shell"); if (!shell) return; shellRingAnimation?.cancel(); shell.dataset.glowing = "true"; const animation = shell.animate( [ { offset: 0, boxShadow: "var(--copy-shadow)" }, { offset: 0.3, boxShadow: "0 2px 8px rgba(0,0,0,0.5), 0 14px 48px rgba(0,0,0,0.6), 0 0 0 1.5px color-mix(in srgb, var(--peach-accent) 58%, transparent), 0 0 22px color-mix(in srgb, var(--peach-accent) 18%, transparent)", }, { offset: 1, boxShadow: "var(--copy-shadow)" }, ], { duration: 720, easing: "cubic-bezier(0.4, 0, 0.2, 1)", fill: "none", } ); shellRingAnimation = animation; const clearGlow = () => { if (shellRingAnimation !== animation) return; shellRingAnimation = null; delete shell.dataset.glowing; }; animation.onfinish = clearGlow; animation.oncancel = clearGlow; } export function triggerPipPulse() { const pip = document.querySelector(".peach-status-pip, .pip"); if (pip) { pip.classList.remove("pulse"); void (pip as HTMLElement).offsetWidth; pip.classList.add("pulse"); } } const ENTRY_TRANSITION_SETTLE_MS = 135; export default function App() { const [bootState, setBootState] = createSignal("loading"); const [view, setView] = createSignal("lock"); const [contentPhase, setContentPhase] = createSignal<"enter" | "idle">("enter"); const [hasVault, setHasVault] = createSignal(false); const [onboarded, setOnboarded] = createSignal(true); const [startOnboardingRestore, setStartOnboardingRestore] = createSignal(false); // The restore source picked before the popup closed (the OS file picker // kills it), so reopening resumes the same source form instead of the // picker. Read from RESTORE_SOURCE_STORAGE_KEY at boot. const [resumeRestoreSource, setResumeRestoreSource] = createSignal(null); // Onboarding remounts whenever it leaves (create/adopt/restore) and comes // back. Those flows all exit from the final card, so resume there — a fresh // install's first mount stays at 0. const [onboardingCardAtExit, setOnboardingCardAtExit] = createSignal(0); const [entries, setEntries] = createSignal([]); const [selectedEntry, setSelectedEntry] = createSignal(null); const [editEntry, setEditEntry] = createSignal(undefined); // Rare device-trust alerts stay visible; routine entry sync is silent. interface SyncAlert { type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED"; entryId: string; entryTitle: string; deviceName: string; timestamp: number; } const [syncAlerts, setSyncAlerts] = createSignal([]); // Per-group flip index: which alert within a type-group is currently shown. const [alertFlipIdx, setAlertFlipIdx] = createSignal>({}); async function loadSyncAlerts() { const r = (await sendMessage({ type: "GET_SYNC_ALERTS" })) as { ok: boolean; data?: SyncAlert[]; }; if (r.ok && (r.data?.length ?? 0) > 0) { setSyncAlerts(r.data ?? []); // Acknowledge so a crash between read and render doesn't lose them — // they remain on screen until the user dismisses each. void sendMessage({ type: "GET_SYNC_ALERTS", payload: { consume: true } }); } } function dismissAlertGroup(type: string) { setSyncAlerts((arr) => arr.filter((a) => a.type !== type)); setAlertFlipIdx((m) => { const n = { ...m }; delete n[type]; return n; }); } function flipAlert(type: string, dir: 1 | -1) { const group = syncAlerts().filter((a) => a.type === type); if (group.length <= 1) return; setAlertFlipIdx((m) => { const cur = m[type] ?? 0; return { ...m, [type]: (cur + dir + group.length) % group.length }; }); } /** Grouped alert types in display order, each with its alerts. */ const alertGroups = createMemo(() => { const map = new Map(); for (const a of syncAlerts()) { const g = map.get(a.type); if (g) g.push(a); else map.set(a.type, [a]); } return [...map.entries()]; }); function syncAlertText(a: SyncAlert): string { if (a.type === "DEVICE_REVOKED") { return `${a.deviceName} revoked a paired device — review your devices in Settings`; } return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`; } const [accentColor, setAccentColor] = createSignal("peach"); const [theme, setTheme] = createSignal<"dark" | "light">("dark"); const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium"); const [reduceMotion, setReduceMotion] = createSignal(false); const [lockOnClose, setLockOnClose] = createSignal(false); const [autoLockMinutes, setAutoLockMinutes] = createSignal(5); const [breachMonitoring, setBreachMonitoring] = createSignal(true); const [shortcutBindings, setShortcutBindings] = createSignal>(DEFAULT_BINDINGS); const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false); const [trashRetentionDays, setTrashRetentionDays] = createSignal(30); const [restoreView, setRestoreView] = createSignal<"file" | null>(null); const [pendingPasskey, setPendingPasskey] = createSignal(null); const [currentTabDomain, setCurrentTabDomain] = createSignal(""); const [currentTabUrl, setCurrentTabUrl] = createSignal(""); // Cached Pro status — synchronous for render paths. Background's // GET_LICENSE is async, but render-time reads of isPro() must not block. // Refreshed at boot, after activate/deactivate, and whenever the popup // re-opens onto a stored license that has changed via another window. const [proStatus, setProStatus] = createSignal(false); const [settingsInitialSection, setSettingsInitialSection] = createSignal(null); // When the sync card's pairing button opens the pairing view, deep-link // straight into that pairing path instead of the rest screen. const [settingsActiveCard, setSettingsActiveCard] = createSignal("protection"); const [upgradeHighlight, setUpgradeHighlight] = createSignal(null); const [sortOrder, setSortOrder] = createSignal("url-first"); const [listSearchSeed, setListSearchSeed] = createSignal(""); const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0); const [pendingCelebration, setPendingCelebration] = createSignal(false); const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null); // A page asked to be remembered for an entry it did not match. Pages cannot // grow an entry's domain set themselves (PB-26-106), so the background queues // the request here and this banner is the confirmation gate. const [pendingRememberSite, setPendingRememberSite] = createSignal<{ entryId: string; url: string; title: string; } | null>(null); // True while Panic Step 1 has bounced the user out to Export. When Export's // onBack fires we return to Panic instead of Settings so the user keeps the // destruction context they came from. const [returnToPanic, setReturnToPanic] = createSignal(false); // The view to return to when the add/edit view is dismissed. Normally "entry" // but "health" when the user arrives via the fix-entry path from Vault Health. const [editBackView, setEditBackView] = createSignal("entry"); const [settingsExiting, setSettingsExiting] = createSignal(false); const [showNotes, setShowNotes] = createSignal(false); // Topbar sync affordance. Always present: gating it on having a paired device // meant it never appeared for the person trying to set pairing up, which is // exactly when you reach for it. const [syncBusy, setSyncBusy] = createSignal(false); const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null); // The indicator light + its spoken-word equivalent. Colour alone is not the // signal; the title/aria-label always carries the same claim in English. const [syncSummary, setSyncSummary] = createSignal({}); const [pairedCount, setPairedCount] = createSignal(0); const [initialSyncDeviceId, setInitialSyncDeviceId] = createSignal(null); const [initialSyncSlow, setInitialSyncSlow] = createSignal(false); let initialSyncWaitGeneration = 0; const syncLight = () => syncBusy() ? "busy" : syncIndicator(syncSummary(), pairedCount()); const syncLabel = () => syncIndicatorLabel(syncLight(), syncSummary()); async function loadSyncIndicator() { const [stateRes, devicesRes] = await Promise.all([ sendMessage({ type: "GET_SYNC_STATE" }), sendMessage({ type: "LIST_PAIRED_DEVICES" }), ]); const s = stateRes as { ok: boolean; data?: SyncOutcomeSummary }; if (s.ok && s.data) setSyncSummary(s.data); const d = devicesRes as { ok: boolean; data?: { devices?: unknown[] } }; if (d.ok) setPairedCount(d.data?.devices?.length ?? 0); } interface InitialSyncPairedDevice extends InitialSyncDevice { pairedAt?: number; } async function listInitialSyncDevices(): Promise { const response = (await sendMessage({ type: "LIST_PAIRED_DEVICES", })) as { ok: boolean; data?: { devices?: InitialSyncPairedDevice[] }; }; return response.ok ? response.data?.devices ?? [] : []; } async function resolveInitialSyncDeviceId(): Promise { const stored = await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch( () => null, ); const devices = await listInitialSyncDevices().catch(() => []); if (stored && devices.some((device) => device.deviceId === stored)) { return stored; } // Fresh adoption has exactly one direct source, but choosing the newest row // also repairs the marker if storage failed after the pair itself committed. const newest = [...devices].sort( (a, b) => (b.pairedAt ?? 0) - (a.pairedAt ?? 0), )[0]; if (!newest) return stored; await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, newest.deviceId).catch( () => undefined, ); return newest.deviceId; } async function finishInitialSync(generation: number): Promise { if (generation !== initialSyncWaitGeneration) return; await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined); setInitialSyncDeviceId(null); setInitialSyncSlow(false); await refreshProStatus(); // A license transferred during pairing intentionally leaves the one-shot // celebration pending. Consume it only now: sync readiness → celebration // → populated vault, never celebration → temporarily empty vault. const celebration = (await sendMessage({ type: "SHOULD_PLAY_CELEBRATION", })) as { ok: boolean; data?: boolean }; const shouldCelebrate = celebration.ok && celebration.data === true; if (shouldCelebrate) { await sendMessage({ type: "MARK_CELEBRATION_PLAYED" }); } const opened = await openVault(undefined, shouldCelebrate); if (!opened || generation !== initialSyncWaitGeneration) return; if (shouldCelebrate) { setView("pro-celebration"); window.setTimeout(() => { if (generation === initialSyncWaitGeneration) setView("entry"); }, 2500); } } async function beginInitialSyncWait(deviceId: string): Promise { const generation = ++initialSyncWaitGeneration; setInitialSyncDeviceId(deviceId); setInitialSyncSlow(false); setView("initial-sync"); const startedAt = Date.now(); // Best effort: the pairing write already wakes the mesh listener, while // this trigger covers the key-order case where this receiver is the dialer. void sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "foreground" }, }).catch(() => undefined); while ( generation === initialSyncWaitGeneration && view() === "initial-sync" ) { try { const devices = await listInitialSyncDevices(); if (hasCompletedInitialSync(devices, deviceId)) { await finishInitialSync(generation); return; } } catch { // A sleeping MV3 worker may miss one poll. The next poll wakes it again. } if (Date.now() - startedAt >= INITIAL_SYNC_SLOW_MS) { setInitialSyncSlow(true); } await new Promise((resolve) => window.setTimeout(resolve, INITIAL_SYNC_POLL_MS), ); } } async function retryInitialSync(): Promise { const deviceId = initialSyncDeviceId() ?? (await resolveInitialSyncDeviceId()); if (deviceId) { await beginInitialSyncWait(deviceId); } else { setInitialSyncSlow(true); } } const [openNoteId, setOpenNoteId] = createSignal(undefined); // Set on popup boot when peach_recovery_pending flag is found in session // storage — instructs LockView to skip the unlock UI and open the recovery // words confirmation surface instead. Cleared via handleUnlock once the // user has typed the 3 confirmation words. const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false); let contentPhaseTimer: number | undefined; // Set when the popup was opened from the inline "Open Peach to unlock" button. // handleUnlock() closes the popup after unlock so the user returns to the page. let openedForInlineUnlock = false; onCleanup(() => { initialSyncWaitGeneration += 1; }); function applyAccent(name: AccentColorName) { const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!; const hex = color.hex; document.documentElement.style.setProperty("--peach-accent", hex); // Ambient variables derived from the selected accent. document.documentElement.style.setProperty("--peach-glow", hex + "2A"); document.documentElement.style.setProperty("--peach-border-accent", hex + "24"); } createEffect(() => { applyAccent(accentColor()); }); createEffect(() => { document.documentElement.classList.toggle("light", theme() === "light"); document.body.classList.toggle("light", theme() === "light"); }); createEffect(() => { document.documentElement.setAttribute("data-text-size", textSize()); }); createEffect(() => { if (reduceMotion()) { document.documentElement.setAttribute("data-motion", "reduce"); } else { document.documentElement.removeAttribute("data-motion"); } }); // Notes are not a destination of the Entry view; selecting a note opens the // notes surface instead (handleSelectEntry). Anything that walks or counts // the Entry view's neighbours (counter, prev/next, index) must use this // filtered list, otherwise the user can arrow into a note and is stuck — // the Entry view can't render a note and the prev/next buttons stop moving. const nonNoteEntries = createMemo(() => entries().filter((e) => e.type !== "note")); const selectedIndex = createMemo(() => { const currentId = selectedEntry()?.id; if (!currentId) return nonNoteEntries().length > 0 ? 0 : -1; return nonNoteEntries().findIndex((entry) => entry.id === currentId); }); async function applyAccentColor(name: AccentColorName) { applyAccent(name); setAccentColor(name); await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } }); } async function applyTheme(t: "dark" | "light") { setTheme(t); await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } }); } async function applyTextSize(size: "small" | "medium" | "large") { setTextSize(size); await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } }); } async function applyReduceMotion(enabled: boolean) { setReduceMotion(enabled); await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } }); } // Persist only the deltas vs DEFAULT_BINDINGS — keeps the vault payload // small and lets future default updates land for actions the user hasn't // touched. Settings sends us the full active map; we filter and store. async function applyShortcuts(active: Record) { setShortcutBindings(active); const overrides: Record = {}; for (const action of Object.keys(active) as ShortcutAction[]) { const a = active[action]; const d = DEFAULT_BINDINGS[action]; const same = a.key === d.key && Boolean(a.ctrl) === Boolean(d.ctrl) && Boolean(a.meta) === Boolean(d.meta) && Boolean(a.shift) === Boolean(d.shift) && Boolean(a.alt) === Boolean(d.alt); if (!same) overrides[action] = a; } await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } }); } async function applyLockOnClose(enabled: boolean) { setLockOnClose(enabled); await sendMessage({ type: "UPDATE_SETTINGS", payload: { lockOnClose: enabled }, }); } async function applyAutoLockMinutes(minutes: number) { setAutoLockMinutes(minutes); await sendMessage({ type: "UPDATE_SETTINGS", payload: { autoLockMinutes: minutes }, }); } async function applyBreachMonitoring(enabled: boolean) { setBreachMonitoring(enabled); await sendMessage({ type: "UPDATE_SETTINGS", payload: { breachMonitoring: enabled }, }); } // Which onboarding exit flow the user is in (create-vault / restore / // adopt). Persisted so closing the popup on one of those screens resumes // it directly instead of rewinding to the last carousel card. function markOnboardingExit(flow: "create" | "restore" | "adopt") { setOnboardingCardAtExit(ONBOARDING_LAST_CARD); void storageSet(ONBOARDING_EXIT_STORAGE_KEY, flow).catch(() => {}); // Backing out of the flow remounts the carousel at this card; keep the // persisted card in sync so a later popup close resumes consistently. void storageSet(ONBOARDING_CARD_STORAGE_KEY, String(ONBOARDING_LAST_CARD)).catch(() => {}); } function clearOnboardingExit() { void storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {}); void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {}); setResumeRestoreSource(null); } onMount(async () => { // Resolve the active tab in parallel with the unlock/has-vault queries — // openVault()'s smart selection picks the entry matching currentTabDomain, // so it must be populated before openVault runs. Previously refreshCurrentTab // ran after openVault, leaving popups that booted already-unlocked falling // back to loadedEntries[0] instead of the domain match. const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([ sendMessage({ type: "IS_UNLOCKED" }), sendMessage({ type: "HAS_VAULT" }), refreshCurrentTab(), sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }), ]); const unlocked = Boolean( (unlockResponse as { ok: boolean; data?: boolean }).data ); const vaultExists = Boolean( (vaultResponse as { ok: boolean; data?: boolean }).data ); const shouldCelebrate = Boolean( (celebrationResponse as { ok: boolean; data?: boolean }).data ); setHasVault(vaultExists); // Onboarding gate — show once on first install, never again. Existing // users (vaultExists) are auto-marked onboarded so they skip the flow // even on the very first popup open after this feature ships. try { const onboarded = await storageGet("peach_onboarded"); const seen = onboarded === "true"; if (vaultExists && !seen) { await storageSet("peach_onboarded", "true"); await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {}); await storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {}); await storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {}); setOnboarded(true); } else { setOnboarded(seen); if (!seen) { // A popup close while the user is in an exit flow (create-vault / // restore / adopt) resumes that flow directly — landing back on the // carousel one screen earlier reads as losing progress. const exit = await storageGet(ONBOARDING_EXIT_STORAGE_KEY).catch(() => null); if (exit === "create" || exit === "restore" || exit === "adopt") { setOnboardingCardAtExit(ONBOARDING_LAST_CARD); setOnboarded(true); setStartOnboardingRestore(exit === "restore"); if (exit === "restore") { // The OS file picker kills the action popup mid-restore, so the // surface resumes on the source the user had already picked. const source = await storageGet(RESTORE_SOURCE_STORAGE_KEY).catch(() => null); if (source === "symbols" || source === "codex" || source === "csv") { setResumeRestoreSource(source); } } if (exit === "adopt") setView("adopting"); } else { // Otherwise resume mid-onboarding where the user left off instead // of rewinding to the opening card. const stored = Number( await storageGet(ONBOARDING_CARD_STORAGE_KEY).catch(() => null), ); if ( Number.isInteger(stored) && stored > 0 && stored <= ONBOARDING_LAST_CARD ) { setOnboardingCardAtExit(stored); } } } } } catch { // storage unavailable — default to onboarded so we never block boot setOnboarded(true); } // Deep-link: ?restore=peachscript|csv — the detached-window half of the // file-picker flow. The action popup dismisses when the OS dialog steals // focus, so LockView.detachForFilePicker() reopens this URL in a // standalone window that owns the dialog. Routing runs before first // render so LockView mounts straight onto the right source form. try { const restoreRoute = new URLSearchParams(window.location.search).get("restore"); if (restoreRoute === "peachscript" || restoreRoute === "csv") { setOnboarded(true); setStartOnboardingRestore(true); setResumeRestoreSource(restoreRoute === "peachscript" ? "symbols" : "csv"); } } catch { // location.search unavailable — ignore. } await Promise.all([restoreSettings(), refreshProStatus()]); const pendingInitialSyncMarker = unlocked && vaultExists ? await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => null) : null; const pendingInitialSyncDeviceId = pendingInitialSyncMarker ? await resolveInitialSyncDeviceId() : null; // Mark played before any animation so a crash mid-sequence never replays // it. A newly adopted receiver deliberately defers this until its first // real sync finishes, preserving initial sync → celebration → vault. if (shouldCelebrate && !pendingInitialSyncDeviceId) { await sendMessage({ type: "MARK_CELEBRATION_PLAYED" }); } // Popup-foreground sync trigger (SEV-1-A). Fire-and-forget — the outcome // arrives on the SYNC_STATE_CHANGED broadcast the indicator listens to. if (unlocked && vaultExists) { void sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "foreground" }, }); void loadSyncIndicator(); // Rare device-trust alerts only; routine entry sync stays quiet. void loadSyncAlerts(); } // If the user created a vault but hasn't yet confirmed their recovery // words, route back to the recovery-words screen instead of opening the // entries view. The flag lives in chrome.storage.local so it survives // popup close while the background owns the session-scoped confirmation // exemption. Cleared when confirmation succeeds. let recoveryPending = false; try { const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" }); recoveryPending = out.ok === true && out.data === true; } catch { /* best-effort */ } // Content UI can ask the background to open this trusted popup either for // unlock or at a specific entry. Consume both session-scoped handoffs once. let pendingOpenEntryId: string | undefined; if (chrome.storage.session) { try { const s = await chrome.storage.session.get(["peach_inline_unlock", "peach_open_entry"]); const pending = s as { peach_inline_unlock?: boolean; peach_open_entry?: string }; if (pending.peach_inline_unlock) { openedForInlineUnlock = true; } if (typeof pending.peach_open_entry === "string") pendingOpenEntryId = pending.peach_open_entry; await chrome.storage.session.remove(["peach_inline_unlock", "peach_open_entry"]); } catch { /* best-effort */ } } if (recoveryPending && unlocked) { // forceRecoveryWords overrides LockView's hasVault gate so the // recovery surface renders even though the vault now exists. setForceRecoveryWords(true); setView("lock"); } else if (unlocked && vaultExists && pendingInitialSyncDeviceId) { void beginInitialSyncWait(pendingInitialSyncDeviceId); } else if (unlocked && vaultExists) { await openVault(pendingOpenEntryId, shouldCelebrate); if (shouldCelebrate) { setView("pro-celebration"); window.setTimeout(() => setView("entry"), 2500); } else if (await shouldShowWhatsNew()) { setView("whats-new"); } } else if (shouldCelebrate) { setPendingCelebration(true); } await refreshPendingPasskey(); // Track tab changes so smart sort stays current const handleTabActivated = () => { void refreshCurrentTab(); }; const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => { if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab(); }; try { chrome.tabs.onActivated.addListener(handleTabActivated); chrome.tabs.onUpdated.addListener(handleTabUpdated); onCleanup(() => { chrome.tabs.onActivated.removeListener(handleTabActivated); chrome.tabs.onUpdated.removeListener(handleTabUpdated); }); } catch { // tabs permission may not be available in all contexts } setBootState("ready"); // Deep-link: ?codex=pdf opens a detached window straight into the // Codex restore flow with auto-picked PDF. The action popup can't host // a file dialog without dismissing itself, so this is the only way the // PDF import path survives. See CodexRestoreView.openPdfPicker(). try { const route = new URLSearchParams(window.location.search).get("codex"); if (route === "pdf" || route === "restore") { setView("codex-restore"); } } catch { // location.search unavailable — ignore. } // Check for pending session-storage notices from the background. if (chrome.storage.session) { const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as Record; if (session.pendingProGate?.feature) { await chrome.storage.session.remove("pendingProGate"); setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null); setSettingsInitialSection("license"); setView("settings"); } else if (session.pendingProNotice?.feature) { await chrome.storage.session.remove("pendingProNotice"); setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" }); } const remembered = (await chrome.storage.session.get("pendingRememberSite")) as { pendingRememberSite?: { entryId?: unknown; url?: unknown }; }; const request = remembered.pendingRememberSite; if ( typeof request?.entryId === "string" && typeof request.url === "string" ) { const entry = await sendMessage({ type: "GET_ENTRIES" }); const match = entry.ok ? (entry.data as Array<{ id: string; title?: string }> | undefined)?.find( (candidate) => candidate.id === request.entryId, ) : undefined; if (match) { let host = request.url; try { host = new URL(request.url).hostname; } catch { /* keep raw */ } setPendingRememberSite({ entryId: request.entryId, url: request.url, title: match.title ?? host }); } else { await chrome.storage.session.remove("pendingRememberSite"); } } } // Warm the lazy-module cache so the Suspense fallback never shows for // locally-bundled extension views. Without this, the first navigation to // Settings/Import/Export/etc can briefly mount `.peach-loading`, which // covers the shell and absorbs clicks on the header. void Promise.all([ import("./views/Settings").catch(() => {}), import("./views/Import").catch(() => {}), import("./views/Export").catch(() => {}), import("./views/Health").catch(() => {}), import("./views/TrashView").catch(() => {}), ]); // Lock-on-close via a persistent port. sendMessage in pagehide is // unreliable — the popup context tears down before the message lands. // The SW's onConnect listener locks when this port disconnects. let popupPort: chrome.runtime.Port | null = null; try { // UI-6 / LV-5: nonce the port name per popup open. The SW's onConnect // listener accepts the "popup-" prefix; the random suffix is defence in // depth against a port named exactly "popup" from anything but our popup. popupPort = chrome.runtime.connect({ name: `popup-${crypto.randomUUID()}` }); } catch { /* extension context invalidated — ignore */ } onCleanup(() => { try { popupPort?.disconnect(); } catch {} }); // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale // entries instead of waiting for the next GET_ENTRIES round-trip. const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => { if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock(); if (msg?.type === "SYNC_STATE_CHANGED") { setSyncSummary((msg.payload ?? {}) as SyncOutcomeSummary); } if (msg?.type === "PRO_GATE_TRIGGERED") { setUpgradeHighlight(msg.payload?.feature ?? null); setSettingsInitialSection("license"); setView("settings"); } }; chrome.runtime.onMessage.addListener(handleRuntimeMessage); onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage)); // Action handlers — keyed by ShortcutAction. The dispatcher below tests // each binding against the event and runs the matching handler. New // shortcuts only need an entry here; the binding is data, not code. const SHORTCUT_HANDLERS: Partial void>> = { copy_password: () => { const entry = selectedEntry(); if (entry?.password) void popupCopy( entry.password, "Password", entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined, ).then((ok) => { if (ok) triggerRing(); }); }, copy_username: () => { const entry = selectedEntry(); if (entry?.username) void popupCopy( entry.username, "Username", entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined, ).then((ok) => { if (ok) triggerRing(); }); }, copy_totp: () => { const entry = selectedEntry(); if (!entry?.totp) return; try { const { code } = generateTOTP( entry.totp, entry.totpAlgorithm, entry.totpDigits, entry.totpPeriod, ); void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); }); } catch { /* invalid TOTP secret — silent */ } }, new_entry: () => handleAdd(), edit_entry: () => { const entry = selectedEntry(); // Passkey-only entries have nothing user-editable in the Add view; the // passkey credential itself is opaque. Skip the edit action for them. if (!entry || isPasskeyOnly(entry)) return; setEditEntry(entry); setView("add"); }, delete_entry: () => { if (selectedEntry()) void handleDelete(); }, lock_vault: () => { void handleHeaderLock(); }, search: () => openList(), nav_prev: () => navigate(-1), nav_next: () => navigate(1), jump_vault: () => setView("entry"), jump_settings: () => setView("settings"), show_help: () => setShowShortcutsHelp(true), }; // Actions that are MEANINGFUL only on the entry-detail view. Outside // that scope the binding is dormant — pressing C while on Settings // does nothing rather than awkwardly copying from a now-stale entry. const ENTRY_SCOPED: ReadonlySet = new Set([ "copy_password", "copy_username", "copy_totp", "edit_entry", "delete_entry", "nav_prev", "nav_next", ]); const handleGlobalKeydown = (e: KeyboardEvent) => { const v = view(); const inInput = (e.target as HTMLElement)?.tagName === "INPUT" || (e.target as HTMLElement)?.tagName === "TEXTAREA" || (e.target as HTMLElement)?.isContentEditable; const isPrintableSearchKey = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey; // No shortcuts while a full-screen surface owns the whole viewport: // the lock screen, the post-pairing readiness sequence, or the Pro // celebration. Without this the lock screen is at the mercy of whatever // single-key binding is active — e.g. typing "n" in a password that // slips out of focus triggers `new_entry` and escapes the lock screen. if (v === "lock" || v === "adopting" || v === "pro-celebration" || v === "initial-sync") return; // Help dialog: Escape closes it; eat the keypress so it doesn't // also navigate away from the underlying view. if (showShortcutsHelp()) { if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); } return; } // ── Escape — fixed binding (not user-customizable) ─────── if (e.key === "Escape") { if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; } if (v === "list") { setView("entry"); return; } if (v === "settings" || v === "add") { handleBack(); return; } if (v === "import" || v === "health") { setView("settings"); return; } if (v === "export") { handleExportBack(); return; } if (v === "pair-device") { setView("settings"); return; } } // Bindings-driven dispatch. Single-key letter bindings without // modifiers (c/u/t/n/e/d/?) are silently swallowed when focus is // inside an input, so users can type into search/forms normally. const bindings = shortcutBindings(); for (const action of Object.keys(bindings) as ShortcutAction[]) { const binding = bindings[action]; if (!matchKeybinding(e, binding)) continue; // Single-key letter bindings need the input-focus guard. Bindings // with any modifier are safe — Ctrl+L doesn't conflict with typing. const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift; if (!hasMod && binding.key.length === 1 && inInput) return; if (ENTRY_SCOPED.has(action) && v !== "entry") return; e.preventDefault(); SHORTCUT_HANDLERS[action]?.(); return; } // Type-to-search: in entry view, any printable key not consumed by a // shortcut above primes the list search seed and opens the list. // Kept outside the bindings table because it's a behavior, not a // discrete action — every printable character can trigger it. if (v === "entry" && !inInput && isPrintableSearchKey) { e.preventDefault(); primeListSearch(e.key); return; } }; window.addEventListener("keydown", handleGlobalKeydown); onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown)); const handleStorageChanged = ( changes: Record, areaName: string ) => { if (areaName !== "session") return; if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) { return; } void refreshPendingPasskey(); }; chrome.storage.onChanged.addListener(handleStorageChanged); onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged)); }); async function refreshCurrentTab() { try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); const url = tab?.url ?? tab?.pendingUrl ?? ""; setCurrentTabDomain(url ? (extractDomain(url) ?? "") : ""); // Skip extension/internal URLs — adding chrome://, about:, or // chrome-extension:// to a vault entry is never what the user wants. const isWebUrl = /^https?:\/\//i.test(url); setCurrentTabUrl(isWebUrl ? url : ""); } catch { setCurrentTabDomain(""); setCurrentTabUrl(""); } } async function refreshPendingPasskey() { const res = await sendMessage({ type: "GET_PENDING_PASSKEY" }); setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null); } async function refreshProStatus() { const res = await sendMessage({ type: "GET_LICENSE" }); const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data; setProStatus(Boolean(data?.valid && data?.plan === "pro")); } async function restoreSettings() { const res = await sendMessage({ type: "GET_SETTINGS" }); const s = (res as { ok: boolean; data?: any }).data ?? {}; if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) { setAccentColor(s.accentColor as AccentColorName); } setTheme(s.theme === "light" ? "light" : "dark"); if (s.textSize === "small" || s.textSize === "large") { setTextSize(s.textSize); } else { setTextSize("medium"); } setReduceMotion(s.reduceMotion === true); setLockOnClose(s.lockOnClose === true); setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5); setBreachMonitoring(s.breachMonitoring !== false); setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30); setShortcutBindings(getActiveBindings(s.shortcuts)); } async function applyTrashRetention(days: number) { setTrashRetentionDays(days); await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } }); } function primeListSearch(seed: string) { setListSearchSeed(seed); setListSearchSeedNonce((value) => value + 1); openList(); } // Returns null when it detected a dead service worker and triggered handleLock(). // Callers that need to abort (e.g. openVault) check for null rather than relying // on view() state, which may already be "lock" during normal startup flows. async function loadEntries(): Promise { const response = await sendMessage({ type: "GET_ENTRIES" }); const res = response as { ok: boolean; data?: VaultEntry[]; error?: string }; // If the service worker was killed and restarted (MV3 workers sleep), the // vault will be locked again — or the worker may not respond at all. // In either case, send the user to the lock screen rather than silently // showing an empty vault (which looks like data loss). if (!res.ok) { if ( res.error === "vault_locked" || res.error === "SERVICE_WORKER_UNAVAILABLE" || (typeof res.error === "string" && res.error.includes("locked")) ) { handleLock(); return null; // signal emergency lock to callers } return []; } const loadedEntries = res.data ?? []; setEntries(loadedEntries); const currentId = selectedEntry()?.id; if (currentId) { setSelectedEntry( loadedEntries.find((entry) => entry.id === currentId) ?? pickNavigable(loadedEntries) ); } return loadedEntries; } async function openVault(preferredEntryId?: string, skipViewSet = false) { // Purge expired trash entries on each vault open const retDays = trashRetentionDays(); if (retDays > 0) { void sendMessage({ type: "PURGE_EXPIRED_TRASH", payload: { retentionMs: retDays * 86_400_000 }, }); } const loadedEntries = await loadEntries(); // null means loadEntries() triggered an emergency lock (service worker died). // Don't override it by setting view back to "entry" with an empty vault. if (loadedEntries === null) return false; // Smart selection: prefer explicitly requested ID, then a domain match, then first entry let nextSelectedEntry: VaultEntry | null = null; if (preferredEntryId) { nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null; } if (!nextSelectedEntry) { const tabDomain = currentTabDomain(); if (tabDomain) { nextSelectedEntry = loadedEntries.find((e) => { if (e.type === "login") { if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true; for (const extra of e.additionalUrls ?? []) { if ((extractDomain(extra) ?? "") === tabDomain) return true; } return false; } if (e.passkey?.rpId) return e.passkey.rpId === tabDomain; return false; }) ?? null; } } nextSelectedEntry ??= pickNavigable(loadedEntries); setSelectedEntry(nextSelectedEntry); setEditEntry(undefined); setContentPhase("enter"); queueContentIdle(); if (!skipViewSet) setView("entry"); return true; } async function handleUnlock() { // If the user previously created a vault but hadn't yet confirmed their // recovery words (and the browser was closed before they could), the // the background-owned recovery-pending state is still set. Re-route // back to the recovery surface so they finish the confirmation step // before reaching the entries view — the words can be re-derived now // that the vault is unlocked again. let recoveryPending = false; try { const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" }); recoveryPending = out.ok === true && out.data === true; } catch { /* best-effort */ } if (recoveryPending) { setForceRecoveryWords(true); setView("lock"); triggerPipPulse(); await restoreSettings(); return; } setHasVault(true); triggerPipPulse(); await restoreSettings(); const celebrating = pendingCelebration(); if (celebrating) { await sendMessage({ type: "MARK_CELEBRATION_PLAYED" }); setPendingCelebration(false); } await openVault(undefined, celebrating); if (celebrating) { setView("pro-celebration"); window.setTimeout(() => setView("entry"), 2500); } else if (!openedForInlineUnlock && await shouldShowWhatsNew()) { setView("whats-new"); } // Popup was opened from the inline "Open Peach to unlock" button — close it // now so the user lands back on the page with the autofill selector ready. if (openedForInlineUnlock) { openedForInlineUnlock = false; window.close(); } } function handleSelectEntry(entry: VaultEntry) { if (entry.type === "note") { setOpenNoteId(entry.id); setShowNotes(true); return; } setSelectedEntry(entry); setContentPhase("enter"); queueContentIdle(); setView("entry"); } function handleAdd() { setEditEntry(undefined); setView("add"); } async function handleDelete() { const entry = selectedEntry(); if (!entry) return; await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } }); const loadedEntries = await loadEntries() ?? []; setSelectedEntry(pickNavigable(loadedEntries)); setView("entry"); } async function handleDeleteMany(ids: string[]) { for (const id of ids) { await sendMessage({ type: "DELETE_ENTRY", payload: { id } }); } const loadedEntries = await loadEntries() ?? []; // If the currently selected entry was among the deleted, move to first const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id); if (!stillExists) setSelectedEntry(pickNavigable(loadedEntries)); } async function handleSave(savedEntry?: VaultEntry) { await openVault(savedEntry?.id); } function handleBack() { const dest = editBackView(); setEditBackView("entry"); setEditEntry(undefined); setView(dest); } function handleExportBack() { if (returnToPanic()) { setReturnToPanic(false); setView("panic"); return; } setView("settings"); } function handleLock() { setEntries([]); setSelectedEntry(null); setEditEntry(undefined); setContentPhase("idle"); setView("lock"); } function handleHeaderLock() { // Lock the UI immediately — do not wait for the service worker. If the SW // is dead or slow, the 5s sendMessage timeout would create a dead-click // window. The SW will lock on its next wake regardless. handleLock(); void sendMessage({ type: "LOCK" }); } // Manual sync from the topbar. TRIGGER_SYNC returns as soon as the run is // queued, so the outcome is read back from GET_SYNC_STATE rather than from // the trigger's own reply. async function handleHeaderSync(): Promise { if (syncBusy()) return; setSyncBusy(true); setSyncFlash(null); try { await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } }); let state: SyncOutcomeSummary = { status: "syncing" }; for (let i = 0; i < 40 && state.status === "syncing"; i++) { await new Promise((r) => setTimeout(r, 250)); const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as { ok: boolean; data?: SyncOutcomeSummary; }; state = res.ok ? res.data ?? { status: "idle" } : { status: "error" }; } setSyncSummary(state); setSyncFlash(state.status === "error" ? "error" : "ok"); await loadSyncAlerts(); await loadEntries(); await loadSyncIndicator(); } catch { setSyncSummary({ status: "error", outcome: "run_failed" }); setSyncFlash("error"); } finally { setSyncBusy(false); setTimeout(() => setSyncFlash(null), 2_000); } } function navigate(dir: number) { const navigable = nonNoteEntries(); if (navigable.length === 0) return; const currentIndex = selectedIndex(); const baseIndex = currentIndex >= 0 ? currentIndex : 0; const nextIndex = (baseIndex + dir + navigable.length) % navigable.length; setSelectedEntry(navigable[nextIndex]); setContentPhase("enter"); queueContentIdle(); } /** * The Entry view's selection invariant: never select a note. Notes are a * separate surface (handleSelectEntry opens the notes view), so any code path * that picks a default — vault open, post-delete, refresh — must skip them * and return null if the only entries are notes. */ function pickNavigable(list: VaultEntry[]): VaultEntry | null { return list.find((e) => e.type !== "note") ?? null; } function openList() { setView("list"); } function openHealth() { setEditEntry(undefined); setView("health"); } function handleOpenFileImport() { setRestoreView("file"); } const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null; function handleFixEntry(entry: VaultEntry) { setSelectedEntry(entry); // Passkey-only entries have no editable surface — drop into view mode // instead of opening the Add editor. if (isPasskeyOnly(entry)) { setView("entry"); return; } setEditBackView(view()); // preserve caller view so back returns here setEditEntry(entry); setView("add"); } const isLockedView = () => view() === "lock" || view() === "adopting"; async function handleAdoptionDone() { await storageSet("peach_onboarded", "true").catch(() => {}); await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {}); clearOnboardingExit(); setOnboarded(true); setHasVault(true); setView("initial-sync"); // The background applies a Pro license and persists the source-device // readiness marker before it acknowledges adoption. Keep the one-shot Pro // celebration pending until that exact peer records a completed sync. await refreshProStatus(); const deviceId = await resolveInitialSyncDeviceId(); if (deviceId) { void beginInitialSyncWait(deviceId); } else { // Pairing completion without a durable peer row should be impossible. // Stay on the honest waiting surface and expose retry instead of falling // through to an empty vault. setInitialSyncSlow(true); } } async function handleApprovePendingPasskey() { const pending = pendingPasskey(); if (!pending) return; if (pending.browser === "firefox" && pending.type === "create") { // Firefox path: popup itself runs navigator.credentials.create. The // background never approved/rejected anything — it just stashed // intent. After the popup-driven flow resolves (success or cancel), // clear the pending key so the popup doesn't re-show this on next open. const { createFirefoxPasskey } = await import("./firefox-passkey"); const result = await createFirefoxPasskey(pending); if (!result.ok) { console.warn("Peach Firefox passkey create failed:", result.error); } await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } }); setPendingPasskey(null); void loadEntries(); window.close(); return; } await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } }); window.close(); } async function handleDenyPendingPasskey() { const pending = pendingPasskey(); if (!pending) return; await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } }); window.close(); } function queueContentIdle(after?: () => void) { if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer); contentPhaseTimer = window.setTimeout(() => { setContentPhase("idle"); after?.(); }, ENTRY_TRANSITION_SETTLE_MS); } return ( }> { clearOnboardingExit(); setOnboarded(false); setView("lock"); }} onDone={handleAdoptionDone} /> } > { markOnboardingExit("create"); setStartOnboardingRestore(false); setOnboarded(true); }} onRestore={() => { markOnboardingExit("restore"); setStartOnboardingRestore(true); setOnboarded(true); }} onAdopt={() => { markOnboardingExit("adopt"); setStartOnboardingRestore(false); setOnboarded(true); setView("adopting"); }} /> } > { // RecoveryWordDisplay clears the storage flag before // calling onDone, so handleUnlock will see no pending // confirmation and proceed through the normal path. if (forceRecoveryWords()) setForceRecoveryWords(false); // The create/restore flow is done — a lock later in this // window must show the unlock screen, not re-enter the // onboarding restore surface. setStartOnboardingRestore(false); setResumeRestoreSource(null); void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {}); return handleUnlock(); }} onBackToOnboarding={ !hasVault() ? () => { // Deliberately rewinding to the carousel — the exit // flow must not reclaim the next popup open. clearOnboardingExit(); setStartOnboardingRestore(false); setOnboarded(false); } : undefined } onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined} /> } > } > setView("entry")} /> } > void retryInitialSync()} /> } >
✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro
{(request) => { let host = request().url; try { host = new URL(request().url).hostname; } catch { /* keep raw */ } return (
Also use “{request().title}” on {host}?
); }}
void loadEntries()} onEscape={() => { setView("list"); }} onProGate={(feature) => { setUpgradeHighlight(feature); setSettingsInitialSection("license"); setView("settings"); }} />
{/* Rare device-trust alerts; normal sync changes are silent. */} 0}>
{([type, group]) => { const idx = () => Math.min(alertFlipIdx()[type] ?? 0, group.length - 1); return (
1}> ⚠ {syncAlertText(group[idx()])} 1}> {idx() + 1}/{group.length}
); }}
setView("entry")} onOpenTrash={() => setView("trash")} onDeleteMany={handleDeleteMany} /> }> { // After activation/deactivation: refresh Pro status, // re-load entries (defensive — signal state can drift if // the SW briefly restarts during the verify+save round-trip), // and replay the Pro celebration if this is the first time // a valid Pro license is seen on this device. await refreshProStatus(); void loadEntries(); const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }); if ((should as { ok: boolean; data?: boolean }).data === true) { await sendMessage({ type: "MARK_CELEBRATION_PLAYED" }); setView("pro-celebration"); window.setTimeout(() => setView("settings"), 2500); } }} onBack={() => { setSettingsExiting(true); window.setTimeout(() => { setSettingsExiting(false); setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }, 200); }} onImport={() => setView("import")} onExport={() => setView("export")} onBreachCheckDone={loadEntries} onOpenHealth={openHealth} onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }} onOpenFileImport={handleOpenFileImport} onOpenPanic={() => setView("panic")} onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }} onOpenCodexRestore={() => setView("codex-restore")} onCardChange={setSettingsActiveCard} onProGate={(feature) => { setUpgradeHighlight(feature); setSettingsInitialSection("license"); }} /> }> setView("settings")} onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }} onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }} onVaultLocked={() => handleLock()} /> }> setView("lock")} onRestoreSuccess={async () => { // After restore the vault is unlocked; refresh entries // and route to the main vault list. handleUnlock is the // canonical post-unlock setup path. await handleUnlock(); }} /> }> { void refreshProStatus(); }} onBack={() => setView("settings")} onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }} /> }> setView("settings")} onImport={handleSave} /> }> }> setView("settings")} onOpenExport={() => { setReturnToPanic(true); setView("export"); }} onDestroyed={async () => { // Fresh onboarding start after a destroy — don't let the // resume markers land the user mid-carousel. setOnboardingCardAtExit(0); void storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {}); clearOnboardingExit(); setEntries([]); setSelectedEntry(null); setEditEntry(undefined); setHasVault(false); setOnboarded(false); setProStatus(false); setPendingPasskey(null); setUpgradeHighlight(null); setSettingsInitialSection(null); setPendingProNotice(null); setPendingCelebration(false); setReturnToPanic(false); setContentPhase("idle"); setView("lock"); }} /> {/* Health stays mounted while editing an entry from it so computeReused() doesn't rerun on every cancel/back. */} }> setView("settings")} onRefresh={loadEntries} hidden={view() !== "health"} /> }> setView("list")} onRestored={() => void loadEntries()} />
e.type === "note")} initialNoteId={openNoteId()} onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }} onNoteListChanged={() => void loadEntries()} />
}> setRestoreView(null)} onImport={async () => { setRestoreView(null); await handleUnlock(); }} />
setShowShortcutsHelp(false)} /> ); } function ShortcutsHelpOverlay(props: { bindings: Record; onClose: () => void; }) { return (
e.stopPropagation()}>

Keyboard shortcuts

ENTRY VIEW

    {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (
  • {d.label} {formatKeybinding(props.bindings[d.action])}
  • ))}

ANYWHERE

    {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (
  • {d.label} {formatKeybinding(props.bindings[d.action])}
  • ))}
); }