CodexGenerateView.tsx

21.2 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, onCleanup, Show } from "solid-js";18import { sendMessage } from "../../shared/bridge";19import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground";20import type {21  CodexDeps,22  CodexError,23  CodexManifest,24  CodexProgress,25  CodexVaultSnapshot,26} from "../../shared/codex-types";27import { STORAGE_KEY_LAST_GENERATED } from "../../shared/codex-types";28import { pickConfirmationPositions } from "../codex/confirmation";29import type { VaultEntry } from "../../shared/types";3031interface CodexGenerateViewProps {32  isPro: boolean;33  onBack: () => void;34  onUpgradePro: () => void;35  onOpenSecurity: () => void;36  onVaultLocked: () => void;37}3839type Step = "words" | "generating" | "done";40type CodexGenerateModule = typeof import("../codex/generate");4142let codexGenerateModulePromise: Promise<CodexGenerateModule> | null = null;4344function loadCodexGenerateModule(): Promise<CodexGenerateModule> {45  codexGenerateModulePromise ??= import("../codex/generate");46  return codexGenerateModulePromise;47}484950export function CodexGenerateView(props: CodexGenerateViewProps) {51  const [snapshot, setSnapshot] = createSignal<CodexVaultSnapshot | null>(null);52  const [estimatedPages, setEstimatedPages] = createSignal<number | null>(null);53  const [estimatedParity, setEstimatedParity] = createSignal<number | null>(null);54  const [loading, setLoading] = createSignal(true);55  const [loadError, setLoadError] = createSignal<string | null>(null);56  const [libraryLoading, setLibraryLoading] = createSignal(false);57  const [libraryReady, setLibraryReady] = createSignal(false);58  const [libraryError, setLibraryError] = createSignal<string | null>(null);59  const [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false);6061  const [step, setStep] = createSignal<Step>("words");62  const [confirmPositions, setConfirmPositions] = createSignal<63    [number, number, number]64  >([3, 7, 11]);65  const [confirmInputs, setConfirmInputs] = createSignal<66    [string, string, string]67  >(["", "", ""]);6869  const [progressStep, setProgressStep] = createSignal<string>("");70  const [progressCurrent, setProgressCurrent] = createSignal<number>(0);71  const [progressTotal, setProgressTotal] = createSignal<number>(0);7273  const [genError, setGenError] = createSignal<string | null>(null);74  const [completed, setCompleted] = createSignal<CodexManifest | null>(null);75  // Live gallery of real Aztec PNGs as they're generated — keep the last 3676  // (one page worth) so the display fills without using excessive memory.77  const [aztecGallery, setAztecGallery] = createSignal<string[]>([]);78  const GALLERY_MAX = 36;7980  onCleanup(() => {81    for (const url of aztecGallery()) URL.revokeObjectURL(url);82  });8384  onMount(async () => {85    if (!props.isPro) {86      props.onUpgradePro();87      return;88    }8990    const res = (await sendMessage({91      type: "GET_CODEX_VAULT_DATA",92    })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };9394    if (!res.ok || !res.data) {95      // Service worker restarted (MV3) and the vault re-locked in memory.96      // Route back to the lock screen exactly like the rest of the app does.97      if (res.error?.startsWith("VAULT_LOCKED")) {98        props.onVaultLocked();99        return;100      }101      setLoadError(res.error ?? "could not load vault data");102      setLoading(false);103      return;104    }105106    setSnapshot(res.data);107108    const words = res.data.recoveryWords;109    if (!words || words.length !== 12) {110      setMissingRecoveryWords(true);111      setLoading(false);112      return;113    }114115    setConfirmPositions(pickConfirmationPositions());116    setLoading(false);117    void warmCodexLibrary(res.data);118  });119120  const allCorrect = createMemo(() => {121    const snap = snapshot();122    const words = snap?.recoveryWords;123    if (!words || words.length !== 12) return false;124    const positions = confirmPositions();125    const inputs = confirmInputs();126    return positions.every(127      (pos, i) =>128        inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),129    );130  });131132  function buildDeps(snap: CodexVaultSnapshot): CodexDeps {133    return {134      getSlotA: async () => snap.slotA,135      getSlotB: async () => snap.slotB,136      getLastModified: async () => snap.lastModified,137      // `getAllEntries` is consumed only as a count filter inside the138      // generator — synthesise placeholders so real entries don't cross139      // the popup boundary.140      getAllEntries: async () =>141        Array.from(142          { length: snap.entryCount },143          () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,144        ),145      getVerifyCode: async () => snap.verifyCode,146      isVaultUnlocked: () => snap.vaultUnlocked,147    };148  }149150  async function warmCodexLibrary(151    snap?: CodexVaultSnapshot,152  ): Promise<CodexGenerateModule | null> {153    if (libraryReady()) return loadCodexGenerateModule();154155    setLibraryLoading(true);156    setLibraryError(null);157    try {158      const [generateModule, rsModule] = await Promise.all([159        loadCodexGenerateModule(),160        import("../codex/rs-loader"),161      ]);162      await rsModule.getReedSolomon();163      setLibraryReady(true);164165      if (snap?.slotA) {166        try {167          const { pages, parityShards } = await generateModule.estimateCodexPages(168            snap.slotA,169            snap.slotB,170          );171          setEstimatedPages(pages);172          setEstimatedParity(parityShards);173        } catch {174          // Estimation is informational; failure is non-fatal.175          setEstimatedPages(null);176          setEstimatedParity(null);177        }178      }179180      return generateModule;181    } catch (err) {182      codexGenerateModulePromise = null;183      setLibraryError(184        `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`,185      );186      return null;187    } finally {188      setLibraryLoading(false);189    }190  }191192  function codexFilename(): string {193    const d = new Date();194    const y = d.getFullYear();195    const m = String(d.getMonth() + 1).padStart(2, "0");196    const day = String(d.getDate()).padStart(2, "0");197    return `peach-codex-${y}-${m}-${day}.pdf`;198  }199200  function downloadPdf(bytes: Uint8Array): void {201    const a = document.createElement("a");202    a.download = codexFilename();203    // Copy into a fresh Uint8Array to ensure a tightly-owned ArrayBuffer204    // (Blob is happier with this than a subarray view).205    const owned = new Uint8Array(bytes);206    const blob = new Blob([owned.buffer], { type: "application/pdf" });207    a.href = URL.createObjectURL(blob);208    setTimeout(() => URL.revokeObjectURL(a.href), 4000);209    document.body.appendChild(a);210    a.click();211    a.remove();212  }213214  function explainCodexError(code: string, message: string): string {215    switch (code) {216      case "VAULT_LOCKED":217        return "your vault is locked. unlock and try again.";218      case "VAULT_EMPTY":219        return "your vault is empty — nothing to print yet.";220      case "PRO_REQUIRED":221        return "Codex generation is a Pro feature.";222      case "PDF_GENERATION_FAILED":223      case "AZTEC_GENERATION_FAILED":224        return "could not render the Codex. please try again.";225      case "COMPRESSION_ROUNDTRIP_FAILED":226      case "ERASURE_CODING_VERIFICATION_FAILED":227      case "ERASURE_CODING_CONTENT_MISMATCH":228      case "AZTEC_ROUNDTRIP_FAILED":229      case "AZTEC_CONTENT_MISMATCH":230      case "RECONSTRUCTION_SIMULATION_FAILED":231      case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":232      case "RECONSTRUCTION_FINGERPRINT_MISMATCH":233      case "RECONSTRUCTION_COUNT_MISMATCH":234        return `a self-check failed while building the Codex (${code}). nothing was saved. please try again.`;235      default:236        return message || "Codex generation failed.";237    }238  }239240  const onProgress: CodexProgress = (s, current, total, imageUrl) => {241    setProgressStep(s);242    setProgressCurrent(current ?? 0);243    setProgressTotal(total ?? 0);244    if (s === "aztec" && imageUrl) {245      setAztecGallery(prev => {246        if (prev.length >= GALLERY_MAX) {247          URL.revokeObjectURL(prev[0]!);248          return [...prev.slice(1), imageUrl];249        }250        return [...prev, imageUrl];251      });252    }253  };254255  async function handleGenerate() {256    const snap = snapshot();257    if (!snap || !allCorrect() || step() === "generating") return;258259    setGenError(null);260    setAztecGallery([]);261    setStep("generating");262    setProgressStep("compress");263264    try {265      const generateModule = await warmCodexLibrary(snap);266      if (!generateModule) {267        setStep("words");268        return;269      }270      const result = await generateModule.generateCodex(buildDeps(snap), onProgress);271      downloadPdf(result.pdfBytes);272      setCompleted(result.manifest);273      setStep("done");274275      try {276        await chrome.storage.local?.set({277          [STORAGE_KEY_LAST_GENERATED]: Date.now(),278        });279      } catch {280        // Bookkeeping; failure is non-fatal.281      }282    } catch (err) {283      const codexErr = err as CodexError;284      const code = (codexErr && (codexErr as { code?: string }).code) ?? "";285      const message =286        err instanceof Error ? err.message : "Codex generation failed.";287      console.error("[Codex] generation failed:", code, message);288      setGenError(explainCodexError(code, message));289      setStep("words");290    }291  }292293  function progressLabel(): string {294    switch (progressStep()) {295      case "compress":296        return "compressing vault";297      case "erasure":298        return "building chunks";299      case "aztec": {300        const total = progressTotal();301        const current = progressCurrent();302        if (total > 0) return `generating codes (${current} of ${total})`;303        return "generating codes";304      }305      case "verify":306        return "self-checking";307      case "pdf":308        return "creating PDF";309      default:310        return "preparing";311    }312  }313314  function progressPercent(): number {315    const total = progressTotal();316    const current = progressCurrent();317    // Map step to a coarse progress band so the bar advances monotonically318    // even when intra-step progress (aztec N of M) is fine-grained.319    switch (progressStep()) {320      case "compress":321        return 8;322      case "erasure":323        return 18;324      case "aztec":325        if (total <= 0) return 30;326        return 30 + Math.floor((current / total) * 55); // 30 → 85327      case "verify":328        return 90;329      case "pdf":330        return 96;331      default:332        return 4;333    }334  }335336  return (337    <div class="peach-overlay-view ov-enter-right" style="display:flex">338      <div class="peach-overlay-header">339        <button340          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"341          onClick={props.onBack}342          aria-label="Back"343          disabled={step() === "generating"}344        >345          <svg width="7" height="12" viewBox="0 0 7 12" fill="none"346            stroke="currentColor" stroke-width="1.1"347            stroke-linecap="round" stroke-linejoin="round">348            <path d="M6 1L1 6L6 11" />349          </svg>350        </button>351        <span class="peach-overlay-title">peach codex</span>352        <div class="peach-overlay-spacer"></div>353      </div>354355      <div356        class="peach-settings-body peach-codex-body peach-codex-body--runes"357        classList={{ "peach-codex-body--done": step() === "done" }}358      >359        <RecoveryRuneBackground />360361        <Show when={!props.isPro}>362          <p class="peach-hint">redirecting…</p>363        </Show>364365        <Show when={props.isPro && loading()}>366          <p class="peach-hint">loading vault…</p>367        </Show>368369        <Show when={props.isPro && !loading() && loadError()}>370          <p class="peach-hint peach-hint-error">{loadError()}</p>371        </Show>372373        <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>374          <div class="peach-settings-group peach-codex-intro">375            <p class="peach-codex-blurb">376              the Codex prints your encrypted vault as Aztec codes. before377              you can generate one, set up your 12 recovery words in378              Settings → Security.379            </p>380          </div>381          <button382            class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"383            onClick={props.onOpenSecurity}384          >385            open Security settings386          </button>387        </Show>388389        <Show390          when={391            props.isPro &&392            !loading() &&393            !loadError() &&394            !missingRecoveryWords() &&395            snapshot()396          }397        >398          <Show when={step() === "words"}>399            <div class="peach-codex-recovery-headline">400              <h2 class="peach-codex-recovery-title">your recovery words.</h2>401              <p class="peach-codex-recovery-body">402                write these down — store separately from your Codex.403              </p>404            </div>405406            <ol class="peach-codex-words-grid" start={1}>407              <For each={snapshot()!.recoveryWords ?? []}>408                {(word, i) => (409                  <li class="peach-codex-word-row">410                    <span class="peach-codex-word-numeral">{i() + 1}</span>411                    <span class="peach-codex-word-text">{word}</span>412                  </li>413                )}414              </For>415            </ol>416417            <Show when={estimatedPages() !== null}>418              <p class="peach-codex-meta">419                <strong>{estimatedPages()}</strong>{" "}420                {estimatedPages() === 1 ? "page" : "pages"} ·{" "}421                {snapshot()!.entryCount}{" "}422                {snapshot()!.entryCount === 1 ? "entry" : "entries"}423                <Show when={estimatedParity() !== null}>424                  {" "}· {estimatedParity()} redundancy codes425                </Show>426              </p>427            </Show>428429            <div class="peach-codex-confirm-section">430              <p class="peach-codex-confirm-label">431                to continue, enter words {confirmPositions().join(", ")}:432              </p>433              <For each={confirmPositions()}>434                {(pos, i) => (435                  <div class="peach-codex-confirm-row">436                    <span class="peach-codex-confirm-pos">word {pos}</span>437                    <input438                      class="peach-codex-confirm-input"439                      type="text"440                      autocomplete="off"441                      autocorrect="off"442                      autocapitalize="none"443                      spellcheck={false}444                      value={confirmInputs()[i()]}445                      onInput={(e) => {446                        const next = [...confirmInputs()] as [447                          string,448                          string,449                          string,450                        ];451                        next[i()] = (e.target as HTMLInputElement).value;452                        setConfirmInputs(next);453                      }}454                      onKeyDown={(e) => {455                        if (e.key === "Enter" && allCorrect())456                          void handleGenerate();457                      }}458                    />459                  </div>460                )}461              </For>462            </div>463464            <Show when={genError()}>465              <p class="peach-hint peach-hint-error peach-codex-error">466                {genError()}467              </p>468            </Show>469            <Show when={libraryError()}>470              <p class="peach-hint peach-hint-error peach-codex-error">471                {libraryError()}472              </p>473            </Show>474            <Show when={libraryLoading() && !libraryReady()}>475              <p class="peach-hint peach-codex-meta">476                preparing Codex library…477              </p>478            </Show>479480            <button481              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"482              disabled={!allCorrect() || libraryLoading()}483              onClick={() => void handleGenerate()}484            >485              {libraryLoading() && !libraryReady() ? "preparing…" : "generate my Codex"}486            </button>487          </Show>488489          <Show when={step() === "generating"}>490            <div class="peach-codex-recovery-headline">491              <h2 class="peach-codex-recovery-title">492                generating your codex…493              </h2>494            </div>495496            <div class="peach-codex-progress-group">497              <p class="peach-codex-progress-label">498                <span>{progressLabel()}</span>499                <span class="peach-codex-verify-tag">500                  {snapshot()?.verifyCode}501                </span>502              </p>503              <div class="peach-codex-progress-track">504                <div505                  class="peach-codex-progress-fill"506                  style={`width:${progressPercent()}%`}507                />508              </div>509            </div>510511            <Show when={aztecGallery().length > 0}>512              <div class="peach-codex-aztec-gallery">513                <For each={aztecGallery()}>514                  {(url) => (515                    <img516                      class="peach-codex-aztec-tile"517                      src={url}518                      alt=""519                      aria-hidden="true"520                    />521                  )}522                </For>523              </div>524            </Show>525          </Show>526527          <Show when={step() === "done"}>528            <div class="peach-codex-recovery-headline">529              <h2 class="peach-codex-recovery-title">530                your codex is downloading.531              </h2>532            </div>533534            <Show when={completed()}>535              <p class="peach-codex-meta">536                {completed()!.pageCount}{" "}537                {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}538                {completed()!.totalChunks} codes · verify code{" "}539                <span class="peach-codex-verify">540                  {completed()!.verifyCode}541                </span>542              </p>543            </Show>544545            <div class="peach-codex-done-guidance">546              <div class="peach-codex-done-item">547                <div>548                  <p class="peach-codex-done-item-title">549                    print on a laser printer550                  </p>551                  <p class="peach-codex-done-item-body">552                    laser prints last longer and scan more reliably than553                    inkjet. print at 100% scale — do not fit to page.554                  </p>555                </div>556              </div>557558              <div class="peach-codex-done-item">559                <div>560                  <p class="peach-codex-done-item-title">make two copies</p>561                  <p class="peach-codex-done-item-body">562                    store one at home (fireproof box or safe) and one563                    somewhere else (trusted person, safety deposit box).564                    fire and flood can destroy a single copy.565                  </p>566                </div>567              </div>568569              <div class="peach-codex-done-item">570                <div>571                  <p class="peach-codex-done-item-title">572                    store separately from recovery words573                  </p>574                  <p class="peach-codex-done-item-body">575                    your 12 recovery words go somewhere different. together576                    they restore your vault without your passphrase. keep577                    them apart.578                  </p>579                </div>580              </div>581582              <div class="peach-codex-done-item">583                <div>584                  <p class="peach-codex-done-item-title">also save the PDF</p>585                  <p class="peach-codex-done-item-body">586                    copy the PDF to a USB drive. Peach can import it587                    directly without needing to scan printed codes.588                  </p>589                </div>590              </div>591            </div>592593            <p class="peach-codex-done-regenerate">594              regenerate your codex whenever you add many new entries.595            </p>596597            <button598              class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"599              onClick={() => {600                setCompleted(null);601                setGenError(null);602                setLibraryError(null);603                setConfirmInputs(["", "", ""]);604                setConfirmPositions(pickConfirmationPositions());605                setStep("words");606              }}607            >608              generate another609            </button>610          </Show>611        </Show>612      </div>613    </div>614  );615}