import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; import { sendMessage } from "../App"; import { PeachMark } from "../components/PeachMark"; import { SyncModeSelector } from "../components/SyncModeSelector"; import { StackDeck } from "../components/settings/StackDeck"; import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types"; import { RecoveryWordDisplayView } from "./RecoveryWordDisplay"; import { WhatsNewView } from "./WhatsNew"; import type { Canary } from "../../shared/types"; import { ACCENT_COLORS } from "../../shared/types"; import { PEACH_VERSION, hasReleaseNotes } from "../../shared/release-notes"; import type { AccentColorName, PasskeyProviderState, ProFeature, VaultSnapshot, } from "../../shared/types"; import type { PairedDevice } from "../../shared/sync-types"; import { describeSyncOutcome, syncIndicator, syncIndicatorLabel, type SyncOutcomeSummary, } from "../../shared/sync-outcome"; import type { SyncGeneration } from "../../background/sync-generation"; import { DEFAULT_BINDINGS, findConflict, formatKeybinding, keybindingFromEvent, SHORTCUT_DESCRIPTORS, type Keybinding, type ShortcutAction, } from "../../shared/shortcuts"; import { WEB3FORMS_KEY } from "../../shared/config"; interface LicenseInfo { valid: boolean; plan: "free" | "pro"; issued?: string; } interface SettingsProps { entriesCount: number; breachMonitoring: boolean; lockOnClose: boolean; autoLockMinutes: number; trashRetentionDays: number; theme: "dark" | "light"; accentColor: AccentColorName; textSize: "small" | "medium" | "large"; reduceMotion: boolean; isPro?: boolean; initialSection?: string | null; upgradeHighlight?: ProFeature | null; onBreachMonitoringChange: (enabled: boolean) => void | Promise; onLockOnCloseChange: (enabled: boolean) => void | Promise; onAutoLockMinutesChange: (minutes: number) => void | Promise; onTrashRetentionChange: (days: number) => void | Promise; onThemeChange: (theme: "dark" | "light") => void; onAccentChange: (name: AccentColorName) => void | Promise; onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise; onReduceMotionChange: (enabled: boolean) => void | Promise; shortcuts: Record; onShortcutsChange: (active: Record) => void | Promise; onLicenseChange?: () => void | Promise; isExiting?: boolean; onBack: () => void; onImport: () => void; onExport: () => void; onBreachCheckDone: () => void; onOpenHealth?: () => void; onOpenSyncPairing?: (method?: "code") => void; onOpenFileImport?: () => void; onOpenPanic?: () => void; onOpenCodexGenerate?: () => void; onOpenCodexRestore?: () => void; /** Invoked from within Settings when a Pro-gated control is tapped on * free tier. App owns the upgrade navigation + highlight state, so this * callback is the single way for Settings to request that navigation. */ onProGate?: (feature: ProFeature) => void; onCardChange?: (id: CardId) => void; } const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"]; const MAX_FEEDBACK_MESSAGE_LENGTH = 4000; const PRO_CELEBRATION_CARD_STEP_MS = 150; const PRO_CELEBRATION_THANK_YOU_MS = 3200; function sanitizeFeedbackMessage(value: string): string { return value .trim() .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH) // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — strips control characters from feedback text .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function formatActivatedSince(issued: string | undefined): string { if (!issued) return "Active"; try { const d = new Date(issued); if (Number.isNaN(d.getTime())) return "Active"; return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`; } catch { return "Active"; } } function initialSectionToCard(value: string | null | undefined): CardId | null { if (!value) return null; if (value === "security") return "protection"; if (value === "license") return "pro"; if ( value === "protection" || value === "vault" || value === "sync" || value === "appearance" || value === "shortcuts" || value === "pro" || value === "feedback" ) { return value; } return null; } /** * §11 migration copy. It must never imply that old peers keep working: the * whole point of the reset is that they cannot. */ const SECURE_RESET_DISCLOSURE = "Peach will archive this device's old Sync trust and start the new Sync generation. " + "Every other device must be updated first, then re-paired with this one — they will " + "not continue on their own. Your vault and your recovery words are not changed."; function secureResetHeadline(state: string | undefined): string { switch (state) { case "LEGACY_ACTIVE": return "Legacy Sync generation"; case "RESET_REQUIRED": return "Reset required"; case "RESET_CONFIRMED": return "Reset not completed"; case "V2_ANCHOR": case "REPAIRING": return "Not re-paired"; case "V2_ACTIVE": return "New Sync generation"; case "UNKNOWN": return "Sync generation unreadable"; default: return "Sync generation"; } } function secureResetDetail(state: string | undefined): string { switch (state) { case "LEGACY_ACTIVE": return "This device still holds the old Sync trust. Resetting retires it for good: update Peach on every other device, then re-pair each one."; case "RESET_REQUIRED": return "Sync is stopped until the reset completes. Update Peach on every other device, then reset this one and re-pair each device."; case "RESET_CONFIRMED": return "The reset did not finish. Nothing was lost — the old trust is archived. Try again."; case "V2_ANCHOR": case "REPAIRING": return "This device anchors the new Sync generation and is not re-paired yet. Pair each device again; until then it keeps its own vault and cannot sync."; case "V2_ACTIVE": return "This device is on the new Sync generation. Each device is re-paired individually."; case "UNKNOWN": return "This device's Sync generation can't be read, so sync is stopped. Reinstall or contact support."; default: return "Sync state is unavailable."; } } export function SettingsView(props: SettingsProps) { const manifest = chrome.runtime.getManifest(); const version = manifest.version; const initial = props.initialSection; const [activeCard, setActiveCard] = createSignal( props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro" ); createEffect(() => { props.onCardChange?.(activeCard()); }); const [justActivated, setJustActivated] = createSignal(false); const [isFanning, setIsFanning] = createSignal(false); function triggerCelebration() { if (isFanning()) return; setJustActivated(false); setIsFanning(true); setActiveCard("pro"); const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"]; fanOrder.forEach((id, i) => { window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS); }); window.setTimeout(() => { setActiveCard("pro"); setIsFanning(false); setJustActivated(true); window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS); }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS); } function handleProCelebrationClick(e: MouseEvent) { e.stopPropagation(); triggerCelebration(); } const [checkingBreaches, setCheckingBreaches] = createSignal(false); const [breachResult, setBreachResult] = createSignal(null); const [licenseInfo, setLicenseInfo] = createSignal(null); const [licenseKeyInput, setLicenseKeyInput] = createSignal(""); const [licenseError, setLicenseError] = createSignal(null); const [, setLicenseSuccess] = createSignal(null); const [activatingLicense, setActivatingLicense] = createSignal(false); const isPro = () => props.isPro ?? false; const cards = createMemo(() => [ { id: "pro", num: "I · FOREVER", title: "Peach Pro", lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever", bgVar: "--stack-pro-bg", fgVar: "--stack-pro-fg", icon: "spark", onIconClick: isPro() ? triggerCelebration : undefined, }, { id: "protection", num: "II · PROTECT", title: "Protection", lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`, bgVar: "--stack-protect-bg", fgVar: "--stack-protect-fg", icon: "shield", }, { id: "vault", num: "III · KEEP", title: "Vault", lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`, bgVar: "--stack-vault-bg", fgVar: "--stack-vault-fg", icon: "lock", }, { id: "sync", num: "IV · ECHO", title: "Sync", lede: isPro() ? "End to End Encryption" : "Pro · encrypted device sync", bgVar: "--stack-sync-bg", fgVar: "--stack-sync-fg", icon: "sync", }, { id: "appearance", num: "V · DRESS", title: "Appearance", lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`, bgVar: "--stack-appear-bg", fgVar: "--stack-appear-fg", icon: "sun", }, { id: "shortcuts", num: "VI · KEYS", title: "Shortcuts", lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`, bgVar: "--stack-shortcut-bg", fgVar: "--stack-shortcut-fg", icon: "keys", }, { id: "feedback", num: "VII · ECHO", title: "Feedback", lede: `version ${version} · tell us what would make it better`, bgVar: "--stack-feedback-bg", fgVar: "--stack-feedback-fg", icon: "bubble", }, ]); const [passkeyState, setPasskeyState] = createSignal(null); const [passkeySaving, setPasskeySaving] = createSignal(false); // UI-3 / MV-7: devices vouched by another of the user's devices, awaiting // explicit confirmation before they become sync targets. interface PendingVouched { deviceId: string; deviceName: string; longTermPublicKeyHex: string; receivedAt: number; vouchedByDeviceId: string; } const [pendingVouched, setPendingVouched] = createSignal([]); const [vouchedBusy, setVouchedBusy] = createSignal(null); // Paired-device management lives here — Settings is the sync hub. const [pairedDevices, setPairedDevices] = createSignal([]); const [trustDataCorrupted, setTrustDataCorrupted] = createSignal(false); const [confirmUnpair, setConfirmUnpair] = createSignal(null); const [unpairError, setUnpairError] = createSignal(null); const [renameDeviceId, setRenameDeviceId] = createSignal(null); const [renameDraft, setRenameDraft] = createSignal(""); const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle"); const [syncFlash, setSyncFlash] = createSignal(null); // The last sync ATTEMPT, kept on screen rather than flashed for 3 seconds — // a transient toast is no use for diagnosing a phone that isn't syncing. const [syncSummary, setSyncSummary] = createSignal({}); // §11: the Sync generation this installation is on. Not secret — a version // and a state name — so it renders the same in any session. const [generation, setGeneration] = createSignal(null); const [resetOpen, setResetOpen] = createSignal(false); const [resetPassword, setResetPassword] = createSignal(""); const [resetError, setResetError] = createSignal(null); const [resetting, setResetting] = createSignal(false); const syncLight = () => syncStatus() === "syncing" ? "busy" : syncIndicator(syncSummary(), pairedDevices().length); const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary()); const lastSyncLine = () => { const s = syncSummary(); if (!s.outcome) return "No sync attempted yet"; return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`; }; async function loadSyncState() { const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as { ok: boolean; data?: SyncOutcomeSummary; }; if (r.ok && r.data) { setSyncSummary(r.data); setSyncStatus(r.data.status ?? "idle"); } } async function loadGeneration() { const r = (await sendMessage({ type: "GET_SYNC_GENERATION" })) as { ok: boolean; data?: SyncGeneration | null; }; if (r.ok) setGeneration(r.data ?? null); } async function runSecureReset() { setResetting(true); const r = (await sendMessage({ type: "SECURE_RESET", payload: { password: resetPassword() }, })) as { ok: boolean; data?: SyncGeneration | null; error?: string }; setResetting(false); setResetPassword(""); if (!r.ok) { setResetError(r.error ?? "The reset did not complete — nothing was lost, try again"); await loadGeneration(); return; } setResetError(null); setResetOpen(false); if (r.data) setGeneration(r.data); } const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; function syncRelativeTime(when: number | undefined): string { if (!when) return "Not synced yet"; const elapsed = Date.now() - when; if (elapsed < 60_000) return "Just now"; const minutes = Math.floor(elapsed / 60_000); if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; const days = Math.floor(hours / 24); return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently"; } function isSyncStale(d: PairedDevice): boolean { return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS; } function deviceTypeLabel(t: PairedDevice["deviceType"]): string { switch (t) { case "android": return "Android"; case "ios": return "iPhone"; case "extension": return "Browser"; default: return "Device"; } } async function loadPairedDevices() { if (!isPro()) return; const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as { ok: boolean; error?: string; data?: { devices: PairedDevice[] }; }; if (r.ok) { setTrustDataCorrupted(false); setPairedDevices(r.data?.devices ?? []); } else if (r.error === "trust_data_corrupted") { setPairedDevices([]); setTrustDataCorrupted(true); } } async function unpairDevice(deviceId: string) { const result = (await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } })) as { ok: boolean; error?: string; }; if (!result.ok) { setUnpairError(result.error ?? "Could not remove this device"); return; } setUnpairError(null); setConfirmUnpair(null); await loadPairedDevices(); } async function renameDevice(deviceId: string) { const name = renameDraft().trim(); if (!name) return; const result = (await sendMessage({ type: "RENAME_PAIRED_DEVICE", payload: { deviceId, name }, })) as { ok: boolean }; if (!result.ok) return; setRenameDeviceId(null); await loadPairedDevices(); } async function syncNow() { setSyncStatus("syncing"); setSyncFlash(null); const res = (await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true }, })) as { ok: boolean; error?: string }; if (!res.ok) { setSyncStatus("error"); setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error }); return; } // TRIGGER_SYNC returns as soon as the run is queued, so poll for the // outcome. A single delayed read used to land while the run was still // "syncing" and leave the button disabled forever. let s: SyncOutcomeSummary = { status: "syncing" }; for (let i = 0; i < 40 && s.status === "syncing"; i++) { await new Promise((r) => setTimeout(r, 250)); const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as { ok: boolean; data?: SyncOutcomeSummary; }; s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" }; } setSyncSummary(s); setSyncStatus(s.status ?? "idle"); // The persistent line below carries the outcome; the flash is only the // "that click did something" acknowledgement. setSyncFlash(describeSyncOutcome(s)); setTimeout(() => setSyncFlash(null), 4000); await loadPairedDevices(); } // UI-4 / MV-8: rotate sync identity confirmation dialog. const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false); const [rotating, setRotating] = createSignal(false); const [rotateError, setRotateError] = createSignal(null); const [showSyncConnection, setShowSyncConnection] = createSignal(false); const [showSyncRecovery, setShowSyncRecovery] = createSignal(false); async function loadPendingVouched() { const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as { ok: boolean; data?: PendingVouched[]; }; if (r.ok) setPendingVouched(r.data ?? []); } async function confirmVouched(deviceId: string) { setVouchedBusy(deviceId); try { await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } }); await loadPendingVouched(); } finally { setVouchedBusy(null); } } async function rejectVouched(deviceId: string) { setVouchedBusy(deviceId); try { await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } }); await loadPendingVouched(); } finally { setVouchedBusy(null); } } async function doRotateSyncIdentity() { setRotateError(null); setRotating(true); try { const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as { ok: boolean; error?: string; }; if (!r.ok) { setRotateError(r.error ?? "Rotation failed."); return; } setRotateConfirmOpen(false); // Backend set peach_recovery_pending; reuse the existing recovery-words // display (its GET_RECOVERY_WORDS fetch is exempted during pending). setShowNewRecoveryWords(true); } finally { setRotating(false); } } const [dedupCount, setDedupCount] = createSignal(null); const [dedupRunning, setDedupRunning] = createSignal(false); const [dedupDone, setDedupDone] = createSignal(null); const [declinedCardCount, setDeclinedCardCount] = createSignal(0); // Vault history (peach_vault_history) — last 10 snapshots, snapshotted // BEFORE every real-mode vault write so the user can roll back if a sync // or import overwrites the wrong state. const [historySnapshots, setHistorySnapshots] = createSignal([]); const [historyLoading, setHistoryLoading] = createSignal(false); const [historyRestoring, setHistoryRestoring] = createSignal(null); const [historyShowAll, setHistoryShowAll] = createSignal(false); async function loadVaultHistory() { setHistoryLoading(true); try { const res = await sendMessage({ type: "GET_VAULT_HISTORY" }); const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? []; setHistorySnapshots(list); } finally { setHistoryLoading(false); } } async function handleRestoreVaultSnapshot(id: string) { if (historyRestoring()) return; setHistoryRestoring(id); const res = await sendMessage({ type: "RESTORE_VAULT_SNAPSHOT", payload: { snapshotId: id }, }); setHistoryRestoring(null); if ((res as { ok: boolean }).ok) { // Vault is now locked — let the parent close Settings; App.tsx's // VAULT_LOCKED listener will route back to the lock screen. props.onBack(); } } function formatSnapshotTime(savedAt: number): string { const diff = Date.now() - savedAt; if (diff < 60_000) return "Just now"; if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; const d = new Date(savedAt); const isToday = new Date().toDateString() === d.toDateString(); const isYest = new Date(Date.now() - 86_400_000).toDateString() === d.toDateString(); const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); if (isToday) return `Today ${t}`; if (isYest) return `Yesterday ${t}`; return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`; } function reasonLabel(reason: VaultSnapshot["reason"]): string { return ( { local_save: "saved", sync_received: "synced", import: "imported", restore: "restored", } as const )[reason] ?? reason; } const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false); const [showWhatsNew, setShowWhatsNew] = createSignal(false); const [showChangePw, setShowChangePw] = createSignal(false); const [changePwCurrent, setChangePwCurrent] = createSignal(""); const [changePwNew, setChangePwNew] = createSignal(""); const [changePwConfirm, setChangePwConfirm] = createSignal(""); const [changePwSaving, setChangePwSaving] = createSignal(false); const [changePwError, setChangePwError] = createSignal(null); const [changePwSuccess] = createSignal(false); const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false); const [pinEnabled, setPinEnabled] = createSignal(false); const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null); const [pinCurrentPassword, setPinCurrentPassword] = createSignal(""); const [pinValue, setPinValue] = createSignal(""); const [pinConfirm, setPinConfirm] = createSignal(""); const [pinSaving, setPinSaving] = createSignal(false); const [pinError, setPinError] = createSignal(null); // UI-5 / LV-4: view existing recovery words behind a fresh password proof. const [showViewWords, setShowViewWords] = createSignal(false); const [viewWordsPassword, setViewWordsPassword] = createSignal(""); const [viewWordsError, setViewWordsError] = createSignal(null); const [viewedWords, setViewedWords] = createSignal(null); const [viewWordsBusy, setViewWordsBusy] = createSignal(false); async function handleViewRecoveryWords() { setViewWordsError(null); if (!viewWordsPassword()) { setViewWordsError("Enter your master password."); return; } setViewWordsBusy(true); try { const r = (await sendMessage({ type: "GET_RECOVERY_WORDS", payload: { password: viewWordsPassword() }, })) as { ok: boolean; data?: string[]; error?: string }; if (r.ok) { setViewedWords(r.data ?? []); setViewWordsPassword(""); return; } switch (r.error) { case "wrong_password": setViewWordsError("Incorrect password"); break; case "password_required": setViewWordsError("Enter your master password."); break; case "recovery_not_setup": setViewWordsError("Recovery words aren't set up for this vault yet."); break; default: setViewWordsError("Couldn't retrieve recovery words."); } } finally { setViewWordsBusy(false); } } function closeViewWords() { setShowViewWords(false); setViewWordsPassword(""); setViewWordsError(null); setViewedWords(null); } const [duressEnabled, setDuressEnabled] = createSignal(false); const [showDuressSetup, setShowDuressSetup] = createSignal(false); const [duressPassword, setDuressPassword] = createSignal(""); const [duressConfirm, setDuressConfirm] = createSignal(""); const [duressMainPassword, setDuressMainPassword] = createSignal(""); const [duressSaving, setDuressSaving] = createSignal(false); const [duressError, setDuressError] = createSignal(null); const [duressSuccess, setDuressSuccess] = createSignal(null); const [canaryEntries, setCanaryEntries] = createSignal>([]); const [canaryTestingId, setCanaryTestingId] = createSignal(null); const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null); const [fbCategory, setFbCategory] = createSignal("Bug report"); const [fbMessage, setFbMessage] = createSignal(""); type FeedbackState = 'idle' | 'sending' | 'sent' | 'error'; const [feedbackState, setFeedbackState] = createSignal('idle'); createEffect(() => { if (props.upgradeHighlight) { setActiveCard("pro"); } }); onMount(async () => { const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([ sendMessage({ type: "GET_LICENSE" }), sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }), sendMessage({ type: "IS_DURESS_ENABLED" }), sendMessage({ type: "GET_SETTINGS" }), sendMessage({ type: "GET_LICENSE_KEY" }), ]); // Vault history loads in the background — the Vault sub-view picks it up // when the user navigates in. Loading it lazily keeps Settings boot fast. void loadVaultHistory(); // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open. void loadPendingVouched(); void loadPairedDevices(); void loadSyncState(); void loadGeneration(); const onBroadcast = (msg: unknown) => { const m = msg as { type?: string; payload?: unknown } | null; if (m?.type === "VOUCHED_DEVICE_PENDING") { void loadPendingVouched(); } if (m?.type === "SYNC_STATE_CHANGED") { const payload = (m.payload ?? {}) as SyncOutcomeSummary; setSyncSummary(payload); setSyncStatus(payload.status ?? "idle"); } }; chrome.runtime?.onMessage?.addListener(onBroadcast); onCleanup(() => { try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ } }); if ((licenseRes as { ok: boolean }).ok) { setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null); } if ((passkeyRes as { ok: boolean }).ok) { setPasskeyState( (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null ); } if ((duressRes as { ok: boolean }).ok) { setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false); } if ((settingsRes as { ok: boolean }).ok) { const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data; setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0); setPinEnabled(sData?.pinEnabled === true); } if ((keyRes as { ok: boolean }).ok) { setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null); } const entriesRes = await sendMessage({ type: "GET_ENTRIES" }); if ((entriesRes as { ok: boolean }).ok) { const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? []; setCanaryEntries( entries .filter((e) => e.canary?.webhookUrl) .map((e) => ({ id: e.id, title: e.title, canary: e.canary! })) ); } }); function resetPinFlow() { setPinFlow(null); setPinCurrentPassword(""); setPinValue(""); setPinConfirm(""); setPinError(null); } async function handleSetupPin() { setPinError(null); if (!pinCurrentPassword()) { setPinError("enter your current password"); return; } if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; } if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; } setPinSaving(true); const res = await sendMessage({ type: "SETUP_PIN", payload: { currentPassword: pinCurrentPassword(), pin: pinValue() }, }) as { ok: boolean; error?: string }; if (res.ok) { setPinEnabled(true); resetPinFlow(); } else { setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN"); } setPinSaving(false); } async function handleDisablePin() { setPinError(null); if (!pinCurrentPassword()) { setPinError("enter your current password"); return; } setPinSaving(true); const res = await sendMessage({ type: "DISABLE_PIN", payload: { currentPassword: pinCurrentPassword() }, }) as { ok: boolean; error?: string }; if (res.ok) { setPinEnabled(false); resetPinFlow(); } else { setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN"); } setPinSaving(false); } async function handleChangePassword() { setChangePwError(null); if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; } if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; } if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; } if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; } setChangePwSaving(true); const res = await sendMessage({ type: "CHANGE_PASSWORD", payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() }, }) as { ok: boolean; error?: string }; if (res.ok) { setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm(""); setShowChangePw(false); // LV-4: the user just authenticated (current password), so the recovery // words reveal that follows is exempt from the re-auth gate. The // background owns this session-scoped exemption; the display clears it. await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" }); setShowNewRecoveryWords(true); } else { setChangePwError(res.error ?? "failed to change password"); } setChangePwSaving(false); } const [copied, setCopied] = createSignal(false); async function copyLicenseKey(key: string) { await navigator.clipboard.writeText(key); setCopied(true); setTimeout(() => setCopied(false), 2000); } async function handleActivateLicense() { const key = licenseKeyInput().trim(); if (!key) return; setActivatingLicense(true); setLicenseError(null); setLicenseSuccess(null); const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } }); setActivatingLicense(false); if ((res as { ok: boolean }).ok) { const data = (res as { ok: boolean; data?: LicenseInfo }).data; if (data?.valid) { setLicenseInfo(data); setLicenseKey(key); setLicenseSuccess("License activated!"); setLicenseKeyInput(""); } else { setLicenseError("Invalid license key"); } } else { setLicenseError("Activation failed"); } } async function handleTogglePasskeys() { const current = passkeyState(); if (!current || !current.supported) return; setPasskeySaving(true); const res = await sendMessage({ type: "SET_PASSKEY_PROVIDER_ENABLED", payload: { enabled: !current.enabled }, }); setPasskeySaving(false); if ((res as { ok: boolean }).ok) { setPasskeyState( (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null ); } } async function handleCheckAllBreaches() { setCheckingBreaches(true); setBreachResult(null); const res = await sendMessage({ type: "CHECK_ALL_BREACHES" }); setCheckingBreaches(false); if ((res as { ok: boolean }).ok) { const data = (res as { ok: boolean; data?: Record }).data ?? {}; const breached = Object.values(data).filter((count) => count > 0).length; setBreachResult( breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean" ); props.onBreachCheckDone(); } } async function handleSetupDuress() { const mainPw = duressMainPassword().trim(); const duressPw = duressPassword().trim(); const confirmPw = duressConfirm().trim(); if (!mainPw) { setDuressError("enter your main password to confirm"); return; } if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; } if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; } if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; } setDuressSaving(true); setDuressError(null); const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean }; if (!verifyRes.ok || !verifyRes.data) { setDuressError("incorrect main password"); setDuressSaving(false); return; } const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string }; setDuressSaving(false); if (res.ok) { setDuressEnabled(true); setShowDuressSetup(false); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); setDuressSuccess("Duress password set. If entered, a decoy vault will be shown."); setTimeout(() => setDuressSuccess(null), 5000); } else { setDuressError( res.error === "session_expired" ? "Vault session expired — lock and unlock Peach, then try again" : res.error ?? "failed to set duress password" ); } } async function handleClearDeclinedCards() { await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } }); setDeclinedCardCount(0); } async function handleCountDuplicates() { setDedupRunning(true); setDedupDone(null); const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number }; setDedupRunning(false); if (res.ok) setDedupCount(res.data ?? 0); } async function handleDeduplicate() { setDedupRunning(true); const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number }; setDedupRunning(false); if (res.ok) { setDedupDone(res.data ?? 0); setDedupCount(0); } } async function handleDisableDuress() { const mainPw = duressMainPassword().trim(); if (!mainPw) { setDuressError("enter your main password to confirm"); return; } setDuressSaving(true); setDuressError(null); const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean }; if (!verifyRes.ok || !verifyRes.data) { setDuressError("incorrect main password"); setDuressSaving(false); return; } const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string }; setDuressSaving(false); if (res.ok) { setDuressEnabled(false); setShowDuressSetup(false); setDuressMainPassword(""); setDuressSuccess("Duress password disabled."); setTimeout(() => setDuressSuccess(null), 4000); } else { setDuressError(res.error ?? "failed to disable duress password"); } } async function handleSubmitFeedback() { const message = sanitizeFeedbackMessage(fbMessage()); if (!message) return; setFeedbackState('sending'); try { const category = FEEDBACK_CATEGORIES.includes(fbCategory()) ? fbCategory() : FEEDBACK_CATEGORIES[0]; const res = await fetch('https://api.web3forms.com/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ access_key: WEB3FORMS_KEY, subject: `Peach Feedback - ${category}`, message, from_name: 'Peach User', // Honeypot — leave blank botcheck: '', }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json() as { success: boolean; message?: string }; if (!data.success) throw new Error(data.message ?? 'unknown error'); setFeedbackState('sent'); setFbMessage(''); setFbCategory(FEEDBACK_CATEGORIES[0]); } catch (err) { console.error('[feedback]', err); setFeedbackState('error'); setTimeout(() => setFeedbackState('idle'), 4000); } } function renderSecurity() { return (

