// Peach Codex — generation view. // // Two-step flow (see peach_codex_addendum.md): // 1. words — show the 12 recovery words and require the user to type 3 // randomly-chosen ones correctly before generation can proceed. // The Codex PDF never contains recovery words; this is the // due-diligence gate that pushes the user to commit them to // paper separately. // 2. gen — run the popup-side `generateCodex` pipeline, surface progress, // trigger the PDF download. // 3. done — reminder to store the Codex and recovery words separately. // // Pro-gated: a non-Pro user is bounced to the license section on mount. // Restoration (CodexRestoreView) stays free — a printed Codex must restore // regardless of license state. import { createSignal, createMemo, For, onMount, onCleanup, Show } from "solid-js"; import { sendMessage } from "../../shared/bridge"; import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground"; import type { CodexDeps, CodexError, CodexManifest, CodexProgress, CodexVaultSnapshot, } from "../../shared/codex-types"; import { STORAGE_KEY_LAST_GENERATED } from "../../shared/codex-types"; import { pickConfirmationPositions } from "../codex/confirmation"; import type { VaultEntry } from "../../shared/types"; interface CodexGenerateViewProps { isPro: boolean; onBack: () => void; onUpgradePro: () => void; onOpenSecurity: () => void; onVaultLocked: () => void; } type Step = "words" | "generating" | "done"; type CodexGenerateModule = typeof import("../codex/generate"); let codexGenerateModulePromise: Promise | null = null; function loadCodexGenerateModule(): Promise { codexGenerateModulePromise ??= import("../codex/generate"); return codexGenerateModulePromise; } export function CodexGenerateView(props: CodexGenerateViewProps) { const [snapshot, setSnapshot] = createSignal(null); const [estimatedPages, setEstimatedPages] = createSignal(null); const [estimatedParity, setEstimatedParity] = createSignal(null); const [loading, setLoading] = createSignal(true); const [loadError, setLoadError] = createSignal(null); const [libraryLoading, setLibraryLoading] = createSignal(false); const [libraryReady, setLibraryReady] = createSignal(false); const [libraryError, setLibraryError] = createSignal(null); const [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false); // PB-26-001: the background only releases recoveryWords with a fresh // master-password proof, so the words step re-asks for it here. const [needsPassword, setNeedsPassword] = createSignal(false); const [password, setPassword] = createSignal(""); const [passwordError, setPasswordError] = createSignal(null); const [step, setStep] = createSignal("words"); const [confirmPositions, setConfirmPositions] = createSignal< [number, number, number] >([3, 7, 11]); const [confirmInputs, setConfirmInputs] = createSignal< [string, string, string] >(["", "", ""]); const [progressStep, setProgressStep] = createSignal(""); const [progressCurrent, setProgressCurrent] = createSignal(0); const [progressTotal, setProgressTotal] = createSignal(0); const [genError, setGenError] = createSignal(null); const [completed, setCompleted] = createSignal(null); // Live gallery of real Aztec PNGs as they're generated — keep the last 36 // (one page worth) so the display fills without using excessive memory. const [aztecGallery, setAztecGallery] = createSignal([]); const GALLERY_MAX = 36; onCleanup(() => { for (const url of aztecGallery()) URL.revokeObjectURL(url); }); onMount(async () => { if (!props.isPro) { props.onUpgradePro(); return; } await loadSnapshot(); }); // Screenshot hygiene: drop the displayed words when the window is // backgrounded so an OS screenshot of the hidden popup never captures // them; re-showing requires the master-password step again. const handleVisibilityChange = () => { const snap = snapshot(); if (document.visibilityState === "hidden" && snap?.recoveryWords) { setSnapshot({ ...snap, recoveryWords: null }); setNeedsPassword(true); } }; document.addEventListener("visibilitychange", handleVisibilityChange); onCleanup(() => document.removeEventListener("visibilitychange", handleVisibilityChange), ); async function loadSnapshot(password?: string): Promise { const res = (await sendMessage({ type: "GET_CODEX_VAULT_DATA", ...(password ? { payload: { password } } : {}), })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string }; if (!res.ok || !res.data) { // Service worker restarted (MV3) and the vault re-locked in memory. // Route back to the lock screen exactly like the rest of the app does. if (res.error?.startsWith("VAULT_LOCKED")) { props.onVaultLocked(); return; } // Wrong re-auth password is a form error, not a load failure. if (res.error === "wrong_password") { setPasswordError("wrong password. try again."); return; } setLoadError(res.error ?? "could not load vault data"); setLoading(false); return; } setSnapshot(res.data); const words = res.data.recoveryWords; if (!words || words.length !== 12) { // No proof supplied yet → ask for it; proof supplied but still no // words → the vault genuinely has no recovery escrow. if (!password) { setNeedsPassword(true); } else { setMissingRecoveryWords(true); } setLoading(false); return; } setNeedsPassword(false); setPassword(""); setPasswordError(null); setConfirmPositions(pickConfirmationPositions()); setLoading(false); void warmCodexLibrary(res.data); } async function submitPassword(): Promise { if (!password()) return; setPasswordError(null); await loadSnapshot(password()); } const allCorrect = createMemo(() => { const snap = snapshot(); const words = snap?.recoveryWords; if (!words || words.length !== 12) return false; const positions = confirmPositions(); const inputs = confirmInputs(); return positions.every( (pos, i) => inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(), ); }); function buildDeps(snap: CodexVaultSnapshot): CodexDeps { return { getSlotA: async () => snap.slotA, getSlotB: async () => snap.slotB, getLastModified: async () => snap.lastModified, // `getAllEntries` is consumed only as a count filter inside the // generator — synthesise placeholders so real entries don't cross // the popup boundary. getAllEntries: async () => Array.from( { length: snap.entryCount }, () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry, ), getVerifyCode: async () => snap.verifyCode, isVaultUnlocked: () => snap.vaultUnlocked, }; } async function warmCodexLibrary( snap?: CodexVaultSnapshot, ): Promise { if (libraryReady()) return loadCodexGenerateModule(); setLibraryLoading(true); setLibraryError(null); try { const [generateModule, rsModule] = await Promise.all([ loadCodexGenerateModule(), import("../codex/rs-loader"), ]); await rsModule.getReedSolomon(); setLibraryReady(true); if (snap?.slotA) { try { const { pages, parityShards } = await generateModule.estimateCodexPages( snap.slotA, snap.slotB, ); setEstimatedPages(pages); setEstimatedParity(parityShards); } catch { // Estimation is informational; failure is non-fatal. setEstimatedPages(null); setEstimatedParity(null); } } return generateModule; } catch (err) { codexGenerateModulePromise = null; setLibraryError( `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`, ); return null; } finally { setLibraryLoading(false); } } function codexFilename(): string { const d = new Date(); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `peach-codex-${y}-${m}-${day}.pdf`; } function downloadPdf(bytes: Uint8Array): void { const a = document.createElement("a"); a.download = codexFilename(); // Copy into a fresh Uint8Array to ensure a tightly-owned ArrayBuffer // (Blob is happier with this than a subarray view). const owned = new Uint8Array(bytes); const blob = new Blob([owned.buffer], { type: "application/pdf" }); a.href = URL.createObjectURL(blob); setTimeout(() => URL.revokeObjectURL(a.href), 4000); document.body.appendChild(a); a.click(); a.remove(); } function explainCodexError(code: string, message: string): string { switch (code) { case "VAULT_LOCKED": return "your vault is locked. unlock and try again."; case "VAULT_EMPTY": return "your vault is empty — nothing to print yet."; case "PRO_REQUIRED": return "Codex generation is a Pro feature."; case "PDF_GENERATION_FAILED": case "AZTEC_GENERATION_FAILED": return "could not render the Codex. please try again."; case "COMPRESSION_ROUNDTRIP_FAILED": case "ERASURE_CODING_VERIFICATION_FAILED": case "ERASURE_CODING_CONTENT_MISMATCH": case "AZTEC_ROUNDTRIP_FAILED": case "AZTEC_CONTENT_MISMATCH": case "RECONSTRUCTION_SIMULATION_FAILED": case "RECONSTRUCTION_VERIFY_CODE_MISMATCH": case "RECONSTRUCTION_FINGERPRINT_MISMATCH": case "RECONSTRUCTION_COUNT_MISMATCH": return `a self-check failed while building the Codex (${code}). nothing was saved. please try again.`; default: return message || "Codex generation failed."; } } const onProgress: CodexProgress = (s, current, total, imageUrl) => { setProgressStep(s); setProgressCurrent(current ?? 0); setProgressTotal(total ?? 0); if (s === "aztec" && imageUrl) { setAztecGallery(prev => { if (prev.length >= GALLERY_MAX) { URL.revokeObjectURL(prev[0]!); return [...prev.slice(1), imageUrl]; } return [...prev, imageUrl]; }); } }; async function handleGenerate() { const snap = snapshot(); if (!snap || !allCorrect() || step() === "generating") return; setGenError(null); setAztecGallery([]); setStep("generating"); setProgressStep("compress"); try { const generateModule = await warmCodexLibrary(snap); if (!generateModule) { setStep("words"); return; } const result = await generateModule.generateCodex(buildDeps(snap), onProgress); downloadPdf(result.pdfBytes); setCompleted(result.manifest); setStep("done"); try { await chrome.storage.local?.set({ [STORAGE_KEY_LAST_GENERATED]: Date.now(), }); } catch { // Bookkeeping; failure is non-fatal. } } catch (err) { const codexErr = err as CodexError; const code = (codexErr && (codexErr as { code?: string }).code) ?? ""; const message = err instanceof Error ? err.message : "Codex generation failed."; console.error("[Codex] generation failed:", code, message); setGenError(explainCodexError(code, message)); setStep("words"); } } function progressLabel(): string { switch (progressStep()) { case "compress": return "compressing vault"; case "erasure": return "building chunks"; case "aztec": { const total = progressTotal(); const current = progressCurrent(); if (total > 0) return `generating codes (${current} of ${total})`; return "generating codes"; } case "verify": return "self-checking"; case "pdf": return "creating PDF"; default: return "preparing"; } } function progressPercent(): number { const total = progressTotal(); const current = progressCurrent(); // Map step to a coarse progress band so the bar advances monotonically // even when intra-step progress (aztec N of M) is fine-grained. switch (progressStep()) { case "compress": return 8; case "erasure": return 18; case "aztec": if (total <= 0) return 30; return 30 + Math.floor((current / total) * 55); // 30 → 85 case "verify": return 90; case "pdf": return 96; default: return 4; } } return (
peach codex

