1234567891011121314151617import { createSignal, createMemo, For, onMount, Show } from "solid-js";18import { sendMessage } from "../../shared/bridge";19import { isNativePlatform } from "../../shared/native";20import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground";21import type {22 CodexDeps,23 CodexError,24 CodexManifest,25 CodexProgress,26 CodexVaultSnapshot,27} from "../../shared/codex-types";28import { STORAGE_KEY_LAST_GENERATED } from "../../shared/codex-types";29import {30 estimateCodexPages,31 generateCodex,32 pickConfirmationPositions,33} from "../codex/generate";34import { getReedSolomon } from "../codex/rs-loader";35import type { VaultEntry } from "../../shared/types";3637interface CodexGenerateViewProps {38 isPro: boolean;39 onBack: () => void;40 onUpgradePro: () => void;41 onOpenSecurity: () => void;42 onVaultLocked: () => void;43}4445type Step = "words" | "generating" | "done";464748export function CodexGenerateView(props: CodexGenerateViewProps) {49 const [snapshot, setSnapshot] = createSignal<CodexVaultSnapshot | null>(null);50 const [estimatedPages, setEstimatedPages] = createSignal<number | null>(null);51 const [estimatedParity, setEstimatedParity] = createSignal<number | null>(null);52 const [loading, setLoading] = createSignal(true);53 const [loadError, setLoadError] = createSignal<string | null>(null);54 const [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false);5556 const [step, setStep] = createSignal<Step>("words");57 const [confirmPositions, setConfirmPositions] = createSignal<58 [number, number, number]59 >([3, 7, 11]);60 const [confirmInputs, setConfirmInputs] = createSignal<61 [string, string, string]62 >(["", "", ""]);6364 const [progressStep, setProgressStep] = createSignal<string>("");65 const [progressCurrent, setProgressCurrent] = createSignal<number>(0);66 const [progressTotal, setProgressTotal] = createSignal<number>(0);6768 const [genError, setGenError] = createSignal<string | null>(null);69 const [completed, setCompleted] = createSignal<CodexManifest | null>(null);7071 onMount(async () => {72 if (!props.isPro) {73 props.onUpgradePro();74 return;75 }7677 const res = (await sendMessage({78 type: "GET_CODEX_VAULT_DATA",79 })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };8081 if (!res.ok || !res.data) {82 83 84 if (res.error?.startsWith("VAULT_LOCKED")) {85 props.onVaultLocked();86 return;87 }88 setLoadError(res.error ?? "could not load vault data");89 setLoading(false);90 return;91 }9293 setSnapshot(res.data);9495 const words = res.data.recoveryWords;96 if (!words || words.length !== 12) {97 setMissingRecoveryWords(true);98 setLoading(false);99 return;100 }101102 setConfirmPositions(pickConfirmationPositions());103104 105 106 try {107 await getReedSolomon();108 } catch (err) {109 setLoadError(110 `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`111 );112 setLoading(false);113 return;114 }115116 if (res.data.slotA) {117 try {118 const { pages, parityShards } = await estimateCodexPages(119 res.data.slotA,120 res.data.slotB,121 );122 setEstimatedPages(pages);123 setEstimatedParity(parityShards);124 } catch {125 126 setEstimatedPages(null);127 setEstimatedParity(null);128 }129 }130131 setLoading(false);132 });133134 const allCorrect = createMemo(() => {135 const snap = snapshot();136 const words = snap?.recoveryWords;137 if (!words || words.length !== 12) return false;138 const positions = confirmPositions();139 const inputs = confirmInputs();140 return positions.every(141 (pos, i) =>142 inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),143 );144 });145146 function buildDeps(snap: CodexVaultSnapshot): CodexDeps {147 return {148 getSlotA: async () => snap.slotA,149 getSlotB: async () => snap.slotB,150 getLastModified: async () => snap.lastModified,151 152 153 154 getAllEntries: async () =>155 Array.from(156 { length: snap.entryCount },157 () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,158 ),159 getVerifyCode: async () => snap.verifyCode,160 isVaultUnlocked: () => snap.vaultUnlocked,161 };162 }163164 function codexFilename(): string {165 const d = new Date();166 const y = d.getFullYear();167 const m = String(d.getMonth() + 1).padStart(2, "0");168 const day = String(d.getDate()).padStart(2, "0");169 return `peach-codex-${y}-${m}-${day}.pdf`;170 }171172 function downloadPdf(bytes: Uint8Array): void {173 const a = document.createElement("a");174 a.download = codexFilename();175 if (isNativePlatform()) {176 177 178 179 180 let bin = "";181 const chunk = 0x8000;182 for (let i = 0; i < bytes.length; i += chunk) {183 const slice = bytes.subarray(i, Math.min(i + chunk, bytes.length));184 bin += String.fromCharCode(...slice);185 }186 a.href = `data:application/pdf;base64,${btoa(bin)}`;187 } else {188 189 190 const owned = new Uint8Array(bytes);191 const blob = new Blob([owned.buffer], { type: "application/pdf" });192 a.href = URL.createObjectURL(blob);193 setTimeout(() => URL.revokeObjectURL(a.href), 4000);194 }195 document.body.appendChild(a);196 a.click();197 a.remove();198 }199200 function explainCodexError(code: string, message: string): string {201 switch (code) {202 case "VAULT_LOCKED":203 return "your vault is locked. unlock and try again.";204 case "VAULT_EMPTY":205 return "your vault is empty — nothing to print yet.";206 case "PRO_REQUIRED":207 return "Codex generation is a Pro feature.";208 case "PDF_GENERATION_FAILED":209 case "AZTEC_GENERATION_FAILED":210 return "could not render the Codex. please try again.";211 case "COMPRESSION_ROUNDTRIP_FAILED":212 case "ERASURE_CODING_VERIFICATION_FAILED":213 case "ERASURE_CODING_CONTENT_MISMATCH":214 case "AZTEC_ROUNDTRIP_FAILED":215 case "AZTEC_CONTENT_MISMATCH":216 case "RECONSTRUCTION_SIMULATION_FAILED":217 case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":218 case "RECONSTRUCTION_FINGERPRINT_MISMATCH":219 case "RECONSTRUCTION_COUNT_MISMATCH":220 return `a self-check failed while building the Codex (${code}). nothing was saved. please try again.`;221 default:222 return message || "Codex generation failed.";223 }224 }225226 const onProgress: CodexProgress = (s, current, total) => {227 setProgressStep(s);228 setProgressCurrent(current ?? 0);229 setProgressTotal(total ?? 0);230 };231232 async function handleGenerate() {233 const snap = snapshot();234 if (!snap || !allCorrect() || step() === "generating") return;235236 setGenError(null);237 setStep("generating");238 setProgressStep("compress");239240 try {241 const result = await generateCodex(buildDeps(snap), onProgress);242 downloadPdf(result.pdfBytes);243 setCompleted(result.manifest);244 setStep("done");245246 try {247 await chrome.storage.local?.set({248 [STORAGE_KEY_LAST_GENERATED]: Date.now(),249 });250 } catch {251 252 }253 } catch (err) {254 const codexErr = err as CodexError;255 const code = (codexErr && (codexErr as { code?: string }).code) ?? "";256 const message =257 err instanceof Error ? err.message : "Codex generation failed.";258 console.error("[Codex] generation failed:", code, message);259 setGenError(explainCodexError(code, message));260 setStep("words");261 }262 }263264 function progressLabel(): string {265 switch (progressStep()) {266 case "compress":267 return "compressing vault";268 case "erasure":269 return "building chunks";270 case "aztec": {271 const total = progressTotal();272 const current = progressCurrent();273 if (total > 0) return `generating codes (${current} of ${total})`;274 return "generating codes";275 }276 case "verify":277 return "self-checking";278 case "pdf":279 return "creating PDF";280 default:281 return "preparing";282 }283 }284285 function progressPercent(): number {286 const total = progressTotal();287 const current = progressCurrent();288 289 290 switch (progressStep()) {291 case "compress":292 return 8;293 case "erasure":294 return 18;295 case "aztec":296 if (total <= 0) return 30;297 return 30 + Math.floor((current / total) * 55); 298 case "verify":299 return 90;300 case "pdf":301 return 96;302 default:303 return 4;304 }305 }306307 return (308 <div class="peach-overlay-view ov-enter-right" style="display:flex">309 <div class="peach-overlay-header">310 <button311 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"312 onClick={props.onBack}313 aria-label="Back"314 disabled={step() === "generating"}315 >316 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"317 stroke="currentColor" stroke-width="1.1"318 stroke-linecap="round" stroke-linejoin="round">319 <path d="M6 1L1 6L6 11" />320 </svg>321 </button>322 <span class="peach-overlay-title">peach codex</span>323 <div class="peach-overlay-spacer"></div>324 </div>325326 <div class="peach-settings-body peach-codex-body peach-codex-body--runes">327 <RecoveryRuneBackground />328329 <Show when={!props.isPro}>330 <p class="peach-hint">redirecting…</p>331 </Show>332333 <Show when={props.isPro && loading()}>334 <p class="peach-hint">loading vault…</p>335 </Show>336337 <Show when={props.isPro && !loading() && loadError()}>338 <p class="peach-hint peach-hint-error">{loadError()}</p>339 </Show>340341 <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>342 <div class="peach-settings-group peach-codex-intro">343 <p class="peach-codex-blurb">344 the Codex prints your encrypted vault as Aztec codes. before345 you can generate one, set up your 12 recovery words in346 Settings → Security.347 </p>348 </div>349 <button350 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"351 onClick={props.onOpenSecurity}352 >353 open Security settings354 </button>355 </Show>356357 <Show358 when={359 props.isPro &&360 !loading() &&361 !loadError() &&362 !missingRecoveryWords() &&363 snapshot()364 }365 >366 <Show when={step() === "words"}>367 <div class="peach-codex-recovery-headline">368 <h2 class="peach-codex-recovery-title">your recovery words.</h2>369 <p class="peach-codex-recovery-body">370 write these down — store separately from your Codex.371 </p>372 </div>373374 <ol class="peach-codex-words-grid" start={1}>375 <For each={snapshot()!.recoveryWords ?? []}>376 {(word, i) => (377 <li class="peach-codex-word-row">378 <span class="peach-codex-word-numeral">{i() + 1}</span>379 <span class="peach-codex-word-text">{word}</span>380 </li>381 )}382 </For>383 </ol>384385 <Show when={estimatedPages() !== null}>386 <p class="peach-codex-meta">387 <strong>{estimatedPages()}</strong>{" "}388 {estimatedPages() === 1 ? "page" : "pages"} ·{" "}389 {snapshot()!.entryCount}{" "}390 {snapshot()!.entryCount === 1 ? "entry" : "entries"}391 <Show when={estimatedParity() !== null}>392 {" "}· {estimatedParity()} redundancy codes393 </Show>394 </p>395 </Show>396397 <div class="peach-codex-confirm-section">398 <p class="peach-codex-confirm-label">399 to continue, enter words {confirmPositions().join(", ")}:400 </p>401 <For each={confirmPositions()}>402 {(pos, i) => (403 <div class="peach-codex-confirm-row">404 <span class="peach-codex-confirm-pos">word {pos}</span>405 <input406 class="peach-codex-confirm-input"407 type="text"408 autocomplete="off"409 autocorrect="off"410 autocapitalize="none"411 spellcheck={false}412 value={confirmInputs()[i()]}413 onInput={(e) => {414 const next = [...confirmInputs()] as [415 string,416 string,417 string,418 ];419 next[i()] = (e.target as HTMLInputElement).value;420 setConfirmInputs(next);421 }}422 onKeyDown={(e) => {423 if (e.key === "Enter" && allCorrect())424 void handleGenerate();425 }}426 />427 </div>428 )}429 </For>430 </div>431432 <Show when={genError()}>433 <p class="peach-hint peach-hint-error peach-codex-error">434 {genError()}435 </p>436 </Show>437438 <button439 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"440 disabled={!allCorrect()}441 onClick={() => void handleGenerate()}442 >443 generate my Codex444 </button>445 </Show>446447 <Show when={step() === "generating"}>448 <div class="peach-codex-recovery-headline">449 <h2 class="peach-codex-recovery-title">450 generating your codex…451 </h2>452 </div>453454 <div class="peach-codex-progress-group">455 <p class="peach-codex-progress-label">456 <span>{progressLabel()}</span>457 <span class="peach-codex-verify-tag">458 {snapshot()?.verifyCode}459 </span>460 </p>461 <div class="peach-codex-progress-track">462 <div463 class="peach-codex-progress-fill"464 style={`width:${progressPercent()}%`}465 />466 </div>467 </div>468 </Show>469470 <Show when={step() === "done"}>471 <div class="peach-codex-recovery-headline">472 <h2 class="peach-codex-recovery-title">473 your codex is downloading.474 </h2>475 </div>476477 <Show when={completed()}>478 <p class="peach-codex-meta">479 {completed()!.pageCount}{" "}480 {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}481 {completed()!.totalChunks} codes · verify code{" "}482 <span class="peach-codex-verify">483 {completed()!.verifyCode}484 </span>485 </p>486 </Show>487488 <div class="peach-codex-done-guidance">489 <div class="peach-codex-done-item">490 <span class="peach-codex-done-item-icon">🖨</span>491 <div>492 <p class="peach-codex-done-item-title">493 print on a laser printer494 </p>495 <p class="peach-codex-done-item-body">496 laser prints last longer and scan more reliably than497 inkjet. print at 100% scale — do not fit to page.498 </p>499 </div>500 </div>501502 <div class="peach-codex-done-item">503 <span class="peach-codex-done-item-icon">📦</span>504 <div>505 <p class="peach-codex-done-item-title">make two copies</p>506 <p class="peach-codex-done-item-body">507 store one at home (fireproof box or safe) and one508 somewhere else (trusted person, safety deposit box).509 fire and flood can destroy a single copy.510 </p>511 </div>512 </div>513514 <div class="peach-codex-done-item">515 <span class="peach-codex-done-item-icon">🔑</span>516 <div>517 <p class="peach-codex-done-item-title">518 store separately from recovery words519 </p>520 <p class="peach-codex-done-item-body">521 your 12 recovery words go somewhere different. together522 they restore your vault without your passphrase. keep523 them apart.524 </p>525 </div>526 </div>527528 <div class="peach-codex-done-item">529 <span class="peach-codex-done-item-icon">💾</span>530 <div>531 <p class="peach-codex-done-item-title">also save the PDF</p>532 <p class="peach-codex-done-item-body">533 copy the PDF to a USB drive. Peach can import it534 directly without needing to scan printed codes.535 </p>536 </div>537 </div>538 </div>539540 <p class="peach-codex-done-regenerate">541 regenerate your codex whenever you add many new entries.542 </p>543544 <button545 class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"546 onClick={() => {547 setCompleted(null);548 setGenError(null);549 setConfirmInputs(["", "", ""]);550 setConfirmPositions(pickConfirmationPositions());551 setStep("words");552 }}553 >554 generate another555 </button>556 </Show>557 </Show>558 </div>559 </div>560 );561}