LOCKING

Lock on close
Auto-lock after

QUICK UNLOCK

PIN unlock
setPinCurrentPassword((e.target as HTMLInputElement).value)} autocomplete="current-password" /> setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))} autocomplete="off" /> setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))} autocomplete="off" onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }} />

{pinError()}

setPinCurrentPassword((e.target as HTMLInputElement).value)} autocomplete="current-password" onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }} />

{pinError()}

BREACH PROTECTION

Breach monitoring
Check all passwords
Last check {breachResult()}

MASTER PASSWORD

Master password
setChangePwCurrent((e.target as HTMLInputElement).value)} autocomplete="current-password" /> setChangePwNew((e.target as HTMLInputElement).value)} autocomplete="new-password" /> setChangePwConfirm((e.target as HTMLInputElement).value)} autocomplete="new-password" onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }} />

{changePwError()}

password changed ✓

{/* UI-5 / LV-4: view existing recovery words behind a password proof. */}
Recovery words

Enter your master password to reveal your recovery words.

setViewWordsPassword((e.target as HTMLInputElement).value)} autocomplete="current-password" onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }} />

{viewWordsError()}

} >
{(word, i) => ( {i() + 1} {word} )}

DURESS PASSWORD ✦

Duress password Pro If entered under coercion, shows a decoy empty vault instead of your real one.
{duressEnabled() ? "on" : "off"}
If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden. The encrypted storage is identical in size whether a hidden vault exists or not, so the hidden vault cannot be proven or disproven without both passwords. This protects against a coercer who demands your password — not against device malware or someone who already has your device unlocked.
{duressSuccess()}
setDuressMainPassword(e.currentTarget.value)} /> setDuressPassword(e.currentTarget.value)} /> setDuressConfirm(e.currentTarget.value)} />

