CodexGenerateView.tsx

17.1 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>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}