1234567891011121314151617import { createSignal, createMemo, For, onMount, 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 {30 estimateCodexPages,31 generateCodex,32 pickConfirmationPositions,33} from "../codex/generate";34import { getReedSolomon } from "../codex/rs-loader";35import type { VaultEntry } from "../../shared/types";3637interface CodexGenerateViewProps {38 isPro: boolean;39 onBack: () => void;40 onUpgradePro: () => void;41 onOpenSecurity: () => void;42}4344type Step = "words" | "generating" | "done";454647export function CodexGenerateView(props: CodexGenerateViewProps) {48 const [snapshot, setSnapshot] = createSignal<CodexVaultSnapshot | null>(null);49 const [estimatedPages, setEstimatedPages] = createSignal<number | null>(null);50 const [estimatedParity, setEstimatedParity] = createSignal<number | null>(null);51 const [loading, setLoading] = createSignal(true);52 const [loadError, setLoadError] = createSignal<string | null>(null);53 const [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false);5455 const [step, setStep] = createSignal<Step>("words");56 const [confirmPositions, setConfirmPositions] = createSignal<57 [number, number, number]58 >([3, 7, 11]);59 const [confirmInputs, setConfirmInputs] = createSignal<60 [string, string, string]61 >(["", "", ""]);6263 const [progressStep, setProgressStep] = createSignal<string>("");64 const [progressCurrent, setProgressCurrent] = createSignal<number>(0);65 const [progressTotal, setProgressTotal] = createSignal<number>(0);6667 const [genError, setGenError] = createSignal<string | null>(null);68 const [completed, setCompleted] = createSignal<CodexManifest | null>(null);6970 onMount(async () => {71 if (!props.isPro) {72 props.onUpgradePro();73 return;74 }7576 const res = (await sendMessage({77 type: "GET_CODEX_VAULT_DATA",78 })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };7980 if (!res.ok || !res.data) {81 setLoadError(res.error ?? "could not load vault data");82 setLoading(false);83 return;84 }8586 setSnapshot(res.data);8788 const words = res.data.recoveryWords;89 if (!words || words.length !== 12) {90 setMissingRecoveryWords(true);91 setLoading(false);92 return;93 }9495 setConfirmPositions(pickConfirmationPositions());9697 98 99 try {100 await getReedSolomon();101 } catch (err) {102 setLoadError(103 `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`104 );105 setLoading(false);106 return;107 }108109 if (res.data.slotA) {110 try {111 const { pages, parityShards } = await estimateCodexPages(112 res.data.slotA,113 res.data.slotB,114 );115 setEstimatedPages(pages);116 setEstimatedParity(parityShards);117 } catch {118 119 setEstimatedPages(null);120 setEstimatedParity(null);121 }122 }123124 setLoading(false);125 });126127 const allCorrect = createMemo(() => {128 const snap = snapshot();129 const words = snap?.recoveryWords;130 if (!words || words.length !== 12) return false;131 const positions = confirmPositions();132 const inputs = confirmInputs();133 return positions.every(134 (pos, i) =>135 inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),136 );137 });138139 function buildDeps(snap: CodexVaultSnapshot): CodexDeps {140 return {141 getSlotA: async () => snap.slotA,142 getSlotB: async () => snap.slotB,143 getLastModified: async () => snap.lastModified,144 145 146 147 getAllEntries: async () =>148 Array.from(149 { length: snap.entryCount },150 () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,151 ),152 getVerifyCode: async () => snap.verifyCode,153 isVaultUnlocked: () => snap.vaultUnlocked,154 };155 }156157 function codexFilename(): string {158 const d = new Date();159 const y = d.getFullYear();160 const m = String(d.getMonth() + 1).padStart(2, "0");161 const day = String(d.getDate()).padStart(2, "0");162 return `peach-codex-${y}-${m}-${day}.pdf`;163 }164165 function downloadPdf(bytes: Uint8Array): void {166 const a = document.createElement("a");167 a.download = codexFilename();168 if (isNativePlatform()) {169 170 171 172 173 let bin = "";174 const chunk = 0x8000;175 for (let i = 0; i < bytes.length; i += chunk) {176 const slice = bytes.subarray(i, Math.min(i + chunk, bytes.length));177 bin += String.fromCharCode(...slice);178 }179 a.href = `data:application/pdf;base64,${btoa(bin)}`;180 } else {181 182 183 const owned = new Uint8Array(bytes);184 const blob = new Blob([owned.buffer], { type: "application/pdf" });185 a.href = URL.createObjectURL(blob);186 setTimeout(() => URL.revokeObjectURL(a.href), 4000);187 }188 document.body.appendChild(a);189 a.click();190 a.remove();191 }192193 function explainCodexError(code: string, message: string): string {194 switch (code) {195 case "VAULT_LOCKED":196 return "your vault is locked. unlock and try again.";197 case "VAULT_EMPTY":198 return "your vault is empty — nothing to print yet.";199 case "PRO_REQUIRED":200 return "Codex generation is a Pro feature.";201 case "PDF_GENERATION_FAILED":202 case "AZTEC_GENERATION_FAILED":203 return "could not render the Codex. please try again.";204 case "COMPRESSION_ROUNDTRIP_FAILED":205 case "ERASURE_CODING_VERIFICATION_FAILED":206 case "ERASURE_CODING_CONTENT_MISMATCH":207 case "AZTEC_ROUNDTRIP_FAILED":208 case "AZTEC_CONTENT_MISMATCH":209 case "RECONSTRUCTION_SIMULATION_FAILED":210 case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":211 case "RECONSTRUCTION_FINGERPRINT_MISMATCH":212 case "RECONSTRUCTION_COUNT_MISMATCH":213 return "a self-check failed while building the Codex. nothing was saved. please try again.";214 default:215 return message || "Codex generation failed.";216 }217 }218219 const onProgress: CodexProgress = (s, current, total) => {220 setProgressStep(s);221 setProgressCurrent(current ?? 0);222 setProgressTotal(total ?? 0);223 };224225 async function handleGenerate() {226 const snap = snapshot();227 if (!snap || !allCorrect() || step() === "generating") return;228229 setGenError(null);230 setStep("generating");231 setProgressStep("compress");232233 try {234 const result = await generateCodex(buildDeps(snap), onProgress);235 downloadPdf(result.pdfBytes);236 setCompleted(result.manifest);237 setStep("done");238239 try {240 await chrome.storage.local?.set({241 [STORAGE_KEY_LAST_GENERATED]: Date.now(),242 });243 } catch {244 245 }246 } catch (err) {247 const codexErr = err as CodexError;248 const code = (codexErr && (codexErr as { code?: string }).code) ?? "";249 const message =250 err instanceof Error ? err.message : "Codex generation failed.";251 setGenError(explainCodexError(code, message));252 setStep("words");253 }254 }255256 function progressLabel(): string {257 switch (progressStep()) {258 case "compress":259 return "compressing vault";260 case "erasure":261 return "building chunks";262 case "aztec": {263 const total = progressTotal();264 const current = progressCurrent();265 if (total > 0) return `generating codes (${current} of ${total})`;266 return "generating codes";267 }268 case "verify":269 return "self-checking";270 case "pdf":271 return "creating PDF";272 default:273 return "preparing";274 }275 }276277 function progressPercent(): number {278 const total = progressTotal();279 const current = progressCurrent();280 281 282 switch (progressStep()) {283 case "compress":284 return 8;285 case "erasure":286 return 18;287 case "aztec":288 if (total <= 0) return 30;289 return 30 + Math.floor((current / total) * 55); 290 case "verify":291 return 90;292 case "pdf":293 return 96;294 default:295 return 4;296 }297 }298299 return (300 <div class="peach-overlay-view ov-enter-right" style="display:flex">301 <div class="peach-overlay-header">302 <button303 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"304 onClick={props.onBack}305 aria-label="Back"306 disabled={step() === "generating"}307 >308 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"309 stroke="currentColor" stroke-width="1.1"310 stroke-linecap="round" stroke-linejoin="round">311 <path d="M6 1L1 6L6 11" />312 </svg>313 </button>314 <span class="peach-overlay-title">peach codex</span>315 <div class="peach-overlay-spacer"></div>316 </div>317318 <div class="peach-settings-body peach-codex-body peach-codex-body--runes">319 <RecoveryRuneBackground />320321 <Show when={!props.isPro}>322 <p class="peach-hint">redirecting…</p>323 </Show>324325 <Show when={props.isPro && loading()}>326 <p class="peach-hint">loading vault…</p>327 </Show>328329 <Show when={props.isPro && !loading() && loadError()}>330 <p class="peach-hint peach-hint-error">{loadError()}</p>331 </Show>332333 <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>334 <div class="peach-settings-group peach-codex-intro">335 <p class="peach-codex-blurb">336 the Codex prints your encrypted vault as Aztec codes. before337 you can generate one, set up your 12 recovery words in338 Settings → Security.339 </p>340 </div>341 <button342 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"343 onClick={props.onOpenSecurity}344 >345 open Security settings346 </button>347 </Show>348349 <Show350 when={351 props.isPro &&352 !loading() &&353 !loadError() &&354 !missingRecoveryWords() &&355 snapshot()356 }357 >358 <Show when={step() === "words"}>359 <div class="peach-codex-recovery-headline">360 <h2 class="peach-codex-recovery-title">your recovery words.</h2>361 <p class="peach-codex-recovery-body">362 write these down — store separately from your Codex.363 </p>364 </div>365366 <ol class="peach-codex-words-grid" start={1}>367 <For each={snapshot()!.recoveryWords ?? []}>368 {(word, i) => (369 <li class="peach-codex-word-row">370 <span class="peach-codex-word-numeral">{i() + 1}</span>371 <span class="peach-codex-word-text">{word}</span>372 </li>373 )}374 </For>375 </ol>376377 <Show when={estimatedPages() !== null}>378 <p class="peach-codex-meta">379 <strong>{estimatedPages()}</strong>{" "}380 {estimatedPages() === 1 ? "page" : "pages"} ·{" "}381 {snapshot()!.entryCount}{" "}382 {snapshot()!.entryCount === 1 ? "entry" : "entries"}383 <Show when={estimatedParity() !== null}>384 {" "}· {estimatedParity()} redundancy codes385 </Show>386 </p>387 </Show>388389 <div class="peach-codex-confirm-section">390 <p class="peach-codex-confirm-label">391 to continue, enter words {confirmPositions().join(", ")}:392 </p>393 <For each={confirmPositions()}>394 {(pos, i) => (395 <div class="peach-codex-confirm-row">396 <span class="peach-codex-confirm-pos">word {pos}</span>397 <input398 class="peach-codex-confirm-input"399 type="text"400 autocomplete="off"401 autocorrect="off"402 autocapitalize="none"403 spellcheck={false}404 value={confirmInputs()[i()]}405 onInput={(e) => {406 const next = [...confirmInputs()] as [407 string,408 string,409 string,410 ];411 next[i()] = (e.target as HTMLInputElement).value;412 setConfirmInputs(next);413 }}414 onKeyDown={(e) => {415 if (e.key === "Enter" && allCorrect())416 void handleGenerate();417 }}418 />419 </div>420 )}421 </For>422 </div>423424 <Show when={genError()}>425 <p class="peach-hint peach-hint-error peach-codex-error">426 {genError()}427 </p>428 </Show>429430 <button431 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"432 disabled={!allCorrect()}433 onClick={() => void handleGenerate()}434 >435 generate my Codex436 </button>437 </Show>438439 <Show when={step() === "generating"}>440 <div class="peach-codex-recovery-headline">441 <h2 class="peach-codex-recovery-title">442 generating your codex…443 </h2>444 </div>445446 <div class="peach-codex-progress-group">447 <p class="peach-codex-progress-label">448 <span>{progressLabel()}</span>449 <span class="peach-codex-verify-tag">450 {snapshot()?.verifyCode}451 </span>452 </p>453 <div class="peach-codex-progress-track">454 <div455 class="peach-codex-progress-fill"456 style={`width:${progressPercent()}%`}457 />458 </div>459 </div>460 </Show>461462 <Show when={step() === "done"}>463 <div class="peach-codex-recovery-headline">464 <h2 class="peach-codex-recovery-title">465 your codex is downloading.466 </h2>467 </div>468469 <Show when={completed()}>470 <p class="peach-codex-meta">471 {completed()!.pageCount}{" "}472 {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}473 {completed()!.totalChunks} codes · verify code{" "}474 <span class="peach-codex-verify">475 {completed()!.verifyCode}476 </span>477 </p>478 </Show>479480 <div class="peach-codex-done-guidance">481 <div class="peach-codex-done-item">482 <span class="peach-codex-done-item-icon">🖨</span>483 <div>484 <p class="peach-codex-done-item-title">485 print on a laser printer486 </p>487 <p class="peach-codex-done-item-body">488 laser prints last longer and scan more reliably than489 inkjet. print at 100% scale — do not fit to page.490 </p>491 </div>492 </div>493494 <div class="peach-codex-done-item">495 <span class="peach-codex-done-item-icon">📦</span>496 <div>497 <p class="peach-codex-done-item-title">make two copies</p>498 <p class="peach-codex-done-item-body">499 store one at home (fireproof box or safe) and one500 somewhere else (trusted person, safety deposit box).501 fire and flood can destroy a single copy.502 </p>503 </div>504 </div>505506 <div class="peach-codex-done-item">507 <span class="peach-codex-done-item-icon">🔑</span>508 <div>509 <p class="peach-codex-done-item-title">510 store separately from recovery words511 </p>512 <p class="peach-codex-done-item-body">513 your 12 recovery words go somewhere different. together514 they restore your vault without your passphrase. keep515 them apart.516 </p>517 </div>518 </div>519520 <div class="peach-codex-done-item">521 <span class="peach-codex-done-item-icon">💾</span>522 <div>523 <p class="peach-codex-done-item-title">also save the PDF</p>524 <p class="peach-codex-done-item-body">525 copy the PDF to a USB drive. Peach can import it526 directly without needing to scan printed codes.527 </p>528 </div>529 </div>530 </div>531532 <p class="peach-codex-done-regenerate">533 regenerate your codex whenever you add many new entries.534 </p>535536 <button537 class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"538 onClick={() => {539 setCompleted(null);540 setGenError(null);541 setConfirmInputs(["", "", ""]);542 setConfirmPositions(pickConfirmationPositions());543 setStep("words");544 }}545 >546 generate another547 </button>548 </Show>549 </Show>550 </div>551 </div>552 );553}