{duressError()}

CANARY ENTRIES ✦

Canary entries Pro Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.
{canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}
0}> {(entry) => (
{entry.title}
)}

PASSKEYS

Intercept passkey requests Peach can handle passkey sign-in and creation on supported Chromium browsers.
Status {passkeyState()?.statusLabel ?? "Unavailable"}
Browser {passkeyState()?.browserLabel ?? "Unknown"} {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}
Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.
{passkeyState()!.conflict}
); } function renderVault() { return (

ENTRIES

Entries {props.entriesCount}
Duplicates void handleCountDuplicates()} disabled={dedupRunning()} > {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`} }> {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}
0 && dedupDone() === null}>
{dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.

MAINTENANCE

Trash auto-delete
Advanced Vault Health
0}>
{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save

DATA

Import passwords
Export backup
Peach Codex Pro
{/* Restore from Codex: intentionally never gated — a printed Codex must remain restorable regardless of license state. */}
Restore from Codex

RELEASE NOTES

v{PEACH_VERSION}
VAULT HISTORY

Loading…

No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.

0}>

Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.

{(snap) => (
{formatSnapshotTime(snap.savedAt)} {[ snap.entryCount !== null ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}` : null, reasonLabel(snap.reason), ] .filter(Boolean) .join(" · ")}
)}
3}>

