CodexRestoreView.tsx

21.1 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  // The Chrome action popup dismisses the moment the OS file picker steals167  // focus, which stranded users mid-import. From the popup we instead open168  // the restore view in a standalone window (chrome.windows.create) and169  // let that window own the file dialog. Within the detached window, or in170  // any non-Chromium context that lacks chrome.windows, we trigger the171  // input directly.172  async function openPdfPicker() {173    if (autoPickPdf) {174      pdfFileInput?.click();175      return;176    }177    const canDetach =178      typeof chrome !== "undefined" &&179      typeof chrome.windows?.create === "function" &&180      typeof chrome.runtime?.getURL === "function";181    if (!canDetach) {182      pdfFileInput?.click();183      return;184    }185    try {186      // Use the same HTML entrypoint the action popup uses. Reading it187      // from chrome.runtime.getManifest().action.default_popup keeps the188      // path correct across builds (Chrome vs Firefox manifest variants)189      // without hard-coding "src/popup/index.html".190      const popupHtml =191        chrome.runtime.getManifest().action?.default_popup ?? "src/popup/index.html";192      // Match the popup's intrinsic 320×520 body; add small allowances for193      // the OS title bar so the popup isn't clipped and so the rendered194      // window doesn't stretch a giant black background around a tiny popup195      // (the earlier 460×720 left ~150px of dead space on the right and196      // ~250px on the bottom). Chrome counts these as outer dimensions, so197      // the interior is ~popup-sized after subtracting native chrome.198      await chrome.windows.create({199        url: chrome.runtime.getURL(`${popupHtml}?codex=pdf`),200        type: "popup",201        width: 340,202        height: 560,203      });204      window.close();205    } catch (err) {206      console.warn("[codex] failed to detach popup for PDF import:", err);207      pdfFileInput?.click();208    }209  }210211  async function handlePDFSelected(e: Event) {212    const input = e.currentTarget as HTMLInputElement;213    const file = input.files?.[0];214    // Reset the input so the same file can be re-selected after an error.215    input.value = "";216    if (!file) return;217218    if (!file.type.includes("pdf") && !file.name.toLowerCase().endsWith(".pdf")) {219      setPdfError("please select a Peach Codex PDF file.");220      return;221    }222223    setPhase("pdf-import");224    setPdfProgress(null);225    setPdfError(null);226227    try {228      // Dynamic import so the popup never pulls pdf.js into the camera-only229      // path — keeps the initial bundle small.230      const { importCodexPDF } = await import("../codex/pdf-import");231      const result = await importCodexPDF(file, (page, total) => {232        setPdfProgress({ page, total });233      });234235      if (result.envelopes.length === 0) {236        setPdfError(237          "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document.",238        );239        setPhase("choose");240        return;241      }242243      for (const envelope of result.envelopes) {244        await buffer.add(envelope);245      }246      refreshFromBuffer();247248      if (!buffer.isReady()) {249        // Some codes are missing — let the user supplement with the camera.250        setPdfError(251          `found ${buffer.size()}${252            result.totalExpected ? ` of ${result.totalExpected}` : ""253          } codes. the PDF may be incomplete — you can scan the remaining codes with your camera.`,254        );255        await startCameraScan();256      }257    } catch (err) {258      setPdfError(259        `failed to read PDF: ${err instanceof Error ? err.message : String(err)}`,260      );261      setPhase("choose");262    }263  }264265  // Detect whether we are running inside a chrome.windows.create() window266  // that was opened specifically to handle the PDF picker. The action267  // popup dismisses on focus loss, so the file dialog tears it down. When268  // routed via ?codex=pdf we know we are in a survivable window and can269  // safely auto-trigger the picker on mount.270  const autoPickPdf = (() => {271    try {272      return new URLSearchParams(window.location.search).get("codex") === "pdf";273    } catch {274      return false;275    }276  })();277278  onMount(() => {279    if (autoPickPdf) {280      // Tiny delay so the click happens after the SolidJS mount settles281      // and the hidden <input> is in the DOM.282      setTimeout(() => pdfFileInput?.click(), 50);283    }284  });285286  onCleanup(() => {287    if (scanner) {288      scanner.stop();289      scanner = null;290    }291    // `scanner.stop()` calls reader.reset(), which tears down the292    // MediaStream attached to the <video> element. Defensive: clear srcObject293    // in case the library leaves a reference behind after reset (observed in294    // older @zxing/library versions).295    if (videoEl?.srcObject) {296      const stream = videoEl.srcObject as MediaStream;297      stream.getTracks().forEach((t) => t.stop());298      videoEl.srcObject = null;299    }300  });301302  function explainRestoreError(error: string | undefined): string {303    if (!error) return "restoration failed. please try again.";304    if (error.includes("WRONG_PASSPHRASE") || error.includes("wrong"))305      return "wrong master password. try again.";306    if (error.includes("INVALID_VAULT_BLOB"))307      return "the reconstructed vault is unreadable. please re-scan the Codex.";308    if (error.includes("FINGERPRINT_MISMATCH"))309      return "vault integrity check failed. please re-scan the Codex.";310    return error;311  }312313  async function handleRestore() {314    if (phase() === "restoring") return;315    const pw = passphrase().trim();316    if (!pw) {317      setRestoreError("enter your master password.");318      return;319    }320321    setPhase("restoring");322    setRestoreError(null);323324    let payload = pendingPayload();325    if (!payload) {326      try {327        payload = await reconstructAndVerify(buffer);328        setPendingPayload(payload);329      } catch (err) {330        const message =331          err instanceof Error332            ? err.message333            : "the scanned codes could not be reassembled.";334        setRestoreError(message);335        setPhase("ready");336        return;337      }338    }339340    const res = (await sendMessage({341      type: "RESTORE_FROM_CODEX",342      payload: {343        vaultBlob: payload.vaultBlob,344        timestamp: payload.timestamp,345        verifyCode: payload.verifyCode,346        vaultFingerprint: payload.vaultFingerprint,347        entryCount: payload.entryCount,348        masterPassphrase: pw,349      },350    })) as {351      ok: boolean;352      data?: { entryCount: number; verifyCode: string };353      error?: string;354    };355356    if (res.ok && res.data) {357      setRestoredCount(res.data.entryCount);358      setPhase("success");359      if (scanner) {360        scanner.stop();361        scanner = null;362      }363      setTimeout(() => props.onRestoreSuccess(), 1500);364    } else {365      setRestoreError(explainRestoreError(res.error));366      setPhase("ready");367    }368  }369370  function progressPercent(): number {371    const ds = dataShards();372    if (!ds || ds <= 0) return 0;373    return Math.min(100, Math.round((scanned() / ds) * 100));374  }375376  return (377    <div class="peach-overlay-view ov-enter-right" style="display:flex">378      <div class="peach-overlay-header">379        <button380          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"381          onClick={props.onBack}382          aria-label="Back"383        >384          <svg width="7" height="12" viewBox="0 0 7 12" fill="none"385            stroke="currentColor" stroke-width="1.1"386            stroke-linecap="round" stroke-linejoin="round">387            <path d="M6 1L1 6L6 11" />388          </svg>389        </button>390        <span class="peach-overlay-title">restore from codex</span>391        <div class="peach-overlay-spacer"></div>392      </div>393394      <div class="peach-settings-body peach-codex-body">395        <Show when={phase() === "choose"}>396          <div class="peach-codex-restore-choose">397            <h2 class="peach-codex-restore-headline">restore from codex.</h2>398            <p class="peach-codex-restore-sub">399              how do you have your codex?400            </p>401402            <button403              class="peach-codex-restore-option"404              onClick={() => void startCameraScan()}405            >406              <div class="peach-codex-restore-option-icon">407                <svg width="22" height="22" viewBox="0 0 24 24" fill="none"408                  stroke="currentColor" stroke-width="1.5"409                  stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">410                  <path d="M3 8a2 2 0 0 1 2-2h2l1.5-2h7L17 6h2a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8z" />411                  <circle cx="12" cy="13" r="3.5" />412                </svg>413              </div>414              <div class="peach-codex-restore-option-content">415                <span class="peach-codex-restore-option-title">416                  scan printed pages417                </span>418                <span class="peach-codex-restore-option-sub">419                  point your camera at each code420                </span>421              </div>422            </button>423424            <button425              class="peach-codex-restore-option"426              onClick={() => void openPdfPicker()}427            >428              <div class="peach-codex-restore-option-icon">429                <svg width="22" height="22" viewBox="0 0 24 24" fill="none"430                  stroke="currentColor" stroke-width="1.5"431                  stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">432                  <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />433                  <path d="M14 3v5h5" />434                  <path d="M9 14h6" />435                  <path d="M9 17h4" />436                </svg>437              </div>438              <div class="peach-codex-restore-option-content">439                <span class="peach-codex-restore-option-title">440                  import PDF file441                </span>442                <span class="peach-codex-restore-option-sub">443                  select your codex PDF from a USB drive or folder444                </span>445              </div>446            </button>447448            <input449              ref={(el) => (pdfFileInput = el)}450              type="file"451              accept=".pdf,application/pdf"452              style={{ display: "none" }}453              onChange={(e) => void handlePDFSelected(e)}454            />455456            <p class="peach-codex-restore-guidance">457              have both the printed pages and the PDF? the PDF import is458              faster and more reliable — use it if you can.459            </p>460461            <Show when={pdfError()}>462              <p class="peach-hint peach-hint-error peach-codex-restore-guidance">463                {pdfError()}464              </p>465            </Show>466          </div>467        </Show>468469        <Show470          when={471            phase() === "scanning" ||472            phase() === "ready" ||473            phase() === "restoring"474          }475        >476          <div class="peach-codex-scanner">477            <video478              ref={(el) => (videoEl = el)}479              class="peach-codex-video"480              autoplay481              playsinline482              muted483            />484            <Show when={cameraError()}>485              <div class="peach-codex-camera-overlay">486                <p class="peach-hint peach-hint-error">{cameraError()}</p>487              </div>488            </Show>489          </div>490        </Show>491492        <Show when={phase() === "pdf-import"}>493          <div class="peach-settings-group peach-codex-pdf-import">494            <p class="peach-codex-restore-headline">reading your codex…</p>495            <Show when={pdfProgress()}>496              {(p) => (497                <>498                  <div class="peach-codex-progress-track">499                    <div500                      class="peach-codex-progress-fill"501                      style={{502                        width: `${Math.round((p().page / p().total) * 100)}%`,503                      }}504                    />505                  </div>506                  <p class="peach-codex-progress-label">507                    page {p().page} of {p().total}508                  </p>509                </>510              )}511            </Show>512          </div>513        </Show>514515        <Show516          when={517            phase() === "scanning" ||518            phase() === "ready" ||519            phase() === "restoring"520          }521        >522          <div class="peach-settings-group peach-codex-progress-group">523            <div class="peach-codex-progress-track">524              <div525                class="peach-codex-progress-fill"526                style={{ width: `${progressPercent()}%` }}527              />528            </div>529            <p class="peach-codex-progress-label">530              <Show531                when={totalShards() !== null}532                fallback={<span>{scanned()} codes scanned</span>}533              >534                <span>535                  {scanned()} / {dataShards()} codes536                </span>537              </Show>538              <Show when={verifyCode()}>539                <span class="peach-codex-verify-tag">540                  {verifyCode()}541                </span>542              </Show>543            </p>544            <p class="peach-codex-hint">{hint()}</p>545            <Show when={scanWarning()}>546              <p class="peach-hint peach-hint--warning peach-codex-warning">547                {scanWarning()}548              </p>549            </Show>550          </div>551        </Show>552553        <Show when={phase() === "ready" || phase() === "restoring"}>554          <div class="peach-settings-group peach-codex-passphrase-group">555            <label class="peach-settings-label" for="codex-passphrase">556              master password557            </label>558            <input559              id="codex-passphrase"560              type="password"561              class="peach-underline-input"562              autocomplete="current-password"563              autocorrect="off"564              autocapitalize="none"565              spellcheck={false}566              disabled={phase() === "restoring"}567              value={passphrase()}568              onInput={(e) => {569                setPassphrase((e.target as HTMLInputElement).value);570                if (restoreError()) setRestoreError(null);571              }}572              onKeyDown={(e) => {573                if (e.key === "Enter" && phase() === "ready") {574                  void handleRestore();575                }576              }}577            />578            <Show when={restoreError()}>579              <p class="peach-hint peach-hint-error">{restoreError()}</p>580            </Show>581            <button582              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"583              disabled={phase() === "restoring" || passphrase().length === 0}584              onClick={() => void handleRestore()}585            >586              {phase() === "restoring" ? "restoring…" : "restore vault"}587            </button>588          </div>589        </Show>590591        <Show when={phase() === "success"}>592          <div class="peach-settings-group peach-codex-done">593            <p class="peach-hint peach-hint--success">✓ vault restored</p>594            <p class="peach-codex-meta">595              {restoredCount()}{" "}596              {restoredCount() === 1 ? "entry" : "entries"} recovered.597            </p>598            <p class="peach-codex-footnote">599              your codex has been read and your vault is unlocked.600            </p>601          </div>602        </Show>603604        <Show when={phase() !== "success"}>605          <button606            class="peach-btn peach-btn--ghost peach-btn--sm peach-btn--full peach-codex-cancel-btn"607            onClick={props.onBack}608          >609            cancel610          </button>611        </Show>612      </div>613    </div>614  );615}