import { For, Match, Show, Switch, createMemo, createSignal, onCleanup, onMount } from "solid-js"; import { sendMessage } from "../App"; import { PeachMark, PeachWordmark } from "../components/PeachMark"; import { RecoveryWordEntryView } from "./RecoveryWordEntry"; import { RecoveryWordDisplayView } from "./RecoveryWordDisplay"; import { RuneBackground } from "../components/RuneBackground"; import { RuneCanvas } from "../components/RuneCanvas"; import { storageGet, storageRemove, storageSet } from "../../shared/storage"; import { isPeachScript, decodePeachScript } from "../../crypto/peachScript"; import { parsePayload } from "../../crypto/peachPayload"; import { parseImportSource } from "../../shared/import"; import { IMPORT_LIMITS } from "../../shared/import-limits"; const peachMarkImg = new Image(); peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent( '' + '' + '' + '' + '' + '' + '' )}`; // Lazy-load @zxcvbn-ts — heavy (dictionaries ~700 kB) and only needed during // vault creation. Replaces the unmaintained zxcvbn@4.4.2 (LV-8); result.score // keeps the same 0-4 scale so downstream UI is unchanged. let _zxcvbn: ((password: string) => { score: number }) | null = null; async function getZxcvbn() { if (_zxcvbn) return _zxcvbn; const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([ import("@zxcvbn-ts/core"), import("@zxcvbn-ts/language-common"), import("@zxcvbn-ts/language-en"), ]); zxcvbnOptions.setOptions({ translations: en.translations, graphs: common.adjacencyGraphs, dictionary: { ...common.dictionary, ...en.dictionary }, }); _zxcvbn = (password: string) => zxcvbn(password); return _zxcvbn; } interface LockProps { hasVault: boolean; ready: boolean; isPro?: boolean; /** When true, render the recovery-words display directly instead of the * unlock UI. Used after a popup close+reopen during the create-vault * recovery confirmation flow (peach_recovery_pending flag). */ forceRecoveryWords?: boolean; onUnlock: () => void | Promise; /** Optional escape hatch: rewind from the create-vault screen back to * Onboarding. Only meaningful when the user reached create-vault from * Onboarding in the first place. */ onBackToOnboarding?: () => void; /** Optional: open the Peach Codex restore flow (camera scan of printed * Codex pages). Surfaced as a small footer link on the new-vault screen. */ onOpenCodexRestore?: () => void; /** Open directly on backup restore when onboarding routed the user there. */ initialRestore?: boolean; /** Resume the restore surface on the source the user had picked. Set from * RESTORE_SOURCE_STORAGE_KEY at boot — a popup close (the OS file picker * kills it) must land back on the same source form, not the picker. */ initialRestoreSource?: RestoreSource; } /** The restore source picked before a popup close, so boot can resume the * same source form instead of rewinding to the source picker. Written by * RestoreView, read/cleared by App. */ export const RESTORE_SOURCE_STORAGE_KEY = "peach_onboarding_restore_source"; type RestoreSurface = "restore" | null; type RecoverSurface = "words" | "new-password" | null; export function LockView(props: LockProps) { const [password, setPassword] = createSignal(""); const [confirmPassword, setConfirmPassword] = createSignal(""); const [error, setError] = createSignal(""); const [loading, setLoading] = createSignal(false); const [restoreSurface, setRestoreSurface] = createSignal( props.initialRestore ? "restore" : null, ); const [showPassword, setShowPassword] = createSignal(false); const [recoverSurface, setRecoverSurface] = createSignal(null); // After vault creation succeeds, set this to show the recovery words screen. // Only meaningful when hasVault === false. const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false); const MIN_LENGTH = 12; const MIN_ZXCVBN_SCORE = 2; const [strengthScore, setStrengthScore] = createSignal(null); const [pinAvailable, setPinAvailable] = createSignal(false); const [usingPin, setUsingPin] = createSignal(false); const [pin, setPin] = createSignal(""); const canCreate = createMemo( () => password().length >= MIN_LENGTH && confirmPassword().length >= MIN_LENGTH && password() === confirmPassword() && strengthScore() !== null && strengthScore()! >= MIN_ZXCVBN_SCORE ); const passwordStrength = createMemo(() => { const score = strengthScore(); if (score === null) return null; if (score <= 1) return "weak"; if (score === 2) return "fair"; if (score === 3) return "good"; return "strong"; }); const createBlockerMessage = createMemo(() => { if (!password()) return null; if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`; if (!confirmPassword()) return "confirm your password"; if (password() !== confirmPassword()) return "passwords don't match"; if (strengthScore() === null) return "checking strength…"; if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — use a longer passphrase"; return null; }); // Update strength asynchronously when password changes createMemo(() => { const value = password(); if (!value) { setStrengthScore(null); return; } getZxcvbn().then((zxcvbn) => { if (password() === value) setStrengthScore(zxcvbn(value).score); }).catch(() => { // zxcvbn unavailable — fall back to length-only heuristic if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1); }); }); async function handleUnlock() { if (!password().trim()) return; setLoading(true); setError(""); const response = await sendMessage({ type: "UNLOCK", payload: { password: password() }, }); const result = response as { ok: boolean; error?: string }; if (result.ok) { await props.onUnlock(); setLoading(false); return; } setError(getDisplayError(result.error, "incorrect password")); setPassword(""); const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined; passwordField?.classList.add("shake"); setTimeout(() => passwordField?.classList.remove("shake"), 300); setLoading(false); } async function handlePinUnlock() { if (!/^\d{4,12}$/.test(pin())) return; setLoading(true); setError(""); const response = await sendMessage({ type: "UNLOCK_WITH_PIN", payload: { pin: pin() }, }); const result = response as { ok: boolean; error?: string }; if (result.ok) { await props.onUnlock(); setLoading(false); return; } if (result.error === "pin_attempts_exceeded") { setUsingPin(false); setError("too many incorrect PIN attempts — use your master password"); } else if (result.error === "pin_session_expired") { setUsingPin(false); setError("PIN is unavailable until you unlock with your master password"); } else { setError(getDisplayError(result.error, "incorrect PIN")); } setPin(""); const pinField = document.querySelector(".lock-pin-input") as HTMLInputElement | undefined; pinField?.classList.add("shake"); setTimeout(() => pinField?.classList.remove("shake"), 300); setLoading(false); } async function checkPinAvailable(): Promise { if (!props.hasVault) return; try { const response = await sendMessage({ type: "GET_PIN_STATUS" }); const status = (response as { ok: boolean; data?: { configured?: boolean; available?: boolean; attemptsRemaining?: number; pinPreferred?: boolean }; }).data; const enabled = status?.configured === true && status.available === true; setPinAvailable(enabled); if (enabled) { const settingsResponse = await sendMessage({ type: "GET_SETTINGS" }); const settings = (settingsResponse as { ok: boolean; data?: { pinPreferred?: boolean } }).data; if (settings?.pinPreferred === true) setUsingPin(true); } } catch { /* settings are optional at the lock screen */ } } onMount(() => { void checkPinAvailable(); }); async function handleCreate() { if (!password().trim()) return; if (password().length < MIN_LENGTH) { setError(`password must be at least ${MIN_LENGTH} characters`); return; } if (password() !== confirmPassword()) { setError("passwords do not match"); return; } const score = strengthScore(); if (score === null || score < MIN_ZXCVBN_SCORE) { setError("password is too weak — use a longer passphrase"); return; } setLoading(true); setError(""); const response = await sendMessage({ type: "CREATE_VAULT", payload: { password: password() }, }); const result = response as { ok: boolean; error?: string }; if (result.ok) { // Don't unlock yet — show the recovery words screen first. The // background owns this session-scoped exemption so content scripts // cannot forge it through storage.local. await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" }); setShowRecoveryWords(true); setLoading(false); return; } setError(getDisplayError(result.error, "failed to create vault")); setLoading(false); } // ── All surfaces routed reactively via Switch/Match ─────────────────── return ( {/* ── Restore surface ───────────────────────────────────────── */} { // The restore tab is only reached from the onboarding carousel, // so back returns there — not to the create-vault form. if (props.onBackToOnboarding) props.onBackToOnboarding(); else setRestoreSurface(null); }} onUnlock={async () => { setRestoreSurface(null); await props.onUnlock(); }} /> {/* ── Recovery words display after fresh vault creation ────────── */} {/* Read props.forceRecoveryWords directly, not just the boot-time signal copy: when the vault is locked with a pending confirmation, App flips the flag after UNLOCK without remounting LockView. */} {/* ── Recovery word entry ─────────────────────────────────────── */} setRecoverSurface(null)} onRecoveryComplete={props.onUnlock} /> {/* ── Unlock (hasVault = true) ───────────────────────────────── */}
pro
vault locked