Replace vault from backup

Wipes the current vault and restores from a backup file. Recoverable only if your backup is good.

Destroy vault

Permanently wipes every entry, setting, and license on this device. There is no recovery.

); } function renderSync() { return (

SYNC STATUS

{/* §11: this panel is a migration call to action, not a permanent label. On the settled new generation there is nothing left to do here, so it goes away entirely — Android applies the same rule. */}

SECURE SYNC RESET

{secureResetHeadline(generation()?.state)} {secureResetDetail(generation()?.state)}

{SECURE_RESET_DISCLOSURE}

setResetPassword((e.target as HTMLInputElement).value)} autocomplete="current-password" />
0}>

PAIRED DEVICES

{(d) => (
{d.localAlias ?? d.deviceName ?? "Unnamed device"}} > setRenameDraft(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Enter") void renameDevice(d.deviceId); if (event.key === "Escape") setRenameDeviceId(null); }} /> {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}
{ setRenameDraft(d.localAlias ?? d.deviceName ?? ""); setRenameDeviceId(d.deviceId); }} >rename } >
} > {/* HOLE 4: revocation stops this device receiving future changes. It does NOT secure the vault against it — that device may already hold the recovery words, which are master-password-equivalent and restore the whole vault anywhere, forever, with no sync involved. Say so here, rather than leaving the user falsely reassured. */}

