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 98 99 100 const handleVisibilityChange = () => {101 const snap = snapshot();102 if (document.visibilityState === "hidden" && snap?.recoveryWords) {103 setSnapshot({ ...snap, recoveryWords: null });104 setNeedsPassword(true);105 }106 };107 document.addEventListener("visibilitychange", handleVisibilityChange);108 onCleanup(() =>109 document.removeEventListener("visibilitychange", handleVisibilityChange),110 );111112 async function loadSnapshot(password?: string): Promise<void> {113 const res = (await sendMessage({114 type: "GET_CODEX_VAULT_DATA",115 ...(password ? { payload: { password } } : {}),116 })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };117118 if (!res.ok || !res.data) {119 120 121 if (res.error?.startsWith("VAULT_LOCKED")) {122 props.onVaultLocked();123 return;124 }125 126 if (res.error === "wrong_password") {127 setPasswordError("wrong password. try again.");128 return;129 }130 setLoadError(res.error ?? "could not load vault data");131 setLoading(false);132 return;133 }134135 setSnapshot(res.data);136137 const words = res.data.recoveryWords;138 if (!words || words.length !== 12) {139 140 141 if (!password) {142 setNeedsPassword(true);143 } else {144 setMissingRecoveryWords(true);145 }146 setLoading(false);147 return;148 }149150 setNeedsPassword(false);151 setPassword("");152 setPasswordError(null);153 setConfirmPositions(pickConfirmationPositions());154 setLoading(false);155 void warmCodexLibrary(res.data);156 }157158 async function submitPassword(): Promise<void> {159 if (!password()) return;160 setPasswordError(null);161 await loadSnapshot(password());162 }163164 const allCorrect = createMemo(() => {165 const snap = snapshot();166 const words = snap?.recoveryWords;167 if (!words || words.length !== 12) return false;168 const positions = confirmPositions();169 const inputs = confirmInputs();170 return positions.every(171 (pos, i) =>172 inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),173 );174 });175176 function buildDeps(snap: CodexVaultSnapshot): CodexDeps {177 return {178 getSlotA: async () => snap.slotA,179 getSlotB: async () => snap.slotB,180 getLastModified: async () => snap.lastModified,181 182 183 184 getAllEntries: async () =>185 Array.from(186 { length: snap.entryCount },187 () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,188 ),189 getVerifyCode: async () => snap.verifyCode,190 isVaultUnlocked: () => snap.vaultUnlocked,191 };192 }193194 async function warmCodexLibrary(195 snap?: CodexVaultSnapshot,196 ): Promise<CodexGenerateModule | null> {197 if (libraryReady()) return loadCodexGenerateModule();198199 setLibraryLoading(true);200 setLibraryError(null);201 try {202 const [generateModule, rsModule] = await Promise.all([203 loadCodexGenerateModule(),204 import("../codex/rs-loader"),205 ]);206 await rsModule.getReedSolomon();207 setLibraryReady(true);208209 if (snap?.slotA) {210 try {211 const { pages, parityShards } = await generateModule.estimateCodexPages(212 snap.slotA,213 snap.slotB,214 );215 setEstimatedPages(pages);216 setEstimatedParity(parityShards);217 } catch {218 219 setEstimatedPages(null);220 setEstimatedParity(null);221 }222 }223224 return generateModule;225 } catch (err) {226 codexGenerateModulePromise = null;227 setLibraryError(228 `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`,229 );230 return null;231 } finally {232 setLibraryLoading(false);233 }234 }235236 function codexFilename(): string {237 const d = new Date();238 const y = d.getFullYear();239 const m = String(d.getMonth() + 1).padStart(2, "0");240 const day = String(d.getDate()).padStart(2, "0");241 return `peach-codex-${y}-${m}-${day}.pdf`;242 }243244 function downloadPdf(bytes: Uint8Array): void {245 const a = document.createElement("a");246 a.download = codexFilename();247 248 249 const owned = new Uint8Array(bytes);250 const blob = new Blob([owned.buffer], { type: "application/pdf" });251 a.href = URL.createObjectURL(blob);252 setTimeout(() => URL.revokeObjectURL(a.href), 4000);253 document.body.appendChild(a);254 a.click();255 a.remove();256 }257258 function explainCodexError(code: string, message: string): string {259 switch (code) {260 case "VAULT_LOCKED":261 return "your vault is locked. unlock and try again.";262 case "VAULT_EMPTY":263 return "your vault is empty — nothing to print yet.";264 case "PRO_REQUIRED":265 return "Codex generation is a Pro feature.";266 case "PDF_GENERATION_FAILED":267 case "AZTEC_GENERATION_FAILED":268 return "could not render the Codex. please try again.";269 case "COMPRESSION_ROUNDTRIP_FAILED":270 case "ERASURE_CODING_VERIFICATION_FAILED":271 case "ERASURE_CODING_CONTENT_MISMATCH":272 case "AZTEC_ROUNDTRIP_FAILED":273 case "AZTEC_CONTENT_MISMATCH":274 case "RECONSTRUCTION_SIMULATION_FAILED":275 case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":276 case "RECONSTRUCTION_FINGERPRINT_MISMATCH":277 case "RECONSTRUCTION_COUNT_MISMATCH":278 return `a self-check failed while building the Codex (${code}). nothing was saved. please try again.`;279 default:280 return message || "Codex generation failed.";281 }282 }283284 const onProgress: CodexProgress = (s, current, total, imageUrl) => {285 setProgressStep(s);286 setProgressCurrent(current ?? 0);287 setProgressTotal(total ?? 0);288 if (s === "aztec" && imageUrl) {289 setAztecGallery(prev => {290 if (prev.length >= GALLERY_MAX) {291 URL.revokeObjectURL(prev[0]!);292 return [...prev.slice(1), imageUrl];293 }294 return [...prev, imageUrl];295 });296 }297 };298299 async function handleGenerate() {300 const snap = snapshot();301 if (!snap || !allCorrect() || step() === "generating") return;302303 setGenError(null);304 setAztecGallery([]);305 setStep("generating");306 setProgressStep("compress");307308 try {309 const generateModule = await warmCodexLibrary(snap);310 if (!generateModule) {311 setStep("words");312 return;313 }314 const result = await generateModule.generateCodex(buildDeps(snap), onProgress);315 downloadPdf(result.pdfBytes);316 setCompleted(result.manifest);317 setStep("done");318319 try {320 await chrome.storage.local?.set({321 [STORAGE_KEY_LAST_GENERATED]: Date.now(),322 });323 } catch {324 325 }326 } catch (err) {327 const codexErr = err as CodexError;328 const code = (codexErr && (codexErr as { code?: string }).code) ?? "";329 const message =330 err instanceof Error ? err.message : "Codex generation failed.";331 console.error("[Codex] generation failed:", code, message);332 setGenError(explainCodexError(code, message));333 setStep("words");334 }335 }336337 function progressLabel(): string {338 switch (progressStep()) {339 case "compress":340 return "compressing vault";341 case "erasure":342 return "building chunks";343 case "aztec": {344 const total = progressTotal();345 const current = progressCurrent();346 if (total > 0) return `generating codes (${current} of ${total})`;347 return "generating codes";348 }349 case "verify":350 return "self-checking";351 case "pdf":352 return "creating PDF";353 default:354 return "preparing";355 }356 }357358 function progressPercent(): number {359 const total = progressTotal();360 const current = progressCurrent();361 362 363 switch (progressStep()) {364 case "compress":365 return 8;366 case "erasure":367 return 18;368 case "aztec":369 if (total <= 0) return 30;370 return 30 + Math.floor((current / total) * 55); 371 case "verify":372 return 90;373 case "pdf":374 return 96;375 default:376 return 4;377 }378 }379380 return (381 <div class="peach-overlay-view ov-enter-right" style="display:flex">382 <div class="peach-overlay-header">383 <button384 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"385 onClick={props.onBack}386 aria-label="Back"387 disabled={step() === "generating"}388 >389 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"390 stroke="currentColor" stroke-width="1.1"391 stroke-linecap="round" stroke-linejoin="round">392 <path d="M6 1L1 6L6 11" />393 </svg>394 </button>395 <span class="peach-overlay-title">peach codex</span>396 <div class="peach-overlay-spacer"></div>397 </div>398399 <div400 class="peach-settings-body peach-codex-body peach-codex-body--runes"401 classList={{ "peach-codex-body--done": step() === "done" }}402 >403 <RecoveryRuneBackground />404405 <Show when={!props.isPro}>406 <p class="peach-hint">redirecting…</p>407 </Show>408409 <Show when={props.isPro && loading()}>410 <p class="peach-hint">loading vault…</p>411 </Show>412413 <Show when={props.isPro && !loading() && loadError()}>414 <p class="peach-hint peach-hint-error">{loadError()}</p>415 </Show>416417 <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>418 <div class="peach-settings-group peach-codex-intro">419 <p class="peach-codex-blurb">420 the Codex prints your encrypted vault as Aztec codes. before421 you can generate one, set up your 12 recovery words in422 Settings → Security.423 </p>424 </div>425 <button426 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"427 onClick={props.onOpenSecurity}428 >429 open Security settings430 </button>431 </Show>432433 <Show when={props.isPro && !loading() && !loadError() && needsPassword()}>434 <div class="peach-codex-recovery-headline">435 <h2 class="peach-codex-recovery-title">confirm it's you.</h2>436 <p class="peach-codex-recovery-body">437 enter your master password to show your recovery words.438 </p>439 </div>440441 <div class="peach-codex-confirm-section">442 <input443 class="peach-codex-confirm-input"444 type="password"445 autocomplete="current-password"446 placeholder="master password"447 value={password()}448 onInput={(e) => setPassword((e.target as HTMLInputElement).value)}449 onKeyDown={(e) => {450 if (e.key === "Enter") void submitPassword();451 }}452 />453 <Show when={passwordError()}>454 <p class="peach-hint peach-hint-error peach-codex-error">455 {passwordError()}456 </p>457 </Show>458 </div>459460 <button461 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"462 disabled={!password()}463 onClick={() => void submitPassword()}464 >465 continue466 </button>467 </Show>468469 <Show470 when={471 props.isPro &&472 !loading() &&473 !loadError() &&474 !needsPassword() &&475 !missingRecoveryWords() &&476 snapshot()477 }478 >479 <Show when={step() === "words"}>480 <div class="peach-codex-recovery-headline">481 <h2 class="peach-codex-recovery-title">your recovery words.</h2>482 <p class="peach-codex-recovery-body">483 write these down — store separately from your Codex.484 </p>485 </div>486487 <ol class="peach-codex-words-grid" start={1}>488 <For each={snapshot()!.recoveryWords ?? []}>489 {(word, i) => (490 <li class="peach-codex-word-row">491 <span class="peach-codex-word-numeral">{i() + 1}</span>492 <span class="peach-codex-word-text">{word}</span>493 </li>494 )}495 </For>496 </ol>497498 <Show when={estimatedPages() !== null}>499 <p class="peach-codex-meta">500 <strong>{estimatedPages()}</strong>{" "}501 {estimatedPages() === 1 ? "page" : "pages"} ·{" "}502 {snapshot()!.entryCount}{" "}503 {snapshot()!.entryCount === 1 ? "entry" : "entries"}504 <Show when={estimatedParity() !== null}>505 {" "}· {estimatedParity()} redundancy codes506 </Show>507 </p>508 </Show>509510 <div class="peach-codex-confirm-section">511 <p class="peach-codex-confirm-label">512 to continue, enter words {confirmPositions().join(", ")}:513 </p>514 <For each={confirmPositions()}>515 {(pos, i) => (516 <div class="peach-codex-confirm-row">517 <span class="peach-codex-confirm-pos">word {pos}</span>518 <input519 class="peach-codex-confirm-input"520 type="text"521 autocomplete="off"522 autocorrect="off"523 autocapitalize="none"524 spellcheck={false}525 value={confirmInputs()[i()]}526 onInput={(e) => {527 const next = [...confirmInputs()] as [528 string,529 string,530 string,531 ];532 next[i()] = (e.target as HTMLInputElement).value;533 setConfirmInputs(next);534 }}535 onKeyDown={(e) => {536 if (e.key === "Enter" && allCorrect())537 void handleGenerate();538 }}539 />540 </div>541 )}542 </For>543 </div>544545 <Show when={genError()}>546 <p class="peach-hint peach-hint-error peach-codex-error">547 {genError()}548 </p>549 </Show>550 <Show when={libraryError()}>551 <p class="peach-hint peach-hint-error peach-codex-error">552 {libraryError()}553 </p>554 </Show>555 <Show when={libraryLoading() && !libraryReady()}>556 <p class="peach-hint peach-codex-meta">557 preparing Codex library…558 </p>559 </Show>560561 <button562 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"563 disabled={!allCorrect() || libraryLoading()}564 onClick={() => void handleGenerate()}565 >566 {libraryLoading() && !libraryReady() ? "preparing…" : "generate my Codex"}567 </button>568 </Show>569570 <Show when={step() === "generating"}>571 <div class="peach-codex-recovery-headline">572 <h2 class="peach-codex-recovery-title">573 generating your codex…574 </h2>575 </div>576577 <div class="peach-codex-progress-group">578 <p class="peach-codex-progress-label">579 <span>{progressLabel()}</span>580 <span class="peach-codex-verify-tag">581 {snapshot()?.verifyCode}582 </span>583 </p>584 <div class="peach-codex-progress-track">585 <div586 class="peach-codex-progress-fill"587 style={`width:${progressPercent()}%`}588 />589 </div>590 </div>591592 <Show when={aztecGallery().length > 0}>593 <div class="peach-codex-aztec-gallery">594 <For each={aztecGallery()}>595 {(url) => (596 <img597 class="peach-codex-aztec-tile"598 src={url}599 alt=""600 aria-hidden="true"601 />602 )}603 </For>604 </div>605 </Show>606 </Show>607608 <Show when={step() === "done"}>609 <div class="peach-codex-recovery-headline">610 <h2 class="peach-codex-recovery-title">611 your codex is downloading.612 </h2>613 </div>614615 <Show when={completed()}>616 <p class="peach-codex-meta">617 {completed()!.pageCount}{" "}618 {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}619 {completed()!.totalChunks} codes · verify code{" "}620 <span class="peach-codex-verify">621 {completed()!.verifyCode}622 </span>623 </p>624 </Show>625626 <div class="peach-codex-done-guidance">627 <div class="peach-codex-done-item">628 <div>629 <p class="peach-codex-done-item-title">630 print on a laser printer631 </p>632 <p class="peach-codex-done-item-body">633 laser prints last longer and scan more reliably than634 inkjet. print at 100% scale — do not fit to page.635 </p>636 </div>637 </div>638639 <div class="peach-codex-done-item">640 <div>641 <p class="peach-codex-done-item-title">make two copies</p>642 <p class="peach-codex-done-item-body">643 store one at home (fireproof box or safe) and one644 somewhere else (trusted person, safety deposit box).645 fire and flood can destroy a single copy.646 </p>647 </div>648 </div>649650 <div class="peach-codex-done-item">651 <div>652 <p class="peach-codex-done-item-title">653 store separately from recovery words654 </p>655 <p class="peach-codex-done-item-body">656 your 12 recovery words go somewhere different. together657 they restore your vault without your passphrase. keep658 them apart.659 </p>660 </div>661 </div>662663 <div class="peach-codex-done-item">664 <div>665 <p class="peach-codex-done-item-title">also save the PDF</p>666 <p class="peach-codex-done-item-body">667 copy the PDF to a USB drive. Peach can import it668 directly without needing to scan printed codes.669 </p>670 </div>671 </div>672 </div>673674 <p class="peach-codex-done-regenerate">675 regenerate your codex whenever you add many new entries.676 </p>677678 <button679 class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"680 onClick={() => {681 setCompleted(null);682 setGenError(null);683 setLibraryError(null);684 setConfirmInputs(["", "", ""]);685 setConfirmPositions(pickConfirmationPositions());686 setStep("words");687 }}688 >689 generate another690 </button>691 </Show>692 </Show>693 </div>694 </div>695 );696}