App.tsx

45.0 KB · raw

1import { createEffect, createMemo, createSignal, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";2import { ACCENT_COLORS } from "../shared/types";3import type { AccentColorName, PasskeyEntry, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";4import { LockView } from "./views/Lock";5import { Onboarding } from "./views/Onboarding";6import { ProCelebration } from "./views/ProCelebration";7import { VaultView, type SortOrder } from "./views/Vault";8import { EntryView } from "./views/Entry";9import { AddView } from "./views/Add";10import { PeachWordmark } from "./components/PeachMark";11import { PasskeyApprovalView } from "./views/PasskeyApproval";12import { PasskeyEntryView } from "./views/PasskeyEntry";13import { extractDomain } from "../shared/domain";14import { popupCopy } from "./copy";15import {16  DEFAULT_BINDINGS,17  formatKeybinding,18  getActiveBindings,19  matchKeybinding,20  SHORTCUT_DESCRIPTORS,21  type Keybinding,22  type ShortcutAction,23} from "../shared/shortcuts";24import { generateTOTP } from "../crypto/totp";2526const ImportView    = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));27const ExportView    = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));28const SettingsView  = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));29const HealthView    = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));30const TrashView     = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));3132type View =33  | "lock"34  | "pro-celebration"35  | "entry"36  | "list"37  | "add"38  | "settings"39  | "import"40  | "export"41  | "health"42  | "trash";43type BootState = "loading" | "ready";4445const canarySuppressMap = new Map<string, boolean>();46let shellRingAnimation: Animation | null = null;4748export function isCanarySuppressed(entryId: string): boolean {49  return canarySuppressMap.get(entryId) ?? false;50}5152export function setCanarySuppressed(entryId: string, suppressed: boolean): void {53  canarySuppressMap.set(entryId, suppressed);54}5556export function clearCanarySuppressions(): void {57  canarySuppressMap.clear();58}5960export function triggerRing() {61  const shell = document.getElementById("hd-shell");62  if (!shell) return;6364  shellRingAnimation?.cancel();65  shell.dataset.glowing = "true";66  const animation = shell.animate(67    [68      { offset: 0, boxShadow: "var(--copy-shadow)" },69      {70        offset: 0.3,71        boxShadow:72          "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)",73      },74      { offset: 1, boxShadow: "var(--copy-shadow)" },75    ],76    {77      duration: 720,78      easing: "cubic-bezier(0.4, 0, 0.2, 1)",79      fill: "none",80    }81  );82  shellRingAnimation = animation;83  const clearGlow = () => {84    if (shellRingAnimation !== animation) return;85    shellRingAnimation = null;86    delete shell.dataset.glowing;87  };88  animation.onfinish = clearGlow;89  animation.oncancel = clearGlow;90}919293export function triggerPipPulse() {94  const pip = document.querySelector(".peach-status-pip, .pip");95  if (pip) {96    pip.classList.remove("pulse");97    void (pip as HTMLElement).offsetWidth;98    pip.classList.add("pulse");99  }100}101102function humanizeError(error: string | undefined): string | undefined {103  if (!error) return error;104  if (error === "STORAGE_QUOTA_EXCEEDED") {105    return "Peach has run out of browser storage. Delete some entries or clear browser storage to continue.";106  }107  return error;108}109110async function sendMessage(msg: any): Promise<any> {111  const MESSAGE_TIMEOUT_MS = 5000;112113  return new Promise((resolve) => {114    let settled = false;115    const timeoutId = window.setTimeout(() => {116      if (settled) return;117      settled = true;118      resolve({ ok: false, error: "SERVICE_WORKER_UNAVAILABLE" });119    }, MESSAGE_TIMEOUT_MS);120121    try {122      chrome.runtime.sendMessage(msg, (response) => {123        if (settled) return;124        settled = true;125        window.clearTimeout(timeoutId);126127        if (chrome.runtime.lastError) {128          resolve({129            ok: false,130            error:131              chrome.runtime.lastError.message ?? "SERVICE_WORKER_UNAVAILABLE",132          });133          return;134        }135136        if (response === undefined) {137          resolve({ ok: false, error: "SERVICE_WORKER_UNAVAILABLE" });138          return;139        }140141        if (response && response.ok === false) {142          response.error = humanizeError(response.error);143        }144        resolve(response);145      });146    } catch (error) {147      if (settled) return;148      settled = true;149      window.clearTimeout(timeoutId);150      resolve({151        ok: false,152        error:153          error instanceof Error ? error.message : "SERVICE_WORKER_UNAVAILABLE",154      });155    }156  });157}158159export { sendMessage };160161const ENTRY_TRANSITION_EXIT_MS = 95;162const ENTRY_TRANSITION_SETTLE_MS = 135;163164export default function App() {165  const [bootState, setBootState] = createSignal<BootState>("loading");166  const [view, setView] = createSignal<View>("lock");167  const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(168    "enter"169  );170  const [contentTransitioning, setContentTransitioning] = createSignal(false);171  const [hasVault, setHasVault] = createSignal(false);172  const [onboarded, setOnboarded] = createSignal(true);173  const [entries, setEntries] = createSignal<VaultEntry[]>([]);174  const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);175  const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);176  const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");177  const [theme, setTheme] = createSignal<"dark" | "light">("dark");178  const [displayFont, setDisplayFont] = createSignal<"cormorant-italic" | "cormorant" | "inter" | "system">("cormorant-italic");179  const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");180  const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);181  const [lockOnClose, setLockOnClose] = createSignal(true);182  const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);183  const [breachMonitoring, setBreachMonitoring] = createSignal(true);184  const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);185  const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);186  const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);187  const [restoreView, setRestoreView] = createSignal<"file" | null>(null);188  const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);189  const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");190  // Cached Pro status — synchronous for render paths. Background's191  // GET_LICENSE is async, but render-time reads of isPro() must not block.192  // Refreshed at boot, after activate/deactivate, and whenever the popup193  // re-opens onto a stored license that has changed via another window.194  const [proStatus, setProStatus] = createSignal(false);195  const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);196  const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);197  const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");198  const [listSearchSeed, setListSearchSeed] = createSignal("");199  const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);200  const [pendingCelebration, setPendingCelebration] = createSignal(false);201  const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);202203  let contentPhaseTimer: number | undefined;204205  function applyAccent(name: AccentColorName) {206    const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;207    const hex = color.hex;208    document.documentElement.style.setProperty("--peach-accent", hex);209    // Ambient surface variables — derived from the accent so surfaces shift210    // subtly toward the accent hue rather than staying fixed warm-brown.211    document.documentElement.style.setProperty("--peach-glow", hex + "2A");212    document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");213    document.documentElement.style.setProperty("--peach-border-accent", hex + "24");214  }215216  createEffect(() => {217    applyAccent(accentColor());218  });219220  createEffect(() => {221    document.documentElement.classList.toggle("light", theme() === "light");222    document.body.classList.toggle("light", theme() === "light");223  });224225  createEffect(() => {226    document.documentElement.setAttribute("data-display-font", displayFont());227  });228229  createEffect(() => {230    document.documentElement.setAttribute("data-text-size", textSize());231  });232233  createEffect(() => {234    if (reduceMotion()) {235      document.documentElement.setAttribute("data-motion", "reduce");236    } else {237      document.documentElement.removeAttribute("data-motion");238    }239  });240241  const selectedIndex = createMemo(() => {242    const currentId = selectedEntry()?.id;243    if (!currentId) return entries().length > 0 ? 0 : -1;244    return entries().findIndex((entry) => entry.id === currentId);245  });246247  async function applyAccentColor(name: AccentColorName) {248    applyAccent(name);249    setAccentColor(name);250    await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });251  }252253  async function applyTheme(t: "dark" | "light") {254    setTheme(t);255    await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });256  }257258  async function applyDisplayFont(f: "cormorant-italic" | "cormorant" | "inter" | "system") {259    setDisplayFont(f);260    await sendMessage({ type: "UPDATE_SETTINGS", payload: { displayFont: f } });261  }262263  async function applyTextSize(size: "small" | "medium" | "large") {264    setTextSize(size);265    await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });266  }267268  async function applyReduceMotion(enabled: boolean) {269    setReduceMotion(enabled);270    await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });271  }272273  // Persist only the deltas vs DEFAULT_BINDINGS — keeps the vault payload274  // small and lets future default updates land for actions the user hasn't275  // touched. Settings sends us the full active map; we filter and store.276  async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {277    setShortcutBindings(active);278    const overrides: Record<string, Keybinding> = {};279    for (const action of Object.keys(active) as ShortcutAction[]) {280      const a = active[action];281      const d = DEFAULT_BINDINGS[action];282      const same = a.key === d.key283        && Boolean(a.ctrl)  === Boolean(d.ctrl)284        && Boolean(a.meta)  === Boolean(d.meta)285        && Boolean(a.shift) === Boolean(d.shift)286        && Boolean(a.alt)   === Boolean(d.alt);287      if (!same) overrides[action] = a;288    }289    await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });290  }291292  async function applyLockOnClose(enabled: boolean) {293    setLockOnClose(enabled);294    await sendMessage({295      type: "UPDATE_SETTINGS",296      payload: { lockOnClose: enabled },297    });298  }299300  async function applyAutoLockMinutes(minutes: number) {301    setAutoLockMinutes(minutes);302    await sendMessage({303      type: "UPDATE_SETTINGS",304      payload: { autoLockMinutes: minutes },305    });306  }307308  async function applyBreachMonitoring(enabled: boolean) {309    setBreachMonitoring(enabled);310    await sendMessage({311      type: "UPDATE_SETTINGS",312      payload: { breachMonitoring: enabled },313    });314  }315316  onMount(async () => {317    // Resolve the active tab in parallel with the unlock/has-vault queries —318    // openVault()'s smart selection picks the entry matching currentTabDomain,319    // so it must be populated before openVault runs. Previously refreshCurrentTab320    // ran after openVault, leaving popups that booted already-unlocked falling321    // back to loadedEntries[0] instead of the domain match.322    const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([323      sendMessage({ type: "IS_UNLOCKED" }),324      sendMessage({ type: "HAS_VAULT" }),325      refreshCurrentTab(),326      sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),327    ]);328    const unlocked = Boolean(329      (unlockResponse as { ok: boolean; data?: boolean }).data330    );331    const vaultExists = Boolean(332      (vaultResponse as { ok: boolean; data?: boolean }).data333    );334    const shouldCelebrate = Boolean(335      (celebrationResponse as { ok: boolean; data?: boolean }).data336    );337    setHasVault(vaultExists);338    // Onboarding gate — show once on first install, never again. Existing339    // users (vaultExists) are auto-marked onboarded so they skip the flow340    // even on the very first popup open after this feature ships.341    try {342      const onboardingState = await chrome.storage.local.get("peach_onboarded");343      const seen = onboardingState.peach_onboarded === true;344      if (vaultExists && !seen) {345        await chrome.storage.local.set({ peach_onboarded: true });346        setOnboarded(true);347      } else {348        setOnboarded(seen);349      }350    } catch {351      // storage unavailable — default to onboarded so we never block boot352      setOnboarded(true);353    }354    await Promise.all([restoreSettings(), refreshProStatus()]);355356    // Mark played before any animation so a crash mid-sequence never replays it357    if (shouldCelebrate) {358      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });359    }360361    if (unlocked && vaultExists) {362      await openVault(undefined, shouldCelebrate);363      if (shouldCelebrate) {364        setView("pro-celebration");365        window.setTimeout(() => setView("entry"), 2500);366      }367    } else if (shouldCelebrate) {368      setPendingCelebration(true);369    }370371    await refreshPendingPasskey();372373    // Track tab changes so smart sort stays current374    const handleTabActivated = () => { void refreshCurrentTab(); };375    const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {376      if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();377    };378    try {379      chrome.tabs.onActivated.addListener(handleTabActivated);380      chrome.tabs.onUpdated.addListener(handleTabUpdated);381      onCleanup(() => {382        chrome.tabs.onActivated.removeListener(handleTabActivated);383        chrome.tabs.onUpdated.removeListener(handleTabUpdated);384      });385    } catch {386      // tabs permission may not be available in all contexts387    }388389    setBootState("ready");390    document.getElementById("pre-lock")?.setAttribute("hidden", "true");391392    // Check for pending session-storage notices from the background.393    if (chrome.storage.session) {394      const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as395        Record<string, { feature?: string } | undefined>;396      if (session.pendingProGate?.feature) {397        await chrome.storage.session.remove("pendingProGate");398        setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);399        setSettingsInitialSection("license");400        setView("settings");401      } else if (session.pendingProNotice?.feature) {402        await chrome.storage.session.remove("pendingProNotice");403        setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });404      }405    }406407    // Warm the lazy-module cache so the Suspense fallback never shows for408    // locally-bundled extension views. Without this, the first navigation to409    // Settings/Import/Export/etc can briefly mount `.peach-loading`, which410    // covers the shell and absorbs clicks on the header.411    void Promise.all([412      import("./views/Settings").catch(() => {}),413      import("./views/Import").catch(() => {}),414      import("./views/Export").catch(() => {}),415      import("./views/Health").catch(() => {}),416      import("./views/TrashView").catch(() => {}),417    ]);418419    // Lock-on-close via a persistent port. sendMessage in pagehide is420    // unreliable — the popup context tears down before the message lands.421    // The SW's onConnect listener locks when this port disconnects.422    let popupPort: chrome.runtime.Port | null = null;423    try {424      popupPort = chrome.runtime.connect({ name: "popup" });425    } catch { /* extension context invalidated — ignore */ }426    onCleanup(() => { try { popupPort?.disconnect(); } catch {} });427428    // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale429    // entries instead of waiting for the next GET_ENTRIES round-trip.430    const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {431      if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();432      if (msg?.type === "PRO_GATE_TRIGGERED") {433        // Background never navigates UI directly — it asks; the popup decides.434        // Land on Settings → License with the triggering feature highlighted.435        setUpgradeHighlight(msg.payload?.feature ?? null);436        setSettingsInitialSection("license");437        setView("settings");438      }439    };440    chrome.runtime.onMessage.addListener(handleRuntimeMessage);441    onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));442443    // Action handlers — keyed by ShortcutAction. The dispatcher below tests444    // each binding against the event and runs the matching handler. New445    // shortcuts only need an entry here; the binding is data, not code.446    const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {447      copy_password: () => {448        const entry = selectedEntry();449        if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });450      },451      copy_username: () => {452        const entry = selectedEntry();453        if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });454      },455      copy_totp: () => {456        const entry = selectedEntry();457        if (!entry?.totp) return;458        try {459          const { code } = generateTOTP(entry.totp);460          void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });461        } catch { /* invalid TOTP secret — silent */ }462      },463      new_entry: () => handleAdd(),464      edit_entry: () => {465        const entry = selectedEntry();466        if (!entry || entry.type === "passkey") return;467        setEditEntry(entry);468        setView("add");469      },470      delete_entry: () => {471        if (selectedEntry()) void handleDelete();472      },473      lock_vault: () => { void handleHeaderLock(); },474      search: () => openList(),475      nav_prev: () => navigate(-1),476      nav_next: () => navigate(1),477      jump_vault: () => setView("entry"),478      jump_settings: () => setView("settings"),479      show_help: () => setShowShortcutsHelp(true),480    };481482    // Actions that are MEANINGFUL only on the entry-detail view. Outside483    // that scope the binding is dormant — pressing C while on Settings484    // does nothing rather than awkwardly copying from a now-stale entry.485    const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([486      "copy_password", "copy_username", "copy_totp",487      "edit_entry", "delete_entry", "nav_prev", "nav_next",488    ]);489490    const handleGlobalKeydown = (e: KeyboardEvent) => {491      const v = view();492      const inInput = (e.target as HTMLElement)?.tagName === "INPUT"493        || (e.target as HTMLElement)?.tagName === "TEXTAREA"494        || (e.target as HTMLElement)?.isContentEditable;495      const isPrintableSearchKey =496        e.key.length === 1 &&497        !e.ctrlKey &&498        !e.metaKey &&499        !e.altKey;500501      // No input during the pro celebration sequence502      if (v === "pro-celebration") return;503504      // Help dialog: Escape closes it; eat the keypress so it doesn't505      // also navigate away from the underlying view.506      if (showShortcutsHelp()) {507        if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }508        return;509      }510511      // ── Escape — fixed binding (not user-customizable) ───────512      if (e.key === "Escape") {513        if (v === "list") { setView("entry"); return; }514        if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }515      }516517      // Bindings-driven dispatch. Single-key letter bindings without518      // modifiers (c/u/t/n/e/d/?) are silently swallowed when focus is519      // inside an input, so users can type into search/forms normally.520      const bindings = shortcutBindings();521      for (const action of Object.keys(bindings) as ShortcutAction[]) {522        const binding = bindings[action];523        if (!matchKeybinding(e, binding)) continue;524        // Single-key letter bindings need the input-focus guard. Bindings525        // with any modifier are safe — Ctrl+L doesn't conflict with typing.526        const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;527        if (!hasMod && binding.key.length === 1 && inInput) return;528        if (ENTRY_SCOPED.has(action) && v !== "entry") return;529        e.preventDefault();530        SHORTCUT_HANDLERS[action]?.();531        return;532      }533534      // Type-to-search: in entry view, any printable key not consumed by a535      // shortcut above primes the list search seed and opens the list.536      // Kept outside the bindings table because it's a behavior, not a537      // discrete action — every printable character can trigger it.538      if (v === "entry" && !inInput && isPrintableSearchKey) {539        e.preventDefault();540        primeListSearch(e.key);541        return;542      }543    };544    window.addEventListener("keydown", handleGlobalKeydown);545    onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));546547    const handleStorageChanged = (548      changes: Record<string, chrome.storage.StorageChange>,549      areaName: string550    ) => {551      if (areaName !== "session") return;552      if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {553        return;554      }555      void refreshPendingPasskey();556    };557558    chrome.storage.onChanged.addListener(handleStorageChanged);559    onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));560  });561562  async function refreshCurrentTab() {563    try {564      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });565      const url = tab?.url ?? tab?.pendingUrl ?? "";566      setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");567    } catch {568      setCurrentTabDomain("");569    }570  }571572  async function refreshPendingPasskey() {573    const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });574    setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);575  }576577  async function refreshProStatus() {578    const res = await sendMessage({ type: "GET_LICENSE" });579    const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;580    setProStatus(Boolean(data?.valid && data?.plan === "pro"));581  }582583  async function restoreSettings() {584    const res = await sendMessage({ type: "GET_SETTINGS" });585    const s = (res as { ok: boolean; data?: any }).data ?? {};586    if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {587      setAccentColor(s.accentColor as AccentColorName);588    }589    setTheme(s.theme === "light" ? "light" : "dark");590    if (s.displayFont === "cormorant" || s.displayFont === "inter" || s.displayFont === "system") {591      setDisplayFont(s.displayFont);592    } else {593      setDisplayFont("cormorant-italic");594    }595    if (s.textSize === "small" || s.textSize === "large") {596      setTextSize(s.textSize);597    } else {598      setTextSize("medium");599    }600    setReduceMotion(s.reduceMotion === true);601    setLockOnClose(s.lockOnClose !== false);602    setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);603    setBreachMonitoring(s.breachMonitoring !== false);604    setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);605    setShortcutBindings(getActiveBindings(s.shortcuts));606  }607608  async function applyTrashRetention(days: number) {609    setTrashRetentionDays(days);610    await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });611  }612613  function primeListSearch(seed: string) {614    setListSearchSeed(seed);615    setListSearchSeedNonce((value) => value + 1);616    openList();617  }618619  // Returns null when it detected a dead service worker and triggered handleLock().620  // Callers that need to abort (e.g. openVault) check for null rather than relying621  // on view() state, which may already be "lock" during normal startup flows.622  async function loadEntries(): Promise<VaultEntry[] | null> {623    const response = await sendMessage({ type: "GET_ENTRIES" });624    const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };625626    // If the service worker was killed and restarted (MV3 workers sleep), the627    // vault will be locked again — or the worker may not respond at all.628    // In either case, send the user to the lock screen rather than silently629    // showing an empty vault (which looks like data loss).630    if (!res.ok) {631      if (632        res.error === "vault locked" ||633        res.error === "SERVICE_WORKER_UNAVAILABLE" ||634        (typeof res.error === "string" && res.error.includes("locked"))635      ) {636        handleLock();637        return null; // signal emergency lock to callers638      }639      return [];640    }641642    const loadedEntries = res.data ?? [];643    setEntries(loadedEntries);644645    const currentId = selectedEntry()?.id;646    if (currentId) {647      setSelectedEntry(648        loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null649      );650    }651652    return loadedEntries;653  }654655  async function openVault(preferredEntryId?: string, skipViewSet = false) {656    // Purge expired trash entries on each vault open657    const retDays = trashRetentionDays();658    if (retDays > 0) {659      void sendMessage({660        type: "PURGE_EXPIRED_TRASH",661        payload: { retentionMs: retDays * 86_400_000 },662      });663    }664665    const loadedEntries = await loadEntries();666667    // null means loadEntries() triggered an emergency lock (service worker died).668    // Don't override it by setting view back to "entry" with an empty vault.669    if (loadedEntries === null) return;670671    // Smart selection: prefer explicitly requested ID, then a domain match, then first entry672    let nextSelectedEntry: VaultEntry | null = null;673    if (preferredEntryId) {674      nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;675    }676    if (!nextSelectedEntry) {677      const tabDomain = currentTabDomain();678      if (tabDomain) {679        nextSelectedEntry = loadedEntries.find((e) => {680          if (e.type === "login") {681            if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;682            for (const extra of e.additionalUrls ?? []) {683              if ((extractDomain(extra) ?? "") === tabDomain) return true;684            }685            return false;686          }687          if (e.type === "passkey" && e.rpId) return e.rpId === tabDomain;688          return false;689        }) ?? null;690      }691    }692    nextSelectedEntry ??= loadedEntries[0] ?? null;693694    setSelectedEntry(nextSelectedEntry);695    setEditEntry(undefined);696    setContentTransitioning(false);697    setContentPhase("enter");698    queueContentIdle();699    if (!skipViewSet) setView("entry");700  }701702  async function handleUnlock() {703    setHasVault(true);704    triggerPipPulse();705    await restoreSettings();706    const celebrating = pendingCelebration();707    if (celebrating) {708      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });709      setPendingCelebration(false);710    }711    await openVault(undefined, celebrating);712    if (celebrating) {713      setView("pro-celebration");714      window.setTimeout(() => setView("entry"), 2500);715    }716  }717718  function handleSelectEntry(entry: VaultEntry) {719    setSelectedEntry(entry);720    setContentTransitioning(false);721    setContentPhase("enter");722    queueContentIdle();723    setView("entry");724  }725726  function handleAdd() {727    setEditEntry(undefined);728    setView("add");729  }730731  async function handleDelete() {732    const entry = selectedEntry();733    if (!entry) return;734    await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });735    const loadedEntries = await loadEntries() ?? [];736    setSelectedEntry(loadedEntries[0] ?? null);737    setView("entry");738  }739740  async function handleDeleteMany(ids: string[]) {741    for (const id of ids) {742      await sendMessage({ type: "DELETE_ENTRY", payload: { id } });743    }744    const loadedEntries = await loadEntries() ?? [];745    // If the currently selected entry was among the deleted, move to first746    const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);747    if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);748  }749750  async function handleSave(savedEntry?: VaultEntry) {751    await openVault(savedEntry?.id);752  }753754  function handleBack() {755    setEditEntry(undefined);756    setView("entry");757  }758759  function handleLock() {760    clearCanarySuppressions();761    setEntries([]);762    setSelectedEntry(null);763    setEditEntry(undefined);764    setContentPhase("idle");765    setView("lock");766  }767768  function handleHeaderLock() {769    // Lock the UI immediately — do not wait for the service worker. If the SW770    // is dead or slow, the 5s sendMessage timeout would create a dead-click771    // window. The SW will lock on its next wake regardless.772    handleLock();773    void sendMessage({ type: "LOCK" });774  }775776  function navigate(dir: number) {777    const allEntries = entries();778    if (allEntries.length === 0) return;779    const currentIndex = selectedIndex();780    const baseIndex = currentIndex >= 0 ? currentIndex : 0;781    const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;782    setSelectedEntry(allEntries[nextIndex]);783    setContentPhase("enter");784    queueContentIdle();785  }786787  function openList() {788    setView("list");789  }790791  function openHealth() {792    setEditEntry(undefined);793    setView("health");794  }795796  function handleOpenFileImport() {797    setRestoreView("file");798  }799800  const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;801802  function handleFixEntry(entry: VaultEntry) {803    if (entry.type === "passkey") {804      setSelectedEntry(entry);805      setView("entry");806      return;807    }808    setEditEntry(entry);809    setView("add");810  }811812  const isLockedView = () => view() === "lock";813  const selectedPasskeyEntry = createMemo(() =>814    selectedEntry()?.type === "passkey" ? (selectedEntry() as PasskeyEntry) : null815  );816817  async function handleApprovePendingPasskey() {818    const pending = pendingPasskey();819    if (!pending) return;820    await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });821    setPendingPasskey(null);822  }823824  async function handleDenyPendingPasskey() {825    const pending = pendingPasskey();826    if (!pending) return;827    await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });828    setPendingPasskey(null);829  }830831  function queueContentIdle(after?: () => void) {832    if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);833    contentPhaseTimer = window.setTimeout(() => {834      setContentPhase("idle");835      after?.();836    }, ENTRY_TRANSITION_SETTLE_MS);837  }838839  return (840    <div class="popup-root peach-theme">841      <Show when={bootState() === "ready"}>842        <Show843          when={!pendingPasskey()}844          fallback={845            <PasskeyApprovalView846              type={pendingPasskey()!.type}847              rpName={pendingPasskey()!.rpName}848              rpId={pendingPasskey()!.rpId}849              userName={pendingPasskey()!.userName}850              userDisplayName={pendingPasskey()!.userDisplayName}851              onApprove={handleApprovePendingPasskey}852              onDeny={handleDenyPendingPasskey}853            />854          }855        >856          <Show857            when={!isLockedView()}858            fallback={859              <Show860                when={onboarded() || hasVault()}861                fallback={862                  <Onboarding863                    onComplete={() => setOnboarded(true)}864                  />865                }866              >867                <LockView868                  hasVault={hasVault()}869                  ready={bootState() === "ready"}870                  isPro={proStatus()}871                  onUnlock={handleUnlock}872                />873              </Show>874            }875          >876            <Show877              when={view() !== "pro-celebration"}878              fallback={<ProCelebration />}879            >880            <section881              id="hd-shell"882              class="peach-shell peach-theme"883              data-surface="extension"884            >885            <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>886              <PeachWordmark unlocked />887              <div class="peach-chrome-actions">888                <button889                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"890                  onClick={() => setView("settings")}891                  aria-label="Settings"892                >893                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">894                    <path895                      d="M5.91 1.11C6.38-.02 7.62-.02 8.09 1.11L8.31 1.64C8.48 2.05 8.91 2.27 9.34 2.17L9.9 2.04C11.1 1.76 11.98 2.64 11.7 3.84L11.57 4.4C11.47 4.83 11.69 5.26 12.1 5.43L12.63 5.65C13.76 6.12 13.76 7.36 12.63 7.83L12.1 8.05C11.69 8.22 11.47 8.65 11.57 9.08L11.7 9.64C11.98 10.84 11.1 11.72 9.9 11.44L9.34 11.31C8.91 11.21 8.48 11.43 8.31 11.84L8.09 12.37C7.62 13.5 6.38 13.5 5.91 12.37L5.69 11.84C5.52 11.43 5.09 11.21 4.66 11.31L4.1 11.44C2.9 11.72 2.02 10.84 2.3 9.64L2.43 9.08C2.53 8.65 2.31 8.22 1.9 8.05L1.37 7.83C.24 7.36.24 6.12 1.37 5.65L1.9 5.43C2.31 5.26 2.53 4.83 2.43 4.4L2.3 3.84C2.02 2.64 2.9 1.76 4.1 2.04L4.66 2.17C5.09 2.27 5.52 2.05 5.69 1.64L5.91 1.11Z"896                      stroke="currentColor"897                      stroke-width="0.9"898                    />899                    <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />900                  </svg>901                </button>902                <button903                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"904                  onClick={handleAdd}905                  aria-label="New entry"906                >907                  <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">908                    <path d="M5.5 1v9M1 5.5h9" />909                  </svg>910                </button>911                <button912                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"913                  onClick={() => void handleHeaderLock()}914                  aria-label="Lock vault"915                >916                  <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">917                    <path918                      d="M3.75 6V4.75C3.75 2.96 5.21 1.5 7 1.5C8.79 1.5 10.25 2.96 10.25 4.75V6"919                      stroke="currentColor"920                      stroke-width="1"921                      stroke-linecap="round"922                    />923                    <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />924                    <circle cx="7" cy="10.25" r="1" fill="currentColor" />925                  </svg>926                </button>927              </div>928            </header>929930            <Show when={pendingProNotice() !== null}>931              <div class="peach-pro-notice-banner">932                <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>933                <button934                  class="peach-pro-notice-link"935                  onClick={() => {936                    setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);937                    setSettingsInitialSection("license");938                    setView("settings");939                    setPendingProNotice(null);940                  }}941                >942                  Learn more →943                </button>944                <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>945              </div>946            </Show>947948            <div class="peach-shell-body">949              <div class="peach-shell-veil"></div>950              <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>951                <Show952                  when={selectedPasskeyEntry()}953                  fallback={954                    <EntryView955                      entry={selectedEntry()}956                      onDelete={handleDelete}957                      onFieldSaved={() => void loadEntries()}958                      onEscape={() => { setView("list"); }}959                    />960                  }961                >962                  <PasskeyEntryView entry={selectedPasskeyEntry()} onDelete={handleDelete} />963                </Show>964              </div>965966              <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>967                <div class="peach-search-bar">968                  <span class="peach-search-icon">969                    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">970                      <circle cx="5.5" cy="5.5" r="3.75" />971                      <path d="M8.75 8.75L11.5 11.5" />972                    </svg>973                  </span>974                  <input975                    class="peach-search-input"976                    placeholder="search vault..."977                    autocomplete="off"978                    aria-label="Search vault entries"979                    onClick={openList}980                    onFocus={openList}981                    readOnly982                  />983                  <div class="peach-search-meta">984                    <span class="peach-search-counter">985                      {entries().length > 0986                        ? `${selectedIndex() + 1} / ${entries().length}`987                        : "0 entries"}988                    </span>989                    <span class="peach-divider"></span>990                    <button991                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"992                      onClick={() => navigate(-1)}993                      aria-label="Previous entry"994                      disabled={entries().length === 0}995                    >996                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">997                        <path d="M5 1L1 5.5L5 10" />998                      </svg>999                    </button>1000                    <button1001                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1002                      onClick={() => navigate(1)}1003                      aria-label="Next entry"1004                      disabled={entries().length === 0}1005                    >1006                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1007                        <path d="M1 1L5 5.5L1 10" />1008                      </svg>1009                    </button>1010                  </div>1011                </div>1012              </section>1013            </div>10141015            <Show when={view() === "list"}>1016              <VaultView1017                entries={entries()}1018                selectedEntryId={selectedEntry()?.id}1019                currentTabDomain={currentTabDomain()}1020                sortOrder={sortOrder()}1021                searchSeed={listSearchSeed()}1022                searchSeedNonce={listSearchSeedNonce()}1023                onSelectEntry={handleSelectEntry}1024                onSortOrderChange={setSortOrder}1025                onAdd={handleAdd}1026                onClose={() => setView("entry")}1027                onOpenTrash={() => setView("trash")}1028                onDeleteMany={handleDeleteMany}1029              />1030            </Show>1031            <Show when={view() === "settings"}>1032              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1033                <SettingsView1034                  entriesCount={entries().length}1035                  breachMonitoring={breachMonitoring()}1036                  lockOnClose={lockOnClose()}1037                  autoLockMinutes={autoLockMinutes()}1038                  trashRetentionDays={trashRetentionDays()}1039                  theme={theme()}1040                  accentColor={accentColor()}1041                  displayFont={displayFont()}1042                  textSize={textSize()}1043                  reduceMotion={reduceMotion()}1044                  isPro={proStatus()}1045                  initialSection={settingsInitialSection()}1046                  upgradeHighlight={upgradeHighlight()}1047                  onBreachMonitoringChange={applyBreachMonitoring}1048                  onLockOnCloseChange={applyLockOnClose}1049                  onAutoLockMinutesChange={applyAutoLockMinutes}1050                  onTrashRetentionChange={applyTrashRetention}1051                  onThemeChange={applyTheme}1052                  onAccentChange={applyAccentColor}1053                  onDisplayFontChange={applyDisplayFont}1054                  onTextSizeChange={applyTextSize}1055                  onReduceMotionChange={applyReduceMotion}1056                  shortcuts={shortcutBindings()}1057                  onShortcutsChange={applyShortcuts}1058                  onLicenseChange={refreshProStatus}1059                  onBack={() => { setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }}1060                  onImport={() => setView("import")}1061                  onExport={() => setView("export")}1062                  onBreachCheckDone={loadEntries}1063                  onOpenHealth={openHealth}1064                  onOpenFileImport={handleOpenFileImport}1065                />1066              </Suspense>1067            </Show>1068            <Show when={view() === "add"}>1069              <AddView1070                entry={editEntry()}1071                entries={entries()}1072                onBack={handleBack}1073                onSave={handleSave}1074              />1075            </Show>1076            <Show when={view() === "import"}>1077              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1078                <ImportView onBack={handleBack} onImport={handleSave} />1079              </Suspense>1080            </Show>1081            <Show when={view() === "export"}>1082              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1083                <ExportView onBack={handleBack} />1084              </Suspense>1085            </Show>1086            <Show when={view() === "health"}>1087              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1088                <HealthView1089                  entries={entries()}1090                  isPro={proStatus()}1091                  onFixEntry={handleFixEntry}1092                  onBack={() => setView("settings")}1093                  onRefresh={loadEntries}1094                />1095              </Suspense>1096            </Show>1097            <Show when={view() === "trash"}>1098              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1099                <TrashView1100                  onBack={() => setView("list")}1101                  onRestored={() => void loadEntries()}1102                />1103              </Suspense>1104            </Show>1105            <Show when={restoreView() === "file"}>1106              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1107                <ImportView1108                  mode="onboarding"1109                  replacesExistingVault={true}1110                  onBack={() => setRestoreView(null)}1111                  onImport={async () => {1112                    setRestoreView(null);1113                    await handleUnlock();1114                  }}1115                />1116              </Suspense>1117            </Show>1118            </section>1119            </Show>1120          </Show>1121        </Show>1122      </Show>1123      <Show when={showShortcutsHelp()}>1124        <ShortcutsHelpOverlay1125          bindings={shortcutBindings()}1126          onClose={() => setShowShortcutsHelp(false)}1127        />1128      </Show>1129    </div>1130  );1131}11321133function ShortcutsHelpOverlay(props: {1134  bindings: Record<ShortcutAction, Keybinding>;1135  onClose: () => void;1136}) {1137  return (1138    <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1139      <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1140        <div class="peach-shortcuts-help-head">1141          <h3>Keyboard shortcuts</h3>1142          <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1143        </div>1144        <div class="peach-shortcuts-help-body">1145          <p class="peach-settings-sub-label">ENTRY VIEW</p>1146          <ul>1147            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1148              <li>1149                <span>{d.label}</span>1150                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1151              </li>1152            ))}1153          </ul>1154          <p class="peach-settings-sub-label">ANYWHERE</p>1155          <ul>1156            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1157              <li>1158                <span>{d.label}</span>1159                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1160              </li>1161            ))}1162          </ul>1163        </div>1164      </div>1165    </div>1166  );1167}