CodexRestoreView.tsx

12.4 KB · raw

1// Peach Codex — restoration view.2//3// Drives the four steps of restoring a vault from a printed Codex:4//   1. Camera scan — Aztec codes feed parseRawScan → ChunkBuffer.5//   2. Once ChunkBuffer.isReady(), prompt for the master passphrase.6//   3. Reconstruct + integrity-verify locally (popup) via reconstructAndVerify.7//   4. Hand the payload to the SW (RESTORE_FROM_CODEX) for actual decrypt8//      and replace.9//10// Restoration is intentionally NOT Pro-gated — a printed Codex must remain11// restorable forever, regardless of license state.1213import { createSignal, onCleanup, onMount, Show } from "solid-js";14import { sendMessage } from "../../shared/bridge";15import {16  ChunkBuffer,17  parseRawScan,18  reconstructAndVerify,19} from "../codex/restore";20import { startScanner } from "../codex/scanner";21import type { ScannerHandle } from "../codex/scanner";22import type { CodexPayload } from "../../shared/codex-types";2324interface CodexRestoreViewProps {25  onBack: () => void;26  onRestoreSuccess: () => void;27}2829type Phase = "scanning" | "ready" | "restoring" | "success" | "error";3031export function CodexRestoreView(props: CodexRestoreViewProps) {32  // The buffer is intentionally not stored in a signal — its identity is33  // stable across the lifetime of the view and we read it through the34  // `size()`/`isReady()` methods. We mirror its observable state into35  // signals so Solid can re-render.36  const buffer = new ChunkBuffer();37  let videoEl: HTMLVideoElement | undefined;38  let scanner: ScannerHandle | null = null;3940  const [phase, setPhase] = createSignal<Phase>("scanning");41  const [scanned, setScanned] = createSignal(0);42  const [totalShards, setTotalShards] = createSignal<number | null>(null);43  const [dataShards, setDataShards] = createSignal<number | null>(null);44  const [verifyCode, setVerifyCode] = createSignal<string | null>(null);45  const [hint, setHint] = createSignal(46    "point your camera at the first code on page 1",47  );48  const [cameraError, setCameraError] = createSignal<string | null>(null);49  const [scanWarning, setScanWarning] = createSignal<string | null>(null);50  // Hold the reconstructed payload between the integrity check and the SW51  // call so the user can re-enter the passphrase on a wrong-password failure52  // without rescanning every code.53  const [pendingPayload, setPendingPayload] = createSignal<CodexPayload | null>(54    null,55  );56  const [passphrase, setPassphrase] = createSignal("");57  const [restoreError, setRestoreError] = createSignal<string | null>(null);58  const [restoredCount, setRestoredCount] = createSignal<number | null>(null);5960  function refreshFromBuffer() {61    setScanned(buffer.size());62    const ctx = buffer.getContext();63    if (ctx) {64      setTotalShards(ctx.total);65      setDataShards(ctx.dataShards);66      setVerifyCode(ctx.verifyCode);67    }68    if (buffer.isReady() && phase() === "scanning") {69      setPhase("ready");70      setHint("scan complete. enter your master password to restore.");71    } else if (phase() === "scanning") {72      const ds = ctx?.dataShards;73      if (ds) {74        const remaining = Math.max(ds - buffer.size(), 0);75        setHint(76          remaining > 077            ? `${remaining} more ${remaining === 1 ? "code" : "codes"} needed`78            : "almost there…",79        );80      }81    }82  }8384  function explainScanReason(reason: string): string {85    switch (reason) {86      case "foreign_codex":87        return "that code belongs to a different Codex.";88      case "bad_integrity":89        return "a code's checksum failed — try again.";90      case "duplicate":91        return ""; // not surfaced — duplicates are normal as the user pans92      case "parse_error":93        return "could not read that code.";94      default:95        return "";96    }97  }9899  async function handleScan(raw: string) {100    const parsed = parseRawScan(raw);101    if ("error" in parsed) {102      // parseRawScan failures are usually a non-Peach Aztec code or a corrupt103      // frame; quietly ignore so the scanner keeps running.104      return;105    }106107    const result = await buffer.add(parsed);108    refreshFromBuffer();109    if (!result.accepted && result.reason) {110      const msg = explainScanReason(result.reason);111      if (msg) {112        setScanWarning(msg);113        // Auto-clear so the warning doesn't linger after the user moves on.114        setTimeout(() => setScanWarning(null), 2500);115      }116    }117  }118119  function handleScannerError(err: Error) {120    // Camera permission denied / no camera / hardware failure.121    setCameraError(122      err.name === "NotAllowedError"123        ? "camera access was denied. allow camera permission and reopen this screen."124        : err.message || "camera failed to start.",125    );126  }127128  onMount(async () => {129    if (!videoEl) return;130    try {131      scanner = await startScanner({132        videoEl,133        onScan: (text) => void handleScan(text),134        onError: (err) => handleScannerError(err),135      });136    } catch (err) {137      handleScannerError(err instanceof Error ? err : new Error(String(err)));138    }139  });140141  onCleanup(() => {142    if (scanner) {143      scanner.stop();144      scanner = null;145    }146    // `scanner.stop()` calls reader.reset(), which tears down the147    // MediaStream attached to the <video> element. Defensive: clear srcObject148    // in case the library leaves a reference behind after reset (observed in149    // older @zxing/library versions).150    if (videoEl?.srcObject) {151      const stream = videoEl.srcObject as MediaStream;152      stream.getTracks().forEach((t) => t.stop());153      videoEl.srcObject = null;154    }155  });156157  function explainRestoreError(error: string | undefined): string {158    if (!error) return "restoration failed. please try again.";159    if (error.includes("WRONG_PASSPHRASE") || error.includes("wrong"))160      return "wrong master password. try again.";161    if (error.includes("INVALID_VAULT_BLOB"))162      return "the reconstructed vault is unreadable. please re-scan the Codex.";163    if (error.includes("FINGERPRINT_MISMATCH"))164      return "vault integrity check failed. please re-scan the Codex.";165    return error;166  }167168  async function handleRestore() {169    if (phase() === "restoring") return;170    const pw = passphrase().trim();171    if (!pw) {172      setRestoreError("enter your master password.");173      return;174    }175176    setPhase("restoring");177    setRestoreError(null);178179    let payload = pendingPayload();180    if (!payload) {181      try {182        payload = await reconstructAndVerify(buffer);183        setPendingPayload(payload);184      } catch (err) {185        const message =186          err instanceof Error187            ? err.message188            : "the scanned codes could not be reassembled.";189        setRestoreError(message);190        setPhase("ready");191        return;192      }193    }194195    const res = (await sendMessage({196      type: "RESTORE_FROM_CODEX",197      payload: {198        vaultBlob: payload.vaultBlob,199        timestamp: payload.timestamp,200        verifyCode: payload.verifyCode,201        vaultFingerprint: payload.vaultFingerprint,202        entryCount: payload.entryCount,203        masterPassphrase: pw,204      },205    })) as {206      ok: boolean;207      data?: { entryCount: number; verifyCode: string };208      error?: string;209    };210211    if (res.ok && res.data) {212      setRestoredCount(res.data.entryCount);213      setPhase("success");214      // Tear the camera down ahead of unmount so the green light goes off the215      // instant the success screen appears.216      if (scanner) {217        scanner.stop();218        scanner = null;219      }220      // Give the user a moment to read the success message before the parent221      // routes them onward.222      setTimeout(() => props.onRestoreSuccess(), 1500);223    } else {224      setRestoreError(explainRestoreError(res.error));225      setPhase("ready");226    }227  }228229  function progressPercent(): number {230    const ds = dataShards();231    if (!ds || ds <= 0) return 0;232    return Math.min(100, Math.round((scanned() / ds) * 100));233  }234235  return (236    <div class="peach-overlay-view ov-enter-right" style="display:flex">237      <div class="peach-overlay-header">238        <button239          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"240          onClick={props.onBack}241          aria-label="Back"242        >243          <svg width="7" height="12" viewBox="0 0 7 12" fill="none"244            stroke="currentColor" stroke-width="1.1"245            stroke-linecap="round" stroke-linejoin="round">246            <path d="M6 1L1 6L6 11" />247          </svg>248        </button>249        <span class="peach-overlay-title">restore from codex</span>250        <div class="peach-overlay-spacer"></div>251      </div>252253      <div class="peach-settings-body peach-codex-body">254        <Show when={phase() !== "success"}>255          <div class="peach-codex-scanner">256            <video257              ref={(el) => (videoEl = el)}258              class="peach-codex-video"259              autoplay260              playsinline261              muted262            />263            <Show when={cameraError()}>264              <div class="peach-codex-camera-overlay">265                <p class="peach-hint peach-hint-error">{cameraError()}</p>266              </div>267            </Show>268          </div>269        </Show>270271        <Show when={phase() === "scanning" || phase() === "ready" || phase() === "restoring"}>272          <div class="peach-settings-group peach-codex-progress-group">273            <div class="peach-codex-progress-track">274              <div275                class="peach-codex-progress-fill"276                style={{ width: `${progressPercent()}%` }}277              />278            </div>279            <p class="peach-codex-progress-label">280              <Show281                when={totalShards() !== null}282                fallback={<span>{scanned()} codes scanned</span>}283              >284                <span>285                  {scanned()} / {dataShards()} codes286                </span>287              </Show>288              <Show when={verifyCode()}>289                <span class="peach-codex-verify-tag">290                  {verifyCode()}291                </span>292              </Show>293            </p>294            <p class="peach-codex-hint">{hint()}</p>295            <Show when={scanWarning()}>296              <p class="peach-hint peach-hint--warning peach-codex-warning">297                {scanWarning()}298              </p>299            </Show>300          </div>301        </Show>302303        <Show when={phase() === "ready" || phase() === "restoring"}>304          <div class="peach-settings-group peach-codex-passphrase-group">305            <label class="peach-settings-label" for="codex-passphrase">306              master password307            </label>308            <input309              id="codex-passphrase"310              type="password"311              class="peach-underline-input"312              autocomplete="current-password"313              autocorrect="off"314              autocapitalize="none"315              spellcheck={false}316              disabled={phase() === "restoring"}317              value={passphrase()}318              onInput={(e) => {319                setPassphrase((e.target as HTMLInputElement).value);320                if (restoreError()) setRestoreError(null);321              }}322              onKeyDown={(e) => {323                if (e.key === "Enter" && phase() === "ready") {324                  void handleRestore();325                }326              }}327            />328            <Show when={restoreError()}>329              <p class="peach-hint peach-hint-error">{restoreError()}</p>330            </Show>331            <button332              class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"333              disabled={phase() === "restoring" || passphrase().length === 0}334              onClick={() => void handleRestore()}335            >336              {phase() === "restoring" ? "restoring…" : "restore vault"}337            </button>338          </div>339        </Show>340341        <Show when={phase() === "success"}>342          <div class="peach-settings-group peach-codex-done">343            <p class="peach-hint peach-hint--success">✓ vault restored</p>344            <p class="peach-codex-meta">345              {restoredCount()}{" "}346              {restoredCount() === 1 ? "entry" : "entries"} recovered.347            </p>348            <p class="peach-codex-footnote">349              your Codex has been read and your vault is unlocked.350            </p>351          </div>352        </Show>353354        <Show when={phase() !== "success"}>355          <button356            class="peach-btn peach-btn--ghost peach-btn--sm peach-btn--full peach-codex-cancel-btn"357            onClick={props.onBack}358          >359            cancel360          </button>361        </Show>362      </div>363    </div>364  );365}