1import { createEffect, createMemo, createSignal, For, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";2import { storageGet, storageRemove, storageSet } from "../shared/storage";3import { sendMessage } from "../shared/bridge";4import { ACCENT_COLORS } from "../shared/types";5import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";6import { isPasskeyOnly } from "../shared/types";7import {8 hasCompletedInitialSync,9 INITIAL_SYNC_PENDING_DEVICE_KEY,10 type InitialSyncDevice,11} from "../shared/initial-sync";12import { LockView, RESTORE_SOURCE_STORAGE_KEY, type RestoreSource } from "./views/Lock";13import { WhatsNewView } from "./views/WhatsNew";14import { shouldShowWhatsNew } from "../shared/release-notes";15import { ProNudgeView } from "./views/ProNudge";16import { claimProNudge } from "../shared/pro-nudge";17import {18 ONBOARDING_CARD_STORAGE_KEY,19 ONBOARDING_LAST_CARD,20 Onboarding,21} from "./views/Onboarding";2223const ONBOARDING_EXIT_STORAGE_KEY = "peach_onboarding_exit";24import { NotesMode } from "./views/NotesMode";25import { ProCelebration } from "./views/ProCelebration";26import { InitialSyncView } from "./views/InitialSyncView";27import { VaultView, type SortOrder } from "./views/Vault";28import { EntryView } from "./views/Entry";29import { AddView } from "./views/Add";30import { PeachWordmark } from "./components/PeachMark";31import { PasskeyApprovalView } from "./views/PasskeyApproval";32import { detectHomograph, extractDomain, getSecurityOriginDisplay } from "../shared/domain";33import {34 isManualSyncPending,35 syncIndicator,36 syncIndicatorLabel,37 type SyncOutcomeSummary,38} from "../shared/sync-outcome";39import { popupCopy } from "./copy";40import {41 DEFAULT_BINDINGS,42 formatKeybinding,43 getActiveBindings,44 matchKeybinding,45 SHORTCUT_DESCRIPTORS,46 type Keybinding,47 type ShortcutAction,48} from "../shared/shortcuts";49import { generateTOTP } from "../crypto/totp";5051export { sendMessage } from "../shared/bridge";5253const ImportView = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));54const ExportView = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));55const SettingsView = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));56const HealthView = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));57const TrashView = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));58const PanicView = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));59const PairDeviceView = lazy(() => import("./views/PairDevice").then(m => ({ default: m.PairDeviceView })));60const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));61const CodexRestoreView = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));62const AdoptVaultView = lazy(() => import("./views/AdoptVaultView").then(m => ({ default: m.AdoptVaultView })));6364type View =65 | "lock"66 | "pro-celebration"67 | "whats-new"68 | "pro-nudge"69 | "entry"70 | "list"71 | "add"72 | "settings"73 | "pair-device"74 | "import"75 | "export"76 | "health"77 | "trash"78 | "panic"79 | "adopting"80 | "initial-sync"81 | "codex-generate"82 | "codex-restore";83type BootState = "loading" | "ready";8485const INITIAL_SYNC_POLL_MS = 500;86const INITIAL_SYNC_SLOW_MS = 15_000;8788let shellRingAnimation: Animation | null = null;8990export function triggerRing() {91 const shell = document.getElementById("hd-shell");92 if (!shell) return;9394 shellRingAnimation?.cancel();95 shell.dataset.glowing = "true";96 const animation = shell.animate(97 [98 { offset: 0, boxShadow: "var(--copy-shadow)" },99 {100 offset: 0.3,101 boxShadow:102 "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)",103 },104 { offset: 1, boxShadow: "var(--copy-shadow)" },105 ],106 {107 duration: 720,108 easing: "cubic-bezier(0.4, 0, 0.2, 1)",109 fill: "none",110 }111 );112 shellRingAnimation = animation;113 const clearGlow = () => {114 if (shellRingAnimation !== animation) return;115 shellRingAnimation = null;116 delete shell.dataset.glowing;117 };118 animation.onfinish = clearGlow;119 animation.oncancel = clearGlow;120}121122export function triggerPipPulse() {123 const pip = document.querySelector(".peach-status-pip, .pip");124 if (pip) {125 pip.classList.remove("pulse");126 void (pip as HTMLElement).offsetWidth;127 pip.classList.add("pulse");128 }129}130131const ENTRY_TRANSITION_SETTLE_MS = 135;132const ALL_SITES: chrome.permissions.Permissions = { origins: ["<all_urls>"] };133134export default function App() {135 const [bootState, setBootState] = createSignal<BootState>("loading");136 const [view, setView] = createSignal<View>("lock");137 const [contentPhase, setContentPhase] = createSignal<"enter" | "idle">("enter");138 const [hasVault, setHasVault] = createSignal(false);139 const [onboarded, setOnboarded] = createSignal(true);140 const [startOnboardingRestore, setStartOnboardingRestore] = createSignal(false);141 142 143 144 const [resumeRestoreSource, setResumeRestoreSource] = createSignal<RestoreSource | null>(null);145 146 147 148 const [onboardingCardAtExit, setOnboardingCardAtExit] = createSignal(0);149 const [entries, setEntries] = createSignal<VaultEntry[]>([]);150 const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);151 const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);152153 154 interface SyncAlert {155 type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";156 entryId: string;157 entryTitle: string;158 deviceName: string;159 timestamp: number;160 }161 const [syncAlerts, setSyncAlerts] = createSignal<SyncAlert[]>([]);162 163 const [alertFlipIdx, setAlertFlipIdx] = createSignal<Record<string, number>>({});164 async function loadSyncAlerts() {165 const r = (await sendMessage({ type: "GET_SYNC_ALERTS" })) as {166 ok: boolean;167 data?: SyncAlert[];168 };169 if (r.ok) setSyncAlerts(r.data ?? []);170 }171 async function dismissAlertGroup(type: SyncAlert["type"]) {172 const result = (await sendMessage({173 type: "DISMISS_SYNC_ALERTS",174 payload: { type },175 })) as { ok: boolean };176 if (!result.ok) return;177 setSyncAlerts((arr) => arr.filter((a) => a.type !== type));178 setAlertFlipIdx((m) => { const n = { ...m }; delete n[type]; return n; });179 }180 function flipAlert(type: string, dir: 1 | -1) {181 const group = syncAlerts().filter((a) => a.type === type);182 if (group.length <= 1) return;183 setAlertFlipIdx((m) => {184 const cur = m[type] ?? 0;185 return { ...m, [type]: (cur + dir + group.length) % group.length };186 });187 }188 189 const alertGroups = createMemo(() => {190 const map = new Map<string, SyncAlert[]>();191 for (const a of syncAlerts()) {192 const g = map.get(a.type);193 if (g) g.push(a); else map.set(a.type, [a]);194 }195 return [...map.entries()];196 });197 function syncAlertText(a: SyncAlert): string {198 if (a.type === "DEVICE_REVOKED") {199 return `${a.deviceName} revoked a paired device — review your devices in Settings`;200 }201 return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`;202 }203 const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");204 const [theme, setTheme] = createSignal<"dark" | "light">("dark");205 const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");206 const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);207 const [lockOnClose, setLockOnClose] = createSignal(false);208 const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);209 const [lockedSyncRequiresUnlock, setLockedSyncRequiresUnlock] = createSignal(false);210 const [breachMonitoring, setBreachMonitoring] = createSignal(true);211 const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);212 const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);213 const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);214 const [restoreView, setRestoreView] = createSignal<"file" | null>(null);215 const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);216 const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");217 const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");218 219 220 221 222 const [proStatus, setProStatus] = createSignal(false);223 const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);224 225 226 const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");227 const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);228 const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");229 const [listSearchSeed, setListSearchSeed] = createSignal("");230 const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);231 const [pendingCelebration, setPendingCelebration] = createSignal(false);232 const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);233 234 235 236 const [pendingRememberSite, setPendingRememberSite] = createSignal<{237 entryId: string;238 url: string;239 title: string;240 } | null>(null);241 242 243 const [siteAccessMissing, setSiteAccessMissing] = createSignal(false);244 245 246 247 const [returnToPanic, setReturnToPanic] = createSignal(false);248 249 250 const [editBackView, setEditBackView] = createSignal<View>("entry");251 const [settingsExiting, setSettingsExiting] = createSignal(false);252 const [showNotes, setShowNotes] = createSignal(false);253 254 255 256 const [syncBusy, setSyncBusy] = createSignal(false);257 const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null);258 259 260 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});261 const [pairedCount, setPairedCount] = createSignal(0);262 const [initialSyncDeviceId, setInitialSyncDeviceId] = createSignal<string | null>(null);263 const [initialSyncSlow, setInitialSyncSlow] = createSignal(false);264 let initialSyncWaitGeneration = 0;265 const syncLight = () =>266 syncBusy() ? "busy" : syncIndicator(syncSummary(), pairedCount());267 const syncLabel = () => syncIndicatorLabel(syncLight(), syncSummary());268 async function loadSyncIndicator() {269 const [stateRes, devicesRes] = await Promise.all([270 sendMessage({ type: "GET_SYNC_STATE" }),271 sendMessage({ type: "LIST_PAIRED_DEVICES" }),272 ]);273 const s = stateRes as { ok: boolean; data?: SyncOutcomeSummary };274 if (s.ok && s.data) setSyncSummary(s.data);275 const d = devicesRes as { ok: boolean; error?: string; data?: { devices?: unknown[] } };276 if (d.ok) {277 setPairedCount(d.data?.devices?.length ?? 0);278 } else if (d.error === "trust_data_corrupted") {279 setPairedCount(0);280 setSyncSummary({ status: "error", outcome: "trust_data_corrupted" });281 }282 }283284 async function listInitialSyncDevices(): Promise<InitialSyncPairedDevice[]> {285 const response = (await sendMessage({286 type: "LIST_PAIRED_DEVICES",287 })) as {288 ok: boolean;289 error?: string;290 data?: { devices?: InitialSyncPairedDevice[] };291 };292 if (response.ok) return response.data?.devices ?? [];293 if (response.error === "trust_data_corrupted") throw new Error("trust_data_corrupted");294 throw new Error(response.error ?? "list_paired_devices_failed");295 }296 interface InitialSyncPairedDevice extends InitialSyncDevice {297 pairedAt?: number;298 }299300301 async function resolveInitialSyncDeviceId(): Promise<string | null> {302 const stored = await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(303 () => null,304 );305 const devices = await listInitialSyncDevices().catch(() => []);306 if (stored && devices.some((device) => device.deviceId === stored)) {307 return stored;308 }309310 311 312 const newest = [...devices].sort(313 (a, b) => (b.pairedAt ?? 0) - (a.pairedAt ?? 0),314 )[0];315 if (!newest) return stored;316 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, newest.deviceId).catch(317 () => undefined,318 );319 return newest.deviceId;320 }321322 async function finishInitialSync(generation: number): Promise<void> {323 if (generation !== initialSyncWaitGeneration) return;324 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);325 setInitialSyncDeviceId(null);326 setInitialSyncSlow(false);327 await refreshProStatus();328329 330 331 332 const celebration = (await sendMessage({333 type: "SHOULD_PLAY_CELEBRATION",334 })) as { ok: boolean; data?: boolean };335 const shouldCelebrate = celebration.ok && celebration.data === true;336 if (shouldCelebrate) {337 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });338 }339340 const opened = await openVault(undefined, shouldCelebrate);341 if (!opened || generation !== initialSyncWaitGeneration) return;342 if (shouldCelebrate) {343 setView("pro-celebration");344 window.setTimeout(() => {345 if (generation === initialSyncWaitGeneration) setView("entry");346 }, 2500);347 }348 }349350 async function beginInitialSyncWait(deviceId: string): Promise<void> {351 const generation = ++initialSyncWaitGeneration;352 setInitialSyncDeviceId(deviceId);353 setInitialSyncSlow(false);354 setView("initial-sync");355 const startedAt = Date.now();356357 358 359 void sendMessage({360 type: "TRIGGER_SYNC",361 payload: { reason: "foreground" },362 }).catch(() => undefined);363364 while (365 generation === initialSyncWaitGeneration &&366 view() === "initial-sync"367 ) {368 try {369 const devices = await listInitialSyncDevices();370 if (hasCompletedInitialSync(devices, deviceId)) {371 await finishInitialSync(generation);372 return;373 }374 } catch {375 376 }377 if (Date.now() - startedAt >= INITIAL_SYNC_SLOW_MS) {378 setInitialSyncSlow(true);379 }380 await new Promise((resolve) =>381 window.setTimeout(resolve, INITIAL_SYNC_POLL_MS),382 );383 }384 }385386 async function retryInitialSync(): Promise<void> {387 const deviceId =388 initialSyncDeviceId() ?? (await resolveInitialSyncDeviceId());389 if (deviceId) {390 await beginInitialSyncWait(deviceId);391 } else {392 setInitialSyncSlow(true);393 }394 }395396 const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);397 398 399 400 401 const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false);402403 let contentPhaseTimer: number | undefined;404 405 406 let openedForInlineUnlock = false;407408 onCleanup(() => {409 initialSyncWaitGeneration += 1;410 });411412 function applyAccent(name: AccentColorName) {413 const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;414 const hex = color.hex;415 document.documentElement.style.setProperty("--peach-accent", hex);416 417 document.documentElement.style.setProperty("--peach-glow", hex + "2A");418 document.documentElement.style.setProperty("--peach-border-accent", hex + "24");419 }420421 createEffect(() => {422 applyAccent(accentColor());423 });424425 createEffect(() => {426 document.documentElement.classList.toggle("light", theme() === "light");427 document.body.classList.toggle("light", theme() === "light");428 });429430 createEffect(() => {431 document.documentElement.setAttribute("data-text-size", textSize());432 });433434 createEffect(() => {435 if (reduceMotion()) {436 document.documentElement.setAttribute("data-motion", "reduce");437 } else {438 document.documentElement.removeAttribute("data-motion");439 }440 });441442 443 444 445 446 447 const nonNoteEntries = createMemo(() => entries().filter((e) => e.type !== "note"));448449 const selectedIndex = createMemo(() => {450 const currentId = selectedEntry()?.id;451 if (!currentId) return nonNoteEntries().length > 0 ? 0 : -1;452 return nonNoteEntries().findIndex((entry) => entry.id === currentId);453 });454455 async function applyAccentColor(name: AccentColorName) {456 applyAccent(name);457 setAccentColor(name);458 await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });459 }460461 async function applyTheme(t: "dark" | "light") {462 setTheme(t);463 await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });464 }465466 async function applyTextSize(size: "small" | "medium" | "large") {467 setTextSize(size);468 await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });469 }470471 async function applyReduceMotion(enabled: boolean) {472 setReduceMotion(enabled);473 await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });474 }475476 477 478 479 async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {480 setShortcutBindings(active);481 const overrides: Record<string, Keybinding> = {};482 for (const action of Object.keys(active) as ShortcutAction[]) {483 const a = active[action];484 const d = DEFAULT_BINDINGS[action];485 const same = a.key === d.key486 && Boolean(a.ctrl) === Boolean(d.ctrl)487 && Boolean(a.meta) === Boolean(d.meta)488 && Boolean(a.shift) === Boolean(d.shift)489 && Boolean(a.alt) === Boolean(d.alt);490 if (!same) overrides[action] = a;491 }492 await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });493 }494495 async function applyLockOnClose(enabled: boolean) {496 setLockOnClose(enabled);497 await sendMessage({498 type: "UPDATE_SETTINGS",499 payload: { lockOnClose: enabled },500 });501 }502503 async function applyAutoLockMinutes(minutes: number) {504 setAutoLockMinutes(minutes);505 await sendMessage({506 type: "UPDATE_SETTINGS",507 payload: { autoLockMinutes: minutes },508 });509 }510 async function applyLockedSyncRequiresUnlock(enabled: boolean) {511 setLockedSyncRequiresUnlock(enabled);512 await sendMessage({ type: "UPDATE_SETTINGS", payload: { lockedSyncRequiresUnlock: enabled } });513 }514515 async function applyBreachMonitoring(enabled: boolean) {516 setBreachMonitoring(enabled);517 await sendMessage({518 type: "UPDATE_SETTINGS",519 payload: { breachMonitoring: enabled },520 });521 }522523 524 525 526 function markOnboardingExit(flow: "create" | "restore" | "adopt") {527 setOnboardingCardAtExit(ONBOARDING_LAST_CARD);528 void storageSet(ONBOARDING_EXIT_STORAGE_KEY, flow).catch(() => {});529 530 531 void storageSet(ONBOARDING_CARD_STORAGE_KEY, String(ONBOARDING_LAST_CARD)).catch(() => {});532 }533 function clearOnboardingExit() {534 void storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});535 void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});536 setResumeRestoreSource(null);537 }538539 onMount(() => {540 void chrome.permissions.contains(ALL_SITES).then((granted) => setSiteAccessMissing(!granted));541 });542543 onMount(async () => {544 545 546 547 548 549 const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([550 sendMessage({ type: "IS_UNLOCKED" }),551 sendMessage({ type: "HAS_VAULT" }),552 refreshCurrentTab(),553 sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),554 ]);555 const unlocked = Boolean(556 (unlockResponse as { ok: boolean; data?: boolean }).data557 );558 const vaultExists = Boolean(559 (vaultResponse as { ok: boolean; data?: boolean }).data560 );561 const shouldCelebrate = Boolean(562 (celebrationResponse as { ok: boolean; data?: boolean }).data563 );564 setHasVault(vaultExists);565 566 567 568 try {569 const onboarded = await storageGet("peach_onboarded");570 const seen = onboarded === "true";571 if (vaultExists && !seen) {572 await storageSet("peach_onboarded", "true");573 await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});574 await storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});575 await storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});576 setOnboarded(true);577 } else {578 setOnboarded(seen);579 if (!seen) {580 581 582 583 const exit = await storageGet(ONBOARDING_EXIT_STORAGE_KEY).catch(() => null);584 if (exit === "create" || exit === "restore" || exit === "adopt") {585 setOnboardingCardAtExit(ONBOARDING_LAST_CARD);586 setOnboarded(true);587 setStartOnboardingRestore(exit === "restore");588 if (exit === "restore") {589 590 591 const source = await storageGet(RESTORE_SOURCE_STORAGE_KEY).catch(() => null);592 if (source === "symbols" || source === "codex" || source === "csv") {593 setResumeRestoreSource(source);594 }595 }596 if (exit === "adopt") setView("adopting");597 } else {598 599 600 const stored = Number(601 await storageGet(ONBOARDING_CARD_STORAGE_KEY).catch(() => null),602 );603 if (604 Number.isInteger(stored) &&605 stored > 0 &&606 stored <= ONBOARDING_LAST_CARD607 ) {608 setOnboardingCardAtExit(stored);609 }610 }611 }612 }613 } catch {614 615 setOnboarded(true);616 }617618 619 620 621 622 623 try {624 const restoreRoute = new URLSearchParams(window.location.search).get("restore");625 if (restoreRoute === "peachscript" || restoreRoute === "csv") {626 setOnboarded(true);627 setStartOnboardingRestore(true);628 setResumeRestoreSource(restoreRoute === "peachscript" ? "symbols" : "csv");629 }630 } catch {631 632 }633634 await Promise.all([restoreSettings(), refreshProStatus()]);635636 const pendingInitialSyncMarker =637 unlocked && vaultExists638 ? await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => null)639 : null;640 const pendingInitialSyncDeviceId = pendingInitialSyncMarker641 ? await resolveInitialSyncDeviceId()642 : null;643644 645 646 647 if (shouldCelebrate && !pendingInitialSyncDeviceId) {648 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });649 }650651 652 653 if (unlocked && vaultExists) {654 void sendMessage({655 type: "TRIGGER_SYNC",656 payload: { reason: "foreground" },657 });658 void loadSyncIndicator();659 660 void loadSyncAlerts();661 }662663 664 665 666 667 668 let recoveryPending = false;669 try {670 const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" });671 recoveryPending = out.ok === true && out.data === true;672 } catch { }673674 675 676 let pendingOpenEntryId: string | undefined;677 if (chrome.storage.session) {678 try {679 const s = await chrome.storage.session.get(["peach_inline_unlock", "peach_open_entry"]);680 const pending = s as { peach_inline_unlock?: boolean; peach_open_entry?: string };681 if (pending.peach_inline_unlock) {682 openedForInlineUnlock = true;683 }684 if (typeof pending.peach_open_entry === "string") pendingOpenEntryId = pending.peach_open_entry;685 await chrome.storage.session.remove(["peach_inline_unlock", "peach_open_entry"]);686 } catch { }687 }688689 if (recoveryPending && unlocked) {690 691 692 setForceRecoveryWords(true);693 setView("lock");694 } else if (unlocked && vaultExists && pendingInitialSyncDeviceId) {695 void beginInitialSyncWait(pendingInitialSyncDeviceId);696 } else if (unlocked && vaultExists) {697 await openVault(pendingOpenEntryId, shouldCelebrate);698 if (shouldCelebrate) {699 setView("pro-celebration");700 window.setTimeout(() => setView("entry"), 2500);701 } else if (await shouldShowWhatsNew()) {702 setView("whats-new");703 } else if (!proStatus() && await claimProNudge()) {704 setView("pro-nudge");705 }706 } else if (shouldCelebrate) {707 setPendingCelebration(true);708 }709710 await refreshPendingPasskey();711712 713 const handleTabActivated = () => { void refreshCurrentTab(); };714 const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {715 if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();716 };717 try {718 chrome.tabs.onActivated.addListener(handleTabActivated);719 chrome.tabs.onUpdated.addListener(handleTabUpdated);720 onCleanup(() => {721 chrome.tabs.onActivated.removeListener(handleTabActivated);722 chrome.tabs.onUpdated.removeListener(handleTabUpdated);723 });724 } catch {725 726 }727728 setBootState("ready");729730 731 732 733 734 try {735 const route = new URLSearchParams(window.location.search).get("codex");736 if (route === "pdf" || route === "restore") {737 setView("codex-restore");738 }739 } catch {740 741 }742743 744 if (chrome.storage.session) {745 const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as746 Record<string, { feature?: string } | undefined>;747 if (session.pendingProGate?.feature) {748 await chrome.storage.session.remove("pendingProGate");749 setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);750 setSettingsInitialSection("license");751 setView("settings");752 } else if (session.pendingProNotice?.feature) {753 await chrome.storage.session.remove("pendingProNotice");754 setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });755 }756 const remembered = (await chrome.storage.session.get("pendingRememberSite")) as {757 pendingRememberSite?: { entryId?: unknown; url?: unknown };758 };759 const request = remembered.pendingRememberSite;760 if (761 typeof request?.entryId === "string" &&762 typeof request.url === "string"763 ) {764 const entry = await sendMessage({ type: "GET_ENTRIES" });765 const match = entry.ok766 ? (entry.data as Array<{ id: string; title?: string }> | undefined)?.find(767 (candidate) => candidate.id === request.entryId,768 )769 : undefined;770 if (match) {771 let host = request.url;772 try { host = new URL(request.url).hostname; } catch { }773 setPendingRememberSite({ entryId: request.entryId, url: request.url, title: match.title ?? host });774 } else {775 await chrome.storage.session.remove("pendingRememberSite");776 }777 }778 }779780 781 782 783 784 void Promise.all([785 import("./views/Settings").catch(() => {}),786 import("./views/Import").catch(() => {}),787 import("./views/Export").catch(() => {}),788 import("./views/Health").catch(() => {}),789 import("./views/TrashView").catch(() => {}),790 ]);791792 793 794 795 let popupPort: chrome.runtime.Port | null = null;796 try {797 798 799 800 popupPort = chrome.runtime.connect({ name: `popup-${crypto.randomUUID()}` });801 } catch { }802 onCleanup(() => { try { popupPort?.disconnect(); } catch {} });803804 805 806 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {807 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();808 if (msg?.type === "SYNC_STATE_CHANGED") {809 setSyncSummary((msg.payload ?? {}) as SyncOutcomeSummary);810 }811 812 813 814 if (msg?.type === "VAULT_ENTRIES_CHANGED" && view() !== "lock") {815 void loadEntries();816 }817 if (msg?.type === "PRO_GATE_TRIGGERED") {818 setUpgradeHighlight(msg.payload?.feature ?? null);819 setSettingsInitialSection("license");820 setView("settings");821 }822 };823 chrome.runtime.onMessage.addListener(handleRuntimeMessage);824 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));825826 827 828 829 const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {830 copy_password: () => {831 const entry = selectedEntry();832 if (entry?.password) void popupCopy(833 entry.password,834 "Password",835 entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,836 ).then((ok) => { if (ok) triggerRing(); });837 },838 copy_username: () => {839 const entry = selectedEntry();840 if (entry?.username) void popupCopy(841 entry.username,842 "Username",843 entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,844 ).then((ok) => { if (ok) triggerRing(); });845 },846 copy_totp: () => {847 const entry = selectedEntry();848 if (!entry?.totp) return;849 try {850 const { code } = generateTOTP(851 entry.totp,852 entry.totpAlgorithm,853 entry.totpDigits,854 entry.totpPeriod,855 );856 void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });857 } catch { }858 },859 new_entry: () => handleAdd(),860 edit_entry: () => {861 const entry = selectedEntry();862 863 864 if (!entry || isPasskeyOnly(entry)) return;865 setEditEntry(entry);866 setView("add");867 },868 delete_entry: () => {869 if (selectedEntry()) void handleDelete();870 },871 lock_vault: () => { void handleHeaderLock(); },872 search: () => openList(),873 nav_prev: () => navigate(-1),874 nav_next: () => navigate(1),875 jump_vault: () => setView("entry"),876 jump_settings: () => setView("settings"),877 show_help: () => setShowShortcutsHelp(true),878 };879880 881 882 883 const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([884 "copy_password", "copy_username", "copy_totp",885 "edit_entry", "delete_entry", "nav_prev", "nav_next",886 ]);887888 const handleGlobalKeydown = (e: KeyboardEvent) => {889 const v = view();890 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"891 || (e.target as HTMLElement)?.tagName === "TEXTAREA"892 || (e.target as HTMLElement)?.isContentEditable;893 const isPrintableSearchKey =894 e.key.length === 1 &&895 !e.ctrlKey &&896 !e.metaKey &&897 !e.altKey;898899 900 901 902 903 904 if (v === "lock" || v === "adopting" || v === "pro-celebration" || v === "initial-sync") return;905906 907 908 if (showShortcutsHelp()) {909 if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }910 return;911 }912913 914 if (e.key === "Escape") {915 if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }916 if (v === "list") { setView("entry"); return; }917 if (v === "settings" || v === "add") { handleBack(); return; }918 if (v === "import" || v === "health") { setView("settings"); return; }919 if (v === "export") { handleExportBack(); return; }920 if (v === "pair-device") { setView("settings"); return; }921 }922923 924 925 926 const bindings = shortcutBindings();927 for (const action of Object.keys(bindings) as ShortcutAction[]) {928 const binding = bindings[action];929 if (!matchKeybinding(e, binding)) continue;930 931 932 const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;933 if (!hasMod && binding.key.length === 1 && inInput) return;934 if (ENTRY_SCOPED.has(action) && v !== "entry") return;935 e.preventDefault();936 SHORTCUT_HANDLERS[action]?.();937 return;938 }939940 941 942 943 944 if (v === "entry" && !inInput && isPrintableSearchKey) {945 e.preventDefault();946 primeListSearch(e.key);947 return;948 }949 };950 window.addEventListener("keydown", handleGlobalKeydown);951 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));952953 const handleStorageChanged = (954 changes: Record<string, chrome.storage.StorageChange>,955 areaName: string956 ) => {957 if (areaName !== "session") return;958 if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {959 return;960 }961 void refreshPendingPasskey();962 };963 chrome.storage.onChanged.addListener(handleStorageChanged);964 onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));965 });966967 async function refreshCurrentTab() {968 try {969 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });970 const url = tab?.url ?? tab?.pendingUrl ?? "";971 setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");972 973 974 const isWebUrl = /^https?:\/\//i.test(url);975 setCurrentTabUrl(isWebUrl ? url : "");976 } catch {977 setCurrentTabDomain("");978 setCurrentTabUrl("");979 }980 }981982 async function refreshPendingPasskey() {983 const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });984 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);985 }986987 async function refreshProStatus() {988 const res = await sendMessage({ type: "GET_LICENSE" });989 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;990 setProStatus(Boolean(data?.valid && data?.plan === "pro"));991 }992993 async function restoreSettings() {994 const res = await sendMessage({ type: "GET_SETTINGS" });995 const s = (res as { ok: boolean; data?: any }).data ?? {};996 if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {997 setAccentColor(s.accentColor as AccentColorName);998 }999 setTheme(s.theme === "light" ? "light" : "dark");1000 if (s.textSize === "small" || s.textSize === "large") {1001 setTextSize(s.textSize);1002 } else {1003 setTextSize("medium");1004 }1005 setReduceMotion(s.reduceMotion === true);1006 setLockOnClose(s.lockOnClose === true);1007 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);1008 setLockedSyncRequiresUnlock(s.lockedSyncRequiresUnlock === true);1009 setBreachMonitoring(s.breachMonitoring !== false);1010 setShortcutBindings(getActiveBindings(s.shortcuts));1011 }10121013 async function applyTrashRetention(days: number) {1014 setTrashRetentionDays(days);1015 await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });1016 }10171018 function primeListSearch(seed: string) {1019 setListSearchSeed(seed);1020 setListSearchSeedNonce((value) => value + 1);1021 openList();1022 }10231024 1025 1026 1027 async function loadEntries(): Promise<VaultEntry[] | null> {1028 const response = await sendMessage({ type: "GET_ENTRIES" });1029 const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };10301031 1032 1033 1034 1035 if (!res.ok) {1036 if (1037 res.error === "vault_locked" ||1038 res.error === "SERVICE_WORKER_UNAVAILABLE" ||1039 (typeof res.error === "string" && res.error.includes("locked"))1040 ) {1041 handleLock();1042 return null; 1043 }1044 return [];1045 }10461047 const loadedEntries = res.data ?? [];1048 setEntries(loadedEntries);10491050 const currentId = selectedEntry()?.id;1051 if (currentId) {1052 setSelectedEntry(1053 loadedEntries.find((entry) => entry.id === currentId) ?? pickNavigable(loadedEntries)1054 );1055 }10561057 return loadedEntries;1058 }10591060 async function openVault(preferredEntryId?: string, skipViewSet = false) {1061 1062 const retDays = trashRetentionDays();1063 if (retDays > 0) {1064 void sendMessage({1065 type: "PURGE_EXPIRED_TRASH",1066 payload: { retentionMs: retDays * 86_400_000 },1067 });1068 }10691070 const loadedEntries = await loadEntries();10711072 1073 1074 if (loadedEntries === null) return false;10751076 1077 let nextSelectedEntry: VaultEntry | null = null;1078 if (preferredEntryId) {1079 nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;1080 }1081 if (!nextSelectedEntry) {1082 const tabDomain = currentTabDomain();1083 if (tabDomain) {1084 nextSelectedEntry = loadedEntries.find((e) => {1085 if (e.type === "login") {1086 if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;1087 for (const extra of e.additionalUrls ?? []) {1088 if ((extractDomain(extra) ?? "") === tabDomain) return true;1089 }1090 return false;1091 }1092 if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;1093 return false;1094 }) ?? null;1095 }1096 }1097 nextSelectedEntry ??= pickNavigable(loadedEntries);10981099 setSelectedEntry(nextSelectedEntry);1100 setEditEntry(undefined);1101 setContentPhase("enter");1102 queueContentIdle();1103 if (!skipViewSet) setView("entry");1104 return true;1105 }11061107 async function handleUnlock() {1108 1109 1110 1111 1112 1113 1114 let recoveryPending = false;1115 try {1116 const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" });1117 recoveryPending = out.ok === true && out.data === true;1118 } catch { }1119 if (recoveryPending) {1120 setForceRecoveryWords(true);1121 setView("lock");1122 triggerPipPulse();1123 await restoreSettings();1124 return;1125 }1126 setHasVault(true);1127 triggerPipPulse();1128 await restoreSettings();1129 const celebrating = pendingCelebration();1130 if (celebrating) {1131 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1132 setPendingCelebration(false);1133 }1134 await openVault(undefined, celebrating);1135 if (celebrating) {1136 setView("pro-celebration");1137 window.setTimeout(() => setView("entry"), 2500);1138 } else if (!openedForInlineUnlock && await shouldShowWhatsNew()) {1139 setView("whats-new");1140 } else if (!openedForInlineUnlock && !proStatus() && await claimProNudge()) {1141 setView("pro-nudge");1142 }1143 1144 1145 if (openedForInlineUnlock) {1146 openedForInlineUnlock = false;1147 window.close();1148 }1149 }11501151 function handleSelectEntry(entry: VaultEntry) {1152 if (entry.type === "note") {1153 setOpenNoteId(entry.id);1154 setShowNotes(true);1155 return;1156 }1157 setSelectedEntry(entry);1158 setContentPhase("enter");1159 queueContentIdle();1160 setView("entry");1161 }11621163 function handleAdd() {1164 setEditEntry(undefined);1165 setView("add");1166 }11671168 async function handleDelete() {1169 const entry = selectedEntry();1170 if (!entry) return;1171 await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1172 const loadedEntries = await loadEntries() ?? [];1173 setSelectedEntry(pickNavigable(loadedEntries));1174 setView("entry");1175 }11761177 async function handleDeleteMany(ids: string[]) {1178 for (const id of ids) {1179 await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1180 }1181 const loadedEntries = await loadEntries() ?? [];1182 1183 const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1184 if (!stillExists) setSelectedEntry(pickNavigable(loadedEntries));1185 }11861187 async function handleSave(savedEntry?: VaultEntry) {1188 await openVault(savedEntry?.id);1189 }11901191 function handleBack() {1192 const dest = editBackView();1193 setEditBackView("entry");1194 setEditEntry(undefined);1195 setView(dest);1196 }11971198 function handleExportBack() {1199 if (returnToPanic()) {1200 setReturnToPanic(false);1201 setView("panic");1202 return;1203 }1204 setView("settings");1205 }12061207 function handleLock() {1208 setEntries([]);1209 setSelectedEntry(null);1210 setEditEntry(undefined);1211 setContentPhase("idle");1212 setView("lock");1213 }12141215 function handleHeaderLock() {1216 1217 1218 1219 handleLock();1220 void sendMessage({ type: "LOCK" });1221 }12221223 1224 1225 1226 async function handleHeaderSync(): Promise<void> {1227 if (syncBusy()) return;1228 setSyncBusy(true);1229 setSyncFlash(null);1230 try {1231 await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });1232 let state: SyncOutcomeSummary = { status: "syncing" };1233 for (let i = 0; i < 360 && isManualSyncPending(state); i++) {1234 await new Promise((r) => setTimeout(r, 250));1235 const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {1236 ok: boolean;1237 data?: SyncOutcomeSummary;1238 };1239 state = res.ok ? res.data ?? { status: "idle" } : { status: "error" };1240 }1241 setSyncSummary(state);1242 setSyncFlash(state.status === "error" ? "error" : "ok");1243 await loadSyncAlerts();1244 await loadEntries();1245 await loadSyncIndicator();1246 } catch {1247 setSyncSummary({ status: "error", outcome: "run_failed" });1248 setSyncFlash("error");1249 } finally {1250 setSyncBusy(false);1251 setTimeout(() => setSyncFlash(null), 2_000);1252 }1253 }12541255 function navigate(dir: number) {1256 const navigable = nonNoteEntries();1257 if (navigable.length === 0) return;1258 const currentIndex = selectedIndex();1259 const baseIndex = currentIndex >= 0 ? currentIndex : 0;1260 const nextIndex = (baseIndex + dir + navigable.length) % navigable.length;1261 setSelectedEntry(navigable[nextIndex]);1262 setContentPhase("enter");1263 queueContentIdle();1264 }12651266 126712681269127012711272 function pickNavigable(list: VaultEntry[]): VaultEntry | null {1273 return list.find((e) => e.type !== "note") ?? null;1274 }12751276 function openList() {1277 setView("list");1278 }12791280 function openHealth() {1281 setEditEntry(undefined);1282 setView("health");1283 }12841285 function handleOpenFileImport() {1286 setRestoreView("file");1287 }12881289 const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;12901291 function handleFixEntry(entry: VaultEntry) {1292 setSelectedEntry(entry);1293 1294 1295 if (isPasskeyOnly(entry)) {1296 setView("entry");1297 return;1298 }1299 setEditBackView(view()); 1300 setEditEntry(entry);1301 setView("add");1302 }13031304 const isLockedView = () => view() === "lock" || view() === "adopting";13051306 async function handleAdoptionDone() {1307 await storageSet("peach_onboarded", "true").catch(() => {});1308 await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1309 clearOnboardingExit();1310 setOnboarded(true);1311 setHasVault(true);1312 setView("initial-sync");1313 1314 1315 1316 await refreshProStatus();1317 const deviceId = await resolveInitialSyncDeviceId();1318 if (deviceId) {1319 void beginInitialSyncWait(deviceId);1320 } else {1321 1322 1323 1324 setInitialSyncSlow(true);1325 }1326 }13271328 async function handleApprovePendingPasskey() {1329 const pending = pendingPasskey();1330 if (!pending) return;1331 if (pending.browser === "firefox" && pending.type === "create") {1332 1333 1334 1335 1336 const { createFirefoxPasskey } = await import("./firefox-passkey");1337 const result = await createFirefoxPasskey(pending);1338 if (!result.ok) {1339 console.warn("Peach Firefox passkey create failed:", result.error);1340 }1341 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1342 setPendingPasskey(null);1343 void loadEntries();1344 window.close();1345 return;1346 }1347 await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1348 window.close();1349 }13501351 async function handleDenyPendingPasskey() {1352 const pending = pendingPasskey();1353 if (!pending) return;1354 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1355 window.close();1356 }13571358 function queueContentIdle(after?: () => void) {1359 if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1360 contentPhaseTimer = window.setTimeout(() => {1361 setContentPhase("idle");1362 after?.();1363 }, ENTRY_TRANSITION_SETTLE_MS);1364 }13651366 return (1367 <div class="popup-root peach-theme">1368 <Show when={siteAccessMissing()}>1369 <div class="peach-pro-notice-banner peach-site-access-banner" role="alert">1370 <span>Peach can't fill on websites until you allow site access.</span>1371 <button1372 class="peach-pro-notice-link"1373 onClick={() => {1374 1375 chrome.permissions.request(ALL_SITES).then(1376 (granted) => setSiteAccessMissing(!granted),1377 (error: unknown) => console.warn("Peach site access request failed:", error),1378 );1379 }}1380 >1381 Allow1382 </button>1383 </div>1384 </Show>1385 <Show when={bootState() === "ready"}>1386 <Show1387 when={!pendingPasskey()}1388 fallback={1389 <PasskeyApprovalView1390 type={pendingPasskey()!.type}1391 rpName={pendingPasskey()!.rpName}1392 rpId={pendingPasskey()!.rpId}1393 userName={pendingPasskey()!.userName}1394 userDisplayName={pendingPasskey()!.userDisplayName}1395 onApprove={handleApprovePendingPasskey}1396 onDeny={handleDenyPendingPasskey}1397 />1398 }1399 >1400 <Show1401 when={!isLockedView()}1402 fallback={1403 <Show1404 when={view() !== "adopting"}1405 fallback={1406 <Suspense fallback={<div class="peach-loading">loading...</div>}>1407 <AdoptVaultView1408 onCancel={() => {1409 clearOnboardingExit();1410 setOnboarded(false);1411 setView("lock");1412 }}1413 onDone={handleAdoptionDone}1414 />1415 </Suspense>1416 }1417 >1418 <Show1419 when={onboarded() || hasVault()}1420 fallback={1421 <Onboarding1422 initialCard={onboardingCardAtExit()}1423 onComplete={() => {1424 markOnboardingExit("create");1425 setStartOnboardingRestore(false);1426 setOnboarded(true);1427 }}1428 onRestore={() => {1429 markOnboardingExit("restore");1430 setStartOnboardingRestore(true);1431 setOnboarded(true);1432 }}1433 onAdopt={() => {1434 markOnboardingExit("adopt");1435 setStartOnboardingRestore(false);1436 setOnboarded(true);1437 setView("adopting");1438 }}1439 />1440 }1441 >1442 <LockView1443 hasVault={hasVault()}1444 ready={bootState() === "ready"}1445 isPro={proStatus()}1446 forceRecoveryWords={forceRecoveryWords()}1447 initialRestore={startOnboardingRestore()}1448 initialRestoreSource={resumeRestoreSource() ?? undefined}1449 onUnlock={() => {1450 1451 1452 1453 if (forceRecoveryWords()) setForceRecoveryWords(false);1454 1455 1456 1457 setStartOnboardingRestore(false);1458 setResumeRestoreSource(null);1459 void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});1460 return handleUnlock();1461 }}1462 onBackToOnboarding={1463 !hasVault()1464 ? () => {1465 1466 1467 clearOnboardingExit();1468 setStartOnboardingRestore(false);1469 setOnboarded(false);1470 }1471 : undefined1472 }1473 onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1474 />1475 </Show>1476 </Show>1477 }1478 >1479 <Show1480 when={1481 view() !== "pro-celebration" &&1482 view() !== "initial-sync" &&1483 view() !== "whats-new" &&1484 view() !== "pro-nudge"1485 }1486 fallback={1487 <Show1488 when={view() === "initial-sync"}1489 fallback={1490 <Show1491 when={view() === "whats-new"}1492 fallback={1493 <Show when={view() === "pro-nudge"} fallback={<ProCelebration />}>1494 <ProNudgeView1495 onSeePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1496 onDismiss={() => setView("entry")}1497 />1498 </Show>1499 }1500 >1501 <WhatsNewView onContinue={() => setView("entry")} />1502 </Show>1503 }1504 >1505 <InitialSyncView1506 slow={initialSyncSlow()}1507 onRetry={() => void retryInitialSync()}1508 />1509 </Show>1510 }1511 >1512 <section1513 id="hd-shell"1514 class="peach-shell peach-theme"1515 data-surface="extension"1516 >1517 <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1518 <PeachWordmark unlocked />1519 <div class="peach-chrome-actions">1520 <button1521 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1522 onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1523 aria-label="Notes"1524 title="Notes"1525 >1526 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1527 <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1528 <line x1="4" y1="5" x2="10" y2="5" />1529 <line x1="4" y1="7.5" x2="10" y2="7.5" />1530 <line x1="4" y1="10" x2="7" y2="10" />1531 </svg>1532 </button>1533 <button1534 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-sync-btn"1535 classList={{1536 "is-syncing": syncBusy(),1537 "is-ok": syncFlash() === "ok",1538 "is-error": syncFlash() === "error",1539 }}1540 onClick={() => void handleHeaderSync()}1541 disabled={syncBusy()}1542 aria-label={`Sync now. ${syncLabel()}`}1543 title={syncLabel()}1544 >1545 <svg class="peach-sync-icon" width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1546 <path d="M12.5 6A5.5 5.5 0 0 0 2.7 3.3" />1547 <path d="M1.5 8A5.5 5.5 0 0 0 11.3 10.7" />1548 <path d="M2.6 1v2.4h2.4" />1549 <path d="M11.4 13v-2.4H9" />1550 </svg>1551 <span class={`peach-sync-pip is-${syncLight()}`} aria-hidden="true" />1552 </button>1553 <button1554 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1555 onClick={() => setView("settings")}1556 aria-label="Settings"1557 >1558 <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1559 <path1560 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"1561 stroke="currentColor"1562 stroke-width="0.9"1563 />1564 <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1565 </svg>1566 </button>1567 <button1568 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1569 onClick={handleAdd}1570 aria-label="New entry"1571 >1572 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1573 <path d="M5.5 1v9M1 5.5h9" />1574 </svg>1575 </button>1576 <button1577 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1578 onClick={() => void handleHeaderLock()}1579 aria-label="Lock vault"1580 >1581 <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1582 <path1583 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"1584 stroke="currentColor"1585 stroke-width="1"1586 stroke-linecap="round"1587 />1588 <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1589 <circle cx="7" cy="10.25" r="1" fill="currentColor" />1590 </svg>1591 </button>1592 </div>1593 </header>15941595 <Show when={pendingProNotice() !== null}>1596 <div class="peach-pro-notice-banner">1597 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1598 <button1599 class="peach-pro-notice-link"1600 onClick={() => {1601 setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1602 setSettingsInitialSection("license");1603 setView("settings");1604 setPendingProNotice(null);1605 }}1606 >1607 Learn more →1608 </button>1609 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1610 </div>1611 </Show>16121613 <Show when={pendingRememberSite()}>1614 {(request) => {1615 const origin = getSecurityOriginDisplay(request().url);1616 const originLabel = detectHomograph(origin) ? `⚠ ${origin}` : origin;1617 return (1618 <div class="peach-pro-notice-banner">1619 <span>Also use “{request().title}” on {originLabel}?</span>1620 <button1621 class="peach-pro-notice-link"1622 onClick={async () => {1623 await sendMessage({1624 type: "ADD_ADDITIONAL_URL",1625 payload: { id: request().entryId, url: request().url },1626 });1627 await chrome.storage.session.remove("pendingRememberSite");1628 setPendingRememberSite(null);1629 }}1630 >1631 Add1632 </button>1633 <button1634 class="peach-pro-notice-close"1635 onClick={async () => {1636 await chrome.storage.session.remove("pendingRememberSite");1637 setPendingRememberSite(null);1638 }}1639 aria-label="Dismiss"1640 >1641 ✕1642 </button>1643 </div>1644 );1645 }}1646 </Show>16471648 <div class="peach-shell-body">1649 <div class="peach-shell-veil"></div>1650 <div class="peach-content-frame" data-content-phase={contentPhase()}>1651 <EntryView1652 keyboardEnabled={view() === "entry"}1653 entry={selectedEntry()}1654 currentTabUrl={currentTabUrl()}1655 entries={entries()}1656 isPro={proStatus()}1657 onDelete={handleDelete}1658 onFieldSaved={() => void loadEntries()}1659 onEscape={() => { setView("list"); }}1660 onProGate={(feature) => {1661 setUpgradeHighlight(feature);1662 setSettingsInitialSection("license");1663 setView("settings");1664 }}1665 />1666 </div>16671668 <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1669 <div class="peach-search-bar">1670 <span class="peach-search-icon">1671 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1672 <circle cx="5.5" cy="5.5" r="3.75" />1673 <path d="M8.75 8.75L11.5 11.5" />1674 </svg>1675 </span>1676 <input1677 class="peach-search-input"1678 placeholder="search vault..."1679 autocomplete="off"1680 aria-label="Search vault entries"1681 onClick={openList}1682 onFocus={openList}1683 readOnly1684 />1685 <div class="peach-search-meta">1686 <span class="peach-search-counter">1687 {nonNoteEntries().length > 01688 ? `${selectedIndex() + 1} / ${nonNoteEntries().length}`1689 : "0 entries"}1690 </span>1691 <span class="peach-divider"></span>1692 <button1693 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1694 onClick={() => navigate(-1)}1695 aria-label="Previous entry"1696 disabled={nonNoteEntries().length === 0}1697 >1698 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1699 <path d="M5 1L1 5.5L5 10" />1700 </svg>1701 </button>1702 <button1703 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1704 onClick={() => navigate(1)}1705 aria-label="Next entry"1706 disabled={nonNoteEntries().length === 0}1707 >1708 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1709 <path d="M1 1L5 5.5L1 10" />1710 </svg>1711 </button>1712 </div>1713 </div>1714 </section>1715 </div>17161717 {}1718 <Show when={alertGroups().length > 0}>1719 <div class="peach-sync-alerts">1720 <For each={alertGroups()}>1721 {([type, group]) => {1722 const idx = () => Math.min(alertFlipIdx()[type] ?? 0, group.length - 1);1723 return (1724 <div class="peach-sync-alert">1725 <Show when={group.length > 1}>1726 <button1727 class="peach-sync-alert-flip"1728 type="button"1729 aria-label="Previous"1730 onClick={() => flipAlert(type, -1)}1731 >‹</button>1732 </Show>1733 <span class="peach-sync-alert-text">⚠ {syncAlertText(group[idx()])}</span>1734 <Show when={group.length > 1}>1735 <span class="peach-sync-alert-count">{idx() + 1}/{group.length}</span>1736 <button1737 class="peach-sync-alert-flip"1738 type="button"1739 aria-label="Next"1740 onClick={() => flipAlert(type, 1)}1741 >›</button>1742 </Show>1743 <button1744 class="peach-sync-alert-dismiss"1745 type="button"1746 aria-label={group.length > 1 ? "Dismiss all" : "Dismiss"}1747 onClick={() => void dismissAlertGroup(type as SyncAlert["type"])}1748 >×</button>1749 </div>1750 );1751 }}1752 </For>1753 </div>1754 </Show>17551756 <Show when={view() === "list"}>1757 <VaultView1758 entries={entries()}1759 selectedEntryId={selectedEntry()?.id}1760 currentTabDomain={currentTabDomain()}1761 sortOrder={sortOrder()}1762 searchSeed={listSearchSeed()}1763 searchSeedNonce={listSearchSeedNonce()}1764 onSelectEntry={handleSelectEntry}1765 onSortOrderChange={setSortOrder}1766 onAdd={handleAdd}1767 onClose={() => setView("entry")}1768 onOpenTrash={() => setView("trash")}1769 onDeleteMany={handleDeleteMany}1770 />1771 </Show>1772 <Show when={view() === "settings" || settingsExiting()}>1773 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1774 <SettingsView1775 entriesCount={entries().length}1776 breachMonitoring={breachMonitoring()}1777 lockOnClose={lockOnClose()}1778 autoLockMinutes={autoLockMinutes()}1779 trashRetentionDays={trashRetentionDays()}1780 lockedSyncRequiresUnlock={lockedSyncRequiresUnlock()}1781 theme={theme()}1782 accentColor={accentColor()}1783 textSize={textSize()}1784 reduceMotion={reduceMotion()}1785 isPro={proStatus()}1786 initialSection={settingsInitialSection()}1787 upgradeHighlight={upgradeHighlight()}1788 isExiting={settingsExiting()}1789 onBreachMonitoringChange={applyBreachMonitoring}1790 onLockOnCloseChange={applyLockOnClose}1791 onLockedSyncRequiresUnlockChange={applyLockedSyncRequiresUnlock}1792 onAutoLockMinutesChange={applyAutoLockMinutes}1793 onTrashRetentionChange={applyTrashRetention}1794 onThemeChange={applyTheme}1795 onAccentChange={applyAccentColor}1796 onTextSizeChange={applyTextSize}1797 onReduceMotionChange={applyReduceMotion}1798 shortcuts={shortcutBindings()}1799 onShortcutsChange={applyShortcuts}1800 onLicenseChange={async () => {1801 1802 1803 1804 1805 1806 await refreshProStatus();1807 void loadEntries();1808 const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1809 if ((should as { ok: boolean; data?: boolean }).data === true) {1810 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1811 setView("pro-celebration");1812 window.setTimeout(() => setView("settings"), 2500);1813 }1814 }}1815 onBack={() => {1816 setSettingsExiting(true);1817 window.setTimeout(() => {1818 setSettingsExiting(false);1819 setSettingsInitialSection(null);1820 setUpgradeHighlight(null);1821 handleBack();1822 }, 200);1823 }}1824 onImport={() => setView("import")}1825 onExport={() => setView("export")}1826 onBreachCheckDone={loadEntries}1827 onOpenHealth={openHealth}1828 onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }}1829 onOpenFileImport={handleOpenFileImport}1830 onOpenPanic={() => setView("panic")}1831 onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}1832 onOpenCodexRestore={() => setView("codex-restore")}1833 onCardChange={setSettingsActiveCard}1834 onProGate={(feature) => {1835 setUpgradeHighlight(feature);1836 setSettingsInitialSection("license");1837 }}1838 />1839 </Suspense>1840 </Show>1841 <Show when={view() === "codex-generate"}>1842 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1843 <CodexGenerateView1844 isPro={proStatus()}1845 onBack={() => setView("settings")}1846 onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1847 onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}1848 onVaultLocked={() => handleLock()}1849 />1850 </Suspense>1851 </Show>1852 <Show when={view() === "codex-restore"}>1853 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1854 <CodexRestoreView1855 onBack={() => setView("lock")}1856 onRestoreSuccess={async () => {1857 1858 1859 1860 await handleUnlock();1861 }}1862 />1863 </Suspense>1864 </Show>1865 <Show when={view() === "pair-device"}>1866 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1867 <PairDeviceView1868 isPro={proStatus()}1869 onProChanged={() => { void refreshProStatus(); }}1870 onBack={() => setView("settings")}1871 onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1872 />1873 </Suspense>1874 </Show>1875 <Show when={view() === "add"}>1876 <AddView1877 entry={editEntry()}1878 entries={entries()}1879 onBack={handleBack}1880 onSave={handleSave}1881 />1882 </Show>1883 <Show when={view() === "import"}>1884 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1885 <ImportView onBack={() => setView("settings")} onImport={handleSave} />1886 </Suspense>1887 </Show>1888 <Show when={view() === "export"}>1889 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1890 <ExportView onBack={handleExportBack} />1891 </Suspense>1892 </Show>1893 <Show when={view() === "panic"}>1894 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1895 <PanicView1896 onCancel={() => setView("settings")}1897 onOpenExport={() => {1898 setReturnToPanic(true);1899 setView("export");1900 }}1901 onDestroyed={async () => {1902 1903 1904 setOnboardingCardAtExit(0);1905 void storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1906 clearOnboardingExit();1907 setEntries([]);1908 setSelectedEntry(null);1909 setEditEntry(undefined);1910 setHasVault(false);1911 setOnboarded(false);1912 setProStatus(false);1913 setPendingPasskey(null);1914 setUpgradeHighlight(null);1915 setSettingsInitialSection(null);1916 setPendingProNotice(null);1917 setPendingCelebration(false);1918 setReturnToPanic(false);1919 setContentPhase("idle");1920 setView("lock");1921 }}1922 />1923 </Suspense>1924 </Show>1925 {1926}1927 <Show when={view() === "health" || (view() === "add" && editBackView() === "health")}>1928 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1929 <HealthView1930 entries={entries()}1931 isPro={proStatus()}1932 onFixEntry={handleFixEntry}1933 onBack={() => setView("settings")}1934 onRefresh={loadEntries}1935 hidden={view() !== "health"}1936 />1937 </Suspense>1938 </Show>1939 <Show when={view() === "trash"}>1940 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1941 <TrashView1942 onBack={() => setView("list")}1943 onRestored={() => void loadEntries()}1944 />1945 </Suspense>1946 </Show>1947 <Show when={showNotes()}>1948 <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>1949 <NotesMode1950 notes={entries().filter((e) => e.type === "note")}1951 initialNoteId={openNoteId()}1952 onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}1953 onNoteListChanged={() => void loadEntries()}1954 />1955 </div>1956 </Show>1957 <Show when={restoreView() === "file"}>1958 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1959 <ImportView1960 mode="onboarding"1961 replacesExistingVault={true}1962 onBack={() => setRestoreView(null)}1963 onImport={async () => {1964 setRestoreView(null);1965 await handleUnlock();1966 }}1967 />1968 </Suspense>1969 </Show>1970 </section>1971 </Show>1972 </Show>1973 </Show>1974 </Show>1975 <Show when={showShortcutsHelp()}>1976 <ShortcutsHelpOverlay1977 bindings={shortcutBindings()}1978 onClose={() => setShowShortcutsHelp(false)}1979 />1980 </Show>1981 </div>1982 );1983}19841985function ShortcutsHelpOverlay(props: {1986 bindings: Record<ShortcutAction, Keybinding>;1987 onClose: () => void;1988}) {1989 return (1990 <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1991 <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1992 <div class="peach-shortcuts-help-head">1993 <h3>Keyboard shortcuts</h3>1994 <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1995 </div>1996 <div class="peach-shortcuts-help-body">1997 <p class="peach-settings-sub-label">ENTRY VIEW</p>1998 <ul>1999 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (2000 <li>2001 <span>{d.label}</span>2002 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>2003 </li>2004 ))}2005 </ul>2006 <p class="peach-settings-sub-label">ANYWHERE</p>2007 <ul>2008 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (2009 <li>2010 <span>{d.label}</span>2011 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>2012 </li>2013 ))}2014 </ul>2015 </div>2016 </div>2017 </div>2018 );2019}