CodexGenerateView.tsx

19.4 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 {30  estimateCodexPages,31  generateCodex,32  pickConfirmationPositions,33} from "../codex/generate";34import { getReedSolomon } from "../codex/rs-loader";35import type { VaultEntry } from "../../shared/types";3637interface CodexGenerateViewProps {38  isPro: boolean;39  onBack: () => void;40  onUpgradePro: () => void;41  onOpenSecurity: () => void;42  onVaultLocked: () => void;43}4445type Step = "words" | "generating" | "done";464748export function CodexGenerateView(props: CodexGenerateViewProps) {49  const [snapshot, setSnapshot] = createSignal<CodexVaultSnapshot | null>(null);50  const [estimatedPages, setEstimatedPages] = createSignal<number | null>(null);51  const [estimatedParity, setEstimatedParity] = createSignal<number | null>(null);52  const [loading, setLoading] = createSignal(true);53  const [loadError, setLoadError] = createSignal<string | null>(null);54  const [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false);5556  const [step, setStep] = createSignal<Step>("words");57  const [confirmPositions, setConfirmPositions] = createSignal<58    [number, number, number]59  >([3, 7, 11]);60  const [confirmInputs, setConfirmInputs] = createSignal<61    [string, string, string]62  >(["", "", ""]);6364  const [progressStep, setProgressStep] = createSignal<string>("");65  const [progressCurrent, setProgressCurrent] = createSignal<number>(0);66  const [progressTotal, setProgressTotal] = createSignal<number>(0);6768  const [genError, setGenError] = createSignal<string | null>(null);69  const [completed, setCompleted] = createSignal<CodexManifest | null>(null);7071  onMount(async () => {72    if (!props.isPro) {73      props.onUpgradePro();74      return;75    }7677    const res = (await sendMessage({78      type: "GET_CODEX_VAULT_DATA",79    })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };8081    if (!res.ok || !res.data) {82      // Service worker restarted (MV3) and the vault re-locked in memory.83      // Route back to the lock screen exactly like the rest of the app does.84      if (res.error?.startsWith("VAULT_LOCKED")) {85        props.onVaultLocked();86        return;87      }88      setLoadError(res.error ?? "could not load vault data");89      setLoading(false);90      return;91    }9293    setSnapshot(res.data);9495    const words = res.data.recoveryWords;96    if (!words || words.length !== 12) {97      setMissingRecoveryWords(true);98      setLoading(false);99      return;100    }101102    setConfirmPositions(pickConfirmationPositions());103104    // Pre-warm the Reed-Solomon WASM so load failures surface before the105    // user completes the word-confirmation flow.106    try {107      await getReedSolomon();108    } catch (err) {109      setLoadError(110        `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`111      );112      setLoading(false);113      return;114    }115116    if (res.data.slotA) {117      try {118        const { pages, parityShards } = await estimateCodexPages(119          res.data.slotA,120          res.data.slotB,121        );122        setEstimatedPages(pages);123        setEstimatedParity(parityShards);124      } catch {125        // Estimation is informational; failure is non-fatal.126        setEstimatedPages(null);127        setEstimatedParity(null);128      }129    }130131    setLoading(false);132  });133134  const allCorrect = createMemo(() => {135    const snap = snapshot();136    const words = snap?.recoveryWords;137    if (!words || words.length !== 12) return false;138    const positions = confirmPositions();139    const inputs = confirmInputs();140    return positions.every(141      (pos, i) =>142        inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),143    );144  });145146  function buildDeps(snap: CodexVaultSnapshot): CodexDeps {147    return {148      getSlotA: async () => snap.slotA,149      getSlotB: async () => snap.slotB,150      getLastModified: async () => snap.lastModified,151      // `getAllEntries` is consumed only as a count filter inside the152      // generator — synthesise placeholders so real entries don't cross153      // the popup boundary.154      getAllEntries: async () =>155        Array.from(156          { length: snap.entryCount },157          () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,158        ),159      getVerifyCode: async () => snap.verifyCode,160      isVaultUnlocked: () => snap.vaultUnlocked,161    };162  }163164  function codexFilename(): string {165    const d = new Date();166    const y = d.getFullYear();167    const m = String(d.getMonth() + 1).padStart(2, "0");168    const day = String(d.getDate()).padStart(2, "0");169    return `peach-codex-${y}-${m}-${day}.pdf`;170  }171172  function downloadPdf(bytes: Uint8Array): void {173    const a = document.createElement("a");174    a.download = codexFilename();175    if (isNativePlatform()) {176      // Capacitor WebView: blob: URLs are opaque to native, but data: URLs177      // are intercepted by MainActivity and written to public Downloads via178      // MediaStore. Encode in chunks to avoid blowing the call stack on179      // large PDFs.180      let bin = "";181      const chunk = 0x8000;182      for (let i = 0; i < bytes.length; i += chunk) {183        const slice = bytes.subarray(i, Math.min(i + chunk, bytes.length));184        bin += String.fromCharCode(...slice);185      }186      a.href = `data:application/pdf;base64,${btoa(bin)}`;187    } else {188      // Browser: copy into a fresh Uint8Array to ensure a tightly-owned189      // ArrayBuffer (Blob is happier with this than a subarray view).190      const owned = new Uint8Array(bytes);191      const blob = new Blob([owned.buffer], { type: "application/pdf" });192      a.href = URL.createObjectURL(blob);193      setTimeout(() => URL.revokeObjectURL(a.href), 4000);194    }195    document.body.appendChild(a);196    a.click();197    a.remove();198  }199200  function explainCodexError(code: string, message: string): string {201    switch (code) {202      case "VAULT_LOCKED":203        return "your vault is locked. unlock and try again.";204      case "VAULT_EMPTY":205        return "your vault is empty — nothing to print yet.";206      case "PRO_REQUIRED":207        return "Codex generation is a Pro feature.";208      case "PDF_GENERATION_FAILED":209      case "AZTEC_GENERATION_FAILED":210        return "could not render the Codex. please try again.";211      case "COMPRESSION_ROUNDTRIP_FAILED":212      case "ERASURE_CODING_VERIFICATION_FAILED":213      case "ERASURE_CODING_CONTENT_MISMATCH":214      case "AZTEC_ROUNDTRIP_FAILED":215      case "AZTEC_CONTENT_MISMATCH":216      case "RECONSTRUCTION_SIMULATION_FAILED":217      case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":218      case "RECONSTRUCTION_FINGERPRINT_MISMATCH":219      case "RECONSTRUCTION_COUNT_MISMATCH":220        return `a self-check failed while building the Codex (${code}). nothing was saved. please try again.`;221      default:222        return message || "Codex generation failed.";223    }224  }225226  const onProgress: CodexProgress = (s, current, total) => {227    setProgressStep(s);228    setProgressCurrent(current ?? 0);229    setProgressTotal(total ?? 0);230  };231232  async function handleGenerate() {233    const snap = snapshot();234    if (!snap || !allCorrect() || step() === "generating") return;235236    setGenError(null);237    setStep("generating");238    setProgressStep("compress");239240    try {241      const result = await generateCodex(buildDeps(snap), onProgress);242      downloadPdf(result.pdfBytes);243      setCompleted(result.manifest);244      setStep("done");245246      try {247        await chrome.storage.local?.set({248          [STORAGE_KEY_LAST_GENERATED]: Date.now(),249        });250      } catch {251        // Bookkeeping; failure is non-fatal.252      }253    } catch (err) {254      const codexErr = err as CodexError;255      const code = (codexErr && (codexErr as { code?: string }).code) ?? "";256      const message =257        err instanceof Error ? err.message : "Codex generation failed.";258      console.error("[Codex] generation failed:", code, message);259      setGenError(explainCodexError(code, message));260      setStep("words");261    }262  }263264  function progressLabel(): string {265    switch (progressStep()) {266      case "compress":267        return "compressing vault";268      case "erasure":269        return "building chunks";270      case "aztec": {271        const total = progressTotal();272        const current = progressCurrent();273        if (total > 0) return `generating codes (${current} of ${total})`;274        return "generating codes";275      }276      case "verify":277        return "self-checking";278      case "pdf":279        return "creating PDF";280      default:281        return "preparing";282    }283  }284285  function progressPercent(): number {286    const total = progressTotal();287    const current = progressCurrent();288    // Map step to a coarse progress band so the bar advances monotonically289    // even when intra-step progress (aztec N of M) is fine-grained.290    switch (progressStep()) {291      case "compress":292        return 8;293      case "erasure":294        return 18;295      case "aztec":296        if (total <= 0) return 30;297        return 30 + Math.floor((current / total) * 55); // 30 → 85298      case "verify":299        return 90;300      case "pdf":301        return 96;302      default:303        return 4;304    }305  }306307  return (308    <div class="peach-overlay-view ov-enter-right" style="display:flex">309      <div class="peach-overlay-header">310        <button311          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"312          onClick={props.onBack}313          aria-label="Back"314          disabled={step() === "generating"}315        >316          <svg width="7" height="12" viewBox="0 0 7 12" fill="none"317            stroke="currentColor" stroke-width="1.1"318            stroke-linecap="round" stroke-linejoin="round">319            <path d="M6 1L1 6L6 11" />320          </svg>321        </button>322        <span class="peach-overlay-title">peach codex</span>323        <div class="peach-overlay-spacer"></div>324      </div>325326      <div class="peach-settings-body peach-codex-body peach-codex-body--runes">327        <RecoveryRuneBackground />328329        <Show when={!props.isPro}>330          <p class="peach-hint">redirecting…</p>331        </Show>332333        <Show when={props.isPro && loading()}>334          <p class="peach-hint">loading vault…</p>335        </Show>336337        <Show when={props.isPro && !loading() && loadError()}>338          <p class="peach-hint peach-hint-error">{loadError()}</p>339        </Show>340341        <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>342          <div class="peach-settings-group peach-codex-intro">343            <p class="peach-codex-blurb">344              the Codex prints your encrypted vault as Aztec codes. before345              you can generate one, set up your 12 recovery words in346              Settings → Security.347            </p>348          </div>349          <button350            class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"351            onClick={props.onOpenSecurity}352          >353            open Security settings354          </button>355        </Show>356357        <Show358          when={359            props.isPro &&360            !loading() &&361            !loadError() &&362            !missingRecoveryWords() &&363            snapshot()364          }365        >366          <Show when={step() === "words"}>367            <div class="peach-codex-recovery-headline">368              <h2 class="peach-codex-recovery-title">your recovery words.</h2>369              <p class="peach-codex-recovery-body">370                write these down — store separately from your Codex.371              </p>372            </div>373374            <ol class="peach-codex-words-grid" start={1}>375              <For each={snapshot()!.recoveryWords ?? []}>376                {(word, i) => (377                  <li class="peach-codex-word-row">378                    <span class="peach-codex-word-numeral">{i() + 1}</span>379                    <span class="peach-codex-word-text">{word}</span>380                  </li>381                )}382              </For>383            </ol>384385            <Show when={estimatedPages() !== null}>386              <p class="peach-codex-meta">387                <strong>{estimatedPages()}</strong>{" "}388                {estimatedPages() === 1 ? "page" : "pages"} ·{" "}389                {snapshot()!.entryCount}{" "}390                {snapshot()!.entryCount === 1 ? "entry" : "entries"}391                <Show when={estimatedParity() !== null}>392                  {" "}· {estimatedParity()} redundancy codes393                </Show>394              </p>395            </Show>396397            <div class="peach-codex-confirm-section">398              <p class="peach-codex-confirm-label">399                to continue, enter words {confirmPositions().join(", ")}:400              </p>401              <For each={confirmPositions()}>402                {(pos, i) => (403                  <div class="peach-codex-confirm-row">404                    <span class="peach-codex-confirm-pos">word {pos}</span>405                    <input406                      class="peach-codex-confirm-input"407                      type="text"408                      autocomplete="off"409                      autocorrect="off"410                      autocapitalize="none"411                      spellcheck={false}412                      value={confirmInputs()[i()]}413                      onInput={(e) => {414                        const next = [...confirmInputs()] as [415                          string,416                          string,417                          string,418                        ];419                        next[i()] = (e.target as HTMLInputElement).value;420                        setConfirmInputs(next);421                      }}422                      onKeyDown={(e) => {423                        if (e.key === "Enter" && allCorrect())424                          void handleGenerate();425                      }}426                    />427                  </div>428                )}429              </For>430            </div>431432            <Show when={genError()}>433              <p class="peach-hint peach-hint-error peach-codex-error">434                {genError()}435              </p>436            </Show>437438            <button439              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"440              disabled={!allCorrect()}441              onClick={() => void handleGenerate()}442            >443              generate my Codex444            </button>445          </Show>446447          <Show when={step() === "generating"}>448            <div class="peach-codex-recovery-headline">449              <h2 class="peach-codex-recovery-title">450                generating your codex…451              </h2>452            </div>453454            <div class="peach-codex-progress-group">455              <p class="peach-codex-progress-label">456                <span>{progressLabel()}</span>457                <span class="peach-codex-verify-tag">458                  {snapshot()?.verifyCode}459                </span>460              </p>461              <div class="peach-codex-progress-track">462                <div463                  class="peach-codex-progress-fill"464                  style={`width:${progressPercent()}%`}465                />466              </div>467            </div>468          </Show>469470          <Show when={step() === "done"}>471            <div class="peach-codex-recovery-headline">472              <h2 class="peach-codex-recovery-title">473                your codex is downloading.474              </h2>475            </div>476477            <Show when={completed()}>478              <p class="peach-codex-meta">479                {completed()!.pageCount}{" "}480                {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}481                {completed()!.totalChunks} codes · verify code{" "}482                <span class="peach-codex-verify">483                  {completed()!.verifyCode}484                </span>485              </p>486            </Show>487488            <div class="peach-codex-done-guidance">489              <div class="peach-codex-done-item">490                <span class="peach-codex-done-item-icon">🖨</span>491                <div>492                  <p class="peach-codex-done-item-title">493                    print on a laser printer494                  </p>495                  <p class="peach-codex-done-item-body">496                    laser prints last longer and scan more reliably than497                    inkjet. print at 100% scale — do not fit to page.498                  </p>499                </div>500              </div>501502              <div class="peach-codex-done-item">503                <span class="peach-codex-done-item-icon">📦</span>504                <div>505                  <p class="peach-codex-done-item-title">make two copies</p>506                  <p class="peach-codex-done-item-body">507                    store one at home (fireproof box or safe) and one508                    somewhere else (trusted person, safety deposit box).509                    fire and flood can destroy a single copy.510                  </p>511                </div>512              </div>513514              <div class="peach-codex-done-item">515                <span class="peach-codex-done-item-icon">🔑</span>516                <div>517                  <p class="peach-codex-done-item-title">518                    store separately from recovery words519                  </p>520                  <p class="peach-codex-done-item-body">521                    your 12 recovery words go somewhere different. together522                    they restore your vault without your passphrase. keep523                    them apart.524                  </p>525                </div>526              </div>527528              <div class="peach-codex-done-item">529                <span class="peach-codex-done-item-icon">💾</span>530                <div>531                  <p class="peach-codex-done-item-title">also save the PDF</p>532                  <p class="peach-codex-done-item-body">533                    copy the PDF to a USB drive. Peach can import it534                    directly without needing to scan printed codes.535                  </p>536                </div>537              </div>538            </div>539540            <p class="peach-codex-done-regenerate">541              regenerate your codex whenever you add many new entries.542            </p>543544            <button545              class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"546              onClick={() => {547                setCompleted(null);548                setGenError(null);549                setConfirmInputs(["", "", ""]);550                setConfirmPositions(pickConfirmationPositions());551                setStep("words");552              }}553            >554              generate another555            </button>556          </Show>557        </Show>558      </div>559    </div>560  );561}