Stops this device receiving future changes. It doesn't lock it out of what it already has — if it's lost or stolen, also change your master password and generate new recovery words.

)} 0}>

PENDING DEVICES {pendingVouched().length}

{(dev) => (
{dev.deviceName} Key {dev.longTermPublicKeyHex.slice(0, 12).match(/.{4}/g)?.join(" ")} · requested {new Date(dev.receivedAt).toISOString().slice(0, 16).replace("T", " ")} UTC · vouched by device {dev.vouchedByDeviceId}. Allowing it can read existing vault contents and all future changes.
)}

ADD A DEVICE

At least one device needs Peach Pro — pair with a Pro device and it carries over to here.

Rotate sync identity Disconnects every device and replaces the keys used for sync.
} >
This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.
{rotateError()}
); } function renderAppearance() { const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [ { id: "medium", label: "Default" }, { id: "large", label: "Large" }, ]; return (

THEME

Theme

ACCENT

Accent
{(color) => (

TEXT SIZE

Size
{(opt) => ( )}

MOTION

Reduce motion
); } function renderShortcuts() { const [capturingAction, setCapturingAction] = createSignal(null); const [conflictMsg, setConflictMsg] = createSignal(null); function startCapture(action: ShortcutAction) { setConflictMsg(null); setCapturingAction(action); } function cancelCapture() { setCapturingAction(null); setConflictMsg(null); } function commitBinding(action: ShortcutAction, binding: Keybinding) { const conflict = findConflict(props.shortcuts, binding, action); if (conflict) { const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict); setConflictMsg(`Already bound to "${other?.label ?? conflict}"`); return; } const next = { ...props.shortcuts, [action]: binding }; setCapturingAction(null); setConflictMsg(null); void props.onShortcutsChange(next); } function resetAction(action: ShortcutAction) { const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] }; void props.onShortcutsChange(next); } function resetAll() { void props.onShortcutsChange({ ...DEFAULT_BINDINGS }); } // Capture-mode key handler — wired via ref onto the chip element so it // catches the next non-modifier keypress and ignores other bindings. function handleCaptureKey(e: KeyboardEvent) { e.preventDefault(); e.stopPropagation(); if (e.key === "Escape") { cancelCapture(); return; } const binding = keybindingFromEvent(e); if (!binding) return; // bare modifier — keep waiting const action = capturingAction(); if (action) commitBinding(action, binding); } return (

