CodexGenerateView.tsx

21.8 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      <div371        class="peach-settings-body peach-codex-body peach-codex-body--runes"372        classList={{ "peach-codex-body--done": step() === "done" }}373      >374        <RecoveryRuneBackground />375376        <Show when={!props.isPro}>377          <p class="peach-hint">redirecting…</p>378        </Show>379380        <Show when={props.isPro && loading()}>381          <p class="peach-hint">loading vault…</p>382        </Show>383384        <Show when={props.isPro && !loading() && loadError()}>385          <p class="peach-hint peach-hint-error">{loadError()}</p>386        </Show>387388        <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>389          <div class="peach-settings-group peach-codex-intro">390            <p class="peach-codex-blurb">391              the Codex prints your encrypted vault as Aztec codes. before392              you can generate one, set up your 12 recovery words in393              Settings → Security.394            </p>395          </div>396          <button397            class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"398            onClick={props.onOpenSecurity}399          >400            open Security settings401          </button>402        </Show>403404        <Show405          when={406            props.isPro &&407            !loading() &&408            !loadError() &&409            !missingRecoveryWords() &&410            snapshot()411          }412        >413          <Show when={step() === "words"}>414            <div class="peach-codex-recovery-headline">415              <h2 class="peach-codex-recovery-title">your recovery words.</h2>416              <p class="peach-codex-recovery-body">417                write these down — store separately from your Codex.418              </p>419            </div>420421            <ol class="peach-codex-words-grid" start={1}>422              <For each={snapshot()!.recoveryWords ?? []}>423                {(word, i) => (424                  <li class="peach-codex-word-row">425                    <span class="peach-codex-word-numeral">{i() + 1}</span>426                    <span class="peach-codex-word-text">{word}</span>427                  </li>428                )}429              </For>430            </ol>431432            <Show when={estimatedPages() !== null}>433              <p class="peach-codex-meta">434                <strong>{estimatedPages()}</strong>{" "}435                {estimatedPages() === 1 ? "page" : "pages"} ·{" "}436                {snapshot()!.entryCount}{" "}437                {snapshot()!.entryCount === 1 ? "entry" : "entries"}438                <Show when={estimatedParity() !== null}>439                  {" "}· {estimatedParity()} redundancy codes440                </Show>441              </p>442            </Show>443444            <div class="peach-codex-confirm-section">445              <p class="peach-codex-confirm-label">446                to continue, enter words {confirmPositions().join(", ")}:447              </p>448              <For each={confirmPositions()}>449                {(pos, i) => (450                  <div class="peach-codex-confirm-row">451                    <span class="peach-codex-confirm-pos">word {pos}</span>452                    <input453                      class="peach-codex-confirm-input"454                      type="text"455                      autocomplete="off"456                      autocorrect="off"457                      autocapitalize="none"458                      spellcheck={false}459                      value={confirmInputs()[i()]}460                      onInput={(e) => {461                        const next = [...confirmInputs()] as [462                          string,463                          string,464                          string,465                        ];466                        next[i()] = (e.target as HTMLInputElement).value;467                        setConfirmInputs(next);468                      }}469                      onKeyDown={(e) => {470                        if (e.key === "Enter" && allCorrect())471                          void handleGenerate();472                      }}473                    />474                  </div>475                )}476              </For>477            </div>478479            <Show when={genError()}>480              <p class="peach-hint peach-hint-error peach-codex-error">481                {genError()}482              </p>483            </Show>484            <Show when={libraryError()}>485              <p class="peach-hint peach-hint-error peach-codex-error">486                {libraryError()}487              </p>488            </Show>489            <Show when={libraryLoading() && !libraryReady()}>490              <p class="peach-hint peach-codex-meta">491                preparing Codex library…492              </p>493            </Show>494495            <button496              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"497              disabled={!allCorrect() || libraryLoading()}498              onClick={() => void handleGenerate()}499            >500              {libraryLoading() && !libraryReady() ? "preparing…" : "generate my Codex"}501            </button>502          </Show>503504          <Show when={step() === "generating"}>505            <div class="peach-codex-recovery-headline">506              <h2 class="peach-codex-recovery-title">507                generating your codex…508              </h2>509            </div>510511            <div class="peach-codex-progress-group">512              <p class="peach-codex-progress-label">513                <span>{progressLabel()}</span>514                <span class="peach-codex-verify-tag">515                  {snapshot()?.verifyCode}516                </span>517              </p>518              <div class="peach-codex-progress-track">519                <div520                  class="peach-codex-progress-fill"521                  style={`width:${progressPercent()}%`}522                />523              </div>524            </div>525526            <Show when={aztecGallery().length > 0}>527              <div class="peach-codex-aztec-gallery">528                <For each={aztecGallery()}>529                  {(url) => (530                    <img531                      class="peach-codex-aztec-tile"532                      src={url}533                      alt=""534                      aria-hidden="true"535                    />536                  )}537                </For>538              </div>539            </Show>540          </Show>541542          <Show when={step() === "done"}>543            <div class="peach-codex-recovery-headline">544              <h2 class="peach-codex-recovery-title">545                your codex is downloading.546              </h2>547            </div>548549            <Show when={completed()}>550              <p class="peach-codex-meta">551                {completed()!.pageCount}{" "}552                {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}553                {completed()!.totalChunks} codes · verify code{" "}554                <span class="peach-codex-verify">555                  {completed()!.verifyCode}556                </span>557              </p>558            </Show>559560            <div class="peach-codex-done-guidance">561              <div class="peach-codex-done-item">562                <div>563                  <p class="peach-codex-done-item-title">564                    print on a laser printer565                  </p>566                  <p class="peach-codex-done-item-body">567                    laser prints last longer and scan more reliably than568                    inkjet. print at 100% scale — do not fit to page.569                  </p>570                </div>571              </div>572573              <div class="peach-codex-done-item">574                <div>575                  <p class="peach-codex-done-item-title">make two copies</p>576                  <p class="peach-codex-done-item-body">577                    store one at home (fireproof box or safe) and one578                    somewhere else (trusted person, safety deposit box).579                    fire and flood can destroy a single copy.580                  </p>581                </div>582              </div>583584              <div class="peach-codex-done-item">585                <div>586                  <p class="peach-codex-done-item-title">587                    store separately from recovery words588                  </p>589                  <p class="peach-codex-done-item-body">590                    your 12 recovery words go somewhere different. together591                    they restore your vault without your passphrase. keep592                    them apart.593                  </p>594                </div>595              </div>596597              <div class="peach-codex-done-item">598                <div>599                  <p class="peach-codex-done-item-title">also save the PDF</p>600                  <p class="peach-codex-done-item-body">601                    copy the PDF to a USB drive. Peach can import it602                    directly without needing to scan printed codes.603                  </p>604                </div>605              </div>606            </div>607608            <p class="peach-codex-done-regenerate">609              regenerate your codex whenever you add many new entries.610            </p>611612            <button613              class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"614              onClick={() => {615                setCompleted(null);616                setGenError(null);617                setLibraryError(null);618                setConfirmInputs(["", "", ""]);619                setConfirmPositions(pickConfirmationPositions());620                setStep("words");621              }}622            >623              generate another624            </button>625          </Show>626        </Show>627      </div>628    </div>629  );630}