// Peach Codex — restoration view. // // Drives the four steps of restoring a vault from a printed Codex: // 1. Camera scan — Aztec codes feed parseRawScan → ChunkBuffer. // 2. Once ChunkBuffer.isReady(), prompt for the master passphrase. // 3. Reconstruct + integrity-verify locally (popup) via reconstructAndVerify. // 4. Hand the payload to the SW (RESTORE_FROM_CODEX) for actual decrypt // and replace. // // Restoration is intentionally NOT Pro-gated — a printed Codex must remain // restorable forever, regardless of license state. import { createSignal, onCleanup, onMount, Show } from "solid-js"; import { sendMessage } from "../../shared/bridge"; import { ChunkBuffer, parseRawScan, reconstructAndVerify, } from "../codex/restore"; import { startScanner } from "../codex/scanner"; import type { ScannerHandle } from "../codex/scanner"; import type { CodexPayload } from "../../shared/codex-types"; interface CodexRestoreViewProps { onBack: () => void; onRestoreSuccess: () => void; } type Phase = "scanning" | "ready" | "restoring" | "success" | "error"; export function CodexRestoreView(props: CodexRestoreViewProps) { // The buffer is intentionally not stored in a signal — its identity is // stable across the lifetime of the view and we read it through the // `size()`/`isReady()` methods. We mirror its observable state into // signals so Solid can re-render. const buffer = new ChunkBuffer(); let videoEl: HTMLVideoElement | undefined; let scanner: ScannerHandle | null = null; const [phase, setPhase] = createSignal("scanning"); const [scanned, setScanned] = createSignal(0); const [totalShards, setTotalShards] = createSignal(null); const [dataShards, setDataShards] = createSignal(null); const [verifyCode, setVerifyCode] = createSignal(null); const [hint, setHint] = createSignal( "point your camera at the first code on page 1", ); const [cameraError, setCameraError] = createSignal(null); const [scanWarning, setScanWarning] = createSignal(null); // Hold the reconstructed payload between the integrity check and the SW // call so the user can re-enter the passphrase on a wrong-password failure // without rescanning every code. const [pendingPayload, setPendingPayload] = createSignal( null, ); const [passphrase, setPassphrase] = createSignal(""); const [restoreError, setRestoreError] = createSignal(null); const [restoredCount, setRestoredCount] = createSignal(null); function refreshFromBuffer() { setScanned(buffer.size()); const ctx = buffer.getContext(); if (ctx) { setTotalShards(ctx.total); setDataShards(ctx.dataShards); setVerifyCode(ctx.verifyCode); } if (buffer.isReady() && phase() === "scanning") { setPhase("ready"); setHint("scan complete. enter your master password to restore."); } else if (phase() === "scanning") { const ds = ctx?.dataShards; if (ds) { const remaining = Math.max(ds - buffer.size(), 0); setHint( remaining > 0 ? `${remaining} more ${remaining === 1 ? "code" : "codes"} needed` : "almost there…", ); } } } function explainScanReason(reason: string): string { switch (reason) { case "foreign_codex": return "that code belongs to a different Codex."; case "bad_integrity": return "a code's checksum failed — try again."; case "duplicate": return ""; // not surfaced — duplicates are normal as the user pans case "parse_error": return "could not read that code."; default: return ""; } } async function handleScan(raw: string) { const parsed = parseRawScan(raw); if ("error" in parsed) { // parseRawScan failures are usually a non-Peach Aztec code or a corrupt // frame; quietly ignore so the scanner keeps running. return; } const result = await buffer.add(parsed); refreshFromBuffer(); if (!result.accepted && result.reason) { const msg = explainScanReason(result.reason); if (msg) { setScanWarning(msg); // Auto-clear so the warning doesn't linger after the user moves on. setTimeout(() => setScanWarning(null), 2500); } } } function handleScannerError(err: Error) { // Camera permission denied / no camera / hardware failure. setCameraError( err.name === "NotAllowedError" ? "camera access was denied. allow camera permission and reopen this screen." : err.message || "camera failed to start.", ); } onMount(async () => { if (!videoEl) return; try { scanner = await startScanner({ videoEl, onScan: (text) => void handleScan(text), onError: (err) => handleScannerError(err), }); } catch (err) { handleScannerError(err instanceof Error ? err : new Error(String(err))); } }); onCleanup(() => { if (scanner) { scanner.stop(); scanner = null; } // `scanner.stop()` calls reader.reset(), which tears down the // MediaStream attached to the