CodexGenerateView.tsx

16.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, 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 type { VaultEntry } from "../../shared/types";3536interface CodexGenerateViewProps {37  isPro: boolean;38  onBack: () => void;39  onUpgradePro: () => void;40  onOpenSecurity: () => void;41}4243type Step = "words" | "generating" | "done";4445const ROMAN = [46  "I.", "II.", "III.", "IV.", "V.", "VI.",47  "VII.", "VIII.", "IX.", "X.", "XI.", "XII.",48];4950export function CodexGenerateView(props: CodexGenerateViewProps) {51  const [snapshot, setSnapshot] = createSignal<CodexVaultSnapshot | null>(null);52  const [estimatedPages, setEstimatedPages] = createSignal<number | null>(null);53  const [loading, setLoading] = createSignal(true);54  const [loadError, setLoadError] = createSignal<string | null>(null);55  const [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false);5657  const [step, setStep] = createSignal<Step>("words");58  const [confirmPositions, setConfirmPositions] = createSignal<59    [number, number, number]60  >([3, 7, 11]);61  const [confirmInputs, setConfirmInputs] = createSignal<62    [string, string, string]63  >(["", "", ""]);6465  const [progressStep, setProgressStep] = createSignal<string>("");66  const [progressCurrent, setProgressCurrent] = createSignal<number>(0);67  const [progressTotal, setProgressTotal] = createSignal<number>(0);6869  const [genError, setGenError] = createSignal<string | null>(null);70  const [completed, setCompleted] = createSignal<CodexManifest | null>(null);7172  onMount(async () => {73    if (!props.isPro) {74      props.onUpgradePro();75      return;76    }7778    const res = (await sendMessage({79      type: "GET_CODEX_VAULT_DATA",80    })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };8182    if (!res.ok || !res.data) {83      setLoadError(res.error ?? "could not load vault data");84      setLoading(false);85      return;86    }8788    setSnapshot(res.data);8990    const words = res.data.recoveryWords;91    if (!words || words.length !== 12) {92      setMissingRecoveryWords(true);93      setLoading(false);94      return;95    }9697    setConfirmPositions(pickConfirmationPositions());9899    if (res.data.slotA) {100      try {101        const pages = await estimateCodexPages(res.data.slotA, res.data.slotB);102        setEstimatedPages(pages);103      } catch {104        // Estimation is informational; failure is non-fatal.105        setEstimatedPages(null);106      }107    }108109    setLoading(false);110  });111112  const allCorrect = createMemo(() => {113    const snap = snapshot();114    const words = snap?.recoveryWords;115    if (!words || words.length !== 12) return false;116    const positions = confirmPositions();117    const inputs = confirmInputs();118    return positions.every(119      (pos, i) =>120        inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),121    );122  });123124  function buildDeps(snap: CodexVaultSnapshot): CodexDeps {125    return {126      getSlotA: async () => snap.slotA,127      getSlotB: async () => snap.slotB,128      getLastModified: async () => snap.lastModified,129      // `getAllEntries` is consumed only as a count filter inside the130      // generator — synthesise placeholders so real entries don't cross131      // the popup boundary.132      getAllEntries: async () =>133        Array.from(134          { length: snap.entryCount },135          () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,136        ),137      getVerifyCode: async () => snap.verifyCode,138      isVaultUnlocked: () => snap.vaultUnlocked,139    };140  }141142  function codexFilename(): string {143    const d = new Date();144    const y = d.getFullYear();145    const m = String(d.getMonth() + 1).padStart(2, "0");146    const day = String(d.getDate()).padStart(2, "0");147    return `peach-codex-${y}-${m}-${day}.pdf`;148  }149150  function downloadPdf(bytes: Uint8Array): void {151    const a = document.createElement("a");152    a.download = codexFilename();153    if (isNativePlatform()) {154      // Capacitor WebView: blob: URLs are opaque to native, but data: URLs155      // are intercepted by MainActivity and written to public Downloads via156      // MediaStore. Encode in chunks to avoid blowing the call stack on157      // large PDFs.158      let bin = "";159      const chunk = 0x8000;160      for (let i = 0; i < bytes.length; i += chunk) {161        const slice = bytes.subarray(i, Math.min(i + chunk, bytes.length));162        bin += String.fromCharCode(...slice);163      }164      a.href = `data:application/pdf;base64,${btoa(bin)}`;165    } else {166      // Browser: copy into a fresh Uint8Array to ensure a tightly-owned167      // ArrayBuffer (Blob is happier with this than a subarray view).168      const owned = new Uint8Array(bytes);169      const blob = new Blob([owned.buffer], { type: "application/pdf" });170      a.href = URL.createObjectURL(blob);171      setTimeout(() => URL.revokeObjectURL(a.href), 4000);172    }173    document.body.appendChild(a);174    a.click();175    a.remove();176  }177178  function explainCodexError(code: string, message: string): string {179    switch (code) {180      case "VAULT_LOCKED":181        return "your vault is locked. unlock and try again.";182      case "VAULT_EMPTY":183        return "your vault is empty — nothing to print yet.";184      case "PRO_REQUIRED":185        return "Codex generation is a Pro feature.";186      case "PDF_GENERATION_FAILED":187      case "AZTEC_GENERATION_FAILED":188        return "could not render the Codex. please try again.";189      case "COMPRESSION_ROUNDTRIP_FAILED":190      case "ERASURE_CODING_VERIFICATION_FAILED":191      case "ERASURE_CODING_CONTENT_MISMATCH":192      case "AZTEC_ROUNDTRIP_FAILED":193      case "AZTEC_CONTENT_MISMATCH":194      case "RECONSTRUCTION_SIMULATION_FAILED":195      case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":196      case "RECONSTRUCTION_FINGERPRINT_MISMATCH":197      case "RECONSTRUCTION_COUNT_MISMATCH":198        return "a self-check failed while building the Codex. nothing was saved. please try again.";199      default:200        return message || "Codex generation failed.";201    }202  }203204  const onProgress: CodexProgress = (s, current, total) => {205    setProgressStep(s);206    setProgressCurrent(current ?? 0);207    setProgressTotal(total ?? 0);208  };209210  async function handleGenerate() {211    const snap = snapshot();212    if (!snap || !allCorrect() || step() === "generating") return;213214    setGenError(null);215    setStep("generating");216    setProgressStep("compress");217218    try {219      const result = await generateCodex(buildDeps(snap), onProgress);220      downloadPdf(result.pdfBytes);221      setCompleted(result.manifest);222      setStep("done");223224      try {225        await chrome.storage.local?.set({226          [STORAGE_KEY_LAST_GENERATED]: Date.now(),227        });228      } catch {229        // Bookkeeping; failure is non-fatal.230      }231    } catch (err) {232      const codexErr = err as CodexError;233      const code = (codexErr && (codexErr as { code?: string }).code) ?? "";234      const message =235        err instanceof Error ? err.message : "Codex generation failed.";236      setGenError(explainCodexError(code, message));237      setStep("words");238    }239  }240241  function progressLabel(): string {242    switch (progressStep()) {243      case "compress":244        return "compressing vault";245      case "erasure":246        return "building chunks";247      case "aztec": {248        const total = progressTotal();249        const current = progressCurrent();250        if (total > 0) return `generating codes (${current} of ${total})`;251        return "generating codes";252      }253      case "verify":254        return "self-checking";255      case "pdf":256        return "creating PDF";257      default:258        return "preparing";259    }260  }261262  function progressPercent(): number {263    const total = progressTotal();264    const current = progressCurrent();265    // Map step to a coarse progress band so the bar advances monotonically266    // even when intra-step progress (aztec N of M) is fine-grained.267    switch (progressStep()) {268      case "compress":269        return 8;270      case "erasure":271        return 18;272      case "aztec":273        if (total <= 0) return 30;274        return 30 + Math.floor((current / total) * 55); // 30 → 85275      case "verify":276        return 90;277      case "pdf":278        return 96;279      default:280        return 4;281    }282  }283284  return (285    <div class="peach-overlay-view ov-enter-right" style="display:flex">286      <div class="peach-overlay-header">287        <button288          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"289          onClick={props.onBack}290          aria-label="Back"291          disabled={step() === "generating"}292        >293          <svg width="7" height="12" viewBox="0 0 7 12" fill="none"294            stroke="currentColor" stroke-width="1.1"295            stroke-linecap="round" stroke-linejoin="round">296            <path d="M6 1L1 6L6 11" />297          </svg>298        </button>299        <span class="peach-overlay-title">peach codex</span>300        <div class="peach-overlay-spacer"></div>301      </div>302303      <div class="peach-settings-body peach-codex-body peach-codex-body--runes">304        <RecoveryRuneBackground />305306        <Show when={!props.isPro}>307          <p class="peach-hint">redirecting…</p>308        </Show>309310        <Show when={props.isPro && loading()}>311          <p class="peach-hint">loading vault…</p>312        </Show>313314        <Show when={props.isPro && !loading() && loadError()}>315          <p class="peach-hint peach-hint-error">{loadError()}</p>316        </Show>317318        <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>319          <div class="peach-settings-group peach-codex-intro">320            <p class="peach-codex-blurb">321              the Codex prints your encrypted vault as Aztec codes. before322              you can generate one, set up your 12 recovery words in323              Settings → Security.324            </p>325          </div>326          <button327            class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"328            onClick={props.onOpenSecurity}329          >330            open Security settings331          </button>332        </Show>333334        <Show335          when={336            props.isPro &&337            !loading() &&338            !loadError() &&339            !missingRecoveryWords() &&340            snapshot()341          }342        >343          <Show when={step() === "words"}>344            <div class="peach-codex-recovery-headline">345              <h2 class="peach-codex-recovery-title">your recovery words.</h2>346              <p class="peach-codex-recovery-body">347                write these down and store them somewhere safe — separately348                from your Codex.349              </p>350              <p class="peach-codex-recovery-warn">351                together they restore your vault without your master352                passphrase. keep them apart.353              </p>354            </div>355356            <ol class="peach-codex-words-grid" start={1}>357              <For each={snapshot()!.recoveryWords ?? []}>358                {(word, i) => (359                  <li class="peach-codex-word-row">360                    <span class="peach-codex-word-numeral">{ROMAN[i()]}</span>361                    <span class="peach-codex-word-text">{word}</span>362                  </li>363                )}364              </For>365            </ol>366367            <Show when={estimatedPages() !== null}>368              <p class="peach-codex-meta">369                estimated <strong>{estimatedPages()}</strong>{" "}370                {estimatedPages() === 1 ? "page" : "pages"} ·{" "}371                {snapshot()!.entryCount}{" "}372                {snapshot()!.entryCount === 1 ? "entry" : "entries"}373              </p>374            </Show>375376            <div class="peach-codex-confirm-section">377              <p class="peach-codex-confirm-label">378                to continue, enter words {confirmPositions().join(", ")}:379              </p>380              <For each={confirmPositions()}>381                {(pos, i) => (382                  <div class="peach-codex-confirm-row">383                    <span class="peach-codex-confirm-pos">word {pos}</span>384                    <input385                      class="peach-codex-confirm-input"386                      type="text"387                      autocomplete="off"388                      autocorrect="off"389                      autocapitalize="none"390                      spellcheck={false}391                      value={confirmInputs()[i()]}392                      onInput={(e) => {393                        const next = [...confirmInputs()] as [394                          string,395                          string,396                          string,397                        ];398                        next[i()] = (e.target as HTMLInputElement).value;399                        setConfirmInputs(next);400                      }}401                      onKeyDown={(e) => {402                        if (e.key === "Enter" && allCorrect())403                          void handleGenerate();404                      }}405                    />406                  </div>407                )}408              </For>409            </div>410411            <Show when={genError()}>412              <p class="peach-hint peach-hint-error peach-codex-error">413                {genError()}414              </p>415            </Show>416417            <button418              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"419              disabled={!allCorrect()}420              onClick={() => void handleGenerate()}421            >422              generate my Codex423            </button>424          </Show>425426          <Show when={step() === "generating"}>427            <div class="peach-codex-recovery-headline">428              <h2 class="peach-codex-recovery-title">429                generating your codex…430              </h2>431            </div>432433            <div class="peach-codex-progress-group">434              <p class="peach-codex-progress-label">435                <span>{progressLabel()}</span>436                <span class="peach-codex-verify-tag">437                  {snapshot()?.verifyCode}438                </span>439              </p>440              <div class="peach-codex-progress-track">441                <div442                  class="peach-codex-progress-fill"443                  style={`width:${progressPercent()}%`}444                />445              </div>446            </div>447          </Show>448449          <Show when={step() === "done"}>450            <div class="peach-codex-recovery-headline">451              <h2 class="peach-codex-recovery-title">452                your codex is downloading.453              </h2>454              <p class="peach-codex-recovery-body">455                store it somewhere safe — a fireproof box, a safe, or a USB456                drive kept offline.457              </p>458              <p class="peach-codex-recovery-warn">459                your recovery words go somewhere else entirely. never store460                them with the Codex.461              </p>462            </div>463464            <Show when={completed()}>465              <p class="peach-codex-meta">466                {completed()!.pageCount}{" "}467                {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}468                {completed()!.totalChunks} codes · verify code{" "}469                <span class="peach-codex-verify">470                  {completed()!.verifyCode}471                </span>472              </p>473            </Show>474475            <button476              class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"477              onClick={() => {478                setCompleted(null);479                setGenError(null);480                setConfirmInputs(["", "", ""]);481                setConfirmPositions(pickConfirmationPositions());482                setStep("words");483              }}484            >485              generate another486            </button>487          </Show>488        </Show>489      </div>490    </div>491  );492}