unlock
your vault.

{ if (props.ready) setTimeout(() => el.focus(), 80); }} onInput={(event) => setPassword((event.target as HTMLInputElement).value)} onKeyDown={(event) => { if (event.key !== "Enter" || !props.ready) return; void handleUnlock(); }} /> }> { if (props.ready) setTimeout(() => el.focus(), 80); }} onInput={(event) => setPin((event.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))} onKeyDown={(event) => { if (event.key !== "Enter" || !props.ready) return; void handlePinUnlock(); }} />

{error()}

{/* ── Onboarding (hasVault = false) ─────────────────────────── */}
pro

{"keep your\nsecrets."}


setPassword((event.target as HTMLInputElement).value)} onKeyDown={(event) => { if (event.key !== "Enter" || !props.ready) return; void handleCreate(); }} /> 0 && passwordStrength() !== null} fallback={

At least 12 characters.

} >
{passwordStrength()}
setConfirmPassword((event.target as HTMLInputElement).value)} onKeyDown={(event) => { if (event.key === "Enter" && props.ready) void handleCreate(); }} />

{error()}

Save your 12 recovery words after creating — they're the only way back in if you forget your password.

); } // ── Restore surface ──────────────────────────────────────────────────────── export type RestoreSource = "symbols" | "codex" | "csv"; const RESTORE_SOURCES: { id: RestoreSource; name: string; detail: string }[] = [ { id: "symbols", name: "PeachScript", detail: "Paste the rune string, or open a .peach file." }, { id: "codex", name: "Codex", detail: "Scan printed pages, or import the PDF." }, { id: "csv", name: "CSV", detail: "A .csv or .json file from another password manager." }, ]; const RESTORE_DEEP_LINKS: Record<"symbols" | "csv", "peachscript" | "csv"> = { symbols: "peachscript", csv: "csv", }; // The Chrome action popup dismisses itself the moment the OS file picker // steals focus, so a picker opened from it never delivers its file. From the // action popup we reopen this surface in a standalone window and let that // window own the dialog — same trick as CodexRestoreView.openPdfPicker(). // The window is created by the service worker: a window created by the popup // itself is cascade-closed by Chrome when the popup dismisses. App.tsx routes // the deep link back here (?restore=peachscript|csv) at boot, before first // render, so the window mounts straight on the right source. async function detachForFilePicker(route: "peachscript" | "csv"): Promise { try { // Already inside a detached window — it can host the dialog directly. if (new URLSearchParams(window.location.search).get("restore")) return false; } catch { /* no location — fall through to the capability check */ } if (typeof chrome === "undefined" || typeof chrome.runtime?.sendMessage !== "function") { return false; } try { const res = (await sendMessage({ type: "OPEN_DETACHED_POPUP", payload: { route }, })) as { ok: boolean; data?: { detached?: boolean } }; if (!res?.data?.detached) return false; // The action popup dismisses itself when the new window steals focus — // calling window.close() here cascade-closes the detached child too. return true; } catch { return false; } } /** True when this window was opened via ?restore= specifically to own * the file dialog — mirrors CodexRestoreView's autoPickPdf detection. */ function restoreDeepLinkActive(link: "peachscript" | "csv"): boolean { try { return new URLSearchParams(window.location.search).get("restore") === link; } catch { return false; } } function RestoreView(props: { hasVault: boolean; onOpenCodexRestore?: () => void; onBack: () => void; onUnlock: () => void | Promise; initialSource?: RestoreSource; }) { const [source, setSource] = createSignal(props.initialSource ?? null); const [showIntro, setShowIntro] = createSignal(false); const [pendingUnlock] = createSignal(false); // The action popup dies the moment the OS file picker steals focus, so a // mid-restore close must resume the same source form — rewinding to the // picker reads as losing progress. The resume marker mirrors the picked // source; App clears it with the rest of the onboarding-exit state. function chooseSource(next: RestoreSource | null) { setSource(next); if (next) void storageSet(RESTORE_SOURCE_STORAGE_KEY, next).catch(() => {}); else void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {}); } async function handleRestoreSuccess() { const seen = await storageGet("peach_shown_restore_intro").catch(() => null); if (!seen) { setShowIntro(true); } else { await props.onUnlock(); } } async function dismissIntro() { await storageSet("peach_shown_restore_intro", "true").catch(() => {}); setShowIntro(false); await props.onUnlock(); } return (

