CodexGenerateView.tsx

23.6 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);60  // PB-26-001: the background only releases recoveryWords with a fresh61  // master-password proof, so the words step re-asks for it here.62  const [needsPassword, setNeedsPassword] = createSignal(false);63  const [password, setPassword] = createSignal("");64  const [passwordError, setPasswordError] = createSignal<string | null>(null);6566  const [step, setStep] = createSignal<Step>("words");67  const [confirmPositions, setConfirmPositions] = createSignal<68    [number, number, number]69  >([3, 7, 11]);70  const [confirmInputs, setConfirmInputs] = createSignal<71    [string, string, string]72  >(["", "", ""]);7374  const [progressStep, setProgressStep] = createSignal<string>("");75  const [progressCurrent, setProgressCurrent] = createSignal<number>(0);76  const [progressTotal, setProgressTotal] = createSignal<number>(0);7778  const [genError, setGenError] = createSignal<string | null>(null);79  const [completed, setCompleted] = createSignal<CodexManifest | null>(null);80  // Live gallery of real Aztec PNGs as they're generated — keep the last 3681  // (one page worth) so the display fills without using excessive memory.82  const [aztecGallery, setAztecGallery] = createSignal<string[]>([]);83  const GALLERY_MAX = 36;8485  onCleanup(() => {86    for (const url of aztecGallery()) URL.revokeObjectURL(url);87  });8889  onMount(async () => {90    if (!props.isPro) {91      props.onUpgradePro();92      return;93    }94    await loadSnapshot();95  });9697  async function loadSnapshot(password?: string): Promise<void> {98    const res = (await sendMessage({99      type: "GET_CODEX_VAULT_DATA",100      ...(password ? { payload: { password } } : {}),101    })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };102103    if (!res.ok || !res.data) {104      // Service worker restarted (MV3) and the vault re-locked in memory.105      // Route back to the lock screen exactly like the rest of the app does.106      if (res.error?.startsWith("VAULT_LOCKED")) {107        props.onVaultLocked();108        return;109      }110      // Wrong re-auth password is a form error, not a load failure.111      if (res.error === "wrong_password") {112        setPasswordError("wrong password. try again.");113        return;114      }115      setLoadError(res.error ?? "could not load vault data");116      setLoading(false);117      return;118    }119120    setSnapshot(res.data);121122    const words = res.data.recoveryWords;123    if (!words || words.length !== 12) {124      // No proof supplied yet → ask for it; proof supplied but still no125      // words → the vault genuinely has no recovery escrow.126      if (!password) {127        setNeedsPassword(true);128      } else {129        setMissingRecoveryWords(true);130      }131      setLoading(false);132      return;133    }134135    setNeedsPassword(false);136    setPassword("");137    setPasswordError(null);138    setConfirmPositions(pickConfirmationPositions());139    setLoading(false);140    void warmCodexLibrary(res.data);141  }142143  async function submitPassword(): Promise<void> {144    if (!password()) return;145    setPasswordError(null);146    await loadSnapshot(password());147  }148149  const allCorrect = createMemo(() => {150    const snap = snapshot();151    const words = snap?.recoveryWords;152    if (!words || words.length !== 12) return false;153    const positions = confirmPositions();154    const inputs = confirmInputs();155    return positions.every(156      (pos, i) =>157        inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),158    );159  });160161  function buildDeps(snap: CodexVaultSnapshot): CodexDeps {162    return {163      getSlotA: async () => snap.slotA,164      getSlotB: async () => snap.slotB,165      getLastModified: async () => snap.lastModified,166      // `getAllEntries` is consumed only as a count filter inside the167      // generator — synthesise placeholders so real entries don't cross168      // the popup boundary.169      getAllEntries: async () =>170        Array.from(171          { length: snap.entryCount },172          () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,173        ),174      getVerifyCode: async () => snap.verifyCode,175      isVaultUnlocked: () => snap.vaultUnlocked,176    };177  }178179  async function warmCodexLibrary(180    snap?: CodexVaultSnapshot,181  ): Promise<CodexGenerateModule | null> {182    if (libraryReady()) return loadCodexGenerateModule();183184    setLibraryLoading(true);185    setLibraryError(null);186    try {187      const [generateModule, rsModule] = await Promise.all([188        loadCodexGenerateModule(),189        import("../codex/rs-loader"),190      ]);191      await rsModule.getReedSolomon();192      setLibraryReady(true);193194      if (snap?.slotA) {195        try {196          const { pages, parityShards } = await generateModule.estimateCodexPages(197            snap.slotA,198            snap.slotB,199          );200          setEstimatedPages(pages);201          setEstimatedParity(parityShards);202        } catch {203          // Estimation is informational; failure is non-fatal.204          setEstimatedPages(null);205          setEstimatedParity(null);206        }207      }208209      return generateModule;210    } catch (err) {211      codexGenerateModulePromise = null;212      setLibraryError(213        `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`,214      );215      return null;216    } finally {217      setLibraryLoading(false);218    }219  }220221  function codexFilename(): string {222    const d = new Date();223    const y = d.getFullYear();224    const m = String(d.getMonth() + 1).padStart(2, "0");225    const day = String(d.getDate()).padStart(2, "0");226    return `peach-codex-${y}-${m}-${day}.pdf`;227  }228229  function downloadPdf(bytes: Uint8Array): void {230    const a = document.createElement("a");231    a.download = codexFilename();232    // Copy into a fresh Uint8Array to ensure a tightly-owned ArrayBuffer233    // (Blob is happier with this than a subarray view).234    const owned = new Uint8Array(bytes);235    const blob = new Blob([owned.buffer], { type: "application/pdf" });236    a.href = URL.createObjectURL(blob);237    setTimeout(() => URL.revokeObjectURL(a.href), 4000);238    document.body.appendChild(a);239    a.click();240    a.remove();241  }242243  function explainCodexError(code: string, message: string): string {244    switch (code) {245      case "VAULT_LOCKED":246        return "your vault is locked. unlock and try again.";247      case "VAULT_EMPTY":248        return "your vault is empty — nothing to print yet.";249      case "PRO_REQUIRED":250        return "Codex generation is a Pro feature.";251      case "PDF_GENERATION_FAILED":252      case "AZTEC_GENERATION_FAILED":253        return "could not render the Codex. please try again.";254      case "COMPRESSION_ROUNDTRIP_FAILED":255      case "ERASURE_CODING_VERIFICATION_FAILED":256      case "ERASURE_CODING_CONTENT_MISMATCH":257      case "AZTEC_ROUNDTRIP_FAILED":258      case "AZTEC_CONTENT_MISMATCH":259      case "RECONSTRUCTION_SIMULATION_FAILED":260      case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":261      case "RECONSTRUCTION_FINGERPRINT_MISMATCH":262      case "RECONSTRUCTION_COUNT_MISMATCH":263        return `a self-check failed while building the Codex (${code}). nothing was saved. please try again.`;264      default:265        return message || "Codex generation failed.";266    }267  }268269  const onProgress: CodexProgress = (s, current, total, imageUrl) => {270    setProgressStep(s);271    setProgressCurrent(current ?? 0);272    setProgressTotal(total ?? 0);273    if (s === "aztec" && imageUrl) {274      setAztecGallery(prev => {275        if (prev.length >= GALLERY_MAX) {276          URL.revokeObjectURL(prev[0]!);277          return [...prev.slice(1), imageUrl];278        }279        return [...prev, imageUrl];280      });281    }282  };283284  async function handleGenerate() {285    const snap = snapshot();286    if (!snap || !allCorrect() || step() === "generating") return;287288    setGenError(null);289    setAztecGallery([]);290    setStep("generating");291    setProgressStep("compress");292293    try {294      const generateModule = await warmCodexLibrary(snap);295      if (!generateModule) {296        setStep("words");297        return;298      }299      const result = await generateModule.generateCodex(buildDeps(snap), onProgress);300      downloadPdf(result.pdfBytes);301      setCompleted(result.manifest);302      setStep("done");303304      try {305        await chrome.storage.local?.set({306          [STORAGE_KEY_LAST_GENERATED]: Date.now(),307        });308      } catch {309        // Bookkeeping; failure is non-fatal.310      }311    } catch (err) {312      const codexErr = err as CodexError;313      const code = (codexErr && (codexErr as { code?: string }).code) ?? "";314      const message =315        err instanceof Error ? err.message : "Codex generation failed.";316      console.error("[Codex] generation failed:", code, message);317      setGenError(explainCodexError(code, message));318      setStep("words");319    }320  }321322  function progressLabel(): string {323    switch (progressStep()) {324      case "compress":325        return "compressing vault";326      case "erasure":327        return "building chunks";328      case "aztec": {329        const total = progressTotal();330        const current = progressCurrent();331        if (total > 0) return `generating codes (${current} of ${total})`;332        return "generating codes";333      }334      case "verify":335        return "self-checking";336      case "pdf":337        return "creating PDF";338      default:339        return "preparing";340    }341  }342343  function progressPercent(): number {344    const total = progressTotal();345    const current = progressCurrent();346    // Map step to a coarse progress band so the bar advances monotonically347    // even when intra-step progress (aztec N of M) is fine-grained.348    switch (progressStep()) {349      case "compress":350        return 8;351      case "erasure":352        return 18;353      case "aztec":354        if (total <= 0) return 30;355        return 30 + Math.floor((current / total) * 55); // 30 → 85356      case "verify":357        return 90;358      case "pdf":359        return 96;360      default:361        return 4;362    }363  }364365  return (366    <div class="peach-overlay-view ov-enter-right" style="display:flex">367      <div class="peach-overlay-header">368        <button369          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"370          onClick={props.onBack}371          aria-label="Back"372          disabled={step() === "generating"}373        >374          <svg width="7" height="12" viewBox="0 0 7 12" fill="none"375            stroke="currentColor" stroke-width="1.1"376            stroke-linecap="round" stroke-linejoin="round">377            <path d="M6 1L1 6L6 11" />378          </svg>379        </button>380        <span class="peach-overlay-title">peach codex</span>381        <div class="peach-overlay-spacer"></div>382      </div>383384      <div385        class="peach-settings-body peach-codex-body peach-codex-body--runes"386        classList={{ "peach-codex-body--done": step() === "done" }}387      >388        <RecoveryRuneBackground />389390        <Show when={!props.isPro}>391          <p class="peach-hint">redirecting…</p>392        </Show>393394        <Show when={props.isPro && loading()}>395          <p class="peach-hint">loading vault…</p>396        </Show>397398        <Show when={props.isPro && !loading() && loadError()}>399          <p class="peach-hint peach-hint-error">{loadError()}</p>400        </Show>401402        <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>403          <div class="peach-settings-group peach-codex-intro">404            <p class="peach-codex-blurb">405              the Codex prints your encrypted vault as Aztec codes. before406              you can generate one, set up your 12 recovery words in407              Settings → Security.408            </p>409          </div>410          <button411            class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"412            onClick={props.onOpenSecurity}413          >414            open Security settings415          </button>416        </Show>417418        <Show when={props.isPro && !loading() && !loadError() && needsPassword()}>419          <div class="peach-codex-recovery-headline">420            <h2 class="peach-codex-recovery-title">confirm it's you.</h2>421            <p class="peach-codex-recovery-body">422              enter your master password to show your recovery words.423            </p>424          </div>425426          <div class="peach-codex-confirm-section">427            <input428              class="peach-codex-confirm-input"429              type="password"430              autocomplete="current-password"431              placeholder="master password"432              value={password()}433              onInput={(e) => setPassword((e.target as HTMLInputElement).value)}434              onKeyDown={(e) => {435                if (e.key === "Enter") void submitPassword();436              }}437            />438            <Show when={passwordError()}>439              <p class="peach-hint peach-hint-error peach-codex-error">440                {passwordError()}441              </p>442            </Show>443          </div>444445          <button446            class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"447            disabled={!password()}448            onClick={() => void submitPassword()}449          >450            continue451          </button>452        </Show>453454        <Show455          when={456            props.isPro &&457            !loading() &&458            !loadError() &&459            !needsPassword() &&460            !missingRecoveryWords() &&461            snapshot()462          }463        >464          <Show when={step() === "words"}>465            <div class="peach-codex-recovery-headline">466              <h2 class="peach-codex-recovery-title">your recovery words.</h2>467              <p class="peach-codex-recovery-body">468                write these down — store separately from your Codex.469              </p>470            </div>471472            <ol class="peach-codex-words-grid" start={1}>473              <For each={snapshot()!.recoveryWords ?? []}>474                {(word, i) => (475                  <li class="peach-codex-word-row">476                    <span class="peach-codex-word-numeral">{i() + 1}</span>477                    <span class="peach-codex-word-text">{word}</span>478                  </li>479                )}480              </For>481            </ol>482483            <Show when={estimatedPages() !== null}>484              <p class="peach-codex-meta">485                <strong>{estimatedPages()}</strong>{" "}486                {estimatedPages() === 1 ? "page" : "pages"} ·{" "}487                {snapshot()!.entryCount}{" "}488                {snapshot()!.entryCount === 1 ? "entry" : "entries"}489                <Show when={estimatedParity() !== null}>490                  {" "}· {estimatedParity()} redundancy codes491                </Show>492              </p>493            </Show>494495            <div class="peach-codex-confirm-section">496              <p class="peach-codex-confirm-label">497                to continue, enter words {confirmPositions().join(", ")}:498              </p>499              <For each={confirmPositions()}>500                {(pos, i) => (501                  <div class="peach-codex-confirm-row">502                    <span class="peach-codex-confirm-pos">word {pos}</span>503                    <input504                      class="peach-codex-confirm-input"505                      type="text"506                      autocomplete="off"507                      autocorrect="off"508                      autocapitalize="none"509                      spellcheck={false}510                      value={confirmInputs()[i()]}511                      onInput={(e) => {512                        const next = [...confirmInputs()] as [513                          string,514                          string,515                          string,516                        ];517                        next[i()] = (e.target as HTMLInputElement).value;518                        setConfirmInputs(next);519                      }}520                      onKeyDown={(e) => {521                        if (e.key === "Enter" && allCorrect())522                          void handleGenerate();523                      }}524                    />525                  </div>526                )}527              </For>528            </div>529530            <Show when={genError()}>531              <p class="peach-hint peach-hint-error peach-codex-error">532                {genError()}533              </p>534            </Show>535            <Show when={libraryError()}>536              <p class="peach-hint peach-hint-error peach-codex-error">537                {libraryError()}538              </p>539            </Show>540            <Show when={libraryLoading() && !libraryReady()}>541              <p class="peach-hint peach-codex-meta">542                preparing Codex library…543              </p>544            </Show>545546            <button547              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"548              disabled={!allCorrect() || libraryLoading()}549              onClick={() => void handleGenerate()}550            >551              {libraryLoading() && !libraryReady() ? "preparing…" : "generate my Codex"}552            </button>553          </Show>554555          <Show when={step() === "generating"}>556            <div class="peach-codex-recovery-headline">557              <h2 class="peach-codex-recovery-title">558                generating your codex…559              </h2>560            </div>561562            <div class="peach-codex-progress-group">563              <p class="peach-codex-progress-label">564                <span>{progressLabel()}</span>565                <span class="peach-codex-verify-tag">566                  {snapshot()?.verifyCode}567                </span>568              </p>569              <div class="peach-codex-progress-track">570                <div571                  class="peach-codex-progress-fill"572                  style={`width:${progressPercent()}%`}573                />574              </div>575            </div>576577            <Show when={aztecGallery().length > 0}>578              <div class="peach-codex-aztec-gallery">579                <For each={aztecGallery()}>580                  {(url) => (581                    <img582                      class="peach-codex-aztec-tile"583                      src={url}584                      alt=""585                      aria-hidden="true"586                    />587                  )}588                </For>589              </div>590            </Show>591          </Show>592593          <Show when={step() === "done"}>594            <div class="peach-codex-recovery-headline">595              <h2 class="peach-codex-recovery-title">596                your codex is downloading.597              </h2>598            </div>599600            <Show when={completed()}>601              <p class="peach-codex-meta">602                {completed()!.pageCount}{" "}603                {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}604                {completed()!.totalChunks} codes · verify code{" "}605                <span class="peach-codex-verify">606                  {completed()!.verifyCode}607                </span>608              </p>609            </Show>610611            <div class="peach-codex-done-guidance">612              <div class="peach-codex-done-item">613                <div>614                  <p class="peach-codex-done-item-title">615                    print on a laser printer616                  </p>617                  <p class="peach-codex-done-item-body">618                    laser prints last longer and scan more reliably than619                    inkjet. print at 100% scale — do not fit to page.620                  </p>621                </div>622              </div>623624              <div class="peach-codex-done-item">625                <div>626                  <p class="peach-codex-done-item-title">make two copies</p>627                  <p class="peach-codex-done-item-body">628                    store one at home (fireproof box or safe) and one629                    somewhere else (trusted person, safety deposit box).630                    fire and flood can destroy a single copy.631                  </p>632                </div>633              </div>634635              <div class="peach-codex-done-item">636                <div>637                  <p class="peach-codex-done-item-title">638                    store separately from recovery words639                  </p>640                  <p class="peach-codex-done-item-body">641                    your 12 recovery words go somewhere different. together642                    they restore your vault without your passphrase. keep643                    them apart.644                  </p>645                </div>646              </div>647648              <div class="peach-codex-done-item">649                <div>650                  <p class="peach-codex-done-item-title">also save the PDF</p>651                  <p class="peach-codex-done-item-body">652                    copy the PDF to a USB drive. Peach can import it653                    directly without needing to scan printed codes.654                  </p>655                </div>656              </div>657            </div>658659            <p class="peach-codex-done-regenerate">660              regenerate your codex whenever you add many new entries.661            </p>662663            <button664              class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"665              onClick={() => {666                setCompleted(null);667                setGenError(null);668                setLibraryError(null);669                setConfirmInputs(["", "", ""]);670                setConfirmPositions(pickConfirmationPositions());671                setStep("words");672              }}673            >674              generate another675            </button>676          </Show>677        </Show>678      </div>679    </div>680  );681}