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 131 setLoadError("could not load vault data");132 setLoading(false);133 return;134 }135136 setSnapshot(res.data);137138 const words = res.data.recoveryWords;139 if (!words || words.length !== 12) {140 141 142 if (!password) {143 setNeedsPassword(true);144 } else {145 setMissingRecoveryWords(true);146 }147 setLoading(false);148 return;149 }150151 setNeedsPassword(false);152 setPassword("");153 setPasswordError(null);154 setConfirmPositions(pickConfirmationPositions());155 setLoading(false);156 void warmCodexLibrary(res.data);157 }158159 async function submitPassword(): Promise<void> {160 if (!password()) return;161 setPasswordError(null);162 await loadSnapshot(password());163 }164165 const allCorrect = createMemo(() => {166 const snap = snapshot();167 const words = snap?.recoveryWords;168 if (!words || words.length !== 12) return false;169 const positions = confirmPositions();170 const inputs = confirmInputs();171 return positions.every(172 (pos, i) =>173 inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),174 );175 });176177 function buildDeps(snap: CodexVaultSnapshot): CodexDeps {178 return {179 getSlotA: async () => snap.slotA,180 getSlotB: async () => snap.slotB,181 getLastModified: async () => snap.lastModified,182 183 184 185 getAllEntries: async () =>186 Array.from(187 { length: snap.entryCount },188 () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,189 ),190 getVerifyCode: async () => snap.verifyCode,191 isVaultUnlocked: () => snap.vaultUnlocked,192 };193 }194195 async function warmCodexLibrary(196 snap?: CodexVaultSnapshot,197 ): Promise<CodexGenerateModule | null> {198 if (libraryReady()) return loadCodexGenerateModule();199200 setLibraryLoading(true);201 setLibraryError(null);202 try {203 const [generateModule, rsModule] = await Promise.all([204 loadCodexGenerateModule(),205 import("../codex/rs-loader"),206 ]);207 await rsModule.getReedSolomon();208 setLibraryReady(true);209210 if (snap?.slotA) {211 try {212 const { pages, parityShards } = await generateModule.estimateCodexPages(213 snap.slotA,214 snap.slotB,215 );216 setEstimatedPages(pages);217 setEstimatedParity(parityShards);218 } catch {219 220 setEstimatedPages(null);221 setEstimatedParity(null);222 }223 }224225 return generateModule;226 } catch (err) {227 codexGenerateModulePromise = null;228 setLibraryError(229 `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`,230 );231 return null;232 } finally {233 setLibraryLoading(false);234 }235 }236237 function codexFilename(): string {238 const d = new Date();239 const y = d.getFullYear();240 const m = String(d.getMonth() + 1).padStart(2, "0");241 const day = String(d.getDate()).padStart(2, "0");242 return `peach-codex-${y}-${m}-${day}.pdf`;243 }244245 function downloadPdf(bytes: Uint8Array): void {246 const a = document.createElement("a");247 a.download = codexFilename();248 249 250 const owned = new Uint8Array(bytes);251 const blob = new Blob([owned.buffer], { type: "application/pdf" });252 a.href = URL.createObjectURL(blob);253 setTimeout(() => URL.revokeObjectURL(a.href), 4000);254 document.body.appendChild(a);255 a.click();256 a.remove();257 }258259 function explainCodexError(code: string): string {260 switch (code) {261 case "VAULT_LOCKED":262 return "your vault is locked. unlock and try again.";263 case "VAULT_EMPTY":264 return "your vault is empty — nothing to print yet.";265 case "PRO_REQUIRED":266 return "Codex generation is a Pro feature.";267 case "PDF_GENERATION_FAILED":268 case "AZTEC_GENERATION_FAILED":269 return "could not render the Codex. please try again.";270 case "COMPRESSION_ROUNDTRIP_FAILED":271 case "ERASURE_CODING_VERIFICATION_FAILED":272 case "ERASURE_CODING_CONTENT_MISMATCH":273 case "AZTEC_ROUNDTRIP_FAILED":274 case "AZTEC_CONTENT_MISMATCH":275 case "RECONSTRUCTION_SIMULATION_FAILED":276 case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":277 case "RECONSTRUCTION_FINGERPRINT_MISMATCH":278 case "RECONSTRUCTION_COUNT_MISMATCH":279 return `a self-check failed while building the Codex (${code}). nothing was saved. please try again.`;280 default:281 282 return "Codex generation failed.";283 }284 }285286 const onProgress: CodexProgress = (s, current, total, imageUrl) => {287 setProgressStep(s);288 setProgressCurrent(current ?? 0);289 setProgressTotal(total ?? 0);290 if (s === "aztec" && imageUrl) {291 setAztecGallery(prev => {292 if (prev.length >= GALLERY_MAX) {293 URL.revokeObjectURL(prev[0]!);294 return [...prev.slice(1), imageUrl];295 }296 return [...prev, imageUrl];297 });298 }299 };300301 async function handleGenerate() {302 const snap = snapshot();303 if (!snap || !allCorrect() || step() === "generating") return;304305 setGenError(null);306 setAztecGallery([]);307 setStep("generating");308 setProgressStep("compress");309310 try {311 const generateModule = await warmCodexLibrary(snap);312 if (!generateModule) {313 setStep("words");314 return;315 }316 const result = await generateModule.generateCodex(buildDeps(snap), onProgress);317 downloadPdf(result.pdfBytes);318 setCompleted(result.manifest);319 setStep("done");320321 try {322 await chrome.storage.local?.set({323 [STORAGE_KEY_LAST_GENERATED]: Date.now(),324 });325 } catch {326 327 }328 } catch (err) {329 const codexErr = err as CodexError;330 const code = (codexErr && (codexErr as { code?: string }).code) ?? "";331 const message =332 err instanceof Error ? err.message : "Codex generation failed.";333 console.error("[Codex] generation failed:", code, message);334 setGenError(explainCodexError(code));335 setStep("words");336 }337 }338339 function progressLabel(): string {340 switch (progressStep()) {341 case "compress":342 return "compressing vault";343 case "erasure":344 return "building chunks";345 case "aztec": {346 const total = progressTotal();347 const current = progressCurrent();348 if (total > 0) return `generating codes (${current} of ${total})`;349 return "generating codes";350 }351 case "verify":352 return "self-checking";353 case "pdf":354 return "creating PDF";355 default:356 return "preparing";357 }358 }359360 function progressPercent(): number {361 const total = progressTotal();362 const current = progressCurrent();363 364 365 switch (progressStep()) {366 case "compress":367 return 8;368 case "erasure":369 return 18;370 case "aztec":371 if (total <= 0) return 30;372 return 30 + Math.floor((current / total) * 55); 373 case "verify":374 return 90;375 case "pdf":376 return 96;377 default:378 return 4;379 }380 }381382 return (383 <div class="peach-overlay-view ov-enter-right" style="display:flex">384 <div class="peach-overlay-header">385 <button386 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"387 onClick={props.onBack}388 aria-label="Back"389 disabled={step() === "generating"}390 >391 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"392 stroke="currentColor" stroke-width="1.1"393 stroke-linecap="round" stroke-linejoin="round">394 <path d="M6 1L1 6L6 11" />395 </svg>396 </button>397 <span class="peach-overlay-title">peach codex</span>398 <div class="peach-overlay-spacer"></div>399 </div>400401 <div402 class="peach-settings-body peach-codex-body peach-codex-body--runes"403 classList={{ "peach-codex-body--done": step() === "done" }}404 >405 <RecoveryRuneBackground />406407 <Show when={!props.isPro}>408 <p class="peach-hint">redirecting…</p>409 </Show>410411 <Show when={props.isPro && loading()}>412 <p class="peach-hint">loading vault…</p>413 </Show>414415 <Show when={props.isPro && !loading() && loadError()}>416 <p class="peach-hint peach-hint-error">{loadError()}</p>417 </Show>418419 <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>420 <div class="peach-settings-group peach-codex-intro">421 <p class="peach-codex-blurb">422 the Codex prints your encrypted vault as Aztec codes. before423 you can generate one, set up your 12 recovery words in424 Settings → Security.425 </p>426 </div>427 <button428 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"429 onClick={props.onOpenSecurity}430 >431 open Security settings432 </button>433 </Show>434435 <Show when={props.isPro && !loading() && !loadError() && needsPassword()}>436 <div class="peach-codex-recovery-headline">437 <h2 class="peach-codex-recovery-title">confirm it's you.</h2>438 <p class="peach-codex-recovery-body">439 enter your master password to show your recovery words.440 </p>441 </div>442443 <div class="peach-codex-confirm-section">444 <input445 class="peach-codex-confirm-input"446 type="password"447 autocomplete="current-password"448 placeholder="master password"449 value={password()}450 onInput={(e) => setPassword((e.target as HTMLInputElement).value)}451 onKeyDown={(e) => {452 if (e.key === "Enter") void submitPassword();453 }}454 />455 <Show when={passwordError()}>456 <p class="peach-hint peach-hint-error peach-codex-error">457 {passwordError()}458 </p>459 </Show>460 </div>461462 <button463 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"464 disabled={!password()}465 onClick={() => void submitPassword()}466 >467 continue468 </button>469 </Show>470471 <Show472 when={473 props.isPro &&474 !loading() &&475 !loadError() &&476 !needsPassword() &&477 !missingRecoveryWords() &&478 snapshot()479 }480 >481 <Show when={step() === "words"}>482 <div class="peach-codex-recovery-headline">483 <h2 class="peach-codex-recovery-title">your recovery words.</h2>484 <p class="peach-codex-recovery-body">485 write these down — store separately from your Codex.486 </p>487 </div>488489 <ol class="peach-codex-words-grid" start={1}>490 <For each={snapshot()!.recoveryWords ?? []}>491 {(word, i) => (492 <li class="peach-codex-word-row">493 <span class="peach-codex-word-numeral">{i() + 1}</span>494 <span class="peach-codex-word-text">{word}</span>495 </li>496 )}497 </For>498 </ol>499500 <Show when={estimatedPages() !== null}>501 <p class="peach-codex-meta">502 <strong>{estimatedPages()}</strong>{" "}503 {estimatedPages() === 1 ? "page" : "pages"} ·{" "}504 {snapshot()!.entryCount}{" "}505 {snapshot()!.entryCount === 1 ? "entry" : "entries"}506 <Show when={estimatedParity() !== null}>507 {" "}· {estimatedParity()} redundancy codes508 </Show>509 </p>510 </Show>511512 <div class="peach-codex-confirm-section">513 <p class="peach-codex-confirm-label">514 to continue, enter words {confirmPositions().join(", ")}:515 </p>516 <For each={confirmPositions()}>517 {(pos, i) => (518 <div class="peach-codex-confirm-row">519 <span class="peach-codex-confirm-pos">word {pos}</span>520 <input521 class="peach-codex-confirm-input"522 type="text"523 autocomplete="off"524 autocorrect="off"525 autocapitalize="none"526 spellcheck={false}527 value={confirmInputs()[i()]}528 onInput={(e) => {529 const next = [...confirmInputs()] as [530 string,531 string,532 string,533 ];534 next[i()] = (e.target as HTMLInputElement).value;535 setConfirmInputs(next);536 }}537 onKeyDown={(e) => {538 if (e.key === "Enter" && allCorrect())539 void handleGenerate();540 }}541 />542 </div>543 )}544 </For>545 </div>546547 <Show when={genError()}>548 <p class="peach-hint peach-hint-error peach-codex-error">549 {genError()}550 </p>551 </Show>552 <Show when={libraryError()}>553 <p class="peach-hint peach-hint-error peach-codex-error">554 {libraryError()}555 </p>556 </Show>557 <Show when={libraryLoading() && !libraryReady()}>558 <p class="peach-hint peach-codex-meta">559 preparing Codex library…560 </p>561 </Show>562563 <button564 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"565 disabled={!allCorrect() || libraryLoading()}566 onClick={() => void handleGenerate()}567 >568 {libraryLoading() && !libraryReady() ? "preparing…" : "generate my Codex"}569 </button>570 </Show>571572 <Show when={step() === "generating"}>573 <div class="peach-codex-recovery-headline">574 <h2 class="peach-codex-recovery-title">575 generating your codex…576 </h2>577 </div>578579 <div class="peach-codex-progress-group">580 <p class="peach-codex-progress-label">581 <span>{progressLabel()}</span>582 <span class="peach-codex-verify-tag">583 {snapshot()?.verifyCode}584 </span>585 </p>586 <div class="peach-codex-progress-track">587 <div588 class="peach-codex-progress-fill"589 style={`width:${progressPercent()}%`}590 />591 </div>592 </div>593594 <Show when={aztecGallery().length > 0}>595 <div class="peach-codex-aztec-gallery">596 <For each={aztecGallery()}>597 {(url) => (598 <img599 class="peach-codex-aztec-tile"600 src={url}601 alt=""602 aria-hidden="true"603 />604 )}605 </For>606 </div>607 </Show>608 </Show>609610 <Show when={step() === "done"}>611 <div class="peach-codex-recovery-headline">612 <h2 class="peach-codex-recovery-title">613 your codex is downloading.614 </h2>615 </div>616617 <Show when={completed()}>618 <p class="peach-codex-meta">619 {completed()!.pageCount}{" "}620 {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}621 {completed()!.totalChunks} codes · verify code{" "}622 <span class="peach-codex-verify">623 {completed()!.verifyCode}624 </span>625 </p>626 </Show>627628 <div class="peach-codex-done-guidance">629 <div class="peach-codex-done-item">630 <div>631 <p class="peach-codex-done-item-title">632 print on a laser printer633 </p>634 <p class="peach-codex-done-item-body">635 laser prints last longer and scan more reliably than636 inkjet. print at 100% scale — do not fit to page.637 </p>638 </div>639 </div>640641 <div class="peach-codex-done-item">642 <div>643 <p class="peach-codex-done-item-title">make two copies</p>644 <p class="peach-codex-done-item-body">645 store one at home (fireproof box or safe) and one646 somewhere else (trusted person, safety deposit box).647 fire and flood can destroy a single copy.648 </p>649 </div>650 </div>651652 <div class="peach-codex-done-item">653 <div>654 <p class="peach-codex-done-item-title">655 store separately from recovery words656 </p>657 <p class="peach-codex-done-item-body">658 your 12 recovery words go somewhere different. together659 they restore your vault without your passphrase. keep660 them apart.661 </p>662 </div>663 </div>664665 <div class="peach-codex-done-item">666 <div>667 <p class="peach-codex-done-item-title">also save the PDF</p>668 <p class="peach-codex-done-item-body">669 copy the PDF to a USB drive. Peach can import it670 directly without needing to scan printed codes.671 </p>672 </div>673 </div>674 </div>675676 <p class="peach-codex-done-regenerate">677 regenerate your codex whenever you add many new entries.678 </p>679680 <button681 class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"682 onClick={() => {683 setCompleted(null);684 setGenError(null);685 setLibraryError(null);686 setConfirmInputs(["", "", ""]);687 setConfirmPositions(pickConfirmationPositions());688 setStep("words");689 }}690 >691 generate another692 </button>693 </Show>694 </Show>695 </div>696 </div>697 );698}