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 [loading, setLoading] = createSignal(true);55 const [loadError, setLoadError] = createSignal<string | null>(null);56 const [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false);5758 const [step, setStep] = createSignal<Step>("words");59 const [confirmPositions, setConfirmPositions] = createSignal<60 [number, number, number]61 >([3, 7, 11]);62 const [confirmInputs, setConfirmInputs] = createSignal<63 [string, string, string]64 >(["", "", ""]);6566 const [progressStep, setProgressStep] = createSignal<string>("");67 const [progressCurrent, setProgressCurrent] = createSignal<number>(0);68 const [progressTotal, setProgressTotal] = createSignal<number>(0);6970 const [genError, setGenError] = createSignal<string | null>(null);71 const [completed, setCompleted] = createSignal<CodexManifest | null>(null);7273 onMount(async () => {74 if (!props.isPro) {75 props.onUpgradePro();76 return;77 }7879 const res = (await sendMessage({80 type: "GET_CODEX_VAULT_DATA",81 })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };8283 if (!res.ok || !res.data) {84 setLoadError(res.error ?? "could not load vault data");85 setLoading(false);86 return;87 }8889 setSnapshot(res.data);9091 const words = res.data.recoveryWords;92 if (!words || words.length !== 12) {93 setMissingRecoveryWords(true);94 setLoading(false);95 return;96 }9798 setConfirmPositions(pickConfirmationPositions());99100 101 102 try {103 await getReedSolomon();104 } catch (err) {105 setLoadError(106 `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`107 );108 setLoading(false);109 return;110 }111112 if (res.data.slotA) {113 try {114 const pages = await estimateCodexPages(res.data.slotA, res.data.slotB);115 setEstimatedPages(pages);116 } catch {117 118 setEstimatedPages(null);119 }120 }121122 setLoading(false);123 });124125 const allCorrect = createMemo(() => {126 const snap = snapshot();127 const words = snap?.recoveryWords;128 if (!words || words.length !== 12) return false;129 const positions = confirmPositions();130 const inputs = confirmInputs();131 return positions.every(132 (pos, i) =>133 inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),134 );135 });136137 function buildDeps(snap: CodexVaultSnapshot): CodexDeps {138 return {139 getSlotA: async () => snap.slotA,140 getSlotB: async () => snap.slotB,141 getLastModified: async () => snap.lastModified,142 143 144 145 getAllEntries: async () =>146 Array.from(147 { length: snap.entryCount },148 () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,149 ),150 getVerifyCode: async () => snap.verifyCode,151 isVaultUnlocked: () => snap.vaultUnlocked,152 };153 }154155 function codexFilename(): string {156 const d = new Date();157 const y = d.getFullYear();158 const m = String(d.getMonth() + 1).padStart(2, "0");159 const day = String(d.getDate()).padStart(2, "0");160 return `peach-codex-${y}-${m}-${day}.pdf`;161 }162163 function downloadPdf(bytes: Uint8Array): void {164 const a = document.createElement("a");165 a.download = codexFilename();166 if (isNativePlatform()) {167 168 169 170 171 let bin = "";172 const chunk = 0x8000;173 for (let i = 0; i < bytes.length; i += chunk) {174 const slice = bytes.subarray(i, Math.min(i + chunk, bytes.length));175 bin += String.fromCharCode(...slice);176 }177 a.href = `data:application/pdf;base64,${btoa(bin)}`;178 } else {179 180 181 const owned = new Uint8Array(bytes);182 const blob = new Blob([owned.buffer], { type: "application/pdf" });183 a.href = URL.createObjectURL(blob);184 setTimeout(() => URL.revokeObjectURL(a.href), 4000);185 }186 document.body.appendChild(a);187 a.click();188 a.remove();189 }190191 function explainCodexError(code: string, message: string): string {192 switch (code) {193 case "VAULT_LOCKED":194 return "your vault is locked. unlock and try again.";195 case "VAULT_EMPTY":196 return "your vault is empty — nothing to print yet.";197 case "PRO_REQUIRED":198 return "Codex generation is a Pro feature.";199 case "PDF_GENERATION_FAILED":200 case "AZTEC_GENERATION_FAILED":201 return "could not render the Codex. please try again.";202 case "COMPRESSION_ROUNDTRIP_FAILED":203 case "ERASURE_CODING_VERIFICATION_FAILED":204 case "ERASURE_CODING_CONTENT_MISMATCH":205 case "AZTEC_ROUNDTRIP_FAILED":206 case "AZTEC_CONTENT_MISMATCH":207 case "RECONSTRUCTION_SIMULATION_FAILED":208 case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":209 case "RECONSTRUCTION_FINGERPRINT_MISMATCH":210 case "RECONSTRUCTION_COUNT_MISMATCH":211 return "a self-check failed while building the Codex. nothing was saved. please try again.";212 default:213 return message || "Codex generation failed.";214 }215 }216217 const onProgress: CodexProgress = (s, current, total) => {218 setProgressStep(s);219 setProgressCurrent(current ?? 0);220 setProgressTotal(total ?? 0);221 };222223 async function handleGenerate() {224 const snap = snapshot();225 if (!snap || !allCorrect() || step() === "generating") return;226227 setGenError(null);228 setStep("generating");229 setProgressStep("compress");230231 try {232 const result = await generateCodex(buildDeps(snap), onProgress);233 downloadPdf(result.pdfBytes);234 setCompleted(result.manifest);235 setStep("done");236237 try {238 await chrome.storage.local?.set({239 [STORAGE_KEY_LAST_GENERATED]: Date.now(),240 });241 } catch {242 243 }244 } catch (err) {245 const codexErr = err as CodexError;246 const code = (codexErr && (codexErr as { code?: string }).code) ?? "";247 const message =248 err instanceof Error ? err.message : "Codex generation failed.";249 setGenError(explainCodexError(code, message));250 setStep("words");251 }252 }253254 function progressLabel(): string {255 switch (progressStep()) {256 case "compress":257 return "compressing vault";258 case "erasure":259 return "building chunks";260 case "aztec": {261 const total = progressTotal();262 const current = progressCurrent();263 if (total > 0) return `generating codes (${current} of ${total})`;264 return "generating codes";265 }266 case "verify":267 return "self-checking";268 case "pdf":269 return "creating PDF";270 default:271 return "preparing";272 }273 }274275 function progressPercent(): number {276 const total = progressTotal();277 const current = progressCurrent();278 279 280 switch (progressStep()) {281 case "compress":282 return 8;283 case "erasure":284 return 18;285 case "aztec":286 if (total <= 0) return 30;287 return 30 + Math.floor((current / total) * 55); 288 case "verify":289 return 90;290 case "pdf":291 return 96;292 default:293 return 4;294 }295 }296297 return (298 <div class="peach-overlay-view ov-enter-right" style="display:flex">299 <div class="peach-overlay-header">300 <button301 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"302 onClick={props.onBack}303 aria-label="Back"304 disabled={step() === "generating"}305 >306 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"307 stroke="currentColor" stroke-width="1.1"308 stroke-linecap="round" stroke-linejoin="round">309 <path d="M6 1L1 6L6 11" />310 </svg>311 </button>312 <span class="peach-overlay-title">peach codex</span>313 <div class="peach-overlay-spacer"></div>314 </div>315316 <div class="peach-settings-body peach-codex-body peach-codex-body--runes">317 <RecoveryRuneBackground />318319 <Show when={!props.isPro}>320 <p class="peach-hint">redirecting…</p>321 </Show>322323 <Show when={props.isPro && loading()}>324 <p class="peach-hint">loading vault…</p>325 </Show>326327 <Show when={props.isPro && !loading() && loadError()}>328 <p class="peach-hint peach-hint-error">{loadError()}</p>329 </Show>330331 <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>332 <div class="peach-settings-group peach-codex-intro">333 <p class="peach-codex-blurb">334 the Codex prints your encrypted vault as Aztec codes. before335 you can generate one, set up your 12 recovery words in336 Settings → Security.337 </p>338 </div>339 <button340 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"341 onClick={props.onOpenSecurity}342 >343 open Security settings344 </button>345 </Show>346347 <Show348 when={349 props.isPro &&350 !loading() &&351 !loadError() &&352 !missingRecoveryWords() &&353 snapshot()354 }355 >356 <Show when={step() === "words"}>357 <div class="peach-codex-recovery-headline">358 <h2 class="peach-codex-recovery-title">your recovery words.</h2>359 <p class="peach-codex-recovery-body">360 write these down and store them somewhere safe — separately361 from your Codex.362 </p>363 <p class="peach-codex-recovery-warn">364 together they restore your vault without your master365 passphrase. keep them apart.366 </p>367 </div>368369 <ol class="peach-codex-words-grid" start={1}>370 <For each={snapshot()!.recoveryWords ?? []}>371 {(word, i) => (372 <li class="peach-codex-word-row">373 <span class="peach-codex-word-numeral">{ROMAN[i()]}</span>374 <span class="peach-codex-word-text">{word}</span>375 </li>376 )}377 </For>378 </ol>379380 <Show when={estimatedPages() !== null}>381 <p class="peach-codex-meta">382 estimated <strong>{estimatedPages()}</strong>{" "}383 {estimatedPages() === 1 ? "page" : "pages"} ·{" "}384 {snapshot()!.entryCount}{" "}385 {snapshot()!.entryCount === 1 ? "entry" : "entries"}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 <p class="peach-codex-recovery-body">468 store it somewhere safe — a fireproof box, a safe, or a USB469 drive kept offline.470 </p>471 <p class="peach-codex-recovery-warn">472 your recovery words go somewhere else entirely. never store473 them with the Codex.474 </p>475 </div>476477 <Show when={completed()}>478 <p class="peach-codex-meta">479 {completed()!.pageCount}{" "}480 {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}481 {completed()!.totalChunks} codes · verify code{" "}482 <span class="peach-codex-verify">483 {completed()!.verifyCode}484 </span>485 </p>486 </Show>487488 <button489 class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"490 onClick={() => {491 setCompleted(null);492 setGenError(null);493 setConfirmInputs(["", "", ""]);494 setConfirmPositions(pickConfirmationPositions());495 setStep("words");496 }}497 >498 generate another499 </button>500 </Show>501 </Show>502 </div>503 </div>504 );505}