1234567891011121314151617import { createSignal, createMemo, For, onMount, onCleanup, Show } from "solid-js";18import { sendMessage } from "../../shared/bridge";19import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground";20import type {21 CodexDeps,22 CodexError,23 CodexManifest,24 CodexProgress,25 CodexVaultSnapshot,26} from "../../shared/codex-types";27import { STORAGE_KEY_LAST_GENERATED } from "../../shared/codex-types";28import { pickConfirmationPositions } from "../codex/confirmation";29import type { VaultEntry } from "../../shared/types";3031interface CodexGenerateViewProps {32 isPro: boolean;33 onBack: () => void;34 onUpgradePro: () => void;35 onOpenSecurity: () => void;36 onVaultLocked: () => void;37}3839type Step = "words" | "generating" | "done";40type CodexGenerateModule = typeof import("../codex/generate");4142let codexGenerateModulePromise: Promise<CodexGenerateModule> | null = null;4344function loadCodexGenerateModule(): Promise<CodexGenerateModule> {45 codexGenerateModulePromise ??= import("../codex/generate");46 return codexGenerateModulePromise;47}484950export function CodexGenerateView(props: CodexGenerateViewProps) {51 const [snapshot, setSnapshot] = createSignal<CodexVaultSnapshot | null>(null);52 const [estimatedPages, setEstimatedPages] = createSignal<number | null>(null);53 const [estimatedParity, setEstimatedParity] = createSignal<number | null>(null);54 const [loading, setLoading] = createSignal(true);55 const [loadError, setLoadError] = createSignal<string | null>(null);56 const [libraryLoading, setLibraryLoading] = createSignal(false);57 const [libraryReady, setLibraryReady] = createSignal(false);58 const [libraryError, setLibraryError] = createSignal<string | null>(null);59 const [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false);6061 const [step, setStep] = createSignal<Step>("words");62 const [confirmPositions, setConfirmPositions] = createSignal<63 [number, number, number]64 >([3, 7, 11]);65 const [confirmInputs, setConfirmInputs] = createSignal<66 [string, string, string]67 >(["", "", ""]);6869 const [progressStep, setProgressStep] = createSignal<string>("");70 const [progressCurrent, setProgressCurrent] = createSignal<number>(0);71 const [progressTotal, setProgressTotal] = createSignal<number>(0);7273 const [genError, setGenError] = createSignal<string | null>(null);74 const [completed, setCompleted] = createSignal<CodexManifest | null>(null);75 76 77 const [aztecGallery, setAztecGallery] = createSignal<string[]>([]);78 const GALLERY_MAX = 36;7980 onCleanup(() => {81 for (const url of aztecGallery()) URL.revokeObjectURL(url);82 });8384 onMount(async () => {85 if (!props.isPro) {86 props.onUpgradePro();87 return;88 }8990 const res = (await sendMessage({91 type: "GET_CODEX_VAULT_DATA",92 })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };9394 if (!res.ok || !res.data) {95 96 97 if (res.error?.startsWith("VAULT_LOCKED")) {98 props.onVaultLocked();99 return;100 }101 setLoadError(res.error ?? "could not load vault data");102 setLoading(false);103 return;104 }105106 setSnapshot(res.data);107108 const words = res.data.recoveryWords;109 if (!words || words.length !== 12) {110 setMissingRecoveryWords(true);111 setLoading(false);112 return;113 }114115 setConfirmPositions(pickConfirmationPositions());116 setLoading(false);117 void warmCodexLibrary(res.data);118 });119120 const allCorrect = createMemo(() => {121 const snap = snapshot();122 const words = snap?.recoveryWords;123 if (!words || words.length !== 12) return false;124 const positions = confirmPositions();125 const inputs = confirmInputs();126 return positions.every(127 (pos, i) =>128 inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),129 );130 });131132 function buildDeps(snap: CodexVaultSnapshot): CodexDeps {133 return {134 getSlotA: async () => snap.slotA,135 getSlotB: async () => snap.slotB,136 getLastModified: async () => snap.lastModified,137 138 139 140 getAllEntries: async () =>141 Array.from(142 { length: snap.entryCount },143 () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,144 ),145 getVerifyCode: async () => snap.verifyCode,146 isVaultUnlocked: () => snap.vaultUnlocked,147 };148 }149150 async function warmCodexLibrary(151 snap?: CodexVaultSnapshot,152 ): Promise<CodexGenerateModule | null> {153 if (libraryReady()) return loadCodexGenerateModule();154155 setLibraryLoading(true);156 setLibraryError(null);157 try {158 const [generateModule, rsModule] = await Promise.all([159 loadCodexGenerateModule(),160 import("../codex/rs-loader"),161 ]);162 await rsModule.getReedSolomon();163 setLibraryReady(true);164165 if (snap?.slotA) {166 try {167 const { pages, parityShards } = await generateModule.estimateCodexPages(168 snap.slotA,169 snap.slotB,170 );171 setEstimatedPages(pages);172 setEstimatedParity(parityShards);173 } catch {174 175 setEstimatedPages(null);176 setEstimatedParity(null);177 }178 }179180 return generateModule;181 } catch (err) {182 codexGenerateModulePromise = null;183 setLibraryError(184 `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`,185 );186 return null;187 } finally {188 setLibraryLoading(false);189 }190 }191192 function codexFilename(): string {193 const d = new Date();194 const y = d.getFullYear();195 const m = String(d.getMonth() + 1).padStart(2, "0");196 const day = String(d.getDate()).padStart(2, "0");197 return `peach-codex-${y}-${m}-${day}.pdf`;198 }199200 function downloadPdf(bytes: Uint8Array): void {201 const a = document.createElement("a");202 a.download = codexFilename();203 204 205 const owned = new Uint8Array(bytes);206 const blob = new Blob([owned.buffer], { type: "application/pdf" });207 a.href = URL.createObjectURL(blob);208 setTimeout(() => URL.revokeObjectURL(a.href), 4000);209 document.body.appendChild(a);210 a.click();211 a.remove();212 }213214 function explainCodexError(code: string, message: string): string {215 switch (code) {216 case "VAULT_LOCKED":217 return "your vault is locked. unlock and try again.";218 case "VAULT_EMPTY":219 return "your vault is empty — nothing to print yet.";220 case "PRO_REQUIRED":221 return "Codex generation is a Pro feature.";222 case "PDF_GENERATION_FAILED":223 case "AZTEC_GENERATION_FAILED":224 return "could not render the Codex. please try again.";225 case "COMPRESSION_ROUNDTRIP_FAILED":226 case "ERASURE_CODING_VERIFICATION_FAILED":227 case "ERASURE_CODING_CONTENT_MISMATCH":228 case "AZTEC_ROUNDTRIP_FAILED":229 case "AZTEC_CONTENT_MISMATCH":230 case "RECONSTRUCTION_SIMULATION_FAILED":231 case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":232 case "RECONSTRUCTION_FINGERPRINT_MISMATCH":233 case "RECONSTRUCTION_COUNT_MISMATCH":234 return `a self-check failed while building the Codex (${code}). nothing was saved. please try again.`;235 default:236 return message || "Codex generation failed.";237 }238 }239240 const onProgress: CodexProgress = (s, current, total, imageUrl) => {241 setProgressStep(s);242 setProgressCurrent(current ?? 0);243 setProgressTotal(total ?? 0);244 if (s === "aztec" && imageUrl) {245 setAztecGallery(prev => {246 if (prev.length >= GALLERY_MAX) {247 URL.revokeObjectURL(prev[0]!);248 return [...prev.slice(1), imageUrl];249 }250 return [...prev, imageUrl];251 });252 }253 };254255 async function handleGenerate() {256 const snap = snapshot();257 if (!snap || !allCorrect() || step() === "generating") return;258259 setGenError(null);260 setAztecGallery([]);261 setStep("generating");262 setProgressStep("compress");263264 try {265 const generateModule = await warmCodexLibrary(snap);266 if (!generateModule) {267 setStep("words");268 return;269 }270 const result = await generateModule.generateCodex(buildDeps(snap), onProgress);271 downloadPdf(result.pdfBytes);272 setCompleted(result.manifest);273 setStep("done");274275 try {276 await chrome.storage.local?.set({277 [STORAGE_KEY_LAST_GENERATED]: Date.now(),278 });279 } catch {280 281 }282 } catch (err) {283 const codexErr = err as CodexError;284 const code = (codexErr && (codexErr as { code?: string }).code) ?? "";285 const message =286 err instanceof Error ? err.message : "Codex generation failed.";287 console.error("[Codex] generation failed:", code, message);288 setGenError(explainCodexError(code, message));289 setStep("words");290 }291 }292293 function progressLabel(): string {294 switch (progressStep()) {295 case "compress":296 return "compressing vault";297 case "erasure":298 return "building chunks";299 case "aztec": {300 const total = progressTotal();301 const current = progressCurrent();302 if (total > 0) return `generating codes (${current} of ${total})`;303 return "generating codes";304 }305 case "verify":306 return "self-checking";307 case "pdf":308 return "creating PDF";309 default:310 return "preparing";311 }312 }313314 function progressPercent(): number {315 const total = progressTotal();316 const current = progressCurrent();317 318 319 switch (progressStep()) {320 case "compress":321 return 8;322 case "erasure":323 return 18;324 case "aztec":325 if (total <= 0) return 30;326 return 30 + Math.floor((current / total) * 55); 327 case "verify":328 return 90;329 case "pdf":330 return 96;331 default:332 return 4;333 }334 }335336 return (337 <div class="peach-overlay-view ov-enter-right" style="display:flex">338 <div class="peach-overlay-header">339 <button340 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"341 onClick={props.onBack}342 aria-label="Back"343 disabled={step() === "generating"}344 >345 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"346 stroke="currentColor" stroke-width="1.1"347 stroke-linecap="round" stroke-linejoin="round">348 <path d="M6 1L1 6L6 11" />349 </svg>350 </button>351 <span class="peach-overlay-title">peach codex</span>352 <div class="peach-overlay-spacer"></div>353 </div>354355 <div356 class="peach-settings-body peach-codex-body peach-codex-body--runes"357 classList={{ "peach-codex-body--done": step() === "done" }}358 >359 <RecoveryRuneBackground />360361 <Show when={!props.isPro}>362 <p class="peach-hint">redirecting…</p>363 </Show>364365 <Show when={props.isPro && loading()}>366 <p class="peach-hint">loading vault…</p>367 </Show>368369 <Show when={props.isPro && !loading() && loadError()}>370 <p class="peach-hint peach-hint-error">{loadError()}</p>371 </Show>372373 <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>374 <div class="peach-settings-group peach-codex-intro">375 <p class="peach-codex-blurb">376 the Codex prints your encrypted vault as Aztec codes. before377 you can generate one, set up your 12 recovery words in378 Settings → Security.379 </p>380 </div>381 <button382 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"383 onClick={props.onOpenSecurity}384 >385 open Security settings386 </button>387 </Show>388389 <Show390 when={391 props.isPro &&392 !loading() &&393 !loadError() &&394 !missingRecoveryWords() &&395 snapshot()396 }397 >398 <Show when={step() === "words"}>399 <div class="peach-codex-recovery-headline">400 <h2 class="peach-codex-recovery-title">your recovery words.</h2>401 <p class="peach-codex-recovery-body">402 write these down — store separately from your Codex.403 </p>404 </div>405406 <ol class="peach-codex-words-grid" start={1}>407 <For each={snapshot()!.recoveryWords ?? []}>408 {(word, i) => (409 <li class="peach-codex-word-row">410 <span class="peach-codex-word-numeral">{i() + 1}</span>411 <span class="peach-codex-word-text">{word}</span>412 </li>413 )}414 </For>415 </ol>416417 <Show when={estimatedPages() !== null}>418 <p class="peach-codex-meta">419 <strong>{estimatedPages()}</strong>{" "}420 {estimatedPages() === 1 ? "page" : "pages"} ·{" "}421 {snapshot()!.entryCount}{" "}422 {snapshot()!.entryCount === 1 ? "entry" : "entries"}423 <Show when={estimatedParity() !== null}>424 {" "}· {estimatedParity()} redundancy codes425 </Show>426 </p>427 </Show>428429 <div class="peach-codex-confirm-section">430 <p class="peach-codex-confirm-label">431 to continue, enter words {confirmPositions().join(", ")}:432 </p>433 <For each={confirmPositions()}>434 {(pos, i) => (435 <div class="peach-codex-confirm-row">436 <span class="peach-codex-confirm-pos">word {pos}</span>437 <input438 class="peach-codex-confirm-input"439 type="text"440 autocomplete="off"441 autocorrect="off"442 autocapitalize="none"443 spellcheck={false}444 value={confirmInputs()[i()]}445 onInput={(e) => {446 const next = [...confirmInputs()] as [447 string,448 string,449 string,450 ];451 next[i()] = (e.target as HTMLInputElement).value;452 setConfirmInputs(next);453 }}454 onKeyDown={(e) => {455 if (e.key === "Enter" && allCorrect())456 void handleGenerate();457 }}458 />459 </div>460 )}461 </For>462 </div>463464 <Show when={genError()}>465 <p class="peach-hint peach-hint-error peach-codex-error">466 {genError()}467 </p>468 </Show>469 <Show when={libraryError()}>470 <p class="peach-hint peach-hint-error peach-codex-error">471 {libraryError()}472 </p>473 </Show>474 <Show when={libraryLoading() && !libraryReady()}>475 <p class="peach-hint peach-codex-meta">476 preparing Codex library…477 </p>478 </Show>479480 <button481 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"482 disabled={!allCorrect() || libraryLoading()}483 onClick={() => void handleGenerate()}484 >485 {libraryLoading() && !libraryReady() ? "preparing…" : "generate my Codex"}486 </button>487 </Show>488489 <Show when={step() === "generating"}>490 <div class="peach-codex-recovery-headline">491 <h2 class="peach-codex-recovery-title">492 generating your codex…493 </h2>494 </div>495496 <div class="peach-codex-progress-group">497 <p class="peach-codex-progress-label">498 <span>{progressLabel()}</span>499 <span class="peach-codex-verify-tag">500 {snapshot()?.verifyCode}501 </span>502 </p>503 <div class="peach-codex-progress-track">504 <div505 class="peach-codex-progress-fill"506 style={`width:${progressPercent()}%`}507 />508 </div>509 </div>510511 <Show when={aztecGallery().length > 0}>512 <div class="peach-codex-aztec-gallery">513 <For each={aztecGallery()}>514 {(url) => (515 <img516 class="peach-codex-aztec-tile"517 src={url}518 alt=""519 aria-hidden="true"520 />521 )}522 </For>523 </div>524 </Show>525 </Show>526527 <Show when={step() === "done"}>528 <div class="peach-codex-recovery-headline">529 <h2 class="peach-codex-recovery-title">530 your codex is downloading.531 </h2>532 </div>533534 <Show when={completed()}>535 <p class="peach-codex-meta">536 {completed()!.pageCount}{" "}537 {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}538 {completed()!.totalChunks} codes · verify code{" "}539 <span class="peach-codex-verify">540 {completed()!.verifyCode}541 </span>542 </p>543 </Show>544545 <div class="peach-codex-done-guidance">546 <div class="peach-codex-done-item">547 <div>548 <p class="peach-codex-done-item-title">549 print on a laser printer550 </p>551 <p class="peach-codex-done-item-body">552 laser prints last longer and scan more reliably than553 inkjet. print at 100% scale — do not fit to page.554 </p>555 </div>556 </div>557558 <div class="peach-codex-done-item">559 <div>560 <p class="peach-codex-done-item-title">make two copies</p>561 <p class="peach-codex-done-item-body">562 store one at home (fireproof box or safe) and one563 somewhere else (trusted person, safety deposit box).564 fire and flood can destroy a single copy.565 </p>566 </div>567 </div>568569 <div class="peach-codex-done-item">570 <div>571 <p class="peach-codex-done-item-title">572 store separately from recovery words573 </p>574 <p class="peach-codex-done-item-body">575 your 12 recovery words go somewhere different. together576 they restore your vault without your passphrase. keep577 them apart.578 </p>579 </div>580 </div>581582 <div class="peach-codex-done-item">583 <div>584 <p class="peach-codex-done-item-title">also save the PDF</p>585 <p class="peach-codex-done-item-body">586 copy the PDF to a USB drive. Peach can import it587 directly without needing to scan printed codes.588 </p>589 </div>590 </div>591 </div>592593 <p class="peach-codex-done-regenerate">594 regenerate your codex whenever you add many new entries.595 </p>596597 <button598 class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"599 onClick={() => {600 setCompleted(null);601 setGenError(null);602 setLibraryError(null);603 setConfirmInputs(["", "", ""]);604 setConfirmPositions(pickConfirmationPositions());605 setStep("words");606 }}607 >608 generate another609 </button>610 </Show>611 </Show>612 </div>613 </div>614 );615}