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")} onUnlock={async () => { setRestoreSurface(null); await props.onUnlock(); }} /> {/* ── 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 to the binary backup payload. * Returns either { ok: true, payload } or { ok: false, reason } so the UI * can surface a precise error per failure mode. */ async function handleMayanDecode(base64: string): Promise< { ok: true; payload: Uint8Array } | { ok: false; reason: string } > { const { decodeMayan } = await import("../../crypto/mayan"); const img = new Image(); const dataUrl = base64.startsWith("data:") ? base64 : `data:image/*;base64,${base64}`; await new Promise((resolve, reject) => { img.onload = () => resolve(); img.onerror = () => reject(new Error("Couldn't read the image.")); img.src = dataUrl; }); const canvas = document.createElement("canvas"); // Cap resolution to keep adaptive-binarize bounded — Mayan codes don't need // more than ~1500×1500 to read every module on a phone-camera capture. const MAX_DIM = 1500; const scale = Math.min(1, MAX_DIM / Math.max(img.width, img.height)); canvas.width = Math.round(img.width * scale); canvas.height = Math.round(img.height * scale); const ctx = canvas.getContext("2d"); if (!ctx) return { ok: false, reason: "Couldn't open a 2D canvas." }; ctx.drawImage(img, 0, 0, canvas.width, canvas.height); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const payload = decodeMayan(imageData); if (!payload) { return { ok: false, reason: "Couldn't read this Mayan code. Try a closer, sharper photo with even lighting.", }; } return { ok: true, payload }; } function MayanRestoreView(props: { hasVault: boolean; onBack: () => void; onUnlock: () => void | Promise; }) { const [decodeError, setDecodeError] = createSignal(null); const [decoding, setDecoding] = createSignal(false); const [decodedBytes, setDecodedBytes] = createSignal(null); const [backupPass, setBackupPass] = createSignal(""); const [masterPass, setMasterPass] = createSignal(""); const [masterConfirm, setMasterConfirm] = createSignal(""); const [restoring, setRestoring] = createSignal(false); const isAndroid = createMemo(() => Capacitor.isNativePlatform() && Capacitor.getPlatform() === "android", ); const masterReady = createMemo( () => masterPass().length >= 8 && masterPass() === masterConfirm(), ); async function processBase64(base64: string) { setDecoding(true); setDecodeError(null); try { const result = await handleMayanDecode(base64); if (!result.ok) { setDecodeError(result.reason); return; } setDecodedBytes(result.payload); } 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) { 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; void processBase64(result); }; reader.readAsDataURL(file); } async function handleRestore() { const bytes = decodedBytes(); if (!bytes) return; if (!backupPass().trim()) { setDecodeError("Enter the backup password."); return; } if (!props.hasVault && !masterReady()) { setDecodeError("Choose a master password (≥8 chars, must match)."); return; } setRestoring(true); setDecodeError(null); try { const { parsePayload } = await import("../../crypto/peachPayload"); const payload = await parsePayload(bytes, backupPass()); if (!props.hasVault) { const createRes = await sendMessage({ type: "CREATE_VAULT", payload: { password: masterPass() }, }); if (!(createRes as { ok: boolean }).ok) { throw new Error("CREATE_VAULT_FAILED"); } } const restoreRes = await sendMessage({ type: "RESTORE_ENTRIES", payload: { entries: payload.entries, mode: "merge", settings: payload.settings, }, }); const env = restoreRes as { ok: boolean; error?: string }; if (!env.ok) throw new Error(env.error ?? "RESTORE_FAILED"); if (payload.license?.key) { await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key }, }).catch(() => {}); } await props.onUnlock(); } catch (err) { const code = err instanceof Error ? err.message : String(err); setDecodeError( code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD" ? "Wrong backup password — this is the password you set when you created the Mayan code." : code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." : "Restore failed. The backup may be corrupted.", ); } finally { setRestoring(false); } } return (
mayan code

{decodedBytes() ? "enter password." : "scan or upload."}

backup password

queueMicrotask(() => el.focus())} class="peach-settings-input" type="password" placeholder="backup password" value={backupPass()} onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)} onKeyDown={(e) => { if (e.key === "Enter" && !restoring()) { e.preventDefault(); void handleRestore(); } }} />

master password

setMasterPass((e.target as HTMLInputElement).value)} /> setMasterConfirm((e.target as HTMLInputElement).value)} />

{decodeError()}

} >

{decodeError()}

); } // ── 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; }