{"welcome\nback."}


{(entry) => ( )}
} > {(active) => ( <>

{RESTORE_SOURCES.find((entry) => entry.id === active)?.name}


props.onOpenCodexRestore?.()} /> )}
{/* One-time intro popup after first restore */}

welcome to peach.

Your vault lives only on this device. No server has a copy.

The inline bubble fills passwords automatically when you visit sites.

Export your vault from Settings before switching devices.

); } // ── Peach Codex source ───────────────────────────────────────────────────── function RestoreCodex(props: { onOpen: () => void }) { return (

Scan printed Codex pages or import the Codex PDF. Codex restore is always available, even before you create a vault.

); } // ── PeachScript source ───────────────────────────────────────────────────── function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise; }) { const [scriptInput, setScriptInput] = createSignal(""); const [backupPass, setBackupPass] = createSignal(""); const [working, setWorking] = createSignal(false); const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null); const [error, setError] = createSignal(null); let canvasRef: HTMLCanvasElement | undefined; let peachFileRef: HTMLInputElement | undefined; let animFrameId = 0; let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = []; const scriptValid = createMemo(() => isPeachScript(scriptInput())); function stopAnim() { cancelAnimationFrame(animFrameId); animFrameId = 0; } function startChaos(canvas: HTMLCanvasElement) { const ctx = canvas.getContext("2d")!; const W = canvas.width; const H = canvas.height; const RUNE_CHARS = Array.from({ length: 40 }, (_, i) => String.fromCodePoint(0xe000 + i) ); if (runes.length === 0) { runes = Array.from({ length: 60 }, () => ({ x: Math.random() * W, y: Math.random() * H, vx: (Math.random() - 0.5) * 3, vy: (Math.random() - 0.5) * 3, char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!, size: 14 + Math.random() * 20, angle: Math.random() * Math.PI * 2, va: (Math.random() - 0.5) * 0.1, })); } function draw() { ctx.clearRect(0, 0, W, H); for (const r of runes) { r.x = (r.x + r.vx + W) % W; r.y = (r.y + r.vy + H) % H; r.angle += r.va; ctx.save(); ctx.translate(r.x, r.y); ctx.rotate(r.angle); ctx.font = `${r.size}px PeachScript, monospace`; const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x); ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`; ctx.shadowColor = "rgba(232, 149, 109, 0.7)"; ctx.shadowBlur = 8; ctx.fillText(r.char, 0, 0); ctx.restore(); } animFrameId = requestAnimationFrame(draw); } draw(); } function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) { stopAnim(); const ctx = canvas.getContext("2d")!; const W = canvas.width; const H = canvas.height; const cols = 8; const rows = 6; const cellW = W / cols; const cellH = H / rows; const targets = runes.slice(0, cols * rows).map((_, i) => ({ x: (i % cols) * cellW + cellW / 2, y: Math.floor(i / cols) * cellH + cellH / 2, })); const start = performance.now(); const DURATION = 400; function drawSnap(now: number) { const t = Math.min((now - start) / DURATION, 1); const ease = t < 1 ? 1 - (1 - t) ** 3 : 1; ctx.clearRect(0, 0, W, H); runes.slice(0, targets.length).forEach((r, i) => { const tx = targets[i]!.x; const ty = targets[i]!.y; const cx = r.x + (tx - r.x) * ease; const cy = r.y + (ty - r.y) * ease; ctx.font = `16px PeachScript, monospace`; ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`; ctx.shadowColor = "rgba(232, 149, 109, 0.6)"; ctx.shadowBlur = 6; ctx.fillText(r.char, cx - 8, cy + 8); }); if (t < 1) { animFrameId = requestAnimationFrame(drawSnap); } else { onDone(); } } animFrameId = requestAnimationFrame(drawSnap); } function bloom(canvas: HTMLCanvasElement, onDone: () => void) { stopAnim(); const ctx = canvas.getContext("2d")!; const W = canvas.width; const H = canvas.height; const start = performance.now(); const DURATION = 900; function drawBloom(now: number) { const t = Math.min((now - start) / DURATION, 1); ctx.clearRect(0, 0, W, H); // Runes dissolve const runeAlpha = 1 - Math.min(t / 0.4, 1); if (runeAlpha > 0) { for (const r of runes.slice(0, 48)) { ctx.font = `16px PeachScript, monospace`; ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`; ctx.fillText(r.char, r.x - 8, r.y + 8); } } // Peach mark bloom const markT = Math.max(0, (t - 0.2) / 0.5); const markScale = Math.min(markT * 1.2, 1); const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2; if (markAlpha > 0) { ctx.save(); ctx.globalAlpha = markAlpha; ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`; ctx.shadowBlur = 30; ctx.translate(W / 2, H / 2); ctx.scale(markScale, markScale); ctx.drawImage(peachMarkImg, -32, -32, 64, 64); ctx.restore(); } if (t < 1) { animFrameId = requestAnimationFrame(drawBloom); } else { onDone(); } } animFrameId = requestAnimationFrame(drawBloom); } onCleanup(() => stopAnim()); // This window was opened specifically to own the file dialog — open it as // soon as the input exists. Mirrors the ?codex=pdf auto-pick. onMount(() => { if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) { setTimeout(() => peachFileRef?.click(), 50); } }); // The action popup dies when the OS file picker steals focus; dropping the // file is the other import path that survives it. const [dragDepth, setDragDepth] = createSignal(0); async function handleDrop(e: DragEvent) { e.preventDefault(); setDragDepth(0); if (working() || animating() !== null) return; const file = e.dataTransfer?.files?.[0]; if (!file) return; // PB-26-208: bound the file before file.text() — a .peach blob over the // import ceiling is rejected without reading a byte. if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) { setError("That file is too large."); return; } try { setScriptInput(await file.text()); setError(null); } catch { setError("Couldn't read file."); } } async function handleRestore() { if (!scriptValid() || !backupPass().trim() || working()) return; setError(null); setWorking(true); setAnimating("chaos"); const canvas = canvasRef; if (canvas) startChaos(canvas); try { // PB-26-208: decodePeachScript skips whitespace internally — no // separate normalise pass. const bytes = decodePeachScript(scriptInput()); const payload = await parsePayload(bytes, backupPass()); if (!props.hasVault) { const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } }); if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED"); } const rr = await sendMessage({ type: "RESTORE_ENTRIES", payload: { entries: payload.entries, mode: "merge", settings: payload.settings }, }); if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED"); if (payload.license?.key) { await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {}); } setAnimating("snap"); stopAnim(); if (canvas) { snapToGrid(canvas, () => { setAnimating("bloom"); bloom(canvas, async () => { setAnimating(null); await props.onSuccess(); }); }); } else { await props.onSuccess(); } } catch (err) { stopAnim(); setAnimating(null); const code = err instanceof Error ? err.message : String(err); setError( code === "INVALID_PASSPHRASE" ? "That passphrase didn't work." : code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." : "Could not read this backup.", ); } finally { setWorking(false); } } return (
0 }} onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }} onDragOver={(e) => e.preventDefault()} onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))} onDrop={(e) => void handleDrop(e)} > { canvasRef = el; }} class="peach-restore-anim-canvas" width={280} height={200} />