CodexGenerateView.tsx

19.2 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 — store separately from your Codex.367              </p>368            </div>369370            <ol class="peach-codex-words-grid" start={1}>371              <For each={snapshot()!.recoveryWords ?? []}>372                {(word, i) => (373                  <li class="peach-codex-word-row">374                    <span class="peach-codex-word-numeral">{ROMAN[i()]}</span>375                    <span class="peach-codex-word-text">{word}</span>376                  </li>377                )}378              </For>379            </ol>380381            <Show when={estimatedPages() !== null}>382              <p class="peach-codex-meta">383                <strong>{estimatedPages()}</strong>{" "}384                {estimatedPages() === 1 ? "page" : "pages"} ·{" "}385                {snapshot()!.entryCount}{" "}386                {snapshot()!.entryCount === 1 ? "entry" : "entries"}387                <Show when={estimatedParity() !== null}>388                  {" "}· {estimatedParity()} redundancy codes389                </Show>390              </p>391            </Show>392393            <div class="peach-codex-confirm-section">394              <p class="peach-codex-confirm-label">395                to continue, enter words {confirmPositions().join(", ")}:396              </p>397              <For each={confirmPositions()}>398                {(pos, i) => (399                  <div class="peach-codex-confirm-row">400                    <span class="peach-codex-confirm-pos">word {pos}</span>401                    <input402                      class="peach-codex-confirm-input"403                      type="text"404                      autocomplete="off"405                      autocorrect="off"406                      autocapitalize="none"407                      spellcheck={false}408                      value={confirmInputs()[i()]}409                      onInput={(e) => {410                        const next = [...confirmInputs()] as [411                          string,412                          string,413                          string,414                        ];415                        next[i()] = (e.target as HTMLInputElement).value;416                        setConfirmInputs(next);417                      }}418                      onKeyDown={(e) => {419                        if (e.key === "Enter" && allCorrect())420                          void handleGenerate();421                      }}422                    />423                  </div>424                )}425              </For>426            </div>427428            <Show when={genError()}>429              <p class="peach-hint peach-hint-error peach-codex-error">430                {genError()}431              </p>432            </Show>433434            <button435              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"436              disabled={!allCorrect()}437              onClick={() => void handleGenerate()}438            >439              generate my Codex440            </button>441          </Show>442443          <Show when={step() === "generating"}>444            <div class="peach-codex-recovery-headline">445              <h2 class="peach-codex-recovery-title">446                generating your codex…447              </h2>448            </div>449450            <div class="peach-codex-progress-group">451              <p class="peach-codex-progress-label">452                <span>{progressLabel()}</span>453                <span class="peach-codex-verify-tag">454                  {snapshot()?.verifyCode}455                </span>456              </p>457              <div class="peach-codex-progress-track">458                <div459                  class="peach-codex-progress-fill"460                  style={`width:${progressPercent()}%`}461                />462              </div>463            </div>464          </Show>465466          <Show when={step() === "done"}>467            <div class="peach-codex-recovery-headline">468              <h2 class="peach-codex-recovery-title">469                your codex is downloading.470              </h2>471            </div>472473            <Show when={completed()}>474              <p class="peach-codex-meta">475                {completed()!.pageCount}{" "}476                {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}477                {completed()!.totalChunks} codes · verify code{" "}478                <span class="peach-codex-verify">479                  {completed()!.verifyCode}480                </span>481              </p>482            </Show>483484            <div class="peach-codex-done-guidance">485              <div class="peach-codex-done-item">486                <span class="peach-codex-done-item-icon">🖨</span>487                <div>488                  <p class="peach-codex-done-item-title">489                    print on a laser printer490                  </p>491                  <p class="peach-codex-done-item-body">492                    laser prints last longer and scan more reliably than493                    inkjet. print at 100% scale — do not fit to page.494                  </p>495                </div>496              </div>497498              <div class="peach-codex-done-item">499                <span class="peach-codex-done-item-icon">📦</span>500                <div>501                  <p class="peach-codex-done-item-title">make two copies</p>502                  <p class="peach-codex-done-item-body">503                    store one at home (fireproof box or safe) and one504                    somewhere else (trusted person, safety deposit box).505                    fire and flood can destroy a single copy.506                  </p>507                </div>508              </div>509510              <div class="peach-codex-done-item">511                <span class="peach-codex-done-item-icon">🔑</span>512                <div>513                  <p class="peach-codex-done-item-title">514                    store separately from recovery words515                  </p>516                  <p class="peach-codex-done-item-body">517                    your 12 recovery words go somewhere different. together518                    they restore your vault without your passphrase. keep519                    them apart.520                  </p>521                </div>522              </div>523524              <div class="peach-codex-done-item">525                <span class="peach-codex-done-item-icon">💾</span>526                <div>527                  <p class="peach-codex-done-item-title">also save the PDF</p>528                  <p class="peach-codex-done-item-body">529                    copy the PDF to a USB drive. Peach can import it530                    directly without needing to scan printed codes.531                  </p>532                </div>533              </div>534            </div>535536            <p class="peach-codex-done-regenerate">537              regenerate your codex whenever you add many new entries.538            </p>539540            <button541              class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"542              onClick={() => {543                setCompleted(null);544                setGenError(null);545                setConfirmInputs(["", "", ""]);546                setConfirmPositions(pickConfirmationPositions());547                setStep("words");548              }}549            >550              generate another551            </button>552          </Show>553        </Show>554      </div>555    </div>556  );557}