1234567891011121314151617import { createSignal, createMemo, For, onMount, onCleanup, 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 { pickConfirmationPositions } from "../codex/confirmation";30import type { VaultEntry } from "../../shared/types";3132interface CodexGenerateViewProps {33 isPro: boolean;34 onBack: () => void;35 onUpgradePro: () => void;36 onOpenSecurity: () => void;37 onVaultLocked: () => void;38}3940type Step = "words" | "generating" | "done";41type CodexGenerateModule = typeof import("../codex/generate");4243let codexGenerateModulePromise: Promise<CodexGenerateModule> | null = null;4445function loadCodexGenerateModule(): Promise<CodexGenerateModule> {46 codexGenerateModulePromise ??= import("../codex/generate");47 return codexGenerateModulePromise;48}495051export function CodexGenerateView(props: CodexGenerateViewProps) {52 const [snapshot, setSnapshot] = createSignal<CodexVaultSnapshot | null>(null);53 const [estimatedPages, setEstimatedPages] = createSignal<number | null>(null);54 const [estimatedParity, setEstimatedParity] = createSignal<number | null>(null);55 const [loading, setLoading] = createSignal(true);56 const [loadError, setLoadError] = createSignal<string | null>(null);57 const [libraryLoading, setLibraryLoading] = createSignal(false);58 const [libraryReady, setLibraryReady] = createSignal(false);59 const [libraryError, setLibraryError] = createSignal<string | null>(null);60 const [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false);6162 const [step, setStep] = createSignal<Step>("words");63 const [confirmPositions, setConfirmPositions] = createSignal<64 [number, number, number]65 >([3, 7, 11]);66 const [confirmInputs, setConfirmInputs] = createSignal<67 [string, string, string]68 >(["", "", ""]);6970 const [progressStep, setProgressStep] = createSignal<string>("");71 const [progressCurrent, setProgressCurrent] = createSignal<number>(0);72 const [progressTotal, setProgressTotal] = createSignal<number>(0);7374 const [genError, setGenError] = createSignal<string | null>(null);75 const [completed, setCompleted] = createSignal<CodexManifest | null>(null);76 77 78 const [aztecGallery, setAztecGallery] = createSignal<string[]>([]);79 const GALLERY_MAX = 36;8081 onCleanup(() => {82 for (const url of aztecGallery()) URL.revokeObjectURL(url);83 });8485 onMount(async () => {86 if (!props.isPro) {87 props.onUpgradePro();88 return;89 }9091 const res = (await sendMessage({92 type: "GET_CODEX_VAULT_DATA",93 })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };9495 if (!res.ok || !res.data) {96 97 98 if (res.error?.startsWith("VAULT_LOCKED")) {99 props.onVaultLocked();100 return;101 }102 setLoadError(res.error ?? "could not load vault data");103 setLoading(false);104 return;105 }106107 setSnapshot(res.data);108109 const words = res.data.recoveryWords;110 if (!words || words.length !== 12) {111 setMissingRecoveryWords(true);112 setLoading(false);113 return;114 }115116 setConfirmPositions(pickConfirmationPositions());117 setLoading(false);118 void warmCodexLibrary(res.data);119 });120121 const allCorrect = createMemo(() => {122 const snap = snapshot();123 const words = snap?.recoveryWords;124 if (!words || words.length !== 12) return false;125 const positions = confirmPositions();126 const inputs = confirmInputs();127 return positions.every(128 (pos, i) =>129 inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),130 );131 });132133 function buildDeps(snap: CodexVaultSnapshot): CodexDeps {134 return {135 getSlotA: async () => snap.slotA,136 getSlotB: async () => snap.slotB,137 getLastModified: async () => snap.lastModified,138 139 140 141 getAllEntries: async () =>142 Array.from(143 { length: snap.entryCount },144 () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,145 ),146 getVerifyCode: async () => snap.verifyCode,147 isVaultUnlocked: () => snap.vaultUnlocked,148 };149 }150151 async function warmCodexLibrary(152 snap?: CodexVaultSnapshot,153 ): Promise<CodexGenerateModule | null> {154 if (libraryReady()) return loadCodexGenerateModule();155156 setLibraryLoading(true);157 setLibraryError(null);158 try {159 const [generateModule, rsModule] = await Promise.all([160 loadCodexGenerateModule(),161 import("../codex/rs-loader"),162 ]);163 await rsModule.getReedSolomon();164 setLibraryReady(true);165166 if (snap?.slotA) {167 try {168 const { pages, parityShards } = await generateModule.estimateCodexPages(169 snap.slotA,170 snap.slotB,171 );172 setEstimatedPages(pages);173 setEstimatedParity(parityShards);174 } catch {175 176 setEstimatedPages(null);177 setEstimatedParity(null);178 }179 }180181 return generateModule;182 } catch (err) {183 codexGenerateModulePromise = null;184 setLibraryError(185 `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`,186 );187 return null;188 } finally {189 setLibraryLoading(false);190 }191 }192193 function codexFilename(): string {194 const d = new Date();195 const y = d.getFullYear();196 const m = String(d.getMonth() + 1).padStart(2, "0");197 const day = String(d.getDate()).padStart(2, "0");198 return `peach-codex-${y}-${m}-${day}.pdf`;199 }200201 function downloadPdf(bytes: Uint8Array): void {202 const a = document.createElement("a");203 a.download = codexFilename();204 if (isNativePlatform()) {205 206 207 208 209 let bin = "";210 const chunk = 0x8000;211 for (let i = 0; i < bytes.length; i += chunk) {212 const slice = bytes.subarray(i, Math.min(i + chunk, bytes.length));213 bin += String.fromCharCode(...slice);214 }215 a.href = `data:application/pdf;base64,${btoa(bin)}`;216 } else {217 218 219 const owned = new Uint8Array(bytes);220 const blob = new Blob([owned.buffer], { type: "application/pdf" });221 a.href = URL.createObjectURL(blob);222 setTimeout(() => URL.revokeObjectURL(a.href), 4000);223 }224 document.body.appendChild(a);225 a.click();226 a.remove();227 }228229 function explainCodexError(code: string, message: string): string {230 switch (code) {231 case "VAULT_LOCKED":232 return "your vault is locked. unlock and try again.";233 case "VAULT_EMPTY":234 return "your vault is empty — nothing to print yet.";235 case "PRO_REQUIRED":236 return "Codex generation is a Pro feature.";237 case "PDF_GENERATION_FAILED":238 case "AZTEC_GENERATION_FAILED":239 return "could not render the Codex. please try again.";240 case "COMPRESSION_ROUNDTRIP_FAILED":241 case "ERASURE_CODING_VERIFICATION_FAILED":242 case "ERASURE_CODING_CONTENT_MISMATCH":243 case "AZTEC_ROUNDTRIP_FAILED":244 case "AZTEC_CONTENT_MISMATCH":245 case "RECONSTRUCTION_SIMULATION_FAILED":246 case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":247 case "RECONSTRUCTION_FINGERPRINT_MISMATCH":248 case "RECONSTRUCTION_COUNT_MISMATCH":249 return `a self-check failed while building the Codex (${code}). nothing was saved. please try again.`;250 default:251 return message || "Codex generation failed.";252 }253 }254255 const onProgress: CodexProgress = (s, current, total, imageUrl) => {256 setProgressStep(s);257 setProgressCurrent(current ?? 0);258 setProgressTotal(total ?? 0);259 if (s === "aztec" && imageUrl) {260 setAztecGallery(prev => {261 if (prev.length >= GALLERY_MAX) {262 URL.revokeObjectURL(prev[0]!);263 return [...prev.slice(1), imageUrl];264 }265 return [...prev, imageUrl];266 });267 }268 };269270 async function handleGenerate() {271 const snap = snapshot();272 if (!snap || !allCorrect() || step() === "generating") return;273274 setGenError(null);275 setAztecGallery([]);276 setStep("generating");277 setProgressStep("compress");278279 try {280 const generateModule = await warmCodexLibrary(snap);281 if (!generateModule) {282 setStep("words");283 return;284 }285 const result = await generateModule.generateCodex(buildDeps(snap), onProgress);286 downloadPdf(result.pdfBytes);287 setCompleted(result.manifest);288 setStep("done");289290 try {291 await chrome.storage.local?.set({292 [STORAGE_KEY_LAST_GENERATED]: Date.now(),293 });294 } catch {295 296 }297 } catch (err) {298 const codexErr = err as CodexError;299 const code = (codexErr && (codexErr as { code?: string }).code) ?? "";300 const message =301 err instanceof Error ? err.message : "Codex generation failed.";302 console.error("[Codex] generation failed:", code, message);303 setGenError(explainCodexError(code, message));304 setStep("words");305 }306 }307308 function progressLabel(): string {309 switch (progressStep()) {310 case "compress":311 return "compressing vault";312 case "erasure":313 return "building chunks";314 case "aztec": {315 const total = progressTotal();316 const current = progressCurrent();317 if (total > 0) return `generating codes (${current} of ${total})`;318 return "generating codes";319 }320 case "verify":321 return "self-checking";322 case "pdf":323 return "creating PDF";324 default:325 return "preparing";326 }327 }328329 function progressPercent(): number {330 const total = progressTotal();331 const current = progressCurrent();332 333 334 switch (progressStep()) {335 case "compress":336 return 8;337 case "erasure":338 return 18;339 case "aztec":340 if (total <= 0) return 30;341 return 30 + Math.floor((current / total) * 55); 342 case "verify":343 return 90;344 case "pdf":345 return 96;346 default:347 return 4;348 }349 }350351 return (352 <div class="peach-overlay-view ov-enter-right" style="display:flex">353 <div class="peach-overlay-header">354 <button355 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"356 onClick={props.onBack}357 aria-label="Back"358 disabled={step() === "generating"}359 >360 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"361 stroke="currentColor" stroke-width="1.1"362 stroke-linecap="round" stroke-linejoin="round">363 <path d="M6 1L1 6L6 11" />364 </svg>365 </button>366 <span class="peach-overlay-title">peach codex</span>367 <div class="peach-overlay-spacer"></div>368 </div>369370 <div371 class="peach-settings-body peach-codex-body peach-codex-body--runes"372 classList={{ "peach-codex-body--done": step() === "done" }}373 >374 <RecoveryRuneBackground />375376 <Show when={!props.isPro}>377 <p class="peach-hint">redirecting…</p>378 </Show>379380 <Show when={props.isPro && loading()}>381 <p class="peach-hint">loading vault…</p>382 </Show>383384 <Show when={props.isPro && !loading() && loadError()}>385 <p class="peach-hint peach-hint-error">{loadError()}</p>386 </Show>387388 <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>389 <div class="peach-settings-group peach-codex-intro">390 <p class="peach-codex-blurb">391 the Codex prints your encrypted vault as Aztec codes. before392 you can generate one, set up your 12 recovery words in393 Settings → Security.394 </p>395 </div>396 <button397 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"398 onClick={props.onOpenSecurity}399 >400 open Security settings401 </button>402 </Show>403404 <Show405 when={406 props.isPro &&407 !loading() &&408 !loadError() &&409 !missingRecoveryWords() &&410 snapshot()411 }412 >413 <Show when={step() === "words"}>414 <div class="peach-codex-recovery-headline">415 <h2 class="peach-codex-recovery-title">your recovery words.</h2>416 <p class="peach-codex-recovery-body">417 write these down — store separately from your Codex.418 </p>419 </div>420421 <ol class="peach-codex-words-grid" start={1}>422 <For each={snapshot()!.recoveryWords ?? []}>423 {(word, i) => (424 <li class="peach-codex-word-row">425 <span class="peach-codex-word-numeral">{i() + 1}</span>426 <span class="peach-codex-word-text">{word}</span>427 </li>428 )}429 </For>430 </ol>431432 <Show when={estimatedPages() !== null}>433 <p class="peach-codex-meta">434 <strong>{estimatedPages()}</strong>{" "}435 {estimatedPages() === 1 ? "page" : "pages"} ·{" "}436 {snapshot()!.entryCount}{" "}437 {snapshot()!.entryCount === 1 ? "entry" : "entries"}438 <Show when={estimatedParity() !== null}>439 {" "}· {estimatedParity()} redundancy codes440 </Show>441 </p>442 </Show>443444 <div class="peach-codex-confirm-section">445 <p class="peach-codex-confirm-label">446 to continue, enter words {confirmPositions().join(", ")}:447 </p>448 <For each={confirmPositions()}>449 {(pos, i) => (450 <div class="peach-codex-confirm-row">451 <span class="peach-codex-confirm-pos">word {pos}</span>452 <input453 class="peach-codex-confirm-input"454 type="text"455 autocomplete="off"456 autocorrect="off"457 autocapitalize="none"458 spellcheck={false}459 value={confirmInputs()[i()]}460 onInput={(e) => {461 const next = [...confirmInputs()] as [462 string,463 string,464 string,465 ];466 next[i()] = (e.target as HTMLInputElement).value;467 setConfirmInputs(next);468 }}469 onKeyDown={(e) => {470 if (e.key === "Enter" && allCorrect())471 void handleGenerate();472 }}473 />474 </div>475 )}476 </For>477 </div>478479 <Show when={genError()}>480 <p class="peach-hint peach-hint-error peach-codex-error">481 {genError()}482 </p>483 </Show>484 <Show when={libraryError()}>485 <p class="peach-hint peach-hint-error peach-codex-error">486 {libraryError()}487 </p>488 </Show>489 <Show when={libraryLoading() && !libraryReady()}>490 <p class="peach-hint peach-codex-meta">491 preparing Codex library…492 </p>493 </Show>494495 <button496 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"497 disabled={!allCorrect() || libraryLoading()}498 onClick={() => void handleGenerate()}499 >500 {libraryLoading() && !libraryReady() ? "preparing…" : "generate my Codex"}501 </button>502 </Show>503504 <Show when={step() === "generating"}>505 <div class="peach-codex-recovery-headline">506 <h2 class="peach-codex-recovery-title">507 generating your codex…508 </h2>509 </div>510511 <div class="peach-codex-progress-group">512 <p class="peach-codex-progress-label">513 <span>{progressLabel()}</span>514 <span class="peach-codex-verify-tag">515 {snapshot()?.verifyCode}516 </span>517 </p>518 <div class="peach-codex-progress-track">519 <div520 class="peach-codex-progress-fill"521 style={`width:${progressPercent()}%`}522 />523 </div>524 </div>525526 <Show when={aztecGallery().length > 0}>527 <div class="peach-codex-aztec-gallery">528 <For each={aztecGallery()}>529 {(url) => (530 <img531 class="peach-codex-aztec-tile"532 src={url}533 alt=""534 aria-hidden="true"535 />536 )}537 </For>538 </div>539 </Show>540 </Show>541542 <Show when={step() === "done"}>543 <div class="peach-codex-recovery-headline">544 <h2 class="peach-codex-recovery-title">545 your codex is downloading.546 </h2>547 </div>548549 <Show when={completed()}>550 <p class="peach-codex-meta">551 {completed()!.pageCount}{" "}552 {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}553 {completed()!.totalChunks} codes · verify code{" "}554 <span class="peach-codex-verify">555 {completed()!.verifyCode}556 </span>557 </p>558 </Show>559560 <div class="peach-codex-done-guidance">561 <div class="peach-codex-done-item">562 <div>563 <p class="peach-codex-done-item-title">564 print on a laser printer565 </p>566 <p class="peach-codex-done-item-body">567 laser prints last longer and scan more reliably than568 inkjet. print at 100% scale — do not fit to page.569 </p>570 </div>571 </div>572573 <div class="peach-codex-done-item">574 <div>575 <p class="peach-codex-done-item-title">make two copies</p>576 <p class="peach-codex-done-item-body">577 store one at home (fireproof box or safe) and one578 somewhere else (trusted person, safety deposit box).579 fire and flood can destroy a single copy.580 </p>581 </div>582 </div>583584 <div class="peach-codex-done-item">585 <div>586 <p class="peach-codex-done-item-title">587 store separately from recovery words588 </p>589 <p class="peach-codex-done-item-body">590 your 12 recovery words go somewhere different. together591 they restore your vault without your passphrase. keep592 them apart.593 </p>594 </div>595 </div>596597 <div class="peach-codex-done-item">598 <div>599 <p class="peach-codex-done-item-title">also save the PDF</p>600 <p class="peach-codex-done-item-body">601 copy the PDF to a USB drive. Peach can import it602 directly without needing to scan printed codes.603 </p>604 </div>605 </div>606 </div>607608 <p class="peach-codex-done-regenerate">609 regenerate your codex whenever you add many new entries.610 </p>611612 <button613 class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"614 onClick={() => {615 setCompleted(null);616 setGenError(null);617 setLibraryError(null);618 setConfirmInputs(["", "", ""]);619 setConfirmPositions(pickConfirmationPositions());620 setStep("words");621 }}622 >623 generate another624 </button>625 </Show>626 </Show>627 </div>628 </div>629 );630}