redirecting…

loading vault…

{loadError()}

the Codex prints your encrypted vault as Aztec codes. before you can generate one, set up your 12 recovery words in Settings → Security.

confirm it's you.

enter your master password to show your recovery words.

setPassword((e.target as HTMLInputElement).value)} onKeyDown={(e) => { if (e.key === "Enter") void submitPassword(); }} />

{passwordError()}

your recovery words.

write these down — store separately from your Codex.

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

{estimatedPages()}{" "} {estimatedPages() === 1 ? "page" : "pages"} ·{" "} {snapshot()!.entryCount}{" "} {snapshot()!.entryCount === 1 ? "entry" : "entries"} {" "}· {estimatedParity()} redundancy codes

to continue, enter words {confirmPositions().join(", ")}:

{(pos, i) => (
word {pos} { const next = [...confirmInputs()] as [ string, string, string, ]; next[i()] = (e.target as HTMLInputElement).value; setConfirmInputs(next); }} onKeyDown={(e) => { if (e.key === "Enter" && allCorrect()) void handleGenerate(); }} />
)}

{genError()}

{libraryError()}

preparing Codex library…

generating your codex…

{progressLabel()} {snapshot()?.verifyCode}

0}>

your codex is downloading.

{completed()!.pageCount}{" "} {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "} {completed()!.totalChunks} codes · verify code{" "} {completed()!.verifyCode}

print on a laser printer

laser prints last longer and scan more reliably than inkjet. print at 100% scale — do not fit to page.

make two copies

store one at home (fireproof box or safe) and one somewhere else (trusted person, safety deposit box). fire and flood can destroy a single copy.

store separately from recovery words

your 12 recovery words go somewhere different. together they restore your vault without your passphrase. keep them apart.

also save the PDF

copy the PDF to a USB drive. Peach can import it directly without needing to scan printed codes.

regenerate your codex whenever you add many new entries.

); }