CodexRestoreView.tsx

17.8 KB · raw

1// Peach Codex — restoration view.2//3// Two entry paths, both feeding the same ChunkBuffer:4//   • Camera scan — Aztec codes via @zxing/library5//   • PDF import — pdf.js + grid scan of each rendered page6//7// Once ChunkBuffer.isReady():8//   1. Prompt for the master passphrase.9//   2. Reconstruct + integrity-verify locally (popup) via reconstructAndVerify.10//   3. Hand the payload to the SW (RESTORE_FROM_CODEX) for actual decrypt11//      and replace.12//13// Restoration is intentionally NOT Pro-gated — a printed Codex must remain14// restorable forever, regardless of license state.1516import { createSignal, onCleanup, onMount, Show } from "solid-js";17import { sendMessage } from "../../shared/bridge";18import {19  ChunkBuffer,20  parseRawScan,21  reconstructAndVerify,22} from "../codex/restore";23import { startScanner } from "../codex/scanner";24import type { ScannerHandle } from "../codex/scanner";25import type { CodexPayload } from "../../shared/codex-types";2627interface CodexRestoreViewProps {28  onBack: () => void;29  onRestoreSuccess: () => void;30}3132type Phase =33  | "choose"34  | "scanning"35  | "pdf-import"36  | "ready"37  | "restoring"38  | "success"39  | "error";4041export function CodexRestoreView(props: CodexRestoreViewProps) {42  // The buffer is intentionally not stored in a signal — its identity is43  // stable across the lifetime of the view and we read it through the44  // `size()`/`isReady()` methods. We mirror its observable state into45  // signals so Solid can re-render.46  const buffer = new ChunkBuffer();47  let videoEl: HTMLVideoElement | undefined;48  let scanner: ScannerHandle | null = null;49  let pdfFileInput: HTMLInputElement | undefined;5051  const [phase, setPhase] = createSignal<Phase>("choose");52  const [scanned, setScanned] = createSignal(0);53  const [totalShards, setTotalShards] = createSignal<number | null>(null);54  const [dataShards, setDataShards] = createSignal<number | null>(null);55  const [verifyCode, setVerifyCode] = createSignal<string | null>(null);56  const [hint, setHint] = createSignal(57    "point your camera at the first code on page 1",58  );59  const [cameraError, setCameraError] = createSignal<string | null>(null);60  const [scanWarning, setScanWarning] = createSignal<string | null>(null);61  const [pdfProgress, setPdfProgress] = createSignal<{62    page: number;63    total: number;64  } | null>(null);65  const [pdfError, setPdfError] = createSignal<string | null>(null);66  // Hold the reconstructed payload between the integrity check and the SW67  // call so the user can re-enter the passphrase on a wrong-password failure68  // without rescanning every code.69  const [pendingPayload, setPendingPayload] = createSignal<CodexPayload | null>(70    null,71  );72  const [passphrase, setPassphrase] = createSignal("");73  const [restoreError, setRestoreError] = createSignal<string | null>(null);74  const [restoredCount, setRestoredCount] = createSignal<number | null>(null);7576  function refreshFromBuffer() {77    setScanned(buffer.size());78    const ctx = buffer.getContext();79    if (ctx) {80      setTotalShards(ctx.total);81      setDataShards(ctx.dataShards);82      setVerifyCode(ctx.verifyCode);83    }84    if (buffer.isReady() && (phase() === "scanning" || phase() === "pdf-import")) {85      setPhase("ready");86      setHint("scan complete. enter your master password to restore.");87      // Stop the camera the instant we have enough codes — no reason to keep88      // the green light on while the user types their passphrase.89      if (scanner) {90        scanner.stop();91        scanner = null;92      }93    } else if (phase() === "scanning") {94      const ds = ctx?.dataShards;95      if (ds) {96        const remaining = Math.max(ds - buffer.size(), 0);97        setHint(98          remaining > 099            ? `${remaining} more ${remaining === 1 ? "code" : "codes"} needed`100            : "almost there…",101        );102      }103    }104  }105106  function explainScanReason(reason: string): string {107    switch (reason) {108      case "foreign_codex":109        return "that code belongs to a different Codex.";110      case "bad_integrity":111        return "a code's checksum failed — try again.";112      case "duplicate":113        return ""; // not surfaced — duplicates are normal as the user pans114      case "parse_error":115        return "could not read that code.";116      default:117        return "";118    }119  }120121  async function handleScan(raw: string) {122    const parsed = parseRawScan(raw);123    if ("error" in parsed) {124      // parseRawScan failures are usually a non-Peach Aztec code or a corrupt125      // frame; quietly ignore so the scanner keeps running.126      return;127    }128129    const result = await buffer.add(parsed);130    refreshFromBuffer();131    if (!result.accepted && result.reason) {132      const msg = explainScanReason(result.reason);133      if (msg) {134        setScanWarning(msg);135        // Auto-clear so the warning doesn't linger after the user moves on.136        setTimeout(() => setScanWarning(null), 2500);137      }138    }139  }140141  function handleScannerError(err: Error) {142    setCameraError(143      err.name === "NotAllowedError"144        ? "camera access was denied. allow camera permission and reopen this screen."145        : err.message || "camera failed to start.",146    );147  }148149  async function startCameraScan() {150    setPhase("scanning");151    setCameraError(null);152    // Solid hasn't rendered the <video> element yet — defer until next tick.153    await Promise.resolve();154    if (!videoEl) return;155    try {156      scanner = await startScanner({157        videoEl,158        onScan: (text) => void handleScan(text),159        onError: (err) => handleScannerError(err),160      });161    } catch (err) {162      handleScannerError(err instanceof Error ? err : new Error(String(err)));163    }164  }165166  async function handlePDFSelected(e: Event) {167    const input = e.currentTarget as HTMLInputElement;168    const file = input.files?.[0];169    // Reset the input so the same file can be re-selected after an error.170    input.value = "";171    if (!file) return;172173    if (!file.type.includes("pdf") && !file.name.toLowerCase().endsWith(".pdf")) {174      setPdfError("please select a Peach Codex PDF file.");175      return;176    }177178    setPhase("pdf-import");179    setPdfProgress(null);180    setPdfError(null);181182    try {183      // Dynamic import so the popup never pulls pdf.js into the camera-only184      // path — keeps the initial bundle small.185      const { importCodexPDF } = await import("../codex/pdf-import");186      const result = await importCodexPDF(file, (page, total) => {187        setPdfProgress({ page, total });188      });189190      if (result.envelopes.length === 0) {191        setPdfError(192          "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document.",193        );194        setPhase("choose");195        return;196      }197198      for (const envelope of result.envelopes) {199        await buffer.add(envelope);200      }201      refreshFromBuffer();202203      if (!buffer.isReady()) {204        // Some codes are missing — let the user supplement with the camera.205        setPdfError(206          `found ${buffer.size()}${207            result.totalExpected ? ` of ${result.totalExpected}` : ""208          } codes. the PDF may be incomplete — you can scan the remaining codes with your camera.`,209        );210        await startCameraScan();211      }212    } catch (err) {213      setPdfError(214        `failed to read PDF: ${err instanceof Error ? err.message : String(err)}`,215      );216      setPhase("choose");217    }218  }219220  onMount(() => {221    // Camera does not start automatically — the user picks a path on the222    // choose screen first.223  });224225  onCleanup(() => {226    if (scanner) {227      scanner.stop();228      scanner = null;229    }230    // `scanner.stop()` calls reader.reset(), which tears down the231    // MediaStream attached to the <video> element. Defensive: clear srcObject232    // in case the library leaves a reference behind after reset (observed in233    // older @zxing/library versions).234    if (videoEl?.srcObject) {235      const stream = videoEl.srcObject as MediaStream;236      stream.getTracks().forEach((t) => t.stop());237      videoEl.srcObject = null;238    }239  });240241  function explainRestoreError(error: string | undefined): string {242    if (!error) return "restoration failed. please try again.";243    if (error.includes("WRONG_PASSPHRASE") || error.includes("wrong"))244      return "wrong master password. try again.";245    if (error.includes("INVALID_VAULT_BLOB"))246      return "the reconstructed vault is unreadable. please re-scan the Codex.";247    if (error.includes("FINGERPRINT_MISMATCH"))248      return "vault integrity check failed. please re-scan the Codex.";249    return error;250  }251252  async function handleRestore() {253    if (phase() === "restoring") return;254    const pw = passphrase().trim();255    if (!pw) {256      setRestoreError("enter your master password.");257      return;258    }259260    setPhase("restoring");261    setRestoreError(null);262263    let payload = pendingPayload();264    if (!payload) {265      try {266        payload = await reconstructAndVerify(buffer);267        setPendingPayload(payload);268      } catch (err) {269        const message =270          err instanceof Error271            ? err.message272            : "the scanned codes could not be reassembled.";273        setRestoreError(message);274        setPhase("ready");275        return;276      }277    }278279    const res = (await sendMessage({280      type: "RESTORE_FROM_CODEX",281      payload: {282        vaultBlob: payload.vaultBlob,283        timestamp: payload.timestamp,284        verifyCode: payload.verifyCode,285        vaultFingerprint: payload.vaultFingerprint,286        entryCount: payload.entryCount,287        masterPassphrase: pw,288      },289    })) as {290      ok: boolean;291      data?: { entryCount: number; verifyCode: string };292      error?: string;293    };294295    if (res.ok && res.data) {296      setRestoredCount(res.data.entryCount);297      setPhase("success");298      if (scanner) {299        scanner.stop();300        scanner = null;301      }302      setTimeout(() => props.onRestoreSuccess(), 1500);303    } else {304      setRestoreError(explainRestoreError(res.error));305      setPhase("ready");306    }307  }308309  function progressPercent(): number {310    const ds = dataShards();311    if (!ds || ds <= 0) return 0;312    return Math.min(100, Math.round((scanned() / ds) * 100));313  }314315  return (316    <div class="peach-overlay-view ov-enter-right" style="display:flex">317      <div class="peach-overlay-header">318        <button319          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"320          onClick={props.onBack}321          aria-label="Back"322        >323          <svg width="7" height="12" viewBox="0 0 7 12" fill="none"324            stroke="currentColor" stroke-width="1.1"325            stroke-linecap="round" stroke-linejoin="round">326            <path d="M6 1L1 6L6 11" />327          </svg>328        </button>329        <span class="peach-overlay-title">restore from codex</span>330        <div class="peach-overlay-spacer"></div>331      </div>332333      <div class="peach-settings-body peach-codex-body">334        <Show when={phase() === "choose"}>335          <div class="peach-codex-restore-choose">336            <h2 class="peach-codex-restore-headline">restore from codex.</h2>337            <p class="peach-codex-restore-sub">338              how do you have your codex?339            </p>340341            <button342              class="peach-codex-restore-option"343              onClick={() => void startCameraScan()}344            >345              <div class="peach-codex-restore-option-icon">📷</div>346              <div class="peach-codex-restore-option-content">347                <span class="peach-codex-restore-option-title">348                  scan printed pages349                </span>350                <span class="peach-codex-restore-option-sub">351                  point your camera at each code352                </span>353              </div>354            </button>355356            <button357              class="peach-codex-restore-option"358              onClick={() => pdfFileInput?.click()}359            >360              <div class="peach-codex-restore-option-icon">📄</div>361              <div class="peach-codex-restore-option-content">362                <span class="peach-codex-restore-option-title">363                  import PDF file364                </span>365                <span class="peach-codex-restore-option-sub">366                  select your codex PDF from a USB drive or folder367                </span>368              </div>369            </button>370371            <input372              ref={(el) => (pdfFileInput = el)}373              type="file"374              accept=".pdf,application/pdf"375              style={{ display: "none" }}376              onChange={(e) => void handlePDFSelected(e)}377            />378379            <p class="peach-codex-restore-guidance">380              have both the printed pages and the PDF? the PDF import is381              faster and more reliable — use it if you can.382            </p>383384            <Show when={pdfError()}>385              <p class="peach-hint peach-hint-error peach-codex-restore-guidance">386                {pdfError()}387              </p>388            </Show>389          </div>390        </Show>391392        <Show393          when={394            phase() === "scanning" ||395            phase() === "ready" ||396            phase() === "restoring"397          }398        >399          <div class="peach-codex-scanner">400            <video401              ref={(el) => (videoEl = el)}402              class="peach-codex-video"403              autoplay404              playsinline405              muted406            />407            <Show when={cameraError()}>408              <div class="peach-codex-camera-overlay">409                <p class="peach-hint peach-hint-error">{cameraError()}</p>410              </div>411            </Show>412          </div>413        </Show>414415        <Show when={phase() === "pdf-import"}>416          <div class="peach-settings-group peach-codex-pdf-import">417            <p class="peach-codex-restore-headline">reading your codex…</p>418            <Show when={pdfProgress()}>419              {(p) => (420                <>421                  <div class="peach-codex-progress-track">422                    <div423                      class="peach-codex-progress-fill"424                      style={{425                        width: `${Math.round((p().page / p().total) * 100)}%`,426                      }}427                    />428                  </div>429                  <p class="peach-codex-progress-label">430                    page {p().page} of {p().total}431                  </p>432                </>433              )}434            </Show>435          </div>436        </Show>437438        <Show439          when={440            phase() === "scanning" ||441            phase() === "ready" ||442            phase() === "restoring"443          }444        >445          <div class="peach-settings-group peach-codex-progress-group">446            <div class="peach-codex-progress-track">447              <div448                class="peach-codex-progress-fill"449                style={{ width: `${progressPercent()}%` }}450              />451            </div>452            <p class="peach-codex-progress-label">453              <Show454                when={totalShards() !== null}455                fallback={<span>{scanned()} codes scanned</span>}456              >457                <span>458                  {scanned()} / {dataShards()} codes459                </span>460              </Show>461              <Show when={verifyCode()}>462                <span class="peach-codex-verify-tag">463                  {verifyCode()}464                </span>465              </Show>466            </p>467            <p class="peach-codex-hint">{hint()}</p>468            <Show when={scanWarning()}>469              <p class="peach-hint peach-hint--warning peach-codex-warning">470                {scanWarning()}471              </p>472            </Show>473          </div>474        </Show>475476        <Show when={phase() === "ready" || phase() === "restoring"}>477          <div class="peach-settings-group peach-codex-passphrase-group">478            <label class="peach-settings-label" for="codex-passphrase">479              master password480            </label>481            <input482              id="codex-passphrase"483              type="password"484              class="peach-underline-input"485              autocomplete="current-password"486              autocorrect="off"487              autocapitalize="none"488              spellcheck={false}489              disabled={phase() === "restoring"}490              value={passphrase()}491              onInput={(e) => {492                setPassphrase((e.target as HTMLInputElement).value);493                if (restoreError()) setRestoreError(null);494              }}495              onKeyDown={(e) => {496                if (e.key === "Enter" && phase() === "ready") {497                  void handleRestore();498                }499              }}500            />501            <Show when={restoreError()}>502              <p class="peach-hint peach-hint-error">{restoreError()}</p>503            </Show>504            <button505              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"506              disabled={phase() === "restoring" || passphrase().length === 0}507              onClick={() => void handleRestore()}508            >509              {phase() === "restoring" ? "restoring…" : "restore vault"}510            </button>511          </div>512        </Show>513514        <Show when={phase() === "success"}>515          <div class="peach-settings-group peach-codex-done">516            <p class="peach-hint peach-hint--success">✓ vault restored</p>517            <p class="peach-codex-meta">518              {restoredCount()}{" "}519              {restoredCount() === 1 ? "entry" : "entries"} recovered.520            </p>521            <p class="peach-codex-footnote">522              your codex has been read and your vault is unlocked.523            </p>524          </div>525        </Show>526527        <Show when={phase() !== "success"}>528          <button529            class="peach-btn peach-btn--ghost peach-btn--sm peach-btn--full peach-codex-cancel-btn"530            onClick={props.onBack}531          >532            cancel533          </button>534        </Show>535      </div>536    </div>537  );538}