ENTRY VIEW

d.scope === "entry")}> {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}

ANYWHERE IN POPUP

d.scope === "global")}> {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}
{conflictMsg()}
); function renderRow( desc: typeof SHORTCUT_DESCRIPTORS[number], capturing: () => ShortcutAction | null, onStart: (a: ShortcutAction) => void, onCancel: () => void, onReset: (a: ShortcutAction) => void, onKey: (e: KeyboardEvent) => void, ) { const isActive = () => capturing() === desc.action; const binding = () => props.shortcuts[desc.action]; const isCustom = () => { const b = binding(); const d = DEFAULT_BINDINGS[desc.action]; return b.key !== d.key || Boolean(b.ctrl) !== Boolean(d.ctrl) || Boolean(b.meta) !== Boolean(d.meta) || Boolean(b.shift) !== Boolean(d.shift) || Boolean(b.alt) !== Boolean(d.alt); }; return (
{desc.label}
); } } const PRO_FEATURES: Array<{ feature: ProFeature; name: string; desc: string; }> = [ { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" }, { feature: "codex", name: "Peach Codex", desc: "Restore from paper" }, { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" }, { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" }, ]; const PRO_SECURITY_FEATURES: Array<{ feature: ProFeature; name: string; desc: string; }> = [ { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" }, { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" }, ]; const [licenseKey, setLicenseKey] = createSignal(null); const [showKey, setShowKey] = createSignal(false); const [deactivating, setDeactivating] = createSignal(false); async function handleDeactivateLicense() { setDeactivating(true); await sendMessage({ type: "DEACTIVATE_LICENSE" }); setLicenseInfo(null); await props.onLicenseChange?.(); setDeactivating(false); } async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) { setCanaryTestingId(entry.id); setCanaryTestResult(null); const res = await sendMessage({ type: "FIRE_CANARY_PING", payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" }, }) as { ok: boolean }; setCanaryTestResult({ id: entry.id, ok: res.ok }); setCanaryTestingId(null); setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000); } function renderLicense() { async function onActivate() { await handleActivateLicense(); if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") { await props.onLicenseChange?.(); } } return (

PEACH PRO · ONE-TIME

Everything,
everywhere.

One key unlocks every Pro feature — for the lifetime of the app.

{(item, i) => (
{String(i() + 1).padStart(2, "0")} {item.name} {item.desc}
)}
{(item, i) => (
{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")} {item.name} {item.desc}
)}

$39 once · yours forever

Get Peach Pro
ALREADY HAVE A KEY
setLicenseKeyInput(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }} autocomplete="off" spellcheck={false} />

{licenseError()}

} >

Peach Pro

{formatActivatedSince(licenseInfo()?.issued)}

thank you ✦

LICENSE KEY
•••••••••••••••••••••••••••• } > {licenseKey()!}
{(item) => (
✦ {item.name}
)}
); } function renderFeedback() { return (

Help us improve Peach. Every report goes directly to our inbox.

TYPE

{(cat) => { const selected = () => fbCategory() === cat; return ( ); }}

MESSAGE