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 and store them somewhere safe — separately367 from your Codex.368 </p>369 <p class="peach-codex-recovery-warn">370 together they restore your vault without your master371 passphrase. keep them apart.372 </p>373 </div>374375 <ol class="peach-codex-words-grid" start={1}>376 <For each={snapshot()!.recoveryWords ?? []}>377 {(word, i) => (378 <li class="peach-codex-word-row">379 <span class="peach-codex-word-numeral">{ROMAN[i()]}</span>380 <span class="peach-codex-word-text">{word}</span>381 </li>382 )}383 </For>384 </ol>385386 <Show when={estimatedPages() !== null}>387 <p class="peach-codex-meta">388 estimated <strong>{estimatedPages()}</strong>{" "}389 {estimatedPages() === 1 ? "page" : "pages"} ·{" "}390 {snapshot()!.entryCount}{" "}391 {snapshot()!.entryCount === 1 ? "entry" : "entries"}392 <Show when={estimatedParity() !== null}>393 {" "}· {estimatedParity()} redundancy codes394 </Show>395 </p>396 <Show when={estimatedParity() !== null}>397 <p class="peach-codex-meta peach-codex-meta-hint">398 can recover from loss of up to {estimatedParity()} codes399 anywhere in the document.400 </p>401 </Show>402 <Show when={(estimatedPages() ?? 0) >= 2}>403 <p class="peach-codex-meta peach-codex-meta-hint">404 this is your complete vault. print double-sided to reduce to{" "}405 {Math.ceil((estimatedPages() ?? 0) / 2)} sheets. store406 separately from your recovery words.407 </p>408 </Show>409 </Show>410411 <div class="peach-codex-confirm-section">412 <p class="peach-codex-confirm-label">413 to continue, enter words {confirmPositions().join(", ")}:414 </p>415 <For each={confirmPositions()}>416 {(pos, i) => (417 <div class="peach-codex-confirm-row">418 <span class="peach-codex-confirm-pos">word {pos}</span>419 <input420 class="peach-codex-confirm-input"421 type="text"422 autocomplete="off"423 autocorrect="off"424 autocapitalize="none"425 spellcheck={false}426 value={confirmInputs()[i()]}427 onInput={(e) => {428 const next = [...confirmInputs()] as [429 string,430 string,431 string,432 ];433 next[i()] = (e.target as HTMLInputElement).value;434 setConfirmInputs(next);435 }}436 onKeyDown={(e) => {437 if (e.key === "Enter" && allCorrect())438 void handleGenerate();439 }}440 />441 </div>442 )}443 </For>444 </div>445446 <Show when={genError()}>447 <p class="peach-hint peach-hint-error peach-codex-error">448 {genError()}449 </p>450 </Show>451452 <button453 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"454 disabled={!allCorrect()}455 onClick={() => void handleGenerate()}456 >457 generate my Codex458 </button>459 </Show>460461 <Show when={step() === "generating"}>462 <div class="peach-codex-recovery-headline">463 <h2 class="peach-codex-recovery-title">464 generating your codex…465 </h2>466 </div>467468 <div class="peach-codex-progress-group">469 <p class="peach-codex-progress-label">470 <span>{progressLabel()}</span>471 <span class="peach-codex-verify-tag">472 {snapshot()?.verifyCode}473 </span>474 </p>475 <div class="peach-codex-progress-track">476 <div477 class="peach-codex-progress-fill"478 style={`width:${progressPercent()}%`}479 />480 </div>481 </div>482 </Show>483484 <Show when={step() === "done"}>485 <div class="peach-codex-recovery-headline">486 <h2 class="peach-codex-recovery-title">487 your codex is downloading.488 </h2>489 <p class="peach-codex-recovery-body">490 store it somewhere safe — a fireproof box, a safe, or a USB491 drive kept offline.492 </p>493 <p class="peach-codex-recovery-warn">494 your recovery words go somewhere else entirely. never store495 them with the Codex.496 </p>497 </div>498499 <Show when={completed()}>500 <p class="peach-codex-meta">501 {completed()!.pageCount}{" "}502 {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}503 {completed()!.totalChunks} codes · verify code{" "}504 <span class="peach-codex-verify">505 {completed()!.verifyCode}506 </span>507 </p>508 </Show>509510 <button511 class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"512 onClick={() => {513 setCompleted(null);514 setGenError(null);515 setConfirmInputs(["", "", ""]);516 setConfirmPositions(pickConfirmationPositions());517 setStep("words");518 }}519 >520 generate another521 </button>522 </Show>523 </Show>524 </div>525 </div>526 );527}