CodexGenerateView.tsx

21.7 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 { 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);76  // Live gallery of real Aztec PNGs as they're generated — keep the last 3677  // (one page worth) so the display fills without using excessive memory.78  const [aztecGallery, setAztecGallery] = createSignal<string[]>([]);79  const GALLERY_MAX = 36;8081  onCleanup(() => {82    for (const url of aztecGallery()) URL.revokeObjectURL(url);83  });8485  onMount(async () => {86    if (!props.isPro) {87      props.onUpgradePro();88      return;89    }9091    const res = (await sendMessage({92      type: "GET_CODEX_VAULT_DATA",93    })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };9495    if (!res.ok || !res.data) {96      // Service worker restarted (MV3) and the vault re-locked in memory.97      // Route back to the lock screen exactly like the rest of the app does.98      if (res.error?.startsWith("VAULT_LOCKED")) {99        props.onVaultLocked();100        return;101      }102      setLoadError(res.error ?? "could not load vault data");103      setLoading(false);104      return;105    }106107    setSnapshot(res.data);108109    const words = res.data.recoveryWords;110    if (!words || words.length !== 12) {111      setMissingRecoveryWords(true);112      setLoading(false);113      return;114    }115116    setConfirmPositions(pickConfirmationPositions());117    setLoading(false);118    void warmCodexLibrary(res.data);119  });120121  const allCorrect = createMemo(() => {122    const snap = snapshot();123    const words = snap?.recoveryWords;124    if (!words || words.length !== 12) return false;125    const positions = confirmPositions();126    const inputs = confirmInputs();127    return positions.every(128      (pos, i) =>129        inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),130    );131  });132133  function buildDeps(snap: CodexVaultSnapshot): CodexDeps {134    return {135      getSlotA: async () => snap.slotA,136      getSlotB: async () => snap.slotB,137      getLastModified: async () => snap.lastModified,138      // `getAllEntries` is consumed only as a count filter inside the139      // generator — synthesise placeholders so real entries don't cross140      // the popup boundary.141      getAllEntries: async () =>142        Array.from(143          { length: snap.entryCount },144          () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,145        ),146      getVerifyCode: async () => snap.verifyCode,147      isVaultUnlocked: () => snap.vaultUnlocked,148    };149  }150151  async function warmCodexLibrary(152    snap?: CodexVaultSnapshot,153  ): Promise<CodexGenerateModule | null> {154    if (libraryReady()) return loadCodexGenerateModule();155156    setLibraryLoading(true);157    setLibraryError(null);158    try {159      const [generateModule, rsModule] = await Promise.all([160        loadCodexGenerateModule(),161        import("../codex/rs-loader"),162      ]);163      await rsModule.getReedSolomon();164      setLibraryReady(true);165166      if (snap?.slotA) {167        try {168          const { pages, parityShards } = await generateModule.estimateCodexPages(169            snap.slotA,170            snap.slotB,171          );172          setEstimatedPages(pages);173          setEstimatedParity(parityShards);174        } catch {175          // Estimation is informational; failure is non-fatal.176          setEstimatedPages(null);177          setEstimatedParity(null);178        }179      }180181      return generateModule;182    } catch (err) {183      codexGenerateModulePromise = null;184      setLibraryError(185        `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`,186      );187      return null;188    } finally {189      setLibraryLoading(false);190    }191  }192193  function codexFilename(): string {194    const d = new Date();195    const y = d.getFullYear();196    const m = String(d.getMonth() + 1).padStart(2, "0");197    const day = String(d.getDate()).padStart(2, "0");198    return `peach-codex-${y}-${m}-${day}.pdf`;199  }200201  function downloadPdf(bytes: Uint8Array): void {202    const a = document.createElement("a");203    a.download = codexFilename();204    if (isNativePlatform()) {205      // Capacitor WebView: blob: URLs are opaque to native, but data: URLs206      // are intercepted by MainActivity and written to public Downloads via207      // MediaStore. Encode in chunks to avoid blowing the call stack on208      // large PDFs.209      let bin = "";210      const chunk = 0x8000;211      for (let i = 0; i < bytes.length; i += chunk) {212        const slice = bytes.subarray(i, Math.min(i + chunk, bytes.length));213        bin += String.fromCharCode(...slice);214      }215      a.href = `data:application/pdf;base64,${btoa(bin)}`;216    } else {217      // Browser: copy into a fresh Uint8Array to ensure a tightly-owned218      // ArrayBuffer (Blob is happier with this than a subarray view).219      const owned = new Uint8Array(bytes);220      const blob = new Blob([owned.buffer], { type: "application/pdf" });221      a.href = URL.createObjectURL(blob);222      setTimeout(() => URL.revokeObjectURL(a.href), 4000);223    }224    document.body.appendChild(a);225    a.click();226    a.remove();227  }228229  function explainCodexError(code: string, message: string): string {230    switch (code) {231      case "VAULT_LOCKED":232        return "your vault is locked. unlock and try again.";233      case "VAULT_EMPTY":234        return "your vault is empty — nothing to print yet.";235      case "PRO_REQUIRED":236        return "Codex generation is a Pro feature.";237      case "PDF_GENERATION_FAILED":238      case "AZTEC_GENERATION_FAILED":239        return "could not render the Codex. please try again.";240      case "COMPRESSION_ROUNDTRIP_FAILED":241      case "ERASURE_CODING_VERIFICATION_FAILED":242      case "ERASURE_CODING_CONTENT_MISMATCH":243      case "AZTEC_ROUNDTRIP_FAILED":244      case "AZTEC_CONTENT_MISMATCH":245      case "RECONSTRUCTION_SIMULATION_FAILED":246      case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":247      case "RECONSTRUCTION_FINGERPRINT_MISMATCH":248      case "RECONSTRUCTION_COUNT_MISMATCH":249        return `a self-check failed while building the Codex (${code}). nothing was saved. please try again.`;250      default:251        return message || "Codex generation failed.";252    }253  }254255  const onProgress: CodexProgress = (s, current, total, imageUrl) => {256    setProgressStep(s);257    setProgressCurrent(current ?? 0);258    setProgressTotal(total ?? 0);259    if (s === "aztec" && imageUrl) {260      setAztecGallery(prev => {261        if (prev.length >= GALLERY_MAX) {262          URL.revokeObjectURL(prev[0]!);263          return [...prev.slice(1), imageUrl];264        }265        return [...prev, imageUrl];266      });267    }268  };269270  async function handleGenerate() {271    const snap = snapshot();272    if (!snap || !allCorrect() || step() === "generating") return;273274    setGenError(null);275    setAztecGallery([]);276    setStep("generating");277    setProgressStep("compress");278279    try {280      const generateModule = await warmCodexLibrary(snap);281      if (!generateModule) {282        setStep("words");283        return;284      }285      const result = await generateModule.generateCodex(buildDeps(snap), onProgress);286      downloadPdf(result.pdfBytes);287      setCompleted(result.manifest);288      setStep("done");289290      try {291        await chrome.storage.local?.set({292          [STORAGE_KEY_LAST_GENERATED]: Date.now(),293        });294      } catch {295        // Bookkeeping; failure is non-fatal.296      }297    } catch (err) {298      const codexErr = err as CodexError;299      const code = (codexErr && (codexErr as { code?: string }).code) ?? "";300      const message =301        err instanceof Error ? err.message : "Codex generation failed.";302      console.error("[Codex] generation failed:", code, message);303      setGenError(explainCodexError(code, message));304      setStep("words");305    }306  }307308  function progressLabel(): string {309    switch (progressStep()) {310      case "compress":311        return "compressing vault";312      case "erasure":313        return "building chunks";314      case "aztec": {315        const total = progressTotal();316        const current = progressCurrent();317        if (total > 0) return `generating codes (${current} of ${total})`;318        return "generating codes";319      }320      case "verify":321        return "self-checking";322      case "pdf":323        return "creating PDF";324      default:325        return "preparing";326    }327  }328329  function progressPercent(): number {330    const total = progressTotal();331    const current = progressCurrent();332    // Map step to a coarse progress band so the bar advances monotonically333    // even when intra-step progress (aztec N of M) is fine-grained.334    switch (progressStep()) {335      case "compress":336        return 8;337      case "erasure":338        return 18;339      case "aztec":340        if (total <= 0) return 30;341        return 30 + Math.floor((current / total) * 55); // 30 → 85342      case "verify":343        return 90;344      case "pdf":345        return 96;346      default:347        return 4;348    }349  }350351  return (352    <div class="peach-overlay-view ov-enter-right" style="display:flex">353      <div class="peach-overlay-header">354        <button355          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"356          onClick={props.onBack}357          aria-label="Back"358          disabled={step() === "generating"}359        >360          <svg width="7" height="12" viewBox="0 0 7 12" fill="none"361            stroke="currentColor" stroke-width="1.1"362            stroke-linecap="round" stroke-linejoin="round">363            <path d="M6 1L1 6L6 11" />364          </svg>365        </button>366        <span class="peach-overlay-title">peach codex</span>367        <div class="peach-overlay-spacer"></div>368      </div>369370      <div class="peach-settings-body peach-codex-body peach-codex-body--runes">371        <RecoveryRuneBackground />372373        <Show when={!props.isPro}>374          <p class="peach-hint">redirecting…</p>375        </Show>376377        <Show when={props.isPro && loading()}>378          <p class="peach-hint">loading vault…</p>379        </Show>380381        <Show when={props.isPro && !loading() && loadError()}>382          <p class="peach-hint peach-hint-error">{loadError()}</p>383        </Show>384385        <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>386          <div class="peach-settings-group peach-codex-intro">387            <p class="peach-codex-blurb">388              the Codex prints your encrypted vault as Aztec codes. before389              you can generate one, set up your 12 recovery words in390              Settings → Security.391            </p>392          </div>393          <button394            class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"395            onClick={props.onOpenSecurity}396          >397            open Security settings398          </button>399        </Show>400401        <Show402          when={403            props.isPro &&404            !loading() &&405            !loadError() &&406            !missingRecoveryWords() &&407            snapshot()408          }409        >410          <Show when={step() === "words"}>411            <div class="peach-codex-recovery-headline">412              <h2 class="peach-codex-recovery-title">your recovery words.</h2>413              <p class="peach-codex-recovery-body">414                write these down — store separately from your Codex.415              </p>416            </div>417418            <ol class="peach-codex-words-grid" start={1}>419              <For each={snapshot()!.recoveryWords ?? []}>420                {(word, i) => (421                  <li class="peach-codex-word-row">422                    <span class="peach-codex-word-numeral">{i() + 1}</span>423                    <span class="peach-codex-word-text">{word}</span>424                  </li>425                )}426              </For>427            </ol>428429            <Show when={estimatedPages() !== null}>430              <p class="peach-codex-meta">431                <strong>{estimatedPages()}</strong>{" "}432                {estimatedPages() === 1 ? "page" : "pages"} ·{" "}433                {snapshot()!.entryCount}{" "}434                {snapshot()!.entryCount === 1 ? "entry" : "entries"}435                <Show when={estimatedParity() !== null}>436                  {" "}· {estimatedParity()} redundancy codes437                </Show>438              </p>439            </Show>440441            <div class="peach-codex-confirm-section">442              <p class="peach-codex-confirm-label">443                to continue, enter words {confirmPositions().join(", ")}:444              </p>445              <For each={confirmPositions()}>446                {(pos, i) => (447                  <div class="peach-codex-confirm-row">448                    <span class="peach-codex-confirm-pos">word {pos}</span>449                    <input450                      class="peach-codex-confirm-input"451                      type="text"452                      autocomplete="off"453                      autocorrect="off"454                      autocapitalize="none"455                      spellcheck={false}456                      value={confirmInputs()[i()]}457                      onInput={(e) => {458                        const next = [...confirmInputs()] as [459                          string,460                          string,461                          string,462                        ];463                        next[i()] = (e.target as HTMLInputElement).value;464                        setConfirmInputs(next);465                      }}466                      onKeyDown={(e) => {467                        if (e.key === "Enter" && allCorrect())468                          void handleGenerate();469                      }}470                    />471                  </div>472                )}473              </For>474            </div>475476            <Show when={genError()}>477              <p class="peach-hint peach-hint-error peach-codex-error">478                {genError()}479              </p>480            </Show>481            <Show when={libraryError()}>482              <p class="peach-hint peach-hint-error peach-codex-error">483                {libraryError()}484              </p>485            </Show>486            <Show when={libraryLoading() && !libraryReady()}>487              <p class="peach-hint peach-codex-meta">488                preparing Codex library…489              </p>490            </Show>491492            <button493              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"494              disabled={!allCorrect() || libraryLoading()}495              onClick={() => void handleGenerate()}496            >497              {libraryLoading() && !libraryReady() ? "preparing…" : "generate my Codex"}498            </button>499          </Show>500501          <Show when={step() === "generating"}>502            <div class="peach-codex-recovery-headline">503              <h2 class="peach-codex-recovery-title">504                generating your codex…505              </h2>506            </div>507508            <div class="peach-codex-progress-group">509              <p class="peach-codex-progress-label">510                <span>{progressLabel()}</span>511                <span class="peach-codex-verify-tag">512                  {snapshot()?.verifyCode}513                </span>514              </p>515              <div class="peach-codex-progress-track">516                <div517                  class="peach-codex-progress-fill"518                  style={`width:${progressPercent()}%`}519                />520              </div>521            </div>522523            <Show when={aztecGallery().length > 0}>524              <div class="peach-codex-aztec-gallery">525                <For each={aztecGallery()}>526                  {(url) => (527                    <img528                      class="peach-codex-aztec-tile"529                      src={url}530                      alt=""531                      aria-hidden="true"532                    />533                  )}534                </For>535              </div>536            </Show>537          </Show>538539          <Show when={step() === "done"}>540            <div class="peach-codex-recovery-headline">541              <h2 class="peach-codex-recovery-title">542                your codex is downloading.543              </h2>544            </div>545546            <Show when={completed()}>547              <p class="peach-codex-meta">548                {completed()!.pageCount}{" "}549                {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}550                {completed()!.totalChunks} codes · verify code{" "}551                <span class="peach-codex-verify">552                  {completed()!.verifyCode}553                </span>554              </p>555            </Show>556557            <div class="peach-codex-done-guidance">558              <div class="peach-codex-done-item">559                <div>560                  <p class="peach-codex-done-item-title">561                    print on a laser printer562                  </p>563                  <p class="peach-codex-done-item-body">564                    laser prints last longer and scan more reliably than565                    inkjet. print at 100% scale — do not fit to page.566                  </p>567                </div>568              </div>569570              <div class="peach-codex-done-item">571                <div>572                  <p class="peach-codex-done-item-title">make two copies</p>573                  <p class="peach-codex-done-item-body">574                    store one at home (fireproof box or safe) and one575                    somewhere else (trusted person, safety deposit box).576                    fire and flood can destroy a single copy.577                  </p>578                </div>579              </div>580581              <div class="peach-codex-done-item">582                <div>583                  <p class="peach-codex-done-item-title">584                    store separately from recovery words585                  </p>586                  <p class="peach-codex-done-item-body">587                    your 12 recovery words go somewhere different. together588                    they restore your vault without your passphrase. keep589                    them apart.590                  </p>591                </div>592              </div>593594              <div class="peach-codex-done-item">595                <div>596                  <p class="peach-codex-done-item-title">also save the PDF</p>597                  <p class="peach-codex-done-item-body">598                    copy the PDF to a USB drive. Peach can import it599                    directly without needing to scan printed codes.600                  </p>601                </div>602              </div>603            </div>604605            <p class="peach-codex-done-regenerate">606              regenerate your codex whenever you add many new entries.607            </p>608609            <button610              class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"611              onClick={() => {612                setCompleted(null);613                setGenError(null);614                setLibraryError(null);615                setConfirmInputs(["", "", ""]);616                setConfirmPositions(pickConfirmationPositions());617                setStep("words");618              }}619            >620              generate another621            </button>622          </Show>623        </Show>624      </div>625    </div>626  );627}