CodexGenerateView.tsx

20.1 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}4344type Step = "words" | "generating" | "done";4546const ROMAN = [47  "I.", "II.", "III.", "IV.", "V.", "VI.",48  "VII.", "VIII.", "IX.", "X.", "XI.", "XII.",49];5051export 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 [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false);5859  const [step, setStep] = createSignal<Step>("words");60  const [confirmPositions, setConfirmPositions] = createSignal<61    [number, number, number]62  >([3, 7, 11]);63  const [confirmInputs, setConfirmInputs] = createSignal<64    [string, string, string]65  >(["", "", ""]);6667  const [progressStep, setProgressStep] = createSignal<string>("");68  const [progressCurrent, setProgressCurrent] = createSignal<number>(0);69  const [progressTotal, setProgressTotal] = createSignal<number>(0);7071  const [genError, setGenError] = createSignal<string | null>(null);72  const [completed, setCompleted] = createSignal<CodexManifest | null>(null);7374  onMount(async () => {75    if (!props.isPro) {76      props.onUpgradePro();77      return;78    }7980    const res = (await sendMessage({81      type: "GET_CODEX_VAULT_DATA",82    })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };8384    if (!res.ok || !res.data) {85      setLoadError(res.error ?? "could not load vault data");86      setLoading(false);87      return;88    }8990    setSnapshot(res.data);9192    const words = res.data.recoveryWords;93    if (!words || words.length !== 12) {94      setMissingRecoveryWords(true);95      setLoading(false);96      return;97    }9899    setConfirmPositions(pickConfirmationPositions());100101    // Pre-warm the Reed-Solomon WASM so load failures surface before the102    // user completes the word-confirmation flow.103    try {104      await getReedSolomon();105    } catch (err) {106      setLoadError(107        `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`108      );109      setLoading(false);110      return;111    }112113    if (res.data.slotA) {114      try {115        const { pages, parityShards } = await estimateCodexPages(116          res.data.slotA,117          res.data.slotB,118        );119        setEstimatedPages(pages);120        setEstimatedParity(parityShards);121      } catch {122        // Estimation is informational; failure is non-fatal.123        setEstimatedPages(null);124        setEstimatedParity(null);125      }126    }127128    setLoading(false);129  });130131  const allCorrect = createMemo(() => {132    const snap = snapshot();133    const words = snap?.recoveryWords;134    if (!words || words.length !== 12) return false;135    const positions = confirmPositions();136    const inputs = confirmInputs();137    return positions.every(138      (pos, i) =>139        inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),140    );141  });142143  function buildDeps(snap: CodexVaultSnapshot): CodexDeps {144    return {145      getSlotA: async () => snap.slotA,146      getSlotB: async () => snap.slotB,147      getLastModified: async () => snap.lastModified,148      // `getAllEntries` is consumed only as a count filter inside the149      // generator — synthesise placeholders so real entries don't cross150      // the popup boundary.151      getAllEntries: async () =>152        Array.from(153          { length: snap.entryCount },154          () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,155        ),156      getVerifyCode: async () => snap.verifyCode,157      isVaultUnlocked: () => snap.vaultUnlocked,158    };159  }160161  function codexFilename(): string {162    const d = new Date();163    const y = d.getFullYear();164    const m = String(d.getMonth() + 1).padStart(2, "0");165    const day = String(d.getDate()).padStart(2, "0");166    return `peach-codex-${y}-${m}-${day}.pdf`;167  }168169  function downloadPdf(bytes: Uint8Array): void {170    const a = document.createElement("a");171    a.download = codexFilename();172    if (isNativePlatform()) {173      // Capacitor WebView: blob: URLs are opaque to native, but data: URLs174      // are intercepted by MainActivity and written to public Downloads via175      // MediaStore. Encode in chunks to avoid blowing the call stack on176      // large PDFs.177      let bin = "";178      const chunk = 0x8000;179      for (let i = 0; i < bytes.length; i += chunk) {180        const slice = bytes.subarray(i, Math.min(i + chunk, bytes.length));181        bin += String.fromCharCode(...slice);182      }183      a.href = `data:application/pdf;base64,${btoa(bin)}`;184    } else {185      // Browser: copy into a fresh Uint8Array to ensure a tightly-owned186      // ArrayBuffer (Blob is happier with this than a subarray view).187      const owned = new Uint8Array(bytes);188      const blob = new Blob([owned.buffer], { type: "application/pdf" });189      a.href = URL.createObjectURL(blob);190      setTimeout(() => URL.revokeObjectURL(a.href), 4000);191    }192    document.body.appendChild(a);193    a.click();194    a.remove();195  }196197  function explainCodexError(code: string, message: string): string {198    switch (code) {199      case "VAULT_LOCKED":200        return "your vault is locked. unlock and try again.";201      case "VAULT_EMPTY":202        return "your vault is empty — nothing to print yet.";203      case "PRO_REQUIRED":204        return "Codex generation is a Pro feature.";205      case "PDF_GENERATION_FAILED":206      case "AZTEC_GENERATION_FAILED":207        return "could not render the Codex. please try again.";208      case "COMPRESSION_ROUNDTRIP_FAILED":209      case "ERASURE_CODING_VERIFICATION_FAILED":210      case "ERASURE_CODING_CONTENT_MISMATCH":211      case "AZTEC_ROUNDTRIP_FAILED":212      case "AZTEC_CONTENT_MISMATCH":213      case "RECONSTRUCTION_SIMULATION_FAILED":214      case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":215      case "RECONSTRUCTION_FINGERPRINT_MISMATCH":216      case "RECONSTRUCTION_COUNT_MISMATCH":217        return "a self-check failed while building the Codex. nothing was saved. please try again.";218      default:219        return message || "Codex generation failed.";220    }221  }222223  const onProgress: CodexProgress = (s, current, total) => {224    setProgressStep(s);225    setProgressCurrent(current ?? 0);226    setProgressTotal(total ?? 0);227  };228229  async function handleGenerate() {230    const snap = snapshot();231    if (!snap || !allCorrect() || step() === "generating") return;232233    setGenError(null);234    setStep("generating");235    setProgressStep("compress");236237    try {238      const result = await generateCodex(buildDeps(snap), onProgress);239      downloadPdf(result.pdfBytes);240      setCompleted(result.manifest);241      setStep("done");242243      try {244        await chrome.storage.local?.set({245          [STORAGE_KEY_LAST_GENERATED]: Date.now(),246        });247      } catch {248        // Bookkeeping; failure is non-fatal.249      }250    } catch (err) {251      const codexErr = err as CodexError;252      const code = (codexErr && (codexErr as { code?: string }).code) ?? "";253      const message =254        err instanceof Error ? err.message : "Codex generation failed.";255      setGenError(explainCodexError(code, message));256      setStep("words");257    }258  }259260  function progressLabel(): string {261    switch (progressStep()) {262      case "compress":263        return "compressing vault";264      case "erasure":265        return "building chunks";266      case "aztec": {267        const total = progressTotal();268        const current = progressCurrent();269        if (total > 0) return `generating codes (${current} of ${total})`;270        return "generating codes";271      }272      case "verify":273        return "self-checking";274      case "pdf":275        return "creating PDF";276      default:277        return "preparing";278    }279  }280281  function progressPercent(): number {282    const total = progressTotal();283    const current = progressCurrent();284    // Map step to a coarse progress band so the bar advances monotonically285    // even when intra-step progress (aztec N of M) is fine-grained.286    switch (progressStep()) {287      case "compress":288        return 8;289      case "erasure":290        return 18;291      case "aztec":292        if (total <= 0) return 30;293        return 30 + Math.floor((current / total) * 55); // 30 → 85294      case "verify":295        return 90;296      case "pdf":297        return 96;298      default:299        return 4;300    }301  }302303  return (304    <div class="peach-overlay-view ov-enter-right" style="display:flex">305      <div class="peach-overlay-header">306        <button307          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"308          onClick={props.onBack}309          aria-label="Back"310          disabled={step() === "generating"}311        >312          <svg width="7" height="12" viewBox="0 0 7 12" fill="none"313            stroke="currentColor" stroke-width="1.1"314            stroke-linecap="round" stroke-linejoin="round">315            <path d="M6 1L1 6L6 11" />316          </svg>317        </button>318        <span class="peach-overlay-title">peach codex</span>319        <div class="peach-overlay-spacer"></div>320      </div>321322      <div class="peach-settings-body peach-codex-body peach-codex-body--runes">323        <RecoveryRuneBackground />324325        <Show when={!props.isPro}>326          <p class="peach-hint">redirecting…</p>327        </Show>328329        <Show when={props.isPro && loading()}>330          <p class="peach-hint">loading vault…</p>331        </Show>332333        <Show when={props.isPro && !loading() && loadError()}>334          <p class="peach-hint peach-hint-error">{loadError()}</p>335        </Show>336337        <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>338          <div class="peach-settings-group peach-codex-intro">339            <p class="peach-codex-blurb">340              the Codex prints your encrypted vault as Aztec codes. before341              you can generate one, set up your 12 recovery words in342              Settings → Security.343            </p>344          </div>345          <button346            class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"347            onClick={props.onOpenSecurity}348          >349            open Security settings350          </button>351        </Show>352353        <Show354          when={355            props.isPro &&356            !loading() &&357            !loadError() &&358            !missingRecoveryWords() &&359            snapshot()360          }361        >362          <Show when={step() === "words"}>363            <div class="peach-codex-recovery-headline">364              <h2 class="peach-codex-recovery-title">your recovery words.</h2>365              <p class="peach-codex-recovery-body">366                write these down and store them somewhere safe — separately367                from your Codex.368              </p>369              <p class="peach-codex-recovery-warn">370                together they restore your vault without your master371                passphrase. keep them apart.372              </p>373            </div>374375            <ol class="peach-codex-words-grid" start={1}>376              <For each={snapshot()!.recoveryWords ?? []}>377                {(word, i) => (378                  <li class="peach-codex-word-row">379                    <span class="peach-codex-word-numeral">{ROMAN[i()]}</span>380                    <span class="peach-codex-word-text">{word}</span>381                  </li>382                )}383              </For>384            </ol>385386            <Show when={estimatedPages() !== null}>387              <p class="peach-codex-meta">388                estimated <strong>{estimatedPages()}</strong>{" "}389                {estimatedPages() === 1 ? "page" : "pages"} ·{" "}390                {snapshot()!.entryCount}{" "}391                {snapshot()!.entryCount === 1 ? "entry" : "entries"}392                <Show when={estimatedParity() !== null}>393                  {" "}· {estimatedParity()} redundancy codes394                </Show>395              </p>396              <Show when={estimatedParity() !== null}>397                <p class="peach-codex-meta peach-codex-meta-hint">398                  can recover from loss of up to {estimatedParity()} codes399                  anywhere in the document.400                </p>401              </Show>402              <Show when={(estimatedPages() ?? 0) >= 2}>403                <p class="peach-codex-meta peach-codex-meta-hint">404                  this is your complete vault. print double-sided to reduce to{" "}405                  {Math.ceil((estimatedPages() ?? 0) / 2)} sheets. store406                  separately from your recovery words.407                </p>408              </Show>409            </Show>410411            <div class="peach-codex-confirm-section">412              <p class="peach-codex-confirm-label">413                to continue, enter words {confirmPositions().join(", ")}:414              </p>415              <For each={confirmPositions()}>416                {(pos, i) => (417                  <div class="peach-codex-confirm-row">418                    <span class="peach-codex-confirm-pos">word {pos}</span>419                    <input420                      class="peach-codex-confirm-input"421                      type="text"422                      autocomplete="off"423                      autocorrect="off"424                      autocapitalize="none"425                      spellcheck={false}426                      value={confirmInputs()[i()]}427                      onInput={(e) => {428                        const next = [...confirmInputs()] as [429                          string,430                          string,431                          string,432                        ];433                        next[i()] = (e.target as HTMLInputElement).value;434                        setConfirmInputs(next);435                      }}436                      onKeyDown={(e) => {437                        if (e.key === "Enter" && allCorrect())438                          void handleGenerate();439                      }}440                    />441                  </div>442                )}443              </For>444            </div>445446            <Show when={genError()}>447              <p class="peach-hint peach-hint-error peach-codex-error">448                {genError()}449              </p>450            </Show>451452            <button453              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"454              disabled={!allCorrect()}455              onClick={() => void handleGenerate()}456            >457              generate my Codex458            </button>459          </Show>460461          <Show when={step() === "generating"}>462            <div class="peach-codex-recovery-headline">463              <h2 class="peach-codex-recovery-title">464                generating your codex…465              </h2>466            </div>467468            <div class="peach-codex-progress-group">469              <p class="peach-codex-progress-label">470                <span>{progressLabel()}</span>471                <span class="peach-codex-verify-tag">472                  {snapshot()?.verifyCode}473                </span>474              </p>475              <div class="peach-codex-progress-track">476                <div477                  class="peach-codex-progress-fill"478                  style={`width:${progressPercent()}%`}479                />480              </div>481            </div>482          </Show>483484          <Show when={step() === "done"}>485            <div class="peach-codex-recovery-headline">486              <h2 class="peach-codex-recovery-title">487                your codex is downloading.488              </h2>489            </div>490491            <Show when={completed()}>492              <p class="peach-codex-meta">493                {completed()!.pageCount}{" "}494                {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}495                {completed()!.totalChunks} codes · verify code{" "}496                <span class="peach-codex-verify">497                  {completed()!.verifyCode}498                </span>499              </p>500            </Show>501502            <div class="peach-codex-done-guidance">503              <div class="peach-codex-done-item">504                <span class="peach-codex-done-item-icon">🖨</span>505                <div>506                  <p class="peach-codex-done-item-title">507                    print on a laser printer508                  </p>509                  <p class="peach-codex-done-item-body">510                    laser prints last longer and scan more reliably than511                    inkjet. print at 100% scale — do not fit to page.512                  </p>513                </div>514              </div>515516              <div class="peach-codex-done-item">517                <span class="peach-codex-done-item-icon">📦</span>518                <div>519                  <p class="peach-codex-done-item-title">make two copies</p>520                  <p class="peach-codex-done-item-body">521                    store one at home (fireproof box or safe) and one522                    somewhere else (trusted person, safety deposit box).523                    fire and flood can destroy a single copy.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">532                    store separately from recovery words533                  </p>534                  <p class="peach-codex-done-item-body">535                    your 12 recovery words go somewhere different. together536                    they restore your vault without your passphrase. keep537                    them apart.538                  </p>539                </div>540              </div>541542              <div class="peach-codex-done-item">543                <span class="peach-codex-done-item-icon">💾</span>544                <div>545                  <p class="peach-codex-done-item-title">also save the PDF</p>546                  <p class="peach-codex-done-item-body">547                    copy the PDF to a USB drive. Peach can import it548                    directly without needing to scan printed codes.549                  </p>550                </div>551              </div>552            </div>553554            <p class="peach-codex-done-regenerate">555              regenerate your codex whenever you add many new entries.556            </p>557558            <button559              class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"560              onClick={() => {561                setCompleted(null);562                setGenError(null);563                setConfirmInputs(["", "", ""]);564                setConfirmPositions(pickConfirmationPositions());565                setStep("words");566              }}567            >568              generate another569            </button>570          </Show>571        </Show>572      </div>573    </div>574  );575}