// 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, Show } from "solid-js"; import { sendMessage } from "../../shared/bridge"; import { isNativePlatform } from "../../shared/native"; 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 { estimateCodexPages, generateCodex, pickConfirmationPositions, } from "../codex/generate"; import { getReedSolomon } from "../codex/rs-loader"; import type { VaultEntry } from "../../shared/types"; interface CodexGenerateViewProps { isPro: boolean; onBack: () => void; onUpgradePro: () => void; onOpenSecurity: () => void; onVaultLocked: () => void; } type Step = "words" | "generating" | "done"; 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 [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false); 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); onMount(async () => { if (!props.isPro) { props.onUpgradePro(); return; } const res = (await sendMessage({ type: "GET_CODEX_VAULT_DATA", })) 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; } setLoadError(res.error ?? "could not load vault data"); setLoading(false); return; } setSnapshot(res.data); const words = res.data.recoveryWords; if (!words || words.length !== 12) { setMissingRecoveryWords(true); setLoading(false); return; } setConfirmPositions(pickConfirmationPositions()); // Pre-warm the Reed-Solomon WASM so load failures surface before the // user completes the word-confirmation flow. try { await getReedSolomon(); } catch (err) { setLoadError( `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}` ); setLoading(false); return; } if (res.data.slotA) { try { const { pages, parityShards } = await estimateCodexPages( res.data.slotA, res.data.slotB, ); setEstimatedPages(pages); setEstimatedParity(parityShards); } catch { // Estimation is informational; failure is non-fatal. setEstimatedPages(null); setEstimatedParity(null); } } setLoading(false); }); 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, }; } 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(); if (isNativePlatform()) { // Capacitor WebView: blob: URLs are opaque to native, but data: URLs // are intercepted by MainActivity and written to public Downloads via // MediaStore. Encode in chunks to avoid blowing the call stack on // large PDFs. let bin = ""; const chunk = 0x8000; for (let i = 0; i < bytes.length; i += chunk) { const slice = bytes.subarray(i, Math.min(i + chunk, bytes.length)); bin += String.fromCharCode(...slice); } a.href = `data:application/pdf;base64,${btoa(bin)}`; } else { // Browser: 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. nothing was saved. please try again."; default: return message || "Codex generation failed."; } } const onProgress: CodexProgress = (s, current, total) => { setProgressStep(s); setProgressCurrent(current ?? 0); setProgressTotal(total ?? 0); }; async function handleGenerate() { const snap = snapshot(); if (!snap || !allCorrect() || step() === "generating") return; setGenError(null); setStep("generating"); setProgressStep("compress"); try { const result = await 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.

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

generating your codex…

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

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.

); }