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 type { VaultEntry } from "../../shared/types";3536interface CodexGenerateViewProps {37 isPro: boolean;38 onBack: () => void;39 onUpgradePro: () => void;40 onOpenSecurity: () => void;41}4243type Step = "words" | "generating" | "done";4445const ROMAN = [46 "I.", "II.", "III.", "IV.", "V.", "VI.",47 "VII.", "VIII.", "IX.", "X.", "XI.", "XII.",48];4950export function CodexGenerateView(props: CodexGenerateViewProps) {51 const [snapshot, setSnapshot] = createSignal<CodexVaultSnapshot | null>(null);52 const [estimatedPages, setEstimatedPages] = createSignal<number | null>(null);53 const [loading, setLoading] = createSignal(true);54 const [loadError, setLoadError] = createSignal<string | null>(null);55 const [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false);5657 const [step, setStep] = createSignal<Step>("words");58 const [confirmPositions, setConfirmPositions] = createSignal<59 [number, number, number]60 >([3, 7, 11]);61 const [confirmInputs, setConfirmInputs] = createSignal<62 [string, string, string]63 >(["", "", ""]);6465 const [progressStep, setProgressStep] = createSignal<string>("");66 const [progressCurrent, setProgressCurrent] = createSignal<number>(0);67 const [progressTotal, setProgressTotal] = createSignal<number>(0);6869 const [genError, setGenError] = createSignal<string | null>(null);70 const [completed, setCompleted] = createSignal<CodexManifest | null>(null);7172 onMount(async () => {73 if (!props.isPro) {74 props.onUpgradePro();75 return;76 }7778 const res = (await sendMessage({79 type: "GET_CODEX_VAULT_DATA" as any,80 })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };8182 if (!res.ok || !res.data) {83 setLoadError(res.error ?? "could not load vault data");84 setLoading(false);85 return;86 }8788 setSnapshot(res.data);8990 const words = res.data.recoveryWords;91 if (!words || words.length !== 12) {92 setMissingRecoveryWords(true);93 setLoading(false);94 return;95 }9697 setConfirmPositions(pickConfirmationPositions());9899 if (res.data.slotA) {100 try {101 const pages = await estimateCodexPages(res.data.slotA, res.data.slotB);102 setEstimatedPages(pages);103 } catch {104 105 setEstimatedPages(null);106 }107 }108109 setLoading(false);110 });111112 const allCorrect = createMemo(() => {113 const snap = snapshot();114 const words = snap?.recoveryWords;115 if (!words || words.length !== 12) return false;116 const positions = confirmPositions();117 const inputs = confirmInputs();118 return positions.every(119 (pos, i) =>120 inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),121 );122 });123124 function buildDeps(snap: CodexVaultSnapshot): CodexDeps {125 return {126 getSlotA: async () => snap.slotA,127 getSlotB: async () => snap.slotB,128 getLastModified: async () => snap.lastModified,129 130 131 132 getAllEntries: async () =>133 Array.from(134 { length: snap.entryCount },135 () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,136 ),137 getVerifyCode: async () => snap.verifyCode,138 isVaultUnlocked: () => snap.vaultUnlocked,139 };140 }141142 function codexFilename(): string {143 const d = new Date();144 const y = d.getFullYear();145 const m = String(d.getMonth() + 1).padStart(2, "0");146 const day = String(d.getDate()).padStart(2, "0");147 return `peach-codex-${y}-${m}-${day}.pdf`;148 }149150 function downloadPdf(bytes: Uint8Array): void {151 const a = document.createElement("a");152 a.download = codexFilename();153 if (isNativePlatform()) {154 155 156 157 158 let bin = "";159 const chunk = 0x8000;160 for (let i = 0; i < bytes.length; i += chunk) {161 const slice = bytes.subarray(i, Math.min(i + chunk, bytes.length));162 bin += String.fromCharCode(...slice);163 }164 a.href = `data:application/pdf;base64,${btoa(bin)}`;165 } else {166 167 168 const owned = new Uint8Array(bytes);169 const blob = new Blob([owned.buffer], { type: "application/pdf" });170 a.href = URL.createObjectURL(blob);171 setTimeout(() => URL.revokeObjectURL(a.href), 4000);172 }173 document.body.appendChild(a);174 a.click();175 a.remove();176 }177178 function explainCodexError(code: string, message: string): string {179 switch (code) {180 case "VAULT_LOCKED":181 return "your vault is locked. unlock and try again.";182 case "VAULT_EMPTY":183 return "your vault is empty — nothing to print yet.";184 case "PRO_REQUIRED":185 return "Codex generation is a Pro feature.";186 case "PDF_GENERATION_FAILED":187 case "AZTEC_GENERATION_FAILED":188 return "could not render the Codex. please try again.";189 case "COMPRESSION_ROUNDTRIP_FAILED":190 case "ERASURE_CODING_VERIFICATION_FAILED":191 case "ERASURE_CODING_CONTENT_MISMATCH":192 case "AZTEC_ROUNDTRIP_FAILED":193 case "AZTEC_CONTENT_MISMATCH":194 case "RECONSTRUCTION_SIMULATION_FAILED":195 case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":196 case "RECONSTRUCTION_FINGERPRINT_MISMATCH":197 case "RECONSTRUCTION_COUNT_MISMATCH":198 return "a self-check failed while building the Codex. nothing was saved. please try again.";199 default:200 return message || "Codex generation failed.";201 }202 }203204 const onProgress: CodexProgress = (s, current, total) => {205 setProgressStep(s);206 setProgressCurrent(current ?? 0);207 setProgressTotal(total ?? 0);208 };209210 async function handleGenerate() {211 const snap = snapshot();212 if (!snap || !allCorrect() || step() === "generating") return;213214 setGenError(null);215 setStep("generating");216 setProgressStep("compress");217218 try {219 const result = await generateCodex(buildDeps(snap), onProgress);220 downloadPdf(result.pdfBytes);221 setCompleted(result.manifest);222 setStep("done");223224 try {225 await chrome.storage.local?.set({226 [STORAGE_KEY_LAST_GENERATED]: Date.now(),227 });228 } catch {229 230 }231 } catch (err) {232 const codexErr = err as CodexError;233 const code = (codexErr && (codexErr as { code?: string }).code) ?? "";234 const message =235 err instanceof Error ? err.message : "Codex generation failed.";236 setGenError(explainCodexError(code, message));237 setStep("words");238 }239 }240241 function progressLabel(): string {242 switch (progressStep()) {243 case "compress":244 return "compressing vault";245 case "erasure":246 return "building chunks";247 case "aztec": {248 const total = progressTotal();249 const current = progressCurrent();250 if (total > 0) return `generating codes (${current} of ${total})`;251 return "generating codes";252 }253 case "verify":254 return "self-checking";255 case "pdf":256 return "creating PDF";257 default:258 return "preparing";259 }260 }261262 function progressPercent(): number {263 const total = progressTotal();264 const current = progressCurrent();265 266 267 switch (progressStep()) {268 case "compress":269 return 8;270 case "erasure":271 return 18;272 case "aztec":273 if (total <= 0) return 30;274 return 30 + Math.floor((current / total) * 55); 275 case "verify":276 return 90;277 case "pdf":278 return 96;279 default:280 return 4;281 }282 }283284 return (285 <div class="peach-overlay-view ov-enter-right" style="display:flex">286 <div class="peach-overlay-header">287 <button288 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"289 onClick={props.onBack}290 aria-label="Back"291 disabled={step() === "generating"}292 >293 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"294 stroke="currentColor" stroke-width="1.1"295 stroke-linecap="round" stroke-linejoin="round">296 <path d="M6 1L1 6L6 11" />297 </svg>298 </button>299 <span class="peach-overlay-title">peach codex</span>300 <div class="peach-overlay-spacer"></div>301 </div>302303 <div class="peach-settings-body peach-codex-body peach-codex-body--runes">304 <RecoveryRuneBackground />305306 <Show when={!props.isPro}>307 <p class="peach-hint">redirecting…</p>308 </Show>309310 <Show when={props.isPro && loading()}>311 <p class="peach-hint">loading vault…</p>312 </Show>313314 <Show when={props.isPro && !loading() && loadError()}>315 <p class="peach-hint peach-hint-error">{loadError()}</p>316 </Show>317318 <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>319 <div class="peach-settings-group peach-codex-intro">320 <p class="peach-codex-blurb">321 the Codex prints your encrypted vault as Aztec codes. before322 you can generate one, set up your 12 recovery words in323 Settings → Security.324 </p>325 </div>326 <button327 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"328 onClick={props.onOpenSecurity}329 >330 open Security settings331 </button>332 </Show>333334 <Show335 when={336 props.isPro &&337 !loading() &&338 !loadError() &&339 !missingRecoveryWords() &&340 snapshot()341 }342 >343 <Show when={step() === "words"}>344 <div class="peach-codex-recovery-headline">345 <h2 class="peach-codex-recovery-title">your recovery words.</h2>346 <p class="peach-codex-recovery-body">347 write these down and store them somewhere safe — separately348 from your Codex.349 </p>350 <p class="peach-codex-recovery-warn">351 together they restore your vault without your master352 passphrase. keep them apart.353 </p>354 </div>355356 <ol class="peach-codex-words-grid" start={1}>357 <For each={snapshot()!.recoveryWords ?? []}>358 {(word, i) => (359 <li class="peach-codex-word-row">360 <span class="peach-codex-word-numeral">{ROMAN[i()]}</span>361 <span class="peach-codex-word-text">{word}</span>362 </li>363 )}364 </For>365 </ol>366367 <Show when={estimatedPages() !== null}>368 <p class="peach-codex-meta">369 estimated <strong>{estimatedPages()}</strong>{" "}370 {estimatedPages() === 1 ? "page" : "pages"} ·{" "}371 {snapshot()!.entryCount}{" "}372 {snapshot()!.entryCount === 1 ? "entry" : "entries"}373 </p>374 </Show>375376 <div class="peach-codex-confirm-section">377 <p class="peach-codex-confirm-label">378 to continue, enter words {confirmPositions().join(", ")}:379 </p>380 <For each={confirmPositions()}>381 {(pos, i) => (382 <div class="peach-codex-confirm-row">383 <span class="peach-codex-confirm-pos">word {pos}</span>384 <input385 class="peach-codex-confirm-input"386 type="text"387 autocomplete="off"388 autocorrect="off"389 autocapitalize="none"390 spellcheck={false}391 value={confirmInputs()[i()]}392 onInput={(e) => {393 const next = [...confirmInputs()] as [394 string,395 string,396 string,397 ];398 next[i()] = (e.target as HTMLInputElement).value;399 setConfirmInputs(next);400 }}401 onKeyDown={(e) => {402 if (e.key === "Enter" && allCorrect())403 void handleGenerate();404 }}405 />406 </div>407 )}408 </For>409 </div>410411 <Show when={genError()}>412 <p class="peach-hint peach-hint-error peach-codex-error">413 {genError()}414 </p>415 </Show>416417 <button418 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"419 disabled={!allCorrect()}420 onClick={() => void handleGenerate()}421 >422 generate my Codex423 </button>424 </Show>425426 <Show when={step() === "generating"}>427 <div class="peach-codex-recovery-headline">428 <h2 class="peach-codex-recovery-title">429 generating your codex…430 </h2>431 </div>432433 <div class="peach-codex-progress-group">434 <p class="peach-codex-progress-label">435 <span>{progressLabel()}</span>436 <span class="peach-codex-verify-tag">437 {snapshot()?.verifyCode}438 </span>439 </p>440 <div class="peach-codex-progress-track">441 <div442 class="peach-codex-progress-fill"443 style={`width:${progressPercent()}%`}444 />445 </div>446 </div>447 </Show>448449 <Show when={step() === "done"}>450 <div class="peach-codex-recovery-headline">451 <h2 class="peach-codex-recovery-title">452 your codex is downloading.453 </h2>454 <p class="peach-codex-recovery-body">455 store it somewhere safe — a fireproof box, a safe, or a USB456 drive kept offline.457 </p>458 <p class="peach-codex-recovery-warn">459 your recovery words go somewhere else entirely. never store460 them with the Codex.461 </p>462 </div>463464 <Show when={completed()}>465 <p class="peach-codex-meta">466 {completed()!.pageCount}{" "}467 {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}468 {completed()!.totalChunks} codes · verify code{" "}469 <span class="peach-codex-verify">470 {completed()!.verifyCode}471 </span>472 </p>473 </Show>474475 <button476 class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"477 onClick={() => {478 setCompleted(null);479 setGenError(null);480 setConfirmInputs(["", "", ""]);481 setConfirmPositions(pickConfirmationPositions());482 setStep("words");483 }}484 >485 generate another486 </button>487 </Show>488 </Show>489 </div>490 </div>491 );492}