CodexGenerateView.tsx

24.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);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      setLoadError(res.error ?? "could not load vault data");131      setLoading(false);132      return;133    }134135    setSnapshot(res.data);136137    const words = res.data.recoveryWords;138    if (!words || words.length !== 12) {139      // No proof supplied yet → ask for it; proof supplied but still no140      // words → the vault genuinely has no recovery escrow.141      if (!password) {142        setNeedsPassword(true);143      } else {144        setMissingRecoveryWords(true);145      }146      setLoading(false);147      return;148    }149150    setNeedsPassword(false);151    setPassword("");152    setPasswordError(null);153    setConfirmPositions(pickConfirmationPositions());154    setLoading(false);155    void warmCodexLibrary(res.data);156  }157158  async function submitPassword(): Promise<void> {159    if (!password()) return;160    setPasswordError(null);161    await loadSnapshot(password());162  }163164  const allCorrect = createMemo(() => {165    const snap = snapshot();166    const words = snap?.recoveryWords;167    if (!words || words.length !== 12) return false;168    const positions = confirmPositions();169    const inputs = confirmInputs();170    return positions.every(171      (pos, i) =>172        inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),173    );174  });175176  function buildDeps(snap: CodexVaultSnapshot): CodexDeps {177    return {178      getSlotA: async () => snap.slotA,179      getSlotB: async () => snap.slotB,180      getLastModified: async () => snap.lastModified,181      // `getAllEntries` is consumed only as a count filter inside the182      // generator — synthesise placeholders so real entries don't cross183      // the popup boundary.184      getAllEntries: async () =>185        Array.from(186          { length: snap.entryCount },187          () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,188        ),189      getVerifyCode: async () => snap.verifyCode,190      isVaultUnlocked: () => snap.vaultUnlocked,191    };192  }193194  async function warmCodexLibrary(195    snap?: CodexVaultSnapshot,196  ): Promise<CodexGenerateModule | null> {197    if (libraryReady()) return loadCodexGenerateModule();198199    setLibraryLoading(true);200    setLibraryError(null);201    try {202      const [generateModule, rsModule] = await Promise.all([203        loadCodexGenerateModule(),204        import("../codex/rs-loader"),205      ]);206      await rsModule.getReedSolomon();207      setLibraryReady(true);208209      if (snap?.slotA) {210        try {211          const { pages, parityShards } = await generateModule.estimateCodexPages(212            snap.slotA,213            snap.slotB,214          );215          setEstimatedPages(pages);216          setEstimatedParity(parityShards);217        } catch {218          // Estimation is informational; failure is non-fatal.219          setEstimatedPages(null);220          setEstimatedParity(null);221        }222      }223224      return generateModule;225    } catch (err) {226      codexGenerateModulePromise = null;227      setLibraryError(228        `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`,229      );230      return null;231    } finally {232      setLibraryLoading(false);233    }234  }235236  function codexFilename(): string {237    const d = new Date();238    const y = d.getFullYear();239    const m = String(d.getMonth() + 1).padStart(2, "0");240    const day = String(d.getDate()).padStart(2, "0");241    return `peach-codex-${y}-${m}-${day}.pdf`;242  }243244  function downloadPdf(bytes: Uint8Array): void {245    const a = document.createElement("a");246    a.download = codexFilename();247    // Copy into a fresh Uint8Array to ensure a tightly-owned ArrayBuffer248    // (Blob is happier with this than a subarray view).249    const owned = new Uint8Array(bytes);250    const blob = new Blob([owned.buffer], { type: "application/pdf" });251    a.href = URL.createObjectURL(blob);252    setTimeout(() => URL.revokeObjectURL(a.href), 4000);253    document.body.appendChild(a);254    a.click();255    a.remove();256  }257258  function explainCodexError(code: string, message: string): string {259    switch (code) {260      case "VAULT_LOCKED":261        return "your vault is locked. unlock and try again.";262      case "VAULT_EMPTY":263        return "your vault is empty — nothing to print yet.";264      case "PRO_REQUIRED":265        return "Codex generation is a Pro feature.";266      case "PDF_GENERATION_FAILED":267      case "AZTEC_GENERATION_FAILED":268        return "could not render the Codex. please try again.";269      case "COMPRESSION_ROUNDTRIP_FAILED":270      case "ERASURE_CODING_VERIFICATION_FAILED":271      case "ERASURE_CODING_CONTENT_MISMATCH":272      case "AZTEC_ROUNDTRIP_FAILED":273      case "AZTEC_CONTENT_MISMATCH":274      case "RECONSTRUCTION_SIMULATION_FAILED":275      case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":276      case "RECONSTRUCTION_FINGERPRINT_MISMATCH":277      case "RECONSTRUCTION_COUNT_MISMATCH":278        return `a self-check failed while building the Codex (${code}). nothing was saved. please try again.`;279      default:280        return message || "Codex generation failed.";281    }282  }283284  const onProgress: CodexProgress = (s, current, total, imageUrl) => {285    setProgressStep(s);286    setProgressCurrent(current ?? 0);287    setProgressTotal(total ?? 0);288    if (s === "aztec" && imageUrl) {289      setAztecGallery(prev => {290        if (prev.length >= GALLERY_MAX) {291          URL.revokeObjectURL(prev[0]!);292          return [...prev.slice(1), imageUrl];293        }294        return [...prev, imageUrl];295      });296    }297  };298299  async function handleGenerate() {300    const snap = snapshot();301    if (!snap || !allCorrect() || step() === "generating") return;302303    setGenError(null);304    setAztecGallery([]);305    setStep("generating");306    setProgressStep("compress");307308    try {309      const generateModule = await warmCodexLibrary(snap);310      if (!generateModule) {311        setStep("words");312        return;313      }314      const result = await generateModule.generateCodex(buildDeps(snap), onProgress);315      downloadPdf(result.pdfBytes);316      setCompleted(result.manifest);317      setStep("done");318319      try {320        await chrome.storage.local?.set({321          [STORAGE_KEY_LAST_GENERATED]: Date.now(),322        });323      } catch {324        // Bookkeeping; failure is non-fatal.325      }326    } catch (err) {327      const codexErr = err as CodexError;328      const code = (codexErr && (codexErr as { code?: string }).code) ?? "";329      const message =330        err instanceof Error ? err.message : "Codex generation failed.";331      console.error("[Codex] generation failed:", code, message);332      setGenError(explainCodexError(code, message));333      setStep("words");334    }335  }336337  function progressLabel(): string {338    switch (progressStep()) {339      case "compress":340        return "compressing vault";341      case "erasure":342        return "building chunks";343      case "aztec": {344        const total = progressTotal();345        const current = progressCurrent();346        if (total > 0) return `generating codes (${current} of ${total})`;347        return "generating codes";348      }349      case "verify":350        return "self-checking";351      case "pdf":352        return "creating PDF";353      default:354        return "preparing";355    }356  }357358  function progressPercent(): number {359    const total = progressTotal();360    const current = progressCurrent();361    // Map step to a coarse progress band so the bar advances monotonically362    // even when intra-step progress (aztec N of M) is fine-grained.363    switch (progressStep()) {364      case "compress":365        return 8;366      case "erasure":367        return 18;368      case "aztec":369        if (total <= 0) return 30;370        return 30 + Math.floor((current / total) * 55); // 30 → 85371      case "verify":372        return 90;373      case "pdf":374        return 96;375      default:376        return 4;377    }378  }379380  return (381    <div class="peach-overlay-view ov-enter-right" style="display:flex">382      <div class="peach-overlay-header">383        <button384          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"385          onClick={props.onBack}386          aria-label="Back"387          disabled={step() === "generating"}388        >389          <svg width="7" height="12" viewBox="0 0 7 12" fill="none"390            stroke="currentColor" stroke-width="1.1"391            stroke-linecap="round" stroke-linejoin="round">392            <path d="M6 1L1 6L6 11" />393          </svg>394        </button>395        <span class="peach-overlay-title">peach codex</span>396        <div class="peach-overlay-spacer"></div>397      </div>398399      <div400        class="peach-settings-body peach-codex-body peach-codex-body--runes"401        classList={{ "peach-codex-body--done": step() === "done" }}402      >403        <RecoveryRuneBackground />404405        <Show when={!props.isPro}>406          <p class="peach-hint">redirecting…</p>407        </Show>408409        <Show when={props.isPro && loading()}>410          <p class="peach-hint">loading vault…</p>411        </Show>412413        <Show when={props.isPro && !loading() && loadError()}>414          <p class="peach-hint peach-hint-error">{loadError()}</p>415        </Show>416417        <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>418          <div class="peach-settings-group peach-codex-intro">419            <p class="peach-codex-blurb">420              the Codex prints your encrypted vault as Aztec codes. before421              you can generate one, set up your 12 recovery words in422              Settings → Security.423            </p>424          </div>425          <button426            class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"427            onClick={props.onOpenSecurity}428          >429            open Security settings430          </button>431        </Show>432433        <Show when={props.isPro && !loading() && !loadError() && needsPassword()}>434          <div class="peach-codex-recovery-headline">435            <h2 class="peach-codex-recovery-title">confirm it's you.</h2>436            <p class="peach-codex-recovery-body">437              enter your master password to show your recovery words.438            </p>439          </div>440441          <div class="peach-codex-confirm-section">442            <input443              class="peach-codex-confirm-input"444              type="password"445              autocomplete="current-password"446              placeholder="master password"447              value={password()}448              onInput={(e) => setPassword((e.target as HTMLInputElement).value)}449              onKeyDown={(e) => {450                if (e.key === "Enter") void submitPassword();451              }}452            />453            <Show when={passwordError()}>454              <p class="peach-hint peach-hint-error peach-codex-error">455                {passwordError()}456              </p>457            </Show>458          </div>459460          <button461            class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"462            disabled={!password()}463            onClick={() => void submitPassword()}464          >465            continue466          </button>467        </Show>468469        <Show470          when={471            props.isPro &&472            !loading() &&473            !loadError() &&474            !needsPassword() &&475            !missingRecoveryWords() &&476            snapshot()477          }478        >479          <Show when={step() === "words"}>480            <div class="peach-codex-recovery-headline">481              <h2 class="peach-codex-recovery-title">your recovery words.</h2>482              <p class="peach-codex-recovery-body">483                write these down — store separately from your Codex.484              </p>485            </div>486487            <ol class="peach-codex-words-grid" start={1}>488              <For each={snapshot()!.recoveryWords ?? []}>489                {(word, i) => (490                  <li class="peach-codex-word-row">491                    <span class="peach-codex-word-numeral">{i() + 1}</span>492                    <span class="peach-codex-word-text">{word}</span>493                  </li>494                )}495              </For>496            </ol>497498            <Show when={estimatedPages() !== null}>499              <p class="peach-codex-meta">500                <strong>{estimatedPages()}</strong>{" "}501                {estimatedPages() === 1 ? "page" : "pages"} ·{" "}502                {snapshot()!.entryCount}{" "}503                {snapshot()!.entryCount === 1 ? "entry" : "entries"}504                <Show when={estimatedParity() !== null}>505                  {" "}· {estimatedParity()} redundancy codes506                </Show>507              </p>508            </Show>509510            <div class="peach-codex-confirm-section">511              <p class="peach-codex-confirm-label">512                to continue, enter words {confirmPositions().join(", ")}:513              </p>514              <For each={confirmPositions()}>515                {(pos, i) => (516                  <div class="peach-codex-confirm-row">517                    <span class="peach-codex-confirm-pos">word {pos}</span>518                    <input519                      class="peach-codex-confirm-input"520                      type="text"521                      autocomplete="off"522                      autocorrect="off"523                      autocapitalize="none"524                      spellcheck={false}525                      value={confirmInputs()[i()]}526                      onInput={(e) => {527                        const next = [...confirmInputs()] as [528                          string,529                          string,530                          string,531                        ];532                        next[i()] = (e.target as HTMLInputElement).value;533                        setConfirmInputs(next);534                      }}535                      onKeyDown={(e) => {536                        if (e.key === "Enter" && allCorrect())537                          void handleGenerate();538                      }}539                    />540                  </div>541                )}542              </For>543            </div>544545            <Show when={genError()}>546              <p class="peach-hint peach-hint-error peach-codex-error">547                {genError()}548              </p>549            </Show>550            <Show when={libraryError()}>551              <p class="peach-hint peach-hint-error peach-codex-error">552                {libraryError()}553              </p>554            </Show>555            <Show when={libraryLoading() && !libraryReady()}>556              <p class="peach-hint peach-codex-meta">557                preparing Codex library…558              </p>559            </Show>560561            <button562              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"563              disabled={!allCorrect() || libraryLoading()}564              onClick={() => void handleGenerate()}565            >566              {libraryLoading() && !libraryReady() ? "preparing…" : "generate my Codex"}567            </button>568          </Show>569570          <Show when={step() === "generating"}>571            <div class="peach-codex-recovery-headline">572              <h2 class="peach-codex-recovery-title">573                generating your codex…574              </h2>575            </div>576577            <div class="peach-codex-progress-group">578              <p class="peach-codex-progress-label">579                <span>{progressLabel()}</span>580                <span class="peach-codex-verify-tag">581                  {snapshot()?.verifyCode}582                </span>583              </p>584              <div class="peach-codex-progress-track">585                <div586                  class="peach-codex-progress-fill"587                  style={`width:${progressPercent()}%`}588                />589              </div>590            </div>591592            <Show when={aztecGallery().length > 0}>593              <div class="peach-codex-aztec-gallery">594                <For each={aztecGallery()}>595                  {(url) => (596                    <img597                      class="peach-codex-aztec-tile"598                      src={url}599                      alt=""600                      aria-hidden="true"601                    />602                  )}603                </For>604              </div>605            </Show>606          </Show>607608          <Show when={step() === "done"}>609            <div class="peach-codex-recovery-headline">610              <h2 class="peach-codex-recovery-title">611                your codex is downloading.612              </h2>613            </div>614615            <Show when={completed()}>616              <p class="peach-codex-meta">617                {completed()!.pageCount}{" "}618                {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}619                {completed()!.totalChunks} codes · verify code{" "}620                <span class="peach-codex-verify">621                  {completed()!.verifyCode}622                </span>623              </p>624            </Show>625626            <div class="peach-codex-done-guidance">627              <div class="peach-codex-done-item">628                <div>629                  <p class="peach-codex-done-item-title">630                    print on a laser printer631                  </p>632                  <p class="peach-codex-done-item-body">633                    laser prints last longer and scan more reliably than634                    inkjet. print at 100% scale — do not fit to page.635                  </p>636                </div>637              </div>638639              <div class="peach-codex-done-item">640                <div>641                  <p class="peach-codex-done-item-title">make two copies</p>642                  <p class="peach-codex-done-item-body">643                    store one at home (fireproof box or safe) and one644                    somewhere else (trusted person, safety deposit box).645                    fire and flood can destroy a single copy.646                  </p>647                </div>648              </div>649650              <div class="peach-codex-done-item">651                <div>652                  <p class="peach-codex-done-item-title">653                    store separately from recovery words654                  </p>655                  <p class="peach-codex-done-item-body">656                    your 12 recovery words go somewhere different. together657                    they restore your vault without your passphrase. keep658                    them apart.659                  </p>660                </div>661              </div>662663              <div class="peach-codex-done-item">664                <div>665                  <p class="peach-codex-done-item-title">also save the PDF</p>666                  <p class="peach-codex-done-item-body">667                    copy the PDF to a USB drive. Peach can import it668                    directly without needing to scan printed codes.669                  </p>670                </div>671              </div>672            </div>673674            <p class="peach-codex-done-regenerate">675              regenerate your codex whenever you add many new entries.676            </p>677678            <button679              class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"680              onClick={() => {681                setCompleted(null);682                setGenError(null);683                setLibraryError(null);684                setConfirmInputs(["", "", ""]);685                setConfirmPositions(pickConfirmationPositions());686                setStep("words");687              }}688            >689              generate another690            </button>691          </Show>692        </Show>693      </div>694    </div>695  );696}