CodexGenerateView.tsx

12.1 KB · raw

1// Peach Codex — generation view.2//3// Builds the printed PDF Codex (Aztec barcodes + optional recovery words) by:4//   1. Fetching a vault snapshot from the SW (GET_CODEX_VAULT_DATA).5//   2. Constructing a CodexDeps the popup-side `generateCodex` can drive.6//   3. Triggering a PDF download.7//8// Pro-gated: a non-Pro user is bounced to the license section on mount.9// The actual barcode/PDF/erasure-coding work lives in src/popup/codex/* and10// is owned by Subagents 1 and 2 — do not import internals from there beyond11// the published contract.12//13// Behavioural notes:14//  - `getAllEntries` only feeds an entry-count filter on the SW; here we hand15//    back synthetic placeholders of length `entryCount` so the filter passes16//    through unchanged without leaking entries through the popup.17//  - Restoration is intentionally NOT gated by Pro (a printed Codex must stay18//    restorable forever, regardless of license state).1920import { createSignal, createMemo, onMount, Show } from "solid-js";21import { sendMessage } from "../../shared/bridge";22import { isNativePlatform } from "../../shared/native";23import type {24  CodexDeps,25  CodexError,26  CodexManifest,27  CodexVaultSnapshot,28} from "../../shared/codex-types";29import { STORAGE_KEY_LAST_GENERATED } from "../../shared/codex-types";30import { generateCodex, estimateCodexPages } from "../codex/generate";31import type { VaultEntry } from "../../shared/types";3233interface CodexGenerateViewProps {34  isPro: boolean;35  onBack: () => void;36  onUpgradePro: () => void;37}3839export function CodexGenerateView(props: CodexGenerateViewProps) {40  const [snapshot, setSnapshot] = createSignal<CodexVaultSnapshot | null>(null);41  const [estimatedPages, setEstimatedPages] = createSignal<number | null>(null);42  const [loading, setLoading] = createSignal(true);43  const [loadError, setLoadError] = createSignal<string | null>(null);44  const [includeRecoveryWords, setIncludeRecoveryWords] = createSignal(true);45  const [generating, setGenerating] = createSignal(false);46  const [genError, setGenError] = createSignal<string | null>(null);47  const [completed, setCompleted] = createSignal<CodexManifest | null>(null);4849  onMount(async () => {50    if (!props.isPro) {51      // Pro-gated: bounce immediately. Render nothing meaningful in the52      // meantime so the user does not flash see the form before redirect.53      props.onUpgradePro();54      return;55    }5657    const res = (await sendMessage({58      type: "GET_CODEX_VAULT_DATA" as any,59    })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };6061    if (!res.ok || !res.data) {62      setLoadError(res.error ?? "could not load vault data");63      setLoading(false);64      return;65    }6667    setSnapshot(res.data);6869    if (res.data.slotA) {70      try {71        const pages = await estimateCodexPages(res.data.slotA, res.data.slotB);72        setEstimatedPages(pages);73      } catch {74        // Estimation is informational; failure is non-fatal.75        setEstimatedPages(null);76      }77    }7879    setLoading(false);80  });8182  // Description copy mirrors the spec — softens the toggle's consequence so83  // the user understands what a "no recovery words" Codex actually means.84  const toggleDescription = createMemo(() =>85    includeRecoveryWords()86      ? "your 12 recovery words will be printed on the last page. anyone holding the paper can restore the vault."87      : "no recovery words on paper. you will need your master password to restore from this Codex.",88  );8990  function buildDeps(snap: CodexVaultSnapshot): CodexDeps {91    return {92      getSlotA: async () => snap.slotA,93      getSlotB: async () => snap.slotB,94      getLastModified: async () => snap.lastModified,95      // `getAllEntries` is consumed only as a count filter inside the96      // generator. We synthesise a list of the right length so we don't have97      // to surface real entries through the popup boundary.98      getAllEntries: async () =>99        Array.from(100          { length: snap.entryCount },101          () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,102        ),103      getRecoveryWords: async () => snap.recoveryWords,104      getVerifyCode: async () => snap.verifyCode,105      isVaultUnlocked: () => snap.vaultUnlocked,106    };107  }108109  function codexFilename(): string {110    const d = new Date();111    const y = d.getFullYear();112    const m = String(d.getMonth() + 1).padStart(2, "0");113    const day = String(d.getDate()).padStart(2, "0");114    return `peach-codex-${y}-${m}-${day}.pdf`;115  }116117  function downloadPdf(bytes: Uint8Array): void {118    const a = document.createElement("a");119    a.download = codexFilename();120    if (isNativePlatform()) {121      // Capacitor WebView: blob: URLs are opaque to native, but data: URLs122      // are intercepted by MainActivity and written to public Downloads via123      // MediaStore. Encode in chunks to avoid blowing the call stack on large124      // PDFs.125      let bin = "";126      const chunk = 0x8000;127      for (let i = 0; i < bytes.length; i += chunk) {128        const slice = bytes.subarray(i, Math.min(i + chunk, bytes.length));129        bin += String.fromCharCode(...slice);130      }131      a.href = `data:application/pdf;base64,${btoa(bin)}`;132    } else {133      // Browser: copy into a fresh Uint8Array to ensure a tightly-owned134      // ArrayBuffer (Blob is happier with this than a subarray view).135      const owned = new Uint8Array(bytes);136      const blob = new Blob([owned.buffer], { type: "application/pdf" });137      a.href = URL.createObjectURL(blob);138      setTimeout(() => URL.revokeObjectURL(a.href), 4000);139    }140    document.body.appendChild(a);141    a.click();142    a.remove();143  }144145  function explainCodexError(code: string, message: string): string {146    switch (code) {147      case "VAULT_LOCKED":148        return "your vault is locked. unlock and try again.";149      case "VAULT_EMPTY":150        return "your vault is empty — nothing to print yet.";151      case "RECOVERY_WORDS_MISSING":152        return "your 12 recovery words could not be found. try toggling recovery words off.";153      case "RECOVERY_WORDS_INVALID":154        return "your recovery words look wrong. try toggling recovery words off.";155      case "PRO_REQUIRED":156        return "Codex generation is a Pro feature.";157      case "PDF_GENERATION_FAILED":158      case "AZTEC_GENERATION_FAILED":159        return "could not render the Codex. please try again.";160      case "COMPRESSION_ROUNDTRIP_FAILED":161      case "ERASURE_CODING_VERIFICATION_FAILED":162      case "ERASURE_CODING_CONTENT_MISMATCH":163      case "AZTEC_ROUNDTRIP_FAILED":164      case "AZTEC_CONTENT_MISMATCH":165      case "RECONSTRUCTION_SIMULATION_FAILED":166      case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":167      case "RECONSTRUCTION_FINGERPRINT_MISMATCH":168      case "RECONSTRUCTION_COUNT_MISMATCH":169        return "a self-check failed while building the Codex. nothing was saved. please try again.";170      default:171        return message || "Codex generation failed.";172    }173  }174175  async function handleGenerate() {176    const snap = snapshot();177    if (!snap || generating()) return;178179    setGenError(null);180    setGenerating(true);181182    try {183      const result = await generateCodex(buildDeps(snap), {184        includeRecoveryWords: includeRecoveryWords(),185      });186      downloadPdf(result.pdfBytes);187      setCompleted(result.manifest);188189      try {190        await chrome.storage.local?.set({191          [STORAGE_KEY_LAST_GENERATED]: Date.now(),192        });193      } catch {194        // Bookkeeping; failure is non-fatal.195      }196    } catch (err) {197      const codexErr = err as CodexError;198      const code = (codexErr && (codexErr as { code?: string }).code) ?? "";199      const message =200        err instanceof Error ? err.message : "Codex generation failed.";201      setGenError(explainCodexError(code, message));202    } finally {203      setGenerating(false);204    }205  }206207  return (208    <div class="peach-overlay-view ov-enter-right" style="display:flex">209      <div class="peach-overlay-header">210        <button211          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"212          onClick={props.onBack}213          aria-label="Back"214        >215          <svg width="7" height="12" viewBox="0 0 7 12" fill="none"216            stroke="currentColor" stroke-width="1.1"217            stroke-linecap="round" stroke-linejoin="round">218            <path d="M6 1L1 6L6 11" />219          </svg>220        </button>221        <span class="peach-overlay-title">peach codex</span>222        <div class="peach-overlay-spacer"></div>223      </div>224225      <div class="peach-settings-body peach-codex-body">226        <Show when={!props.isPro}>227          <p class="peach-hint">redirecting…</p>228        </Show>229230        <Show when={props.isPro && loading()}>231          <p class="peach-hint">loading vault…</p>232        </Show>233234        <Show when={props.isPro && !loading() && loadError()}>235          <p class="peach-hint peach-hint-error">{loadError()}</p>236        </Show>237238        <Show when={props.isPro && !loading() && !loadError() && snapshot()}>239          <div class="peach-settings-group peach-codex-intro">240            <p class="peach-codex-blurb">241              a printable paper backup of your encrypted vault, encoded as242              Aztec barcodes. store it somewhere only you can reach.243            </p>244            <Show when={estimatedPages() !== null}>245              <p class="peach-codex-meta">246                estimated <strong>{estimatedPages()}</strong>{" "}247                {estimatedPages() === 1 ? "page" : "pages"} ·{" "}248                {snapshot()!.entryCount}{" "}249                {snapshot()!.entryCount === 1 ? "entry" : "entries"}250              </p>251            </Show>252          </div>253254          <Show when={!completed()}>255            <div class="peach-settings-group">256              <div class="peach-settings-row">257                <span>Include recovery words</span>258                <button259                  type="button"260                  class={`peach-toggle ${includeRecoveryWords() ? "is-on" : ""}`}261                  onClick={() =>262                    setIncludeRecoveryWords(!includeRecoveryWords())263                  }264                  aria-label="Toggle recovery words on the Codex"265                  disabled={generating()}266                >267                  <span class="peach-toggle-knob"></span>268                </button>269              </div>270              <p class="peach-codex-toggle-desc">{toggleDescription()}</p>271            </div>272273            <Show when={genError()}>274              <p class="peach-hint peach-hint-error peach-codex-error">275                {genError()}276              </p>277            </Show>278279            <button280              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"281              disabled={generating()}282              onClick={() => void handleGenerate()}283            >284              {generating() ? "building Codex…" : "generate Codex"}285            </button>286287            <p class="peach-codex-footnote">288              print and store your Codex somewhere safe. anyone with the paper289              and your master password (or recovery words, if printed) can290              restore your vault.291            </p>292          </Show>293294          <Show when={completed()}>295            <div class="peach-settings-group peach-codex-done">296              <p class="peach-hint peach-hint--success">✓ Codex generated</p>297              <p class="peach-codex-meta">298                {completed()!.pageCount}{" "}299                {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}300                {completed()!.totalChunks} codes · verify code{" "}301                <span class="peach-codex-verify">302                  {completed()!.verifyCode}303                </span>304              </p>305              <p class="peach-codex-footnote">306                the PDF has been downloaded. print it on plain A4 paper —307                colour or laser, both work. store it somewhere only you can308                reach.309              </p>310              <button311                class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"312                onClick={() => {313                  setCompleted(null);314                  setGenError(null);315                }}316              >317                generate another318              </button>319            </div>320          </Show>321        </Show>322      </div>323    </div>324  );325}