CodexRestoreView.tsx

24.2 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  createScanGate,21  parseRawScan,22  reconstructAndVerify,23} from "../codex/restore";24import { startScanner } from "../codex/scanner";25import type { ScannerHandle } from "../codex/scanner";26import type { CodexPayload } from "../../shared/codex-types";2728interface CodexRestoreViewProps {29  onBack: () => void;30  onRestoreSuccess: () => void;31}3233type Phase =34  | "choose"35  | "scanning"36  | "pdf-import"37  | "pdf-shortfall"38  | "ready"39  | "restoring"40  | "success"41  | "error";4243export function CodexRestoreView(props: CodexRestoreViewProps) {44  // The buffer is intentionally not stored in a signal — its identity is45  // stable across the lifetime of the view and we read it through the46  // `size()`/`isReady()` methods. We mirror its observable state into47  // signals so Solid can re-render.48  const buffer = new ChunkBuffer();49  let videoEl: HTMLVideoElement | undefined;50  let scanner: ScannerHandle | null = null;51  let pdfFileInput: HTMLInputElement | undefined;5253  const [phase, setPhase] = createSignal<Phase>("choose");54  const [scanned, setScanned] = createSignal(0);55  const [dataShards, setDataShards] = createSignal<number | null>(null);56  const [verifyCode, setVerifyCode] = createSignal<string | null>(null);57  const [hint, setHint] = createSignal(58    "point your camera at the first code on page 1",59  );60  const [cameraError, setCameraError] = createSignal<string | null>(null);61  const [scanWarning, setScanWarning] = createSignal<string | null>(null);62  const [pdfProgress, setPdfProgress] = createSignal<{63    page: number;64    total: number;65  } | null>(null);66  const [pdfError, setPdfError] = createSignal<string | null>(null);67  const [pdfShortfall, setPdfShortfall] = createSignal<{68    found: number;69    missing: number;70    ready: number;71    total: number;72  } | null>(null);73  // Hold the reconstructed payload between the integrity check and the SW74  // call so the user can re-enter the passphrase on a wrong-password failure75  // without rescanning every code.76  const [pendingPayload, setPendingPayload] = createSignal<CodexPayload | null>(77    null,78  );79  const [passphrase, setPassphrase] = createSignal("");80  const [restoreError, setRestoreError] = createSignal<string | null>(null);81  const [restoredCount, setRestoredCount] = createSignal<number | null>(null);8283  function refreshFromBuffer() {84    setScanned(buffer.size());85    const ctx = buffer.getContext();86    if (ctx) {87      setDataShards(ctx.dataShards);88      setVerifyCode(ctx.verifyCode);89    }90    if (91      buffer.isReady() &&92      (phase() === "scanning" || phase() === "pdf-import" || phase() === "pdf-shortfall")93    ) {94      setPhase("ready");95      setHint("scan complete. enter your master password to restore.");96      setPdfShortfall(null);97      // Stop the camera the instant we have enough codes — no reason to keep98      // the green light on while the user types their passphrase.99      if (scanner) {100        scanner.stop();101        scanner = null;102      }103    } else if (phase() === "scanning") {104      const ds = ctx?.dataShards;105      if (ds) {106        const remaining = Math.max(ds - buffer.size(), 0);107        setHint(108          remaining > 0109            ? `${remaining} more ${remaining === 1 ? "code" : "codes"} needed`110            : "almost there…",111        );112      }113    }114  }115116  function explainScanReason(reason: string): string {117    switch (reason) {118      case "foreign_codex":119        return "that code belongs to a different Codex.";120      case "bad_integrity":121        return "a code's checksum failed — try again.";122      case "duplicate":123        return ""; // not surfaced — duplicates are normal as the user pans124      case "parse_error":125        return "could not read that code.";126      default:127        return "";128    }129  }130131  async function handleScan(raw: string) {132    const parsed = parseRawScan(raw);133    if ("error" in parsed) {134      // parseRawScan failures are usually a non-Peach Aztec code or a corrupt135      // frame; quietly ignore so the scanner keeps running.136      return;137    }138139    const result = await buffer.add(parsed);140    refreshFromBuffer();141    if (!result.accepted && result.reason) {142      const msg = explainScanReason(result.reason);143      if (msg) {144        setScanWarning(msg);145        // Auto-clear so the warning doesn't linger after the user moves on.146        setTimeout(() => setScanWarning(null), 2500);147      }148    }149  }150151  function handleScannerError(err: Error) {152    setCameraError(153      err.name === "NotAllowedError"154        ? "camera access was denied. allow camera permission and reopen this screen."155        : err.message || "camera failed to start.",156    );157  }158159  async function startCameraScan() {160    setPhase("scanning");161    setCameraError(null);162    setPdfShortfall(null);163    // Solid hasn't rendered the <video> element yet — defer until next tick.164    await Promise.resolve();165    if (!videoEl) return;166    try {167      // PB-26-207: the decoder fires per frame — gate to one parse/add in168      // flight, dropping repeats while busy instead of queueing work.169      const gate = createScanGate((text) => handleScan(text));170      scanner = await startScanner({171        videoEl,172        onScan: (text) => gate.onScan(text),173        onError: (err) => handleScannerError(err),174      });175    } catch (err) {176      handleScannerError(err instanceof Error ? err : new Error(String(err)));177    }178  }179180  // The Chrome action popup dismisses the moment the OS file picker steals181  // focus, which stranded users mid-import. From the popup we instead open182  // the restore view in a standalone window and let that window own the file183  // dialog. The window is created by the service worker — a window the popup184  // creates itself gets cascade-closed when the popup dismisses. Within the185  // detached window (?codex=pdf), or any context without a runtime bridge,186  // we trigger the input directly.187  async function openPdfPicker() {188    if (autoPickPdf) {189      pdfFileInput?.click();190      return;191    }192    if (typeof chrome === "undefined" || typeof chrome.runtime?.sendMessage !== "function") {193      pdfFileInput?.click();194      return;195    }196    try {197      // The standalone window is opened by the service worker: a window the198      // popup creates itself is cascade-closed by Chrome when the popup199      // dismisses, which stranded users mid-import.200      const res = (await sendMessage({201        type: "OPEN_DETACHED_POPUP",202        payload: { route: "codex-pdf" },203      })) as { ok: boolean; data?: { detached?: boolean } };204      if (!res?.data?.detached) {205        pdfFileInput?.click();206        return;207      }208      // The action popup dismisses itself when the new window steals focus —209      // calling window.close() here cascade-closes the detached child too.210    } catch (err) {211      console.warn("[codex] failed to detach popup for PDF import:", err);212      pdfFileInput?.click();213    }214  }215216  async function handlePDFSelected(e: Event) {217    const input = e.currentTarget as HTMLInputElement;218    const file = input.files?.[0];219    // Reset the input so the same file can be re-selected after an error.220    input.value = "";221    if (!file) return;222223    if (!file.type.includes("pdf") && !file.name.toLowerCase().endsWith(".pdf")) {224      setPdfError("please select a Peach Codex PDF file.");225      return;226    }227228    setPhase("pdf-import");229    setPdfProgress(null);230    setPdfError(null);231    setPdfShortfall(null);232233    try {234      // Dynamic import so the popup never pulls pdf.js into the camera-only235      // path — keeps the initial bundle small.236      const { importCodexPDF } = await import("../codex/pdf-import");237      const result = await importCodexPDF(file, (page, total) => {238        setPdfProgress({ page, total });239      });240241      if (result.envelopes.length === 0) {242        // A size/parse rejection lands here with the real reason in errors[];243        // showing the generic "no codes" message for it is what made an244        // oversized Codex read as a broken importer.245        setPdfError(246          result.errors.length > 0247            ? result.errors[0]!248            : "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document.",249        );250        setPhase("choose");251        return;252      }253254      for (const envelope of result.envelopes) {255        await buffer.add(envelope);256      }257      refreshFromBuffer();258259      if (!buffer.isReady()) {260        const stripes = buffer.stripeReadiness();261        const ready = stripes.filter((s) => s.isReady).length;262        const missing = stripes.reduce(263          (sum, s) => sum + Math.max(0, s.required - s.found),264          0,265        );266        setPdfShortfall({267          found: buffer.size(),268          missing,269          ready,270          total: stripes.length,271        });272        setPhase("pdf-shortfall");273        return;274      }275    } catch (err) {276      setPdfError(277        `failed to read PDF: ${err instanceof Error ? err.message : String(err)}`,278      );279      setPhase("choose");280    }281  }282283  function retryPdfImport() {284    setPdfShortfall(null);285    void openPdfPicker();286  }287288  // Detect whether we are running inside a chrome.windows.create() window289  // that was opened specifically to handle the PDF picker. The action290  // popup dismisses on focus loss, so the file dialog tears it down. When291  // routed via ?codex=pdf we know we are in a survivable window and can292  // safely auto-trigger the picker on mount.293  const autoPickPdf = (() => {294    try {295      return new URLSearchParams(window.location.search).get("codex") === "pdf";296    } catch {297      return false;298    }299  })();300301  onMount(() => {302    if (autoPickPdf) {303      // Tiny delay so the click happens after the SolidJS mount settles304      // and the hidden <input> is in the DOM.305      setTimeout(() => pdfFileInput?.click(), 50);306    }307  });308309  onCleanup(() => {310    if (scanner) {311      scanner.stop();312      scanner = null;313    }314    // `scanner.stop()` calls reader.reset(), which tears down the315    // MediaStream attached to the <video> element. Defensive: clear srcObject316    // in case the library leaves a reference behind after reset (observed in317    // older @zxing/library versions).318    if (videoEl?.srcObject) {319      const stream = videoEl.srcObject as MediaStream;320      stream.getTracks().forEach((t) => t.stop());321      videoEl.srcObject = null;322    }323  });324325  function explainRestoreError(error: string | undefined): string {326    if (!error) return "restoration failed. please try again.";327    if (error.includes("WRONG_PASSPHRASE") || error.includes("wrong"))328      return "wrong master password. try again.";329    if (error.includes("INVALID_VAULT_BLOB"))330      return "the reconstructed vault is unreadable. please re-scan the Codex.";331    if (error.includes("FINGERPRINT_MISMATCH"))332      return "vault integrity check failed. please re-scan the Codex.";333    // PB-26-607: never echo the raw background error into the UI.334    return "restoration failed. please try again.";335  }336337  async function handleRestore() {338    if (phase() === "restoring") return;339    const pw = passphrase();340    // PCH-01-015: trim ONLY for the emptiness check — the raw passphrase must341    // reach RESTORE_FROM_CODEX so a master password with surrounding whitespace342    // (which unlocks fine on the lock screen, see Lock.tsx) also restores.343    if (!pw.trim()) {344      setRestoreError("enter your master password.");345      return;346    }347348    setPhase("restoring");349    setRestoreError(null);350351    let payload = pendingPayload();352    if (!payload) {353      try {354        payload = await reconstructAndVerify(buffer);355        setPendingPayload(payload);356      } catch (err) {357        const message =358          err instanceof Error359            ? err.message360            : "the scanned codes could not be reassembled.";361        setRestoreError(message);362        setPhase("ready");363        return;364      }365    }366367    const res = (await sendMessage({368      type: "RESTORE_FROM_CODEX",369      payload: {370        vaultBlob: payload.vaultBlob,371        timestamp: payload.timestamp,372        verifyCode: payload.verifyCode,373        vaultFingerprint: payload.vaultFingerprint,374        entryCount: payload.entryCount,375        masterPassphrase: pw,376      },377    })) as {378      ok: boolean;379      data?: { entryCount: number; verifyCode: string };380      error?: string;381    };382383    if (res.ok && res.data) {384      setRestoredCount(res.data.entryCount);385      setPhase("success");386      if (scanner) {387        scanner.stop();388        scanner = null;389      }390      setTimeout(() => props.onRestoreSuccess(), 1500);391    } else {392      setRestoreError(explainRestoreError(res.error));393      setPhase("ready");394    }395  }396397  function progressPercent(): number {398    const stripes = buffer.stripeReadiness();399    if (stripes.length > 0) {400      const worst = stripes.reduce((min, s) => (s.found < min.found ? s : min), stripes[0]!);401      if (worst.required > 0) {402        return Math.min(100, Math.round((worst.found / worst.required) * 100));403      }404    }405    const ds = dataShards();406    if (!ds || ds <= 0) return 0;407    return Math.min(100, Math.round((scanned() / ds) * 100));408  }409410  function stripeProgressSummary():411    | { ready: number; total: number; worstFound: number; worstRequired: number }412    | null {413    const stripes = buffer.stripeReadiness();414    if (stripes.length === 0) return null;415    const ready = stripes.filter((s) => s.isReady).length;416    const worst = stripes.reduce((min, s) => (s.found < min.found ? s : min), stripes[0]!);417    return {418      ready,419      total: stripes.length,420      worstFound: worst.found,421      worstRequired: worst.required,422    };423  }424425  return (426    <div class="peach-overlay-view ov-enter-right" style="display:flex">427      <div class="peach-overlay-header">428        <button429          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"430          onClick={props.onBack}431          aria-label="Back"432        >433          <svg width="7" height="12" viewBox="0 0 7 12" fill="none"434            stroke="currentColor" stroke-width="1.1"435            stroke-linecap="round" stroke-linejoin="round">436            <path d="M6 1L1 6L6 11" />437          </svg>438        </button>439        <span class="peach-overlay-title">restore from codex</span>440        <div class="peach-overlay-spacer"></div>441      </div>442443      <div class="peach-settings-body peach-codex-body">444        <Show when={phase() === "choose"}>445          <div class="peach-codex-restore-choose">446            <h2 class="peach-codex-restore-headline">restore from codex.</h2>447            <p class="peach-codex-restore-sub">448              how do you have your codex?449            </p>450451            <button452              class="peach-codex-restore-option"453              onClick={() => void startCameraScan()}454            >455              <div class="peach-codex-restore-option-icon">456                <svg width="22" height="22" viewBox="0 0 24 24" fill="none"457                  stroke="currentColor" stroke-width="1.5"458                  stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">459                  <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" />460                  <circle cx="12" cy="13" r="3.5" />461                </svg>462              </div>463              <div class="peach-codex-restore-option-content">464                <span class="peach-codex-restore-option-title">465                  scan printed pages466                </span>467                <span class="peach-codex-restore-option-sub">468                  point your camera at each code469                </span>470              </div>471            </button>472473            <button474              class="peach-codex-restore-option"475              onClick={() => void openPdfPicker()}476            >477              <div class="peach-codex-restore-option-icon">478                <svg width="22" height="22" viewBox="0 0 24 24" fill="none"479                  stroke="currentColor" stroke-width="1.5"480                  stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">481                  <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />482                  <path d="M14 3v5h5" />483                  <path d="M9 14h6" />484                  <path d="M9 17h4" />485                </svg>486              </div>487              <div class="peach-codex-restore-option-content">488                <span class="peach-codex-restore-option-title">489                  import PDF file490                </span>491                <span class="peach-codex-restore-option-sub">492                  select your codex PDF from a USB drive or folder493                </span>494              </div>495            </button>496497            <p class="peach-codex-restore-guidance">498              have both the printed pages and the PDF? the PDF import is499              faster and more reliable — use it if you can.500            </p>501502            <Show when={pdfError()}>503              <p class="peach-hint peach-hint-error peach-codex-restore-guidance">504                {pdfError()}505              </p>506            </Show>507          </div>508        </Show>509510        <input511          ref={(el) => (pdfFileInput = el)}512          type="file"513          accept=".pdf,application/pdf"514          style={{ display: "none" }}515          onChange={(e) => void handlePDFSelected(e)}516        />517518        <Show when={phase() === "pdf-shortfall"}>519          <div class="peach-settings-group peach-codex-pdf-shortfall">520            <p class="peach-codex-restore-headline">pdf import needs another pass.</p>521            <p class="peach-codex-pdf-shortfall-copy">522              The PDF decoded {pdfShortfall()?.found ?? 0} codes, but {pdfShortfall()?.missing ?? 0} remained unreadable.523              {pdfShortfall()?.ready === pdfShortfall()?.total524                ? " all stripes are ready now."525                : ` ${pdfShortfall()?.ready ?? 0} of ${pdfShortfall()?.total ?? 0} stripes are ready.`}526            </p>527            <p class="peach-codex-pdf-shortfall-copy">528              try importing the PDF again to fill the gaps, or switch to camera scanning if it keeps failing.529            </p>530            <div class="peach-codex-pdf-shortfall-actions">531              <button532                class="peach-btn peach-btn--accent peach-btn--md"533                onClick={() => retryPdfImport()}534              >535                try PDF again536              </button>537              <button538                class="peach-btn peach-btn--ghost peach-btn--md"539                onClick={() => void startCameraScan()}540              >541                use camera542              </button>543            </div>544          </div>545        </Show>546547        <Show548          when={549            phase() === "scanning" ||550            phase() === "ready" ||551            phase() === "restoring"552          }553        >554          <div class="peach-codex-scanner">555            <video556              ref={(el) => (videoEl = el)}557              class="peach-codex-video"558              autoplay559              playsinline560              muted561            />562            <Show when={cameraError()}>563              <div class="peach-codex-camera-overlay">564                <p class="peach-hint peach-hint-error">{cameraError()}</p>565              </div>566            </Show>567          </div>568        </Show>569570        <Show when={phase() === "pdf-import"}>571          <div class="peach-settings-group peach-codex-pdf-import">572            <p class="peach-codex-restore-headline">reading your codex…</p>573            <Show when={pdfProgress()}>574              {(p) => (575                <>576                  <div class="peach-codex-progress-track">577                    <div578                      class="peach-codex-progress-fill"579                      style={{580                        width: `${Math.round((p().page / p().total) * 100)}%`,581                      }}582                    />583                  </div>584                  <p class="peach-codex-progress-label">585                    page {p().page} of {p().total}586                  </p>587                </>588              )}589            </Show>590          </div>591        </Show>592593        <Show594          when={595            phase() === "scanning" ||596            phase() === "ready" ||597            phase() === "restoring"598          }599        >600          <div class="peach-settings-group peach-codex-progress-group">601            <div class="peach-codex-progress-track">602              <div603                class="peach-codex-progress-fill"604                style={{ width: `${progressPercent()}%` }}605              />606            </div>607            <p class="peach-codex-progress-label">608              <Show when={stripeProgressSummary()} fallback={<span>{scanned()} codes scanned</span>}>609                {(summary) => (610                  <span>611                    {summary().ready} / {summary().total} stripes ready612                  </span>613                )}614              </Show>615              <Show when={verifyCode()}>616                <span class="peach-codex-verify-tag">{verifyCode()}</span>617              </Show>618            </p>619            <Show when={stripeProgressSummary()}>620              {(summary) => (621                <p class="peach-codex-hint peach-codex-worst-stripe">622                  least-ready stripe: {summary().worstFound} / {summary().worstRequired} codes623                </p>624              )}625            </Show>626            <p class="peach-codex-hint">{hint()}</p>627            <Show when={scanWarning()}>628              <p class="peach-hint peach-hint--warning peach-codex-warning">629                {scanWarning()}630              </p>631            </Show>632          </div>633        </Show>634635        <Show when={phase() === "ready" || phase() === "restoring"}>636          <div class="peach-settings-group peach-codex-passphrase-group">637            <label class="peach-settings-label" for="codex-passphrase">638              master password639            </label>640            <input641              id="codex-passphrase"642              type="password"643              class="peach-underline-input"644              autocomplete="current-password"645              autocorrect="off"646              autocapitalize="none"647              spellcheck={false}648              disabled={phase() === "restoring"}649              value={passphrase()}650              onInput={(e) => {651                setPassphrase((e.target as HTMLInputElement).value);652                if (restoreError()) setRestoreError(null);653              }}654              onKeyDown={(e) => {655                if (e.key === "Enter" && phase() === "ready") {656                  void handleRestore();657                }658              }}659            />660            <Show when={restoreError()}>661              <p class="peach-hint peach-hint-error">{restoreError()}</p>662            </Show>663            <button664              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"665              disabled={phase() === "restoring" || passphrase().length === 0}666              onClick={() => void handleRestore()}667            >668              {phase() === "restoring" ? "restoring…" : "restore vault"}669            </button>670          </div>671        </Show>672673        <Show when={phase() === "success"}>674          <div class="peach-settings-group peach-codex-done">675            <p class="peach-hint peach-hint--success">✓ vault restored</p>676            <p class="peach-codex-meta">677              {restoredCount()}{" "}678              {restoredCount() === 1 ? "entry" : "entries"} recovered.679            </p>680            <p class="peach-codex-footnote">681              your codex has been read and your vault is unlocked.682            </p>683          </div>684        </Show>685686        <Show when={phase() !== "success"}>687          <button688            class="peach-btn peach-btn--ghost peach-btn--sm peach-btn--full peach-codex-cancel-btn"689            onClick={props.onBack}690          >691            cancel692          </button>693        </Show>694      </div>695    </div>696  );697}