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 <div class="peach-settings-body peach-codex-body peach-codex-body--runes">371 <RecoveryRuneBackground />372373 <Show when={!props.isPro}>374 <p class="peach-hint">redirecting…</p>375 </Show>376377 <Show when={props.isPro && loading()}>378 <p class="peach-hint">loading vault…</p>379 </Show>380381 <Show when={props.isPro && !loading() && loadError()}>382 <p class="peach-hint peach-hint-error">{loadError()}</p>383 </Show>384385 <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>386 <div class="peach-settings-group peach-codex-intro">387 <p class="peach-codex-blurb">388 the Codex prints your encrypted vault as Aztec codes. before389 you can generate one, set up your 12 recovery words in390 Settings → Security.391 </p>392 </div>393 <button394 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"395 onClick={props.onOpenSecurity}396 >397 open Security settings398 </button>399 </Show>400401 <Show402 when={403 props.isPro &&404 !loading() &&405 !loadError() &&406 !missingRecoveryWords() &&407 snapshot()408 }409 >410 <Show when={step() === "words"}>411 <div class="peach-codex-recovery-headline">412 <h2 class="peach-codex-recovery-title">your recovery words.</h2>413 <p class="peach-codex-recovery-body">414 write these down — store separately from your Codex.415 </p>416 </div>417418 <ol class="peach-codex-words-grid" start={1}>419 <For each={snapshot()!.recoveryWords ?? []}>420 {(word, i) => (421 <li class="peach-codex-word-row">422 <span class="peach-codex-word-numeral">{i() + 1}</span>423 <span class="peach-codex-word-text">{word}</span>424 </li>425 )}426 </For>427 </ol>428429 <Show when={estimatedPages() !== null}>430 <p class="peach-codex-meta">431 <strong>{estimatedPages()}</strong>{" "}432 {estimatedPages() === 1 ? "page" : "pages"} ·{" "}433 {snapshot()!.entryCount}{" "}434 {snapshot()!.entryCount === 1 ? "entry" : "entries"}435 <Show when={estimatedParity() !== null}>436 {" "}· {estimatedParity()} redundancy codes437 </Show>438 </p>439 </Show>440441 <div class="peach-codex-confirm-section">442 <p class="peach-codex-confirm-label">443 to continue, enter words {confirmPositions().join(", ")}:444 </p>445 <For each={confirmPositions()}>446 {(pos, i) => (447 <div class="peach-codex-confirm-row">448 <span class="peach-codex-confirm-pos">word {pos}</span>449 <input450 class="peach-codex-confirm-input"451 type="text"452 autocomplete="off"453 autocorrect="off"454 autocapitalize="none"455 spellcheck={false}456 value={confirmInputs()[i()]}457 onInput={(e) => {458 const next = [...confirmInputs()] as [459 string,460 string,461 string,462 ];463 next[i()] = (e.target as HTMLInputElement).value;464 setConfirmInputs(next);465 }}466 onKeyDown={(e) => {467 if (e.key === "Enter" && allCorrect())468 void handleGenerate();469 }}470 />471 </div>472 )}473 </For>474 </div>475476 <Show when={genError()}>477 <p class="peach-hint peach-hint-error peach-codex-error">478 {genError()}479 </p>480 </Show>481 <Show when={libraryError()}>482 <p class="peach-hint peach-hint-error peach-codex-error">483 {libraryError()}484 </p>485 </Show>486 <Show when={libraryLoading() && !libraryReady()}>487 <p class="peach-hint peach-codex-meta">488 preparing Codex library…489 </p>490 </Show>491492 <button493 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"494 disabled={!allCorrect() || libraryLoading()}495 onClick={() => void handleGenerate()}496 >497 {libraryLoading() && !libraryReady() ? "preparing…" : "generate my Codex"}498 </button>499 </Show>500501 <Show when={step() === "generating"}>502 <div class="peach-codex-recovery-headline">503 <h2 class="peach-codex-recovery-title">504 generating your codex…505 </h2>506 </div>507508 <div class="peach-codex-progress-group">509 <p class="peach-codex-progress-label">510 <span>{progressLabel()}</span>511 <span class="peach-codex-verify-tag">512 {snapshot()?.verifyCode}513 </span>514 </p>515 <div class="peach-codex-progress-track">516 <div517 class="peach-codex-progress-fill"518 style={`width:${progressPercent()}%`}519 />520 </div>521 </div>522523 <Show when={aztecGallery().length > 0}>524 <div class="peach-codex-aztec-gallery">525 <For each={aztecGallery()}>526 {(url) => (527 <img528 class="peach-codex-aztec-tile"529 src={url}530 alt=""531 aria-hidden="true"532 />533 )}534 </For>535 </div>536 </Show>537 </Show>538539 <Show when={step() === "done"}>540 <div class="peach-codex-recovery-headline">541 <h2 class="peach-codex-recovery-title">542 your codex is downloading.543 </h2>544 </div>545546 <Show when={completed()}>547 <p class="peach-codex-meta">548 {completed()!.pageCount}{" "}549 {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}550 {completed()!.totalChunks} codes · verify code{" "}551 <span class="peach-codex-verify">552 {completed()!.verifyCode}553 </span>554 </p>555 </Show>556557 <div class="peach-codex-done-guidance">558 <div class="peach-codex-done-item">559 <span class="peach-codex-done-item-icon">🖨</span>560 <div>561 <p class="peach-codex-done-item-title">562 print on a laser printer563 </p>564 <p class="peach-codex-done-item-body">565 laser prints last longer and scan more reliably than566 inkjet. print at 100% scale — do not fit to page.567 </p>568 </div>569 </div>570571 <div class="peach-codex-done-item">572 <span class="peach-codex-done-item-icon">📦</span>573 <div>574 <p class="peach-codex-done-item-title">make two copies</p>575 <p class="peach-codex-done-item-body">576 store one at home (fireproof box or safe) and one577 somewhere else (trusted person, safety deposit box).578 fire and flood can destroy a single copy.579 </p>580 </div>581 </div>582583 <div class="peach-codex-done-item">584 <span class="peach-codex-done-item-icon">🔑</span>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 <span class="peach-codex-done-item-icon">💾</span>599 <div>600 <p class="peach-codex-done-item-title">also save the PDF</p>601 <p class="peach-codex-done-item-body">602 copy the PDF to a USB drive. Peach can import it603 directly without needing to scan printed codes.604 </p>605 </div>606 </div>607 </div>608609 <p class="peach-codex-done-regenerate">610 regenerate your codex whenever you add many new entries.611 </p>612613 <button614 class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"615 onClick={() => {616 setCompleted(null);617 setGenError(null);618 setLibraryError(null);619 setConfirmInputs(["", "", ""]);620 setConfirmPositions(pickConfirmationPositions());621 setStep("words");622 }}623 >624 generate another625 </button>626 </Show>627 </Show>628 </div>629 </div>630 );631}