CodexGenerateView.tsx

17.5 KB · raw

1// Peach Codex — generation view.2//3// Two-step flow (see peach_codex_addendum.md):4//   1. words  — show the 12 recovery words and require the user to type 35//               randomly-chosen ones correctly before generation can proceed.6//               The Codex PDF never contains recovery words; this is the7//               due-diligence gate that pushes the user to commit them to8//               paper separately.9//   2. gen    — run the popup-side `generateCodex` pipeline, surface progress,10//               trigger the PDF download.11//   3. done   — reminder to store the Codex and recovery words separately.12//13// Pro-gated: a non-Pro user is bounced to the license section on mount.14// Restoration (CodexRestoreView) stays free — a printed Codex must restore15// regardless of license state.1617import { 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    // Pre-warm the Reed-Solomon WASM so load failures surface before the101    // user completes the word-confirmation flow.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        // Estimation is informational; failure is non-fatal.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      // `getAllEntries` is consumed only as a count filter inside the143      // generator — synthesise placeholders so real entries don't cross144      // the popup boundary.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      // Capacitor WebView: blob: URLs are opaque to native, but data: URLs168      // are intercepted by MainActivity and written to public Downloads via169      // MediaStore. Encode in chunks to avoid blowing the call stack on170      // large PDFs.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      // Browser: copy into a fresh Uint8Array to ensure a tightly-owned180      // ArrayBuffer (Blob is happier with this than a subarray view).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        // Bookkeeping; failure is non-fatal.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    // Map step to a coarse progress band so the bar advances monotonically279    // even when intra-step progress (aztec N of M) is fine-grained.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); // 30 → 85288      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 when={(estimatedPages() ?? 0) >= 2}>388                <p class="peach-codex-meta peach-codex-meta-hint">389                  this is your complete vault. print double-sided to reduce to{" "}390                  {Math.ceil((estimatedPages() ?? 0) / 2)} sheets. store391                  separately from your recovery words.392                </p>393              </Show>394            </Show>395396            <div class="peach-codex-confirm-section">397              <p class="peach-codex-confirm-label">398                to continue, enter words {confirmPositions().join(", ")}:399              </p>400              <For each={confirmPositions()}>401                {(pos, i) => (402                  <div class="peach-codex-confirm-row">403                    <span class="peach-codex-confirm-pos">word {pos}</span>404                    <input405                      class="peach-codex-confirm-input"406                      type="text"407                      autocomplete="off"408                      autocorrect="off"409                      autocapitalize="none"410                      spellcheck={false}411                      value={confirmInputs()[i()]}412                      onInput={(e) => {413                        const next = [...confirmInputs()] as [414                          string,415                          string,416                          string,417                        ];418                        next[i()] = (e.target as HTMLInputElement).value;419                        setConfirmInputs(next);420                      }}421                      onKeyDown={(e) => {422                        if (e.key === "Enter" && allCorrect())423                          void handleGenerate();424                      }}425                    />426                  </div>427                )}428              </For>429            </div>430431            <Show when={genError()}>432              <p class="peach-hint peach-hint-error peach-codex-error">433                {genError()}434              </p>435            </Show>436437            <button438              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"439              disabled={!allCorrect()}440              onClick={() => void handleGenerate()}441            >442              generate my Codex443            </button>444          </Show>445446          <Show when={step() === "generating"}>447            <div class="peach-codex-recovery-headline">448              <h2 class="peach-codex-recovery-title">449                generating your codex…450              </h2>451            </div>452453            <div class="peach-codex-progress-group">454              <p class="peach-codex-progress-label">455                <span>{progressLabel()}</span>456                <span class="peach-codex-verify-tag">457                  {snapshot()?.verifyCode}458                </span>459              </p>460              <div class="peach-codex-progress-track">461                <div462                  class="peach-codex-progress-fill"463                  style={`width:${progressPercent()}%`}464                />465              </div>466            </div>467          </Show>468469          <Show when={step() === "done"}>470            <div class="peach-codex-recovery-headline">471              <h2 class="peach-codex-recovery-title">472                your codex is downloading.473              </h2>474              <p class="peach-codex-recovery-body">475                store it somewhere safe — a fireproof box, a safe, or a USB476                drive kept offline.477              </p>478              <p class="peach-codex-recovery-warn">479                your recovery words go somewhere else entirely. never store480                them with the Codex.481              </p>482            </div>483484            <Show when={completed()}>485              <p class="peach-codex-meta">486                {completed()!.pageCount}{" "}487                {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}488                {completed()!.totalChunks} codes · verify code{" "}489                <span class="peach-codex-verify">490                  {completed()!.verifyCode}491                </span>492              </p>493            </Show>494495            <button496              class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"497              onClick={() => {498                setCompleted(null);499                setGenError(null);500                setConfirmInputs(["", "", ""]);501                setConfirmPositions(pickConfirmationPositions());502                setStep("words");503              }}504            >505              generate another506            </button>507          </Show>508        </Show>509      </div>510    </div>511  );512}