CodexRestoreView.tsx

23.9 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  | "pdf-shortfall"37  | "ready"38  | "restoring"39  | "success"40  | "error";4142export function CodexRestoreView(props: CodexRestoreViewProps) {43  // The buffer is intentionally not stored in a signal — its identity is44  // stable across the lifetime of the view and we read it through the45  // `size()`/`isReady()` methods. We mirror its observable state into46  // signals so Solid can re-render.47  const buffer = new ChunkBuffer();48  let videoEl: HTMLVideoElement | undefined;49  let scanner: ScannerHandle | null = null;50  let pdfFileInput: HTMLInputElement | undefined;5152  const [phase, setPhase] = createSignal<Phase>("choose");53  const [scanned, setScanned] = createSignal(0);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  const [pdfShortfall, setPdfShortfall] = createSignal<{67    found: number;68    missing: number;69    ready: number;70    total: number;71  } | null>(null);72  // Hold the reconstructed payload between the integrity check and the SW73  // call so the user can re-enter the passphrase on a wrong-password failure74  // without rescanning every code.75  const [pendingPayload, setPendingPayload] = createSignal<CodexPayload | null>(76    null,77  );78  const [passphrase, setPassphrase] = createSignal("");79  const [restoreError, setRestoreError] = createSignal<string | null>(null);80  const [restoredCount, setRestoredCount] = createSignal<number | null>(null);8182  function refreshFromBuffer() {83    setScanned(buffer.size());84    const ctx = buffer.getContext();85    if (ctx) {86      setDataShards(ctx.dataShards);87      setVerifyCode(ctx.verifyCode);88    }89    if (90      buffer.isReady() &&91      (phase() === "scanning" || phase() === "pdf-import" || phase() === "pdf-shortfall")92    ) {93      setPhase("ready");94      setHint("scan complete. enter your master password to restore.");95      setPdfShortfall(null);96      // Stop the camera the instant we have enough codes — no reason to keep97      // the green light on while the user types their passphrase.98      if (scanner) {99        scanner.stop();100        scanner = null;101      }102    } else if (phase() === "scanning") {103      const ds = ctx?.dataShards;104      if (ds) {105        const remaining = Math.max(ds - buffer.size(), 0);106        setHint(107          remaining > 0108            ? `${remaining} more ${remaining === 1 ? "code" : "codes"} needed`109            : "almost there…",110        );111      }112    }113  }114115  function explainScanReason(reason: string): string {116    switch (reason) {117      case "foreign_codex":118        return "that code belongs to a different Codex.";119      case "bad_integrity":120        return "a code's checksum failed — try again.";121      case "duplicate":122        return ""; // not surfaced — duplicates are normal as the user pans123      case "parse_error":124        return "could not read that code.";125      default:126        return "";127    }128  }129130  async function handleScan(raw: string) {131    const parsed = parseRawScan(raw);132    if ("error" in parsed) {133      // parseRawScan failures are usually a non-Peach Aztec code or a corrupt134      // frame; quietly ignore so the scanner keeps running.135      return;136    }137138    const result = await buffer.add(parsed);139    refreshFromBuffer();140    if (!result.accepted && result.reason) {141      const msg = explainScanReason(result.reason);142      if (msg) {143        setScanWarning(msg);144        // Auto-clear so the warning doesn't linger after the user moves on.145        setTimeout(() => setScanWarning(null), 2500);146      }147    }148  }149150  function handleScannerError(err: Error) {151    setCameraError(152      err.name === "NotAllowedError"153        ? "camera access was denied. allow camera permission and reopen this screen."154        : err.message || "camera failed to start.",155    );156  }157158  async function startCameraScan() {159    setPhase("scanning");160    setCameraError(null);161    setPdfShortfall(null);162    // Solid hasn't rendered the <video> element yet — defer until next tick.163    await Promise.resolve();164    if (!videoEl) return;165    try {166      scanner = await startScanner({167        videoEl,168        onScan: (text) => void handleScan(text),169        onError: (err) => handleScannerError(err),170      });171    } catch (err) {172      handleScannerError(err instanceof Error ? err : new Error(String(err)));173    }174  }175176  // The Chrome action popup dismisses the moment the OS file picker steals177  // focus, which stranded users mid-import. From the popup we instead open178  // the restore view in a standalone window and let that window own the file179  // dialog. The window is created by the service worker — a window the popup180  // creates itself gets cascade-closed when the popup dismisses. Within the181  // detached window (?codex=pdf), or any context without a runtime bridge,182  // we trigger the input directly.183  async function openPdfPicker() {184    if (autoPickPdf) {185      pdfFileInput?.click();186      return;187    }188    if (typeof chrome === "undefined" || typeof chrome.runtime?.sendMessage !== "function") {189      pdfFileInput?.click();190      return;191    }192    try {193      // The standalone window is opened by the service worker: a window the194      // popup creates itself is cascade-closed by Chrome when the popup195      // dismisses, which stranded users mid-import.196      const res = (await sendMessage({197        type: "OPEN_DETACHED_POPUP",198        payload: { route: "codex-pdf" },199      })) as { ok: boolean; data?: { detached?: boolean } };200      if (!res?.data?.detached) {201        pdfFileInput?.click();202        return;203      }204      // The action popup dismisses itself when the new window steals focus —205      // calling window.close() here cascade-closes the detached child too.206    } catch (err) {207      console.warn("[codex] failed to detach popup for PDF import:", err);208      pdfFileInput?.click();209    }210  }211212  async function handlePDFSelected(e: Event) {213    const input = e.currentTarget as HTMLInputElement;214    const file = input.files?.[0];215    // Reset the input so the same file can be re-selected after an error.216    input.value = "";217    if (!file) return;218219    if (!file.type.includes("pdf") && !file.name.toLowerCase().endsWith(".pdf")) {220      setPdfError("please select a Peach Codex PDF file.");221      return;222    }223224    setPhase("pdf-import");225    setPdfProgress(null);226    setPdfError(null);227    setPdfShortfall(null);228229    try {230      // Dynamic import so the popup never pulls pdf.js into the camera-only231      // path — keeps the initial bundle small.232      const { importCodexPDF } = await import("../codex/pdf-import");233      const result = await importCodexPDF(file, (page, total) => {234        setPdfProgress({ page, total });235      });236237      if (result.envelopes.length === 0) {238        // A size/parse rejection lands here with the real reason in errors[];239        // showing the generic "no codes" message for it is what made an240        // oversized Codex read as a broken importer.241        setPdfError(242          result.errors.length > 0243            ? result.errors[0]!244            : "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document.",245        );246        setPhase("choose");247        return;248      }249250      for (const envelope of result.envelopes) {251        await buffer.add(envelope);252      }253      refreshFromBuffer();254255      if (!buffer.isReady()) {256        const stripes = buffer.stripeReadiness();257        const ready = stripes.filter((s) => s.isReady).length;258        const missing = stripes.reduce(259          (sum, s) => sum + Math.max(0, s.required - s.found),260          0,261        );262        setPdfShortfall({263          found: buffer.size(),264          missing,265          ready,266          total: stripes.length,267        });268        setPhase("pdf-shortfall");269        return;270      }271    } catch (err) {272      setPdfError(273        `failed to read PDF: ${err instanceof Error ? err.message : String(err)}`,274      );275      setPhase("choose");276    }277  }278279  function retryPdfImport() {280    setPdfShortfall(null);281    void openPdfPicker();282  }283284  // Detect whether we are running inside a chrome.windows.create() window285  // that was opened specifically to handle the PDF picker. The action286  // popup dismisses on focus loss, so the file dialog tears it down. When287  // routed via ?codex=pdf we know we are in a survivable window and can288  // safely auto-trigger the picker on mount.289  const autoPickPdf = (() => {290    try {291      return new URLSearchParams(window.location.search).get("codex") === "pdf";292    } catch {293      return false;294    }295  })();296297  onMount(() => {298    if (autoPickPdf) {299      // Tiny delay so the click happens after the SolidJS mount settles300      // and the hidden <input> is in the DOM.301      setTimeout(() => pdfFileInput?.click(), 50);302    }303  });304305  onCleanup(() => {306    if (scanner) {307      scanner.stop();308      scanner = null;309    }310    // `scanner.stop()` calls reader.reset(), which tears down the311    // MediaStream attached to the <video> element. Defensive: clear srcObject312    // in case the library leaves a reference behind after reset (observed in313    // older @zxing/library versions).314    if (videoEl?.srcObject) {315      const stream = videoEl.srcObject as MediaStream;316      stream.getTracks().forEach((t) => t.stop());317      videoEl.srcObject = null;318    }319  });320321  function explainRestoreError(error: string | undefined): string {322    if (!error) return "restoration failed. please try again.";323    if (error.includes("WRONG_PASSPHRASE") || error.includes("wrong"))324      return "wrong master password. try again.";325    if (error.includes("INVALID_VAULT_BLOB"))326      return "the reconstructed vault is unreadable. please re-scan the Codex.";327    if (error.includes("FINGERPRINT_MISMATCH"))328      return "vault integrity check failed. please re-scan the Codex.";329    return error;330  }331332  async function handleRestore() {333    if (phase() === "restoring") return;334    const pw = passphrase();335    // PCH-01-015: trim ONLY for the emptiness check — the raw passphrase must336    // reach RESTORE_FROM_CODEX so a master password with surrounding whitespace337    // (which unlocks fine on the lock screen, see Lock.tsx) also restores.338    if (!pw.trim()) {339      setRestoreError("enter your master password.");340      return;341    }342343    setPhase("restoring");344    setRestoreError(null);345346    let payload = pendingPayload();347    if (!payload) {348      try {349        payload = await reconstructAndVerify(buffer);350        setPendingPayload(payload);351      } catch (err) {352        const message =353          err instanceof Error354            ? err.message355            : "the scanned codes could not be reassembled.";356        setRestoreError(message);357        setPhase("ready");358        return;359      }360    }361362    const res = (await sendMessage({363      type: "RESTORE_FROM_CODEX",364      payload: {365        vaultBlob: payload.vaultBlob,366        timestamp: payload.timestamp,367        verifyCode: payload.verifyCode,368        vaultFingerprint: payload.vaultFingerprint,369        entryCount: payload.entryCount,370        masterPassphrase: pw,371      },372    })) as {373      ok: boolean;374      data?: { entryCount: number; verifyCode: string };375      error?: string;376    };377378    if (res.ok && res.data) {379      setRestoredCount(res.data.entryCount);380      setPhase("success");381      if (scanner) {382        scanner.stop();383        scanner = null;384      }385      setTimeout(() => props.onRestoreSuccess(), 1500);386    } else {387      setRestoreError(explainRestoreError(res.error));388      setPhase("ready");389    }390  }391392  function progressPercent(): number {393    const stripes = buffer.stripeReadiness();394    if (stripes.length > 0) {395      const worst = stripes.reduce((min, s) => (s.found < min.found ? s : min), stripes[0]!);396      if (worst.required > 0) {397        return Math.min(100, Math.round((worst.found / worst.required) * 100));398      }399    }400    const ds = dataShards();401    if (!ds || ds <= 0) return 0;402    return Math.min(100, Math.round((scanned() / ds) * 100));403  }404405  function stripeProgressSummary():406    | { ready: number; total: number; worstFound: number; worstRequired: number }407    | null {408    const stripes = buffer.stripeReadiness();409    if (stripes.length === 0) return null;410    const ready = stripes.filter((s) => s.isReady).length;411    const worst = stripes.reduce((min, s) => (s.found < min.found ? s : min), stripes[0]!);412    return {413      ready,414      total: stripes.length,415      worstFound: worst.found,416      worstRequired: worst.required,417    };418  }419420  return (421    <div class="peach-overlay-view ov-enter-right" style="display:flex">422      <div class="peach-overlay-header">423        <button424          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"425          onClick={props.onBack}426          aria-label="Back"427        >428          <svg width="7" height="12" viewBox="0 0 7 12" fill="none"429            stroke="currentColor" stroke-width="1.1"430            stroke-linecap="round" stroke-linejoin="round">431            <path d="M6 1L1 6L6 11" />432          </svg>433        </button>434        <span class="peach-overlay-title">restore from codex</span>435        <div class="peach-overlay-spacer"></div>436      </div>437438      <div class="peach-settings-body peach-codex-body">439        <Show when={phase() === "choose"}>440          <div class="peach-codex-restore-choose">441            <h2 class="peach-codex-restore-headline">restore from codex.</h2>442            <p class="peach-codex-restore-sub">443              how do you have your codex?444            </p>445446            <button447              class="peach-codex-restore-option"448              onClick={() => void startCameraScan()}449            >450              <div class="peach-codex-restore-option-icon">451                <svg width="22" height="22" viewBox="0 0 24 24" fill="none"452                  stroke="currentColor" stroke-width="1.5"453                  stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">454                  <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" />455                  <circle cx="12" cy="13" r="3.5" />456                </svg>457              </div>458              <div class="peach-codex-restore-option-content">459                <span class="peach-codex-restore-option-title">460                  scan printed pages461                </span>462                <span class="peach-codex-restore-option-sub">463                  point your camera at each code464                </span>465              </div>466            </button>467468            <button469              class="peach-codex-restore-option"470              onClick={() => void openPdfPicker()}471            >472              <div class="peach-codex-restore-option-icon">473                <svg width="22" height="22" viewBox="0 0 24 24" fill="none"474                  stroke="currentColor" stroke-width="1.5"475                  stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">476                  <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />477                  <path d="M14 3v5h5" />478                  <path d="M9 14h6" />479                  <path d="M9 17h4" />480                </svg>481              </div>482              <div class="peach-codex-restore-option-content">483                <span class="peach-codex-restore-option-title">484                  import PDF file485                </span>486                <span class="peach-codex-restore-option-sub">487                  select your codex PDF from a USB drive or folder488                </span>489              </div>490            </button>491492            <p class="peach-codex-restore-guidance">493              have both the printed pages and the PDF? the PDF import is494              faster and more reliable — use it if you can.495            </p>496497            <Show when={pdfError()}>498              <p class="peach-hint peach-hint-error peach-codex-restore-guidance">499                {pdfError()}500              </p>501            </Show>502          </div>503        </Show>504505        <input506          ref={(el) => (pdfFileInput = el)}507          type="file"508          accept=".pdf,application/pdf"509          style={{ display: "none" }}510          onChange={(e) => void handlePDFSelected(e)}511        />512513        <Show when={phase() === "pdf-shortfall"}>514          <div class="peach-settings-group peach-codex-pdf-shortfall">515            <p class="peach-codex-restore-headline">pdf import needs another pass.</p>516            <p class="peach-codex-pdf-shortfall-copy">517              The PDF decoded {pdfShortfall()?.found ?? 0} codes, but {pdfShortfall()?.missing ?? 0} remained unreadable.518              {pdfShortfall()?.ready === pdfShortfall()?.total519                ? " all stripes are ready now."520                : ` ${pdfShortfall()?.ready ?? 0} of ${pdfShortfall()?.total ?? 0} stripes are ready.`}521            </p>522            <p class="peach-codex-pdf-shortfall-copy">523              try importing the PDF again to fill the gaps, or switch to camera scanning if it keeps failing.524            </p>525            <div class="peach-codex-pdf-shortfall-actions">526              <button527                class="peach-btn peach-btn--accent peach-btn--md"528                onClick={() => retryPdfImport()}529              >530                try PDF again531              </button>532              <button533                class="peach-btn peach-btn--ghost peach-btn--md"534                onClick={() => void startCameraScan()}535              >536                use camera537              </button>538            </div>539          </div>540        </Show>541542        <Show543          when={544            phase() === "scanning" ||545            phase() === "ready" ||546            phase() === "restoring"547          }548        >549          <div class="peach-codex-scanner">550            <video551              ref={(el) => (videoEl = el)}552              class="peach-codex-video"553              autoplay554              playsinline555              muted556            />557            <Show when={cameraError()}>558              <div class="peach-codex-camera-overlay">559                <p class="peach-hint peach-hint-error">{cameraError()}</p>560              </div>561            </Show>562          </div>563        </Show>564565        <Show when={phase() === "pdf-import"}>566          <div class="peach-settings-group peach-codex-pdf-import">567            <p class="peach-codex-restore-headline">reading your codex…</p>568            <Show when={pdfProgress()}>569              {(p) => (570                <>571                  <div class="peach-codex-progress-track">572                    <div573                      class="peach-codex-progress-fill"574                      style={{575                        width: `${Math.round((p().page / p().total) * 100)}%`,576                      }}577                    />578                  </div>579                  <p class="peach-codex-progress-label">580                    page {p().page} of {p().total}581                  </p>582                </>583              )}584            </Show>585          </div>586        </Show>587588        <Show589          when={590            phase() === "scanning" ||591            phase() === "ready" ||592            phase() === "restoring"593          }594        >595          <div class="peach-settings-group peach-codex-progress-group">596            <div class="peach-codex-progress-track">597              <div598                class="peach-codex-progress-fill"599                style={{ width: `${progressPercent()}%` }}600              />601            </div>602            <p class="peach-codex-progress-label">603              <Show when={stripeProgressSummary()} fallback={<span>{scanned()} codes scanned</span>}>604                {(summary) => (605                  <span>606                    {summary().ready} / {summary().total} stripes ready607                  </span>608                )}609              </Show>610              <Show when={verifyCode()}>611                <span class="peach-codex-verify-tag">{verifyCode()}</span>612              </Show>613            </p>614            <Show when={stripeProgressSummary()}>615              {(summary) => (616                <p class="peach-codex-hint peach-codex-worst-stripe">617                  least-ready stripe: {summary().worstFound} / {summary().worstRequired} codes618                </p>619              )}620            </Show>621            <p class="peach-codex-hint">{hint()}</p>622            <Show when={scanWarning()}>623              <p class="peach-hint peach-hint--warning peach-codex-warning">624                {scanWarning()}625              </p>626            </Show>627          </div>628        </Show>629630        <Show when={phase() === "ready" || phase() === "restoring"}>631          <div class="peach-settings-group peach-codex-passphrase-group">632            <label class="peach-settings-label" for="codex-passphrase">633              master password634            </label>635            <input636              id="codex-passphrase"637              type="password"638              class="peach-underline-input"639              autocomplete="current-password"640              autocorrect="off"641              autocapitalize="none"642              spellcheck={false}643              disabled={phase() === "restoring"}644              value={passphrase()}645              onInput={(e) => {646                setPassphrase((e.target as HTMLInputElement).value);647                if (restoreError()) setRestoreError(null);648              }}649              onKeyDown={(e) => {650                if (e.key === "Enter" && phase() === "ready") {651                  void handleRestore();652                }653              }}654            />655            <Show when={restoreError()}>656              <p class="peach-hint peach-hint-error">{restoreError()}</p>657            </Show>658            <button659              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"660              disabled={phase() === "restoring" || passphrase().length === 0}661              onClick={() => void handleRestore()}662            >663              {phase() === "restoring" ? "restoring…" : "restore vault"}664            </button>665          </div>666        </Show>667668        <Show when={phase() === "success"}>669          <div class="peach-settings-group peach-codex-done">670            <p class="peach-hint peach-hint--success">✓ vault restored</p>671            <p class="peach-codex-meta">672              {restoredCount()}{" "}673              {restoredCount() === 1 ? "entry" : "entries"} recovered.674            </p>675            <p class="peach-codex-footnote">676              your codex has been read and your vault is unlocked.677            </p>678          </div>679        </Show>680681        <Show when={phase() !== "success"}>682          <button683            class="peach-btn peach-btn--ghost peach-btn--sm peach-btn--full peach-codex-cancel-btn"684            onClick={props.onBack}685          >686            cancel687          </button>688        </Show>689      </div>690    </div>691  );692}