Lock.tsx

30.5 KB · raw

1import { Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js";2import { Capacitor } from "@capacitor/core";34import { sendMessage } from "../App";5import { LockRuneCanvas } from "../components/LockRuneCanvas";6import { PeachMark, PeachWordmark } from "../components/PeachMark";7import { ImportView } from "./Import";8import { biometricUnlock, biometricAvailable } from "../../shared/biometric";910// Lazy-load zxcvbn — it's heavy (~800 kB) and only needed during vault creation11let _zxcvbn: ((password: string) => { score: number }) | null = null;12async function getZxcvbn() {13  if (_zxcvbn) return _zxcvbn;14  const mod = await import("zxcvbn");15  _zxcvbn = mod.default;16  return _zxcvbn;17}1819interface LockProps {20  hasVault: boolean;21  ready: boolean;22  isPro?: boolean;23  onUnlock: () => void | Promise<void>;24  /** Optional escape hatch: rewind from the create-vault screen back to25   *  Onboarding. Only meaningful when the user reached create-vault from26   *  Onboarding in the first place. */27  onBackToOnboarding?: () => void;28}2930// "restore" = picker, "peachscript" / "file" = detail flows,31// "mayan" = Mayan-code import (decoder not shipped yet — picker exposes a "soon" card).32type RestoreSurface = "restore" | "peachscript" | "file" | "mayan" | null;3334export function LockView(props: LockProps) {35  const [password, setPassword] = createSignal("");36  const [confirmPassword, setConfirmPassword] = createSignal("");37  const [error, setError] = createSignal("");38  const [loading, setLoading] = createSignal(false);39  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);40  const [showPassword, setShowPassword] = createSignal(false);4142  const MIN_LENGTH = 12;43  const MIN_ZXCVBN_SCORE = 2;4445  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);4647  const canCreate = createMemo(48    () =>49      password().length >= MIN_LENGTH &&50      confirmPassword().length >= MIN_LENGTH &&51      password() === confirmPassword() &&52      strengthScore() !== null &&53      strengthScore()! >= MIN_ZXCVBN_SCORE54  );5556  const passwordStrength = createMemo(() => {57    const score = strengthScore();58    if (score === null) return null;59    if (score <= 1) return "weak";60    if (score === 2) return "fair";61    if (score === 3) return "good";62    return "strong";63  });6465  const createBlockerMessage = createMemo(() => {66    if (!password()) return null;67    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;68    if (!confirmPassword()) return "confirm your password";69    if (password() !== confirmPassword()) return "passwords don't match";70    if (strengthScore() === null) return "checking strength…";71    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";72    return null;73  });7475  // Update strength asynchronously when password changes76  createMemo(() => {77    const value = password();78    if (!value) { setStrengthScore(null); return; }79    getZxcvbn().then((zxcvbn) => {80      if (password() === value) setStrengthScore(zxcvbn(value).score);81    }).catch(() => {82      // zxcvbn unavailable — fall back to length-only heuristic83      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);84    });85  });8687  async function handleUnlock() {88    if (!password().trim()) return;89    setLoading(true);90    setError("");91    const response = await sendMessage({92      type: "UNLOCK",93      payload: { password: password() },94    });95    const result = response as { ok: boolean; error?: string };96    if (result.ok) {97      await props.onUnlock();98      setLoading(false);99      return;100    }101    setError(getDisplayError(result.error, "incorrect password"));102    setPassword("");103    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;104    passwordField?.classList.add("shake");105    setTimeout(() => passwordField?.classList.remove("shake"), 300);106    setLoading(false);107  }108109  async function handleCreate() {110    if (!password().trim()) return;111    if (password().length < MIN_LENGTH) {112      setError(`password must be at least ${MIN_LENGTH} characters`);113      return;114    }115    if (password() !== confirmPassword()) {116      setError("passwords do not match");117      return;118    }119    const score = strengthScore();120    if (score === null || score < MIN_ZXCVBN_SCORE) {121      setError("password is too weak — try a longer or more varied passphrase");122      return;123    }124    setLoading(true);125    setError("");126    const response = await sendMessage({127      type: "CREATE_VAULT",128      payload: { password: password() },129    });130    const result = response as { ok: boolean; error?: string };131    if (result.ok) {132      await props.onUnlock();133      setLoading(false);134      return;135    }136    setError(getDisplayError(result.error, "failed to create vault"));137    setLoading(false);138  }139140  function openRestore(surface: Exclude<RestoreSurface, null>) {141    if (!props.ready || loading()) return;142    setPassword("");143    setConfirmPassword("");144    setError("");145    setRestoreSurface(surface);146  }147148  // ── All surfaces routed reactively via Switch/Match ───────────────────149  return (150    <Switch>151      {/* ── Restore picker ────────────────────────────────────────── */}152      <Match when={restoreSurface() === "restore"}>153        <RestorePickerView154          onBack={() => setRestoreSurface(null)}155          onPickPeachScript={() => setRestoreSurface("peachscript")}156          onPickMayan={() => setRestoreSurface("mayan")}157          onPickCSV={() => setRestoreSurface("file")}158        />159      </Match>160161      {/* ── Mayan code restore (decoder coming soon) ──────────────── */}162      <Match when={restoreSurface() === "mayan"}>163        <MayanRestoreView164          hasVault={props.hasVault}165          onBack={() => setRestoreSurface("restore")}166          onUnlock={async () => {167            setRestoreSurface(null);168            await props.onUnlock();169          }}170        />171      </Match>172173      {/* ── PeachScript restore ───────────────────────────────────── */}174      <Match when={restoreSurface() === "peachscript"}>175        <ImportView176          mode="onboarding"177          replacesExistingVault={props.hasVault}178          initialView="peachscript"179          onBack={() => setRestoreSurface("restore")}180          onImport={async () => {181            setRestoreSurface(null);182            await props.onUnlock();183          }}184        />185      </Match>186187      {/* ── File restore ──────────────────────────────────────────── */}188      <Match when={restoreSurface() === "file"}>189        <ImportView190          mode="onboarding"191          replacesExistingVault={props.hasVault}192          initialView="file"193          onBack={() => setRestoreSurface("restore")}194          onImport={async () => {195            setRestoreSurface(null);196            await props.onUnlock();197          }}198        />199      </Match>200201      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}202      <Match when={props.hasVault}>203        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">204          <header class="peach-screen-header lock-screen-header">205            <div class="peach-wordmark-stack">206              <PeachWordmark class="peach-wordmark-wrap" />207              <Show when={props.isPro}>208                <span class="peach-pro-mini-badge">pro</span>209              </Show>210            </div>211          </header>212213          <div class="peach-lock-hero peach-lock-hero--unlock">214            <div class="peach-lock-hero-art" aria-hidden="true">215              <PeachMark class="peach-lock-hero-mark" />216            </div>217            <span class="peach-lock-eyebrow">vault locked</span>218            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">219              unlock220              <br />221              your vault.222            </h1>223          </div>224225          <LockRuneCanvas />226227          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">228            <label class="peach-label" for="unlock-password">229              master password230            </label>231            <input232              id="unlock-password"233              class="peach-underline-input peach-unlock-input lock-password-input"234              type="password"235              placeholder="••••••••••••"236              autocomplete="current-password"237              value={password()}238              disabled={!props.ready}239              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}240              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}241              onKeyDown={(event) => {242                if (event.key !== "Enter" || !props.ready) return;243                void handleUnlock();244              }}245            />246247            <Show when={error()}>248              <p class="peach-hint peach-hint-error">{error()}</p>249            </Show>250251            <button252              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"253              onClick={() => void handleUnlock()}254              disabled={!props.ready || loading()}255            >256              {loading() ? "unlocking..." : "unlock"}257            </button>258259            <Show when={Capacitor.isNativePlatform()}>260              <BiometricUnlockButton ready={props.ready} loading={loading} onUnlock={props.onUnlock} />261            </Show>262          </section>263        </section>264      </Match>265266      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}267      <Match when={true}>268        <section269          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"270          data-motion-phase="enter"271        >272          <header class="peach-screen-header peach-newvault-header">273            <Show when={props.onBackToOnboarding}>274              <button275                class="peach-newvault-back"276                type="button"277                onClick={() => props.onBackToOnboarding?.()}278                aria-label="Back to onboarding"279              >280                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">281                  <path d="M7 2L3 5.5L7 9" />282                </svg>283                back284              </button>285            </Show>286            <div class="peach-wordmark-stack">287              <PeachWordmark class="peach-wordmark-wrap" />288              <Show when={props.isPro}>289                <span class="peach-pro-mini-badge">pro</span>290              </Show>291            </div>292          </header>293294          <div class="peach-newvault-mark" aria-hidden="true">295            <PeachMark class="peach-newvault-mark-art" />296          </div>297298          <div class="peach-newvault-card">299            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>300            <hr class="peach-newvault-divider" />301302            <div class="peach-newvault-field">303              <div class="peach-newvault-label-row">304                <label class="peach-label" for="master-password">305                  master password306                </label>307                <button308                  class="peach-lock-reveal-btn"309                  type="button"310                  onClick={() => setShowPassword((value) => !value)}311                  aria-label={showPassword() ? "Hide password" : "Show password"}312                >313                  <Show314                    when={showPassword()}315                    fallback={316                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">317                        <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />318                        <circle cx="7" cy="5" r="1.75" />319                      </svg>320                    }321                  >322                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">323                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />324                      <path d="M3.5 5.5L2.5 7.5" />325                      <path d="M7 6.5V8.5" />326                      <path d="M10.5 5.5L11.5 7.5" />327                    </svg>328                  </Show>329                  {showPassword() ? "hide" : "show"}330                </button>331              </div>332              <input333                id="master-password"334                class="peach-underline-input peach-newvault-input lock-password-input"335                type={showPassword() ? "text" : "password"}336                placeholder="••••••••••••"337                autocomplete="new-password"338                value={password()}339                disabled={!props.ready}340                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}341                onKeyDown={(event) => {342                  if (event.key !== "Enter" || !props.ready) return;343                  void handleCreate();344                }}345              />346              <Show347                when={password().length > 0 && passwordStrength() !== null}348                fallback={349                  <p class="peach-newvault-hint">at least 12 characters</p>350                }351              >352                <div class="peach-password-strength">353                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>354                    <div class="peach-strength-fill"></div>355                  </div>356                  <span class="peach-strength-label">{passwordStrength()}</span>357                </div>358              </Show>359            </div>360361            <div class="peach-newvault-field">362              <label class="peach-label" for="confirm-password">363                confirm password364              </label>365              <input366                id="confirm-password"367                class="peach-underline-input peach-newvault-input"368                type={showPassword() ? "text" : "password"}369                placeholder="••••••••••••"370                autocomplete="new-password"371                value={confirmPassword()}372                disabled={!props.ready}373                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}374                onKeyDown={(event) => {375                  if (event.key === "Enter" && props.ready) void handleCreate();376                }}377              />378            </div>379380            <Show when={error()}>381              <p class="peach-hint peach-hint-error">{error()}</p>382            </Show>383384            <button385              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"386              onClick={() => void handleCreate()}387              disabled={!props.ready || loading() || !canCreate()}388              aria-live="polite"389            >390              <Show391                when={!canCreate() && createBlockerMessage()}392                fallback={loading() ? "creating..." : "create vault"}393              >394                {createBlockerMessage()}395              </Show>396            </button>397398            <p class="peach-newvault-warning">399              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>400              <span>If you forget it, your vault cannot be opened. There is no reset.</span>401            </p>402          </div>403404          <div class="peach-newvault-footer">405            <p class="peach-hint">Already have a backup?</p>406            <button407              class="peach-lock-restore-link"408              onClick={() => openRestore("restore")}409              disabled={!props.ready}410            >411              › restore from backup412            </button>413          </div>414        </section>415      </Match>416    </Switch>417  );418}419420// ── Restore method picker ──────────────────────────────────────────────────421422function RestorePickerView(props: {423  onBack: () => void;424  onPickPeachScript: () => void;425  onPickMayan: () => void;426  onPickCSV: () => void;427}) {428  return (429    <section430      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"431      data-motion-phase="enter"432    >433      <header class="peach-screen-header lock-screen-header">434        <PeachWordmark class="peach-wordmark-wrap" />435        <button class="peach-lock-restore-link" onClick={props.onBack}>436          ‹ back437        </button>438      </header>439440      <div class="peach-lock-hero">441        <span class="peach-lock-eyebrow">restore</span>442        <h1 class="peach-onboarding-headline">443          bring back444          <br />445          your vault.446        </h1>447      </div>448449      <div class="peach-restore-stack" role="list">450        <button451          class="peach-restore-card"452          role="listitem"453          onClick={props.onPickPeachScript}454        >455          <h2 class="peach-restore-card-headline">PeachScript</h2>456          <hr class="peach-restore-card-divider" />457          <p class="peach-restore-card-body">458            Paste a string of Peach symbols, or open a <code>.peach</code> file459            you saved from another device.460          </p>461          <p class="peach-restore-card-meta">backup password required</p>462        </button>463464        <button465          class="peach-restore-card"466          role="listitem"467          onClick={props.onPickMayan}468        >469          <h2 class="peach-restore-card-headline">Mayan code</h2>470          <hr class="peach-restore-card-divider" />471          <p class="peach-restore-card-body">472            Restore from a printed Mayan code — scan or upload a photo.473          </p>474          <p class="peach-restore-card-meta">decoder shipping soon</p>475        </button>476477        <button478          class="peach-restore-card"479          role="listitem"480          onClick={props.onPickCSV}481        >482          <h2 class="peach-restore-card-headline">CSV</h2>483          <hr class="peach-restore-card-divider" />484          <p class="peach-restore-card-body">485            Move in from Bitwarden, 1Password, Proton Pass, or a browser486            (Chrome, Safari, Edge) CSV export.487          </p>488          <p class="peach-restore-card-meta">plain-text — open the file once and delete it</p>489        </button>490      </div>491492    </section>493  );494}495496// ── Mayan code restore ─────────────────────────────────────────────────────497498/**499 * Decode a base64-encoded Mayan code image to the binary backup payload.500 * Returns either { ok: true, payload } or { ok: false, reason } so the UI501 * can surface a precise error per failure mode.502 */503async function handleMayanDecode(base64: string): Promise<504  { ok: true; payload: Uint8Array } | { ok: false; reason: string }505> {506  const { decodeMayan } = await import("../../crypto/mayan");507  const img = new Image();508  const dataUrl = base64.startsWith("data:") ? base64 : `data:image/*;base64,${base64}`;509  await new Promise<void>((resolve, reject) => {510    img.onload = () => resolve();511    img.onerror = () => reject(new Error("Couldn't read the image."));512    img.src = dataUrl;513  });514  const canvas = document.createElement("canvas");515  // Cap resolution to keep adaptive-binarize bounded — Mayan codes don't need516  // more than ~1500×1500 to read every module on a phone-camera capture.517  const MAX_DIM = 1500;518  const scale = Math.min(1, MAX_DIM / Math.max(img.width, img.height));519  canvas.width = Math.round(img.width * scale);520  canvas.height = Math.round(img.height * scale);521  const ctx = canvas.getContext("2d");522  if (!ctx) return { ok: false, reason: "Couldn't open a 2D canvas." };523  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);524  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);525  const payload = decodeMayan(imageData);526  if (!payload) {527    return {528      ok: false,529      reason:530        "Couldn't read this Mayan code. Try a closer, sharper photo with even lighting.",531    };532  }533  return { ok: true, payload };534}535536function MayanRestoreView(props: {537  hasVault: boolean;538  onBack: () => void;539  onUnlock: () => void | Promise<void>;540}) {541  const [decodeError, setDecodeError] = createSignal<string | null>(null);542  const [decoding, setDecoding] = createSignal(false);543  const [decodedBytes, setDecodedBytes] = createSignal<Uint8Array | null>(null);544  const [backupPass, setBackupPass] = createSignal("");545  const [masterPass, setMasterPass] = createSignal("");546  const [masterConfirm, setMasterConfirm] = createSignal("");547  const [restoring, setRestoring] = createSignal(false);548549  const isAndroid = createMemo(() =>550    Capacitor.isNativePlatform() && Capacitor.getPlatform() === "android",551  );552  const masterReady = createMemo(553    () => masterPass().length >= 8 && masterPass() === masterConfirm(),554  );555556  async function processBase64(base64: string) {557    setDecoding(true);558    setDecodeError(null);559    try {560      const result = await handleMayanDecode(base64);561      if (!result.ok) {562        setDecodeError(result.reason);563        return;564      }565      setDecodedBytes(result.payload);566    } finally {567      setDecoding(false);568    }569  }570571  async function handleCameraScan() {572    setDecodeError(null);573    try {574      const { Camera, CameraResultType, CameraSource } = await import("@capacitor/camera");575      const image = await Camera.getPhoto({576        quality: 90,577        resultType: CameraResultType.Base64,578        source: CameraSource.Camera,579        allowEditing: false,580      });581      if (image.base64String) await processBase64(image.base64String);582    } catch (err) {583      const msg = err instanceof Error ? err.message : "";584      if (msg && !/cancel/i.test(msg)) setDecodeError(msg);585    }586  }587588  function handleFileUpload(e: Event) {589    const input = e.currentTarget as HTMLInputElement;590    const file = input.files?.[0];591    if (!file) return;592    const reader = new FileReader();593    reader.onload = () => {594      const result = reader.result;595      if (typeof result !== "string") return;596      void processBase64(result);597    };598    reader.readAsDataURL(file);599  }600601  async function handleRestore() {602    const bytes = decodedBytes();603    if (!bytes) return;604    if (!backupPass().trim()) {605      setDecodeError("Enter the backup password.");606      return;607    }608    if (!props.hasVault && !masterReady()) {609      setDecodeError("Choose a master password (≥8 chars, must match).");610      return;611    }612    setRestoring(true);613    setDecodeError(null);614    try {615      const { parsePayload } = await import("../../crypto/peachPayload");616      const payload = await parsePayload(bytes, backupPass());617      if (!props.hasVault) {618        const createRes = await sendMessage({619          type: "CREATE_VAULT",620          payload: { password: masterPass() },621        });622        if (!(createRes as { ok: boolean }).ok) {623          throw new Error("CREATE_VAULT_FAILED");624        }625      }626      const restoreRes = await sendMessage({627        type: "RESTORE_ENTRIES",628        payload: {629          entries: payload.entries,630          mode: "merge",631          settings: payload.settings,632        },633      });634      const env = restoreRes as { ok: boolean; error?: string };635      if (!env.ok) throw new Error(env.error ?? "RESTORE_FAILED");636      if (payload.license?.key) {637        await sendMessage({638          type: "ACTIVATE_LICENSE",639          payload: { key: payload.license.key },640        }).catch(() => {});641      }642      await props.onUnlock();643    } catch (err) {644      const code = err instanceof Error ? err.message : String(err);645      setDecodeError(646        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"647          ? "Wrong backup password — this is the password you set when you created the Mayan code."648          : code === "CREATE_VAULT_FAILED"649            ? "Couldn't create a new local vault."650            : "Restore failed. The backup may be corrupted.",651      );652    } finally {653      setRestoring(false);654    }655  }656657  return (658    <section659      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"660      data-motion-phase="enter"661    >662      <header class="peach-screen-header lock-screen-header">663        <PeachWordmark class="peach-wordmark-wrap" />664        <button class="peach-lock-restore-link" onClick={props.onBack}>665          ‹ back666        </button>667      </header>668669      <div class="peach-lock-hero">670        <span class="peach-lock-eyebrow">mayan code</span>671        <h1 class="peach-onboarding-headline">672          {decodedBytes() ? "enter password." : "scan or upload."}673        </h1>674      </div>675676      <Show677        when={decodedBytes() === null}678        fallback={679          <div class="peach-restore-stack">680            <div class="peach-settings-group peach-export-card peach-export-card--primary">681              <p class="peach-settings-label">backup password</p>682              <input683                ref={(el) => queueMicrotask(() => el.focus())}684                class="peach-settings-input"685                type="password"686                placeholder="backup password"687                value={backupPass()}688                onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}689                onKeyDown={(e) => {690                  if (e.key === "Enter" && !restoring()) {691                    e.preventDefault();692                    void handleRestore();693                  }694                }}695              />696              <Show when={!props.hasVault}>697                <p class="peach-settings-label" style="margin-top:12px">698                  master password699                </p>700                <input701                  class="peach-settings-input"702                  type="password"703                  placeholder="master password (min 8 chars)"704                  value={masterPass()}705                  onInput={(e) => setMasterPass((e.target as HTMLInputElement).value)}706                />707                <input708                  class="peach-settings-input"709                  type="password"710                  placeholder="confirm master password"711                  value={masterConfirm()}712                  onInput={(e) => setMasterConfirm((e.target as HTMLInputElement).value)}713                />714              </Show>715              <button716                class="peach-btn peach-btn--subtle peach-btn--sm"717                style="margin-top:12px"718                onClick={() => void handleRestore()}719                disabled={restoring()}720              >721                {restoring() ? "restoring…" : "Restore"}722              </button>723            </div>724            <Show when={decodeError()}>725              <div class="peach-restore-card peach-restore-card--static">726                <p class="peach-restore-card-body">{decodeError()}</p>727              </div>728            </Show>729          </div>730        }731      >732        <div class="peach-restore-stack">733          <Show when={isAndroid()}>734            <button735              class="peach-restore-card"736              disabled={decoding()}737              onClick={() => void handleCameraScan()}738            >739              <h2 class="peach-restore-card-headline">Scan with camera</h2>740              <hr class="peach-restore-card-divider" />741              <p class="peach-restore-card-body">742                Point your camera at the printed Mayan code.743              </p>744            </button>745          </Show>746747          <label748            class="peach-restore-card"749            classList={{ "peach-restore-card--disabled": decoding() }}750          >751            <h2 class="peach-restore-card-headline">Choose from gallery</h2>752            <hr class="peach-restore-card-divider" />753            <p class="peach-restore-card-body">754              {decoding() ? "decoding…" : "Pick a saved photo of your Mayan code."}755            </p>756            <input757              type="file"758              accept="image/*"759              style="display:none"760              onChange={handleFileUpload}761              disabled={decoding()}762            />763          </label>764765          <Show when={decodeError()}>766            <div class="peach-restore-card peach-restore-card--static">767              <p class="peach-restore-card-body">{decodeError()}</p>768            </div>769          </Show>770        </div>771      </Show>772773      <div class="peach-restore-footer">774        <button class="peach-restore-footer-link" onClick={props.onBack}>775          ‹ back to restore776        </button>777      </div>778    </section>779  );780}781782// ── Biometric unlock ────────────────────────────────────────────────────────783784async function handleBiometricUnlock(props: { onUnlock: () => void | Promise<void> }) {785  const unlocked = await biometricUnlock();786  if (unlocked) {787    await props.onUnlock();788  }789}790791function BiometricUnlockButton(props: { ready: boolean; loading: () => boolean; onUnlock: () => void | Promise<void> }) {792  const [bioAvailable, setBioAvailable] = createSignal(false);793  onMount(async () => {794    setBioAvailable(await biometricAvailable());795  });796  return (797    <Show when={bioAvailable()}>798      <button799        class="peach-btn peach-btn--subtle peach-btn--md peach-biometric-btn"800        onClick={() => void handleBiometricUnlock(props)}801        disabled={!props.ready || props.loading()}802        type="button"803      >804        <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">805          <path d="M8 1a3 3 0 0 0-3 3v4a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />806          <path d="M5 8v1a3 3 0 0 0 6 0V8" />807          <line x1="8" y1="12" x2="8" y2="15" />808          <line x1="5" y1="15" x2="11" y2="15" />809        </svg>810        use biometrics811      </button>812    </Show>813  );814}815816// ── Helpers ────────────────────────────────────────────────────────────────817818function getDisplayError(error: string | undefined, fallback: string): string {819  if (!error) return fallback;820  if (821    error === "SERVICE_WORKER_UNAVAILABLE" ||822    error.includes("Receiving end does not exist") ||823    error.includes("message port closed")824  ) {825    return "background service unavailable";826  }827  return error;828}