1import { createEffect, createMemo, createSignal, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";2import { App as CapacitorApp } from "@capacitor/app";3import { Capacitor, registerPlugin } from "@capacitor/core";4import { Keyboard } from "@capacitor/keyboard";5import { StatusBar, Style } from "@capacitor/status-bar";6import { storageGet, storageSet } from "../shared/storage";7import { sendMessage } from "../shared/bridge";8import { clearInMemoryVault, publishInMemoryVault } from "../shared/in-memory-vault";9import { ACCENT_COLORS } from "../shared/types";10import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";11import { isPasskeyOnly } from "../shared/types";12import { LockView } from "./views/Lock";13import { Onboarding } from "./views/Onboarding";14import { ProCelebration } from "./views/ProCelebration";15import { VaultView, type SortOrder } from "./views/Vault";16import { EntryView } from "./views/Entry";17import { AddView } from "./views/Add";18import { PeachWordmark } from "./components/PeachMark";19import { PasskeyApprovalView } from "./views/PasskeyApproval";20import { extractDomain } from "../shared/domain";21import { popupCopy } from "./copy";22import {23 DEFAULT_BINDINGS,24 formatKeybinding,25 getActiveBindings,26 matchKeybinding,27 SHORTCUT_DESCRIPTORS,28 type Keybinding,29 type ShortcutAction,30} from "../shared/shortcuts";31import { generateTOTP } from "../crypto/totp";3233export { sendMessage } from "../shared/bridge";3435const ImportView = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));36const ExportView = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));37const SettingsView = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));38const HealthView = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));39const TrashView = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));40const PanicView = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));4142const Autofill = registerPlugin<{43 checkPendingSave(): Promise<{ pending: string | null }>;44 openAutofillSettings(): Promise<void>;45 isEnabledAsAutofillService(): Promise<{ enabled: boolean }>;46}>("Autofill");4748type View =49 | "lock"50 | "pro-celebration"51 | "entry"52 | "list"53 | "add"54 | "settings"55 | "import"56 | "export"57 | "health"58 | "trash"59 | "panic";60type BootState = "loading" | "ready";6162const canarySuppressMap = new Map<string, boolean>();63let shellRingAnimation: Animation | null = null;6465export function isCanarySuppressed(entryId: string): boolean {66 return canarySuppressMap.get(entryId) ?? false;67}6869export function setCanarySuppressed(entryId: string, suppressed: boolean): void {70 canarySuppressMap.set(entryId, suppressed);71}7273export function clearCanarySuppressions(): void {74 canarySuppressMap.clear();75}7677export function triggerRing() {78 const shell = document.getElementById("hd-shell");79 if (!shell) return;8081 shellRingAnimation?.cancel();82 shell.dataset.glowing = "true";83 const animation = shell.animate(84 [85 { offset: 0, boxShadow: "var(--copy-shadow)" },86 {87 offset: 0.3,88 boxShadow:89 "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)",90 },91 { offset: 1, boxShadow: "var(--copy-shadow)" },92 ],93 {94 duration: 720,95 easing: "cubic-bezier(0.4, 0, 0.2, 1)",96 fill: "none",97 }98 );99 shellRingAnimation = animation;100 const clearGlow = () => {101 if (shellRingAnimation !== animation) return;102 shellRingAnimation = null;103 delete shell.dataset.glowing;104 };105 animation.onfinish = clearGlow;106 animation.oncancel = clearGlow;107}108109110export function triggerPipPulse() {111 const pip = document.querySelector(".peach-status-pip, .pip");112 if (pip) {113 pip.classList.remove("pulse");114 void (pip as HTMLElement).offsetWidth;115 pip.classList.add("pulse");116 }117}118119const ENTRY_TRANSITION_EXIT_MS = 95;120const ENTRY_TRANSITION_SETTLE_MS = 135;121122export default function App() {123 const [bootState, setBootState] = createSignal<BootState>("loading");124 const [view, setView] = createSignal<View>("lock");125 const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(126 "enter"127 );128 const [contentTransitioning, setContentTransitioning] = createSignal(false);129 const [hasVault, setHasVault] = createSignal(false);130 const [onboarded, setOnboarded] = createSignal(true);131 const [entries, setEntries] = createSignal<VaultEntry[]>([]);132 const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);133 const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);134 const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");135 const [theme, setTheme] = createSignal<"dark" | "light">("dark");136 const [displayFont, setDisplayFont] = createSignal<"cormorant-italic" | "cormorant" | "inter" | "system">("cormorant-italic");137 const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");138 const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);139 const [lockOnClose, setLockOnClose] = createSignal(true);140 const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);141 const [breachMonitoring, setBreachMonitoring] = createSignal(true);142 const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);143 const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);144 const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);145 const [restoreView, setRestoreView] = createSignal<"file" | null>(null);146 const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);147 const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");148 149 150 151 152 const [proStatus, setProStatus] = createSignal(false);153 const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);154 const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);155 const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");156 const [listSearchSeed, setListSearchSeed] = createSignal("");157 const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);158 const [pendingCelebration, setPendingCelebration] = createSignal(false);159 const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);160 const [pendingAutofillSave, setPendingAutofillSave] = createSignal<{161 domain: string;162 username: string;163 password: string;164 } | null>(null);165 const [backExitNotice, setBackExitNotice] = createSignal(false);166 167 168 169 const [returnToPanic, setReturnToPanic] = createSignal(false);170171 let contentPhaseTimer: number | undefined;172 let lastBackExitAt = 0;173174 function applyAccent(name: AccentColorName) {175 const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;176 const hex = color.hex;177 document.documentElement.style.setProperty("--peach-accent", hex);178 179 180 document.documentElement.style.setProperty("--peach-glow", hex + "2A");181 document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");182 document.documentElement.style.setProperty("--peach-border-accent", hex + "24");183 }184185 createEffect(() => {186 applyAccent(accentColor());187 });188189 createEffect(() => {190 document.documentElement.classList.toggle("light", theme() === "light");191 document.body.classList.toggle("light", theme() === "light");192 if (Capacitor.isNativePlatform()) {193 void applyMobileStatusBar(theme());194 }195 });196197 createEffect(() => {198 document.documentElement.setAttribute("data-display-font", displayFont());199 });200201 createEffect(() => {202 document.documentElement.setAttribute("data-text-size", textSize());203 });204205 createEffect(() => {206 if (reduceMotion()) {207 document.documentElement.setAttribute("data-motion", "reduce");208 } else {209 document.documentElement.removeAttribute("data-motion");210 }211 });212213 const selectedIndex = createMemo(() => {214 const currentId = selectedEntry()?.id;215 if (!currentId) return entries().length > 0 ? 0 : -1;216 return entries().findIndex((entry) => entry.id === currentId);217 });218219 async function applyAccentColor(name: AccentColorName) {220 applyAccent(name);221 setAccentColor(name);222 await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });223 }224225 async function applyTheme(t: "dark" | "light") {226 setTheme(t);227 await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });228 }229230 async function applyDisplayFont(f: "cormorant-italic" | "cormorant" | "inter" | "system") {231 setDisplayFont(f);232 await sendMessage({ type: "UPDATE_SETTINGS", payload: { displayFont: f } });233 }234235 async function applyTextSize(size: "small" | "medium" | "large") {236 setTextSize(size);237 await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });238 }239240 async function applyReduceMotion(enabled: boolean) {241 setReduceMotion(enabled);242 await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });243 }244245 246 247 248 async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {249 setShortcutBindings(active);250 const overrides: Record<string, Keybinding> = {};251 for (const action of Object.keys(active) as ShortcutAction[]) {252 const a = active[action];253 const d = DEFAULT_BINDINGS[action];254 const same = a.key === d.key255 && Boolean(a.ctrl) === Boolean(d.ctrl)256 && Boolean(a.meta) === Boolean(d.meta)257 && Boolean(a.shift) === Boolean(d.shift)258 && Boolean(a.alt) === Boolean(d.alt);259 if (!same) overrides[action] = a;260 }261 await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });262 }263264 async function applyLockOnClose(enabled: boolean) {265 setLockOnClose(enabled);266 await sendMessage({267 type: "UPDATE_SETTINGS",268 payload: { lockOnClose: enabled },269 });270 }271272 async function applyAutoLockMinutes(minutes: number) {273 setAutoLockMinutes(minutes);274 await sendMessage({275 type: "UPDATE_SETTINGS",276 payload: { autoLockMinutes: minutes },277 });278 }279280 async function applyBreachMonitoring(enabled: boolean) {281 setBreachMonitoring(enabled);282 await sendMessage({283 type: "UPDATE_SETTINGS",284 payload: { breachMonitoring: enabled },285 });286 }287288 async function applyMobileStatusBar(nextTheme: "dark" | "light") {289 try {290 await StatusBar.setStyle({ style: nextTheme === "light" ? Style.Light : Style.Dark });291 await StatusBar.setBackgroundColor({292 color: nextTheme === "light" ? "#EEE3CC" : "#0C0A06",293 });294 } catch {295 296 }297 }298299 function handleBackNavigation() {300 if (showShortcutsHelp()) {301 setShowShortcutsHelp(false);302 return;303 }304 if (restoreView() !== null) {305 setRestoreView(null);306 return;307 }308309 switch (view()) {310 case "list":311 setView("entry");312 return;313 case "settings":314 case "add":315 case "import":316 handleBack();317 return;318 case "export":319 if (returnToPanic()) {320 setReturnToPanic(false);321 setView("panic");322 } else {323 handleBack();324 }325 return;326 case "health":327 case "panic":328 setView("settings");329 return;330 case "trash":331 setView("list");332 return;333 case "entry": {334 const now = Date.now();335 if (now - lastBackExitAt < 1600) {336 void CapacitorApp.exitApp();337 return;338 }339 lastBackExitAt = now;340 setBackExitNotice(true);341 window.setTimeout(() => setBackExitNotice(false), 1400);342 return;343 }344 default:345 void CapacitorApp.exitApp();346 }347 }348349 onMount(async () => {350 if (Capacitor.isNativePlatform()) {351 document.body.classList.add("peach-mobile");352 document.documentElement.classList.add("peach-mobile-root");353 void applyMobileStatusBar(theme());354355 const keyboardShow = await Keyboard.addListener("keyboardWillShow", (info) => {356 document.body.style.paddingBottom = `${info.keyboardHeight}px`;357 });358 const keyboardHide = await Keyboard.addListener("keyboardWillHide", () => {359 document.body.style.paddingBottom = "0";360 });361 const backButton = await CapacitorApp.addListener("backButton", () => {362 handleBackNavigation();363 });364365 let touchStartX = 0;366 let touchStartY = 0;367 const handleTouchStart = (event: TouchEvent) => {368 const touch = event.touches[0];369 if (!touch) return;370 touchStartX = touch.clientX;371 touchStartY = touch.clientY;372 };373 const handleTouchEnd = (event: TouchEvent) => {374 const touch = event.changedTouches[0];375 if (!touch) return;376 const dx = touch.clientX - touchStartX;377 const dy = Math.abs(touch.clientY - touchStartY);378 if (touchStartX < 30 && dx > 60 && dy < 40) {379 handleBackNavigation();380 }381 };382 document.addEventListener("touchstart", handleTouchStart, { passive: true });383 document.addEventListener("touchend", handleTouchEnd, { passive: true });384385 onCleanup(() => {386 void keyboardShow.remove();387 void keyboardHide.remove();388 void backButton.remove();389 document.removeEventListener("touchstart", handleTouchStart);390 document.removeEventListener("touchend", handleTouchEnd);391 document.body.style.paddingBottom = "0";392 });393 }394395 396 397 398 399 400 const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([401 sendMessage({ type: "IS_UNLOCKED" }),402 sendMessage({ type: "HAS_VAULT" }),403 refreshCurrentTab(),404 sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),405 ]);406 const unlocked = Boolean(407 (unlockResponse as { ok: boolean; data?: boolean }).data408 );409 const vaultExists = Boolean(410 (vaultResponse as { ok: boolean; data?: boolean }).data411 );412 const shouldCelebrate = Boolean(413 (celebrationResponse as { ok: boolean; data?: boolean }).data414 );415 setHasVault(vaultExists);416 417 418 419 try {420 const onboarded = await storageGet("peach_onboarded");421 const seen = onboarded === "true";422 if (vaultExists && !seen) {423 await storageSet("peach_onboarded", "true");424 setOnboarded(true);425 } else {426 setOnboarded(seen);427 }428 } catch {429 430 setOnboarded(true);431 }432 await Promise.all([restoreSettings(), refreshProStatus()]);433434 435 if (shouldCelebrate) {436 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });437 }438439 if (unlocked && vaultExists) {440 await openVault(undefined, shouldCelebrate);441 if (shouldCelebrate) {442 setView("pro-celebration");443 window.setTimeout(() => setView("entry"), 2500);444 }445 } else if (shouldCelebrate) {446 setPendingCelebration(true);447 }448449 await refreshPendingPasskey();450451 452 if (!Capacitor.isNativePlatform()) {453 const handleTabActivated = () => { void refreshCurrentTab(); };454 const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {455 if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();456 };457 try {458 chrome.tabs.onActivated.addListener(handleTabActivated);459 chrome.tabs.onUpdated.addListener(handleTabUpdated);460 onCleanup(() => {461 chrome.tabs.onActivated.removeListener(handleTabActivated);462 chrome.tabs.onUpdated.removeListener(handleTabUpdated);463 });464 } catch {465 466 }467 }468469 setBootState("ready");470 document.getElementById("pre-lock")?.setAttribute("hidden", "true");471472 473 if (!Capacitor.isNativePlatform() && chrome.storage.session) {474 const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as475 Record<string, { feature?: string } | undefined>;476 if (session.pendingProGate?.feature) {477 await chrome.storage.session.remove("pendingProGate");478 setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);479 setSettingsInitialSection("license");480 setView("settings");481 } else if (session.pendingProNotice?.feature) {482 await chrome.storage.session.remove("pendingProNotice");483 setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });484 }485 }486487 if (Capacitor.getPlatform() === "android") {488 try {489 const { pending } = await Autofill.checkPendingSave();490 if (pending) {491 const parsed = JSON.parse(pending) as {492 domain?: string;493 username?: string;494 password?: string;495 };496 if (parsed.username && parsed.password) {497 setPendingAutofillSave({498 domain: parsed.domain ?? "",499 username: parsed.username,500 password: parsed.password,501 });502 }503 }504 } catch {505 506 }507 }508509 510 511 512 513 void Promise.all([514 import("./views/Settings").catch(() => {}),515 import("./views/Import").catch(() => {}),516 import("./views/Export").catch(() => {}),517 import("./views/Health").catch(() => {}),518 import("./views/TrashView").catch(() => {}),519 ]);520521 522 523 524 if (!Capacitor.isNativePlatform()) {525 let popupPort: chrome.runtime.Port | null = null;526 try {527 popupPort = chrome.runtime.connect({ name: "popup" });528 } catch { }529 onCleanup(() => { try { popupPort?.disconnect(); } catch {} });530531 532 533 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {534 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();535 if (msg?.type === "PRO_GATE_TRIGGERED") {536 setUpgradeHighlight(msg.payload?.feature ?? null);537 setSettingsInitialSection("license");538 setView("settings");539 }540 };541 chrome.runtime.onMessage.addListener(handleRuntimeMessage);542 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));543 }544545 546 547 548 const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {549 copy_password: () => {550 const entry = selectedEntry();551 if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });552 },553 copy_username: () => {554 const entry = selectedEntry();555 if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });556 },557 copy_totp: () => {558 const entry = selectedEntry();559 if (!entry?.totp) return;560 try {561 const { code } = generateTOTP(entry.totp);562 void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });563 } catch { }564 },565 new_entry: () => handleAdd(),566 edit_entry: () => {567 const entry = selectedEntry();568 569 570 if (!entry || isPasskeyOnly(entry)) return;571 setEditEntry(entry);572 setView("add");573 },574 delete_entry: () => {575 if (selectedEntry()) void handleDelete();576 },577 lock_vault: () => { void handleHeaderLock(); },578 search: () => openList(),579 nav_prev: () => navigate(-1),580 nav_next: () => navigate(1),581 jump_vault: () => setView("entry"),582 jump_settings: () => setView("settings"),583 show_help: () => setShowShortcutsHelp(true),584 };585586 587 588 589 const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([590 "copy_password", "copy_username", "copy_totp",591 "edit_entry", "delete_entry", "nav_prev", "nav_next",592 ]);593594 const handleGlobalKeydown = (e: KeyboardEvent) => {595 const v = view();596 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"597 || (e.target as HTMLElement)?.tagName === "TEXTAREA"598 || (e.target as HTMLElement)?.isContentEditable;599 const isPrintableSearchKey =600 e.key.length === 1 &&601 !e.ctrlKey &&602 !e.metaKey &&603 !e.altKey;604605 606 if (v === "pro-celebration") return;607608 609 610 if (showShortcutsHelp()) {611 if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }612 return;613 }614615 616 if (e.key === "Escape") {617 if (v === "list") { setView("entry"); return; }618 if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }619 }620621 622 623 624 const bindings = shortcutBindings();625 for (const action of Object.keys(bindings) as ShortcutAction[]) {626 const binding = bindings[action];627 if (!matchKeybinding(e, binding)) continue;628 629 630 const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;631 if (!hasMod && binding.key.length === 1 && inInput) return;632 if (ENTRY_SCOPED.has(action) && v !== "entry") return;633 e.preventDefault();634 SHORTCUT_HANDLERS[action]?.();635 return;636 }637638 639 640 641 642 if (v === "entry" && !inInput && isPrintableSearchKey) {643 e.preventDefault();644 primeListSearch(e.key);645 return;646 }647 };648 window.addEventListener("keydown", handleGlobalKeydown);649 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));650651 if (!Capacitor.isNativePlatform()) {652 const handleStorageChanged = (653 changes: Record<string, chrome.storage.StorageChange>,654 areaName: string655 ) => {656 if (areaName !== "session") return;657 if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {658 return;659 }660 void refreshPendingPasskey();661 };662 chrome.storage.onChanged.addListener(handleStorageChanged);663 onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));664 }665 });666667 async function refreshCurrentTab() {668 if (Capacitor.isNativePlatform()) return;669 try {670 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });671 const url = tab?.url ?? tab?.pendingUrl ?? "";672 setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");673 } catch {674 setCurrentTabDomain("");675 }676 }677678 async function refreshPendingPasskey() {679 const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });680 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);681 }682683 async function refreshProStatus() {684 const res = await sendMessage({ type: "GET_LICENSE" });685 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;686 setProStatus(Boolean(data?.valid && data?.plan === "pro"));687 }688689 async function restoreSettings() {690 const res = await sendMessage({ type: "GET_SETTINGS" });691 const s = (res as { ok: boolean; data?: any }).data ?? {};692 if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {693 setAccentColor(s.accentColor as AccentColorName);694 }695 setTheme(s.theme === "light" ? "light" : "dark");696 if (s.displayFont === "cormorant" || s.displayFont === "inter" || s.displayFont === "system") {697 setDisplayFont(s.displayFont);698 } else {699 setDisplayFont("cormorant-italic");700 }701 if (s.textSize === "small" || s.textSize === "large") {702 setTextSize(s.textSize);703 } else {704 setTextSize("medium");705 }706 setReduceMotion(s.reduceMotion === true);707 setLockOnClose(s.lockOnClose !== false);708 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);709 setBreachMonitoring(s.breachMonitoring !== false);710 setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);711 setShortcutBindings(getActiveBindings(s.shortcuts));712 }713714 async function applyTrashRetention(days: number) {715 setTrashRetentionDays(days);716 await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });717 }718719 function primeListSearch(seed: string) {720 setListSearchSeed(seed);721 setListSearchSeedNonce((value) => value + 1);722 openList();723 }724725 726 727 728 async function loadEntries(): Promise<VaultEntry[] | null> {729 const response = await sendMessage({ type: "GET_ENTRIES" });730 const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };731732 733 734 735 736 if (!res.ok) {737 if (738 res.error === "vault locked" ||739 res.error === "SERVICE_WORKER_UNAVAILABLE" ||740 (typeof res.error === "string" && res.error.includes("locked"))741 ) {742 handleLock();743 return null; 744 }745 return [];746 }747748 const loadedEntries = res.data ?? [];749 setEntries(loadedEntries);750 void publishInMemoryVault(loadedEntries);751752 const currentId = selectedEntry()?.id;753 if (currentId) {754 setSelectedEntry(755 loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null756 );757 }758759 return loadedEntries;760 }761762 async function openVault(preferredEntryId?: string, skipViewSet = false) {763 764 const retDays = trashRetentionDays();765 if (retDays > 0) {766 void sendMessage({767 type: "PURGE_EXPIRED_TRASH",768 payload: { retentionMs: retDays * 86_400_000 },769 });770 }771772 const loadedEntries = await loadEntries();773774 775 776 if (loadedEntries === null) return;777778 779 let nextSelectedEntry: VaultEntry | null = null;780 if (preferredEntryId) {781 nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;782 }783 if (!nextSelectedEntry) {784 const tabDomain = currentTabDomain();785 if (tabDomain) {786 nextSelectedEntry = loadedEntries.find((e) => {787 if (e.type === "login") {788 if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;789 for (const extra of e.additionalUrls ?? []) {790 if ((extractDomain(extra) ?? "") === tabDomain) return true;791 }792 return false;793 }794 if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;795 return false;796 }) ?? null;797 }798 }799 nextSelectedEntry ??= loadedEntries[0] ?? null;800801 setSelectedEntry(nextSelectedEntry);802 setEditEntry(undefined);803 setContentTransitioning(false);804 setContentPhase("enter");805 queueContentIdle();806 if (!skipViewSet) setView("entry");807 }808809 async function handleUnlock() {810 setHasVault(true);811 triggerPipPulse();812 await restoreSettings();813 const celebrating = pendingCelebration();814 if (celebrating) {815 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });816 setPendingCelebration(false);817 }818 await openVault(undefined, celebrating);819 if (celebrating) {820 setView("pro-celebration");821 window.setTimeout(() => setView("entry"), 2500);822 }823 }824825 function handleSelectEntry(entry: VaultEntry) {826 setSelectedEntry(entry);827 setContentTransitioning(false);828 setContentPhase("enter");829 queueContentIdle();830 setView("entry");831 }832833 function handleAdd() {834 setEditEntry(undefined);835 setView("add");836 }837838 async function handleDelete() {839 const entry = selectedEntry();840 if (!entry) return;841 await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });842 const loadedEntries = await loadEntries() ?? [];843 setSelectedEntry(loadedEntries[0] ?? null);844 setView("entry");845 }846847 async function handleDeleteMany(ids: string[]) {848 for (const id of ids) {849 await sendMessage({ type: "DELETE_ENTRY", payload: { id } });850 }851 const loadedEntries = await loadEntries() ?? [];852 853 const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);854 if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);855 }856857 async function handleSave(savedEntry?: VaultEntry) {858 await openVault(savedEntry?.id);859 }860861 async function savePendingAutofill() {862 const pending = pendingAutofillSave();863 if (!pending) return;864 const domain = pending.domain.trim();865 const response = await sendMessage({866 type: "ADD_ENTRY",867 payload: {868 type: "login",869 title: domain || "Saved login",870 url: domain ? `https://${domain}` : "",871 username: pending.username,872 password: pending.password,873 },874 });875 if ((response as { ok: boolean }).ok) {876 setPendingAutofillSave(null);877 await openVault((response as { data?: VaultEntry }).data?.id);878 }879 }880881 function handleBack() {882 setEditEntry(undefined);883 setView("entry");884 }885886 function handleLock() {887 clearCanarySuppressions();888 setEntries([]);889 setSelectedEntry(null);890 setEditEntry(undefined);891 setContentPhase("idle");892 setView("lock");893 void clearInMemoryVault();894 }895896 function handleHeaderLock() {897 898 899 900 handleLock();901 void sendMessage({ type: "LOCK" });902 }903904 function navigate(dir: number) {905 const allEntries = entries();906 if (allEntries.length === 0) return;907 const currentIndex = selectedIndex();908 const baseIndex = currentIndex >= 0 ? currentIndex : 0;909 const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;910 setSelectedEntry(allEntries[nextIndex]);911 setContentPhase("enter");912 queueContentIdle();913 }914915 function openList() {916 setView("list");917 }918919 function openHealth() {920 setEditEntry(undefined);921 setView("health");922 }923924 function handleOpenFileImport() {925 setRestoreView("file");926 }927928 const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;929930 function handleFixEntry(entry: VaultEntry) {931 932 933 if (isPasskeyOnly(entry)) {934 setSelectedEntry(entry);935 setView("entry");936 return;937 }938 setEditEntry(entry);939 setView("add");940 }941942 const isLockedView = () => view() === "lock";943944 async function handleApprovePendingPasskey() {945 const pending = pendingPasskey();946 if (!pending) return;947 if (pending.browser === "firefox" && pending.type === "create") {948 949 950 951 952 const { createFirefoxPasskey } = await import("./firefox-passkey");953 const result = await createFirefoxPasskey(pending);954 if (!result.ok) {955 console.warn("Peach Firefox passkey create failed:", result.error);956 }957 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });958 setPendingPasskey(null);959 void loadEntries();960 return;961 }962 await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });963 setPendingPasskey(null);964 }965966 async function handleDenyPendingPasskey() {967 const pending = pendingPasskey();968 if (!pending) return;969 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });970 setPendingPasskey(null);971 }972973 function queueContentIdle(after?: () => void) {974 if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);975 contentPhaseTimer = window.setTimeout(() => {976 setContentPhase("idle");977 after?.();978 }, ENTRY_TRANSITION_SETTLE_MS);979 }980981 return (982 <div class="popup-root peach-theme">983 <Show when={bootState() === "ready"}>984 <Show985 when={!pendingPasskey()}986 fallback={987 <PasskeyApprovalView988 type={pendingPasskey()!.type}989 rpName={pendingPasskey()!.rpName}990 rpId={pendingPasskey()!.rpId}991 userName={pendingPasskey()!.userName}992 userDisplayName={pendingPasskey()!.userDisplayName}993 onApprove={handleApprovePendingPasskey}994 onDeny={handleDenyPendingPasskey}995 />996 }997 >998 <Show999 when={!isLockedView()}1000 fallback={1001 <Show1002 when={onboarded() || hasVault()}1003 fallback={1004 <Onboarding1005 onComplete={() => { setOnboarded(true); }}1006 />1007 }1008 >1009 <LockView1010 hasVault={hasVault()}1011 ready={bootState() === "ready"}1012 isPro={proStatus()}1013 onUnlock={handleUnlock}1014 onBackToOnboarding={1015 !hasVault()1016 ? () => setOnboarded(false)1017 : undefined1018 }1019 />1020 </Show>1021 }1022 >1023 <Show1024 when={view() !== "pro-celebration"}1025 fallback={<ProCelebration />}1026 >1027 <section1028 id="hd-shell"1029 class="peach-shell peach-theme"1030 data-surface="extension"1031 >1032 <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1033 <PeachWordmark unlocked />1034 <div class="peach-chrome-actions">1035 <button1036 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1037 onClick={() => setView("settings")}1038 aria-label="Settings"1039 >1040 <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1041 <path1042 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"1043 stroke="currentColor"1044 stroke-width="0.9"1045 />1046 <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1047 </svg>1048 </button>1049 <button1050 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1051 onClick={handleAdd}1052 aria-label="New entry"1053 >1054 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1055 <path d="M5.5 1v9M1 5.5h9" />1056 </svg>1057 </button>1058 <button1059 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1060 onClick={() => void handleHeaderLock()}1061 aria-label="Lock vault"1062 >1063 <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1064 <path1065 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"1066 stroke="currentColor"1067 stroke-width="1"1068 stroke-linecap="round"1069 />1070 <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1071 <circle cx="7" cy="10.25" r="1" fill="currentColor" />1072 </svg>1073 </button>1074 </div>1075 </header>10761077 <Show when={pendingProNotice() !== null}>1078 <div class="peach-pro-notice-banner">1079 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1080 <button1081 class="peach-pro-notice-link"1082 onClick={() => {1083 setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1084 setSettingsInitialSection("license");1085 setView("settings");1086 setPendingProNotice(null);1087 }}1088 >1089 Learn more →1090 </button>1091 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1092 </div>1093 </Show>10941095 <Show when={pendingAutofillSave() !== null && !isLockedView()}>1096 <div class="peach-pro-notice-banner peach-autofill-save-banner">1097 <span>1098 Save login{pendingAutofillSave()?.domain ? ` for ${pendingAutofillSave()!.domain}` : ""}?1099 </span>1100 <button1101 class="peach-pro-notice-link"1102 onClick={() => void savePendingAutofill()}1103 >1104 Save1105 </button>1106 <button1107 class="peach-pro-notice-close"1108 onClick={() => setPendingAutofillSave(null)}1109 aria-label="Dismiss"1110 >1111 x1112 </button>1113 </div>1114 </Show>11151116 <Show when={backExitNotice()}>1117 <div class="peach-pro-notice-banner peach-back-exit-banner">1118 <span>Tap back again to exit Peach</span>1119 </div>1120 </Show>11211122 <div class="peach-shell-body">1123 <div class="peach-shell-veil"></div>1124 <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>1125 <EntryView1126 entry={selectedEntry()}1127 onDelete={handleDelete}1128 onFieldSaved={() => void loadEntries()}1129 onEscape={() => { setView("list"); }}1130 />1131 </div>11321133 <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1134 <div class="peach-search-bar">1135 <span class="peach-search-icon">1136 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1137 <circle cx="5.5" cy="5.5" r="3.75" />1138 <path d="M8.75 8.75L11.5 11.5" />1139 </svg>1140 </span>1141 <input1142 class="peach-search-input"1143 placeholder="search vault..."1144 autocomplete="off"1145 aria-label="Search vault entries"1146 onClick={openList}1147 onFocus={openList}1148 readOnly1149 />1150 <div class="peach-search-meta">1151 <span class="peach-search-counter">1152 {entries().length > 01153 ? `${selectedIndex() + 1} / ${entries().length}`1154 : "0 entries"}1155 </span>1156 <span class="peach-divider"></span>1157 <button1158 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1159 onClick={() => navigate(-1)}1160 aria-label="Previous entry"1161 disabled={entries().length === 0}1162 >1163 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1164 <path d="M5 1L1 5.5L5 10" />1165 </svg>1166 </button>1167 <button1168 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1169 onClick={() => navigate(1)}1170 aria-label="Next entry"1171 disabled={entries().length === 0}1172 >1173 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1174 <path d="M1 1L5 5.5L1 10" />1175 </svg>1176 </button>1177 </div>1178 </div>1179 </section>1180 </div>11811182 <Show when={view() === "list"}>1183 <VaultView1184 entries={entries()}1185 selectedEntryId={selectedEntry()?.id}1186 currentTabDomain={currentTabDomain()}1187 sortOrder={sortOrder()}1188 searchSeed={listSearchSeed()}1189 searchSeedNonce={listSearchSeedNonce()}1190 onSelectEntry={handleSelectEntry}1191 onSortOrderChange={setSortOrder}1192 onAdd={handleAdd}1193 onClose={() => setView("entry")}1194 onOpenTrash={() => setView("trash")}1195 onDeleteMany={handleDeleteMany}1196 />1197 </Show>1198 <Show when={view() === "settings"}>1199 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1200 <SettingsView1201 entriesCount={entries().length}1202 breachMonitoring={breachMonitoring()}1203 lockOnClose={lockOnClose()}1204 autoLockMinutes={autoLockMinutes()}1205 trashRetentionDays={trashRetentionDays()}1206 theme={theme()}1207 accentColor={accentColor()}1208 displayFont={displayFont()}1209 textSize={textSize()}1210 reduceMotion={reduceMotion()}1211 isPro={proStatus()}1212 initialSection={settingsInitialSection()}1213 upgradeHighlight={upgradeHighlight()}1214 onBreachMonitoringChange={applyBreachMonitoring}1215 onLockOnCloseChange={applyLockOnClose}1216 onAutoLockMinutesChange={applyAutoLockMinutes}1217 onTrashRetentionChange={applyTrashRetention}1218 onThemeChange={applyTheme}1219 onAccentChange={applyAccentColor}1220 onDisplayFontChange={applyDisplayFont}1221 onTextSizeChange={applyTextSize}1222 onReduceMotionChange={applyReduceMotion}1223 shortcuts={shortcutBindings()}1224 onShortcutsChange={applyShortcuts}1225 onLicenseChange={async () => {1226 1227 1228 1229 1230 1231 await refreshProStatus();1232 void loadEntries();1233 const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1234 if ((should as { ok: boolean; data?: boolean }).data === true) {1235 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1236 setView("pro-celebration");1237 window.setTimeout(() => setView("settings"), 2500);1238 }1239 }}1240 onBack={() => { setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }}1241 onImport={() => setView("import")}1242 onExport={() => setView("export")}1243 onBreachCheckDone={loadEntries}1244 onOpenHealth={openHealth}1245 onOpenFileImport={handleOpenFileImport}1246 onOpenPanic={() => setView("panic")}1247 />1248 </Suspense>1249 </Show>1250 <Show when={view() === "add"}>1251 <AddView1252 entry={editEntry()}1253 entries={entries()}1254 onBack={handleBack}1255 onSave={handleSave}1256 />1257 </Show>1258 <Show when={view() === "import"}>1259 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1260 <ImportView onBack={handleBack} onImport={handleSave} />1261 </Suspense>1262 </Show>1263 <Show when={view() === "export"}>1264 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1265 <ExportView1266 onBack={() => {1267 if (returnToPanic()) {1268 setReturnToPanic(false);1269 setView("panic");1270 } else {1271 handleBack();1272 }1273 }}1274 />1275 </Suspense>1276 </Show>1277 <Show when={view() === "panic"}>1278 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1279 <PanicView1280 onCancel={() => setView("settings")}1281 onOpenExport={() => {1282 setReturnToPanic(true);1283 setView("export");1284 }}1285 onDestroyed={async () => {1286 clearCanarySuppressions();1287 setEntries([]);1288 setSelectedEntry(null);1289 setEditEntry(undefined);1290 setHasVault(false);1291 setOnboarded(false);1292 setProStatus(false);1293 setPendingPasskey(null);1294 setUpgradeHighlight(null);1295 setSettingsInitialSection(null);1296 setPendingProNotice(null);1297 setPendingCelebration(false);1298 setReturnToPanic(false);1299 setContentPhase("idle");1300 setView("lock");1301 void clearInMemoryVault();1302 }}1303 />1304 </Suspense>1305 </Show>1306 <Show when={view() === "health"}>1307 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1308 <HealthView1309 entries={entries()}1310 isPro={proStatus()}1311 onFixEntry={handleFixEntry}1312 onBack={() => setView("settings")}1313 onRefresh={loadEntries}1314 />1315 </Suspense>1316 </Show>1317 <Show when={view() === "trash"}>1318 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1319 <TrashView1320 onBack={() => setView("list")}1321 onRestored={() => void loadEntries()}1322 />1323 </Suspense>1324 </Show>1325 <Show when={restoreView() === "file"}>1326 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1327 <ImportView1328 mode="onboarding"1329 replacesExistingVault={true}1330 onBack={() => setRestoreView(null)}1331 onImport={async () => {1332 setRestoreView(null);1333 await handleUnlock();1334 }}1335 />1336 </Suspense>1337 </Show>1338 </section>1339 </Show>1340 </Show>1341 </Show>1342 </Show>1343 <Show when={showShortcutsHelp()}>1344 <ShortcutsHelpOverlay1345 bindings={shortcutBindings()}1346 onClose={() => setShowShortcutsHelp(false)}1347 />1348 </Show>1349 </div>1350 );1351}13521353function ShortcutsHelpOverlay(props: {1354 bindings: Record<ShortcutAction, Keybinding>;1355 onClose: () => void;1356}) {1357 return (1358 <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1359 <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1360 <div class="peach-shortcuts-help-head">1361 <h3>Keyboard shortcuts</h3>1362 <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1363 </div>1364 <div class="peach-shortcuts-help-body">1365 <p class="peach-settings-sub-label">ENTRY VIEW</p>1366 <ul>1367 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1368 <li>1369 <span>{d.label}</span>1370 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1371 </li>1372 ))}1373 </ul>1374 <p class="peach-settings-sub-label">ANYWHERE</p>1375 <ul>1376 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1377 <li>1378 <span>{d.label}</span>1379 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1380 </li>1381 ))}1382 </ul>1383 </div>1384 </div>1385 </div>1386 );1387}