CodexGenerateView.tsx

24.3 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  // Screenshot hygiene: drop the displayed words when the window is98  // backgrounded so an OS screenshot of the hidden popup never captures99  // them; re-showing requires the master-password step again.100  const handleVisibilityChange = () => {101    const snap = snapshot();102    if (document.visibilityState === "hidden" && snap?.recoveryWords) {103      setSnapshot({ ...snap, recoveryWords: null });104      setNeedsPassword(true);105    }106  };107  document.addEventListener("visibilitychange", handleVisibilityChange);108  onCleanup(() =>109    document.removeEventListener("visibilitychange", handleVisibilityChange),110  );111112  async function loadSnapshot(password?: string): Promise<void> {113    const res = (await sendMessage({114      type: "GET_CODEX_VAULT_DATA",115      ...(password ? { payload: { password } } : {}),116    })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };117118    if (!res.ok || !res.data) {119      // Service worker restarted (MV3) and the vault re-locked in memory.120      // Route back to the lock screen exactly like the rest of the app does.121      if (res.error?.startsWith("VAULT_LOCKED")) {122        props.onVaultLocked();123        return;124      }125      // Wrong re-auth password is a form error, not a load failure.126      if (res.error === "wrong_password") {127        setPasswordError("wrong password. try again.");128        return;129      }130      // PB-26-607: never echo the raw background error into the UI.131      setLoadError("could not load vault data");132      setLoading(false);133      return;134    }135136    setSnapshot(res.data);137138    const words = res.data.recoveryWords;139    if (!words || words.length !== 12) {140      // No proof supplied yet → ask for it; proof supplied but still no141      // words → the vault genuinely has no recovery escrow.142      if (!password) {143        setNeedsPassword(true);144      } else {145        setMissingRecoveryWords(true);146      }147      setLoading(false);148      return;149    }150151    setNeedsPassword(false);152    setPassword("");153    setPasswordError(null);154    setConfirmPositions(pickConfirmationPositions());155    setLoading(false);156    void warmCodexLibrary(res.data);157  }158159  async function submitPassword(): Promise<void> {160    if (!password()) return;161    setPasswordError(null);162    await loadSnapshot(password());163  }164165  const allCorrect = createMemo(() => {166    const snap = snapshot();167    const words = snap?.recoveryWords;168    if (!words || words.length !== 12) return false;169    const positions = confirmPositions();170    const inputs = confirmInputs();171    return positions.every(172      (pos, i) =>173        inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),174    );175  });176177  function buildDeps(snap: CodexVaultSnapshot): CodexDeps {178    return {179      getSlotA: async () => snap.slotA,180      getSlotB: async () => snap.slotB,181      getLastModified: async () => snap.lastModified,182      // `getAllEntries` is consumed only as a count filter inside the183      // generator — synthesise placeholders so real entries don't cross184      // the popup boundary.185      getAllEntries: async () =>186        Array.from(187          { length: snap.entryCount },188          () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,189        ),190      getVerifyCode: async () => snap.verifyCode,191      isVaultUnlocked: () => snap.vaultUnlocked,192    };193  }194195  async function warmCodexLibrary(196    snap?: CodexVaultSnapshot,197  ): Promise<CodexGenerateModule | null> {198    if (libraryReady()) return loadCodexGenerateModule();199200    setLibraryLoading(true);201    setLibraryError(null);202    try {203      const [generateModule, rsModule] = await Promise.all([204        loadCodexGenerateModule(),205        import("../codex/rs-loader"),206      ]);207      await rsModule.getReedSolomon();208      setLibraryReady(true);209210      if (snap?.slotA) {211        try {212          const { pages, parityShards } = await generateModule.estimateCodexPages(213            snap.slotA,214            snap.slotB,215          );216          setEstimatedPages(pages);217          setEstimatedParity(parityShards);218        } catch {219          // Estimation is informational; failure is non-fatal.220          setEstimatedPages(null);221          setEstimatedParity(null);222        }223      }224225      return generateModule;226    } catch (err) {227      codexGenerateModulePromise = null;228      setLibraryError(229        `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`,230      );231      return null;232    } finally {233      setLibraryLoading(false);234    }235  }236237  function codexFilename(): string {238    const d = new Date();239    const y = d.getFullYear();240    const m = String(d.getMonth() + 1).padStart(2, "0");241    const day = String(d.getDate()).padStart(2, "0");242    return `peach-codex-${y}-${m}-${day}.pdf`;243  }244245  function downloadPdf(bytes: Uint8Array): void {246    const a = document.createElement("a");247    a.download = codexFilename();248    // Copy into a fresh Uint8Array to ensure a tightly-owned ArrayBuffer249    // (Blob is happier with this than a subarray view).250    const owned = new Uint8Array(bytes);251    const blob = new Blob([owned.buffer], { type: "application/pdf" });252    a.href = URL.createObjectURL(blob);253    setTimeout(() => URL.revokeObjectURL(a.href), 4000);254    document.body.appendChild(a);255    a.click();256    a.remove();257  }258259  function explainCodexError(code: string): string {260    switch (code) {261      case "VAULT_LOCKED":262        return "your vault is locked. unlock and try again.";263      case "VAULT_EMPTY":264        return "your vault is empty — nothing to print yet.";265      case "PRO_REQUIRED":266        return "Codex generation is a Pro feature.";267      case "PDF_GENERATION_FAILED":268      case "AZTEC_GENERATION_FAILED":269        return "could not render the Codex. please try again.";270      case "COMPRESSION_ROUNDTRIP_FAILED":271      case "ERASURE_CODING_VERIFICATION_FAILED":272      case "ERASURE_CODING_CONTENT_MISMATCH":273      case "AZTEC_ROUNDTRIP_FAILED":274      case "AZTEC_CONTENT_MISMATCH":275      case "RECONSTRUCTION_SIMULATION_FAILED":276      case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":277      case "RECONSTRUCTION_FINGERPRINT_MISMATCH":278      case "RECONSTRUCTION_COUNT_MISMATCH":279        return `a self-check failed while building the Codex (${code}). nothing was saved. please try again.`;280      default:281        // PB-26-607: never echo the raw error text into the UI.282        return "Codex generation failed.";283    }284  }285286  const onProgress: CodexProgress = (s, current, total, imageUrl) => {287    setProgressStep(s);288    setProgressCurrent(current ?? 0);289    setProgressTotal(total ?? 0);290    if (s === "aztec" && imageUrl) {291      setAztecGallery(prev => {292        if (prev.length >= GALLERY_MAX) {293          URL.revokeObjectURL(prev[0]!);294          return [...prev.slice(1), imageUrl];295        }296        return [...prev, imageUrl];297      });298    }299  };300301  async function handleGenerate() {302    const snap = snapshot();303    if (!snap || !allCorrect() || step() === "generating") return;304305    setGenError(null);306    setAztecGallery([]);307    setStep("generating");308    setProgressStep("compress");309310    try {311      const generateModule = await warmCodexLibrary(snap);312      if (!generateModule) {313        setStep("words");314        return;315      }316      const result = await generateModule.generateCodex(buildDeps(snap), onProgress);317      downloadPdf(result.pdfBytes);318      setCompleted(result.manifest);319      setStep("done");320321      try {322        await chrome.storage.local?.set({323          [STORAGE_KEY_LAST_GENERATED]: Date.now(),324        });325      } catch {326        // Bookkeeping; failure is non-fatal.327      }328    } catch (err) {329      const codexErr = err as CodexError;330      const code = (codexErr && (codexErr as { code?: string }).code) ?? "";331      const message =332        err instanceof Error ? err.message : "Codex generation failed.";333      console.error("[Codex] generation failed:", code, message);334      setGenError(explainCodexError(code));335      setStep("words");336    }337  }338339  function progressLabel(): string {340    switch (progressStep()) {341      case "compress":342        return "compressing vault";343      case "erasure":344        return "building chunks";345      case "aztec": {346        const total = progressTotal();347        const current = progressCurrent();348        if (total > 0) return `generating codes (${current} of ${total})`;349        return "generating codes";350      }351      case "verify":352        return "self-checking";353      case "pdf":354        return "creating PDF";355      default:356        return "preparing";357    }358  }359360  function progressPercent(): number {361    const total = progressTotal();362    const current = progressCurrent();363    // Map step to a coarse progress band so the bar advances monotonically364    // even when intra-step progress (aztec N of M) is fine-grained.365    switch (progressStep()) {366      case "compress":367        return 8;368      case "erasure":369        return 18;370      case "aztec":371        if (total <= 0) return 30;372        return 30 + Math.floor((current / total) * 55); // 30 → 85373      case "verify":374        return 90;375      case "pdf":376        return 96;377      default:378        return 4;379    }380  }381382  return (383    <div class="peach-overlay-view ov-enter-right" style="display:flex">384      <div class="peach-overlay-header">385        <button386          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"387          onClick={props.onBack}388          aria-label="Back"389          disabled={step() === "generating"}390        >391          <svg width="7" height="12" viewBox="0 0 7 12" fill="none"392            stroke="currentColor" stroke-width="1.1"393            stroke-linecap="round" stroke-linejoin="round">394            <path d="M6 1L1 6L6 11" />395          </svg>396        </button>397        <span class="peach-overlay-title">peach codex</span>398        <div class="peach-overlay-spacer"></div>399      </div>400401      <div402        class="peach-settings-body peach-codex-body peach-codex-body--runes"403        classList={{ "peach-codex-body--done": step() === "done" }}404      >405        <RecoveryRuneBackground />406407        <Show when={!props.isPro}>408          <p class="peach-hint">redirecting…</p>409        </Show>410411        <Show when={props.isPro && loading()}>412          <p class="peach-hint">loading vault…</p>413        </Show>414415        <Show when={props.isPro && !loading() && loadError()}>416          <p class="peach-hint peach-hint-error">{loadError()}</p>417        </Show>418419        <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>420          <div class="peach-settings-group peach-codex-intro">421            <p class="peach-codex-blurb">422              the Codex prints your encrypted vault as Aztec codes. before423              you can generate one, set up your 12 recovery words in424              Settings → Security.425            </p>426          </div>427          <button428            class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"429            onClick={props.onOpenSecurity}430          >431            open Security settings432          </button>433        </Show>434435        <Show when={props.isPro && !loading() && !loadError() && needsPassword()}>436          <div class="peach-codex-recovery-headline">437            <h2 class="peach-codex-recovery-title">confirm it's you.</h2>438            <p class="peach-codex-recovery-body">439              enter your master password to show your recovery words.440            </p>441          </div>442443          <div class="peach-codex-confirm-section">444            <input445              class="peach-codex-confirm-input"446              type="password"447              autocomplete="current-password"448              placeholder="master password"449              value={password()}450              onInput={(e) => setPassword((e.target as HTMLInputElement).value)}451              onKeyDown={(e) => {452                if (e.key === "Enter") void submitPassword();453              }}454            />455            <Show when={passwordError()}>456              <p class="peach-hint peach-hint-error peach-codex-error">457                {passwordError()}458              </p>459            </Show>460          </div>461462          <button463            class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"464            disabled={!password()}465            onClick={() => void submitPassword()}466          >467            continue468          </button>469        </Show>470471        <Show472          when={473            props.isPro &&474            !loading() &&475            !loadError() &&476            !needsPassword() &&477            !missingRecoveryWords() &&478            snapshot()479          }480        >481          <Show when={step() === "words"}>482            <div class="peach-codex-recovery-headline">483              <h2 class="peach-codex-recovery-title">your recovery words.</h2>484              <p class="peach-codex-recovery-body">485                write these down — store separately from your Codex.486              </p>487            </div>488489            <ol class="peach-codex-words-grid" start={1}>490              <For each={snapshot()!.recoveryWords ?? []}>491                {(word, i) => (492                  <li class="peach-codex-word-row">493                    <span class="peach-codex-word-numeral">{i() + 1}</span>494                    <span class="peach-codex-word-text">{word}</span>495                  </li>496                )}497              </For>498            </ol>499500            <Show when={estimatedPages() !== null}>501              <p class="peach-codex-meta">502                <strong>{estimatedPages()}</strong>{" "}503                {estimatedPages() === 1 ? "page" : "pages"} ·{" "}504                {snapshot()!.entryCount}{" "}505                {snapshot()!.entryCount === 1 ? "entry" : "entries"}506                <Show when={estimatedParity() !== null}>507                  {" "}· {estimatedParity()} redundancy codes508                </Show>509              </p>510            </Show>511512            <div class="peach-codex-confirm-section">513              <p class="peach-codex-confirm-label">514                to continue, enter words {confirmPositions().join(", ")}:515              </p>516              <For each={confirmPositions()}>517                {(pos, i) => (518                  <div class="peach-codex-confirm-row">519                    <span class="peach-codex-confirm-pos">word {pos}</span>520                    <input521                      class="peach-codex-confirm-input"522                      type="text"523                      autocomplete="off"524                      autocorrect="off"525                      autocapitalize="none"526                      spellcheck={false}527                      value={confirmInputs()[i()]}528                      onInput={(e) => {529                        const next = [...confirmInputs()] as [530                          string,531                          string,532                          string,533                        ];534                        next[i()] = (e.target as HTMLInputElement).value;535                        setConfirmInputs(next);536                      }}537                      onKeyDown={(e) => {538                        if (e.key === "Enter" && allCorrect())539                          void handleGenerate();540                      }}541                    />542                  </div>543                )}544              </For>545            </div>546547            <Show when={genError()}>548              <p class="peach-hint peach-hint-error peach-codex-error">549                {genError()}550              </p>551            </Show>552            <Show when={libraryError()}>553              <p class="peach-hint peach-hint-error peach-codex-error">554                {libraryError()}555              </p>556            </Show>557            <Show when={libraryLoading() && !libraryReady()}>558              <p class="peach-hint peach-codex-meta">559                preparing Codex library…560              </p>561            </Show>562563            <button564              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"565              disabled={!allCorrect() || libraryLoading()}566              onClick={() => void handleGenerate()}567            >568              {libraryLoading() && !libraryReady() ? "preparing…" : "generate my Codex"}569            </button>570          </Show>571572          <Show when={step() === "generating"}>573            <div class="peach-codex-recovery-headline">574              <h2 class="peach-codex-recovery-title">575                generating your codex…576              </h2>577            </div>578579            <div class="peach-codex-progress-group">580              <p class="peach-codex-progress-label">581                <span>{progressLabel()}</span>582                <span class="peach-codex-verify-tag">583                  {snapshot()?.verifyCode}584                </span>585              </p>586              <div class="peach-codex-progress-track">587                <div588                  class="peach-codex-progress-fill"589                  style={`width:${progressPercent()}%`}590                />591              </div>592            </div>593594            <Show when={aztecGallery().length > 0}>595              <div class="peach-codex-aztec-gallery">596                <For each={aztecGallery()}>597                  {(url) => (598                    <img599                      class="peach-codex-aztec-tile"600                      src={url}601                      alt=""602                      aria-hidden="true"603                    />604                  )}605                </For>606              </div>607            </Show>608          </Show>609610          <Show when={step() === "done"}>611            <div class="peach-codex-recovery-headline">612              <h2 class="peach-codex-recovery-title">613                your codex is downloading.614              </h2>615            </div>616617            <Show when={completed()}>618              <p class="peach-codex-meta">619                {completed()!.pageCount}{" "}620                {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}621                {completed()!.totalChunks} codes · verify code{" "}622                <span class="peach-codex-verify">623                  {completed()!.verifyCode}624                </span>625              </p>626            </Show>627628            <div class="peach-codex-done-guidance">629              <div class="peach-codex-done-item">630                <div>631                  <p class="peach-codex-done-item-title">632                    print on a laser printer633                  </p>634                  <p class="peach-codex-done-item-body">635                    laser prints last longer and scan more reliably than636                    inkjet. print at 100% scale — do not fit to page.637                  </p>638                </div>639              </div>640641              <div class="peach-codex-done-item">642                <div>643                  <p class="peach-codex-done-item-title">make two copies</p>644                  <p class="peach-codex-done-item-body">645                    store one at home (fireproof box or safe) and one646                    somewhere else (trusted person, safety deposit box).647                    fire and flood can destroy a single copy.648                  </p>649                </div>650              </div>651652              <div class="peach-codex-done-item">653                <div>654                  <p class="peach-codex-done-item-title">655                    store separately from recovery words656                  </p>657                  <p class="peach-codex-done-item-body">658                    your 12 recovery words go somewhere different. together659                    they restore your vault without your passphrase. keep660                    them apart.661                  </p>662                </div>663              </div>664665              <div class="peach-codex-done-item">666                <div>667                  <p class="peach-codex-done-item-title">also save the PDF</p>668                  <p class="peach-codex-done-item-body">669                    copy the PDF to a USB drive. Peach can import it670                    directly without needing to scan printed codes.671                  </p>672                </div>673              </div>674            </div>675676            <p class="peach-codex-done-regenerate">677              regenerate your codex whenever you add many new entries.678            </p>679680            <button681              class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"682              onClick={() => {683                setCompleted(null);684                setGenError(null);685                setLibraryError(null);686                setConfirmInputs(["", "", ""]);687                setConfirmPositions(pickConfirmationPositions());688                setStep("words");689              }}690            >691              generate another692            </button>693          </Show>694        </Show>695      </div>696    </div>697  );698}