import { Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js"; import { Capacitor } from "@capacitor/core"; import { sendMessage } from "../App"; import { LockRuneCanvas } from "../components/LockRuneCanvas"; import { PeachMark, PeachWordmark } from "../components/PeachMark"; import { ImportView } from "./Import"; import { biometricUnlock, biometricAvailable } from "../../shared/biometric"; // Lazy-load zxcvbn — it's heavy (~800 kB) and only needed during vault creation let _zxcvbn: ((password: string) => { score: number }) | null = null; async function getZxcvbn() { if (_zxcvbn) return _zxcvbn; const mod = await import("zxcvbn"); _zxcvbn = mod.default; return _zxcvbn; } interface LockProps { hasVault: boolean; ready: boolean; isPro?: 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; } // "restore" = picker, "peachscript" / "file" = detail flows, // "mayan" = Mayan-code import (decoder not shipped yet — picker exposes a "soon" card). type RestoreSurface = "restore" | "peachscript" | "file" | "mayan" | 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(null); const [showPassword, setShowPassword] = createSignal(false); const MIN_LENGTH = 12; const MIN_ZXCVBN_SCORE = 2; const [strengthScore, setStrengthScore] = createSignal(null); 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 — add words or length"; 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 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 — try a longer or more varied 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) { await props.onUnlock(); setLoading(false); return; } setError(getDisplayError(result.error, "failed to create vault")); setLoading(false); } function openRestore(surface: Exclude) { if (!props.ready || loading()) return; setPassword(""); setConfirmPassword(""); setError(""); setRestoreSurface(surface); } // ── All surfaces routed reactively via Switch/Match ─────────────────── return ( {/* ── Restore picker ────────────────────────────────────────── */} setRestoreSurface(null)} onPickPeachScript={() => setRestoreSurface("peachscript")} onPickMayan={() => setRestoreSurface("mayan")} onPickCSV={() => setRestoreSurface("file")} /> {/* ── Mayan code restore (decoder coming soon) ──────────────── */} setRestoreSurface("restore")} /> {/* ── PeachScript restore ───────────────────────────────────── */} setRestoreSurface("restore")} onImport={async () => { setRestoreSurface(null); await props.onUnlock(); }} /> {/* ── File restore ──────────────────────────────────────────── */} setRestoreSurface("restore")} onImport={async () => { setRestoreSurface(null); await 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(); }} />

{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()}

If you forget it, your vault cannot be opened. There is no reset.

); } // ── Restore method picker ────────────────────────────────────────────────── function RestorePickerView(props: { onBack: () => void; onPickPeachScript: () => void; onPickMayan: () => void; onPickCSV: () => void; }) { return (
restore

bring back
your vault.

); } // ── Mayan code restore ───────────────────────────────────────────────────── /** * Decode a base64-encoded Mayan code image into the binary backup payload. * Decoder isn't shipped yet — return a typed result so the UI can surface * the right copy, and replace this single function when the decoder lands. */ async function handleMayanDecode(_base64: string): Promise< { ok: true; payload: Uint8Array } | { ok: false; reason: string } > { // TODO(decoder): wire src/crypto/mayan.ts decode() here. return { ok: false, reason: "Mayan decoder is not yet shipped." }; } function MayanRestoreView(props: { onBack: () => void }) { const [decodeError, setDecodeError] = createSignal(null); const [decoding, setDecoding] = createSignal(false); const isAndroid = createMemo(() => Capacitor.isNativePlatform() && Capacitor.getPlatform() === "android", ); async function processBase64(base64: string) { setDecoding(true); setDecodeError(null); try { const result = await handleMayanDecode(base64); if (!result.ok) { setDecodeError(result.reason); return; } // Future: hand payload to the restore-decrypt flow here. setDecodeError("Decoded — but the restore flow isn't wired yet."); } finally { setDecoding(false); } } async function handleCameraScan() { setDecodeError(null); try { const { Camera, CameraResultType, CameraSource } = await import("@capacitor/camera"); const image = await Camera.getPhoto({ quality: 90, resultType: CameraResultType.Base64, source: CameraSource.Camera, allowEditing: false, }); if (image.base64String) await processBase64(image.base64String); } catch (err) { // User cancellation throws — only surface real errors. const msg = err instanceof Error ? err.message : ""; if (msg && !/cancel/i.test(msg)) setDecodeError(msg); } } function handleFileUpload(e: Event) { const input = e.currentTarget as HTMLInputElement; const file = input.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { const result = reader.result; if (typeof result !== "string") return; const base64 = result.split(",", 2)[1] ?? ""; void processBase64(base64); }; reader.readAsDataURL(file); } return (
mayan code

scan or upload.

{decodeError()}

Until the decoder lands, restore via PeachScript or a .peach file.

); } // ── Biometric unlock ──────────────────────────────────────────────────────── async function handleBiometricUnlock(props: { onUnlock: () => void | Promise }) { const unlocked = await biometricUnlock(); if (unlocked) { await props.onUnlock(); } } function BiometricUnlockButton(props: { ready: boolean; loading: () => boolean; onUnlock: () => void | Promise }) { const [bioAvailable, setBioAvailable] = createSignal(false); onMount(async () => { setBioAvailable(await biometricAvailable()); }); return ( ); } // ── Helpers ──────────────────────────────────────────────────────────────── function getDisplayError(error: string | undefined, fallback: string): string { if (!error) return fallback; if ( error === "SERVICE_WORKER_UNAVAILABLE" || error.includes("Receiving end does not exist") || error.includes("message port closed") ) { return "background service unavailable"; } return error; }