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";4546const ROMAN = [47 "I.", "II.", "III.", "IV.", "V.", "VI.",48 "VII.", "VIII.", "IX.", "X.", "XI.", "XII.",49];5051export 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 [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false);5859 const [step, setStep] = createSignal<Step>("words");60 const [confirmPositions, setConfirmPositions] = createSignal<61 [number, number, number]62 >([3, 7, 11]);63 const [confirmInputs, setConfirmInputs] = createSignal<64 [string, string, string]65 >(["", "", ""]);6667 const [progressStep, setProgressStep] = createSignal<string>("");68 const [progressCurrent, setProgressCurrent] = createSignal<number>(0);69 const [progressTotal, setProgressTotal] = createSignal<number>(0);7071 const [genError, setGenError] = createSignal<string | null>(null);72 const [completed, setCompleted] = createSignal<CodexManifest | null>(null);7374 onMount(async () => {75 if (!props.isPro) {76 props.onUpgradePro();77 return;78 }7980 const res = (await sendMessage({81 type: "GET_CODEX_VAULT_DATA",82 })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };8384 if (!res.ok || !res.data) {85 setLoadError(res.error ?? "could not load vault data");86 setLoading(false);87 return;88 }8990 setSnapshot(res.data);9192 const words = res.data.recoveryWords;93 if (!words || words.length !== 12) {94 setMissingRecoveryWords(true);95 setLoading(false);96 return;97 }9899 setConfirmPositions(pickConfirmationPositions());100101 102 103 try {104 await getReedSolomon();105 } catch (err) {106 setLoadError(107 `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`108 );109 setLoading(false);110 return;111 }112113 if (res.data.slotA) {114 try {115 const { pages, parityShards } = await estimateCodexPages(116 res.data.slotA,117 res.data.slotB,118 );119 setEstimatedPages(pages);120 setEstimatedParity(parityShards);121 } catch {122 123 setEstimatedPages(null);124 setEstimatedParity(null);125 }126 }127128 setLoading(false);129 });130131 const allCorrect = createMemo(() => {132 const snap = snapshot();133 const words = snap?.recoveryWords;134 if (!words || words.length !== 12) return false;135 const positions = confirmPositions();136 const inputs = confirmInputs();137 return positions.every(138 (pos, i) =>139 inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),140 );141 });142143 function buildDeps(snap: CodexVaultSnapshot): CodexDeps {144 return {145 getSlotA: async () => snap.slotA,146 getSlotB: async () => snap.slotB,147 getLastModified: async () => snap.lastModified,148 149 150 151 getAllEntries: async () =>152 Array.from(153 { length: snap.entryCount },154 () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,155 ),156 getVerifyCode: async () => snap.verifyCode,157 isVaultUnlocked: () => snap.vaultUnlocked,158 };159 }160161 function codexFilename(): string {162 const d = new Date();163 const y = d.getFullYear();164 const m = String(d.getMonth() + 1).padStart(2, "0");165 const day = String(d.getDate()).padStart(2, "0");166 return `peach-codex-${y}-${m}-${day}.pdf`;167 }168169 function downloadPdf(bytes: Uint8Array): void {170 const a = document.createElement("a");171 a.download = codexFilename();172 if (isNativePlatform()) {173 174 175 176 177 let bin = "";178 const chunk = 0x8000;179 for (let i = 0; i < bytes.length; i += chunk) {180 const slice = bytes.subarray(i, Math.min(i + chunk, bytes.length));181 bin += String.fromCharCode(...slice);182 }183 a.href = `data:application/pdf;base64,${btoa(bin)}`;184 } else {185 186 187 const owned = new Uint8Array(bytes);188 const blob = new Blob([owned.buffer], { type: "application/pdf" });189 a.href = URL.createObjectURL(blob);190 setTimeout(() => URL.revokeObjectURL(a.href), 4000);191 }192 document.body.appendChild(a);193 a.click();194 a.remove();195 }196197 function explainCodexError(code: string, message: string): string {198 switch (code) {199 case "VAULT_LOCKED":200 return "your vault is locked. unlock and try again.";201 case "VAULT_EMPTY":202 return "your vault is empty — nothing to print yet.";203 case "PRO_REQUIRED":204 return "Codex generation is a Pro feature.";205 case "PDF_GENERATION_FAILED":206 case "AZTEC_GENERATION_FAILED":207 return "could not render the Codex. please try again.";208 case "COMPRESSION_ROUNDTRIP_FAILED":209 case "ERASURE_CODING_VERIFICATION_FAILED":210 case "ERASURE_CODING_CONTENT_MISMATCH":211 case "AZTEC_ROUNDTRIP_FAILED":212 case "AZTEC_CONTENT_MISMATCH":213 case "RECONSTRUCTION_SIMULATION_FAILED":214 case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":215 case "RECONSTRUCTION_FINGERPRINT_MISMATCH":216 case "RECONSTRUCTION_COUNT_MISMATCH":217 return "a self-check failed while building the Codex. nothing was saved. please try again.";218 default:219 return message || "Codex generation failed.";220 }221 }222223 const onProgress: CodexProgress = (s, current, total) => {224 setProgressStep(s);225 setProgressCurrent(current ?? 0);226 setProgressTotal(total ?? 0);227 };228229 async function handleGenerate() {230 const snap = snapshot();231 if (!snap || !allCorrect() || step() === "generating") return;232233 setGenError(null);234 setStep("generating");235 setProgressStep("compress");236237 try {238 const result = await generateCodex(buildDeps(snap), onProgress);239 downloadPdf(result.pdfBytes);240 setCompleted(result.manifest);241 setStep("done");242243 try {244 await chrome.storage.local?.set({245 [STORAGE_KEY_LAST_GENERATED]: Date.now(),246 });247 } catch {248 249 }250 } catch (err) {251 const codexErr = err as CodexError;252 const code = (codexErr && (codexErr as { code?: string }).code) ?? "";253 const message =254 err instanceof Error ? err.message : "Codex generation failed.";255 setGenError(explainCodexError(code, message));256 setStep("words");257 }258 }259260 function progressLabel(): string {261 switch (progressStep()) {262 case "compress":263 return "compressing vault";264 case "erasure":265 return "building chunks";266 case "aztec": {267 const total = progressTotal();268 const current = progressCurrent();269 if (total > 0) return `generating codes (${current} of ${total})`;270 return "generating codes";271 }272 case "verify":273 return "self-checking";274 case "pdf":275 return "creating PDF";276 default:277 return "preparing";278 }279 }280281 function progressPercent(): number {282 const total = progressTotal();283 const current = progressCurrent();284 285 286 switch (progressStep()) {287 case "compress":288 return 8;289 case "erasure":290 return 18;291 case "aztec":292 if (total <= 0) return 30;293 return 30 + Math.floor((current / total) * 55); 294 case "verify":295 return 90;296 case "pdf":297 return 96;298 default:299 return 4;300 }301 }302303 return (304 <div class="peach-overlay-view ov-enter-right" style="display:flex">305 <div class="peach-overlay-header">306 <button307 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"308 onClick={props.onBack}309 aria-label="Back"310 disabled={step() === "generating"}311 >312 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"313 stroke="currentColor" stroke-width="1.1"314 stroke-linecap="round" stroke-linejoin="round">315 <path d="M6 1L1 6L6 11" />316 </svg>317 </button>318 <span class="peach-overlay-title">peach codex</span>319 <div class="peach-overlay-spacer"></div>320 </div>321322 <div class="peach-settings-body peach-codex-body peach-codex-body--runes">323 <RecoveryRuneBackground />324325 <Show when={!props.isPro}>326 <p class="peach-hint">redirecting…</p>327 </Show>328329 <Show when={props.isPro && loading()}>330 <p class="peach-hint">loading vault…</p>331 </Show>332333 <Show when={props.isPro && !loading() && loadError()}>334 <p class="peach-hint peach-hint-error">{loadError()}</p>335 </Show>336337 <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>338 <div class="peach-settings-group peach-codex-intro">339 <p class="peach-codex-blurb">340 the Codex prints your encrypted vault as Aztec codes. before341 you can generate one, set up your 12 recovery words in342 Settings → Security.343 </p>344 </div>345 <button346 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"347 onClick={props.onOpenSecurity}348 >349 open Security settings350 </button>351 </Show>352353 <Show354 when={355 props.isPro &&356 !loading() &&357 !loadError() &&358 !missingRecoveryWords() &&359 snapshot()360 }361 >362 <Show when={step() === "words"}>363 <div class="peach-codex-recovery-headline">364 <h2 class="peach-codex-recovery-title">your recovery words.</h2>365 <p class="peach-codex-recovery-body">366 write these down — store separately from your Codex.367 </p>368 </div>369370 <ol class="peach-codex-words-grid" start={1}>371 <For each={snapshot()!.recoveryWords ?? []}>372 {(word, i) => (373 <li class="peach-codex-word-row">374 <span class="peach-codex-word-numeral">{ROMAN[i()]}</span>375 <span class="peach-codex-word-text">{word}</span>376 </li>377 )}378 </For>379 </ol>380381 <Show when={estimatedPages() !== null}>382 <p class="peach-codex-meta">383 <strong>{estimatedPages()}</strong>{" "}384 {estimatedPages() === 1 ? "page" : "pages"} ·{" "}385 {snapshot()!.entryCount}{" "}386 {snapshot()!.entryCount === 1 ? "entry" : "entries"}387 <Show when={estimatedParity() !== null}>388 {" "}· {estimatedParity()} redundancy codes389 </Show>390 </p>391 </Show>392393 <div class="peach-codex-confirm-section">394 <p class="peach-codex-confirm-label">395 to continue, enter words {confirmPositions().join(", ")}:396 </p>397 <For each={confirmPositions()}>398 {(pos, i) => (399 <div class="peach-codex-confirm-row">400 <span class="peach-codex-confirm-pos">word {pos}</span>401 <input402 class="peach-codex-confirm-input"403 type="text"404 autocomplete="off"405 autocorrect="off"406 autocapitalize="none"407 spellcheck={false}408 value={confirmInputs()[i()]}409 onInput={(e) => {410 const next = [...confirmInputs()] as [411 string,412 string,413 string,414 ];415 next[i()] = (e.target as HTMLInputElement).value;416 setConfirmInputs(next);417 }}418 onKeyDown={(e) => {419 if (e.key === "Enter" && allCorrect())420 void handleGenerate();421 }}422 />423 </div>424 )}425 </For>426 </div>427428 <Show when={genError()}>429 <p class="peach-hint peach-hint-error peach-codex-error">430 {genError()}431 </p>432 </Show>433434 <button435 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"436 disabled={!allCorrect()}437 onClick={() => void handleGenerate()}438 >439 generate my Codex440 </button>441 </Show>442443 <Show when={step() === "generating"}>444 <div class="peach-codex-recovery-headline">445 <h2 class="peach-codex-recovery-title">446 generating your codex…447 </h2>448 </div>449450 <div class="peach-codex-progress-group">451 <p class="peach-codex-progress-label">452 <span>{progressLabel()}</span>453 <span class="peach-codex-verify-tag">454 {snapshot()?.verifyCode}455 </span>456 </p>457 <div class="peach-codex-progress-track">458 <div459 class="peach-codex-progress-fill"460 style={`width:${progressPercent()}%`}461 />462 </div>463 </div>464 </Show>465466 <Show when={step() === "done"}>467 <div class="peach-codex-recovery-headline">468 <h2 class="peach-codex-recovery-title">469 your codex is downloading.470 </h2>471 </div>472473 <Show when={completed()}>474 <p class="peach-codex-meta">475 {completed()!.pageCount}{" "}476 {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}477 {completed()!.totalChunks} codes · verify code{" "}478 <span class="peach-codex-verify">479 {completed()!.verifyCode}480 </span>481 </p>482 </Show>483484 <div class="peach-codex-done-guidance">485 <div class="peach-codex-done-item">486 <span class="peach-codex-done-item-icon">🖨</span>487 <div>488 <p class="peach-codex-done-item-title">489 print on a laser printer490 </p>491 <p class="peach-codex-done-item-body">492 laser prints last longer and scan more reliably than493 inkjet. print at 100% scale — do not fit to page.494 </p>495 </div>496 </div>497498 <div class="peach-codex-done-item">499 <span class="peach-codex-done-item-icon">📦</span>500 <div>501 <p class="peach-codex-done-item-title">make two copies</p>502 <p class="peach-codex-done-item-body">503 store one at home (fireproof box or safe) and one504 somewhere else (trusted person, safety deposit box).505 fire and flood can destroy a single copy.506 </p>507 </div>508 </div>509510 <div class="peach-codex-done-item">511 <span class="peach-codex-done-item-icon">🔑</span>512 <div>513 <p class="peach-codex-done-item-title">514 store separately from recovery words515 </p>516 <p class="peach-codex-done-item-body">517 your 12 recovery words go somewhere different. together518 they restore your vault without your passphrase. keep519 them apart.520 </p>521 </div>522 </div>523524 <div class="peach-codex-done-item">525 <span class="peach-codex-done-item-icon">💾</span>526 <div>527 <p class="peach-codex-done-item-title">also save the PDF</p>528 <p class="peach-codex-done-item-body">529 copy the PDF to a USB drive. Peach can import it530 directly without needing to scan printed codes.531 </p>532 </div>533 </div>534 </div>535536 <p class="peach-codex-done-regenerate">537 regenerate your codex whenever you add many new entries.538 </p>539540 <button541 class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"542 onClick={() => {543 setCompleted(null);544 setGenError(null);545 setConfirmInputs(["", "", ""]);546 setConfirmPositions(pickConfirmationPositions());547 setStep("words");548 }}549 >550 generate another551 </button>552 </Show>553 </Show>554 </div>555 </div>556 );557}