CodexGenerateView.tsx

20.9 KB · raw

1// Peach Codex — generation view.2//3// Two-step flow (see peach_codex_addendum.md):4//   1. words  — show the 12 recovery words and require the user to type 35//               randomly-chosen ones correctly before generation can proceed.6//               The Codex PDF never contains recovery words; this is the7//               due-diligence gate that pushes the user to commit them to8//               paper separately.9//   2. gen    — run the popup-side `generateCodex` pipeline, surface progress,10//               trigger the PDF download.11//   3. done   — reminder to store the Codex and recovery words separately.12//13// Pro-gated: a non-Pro user is bounced to the license section on mount.14// Restoration (CodexRestoreView) stays free — a printed Codex must restore15// regardless of license state.1617import { createSignal, createMemo, For, onMount, Show } from "solid-js";18import { sendMessage } from "../../shared/bridge";19import { isNativePlatform } from "../../shared/native";20import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground";21import type {22  CodexDeps,23  CodexError,24  CodexManifest,25  CodexProgress,26  CodexVaultSnapshot,27} from "../../shared/codex-types";28import { STORAGE_KEY_LAST_GENERATED } from "../../shared/codex-types";29import { pickConfirmationPositions } from "../codex/confirmation";30import type { VaultEntry } from "../../shared/types";3132interface CodexGenerateViewProps {33  isPro: boolean;34  onBack: () => void;35  onUpgradePro: () => void;36  onOpenSecurity: () => void;37  onVaultLocked: () => void;38}3940type Step = "words" | "generating" | "done";41type CodexGenerateModule = typeof import("../codex/generate");4243let codexGenerateModulePromise: Promise<CodexGenerateModule> | null = null;4445function loadCodexGenerateModule(): Promise<CodexGenerateModule> {46  codexGenerateModulePromise ??= import("../codex/generate");47  return codexGenerateModulePromise;48}495051export function CodexGenerateView(props: CodexGenerateViewProps) {52  const [snapshot, setSnapshot] = createSignal<CodexVaultSnapshot | null>(null);53  const [estimatedPages, setEstimatedPages] = createSignal<number | null>(null);54  const [estimatedParity, setEstimatedParity] = createSignal<number | null>(null);55  const [loading, setLoading] = createSignal(true);56  const [loadError, setLoadError] = createSignal<string | null>(null);57  const [libraryLoading, setLibraryLoading] = createSignal(false);58  const [libraryReady, setLibraryReady] = createSignal(false);59  const [libraryError, setLibraryError] = createSignal<string | null>(null);60  const [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false);6162  const [step, setStep] = createSignal<Step>("words");63  const [confirmPositions, setConfirmPositions] = createSignal<64    [number, number, number]65  >([3, 7, 11]);66  const [confirmInputs, setConfirmInputs] = createSignal<67    [string, string, string]68  >(["", "", ""]);6970  const [progressStep, setProgressStep] = createSignal<string>("");71  const [progressCurrent, setProgressCurrent] = createSignal<number>(0);72  const [progressTotal, setProgressTotal] = createSignal<number>(0);7374  const [genError, setGenError] = createSignal<string | null>(null);75  const [completed, setCompleted] = createSignal<CodexManifest | null>(null);7677  onMount(async () => {78    if (!props.isPro) {79      props.onUpgradePro();80      return;81    }8283    const res = (await sendMessage({84      type: "GET_CODEX_VAULT_DATA",85    })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };8687    if (!res.ok || !res.data) {88      // Service worker restarted (MV3) and the vault re-locked in memory.89      // Route back to the lock screen exactly like the rest of the app does.90      if (res.error?.startsWith("VAULT_LOCKED")) {91        props.onVaultLocked();92        return;93      }94      setLoadError(res.error ?? "could not load vault data");95      setLoading(false);96      return;97    }9899    setSnapshot(res.data);100101    const words = res.data.recoveryWords;102    if (!words || words.length !== 12) {103      setMissingRecoveryWords(true);104      setLoading(false);105      return;106    }107108    setConfirmPositions(pickConfirmationPositions());109    setLoading(false);110    void warmCodexLibrary(res.data);111  });112113  const allCorrect = createMemo(() => {114    const snap = snapshot();115    const words = snap?.recoveryWords;116    if (!words || words.length !== 12) return false;117    const positions = confirmPositions();118    const inputs = confirmInputs();119    return positions.every(120      (pos, i) =>121        inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),122    );123  });124125  function buildDeps(snap: CodexVaultSnapshot): CodexDeps {126    return {127      getSlotA: async () => snap.slotA,128      getSlotB: async () => snap.slotB,129      getLastModified: async () => snap.lastModified,130      // `getAllEntries` is consumed only as a count filter inside the131      // generator — synthesise placeholders so real entries don't cross132      // the popup boundary.133      getAllEntries: async () =>134        Array.from(135          { length: snap.entryCount },136          () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,137        ),138      getVerifyCode: async () => snap.verifyCode,139      isVaultUnlocked: () => snap.vaultUnlocked,140    };141  }142143  async function warmCodexLibrary(144    snap?: CodexVaultSnapshot,145  ): Promise<CodexGenerateModule | null> {146    if (libraryReady()) return loadCodexGenerateModule();147148    setLibraryLoading(true);149    setLibraryError(null);150    try {151      const [generateModule, rsModule] = await Promise.all([152        loadCodexGenerateModule(),153        import("../codex/rs-loader"),154      ]);155      await rsModule.getReedSolomon();156      setLibraryReady(true);157158      if (snap?.slotA) {159        try {160          const { pages, parityShards } = await generateModule.estimateCodexPages(161            snap.slotA,162            snap.slotB,163          );164          setEstimatedPages(pages);165          setEstimatedParity(parityShards);166        } catch {167          // Estimation is informational; failure is non-fatal.168          setEstimatedPages(null);169          setEstimatedParity(null);170        }171      }172173      return generateModule;174    } catch (err) {175      codexGenerateModulePromise = null;176      setLibraryError(177        `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`,178      );179      return null;180    } finally {181      setLibraryLoading(false);182    }183  }184185  function codexFilename(): string {186    const d = new Date();187    const y = d.getFullYear();188    const m = String(d.getMonth() + 1).padStart(2, "0");189    const day = String(d.getDate()).padStart(2, "0");190    return `peach-codex-${y}-${m}-${day}.pdf`;191  }192193  function downloadPdf(bytes: Uint8Array): void {194    const a = document.createElement("a");195    a.download = codexFilename();196    if (isNativePlatform()) {197      // Capacitor WebView: blob: URLs are opaque to native, but data: URLs198      // are intercepted by MainActivity and written to public Downloads via199      // MediaStore. Encode in chunks to avoid blowing the call stack on200      // large PDFs.201      let bin = "";202      const chunk = 0x8000;203      for (let i = 0; i < bytes.length; i += chunk) {204        const slice = bytes.subarray(i, Math.min(i + chunk, bytes.length));205        bin += String.fromCharCode(...slice);206      }207      a.href = `data:application/pdf;base64,${btoa(bin)}`;208    } else {209      // Browser: copy into a fresh Uint8Array to ensure a tightly-owned210      // ArrayBuffer (Blob is happier with this than a subarray view).211      const owned = new Uint8Array(bytes);212      const blob = new Blob([owned.buffer], { type: "application/pdf" });213      a.href = URL.createObjectURL(blob);214      setTimeout(() => URL.revokeObjectURL(a.href), 4000);215    }216    document.body.appendChild(a);217    a.click();218    a.remove();219  }220221  function explainCodexError(code: string, message: string): string {222    switch (code) {223      case "VAULT_LOCKED":224        return "your vault is locked. unlock and try again.";225      case "VAULT_EMPTY":226        return "your vault is empty — nothing to print yet.";227      case "PRO_REQUIRED":228        return "Codex generation is a Pro feature.";229      case "PDF_GENERATION_FAILED":230      case "AZTEC_GENERATION_FAILED":231        return "could not render the Codex. please try again.";232      case "COMPRESSION_ROUNDTRIP_FAILED":233      case "ERASURE_CODING_VERIFICATION_FAILED":234      case "ERASURE_CODING_CONTENT_MISMATCH":235      case "AZTEC_ROUNDTRIP_FAILED":236      case "AZTEC_CONTENT_MISMATCH":237      case "RECONSTRUCTION_SIMULATION_FAILED":238      case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":239      case "RECONSTRUCTION_FINGERPRINT_MISMATCH":240      case "RECONSTRUCTION_COUNT_MISMATCH":241        return `a self-check failed while building the Codex (${code}). nothing was saved. please try again.`;242      default:243        return message || "Codex generation failed.";244    }245  }246247  const onProgress: CodexProgress = (s, current, total) => {248    setProgressStep(s);249    setProgressCurrent(current ?? 0);250    setProgressTotal(total ?? 0);251  };252253  async function handleGenerate() {254    const snap = snapshot();255    if (!snap || !allCorrect() || step() === "generating") return;256257    setGenError(null);258    setStep("generating");259    setProgressStep("compress");260261    try {262      const generateModule = await warmCodexLibrary(snap);263      if (!generateModule) {264        setStep("words");265        return;266      }267      const result = await generateModule.generateCodex(buildDeps(snap), onProgress);268      downloadPdf(result.pdfBytes);269      setCompleted(result.manifest);270      setStep("done");271272      try {273        await chrome.storage.local?.set({274          [STORAGE_KEY_LAST_GENERATED]: Date.now(),275        });276      } catch {277        // Bookkeeping; failure is non-fatal.278      }279    } catch (err) {280      const codexErr = err as CodexError;281      const code = (codexErr && (codexErr as { code?: string }).code) ?? "";282      const message =283        err instanceof Error ? err.message : "Codex generation failed.";284      console.error("[Codex] generation failed:", code, message);285      setGenError(explainCodexError(code, message));286      setStep("words");287    }288  }289290  function progressLabel(): string {291    switch (progressStep()) {292      case "compress":293        return "compressing vault";294      case "erasure":295        return "building chunks";296      case "aztec": {297        const total = progressTotal();298        const current = progressCurrent();299        if (total > 0) return `generating codes (${current} of ${total})`;300        return "generating codes";301      }302      case "verify":303        return "self-checking";304      case "pdf":305        return "creating PDF";306      default:307        return "preparing";308    }309  }310311  function progressPercent(): number {312    const total = progressTotal();313    const current = progressCurrent();314    // Map step to a coarse progress band so the bar advances monotonically315    // even when intra-step progress (aztec N of M) is fine-grained.316    switch (progressStep()) {317      case "compress":318        return 8;319      case "erasure":320        return 18;321      case "aztec":322        if (total <= 0) return 30;323        return 30 + Math.floor((current / total) * 55); // 30 → 85324      case "verify":325        return 90;326      case "pdf":327        return 96;328      default:329        return 4;330    }331  }332333  return (334    <div class="peach-overlay-view ov-enter-right" style="display:flex">335      <div class="peach-overlay-header">336        <button337          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"338          onClick={props.onBack}339          aria-label="Back"340          disabled={step() === "generating"}341        >342          <svg width="7" height="12" viewBox="0 0 7 12" fill="none"343            stroke="currentColor" stroke-width="1.1"344            stroke-linecap="round" stroke-linejoin="round">345            <path d="M6 1L1 6L6 11" />346          </svg>347        </button>348        <span class="peach-overlay-title">peach codex</span>349        <div class="peach-overlay-spacer"></div>350      </div>351352      <div class="peach-settings-body peach-codex-body peach-codex-body--runes">353        <RecoveryRuneBackground />354355        <Show when={!props.isPro}>356          <p class="peach-hint">redirecting…</p>357        </Show>358359        <Show when={props.isPro && loading()}>360          <p class="peach-hint">loading vault…</p>361        </Show>362363        <Show when={props.isPro && !loading() && loadError()}>364          <p class="peach-hint peach-hint-error">{loadError()}</p>365        </Show>366367        <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>368          <div class="peach-settings-group peach-codex-intro">369            <p class="peach-codex-blurb">370              the Codex prints your encrypted vault as Aztec codes. before371              you can generate one, set up your 12 recovery words in372              Settings → Security.373            </p>374          </div>375          <button376            class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"377            onClick={props.onOpenSecurity}378          >379            open Security settings380          </button>381        </Show>382383        <Show384          when={385            props.isPro &&386            !loading() &&387            !loadError() &&388            !missingRecoveryWords() &&389            snapshot()390          }391        >392          <Show when={step() === "words"}>393            <div class="peach-codex-recovery-headline">394              <h2 class="peach-codex-recovery-title">your recovery words.</h2>395              <p class="peach-codex-recovery-body">396                write these down — store separately from your Codex.397              </p>398            </div>399400            <ol class="peach-codex-words-grid" start={1}>401              <For each={snapshot()!.recoveryWords ?? []}>402                {(word, i) => (403                  <li class="peach-codex-word-row">404                    <span class="peach-codex-word-numeral">{i() + 1}</span>405                    <span class="peach-codex-word-text">{word}</span>406                  </li>407                )}408              </For>409            </ol>410411            <Show when={estimatedPages() !== null}>412              <p class="peach-codex-meta">413                <strong>{estimatedPages()}</strong>{" "}414                {estimatedPages() === 1 ? "page" : "pages"} ·{" "}415                {snapshot()!.entryCount}{" "}416                {snapshot()!.entryCount === 1 ? "entry" : "entries"}417                <Show when={estimatedParity() !== null}>418                  {" "}· {estimatedParity()} redundancy codes419                </Show>420              </p>421            </Show>422423            <div class="peach-codex-confirm-section">424              <p class="peach-codex-confirm-label">425                to continue, enter words {confirmPositions().join(", ")}:426              </p>427              <For each={confirmPositions()}>428                {(pos, i) => (429                  <div class="peach-codex-confirm-row">430                    <span class="peach-codex-confirm-pos">word {pos}</span>431                    <input432                      class="peach-codex-confirm-input"433                      type="text"434                      autocomplete="off"435                      autocorrect="off"436                      autocapitalize="none"437                      spellcheck={false}438                      value={confirmInputs()[i()]}439                      onInput={(e) => {440                        const next = [...confirmInputs()] as [441                          string,442                          string,443                          string,444                        ];445                        next[i()] = (e.target as HTMLInputElement).value;446                        setConfirmInputs(next);447                      }}448                      onKeyDown={(e) => {449                        if (e.key === "Enter" && allCorrect())450                          void handleGenerate();451                      }}452                    />453                  </div>454                )}455              </For>456            </div>457458            <Show when={genError()}>459              <p class="peach-hint peach-hint-error peach-codex-error">460                {genError()}461              </p>462            </Show>463            <Show when={libraryError()}>464              <p class="peach-hint peach-hint-error peach-codex-error">465                {libraryError()}466              </p>467            </Show>468            <Show when={libraryLoading() && !libraryReady()}>469              <p class="peach-hint peach-codex-meta">470                preparing Codex library…471              </p>472            </Show>473474            <button475              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"476              disabled={!allCorrect() || libraryLoading()}477              onClick={() => void handleGenerate()}478            >479              {libraryLoading() && !libraryReady() ? "preparing…" : "generate my Codex"}480            </button>481          </Show>482483          <Show when={step() === "generating"}>484            <div class="peach-codex-recovery-headline">485              <h2 class="peach-codex-recovery-title">486                generating your codex…487              </h2>488            </div>489490            <div class="peach-codex-progress-group">491              <p class="peach-codex-progress-label">492                <span>{progressLabel()}</span>493                <span class="peach-codex-verify-tag">494                  {snapshot()?.verifyCode}495                </span>496              </p>497              <div class="peach-codex-progress-track">498                <div499                  class="peach-codex-progress-fill"500                  style={`width:${progressPercent()}%`}501                />502              </div>503            </div>504          </Show>505506          <Show when={step() === "done"}>507            <div class="peach-codex-recovery-headline">508              <h2 class="peach-codex-recovery-title">509                your codex is downloading.510              </h2>511            </div>512513            <Show when={completed()}>514              <p class="peach-codex-meta">515                {completed()!.pageCount}{" "}516                {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}517                {completed()!.totalChunks} codes · verify code{" "}518                <span class="peach-codex-verify">519                  {completed()!.verifyCode}520                </span>521              </p>522            </Show>523524            <div class="peach-codex-done-guidance">525              <div class="peach-codex-done-item">526                <span class="peach-codex-done-item-icon">🖨</span>527                <div>528                  <p class="peach-codex-done-item-title">529                    print on a laser printer530                  </p>531                  <p class="peach-codex-done-item-body">532                    laser prints last longer and scan more reliably than533                    inkjet. print at 100% scale — do not fit to page.534                  </p>535                </div>536              </div>537538              <div class="peach-codex-done-item">539                <span class="peach-codex-done-item-icon">📦</span>540                <div>541                  <p class="peach-codex-done-item-title">make two copies</p>542                  <p class="peach-codex-done-item-body">543                    store one at home (fireproof box or safe) and one544                    somewhere else (trusted person, safety deposit box).545                    fire and flood can destroy a single copy.546                  </p>547                </div>548              </div>549550              <div class="peach-codex-done-item">551                <span class="peach-codex-done-item-icon">🔑</span>552                <div>553                  <p class="peach-codex-done-item-title">554                    store separately from recovery words555                  </p>556                  <p class="peach-codex-done-item-body">557                    your 12 recovery words go somewhere different. together558                    they restore your vault without your passphrase. keep559                    them apart.560                  </p>561                </div>562              </div>563564              <div class="peach-codex-done-item">565                <span class="peach-codex-done-item-icon">💾</span>566                <div>567                  <p class="peach-codex-done-item-title">also save the PDF</p>568                  <p class="peach-codex-done-item-body">569                    copy the PDF to a USB drive. Peach can import it570                    directly without needing to scan printed codes.571                  </p>572                </div>573              </div>574            </div>575576            <p class="peach-codex-done-regenerate">577              regenerate your codex whenever you add many new entries.578            </p>579580            <button581              class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"582              onClick={() => {583                setCompleted(null);584                setGenError(null);585                setLibraryError(null);586                setConfirmInputs(["", "", ""]);587                setConfirmPositions(pickConfirmationPositions());588                setStep("words");589              }}590            >591              generate another592            </button>593          </Show>594        </Show>595      </div>596    </div>597  );598}