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);60 61 62 const [needsPassword, setNeedsPassword] = createSignal(false);63 const [password, setPassword] = createSignal("");64 const [passwordError, setPasswordError] = createSignal<string | null>(null);6566 const [step, setStep] = createSignal<Step>("words");67 const [confirmPositions, setConfirmPositions] = createSignal<68 [number, number, number]69 >([3, 7, 11]);70 const [confirmInputs, setConfirmInputs] = createSignal<71 [string, string, string]72 >(["", "", ""]);7374 const [progressStep, setProgressStep] = createSignal<string>("");75 const [progressCurrent, setProgressCurrent] = createSignal<number>(0);76 const [progressTotal, setProgressTotal] = createSignal<number>(0);7778 const [genError, setGenError] = createSignal<string | null>(null);79 const [completed, setCompleted] = createSignal<CodexManifest | null>(null);80 81 82 const [aztecGallery, setAztecGallery] = createSignal<string[]>([]);83 const GALLERY_MAX = 36;8485 onCleanup(() => {86 for (const url of aztecGallery()) URL.revokeObjectURL(url);87 });8889 onMount(async () => {90 if (!props.isPro) {91 props.onUpgradePro();92 return;93 }94 await loadSnapshot();95 });9697 async function loadSnapshot(password?: string): Promise<void> {98 const res = (await sendMessage({99 type: "GET_CODEX_VAULT_DATA",100 ...(password ? { payload: { password } } : {}),101 })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };102103 if (!res.ok || !res.data) {104 105 106 if (res.error?.startsWith("VAULT_LOCKED")) {107 props.onVaultLocked();108 return;109 }110 111 if (res.error === "wrong_password") {112 setPasswordError("wrong password. try again.");113 return;114 }115 setLoadError(res.error ?? "could not load vault data");116 setLoading(false);117 return;118 }119120 setSnapshot(res.data);121122 const words = res.data.recoveryWords;123 if (!words || words.length !== 12) {124 125 126 if (!password) {127 setNeedsPassword(true);128 } else {129 setMissingRecoveryWords(true);130 }131 setLoading(false);132 return;133 }134135 setNeedsPassword(false);136 setPassword("");137 setPasswordError(null);138 setConfirmPositions(pickConfirmationPositions());139 setLoading(false);140 void warmCodexLibrary(res.data);141 }142143 async function submitPassword(): Promise<void> {144 if (!password()) return;145 setPasswordError(null);146 await loadSnapshot(password());147 }148149 const allCorrect = createMemo(() => {150 const snap = snapshot();151 const words = snap?.recoveryWords;152 if (!words || words.length !== 12) return false;153 const positions = confirmPositions();154 const inputs = confirmInputs();155 return positions.every(156 (pos, i) =>157 inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),158 );159 });160161 function buildDeps(snap: CodexVaultSnapshot): CodexDeps {162 return {163 getSlotA: async () => snap.slotA,164 getSlotB: async () => snap.slotB,165 getLastModified: async () => snap.lastModified,166 167 168 169 getAllEntries: async () =>170 Array.from(171 { length: snap.entryCount },172 () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,173 ),174 getVerifyCode: async () => snap.verifyCode,175 isVaultUnlocked: () => snap.vaultUnlocked,176 };177 }178179 async function warmCodexLibrary(180 snap?: CodexVaultSnapshot,181 ): Promise<CodexGenerateModule | null> {182 if (libraryReady()) return loadCodexGenerateModule();183184 setLibraryLoading(true);185 setLibraryError(null);186 try {187 const [generateModule, rsModule] = await Promise.all([188 loadCodexGenerateModule(),189 import("../codex/rs-loader"),190 ]);191 await rsModule.getReedSolomon();192 setLibraryReady(true);193194 if (snap?.slotA) {195 try {196 const { pages, parityShards } = await generateModule.estimateCodexPages(197 snap.slotA,198 snap.slotB,199 );200 setEstimatedPages(pages);201 setEstimatedParity(parityShards);202 } catch {203 204 setEstimatedPages(null);205 setEstimatedParity(null);206 }207 }208209 return generateModule;210 } catch (err) {211 codexGenerateModulePromise = null;212 setLibraryError(213 `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`,214 );215 return null;216 } finally {217 setLibraryLoading(false);218 }219 }220221 function codexFilename(): string {222 const d = new Date();223 const y = d.getFullYear();224 const m = String(d.getMonth() + 1).padStart(2, "0");225 const day = String(d.getDate()).padStart(2, "0");226 return `peach-codex-${y}-${m}-${day}.pdf`;227 }228229 function downloadPdf(bytes: Uint8Array): void {230 const a = document.createElement("a");231 a.download = codexFilename();232 233 234 const owned = new Uint8Array(bytes);235 const blob = new Blob([owned.buffer], { type: "application/pdf" });236 a.href = URL.createObjectURL(blob);237 setTimeout(() => URL.revokeObjectURL(a.href), 4000);238 document.body.appendChild(a);239 a.click();240 a.remove();241 }242243 function explainCodexError(code: string, message: string): string {244 switch (code) {245 case "VAULT_LOCKED":246 return "your vault is locked. unlock and try again.";247 case "VAULT_EMPTY":248 return "your vault is empty — nothing to print yet.";249 case "PRO_REQUIRED":250 return "Codex generation is a Pro feature.";251 case "PDF_GENERATION_FAILED":252 case "AZTEC_GENERATION_FAILED":253 return "could not render the Codex. please try again.";254 case "COMPRESSION_ROUNDTRIP_FAILED":255 case "ERASURE_CODING_VERIFICATION_FAILED":256 case "ERASURE_CODING_CONTENT_MISMATCH":257 case "AZTEC_ROUNDTRIP_FAILED":258 case "AZTEC_CONTENT_MISMATCH":259 case "RECONSTRUCTION_SIMULATION_FAILED":260 case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":261 case "RECONSTRUCTION_FINGERPRINT_MISMATCH":262 case "RECONSTRUCTION_COUNT_MISMATCH":263 return `a self-check failed while building the Codex (${code}). nothing was saved. please try again.`;264 default:265 return message || "Codex generation failed.";266 }267 }268269 const onProgress: CodexProgress = (s, current, total, imageUrl) => {270 setProgressStep(s);271 setProgressCurrent(current ?? 0);272 setProgressTotal(total ?? 0);273 if (s === "aztec" && imageUrl) {274 setAztecGallery(prev => {275 if (prev.length >= GALLERY_MAX) {276 URL.revokeObjectURL(prev[0]!);277 return [...prev.slice(1), imageUrl];278 }279 return [...prev, imageUrl];280 });281 }282 };283284 async function handleGenerate() {285 const snap = snapshot();286 if (!snap || !allCorrect() || step() === "generating") return;287288 setGenError(null);289 setAztecGallery([]);290 setStep("generating");291 setProgressStep("compress");292293 try {294 const generateModule = await warmCodexLibrary(snap);295 if (!generateModule) {296 setStep("words");297 return;298 }299 const result = await generateModule.generateCodex(buildDeps(snap), onProgress);300 downloadPdf(result.pdfBytes);301 setCompleted(result.manifest);302 setStep("done");303304 try {305 await chrome.storage.local?.set({306 [STORAGE_KEY_LAST_GENERATED]: Date.now(),307 });308 } catch {309 310 }311 } catch (err) {312 const codexErr = err as CodexError;313 const code = (codexErr && (codexErr as { code?: string }).code) ?? "";314 const message =315 err instanceof Error ? err.message : "Codex generation failed.";316 console.error("[Codex] generation failed:", code, message);317 setGenError(explainCodexError(code, message));318 setStep("words");319 }320 }321322 function progressLabel(): string {323 switch (progressStep()) {324 case "compress":325 return "compressing vault";326 case "erasure":327 return "building chunks";328 case "aztec": {329 const total = progressTotal();330 const current = progressCurrent();331 if (total > 0) return `generating codes (${current} of ${total})`;332 return "generating codes";333 }334 case "verify":335 return "self-checking";336 case "pdf":337 return "creating PDF";338 default:339 return "preparing";340 }341 }342343 function progressPercent(): number {344 const total = progressTotal();345 const current = progressCurrent();346 347 348 switch (progressStep()) {349 case "compress":350 return 8;351 case "erasure":352 return 18;353 case "aztec":354 if (total <= 0) return 30;355 return 30 + Math.floor((current / total) * 55); 356 case "verify":357 return 90;358 case "pdf":359 return 96;360 default:361 return 4;362 }363 }364365 return (366 <div class="peach-overlay-view ov-enter-right" style="display:flex">367 <div class="peach-overlay-header">368 <button369 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"370 onClick={props.onBack}371 aria-label="Back"372 disabled={step() === "generating"}373 >374 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"375 stroke="currentColor" stroke-width="1.1"376 stroke-linecap="round" stroke-linejoin="round">377 <path d="M6 1L1 6L6 11" />378 </svg>379 </button>380 <span class="peach-overlay-title">peach codex</span>381 <div class="peach-overlay-spacer"></div>382 </div>383384 <div385 class="peach-settings-body peach-codex-body peach-codex-body--runes"386 classList={{ "peach-codex-body--done": step() === "done" }}387 >388 <RecoveryRuneBackground />389390 <Show when={!props.isPro}>391 <p class="peach-hint">redirecting…</p>392 </Show>393394 <Show when={props.isPro && loading()}>395 <p class="peach-hint">loading vault…</p>396 </Show>397398 <Show when={props.isPro && !loading() && loadError()}>399 <p class="peach-hint peach-hint-error">{loadError()}</p>400 </Show>401402 <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>403 <div class="peach-settings-group peach-codex-intro">404 <p class="peach-codex-blurb">405 the Codex prints your encrypted vault as Aztec codes. before406 you can generate one, set up your 12 recovery words in407 Settings → Security.408 </p>409 </div>410 <button411 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"412 onClick={props.onOpenSecurity}413 >414 open Security settings415 </button>416 </Show>417418 <Show when={props.isPro && !loading() && !loadError() && needsPassword()}>419 <div class="peach-codex-recovery-headline">420 <h2 class="peach-codex-recovery-title">confirm it's you.</h2>421 <p class="peach-codex-recovery-body">422 enter your master password to show your recovery words.423 </p>424 </div>425426 <div class="peach-codex-confirm-section">427 <input428 class="peach-codex-confirm-input"429 type="password"430 autocomplete="current-password"431 placeholder="master password"432 value={password()}433 onInput={(e) => setPassword((e.target as HTMLInputElement).value)}434 onKeyDown={(e) => {435 if (e.key === "Enter") void submitPassword();436 }}437 />438 <Show when={passwordError()}>439 <p class="peach-hint peach-hint-error peach-codex-error">440 {passwordError()}441 </p>442 </Show>443 </div>444445 <button446 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"447 disabled={!password()}448 onClick={() => void submitPassword()}449 >450 continue451 </button>452 </Show>453454 <Show455 when={456 props.isPro &&457 !loading() &&458 !loadError() &&459 !needsPassword() &&460 !missingRecoveryWords() &&461 snapshot()462 }463 >464 <Show when={step() === "words"}>465 <div class="peach-codex-recovery-headline">466 <h2 class="peach-codex-recovery-title">your recovery words.</h2>467 <p class="peach-codex-recovery-body">468 write these down — store separately from your Codex.469 </p>470 </div>471472 <ol class="peach-codex-words-grid" start={1}>473 <For each={snapshot()!.recoveryWords ?? []}>474 {(word, i) => (475 <li class="peach-codex-word-row">476 <span class="peach-codex-word-numeral">{i() + 1}</span>477 <span class="peach-codex-word-text">{word}</span>478 </li>479 )}480 </For>481 </ol>482483 <Show when={estimatedPages() !== null}>484 <p class="peach-codex-meta">485 <strong>{estimatedPages()}</strong>{" "}486 {estimatedPages() === 1 ? "page" : "pages"} ·{" "}487 {snapshot()!.entryCount}{" "}488 {snapshot()!.entryCount === 1 ? "entry" : "entries"}489 <Show when={estimatedParity() !== null}>490 {" "}· {estimatedParity()} redundancy codes491 </Show>492 </p>493 </Show>494495 <div class="peach-codex-confirm-section">496 <p class="peach-codex-confirm-label">497 to continue, enter words {confirmPositions().join(", ")}:498 </p>499 <For each={confirmPositions()}>500 {(pos, i) => (501 <div class="peach-codex-confirm-row">502 <span class="peach-codex-confirm-pos">word {pos}</span>503 <input504 class="peach-codex-confirm-input"505 type="text"506 autocomplete="off"507 autocorrect="off"508 autocapitalize="none"509 spellcheck={false}510 value={confirmInputs()[i()]}511 onInput={(e) => {512 const next = [...confirmInputs()] as [513 string,514 string,515 string,516 ];517 next[i()] = (e.target as HTMLInputElement).value;518 setConfirmInputs(next);519 }}520 onKeyDown={(e) => {521 if (e.key === "Enter" && allCorrect())522 void handleGenerate();523 }}524 />525 </div>526 )}527 </For>528 </div>529530 <Show when={genError()}>531 <p class="peach-hint peach-hint-error peach-codex-error">532 {genError()}533 </p>534 </Show>535 <Show when={libraryError()}>536 <p class="peach-hint peach-hint-error peach-codex-error">537 {libraryError()}538 </p>539 </Show>540 <Show when={libraryLoading() && !libraryReady()}>541 <p class="peach-hint peach-codex-meta">542 preparing Codex library…543 </p>544 </Show>545546 <button547 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"548 disabled={!allCorrect() || libraryLoading()}549 onClick={() => void handleGenerate()}550 >551 {libraryLoading() && !libraryReady() ? "preparing…" : "generate my Codex"}552 </button>553 </Show>554555 <Show when={step() === "generating"}>556 <div class="peach-codex-recovery-headline">557 <h2 class="peach-codex-recovery-title">558 generating your codex…559 </h2>560 </div>561562 <div class="peach-codex-progress-group">563 <p class="peach-codex-progress-label">564 <span>{progressLabel()}</span>565 <span class="peach-codex-verify-tag">566 {snapshot()?.verifyCode}567 </span>568 </p>569 <div class="peach-codex-progress-track">570 <div571 class="peach-codex-progress-fill"572 style={`width:${progressPercent()}%`}573 />574 </div>575 </div>576577 <Show when={aztecGallery().length > 0}>578 <div class="peach-codex-aztec-gallery">579 <For each={aztecGallery()}>580 {(url) => (581 <img582 class="peach-codex-aztec-tile"583 src={url}584 alt=""585 aria-hidden="true"586 />587 )}588 </For>589 </div>590 </Show>591 </Show>592593 <Show when={step() === "done"}>594 <div class="peach-codex-recovery-headline">595 <h2 class="peach-codex-recovery-title">596 your codex is downloading.597 </h2>598 </div>599600 <Show when={completed()}>601 <p class="peach-codex-meta">602 {completed()!.pageCount}{" "}603 {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}604 {completed()!.totalChunks} codes · verify code{" "}605 <span class="peach-codex-verify">606 {completed()!.verifyCode}607 </span>608 </p>609 </Show>610611 <div class="peach-codex-done-guidance">612 <div class="peach-codex-done-item">613 <div>614 <p class="peach-codex-done-item-title">615 print on a laser printer616 </p>617 <p class="peach-codex-done-item-body">618 laser prints last longer and scan more reliably than619 inkjet. print at 100% scale — do not fit to page.620 </p>621 </div>622 </div>623624 <div class="peach-codex-done-item">625 <div>626 <p class="peach-codex-done-item-title">make two copies</p>627 <p class="peach-codex-done-item-body">628 store one at home (fireproof box or safe) and one629 somewhere else (trusted person, safety deposit box).630 fire and flood can destroy a single copy.631 </p>632 </div>633 </div>634635 <div class="peach-codex-done-item">636 <div>637 <p class="peach-codex-done-item-title">638 store separately from recovery words639 </p>640 <p class="peach-codex-done-item-body">641 your 12 recovery words go somewhere different. together642 they restore your vault without your passphrase. keep643 them apart.644 </p>645 </div>646 </div>647648 <div class="peach-codex-done-item">649 <div>650 <p class="peach-codex-done-item-title">also save the PDF</p>651 <p class="peach-codex-done-item-body">652 copy the PDF to a USB drive. Peach can import it653 directly without needing to scan printed codes.654 </p>655 </div>656 </div>657 </div>658659 <p class="peach-codex-done-regenerate">660 regenerate your codex whenever you add many new entries.661 </p>662663 <button664 class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"665 onClick={() => {666 setCompleted(null);667 setGenError(null);668 setLibraryError(null);669 setConfirmInputs(["", "", ""]);670 setConfirmPositions(pickConfirmationPositions());671 setStep("words");672 }}673 >674 generate another675 </button>676 </Show>677 </Show>678 </div>679 </div>680 );681}