import { createEffect, createMemo, createSignal, lazy, onCleanup, onMount, Show, Suspense } from "solid-js"; import type { PasskeyEntry, PendingPasskeyRequest, VaultEntry } from "../shared/types"; import { LockView } from "./views/Lock"; 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 { PasskeyEntryView } from "./views/PasskeyEntry"; import { extractDomain } from "../shared/domain"; const ImportView = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView }))); const ExportView = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView }))); const CJKExport = lazy(() => import("./views/CJKExport").then(m => ({ default: m.CJKExportView }))); const CJKImport = lazy(() => import("./views/CJKImport").then(m => ({ default: m.CJKImportView }))); 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 }))); type View = | "lock" | "entry" | "list" | "add" | "settings" | "import" | "export" | "health" | "trash"; type BootState = "loading" | "ready"; const canarySuppressMap = new Map(); let shellRingAnimation: Animation | null = null; let totpRingResetTimer: number | null = null; export function isCanarySuppressed(entryId: string): boolean { return canarySuppressMap.get(entryId) ?? false; } export function setCanarySuppressed(entryId: string, suppressed: boolean): void { canarySuppressMap.set(entryId, suppressed); } export function clearCanarySuppressions(): void { canarySuppressMap.clear(); } 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 rgba(217,104,48,0.58), 0 0 22px rgba(217,104,48,0.18)", }, { 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 triggerTotpRing() { const shell = document.getElementById("hd-shell"); if (!shell) return; shell.classList.remove("peach-shell--totp-ring"); void shell.offsetWidth; shell.classList.add("peach-shell--totp-ring"); if (totpRingResetTimer !== null) { window.clearTimeout(totpRingResetTimer); } totpRingResetTimer = window.setTimeout(() => { shell.classList.remove("peach-shell--totp-ring"); totpRingResetTimer = null; }, 920); } 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"); } } function humanizeError(error: string | undefined): string | undefined { if (!error) return error; if (error === "STORAGE_QUOTA_EXCEEDED") { return "Peach has run out of browser storage. Delete some entries or clear browser storage to continue."; } return error; } async function sendMessage(msg: any): Promise { const MESSAGE_TIMEOUT_MS = 5000; return new Promise((resolve) => { let settled = false; const timeoutId = window.setTimeout(() => { if (settled) return; settled = true; resolve({ ok: false, error: "SERVICE_WORKER_UNAVAILABLE" }); }, MESSAGE_TIMEOUT_MS); try { chrome.runtime.sendMessage(msg, (response) => { if (settled) return; settled = true; window.clearTimeout(timeoutId); if (chrome.runtime.lastError) { resolve({ ok: false, error: chrome.runtime.lastError.message ?? "SERVICE_WORKER_UNAVAILABLE", }); return; } if (response === undefined) { resolve({ ok: false, error: "SERVICE_WORKER_UNAVAILABLE" }); return; } if (response && response.ok === false) { response.error = humanizeError(response.error); } resolve(response); }); } catch (error) { if (settled) return; settled = true; window.clearTimeout(timeoutId); resolve({ ok: false, error: error instanceof Error ? error.message : "SERVICE_WORKER_UNAVAILABLE", }); } }); } export { sendMessage }; const ENTRY_TRANSITION_EXIT_MS = 95; 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" | "exit" | "idle">( "enter" ); const [contentTransitioning, setContentTransitioning] = createSignal(false); const [hasVault, setHasVault] = createSignal(false); const [entries, setEntries] = createSignal([]); const [selectedEntry, setSelectedEntry] = createSignal(null); const [editEntry, setEditEntry] = createSignal(undefined); const [accentColor, setAccentColor] = createSignal("#D96830"); const [theme, setTheme] = createSignal<"dark" | "light">("dark"); const [lockOnClose, setLockOnClose] = createSignal(true); const [autoLockMinutes, setAutoLockMinutes] = createSignal(5); const [breachMonitoring, setBreachMonitoring] = createSignal(true); const [trashRetentionDays, setTrashRetentionDays] = createSignal(30); const [restoreView, setRestoreView] = createSignal<"cjk" | "file" | null>(null); const [pendingPasskey, setPendingPasskey] = createSignal(null); const [currentTabDomain, setCurrentTabDomain] = createSignal(""); const [sortOrder, setSortOrder] = createSignal("url-first"); let contentPhaseTimer: number | undefined; createEffect(() => { document.documentElement.style.setProperty("--ac", accentColor()); }); createEffect(() => { document.documentElement.classList.toggle("light", theme() === "light"); document.body.classList.toggle("light", theme() === "light"); }); const selectedIndex = createMemo(() => { const currentId = selectedEntry()?.id; if (!currentId) return entries().length > 0 ? 0 : -1; return entries().findIndex((entry) => entry.id === currentId); }); async function applyTheme(t: "dark" | "light") { setTheme(t); await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } }); } 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 }, }); } onMount(async () => { const [unlockResponse, vaultResponse] = await Promise.all([ sendMessage({ type: "IS_UNLOCKED" }), sendMessage({ type: "HAS_VAULT" }), ]); const unlocked = Boolean( (unlockResponse as { ok: boolean; data?: boolean }).data ); const vaultExists = Boolean( (vaultResponse as { ok: boolean; data?: boolean }).data ); setHasVault(vaultExists); await restoreSettings(); if (unlocked && vaultExists) { await openVault(); } await refreshPendingPasskey(); await refreshCurrentTab(); // 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"); document.getElementById("pre-lock")?.setAttribute("hidden", "true"); // 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(() => {}), import("./views/CJKExport").catch(() => {}), import("./views/CJKImport").catch(() => {}), ]); const handlePageHide = () => { if (!lockOnClose() || view() === "lock") return; chrome.runtime.sendMessage({ type: "LOCK" }); }; window.addEventListener("pagehide", handlePageHide); onCleanup(() => window.removeEventListener("pagehide", handlePageHide)); // 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 }) => { if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock(); }; chrome.runtime.onMessage.addListener(handleRuntimeMessage); onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage)); 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; // ── Escape ──────────────────────────────────────────────── if (e.key === "Escape") { if (v === "list") { setView("entry"); return; } if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; } } // ── Focus search ───────────────────────────────────────── if ((e.key === "/" || (e.ctrlKey && e.key === "f")) && !inInput) { e.preventDefault(); openList(); return; } // ── Lock ────────────────────────────────────────────────── if (e.ctrlKey && e.key === "l") { e.preventDefault(); void handleHeaderLock(); return; } // ── Entry-view shortcuts (not in input fields) ──────────── if (v === "entry" && !inInput) { // c → copy password, u → copy username if (e.key === "c") { e.preventDefault(); const entry = selectedEntry(); if (entry?.password) { void sendMessage({ type: "COPY", payload: { text: entry.password, label: "Password" } }) .then(() => triggerRing()); } return; } if (e.key === "u") { e.preventDefault(); const entry = selectedEntry(); if (entry?.username) { void sendMessage({ type: "COPY", payload: { text: entry.username, label: "Username" } }) .then(() => triggerRing()); } return; } // n → new entry if (e.key === "n") { e.preventDefault(); handleAdd(); return; } // Arrow keys → navigate entries if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); navigate(-1); return; } if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); navigate(1); 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) ?? "") : ""); } catch { setCurrentTabDomain(""); } } async function refreshPendingPasskey() { const res = await sendMessage({ type: "GET_PENDING_PASSKEY" }); setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null); } async function restoreSettings() { const res = await sendMessage({ type: "GET_SETTINGS" }); const s = (res as { ok: boolean; data?: any }).data ?? {}; if (s.accentColor) setAccentColor(s.accentColor); setTheme(s.theme === "light" ? "light" : "dark"); setLockOnClose(s.lockOnClose !== false); setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5); setBreachMonitoring(s.breachMonitoring !== false); setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30); } async function applyTrashRetention(days: number) { setTrashRetentionDays(days); await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } }); } // 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) ?? loadedEntries[0] ?? null ); } return loadedEntries; } async function openVault(preferredEntryId?: string) { // 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; // 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" && e.url) return (extractDomain(e.url) ?? "") === tabDomain; if (e.type === "passkey" && e.rpId) return e.rpId === tabDomain; return false; }) ?? null; } } nextSelectedEntry ??= loadedEntries[0] ?? null; setSelectedEntry(nextSelectedEntry); setEditEntry(undefined); setContentTransitioning(false); setContentPhase("enter"); queueContentIdle(); setView("entry"); } async function handleUnlock() { setHasVault(true); triggerPipPulse(); await restoreSettings(); await openVault(); } function handleSelectEntry(entry: VaultEntry) { setSelectedEntry(entry); setContentTransitioning(false); 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(loadedEntries[0] ?? null); 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(loadedEntries[0] ?? null); } async function handleSave(savedEntry?: VaultEntry) { await openVault(savedEntry?.id); } function handleBack() { setEditEntry(undefined); setView("entry"); } function handleLock() { clearCanarySuppressions(); 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" }); } function navigate(dir: number) { const allEntries = entries(); if (allEntries.length === 0) return; const currentIndex = selectedIndex(); const baseIndex = currentIndex >= 0 ? currentIndex : 0; const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length; setSelectedEntry(allEntries[nextIndex]); setContentPhase("enter"); queueContentIdle(); } function openList() { setView("list"); } function openHealth() { setEditEntry(undefined); setView("health"); } function handleOpenCJKImport() { setRestoreView("cjk"); } function handleOpenFileImport() { setRestoreView("file"); } const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null; function handleFixEntry(entry: VaultEntry) { if (entry.type === "passkey") { setSelectedEntry(entry); setView("entry"); return; } setEditEntry(entry); setView("add"); } const isLockedView = () => view() === "lock"; const selectedPasskeyEntry = createMemo(() => selectedEntry()?.type === "passkey" ? (selectedEntry() as PasskeyEntry) : null ); async function handleApprovePendingPasskey() { const pending = pendingPasskey(); if (!pending) return; await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } }); setPendingPasskey(null); } async function handleDenyPendingPasskey() { const pending = pendingPasskey(); if (!pending) return; await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } }); setPendingPasskey(null); } function queueContentIdle(after?: () => void) { if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer); contentPhaseTimer = window.setTimeout(() => { setContentPhase("idle"); after?.(); }, ENTRY_TRANSITION_SETTLE_MS); } return ( }> setView("import")} onExport={() => setView("export")} onBreachCheckDone={loadEntries} onOpenHealth={openHealth} onOpenCJKImport={handleOpenCJKImport} onOpenFileImport={handleOpenFileImport} /> }> }> }> setView("settings")} onRefresh={loadEntries} /> }> setView("list")} onRestored={() => void loadEntries()} /> setRestoreView(null)} onDone={async () => { setRestoreView(null); await handleUnlock(); }} /> }> setRestoreView(null)} onImport={async () => { setRestoreView(null); await handleUnlock(); }} /> ); }