CodexGenerateView.tsx

19.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";454647export function CodexGenerateView(props: CodexGenerateViewProps) {48  const [snapshot, setSnapshot] = createSignal<CodexVaultSnapshot | null>(null);49  const [estimatedPages, setEstimatedPages] = createSignal<number | null>(null);50  const [estimatedParity, setEstimatedParity] = createSignal<number | null>(null);51  const [loading, setLoading] = createSignal(true);52  const [loadError, setLoadError] = createSignal<string | null>(null);53  const [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false);5455  const [step, setStep] = createSignal<Step>("words");56  const [confirmPositions, setConfirmPositions] = createSignal<57    [number, number, number]58  >([3, 7, 11]);59  const [confirmInputs, setConfirmInputs] = createSignal<60    [string, string, string]61  >(["", "", ""]);6263  const [progressStep, setProgressStep] = createSignal<string>("");64  const [progressCurrent, setProgressCurrent] = createSignal<number>(0);65  const [progressTotal, setProgressTotal] = createSignal<number>(0);6667  const [genError, setGenError] = createSignal<string | null>(null);68  const [completed, setCompleted] = createSignal<CodexManifest | null>(null);6970  onMount(async () => {71    if (!props.isPro) {72      props.onUpgradePro();73      return;74    }7576    const res = (await sendMessage({77      type: "GET_CODEX_VAULT_DATA",78    })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };7980    if (!res.ok || !res.data) {81      setLoadError(res.error ?? "could not load vault data");82      setLoading(false);83      return;84    }8586    setSnapshot(res.data);8788    const words = res.data.recoveryWords;89    if (!words || words.length !== 12) {90      setMissingRecoveryWords(true);91      setLoading(false);92      return;93    }9495    setConfirmPositions(pickConfirmationPositions());9697    // Pre-warm the Reed-Solomon WASM so load failures surface before the98    // user completes the word-confirmation flow.99    try {100      await getReedSolomon();101    } catch (err) {102      setLoadError(103        `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`104      );105      setLoading(false);106      return;107    }108109    if (res.data.slotA) {110      try {111        const { pages, parityShards } = await estimateCodexPages(112          res.data.slotA,113          res.data.slotB,114        );115        setEstimatedPages(pages);116        setEstimatedParity(parityShards);117      } catch {118        // Estimation is informational; failure is non-fatal.119        setEstimatedPages(null);120        setEstimatedParity(null);121      }122    }123124    setLoading(false);125  });126127  const allCorrect = createMemo(() => {128    const snap = snapshot();129    const words = snap?.recoveryWords;130    if (!words || words.length !== 12) return false;131    const positions = confirmPositions();132    const inputs = confirmInputs();133    return positions.every(134      (pos, i) =>135        inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),136    );137  });138139  function buildDeps(snap: CodexVaultSnapshot): CodexDeps {140    return {141      getSlotA: async () => snap.slotA,142      getSlotB: async () => snap.slotB,143      getLastModified: async () => snap.lastModified,144      // `getAllEntries` is consumed only as a count filter inside the145      // generator — synthesise placeholders so real entries don't cross146      // the popup boundary.147      getAllEntries: async () =>148        Array.from(149          { length: snap.entryCount },150          () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,151        ),152      getVerifyCode: async () => snap.verifyCode,153      isVaultUnlocked: () => snap.vaultUnlocked,154    };155  }156157  function codexFilename(): string {158    const d = new Date();159    const y = d.getFullYear();160    const m = String(d.getMonth() + 1).padStart(2, "0");161    const day = String(d.getDate()).padStart(2, "0");162    return `peach-codex-${y}-${m}-${day}.pdf`;163  }164165  function downloadPdf(bytes: Uint8Array): void {166    const a = document.createElement("a");167    a.download = codexFilename();168    if (isNativePlatform()) {169      // Capacitor WebView: blob: URLs are opaque to native, but data: URLs170      // are intercepted by MainActivity and written to public Downloads via171      // MediaStore. Encode in chunks to avoid blowing the call stack on172      // large PDFs.173      let bin = "";174      const chunk = 0x8000;175      for (let i = 0; i < bytes.length; i += chunk) {176        const slice = bytes.subarray(i, Math.min(i + chunk, bytes.length));177        bin += String.fromCharCode(...slice);178      }179      a.href = `data:application/pdf;base64,${btoa(bin)}`;180    } else {181      // Browser: copy into a fresh Uint8Array to ensure a tightly-owned182      // ArrayBuffer (Blob is happier with this than a subarray view).183      const owned = new Uint8Array(bytes);184      const blob = new Blob([owned.buffer], { type: "application/pdf" });185      a.href = URL.createObjectURL(blob);186      setTimeout(() => URL.revokeObjectURL(a.href), 4000);187    }188    document.body.appendChild(a);189    a.click();190    a.remove();191  }192193  function explainCodexError(code: string, message: string): string {194    switch (code) {195      case "VAULT_LOCKED":196        return "your vault is locked. unlock and try again.";197      case "VAULT_EMPTY":198        return "your vault is empty — nothing to print yet.";199      case "PRO_REQUIRED":200        return "Codex generation is a Pro feature.";201      case "PDF_GENERATION_FAILED":202      case "AZTEC_GENERATION_FAILED":203        return "could not render the Codex. please try again.";204      case "COMPRESSION_ROUNDTRIP_FAILED":205      case "ERASURE_CODING_VERIFICATION_FAILED":206      case "ERASURE_CODING_CONTENT_MISMATCH":207      case "AZTEC_ROUNDTRIP_FAILED":208      case "AZTEC_CONTENT_MISMATCH":209      case "RECONSTRUCTION_SIMULATION_FAILED":210      case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":211      case "RECONSTRUCTION_FINGERPRINT_MISMATCH":212      case "RECONSTRUCTION_COUNT_MISMATCH":213        return "a self-check failed while building the Codex. nothing was saved. please try again.";214      default:215        return message || "Codex generation failed.";216    }217  }218219  const onProgress: CodexProgress = (s, current, total) => {220    setProgressStep(s);221    setProgressCurrent(current ?? 0);222    setProgressTotal(total ?? 0);223  };224225  async function handleGenerate() {226    const snap = snapshot();227    if (!snap || !allCorrect() || step() === "generating") return;228229    setGenError(null);230    setStep("generating");231    setProgressStep("compress");232233    try {234      const result = await generateCodex(buildDeps(snap), onProgress);235      downloadPdf(result.pdfBytes);236      setCompleted(result.manifest);237      setStep("done");238239      try {240        await chrome.storage.local?.set({241          [STORAGE_KEY_LAST_GENERATED]: Date.now(),242        });243      } catch {244        // Bookkeeping; failure is non-fatal.245      }246    } catch (err) {247      const codexErr = err as CodexError;248      const code = (codexErr && (codexErr as { code?: string }).code) ?? "";249      const message =250        err instanceof Error ? err.message : "Codex generation failed.";251      setGenError(explainCodexError(code, message));252      setStep("words");253    }254  }255256  function progressLabel(): string {257    switch (progressStep()) {258      case "compress":259        return "compressing vault";260      case "erasure":261        return "building chunks";262      case "aztec": {263        const total = progressTotal();264        const current = progressCurrent();265        if (total > 0) return `generating codes (${current} of ${total})`;266        return "generating codes";267      }268      case "verify":269        return "self-checking";270      case "pdf":271        return "creating PDF";272      default:273        return "preparing";274    }275  }276277  function progressPercent(): number {278    const total = progressTotal();279    const current = progressCurrent();280    // Map step to a coarse progress band so the bar advances monotonically281    // even when intra-step progress (aztec N of M) is fine-grained.282    switch (progressStep()) {283      case "compress":284        return 8;285      case "erasure":286        return 18;287      case "aztec":288        if (total <= 0) return 30;289        return 30 + Math.floor((current / total) * 55); // 30 → 85290      case "verify":291        return 90;292      case "pdf":293        return 96;294      default:295        return 4;296    }297  }298299  return (300    <div class="peach-overlay-view ov-enter-right" style="display:flex">301      <div class="peach-overlay-header">302        <button303          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"304          onClick={props.onBack}305          aria-label="Back"306          disabled={step() === "generating"}307        >308          <svg width="7" height="12" viewBox="0 0 7 12" fill="none"309            stroke="currentColor" stroke-width="1.1"310            stroke-linecap="round" stroke-linejoin="round">311            <path d="M6 1L1 6L6 11" />312          </svg>313        </button>314        <span class="peach-overlay-title">peach codex</span>315        <div class="peach-overlay-spacer"></div>316      </div>317318      <div class="peach-settings-body peach-codex-body peach-codex-body--runes">319        <RecoveryRuneBackground />320321        <Show when={!props.isPro}>322          <p class="peach-hint">redirecting…</p>323        </Show>324325        <Show when={props.isPro && loading()}>326          <p class="peach-hint">loading vault…</p>327        </Show>328329        <Show when={props.isPro && !loading() && loadError()}>330          <p class="peach-hint peach-hint-error">{loadError()}</p>331        </Show>332333        <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>334          <div class="peach-settings-group peach-codex-intro">335            <p class="peach-codex-blurb">336              the Codex prints your encrypted vault as Aztec codes. before337              you can generate one, set up your 12 recovery words in338              Settings → Security.339            </p>340          </div>341          <button342            class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"343            onClick={props.onOpenSecurity}344          >345            open Security settings346          </button>347        </Show>348349        <Show350          when={351            props.isPro &&352            !loading() &&353            !loadError() &&354            !missingRecoveryWords() &&355            snapshot()356          }357        >358          <Show when={step() === "words"}>359            <div class="peach-codex-recovery-headline">360              <h2 class="peach-codex-recovery-title">your recovery words.</h2>361              <p class="peach-codex-recovery-body">362                write these down — store separately from your Codex.363              </p>364            </div>365366            <ol class="peach-codex-words-grid" start={1}>367              <For each={snapshot()!.recoveryWords ?? []}>368                {(word, i) => (369                  <li class="peach-codex-word-row">370                    <span class="peach-codex-word-numeral">{i() + 1}</span>371                    <span class="peach-codex-word-text">{word}</span>372                  </li>373                )}374              </For>375            </ol>376377            <Show when={estimatedPages() !== null}>378              <p class="peach-codex-meta">379                <strong>{estimatedPages()}</strong>{" "}380                {estimatedPages() === 1 ? "page" : "pages"} ·{" "}381                {snapshot()!.entryCount}{" "}382                {snapshot()!.entryCount === 1 ? "entry" : "entries"}383                <Show when={estimatedParity() !== null}>384                  {" "}· {estimatedParity()} redundancy codes385                </Show>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            </div>468469            <Show when={completed()}>470              <p class="peach-codex-meta">471                {completed()!.pageCount}{" "}472                {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}473                {completed()!.totalChunks} codes · verify code{" "}474                <span class="peach-codex-verify">475                  {completed()!.verifyCode}476                </span>477              </p>478            </Show>479480            <div class="peach-codex-done-guidance">481              <div class="peach-codex-done-item">482                <span class="peach-codex-done-item-icon">🖨</span>483                <div>484                  <p class="peach-codex-done-item-title">485                    print on a laser printer486                  </p>487                  <p class="peach-codex-done-item-body">488                    laser prints last longer and scan more reliably than489                    inkjet. print at 100% scale — do not fit to page.490                  </p>491                </div>492              </div>493494              <div class="peach-codex-done-item">495                <span class="peach-codex-done-item-icon">📦</span>496                <div>497                  <p class="peach-codex-done-item-title">make two copies</p>498                  <p class="peach-codex-done-item-body">499                    store one at home (fireproof box or safe) and one500                    somewhere else (trusted person, safety deposit box).501                    fire and flood can destroy a single copy.502                  </p>503                </div>504              </div>505506              <div class="peach-codex-done-item">507                <span class="peach-codex-done-item-icon">🔑</span>508                <div>509                  <p class="peach-codex-done-item-title">510                    store separately from recovery words511                  </p>512                  <p class="peach-codex-done-item-body">513                    your 12 recovery words go somewhere different. together514                    they restore your vault without your passphrase. keep515                    them apart.516                  </p>517                </div>518              </div>519520              <div class="peach-codex-done-item">521                <span class="peach-codex-done-item-icon">💾</span>522                <div>523                  <p class="peach-codex-done-item-title">also save the PDF</p>524                  <p class="peach-codex-done-item-body">525                    copy the PDF to a USB drive. Peach can import it526                    directly without needing to scan printed codes.527                  </p>528                </div>529              </div>530            </div>531532            <p class="peach-codex-done-regenerate">533              regenerate your codex whenever you add many new entries.534            </p>535536            <button537              class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"538              onClick={() => {539                setCompleted(null);540                setGenError(null);541                setConfirmInputs(["", "", ""]);542                setConfirmPositions(pickConfirmationPositions());543                setStep("words");544              }}545            >546              generate another547            </button>548          </Show>549        </Show>550      </div>551    </div>552  );553}