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 (chrome.windows.create) and179  // let that window own the file dialog. Within the detached window, or in180  // any non-Chromium context that lacks chrome.windows, we trigger the181  // input directly.182  async function openPdfPicker() {183    if (autoPickPdf) {184      pdfFileInput?.click();185      return;186    }187    const canDetach =188      typeof chrome !== "undefined" &&189      typeof chrome.windows?.create === "function" &&190      typeof chrome.runtime?.getURL === "function";191    if (!canDetach) {192      pdfFileInput?.click();193      return;194    }195    try {196      // Use the same HTML entrypoint the action popup uses. Reading it197      // from chrome.runtime.getManifest().action.default_popup keeps the198      // path correct across builds (Chrome vs Firefox manifest variants)199      // without hard-coding "src/popup/index.html".200      const popupHtml =201        chrome.runtime.getManifest().action?.default_popup ?? "src/popup/index.html";202      // Match the popup's intrinsic 320×520 body. Chrome's popup-type203      // window has near-zero side chrome on macOS, so width:320 lines up204      // with the popup body and avoids the black band on the right that205      // any wider window produces. Height gets ~30pt for the title bar.206      await chrome.windows.create({207        url: chrome.runtime.getURL(`${popupHtml}?codex=pdf`),208        type: "popup",209        width: 320,210        height: 550,211      });212      window.close();213    } catch (err) {214      console.warn("[codex] failed to detach popup for PDF import:", err);215      pdfFileInput?.click();216    }217  }218219  async function handlePDFSelected(e: Event) {220    const input = e.currentTarget as HTMLInputElement;221    const file = input.files?.[0];222    // Reset the input so the same file can be re-selected after an error.223    input.value = "";224    if (!file) return;225226    if (!file.type.includes("pdf") && !file.name.toLowerCase().endsWith(".pdf")) {227      setPdfError("please select a Peach Codex PDF file.");228      return;229    }230231    setPhase("pdf-import");232    setPdfProgress(null);233    setPdfError(null);234    setPdfShortfall(null);235236    try {237      // Dynamic import so the popup never pulls pdf.js into the camera-only238      // path — keeps the initial bundle small.239      const { importCodexPDF } = await import("../codex/pdf-import");240      const result = await importCodexPDF(file, (page, total) => {241        setPdfProgress({ page, total });242      });243244      if (result.envelopes.length === 0) {245        setPdfError(246          "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document.",247        );248        setPhase("choose");249        return;250      }251252      for (const envelope of result.envelopes) {253        await buffer.add(envelope);254      }255      refreshFromBuffer();256257      if (!buffer.isReady()) {258        const stripes = buffer.stripeReadiness();259        const ready = stripes.filter((s) => s.isReady).length;260        const missing = stripes.reduce(261          (sum, s) => sum + Math.max(0, s.required - s.found),262          0,263        );264        setPdfShortfall({265          found: buffer.size(),266          missing,267          ready,268          total: stripes.length,269        });270        setPhase("pdf-shortfall");271        return;272      }273    } catch (err) {274      setPdfError(275        `failed to read PDF: ${err instanceof Error ? err.message : String(err)}`,276      );277      setPhase("choose");278    }279  }280281  function retryPdfImport() {282    setPdfShortfall(null);283    void openPdfPicker();284  }285286  // Detect whether we are running inside a chrome.windows.create() window287  // that was opened specifically to handle the PDF picker. The action288  // popup dismisses on focus loss, so the file dialog tears it down. When289  // routed via ?codex=pdf we know we are in a survivable window and can290  // safely auto-trigger the picker on mount.291  const autoPickPdf = (() => {292    try {293      return new URLSearchParams(window.location.search).get("codex") === "pdf";294    } catch {295      return false;296    }297  })();298299  onMount(() => {300    if (autoPickPdf) {301      // Tiny delay so the click happens after the SolidJS mount settles302      // and the hidden <input> is in the DOM.303      setTimeout(() => pdfFileInput?.click(), 50);304    }305  });306307  onCleanup(() => {308    if (scanner) {309      scanner.stop();310      scanner = null;311    }312    // `scanner.stop()` calls reader.reset(), which tears down the313    // MediaStream attached to the <video> element. Defensive: clear srcObject314    // in case the library leaves a reference behind after reset (observed in315    // older @zxing/library versions).316    if (videoEl?.srcObject) {317      const stream = videoEl.srcObject as MediaStream;318      stream.getTracks().forEach((t) => t.stop());319      videoEl.srcObject = null;320    }321  });322323  function explainRestoreError(error: string | undefined): string {324    if (!error) return "restoration failed. please try again.";325    if (error.includes("WRONG_PASSPHRASE") || error.includes("wrong"))326      return "wrong master password. try again.";327    if (error.includes("INVALID_VAULT_BLOB"))328      return "the reconstructed vault is unreadable. please re-scan the Codex.";329    if (error.includes("FINGERPRINT_MISMATCH"))330      return "vault integrity check failed. please re-scan the Codex.";331    return error;332  }333334  async function handleRestore() {335    if (phase() === "restoring") return;336    const pw = passphrase();337    // PCH-01-015: trim ONLY for the emptiness check — the raw passphrase must338    // reach RESTORE_FROM_CODEX so a master password with surrounding whitespace339    // (which unlocks fine on the lock screen, see Lock.tsx) also restores.340    if (!pw.trim()) {341      setRestoreError("enter your master password.");342      return;343    }344345    setPhase("restoring");346    setRestoreError(null);347348    let payload = pendingPayload();349    if (!payload) {350      try {351        payload = await reconstructAndVerify(buffer);352        setPendingPayload(payload);353      } catch (err) {354        const message =355          err instanceof Error356            ? err.message357            : "the scanned codes could not be reassembled.";358        setRestoreError(message);359        setPhase("ready");360        return;361      }362    }363364    const res = (await sendMessage({365      type: "RESTORE_FROM_CODEX",366      payload: {367        vaultBlob: payload.vaultBlob,368        timestamp: payload.timestamp,369        verifyCode: payload.verifyCode,370        vaultFingerprint: payload.vaultFingerprint,371        entryCount: payload.entryCount,372        masterPassphrase: pw,373      },374    })) as {375      ok: boolean;376      data?: { entryCount: number; verifyCode: string };377      error?: string;378    };379380    if (res.ok && res.data) {381      setRestoredCount(res.data.entryCount);382      setPhase("success");383      if (scanner) {384        scanner.stop();385        scanner = null;386      }387      setTimeout(() => props.onRestoreSuccess(), 1500);388    } else {389      setRestoreError(explainRestoreError(res.error));390      setPhase("ready");391    }392  }393394  function progressPercent(): number {395    const stripes = buffer.stripeReadiness();396    if (stripes.length > 0) {397      const worst = stripes.reduce((min, s) => (s.found < min.found ? s : min), stripes[0]!);398      if (worst.required > 0) {399        return Math.min(100, Math.round((worst.found / worst.required) * 100));400      }401    }402    const ds = dataShards();403    if (!ds || ds <= 0) return 0;404    return Math.min(100, Math.round((scanned() / ds) * 100));405  }406407  function stripeProgressSummary():408    | { ready: number; total: number; worstFound: number; worstRequired: number }409    | null {410    const stripes = buffer.stripeReadiness();411    if (stripes.length === 0) return null;412    const ready = stripes.filter((s) => s.isReady).length;413    const worst = stripes.reduce((min, s) => (s.found < min.found ? s : min), stripes[0]!);414    return {415      ready,416      total: stripes.length,417      worstFound: worst.found,418      worstRequired: worst.required,419    };420  }421422  return (423    <div class="peach-overlay-view ov-enter-right" style="display:flex">424      <div class="peach-overlay-header">425        <button426          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"427          onClick={props.onBack}428          aria-label="Back"429        >430          <svg width="7" height="12" viewBox="0 0 7 12" fill="none"431            stroke="currentColor" stroke-width="1.1"432            stroke-linecap="round" stroke-linejoin="round">433            <path d="M6 1L1 6L6 11" />434          </svg>435        </button>436        <span class="peach-overlay-title">restore from codex</span>437        <div class="peach-overlay-spacer"></div>438      </div>439440      <div class="peach-settings-body peach-codex-body">441        <Show when={phase() === "choose"}>442          <div class="peach-codex-restore-choose">443            <h2 class="peach-codex-restore-headline">restore from codex.</h2>444            <p class="peach-codex-restore-sub">445              how do you have your codex?446            </p>447448            <button449              class="peach-codex-restore-option"450              onClick={() => void startCameraScan()}451            >452              <div class="peach-codex-restore-option-icon">453                <svg width="22" height="22" viewBox="0 0 24 24" fill="none"454                  stroke="currentColor" stroke-width="1.5"455                  stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">456                  <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" />457                  <circle cx="12" cy="13" r="3.5" />458                </svg>459              </div>460              <div class="peach-codex-restore-option-content">461                <span class="peach-codex-restore-option-title">462                  scan printed pages463                </span>464                <span class="peach-codex-restore-option-sub">465                  point your camera at each code466                </span>467              </div>468            </button>469470            <button471              class="peach-codex-restore-option"472              onClick={() => void openPdfPicker()}473            >474              <div class="peach-codex-restore-option-icon">475                <svg width="22" height="22" viewBox="0 0 24 24" fill="none"476                  stroke="currentColor" stroke-width="1.5"477                  stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">478                  <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />479                  <path d="M14 3v5h5" />480                  <path d="M9 14h6" />481                  <path d="M9 17h4" />482                </svg>483              </div>484              <div class="peach-codex-restore-option-content">485                <span class="peach-codex-restore-option-title">486                  import PDF file487                </span>488                <span class="peach-codex-restore-option-sub">489                  select your codex PDF from a USB drive or folder490                </span>491              </div>492            </button>493494            <p class="peach-codex-restore-guidance">495              have both the printed pages and the PDF? the PDF import is496              faster and more reliable — use it if you can.497            </p>498499            <Show when={pdfError()}>500              <p class="peach-hint peach-hint-error peach-codex-restore-guidance">501                {pdfError()}502              </p>503            </Show>504          </div>505        </Show>506507        <input508          ref={(el) => (pdfFileInput = el)}509          type="file"510          accept=".pdf,application/pdf"511          style={{ display: "none" }}512          onChange={(e) => void handlePDFSelected(e)}513        />514515        <Show when={phase() === "pdf-shortfall"}>516          <div class="peach-settings-group peach-codex-pdf-shortfall">517            <p class="peach-codex-restore-headline">pdf import needs another pass.</p>518            <p class="peach-codex-pdf-shortfall-copy">519              The PDF decoded {pdfShortfall()?.found ?? 0} codes, but {pdfShortfall()?.missing ?? 0} remained unreadable.520              {pdfShortfall()?.ready === pdfShortfall()?.total521                ? " all stripes are ready now."522                : ` ${pdfShortfall()?.ready ?? 0} of ${pdfShortfall()?.total ?? 0} stripes are ready.`}523            </p>524            <p class="peach-codex-pdf-shortfall-copy">525              try importing the PDF again to fill the gaps, or switch to camera scanning if it keeps failing.526            </p>527            <div class="peach-codex-pdf-shortfall-actions">528              <button529                class="peach-btn peach-btn--accent peach-btn--md"530                onClick={() => retryPdfImport()}531              >532                try PDF again533              </button>534              <button535                class="peach-btn peach-btn--ghost peach-btn--md"536                onClick={() => void startCameraScan()}537              >538                use camera539              </button>540            </div>541          </div>542        </Show>543544        <Show545          when={546            phase() === "scanning" ||547            phase() === "ready" ||548            phase() === "restoring"549          }550        >551          <div class="peach-codex-scanner">552            <video553              ref={(el) => (videoEl = el)}554              class="peach-codex-video"555              autoplay556              playsinline557              muted558            />559            <Show when={cameraError()}>560              <div class="peach-codex-camera-overlay">561                <p class="peach-hint peach-hint-error">{cameraError()}</p>562              </div>563            </Show>564          </div>565        </Show>566567        <Show when={phase() === "pdf-import"}>568          <div class="peach-settings-group peach-codex-pdf-import">569            <p class="peach-codex-restore-headline">reading your codex…</p>570            <Show when={pdfProgress()}>571              {(p) => (572                <>573                  <div class="peach-codex-progress-track">574                    <div575                      class="peach-codex-progress-fill"576                      style={{577                        width: `${Math.round((p().page / p().total) * 100)}%`,578                      }}579                    />580                  </div>581                  <p class="peach-codex-progress-label">582                    page {p().page} of {p().total}583                  </p>584                </>585              )}586            </Show>587          </div>588        </Show>589590        <Show591          when={592            phase() === "scanning" ||593            phase() === "ready" ||594            phase() === "restoring"595          }596        >597          <div class="peach-settings-group peach-codex-progress-group">598            <div class="peach-codex-progress-track">599              <div600                class="peach-codex-progress-fill"601                style={{ width: `${progressPercent()}%` }}602              />603            </div>604            <p class="peach-codex-progress-label">605              <Show when={stripeProgressSummary()} fallback={<span>{scanned()} codes scanned</span>}>606                {(summary) => (607                  <span>608                    {summary().ready} / {summary().total} stripes ready609                  </span>610                )}611              </Show>612              <Show when={verifyCode()}>613                <span class="peach-codex-verify-tag">{verifyCode()}</span>614              </Show>615            </p>616            <Show when={stripeProgressSummary()}>617              {(summary) => (618                <p class="peach-codex-hint peach-codex-worst-stripe">619                  least-ready stripe: {summary().worstFound} / {summary().worstRequired} codes620                </p>621              )}622            </Show>623            <p class="peach-codex-hint">{hint()}</p>624            <Show when={scanWarning()}>625              <p class="peach-hint peach-hint--warning peach-codex-warning">626                {scanWarning()}627              </p>628            </Show>629          </div>630        </Show>631632        <Show when={phase() === "ready" || phase() === "restoring"}>633          <div class="peach-settings-group peach-codex-passphrase-group">634            <label class="peach-settings-label" for="codex-passphrase">635              master password636            </label>637            <input638              id="codex-passphrase"639              type="password"640              class="peach-underline-input"641              autocomplete="current-password"642              autocorrect="off"643              autocapitalize="none"644              spellcheck={false}645              disabled={phase() === "restoring"}646              value={passphrase()}647              onInput={(e) => {648                setPassphrase((e.target as HTMLInputElement).value);649                if (restoreError()) setRestoreError(null);650              }}651              onKeyDown={(e) => {652                if (e.key === "Enter" && phase() === "ready") {653                  void handleRestore();654                }655              }}656            />657            <Show when={restoreError()}>658              <p class="peach-hint peach-hint-error">{restoreError()}</p>659            </Show>660            <button661              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"662              disabled={phase() === "restoring" || passphrase().length === 0}663              onClick={() => void handleRestore()}664            >665              {phase() === "restoring" ? "restoring…" : "restore vault"}666            </button>667          </div>668        </Show>669670        <Show when={phase() === "success"}>671          <div class="peach-settings-group peach-codex-done">672            <p class="peach-hint peach-hint--success">✓ vault restored</p>673            <p class="peach-codex-meta">674              {restoredCount()}{" "}675              {restoredCount() === 1 ? "entry" : "entries"} recovered.676            </p>677            <p class="peach-codex-footnote">678              your codex has been read and your vault is unlocked.679            </p>680          </div>681        </Show>682683        <Show when={phase() !== "success"}>684          <button685            class="peach-btn peach-btn--ghost peach-btn--sm peach-btn--full peach-codex-cancel-btn"686            onClick={props.onBack}687          >688            cancel689          </button>690        </Show>691      </div>692    </div>693  );694}