Lock.tsx

37.0 KB · raw

1import { For, Match, Show, Switch, createMemo, createSignal, onCleanup, onMount } from "solid-js";2import { sendMessage } from "../App";3import { LockRuneCanvas } from "../components/LockRuneCanvas";4import { PeachMark, PeachWordmark } from "../components/PeachMark";5import { RecoveryWordEntryView } from "./RecoveryWordEntry";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import { RuneBackground } from "../components/RuneBackground";8import { storageGet, storageSet } from "../../shared/storage";9import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";10import { parsePayload } from "../../crypto/peachPayload";11import { parseImportSource } from "../../shared/import";12import { isNativePlatform, nativePlugin } from "../../shared/native";1314const peachMarkImg = new Image();15peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(16  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" fill="none">' +17  '<path d="M 124 64 C 134 32 168 16 200 22 C 198 56 168 72 130 66 Z" fill="#92AC65"/>' +18  '<path d="M 130 64 C 152 46 178 30 196 24" stroke="#5E7A42" stroke-width="2.6" fill="none" stroke-linecap="round"/>' +19  '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 185 222 222 185 220 130 C 218 78 170 45 120 60 Z" fill="#FFB082"/>' +20  '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 105 195 75 150 100 90 C 105 75 112 65 120 60 Z" fill="#E5805C"/>' +21  '<ellipse cx="178" cy="108" rx="20" ry="11" fill="#FFD0A8" opacity="0.7" transform="rotate(-26 178 108)"/>' +22  '</svg>'23)}`;2425// Lazy-load zxcvbn — it's heavy (~800 kB) and only needed during vault creation26let _zxcvbn: ((password: string) => { score: number }) | null = null;27async function getZxcvbn() {28  if (_zxcvbn) return _zxcvbn;29  const mod = await import("zxcvbn");30  _zxcvbn = mod.default;31  return _zxcvbn;32}3334interface LockProps {35  hasVault: boolean;36  ready: boolean;37  isPro?: boolean;38  /** When true, render the recovery-words display directly instead of the39   *  unlock UI. Used after a popup close+reopen during the create-vault40   *  recovery confirmation flow (peach_recovery_pending flag). */41  forceRecoveryWords?: boolean;42  onUnlock: () => void | Promise<void>;43  /** Optional escape hatch: rewind from the create-vault screen back to44   *  Onboarding. Only meaningful when the user reached create-vault from45   *  Onboarding in the first place. */46  onBackToOnboarding?: () => void;47  /** Optional: open the Peach Codex restore flow (camera scan of printed48   *  Codex pages). Surfaced as a small footer link on the new-vault screen. */49  onOpenCodexRestore?: () => void;50}5152type RestoreSurface = "restore" | null;53type RecoverSurface = "words" | "new-password" | null;5455export function LockView(props: LockProps) {56  const [password, setPassword] = createSignal("");57  const [confirmPassword, setConfirmPassword] = createSignal("");58  const [error, setError] = createSignal("");59  const [loading, setLoading] = createSignal(false);60  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);61  const [showPassword, setShowPassword] = createSignal(false);62  const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);63  // After vault creation succeeds, set this to show the recovery words screen.64  // Only meaningful when hasVault === false.65  const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);6667  const MIN_LENGTH = 12;68  const MIN_ZXCVBN_SCORE = 2;6970  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);71  const [biometricAvailable, setBiometricAvailable] = createSignal(false);72  const [biometricBusy, setBiometricBusy] = createSignal(false);7374  const canCreate = createMemo(75    () =>76      password().length >= MIN_LENGTH &&77      confirmPassword().length >= MIN_LENGTH &&78      password() === confirmPassword() &&79      strengthScore() !== null &&80      strengthScore()! >= MIN_ZXCVBN_SCORE81  );8283  const passwordStrength = createMemo(() => {84    const score = strengthScore();85    if (score === null) return null;86    if (score <= 1) return "weak";87    if (score === 2) return "fair";88    if (score === 3) return "good";89    return "strong";90  });9192  const createBlockerMessage = createMemo(() => {93    if (!password()) return null;94    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;95    if (!confirmPassword()) return "confirm your password";96    if (password() !== confirmPassword()) return "passwords don't match";97    if (strengthScore() === null) return "checking strength…";98    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";99    return null;100  });101102  // Update strength asynchronously when password changes103  createMemo(() => {104    const value = password();105    if (!value) { setStrengthScore(null); return; }106    getZxcvbn().then((zxcvbn) => {107      if (password() === value) setStrengthScore(zxcvbn(value).score);108    }).catch(() => {109      // zxcvbn unavailable — fall back to length-only heuristic110      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);111    });112  });113114  async function handleUnlock() {115    if (!password().trim()) return;116    setLoading(true);117    setError("");118    const response = await sendMessage({119      type: "UNLOCK",120      payload: { password: password() },121    });122    const result = response as { ok: boolean; error?: string };123    if (result.ok) {124      await props.onUnlock();125      setLoading(false);126      return;127    }128    setError(getDisplayError(result.error, "incorrect password"));129    setPassword("");130    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;131    passwordField?.classList.add("shake");132    setTimeout(() => passwordField?.classList.remove("shake"), 300);133    setLoading(false);134  }135136  async function checkBiometricAvailable(): Promise<void> {137    if (!props.hasVault || !isNativePlatform()) return;138    const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");139    const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");140    if (!keystore || !biometric) return;141    try {142      const stored = await keystore.hasMasterKey();143      if (!stored.exists) return;144      const avail = await biometric.isAvailable();145      if (!avail.available) return;146      setBiometricAvailable(true);147      window.setTimeout(() => { void handleBiometricUnlock(); }, 400);148    } catch { /* missing plugins or runtime error — silently skip */ }149  }150151  async function handleBiometricUnlock(): Promise<void> {152    if (biometricBusy()) return;153    const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");154    const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");155    if (!biometric || !keystore) return;156    setBiometricBusy(true);157    setError("");158    try {159      await biometric.authenticate({160        title: "Unlock Peach",161        subtitle: "Use biometrics to access your vault",162        negativeButtonText: "Use Password",163      });164      const { masterKeyHex } = await keystore.retrieveMasterKey();165      const res = (await sendMessage({166        type: "UNLOCK_WITH_KEY",167        payload: { masterKeyHex },168      })) as { ok: boolean; error?: string };169      if (res.ok) {170        await props.onUnlock();171        return;172      }173      if (res.error === "biometric_key_invalidated") {174        await sendMessage({175          type: "UPDATE_SETTINGS",176          payload: { biometricsEnabled: false },177        });178        setBiometricAvailable(false);179        setError("biometric key expired — re-enable in settings");180      } else {181        setError("biometric unlock failed");182      }183    } catch (e) {184      const reason = e instanceof Error ? e.message : String(e);185      if (reason.includes("user_cancelled")) return;186      if (reason.includes("biometric_key_invalidated")) {187        await sendMessage({188          type: "UPDATE_SETTINGS",189          payload: { biometricsEnabled: false },190        });191        setBiometricAvailable(false);192        setError("biometric key expired — re-enable in settings");193        return;194      }195      if (reason.includes("biometric_lockout")) {196        setError("biometric locked out — use your password");197      }198    } finally {199      setBiometricBusy(false);200    }201  }202203  onMount(() => { void checkBiometricAvailable(); });204205  async function handleCreate() {206    if (!password().trim()) return;207    if (password().length < MIN_LENGTH) {208      setError(`password must be at least ${MIN_LENGTH} characters`);209      return;210    }211    if (password() !== confirmPassword()) {212      setError("passwords do not match");213      return;214    }215    const score = strengthScore();216    if (score === null || score < MIN_ZXCVBN_SCORE) {217      setError("password is too weak — try a longer or more varied passphrase");218      return;219    }220    setLoading(true);221    setError("");222    const response = await sendMessage({223      type: "CREATE_VAULT",224      payload: { password: password() },225    });226    const result = response as { ok: boolean; error?: string };227    if (result.ok) {228      // Don't unlock yet — show the recovery words screen first. Mark the229      // confirmation as pending in chrome.storage.local so it survives both230      // popup close (lock-on-close skips while set) and full browser restart231      // (next unlock re-routes to the recovery surface). Cleared when the232      // user types the 3 confirmation words successfully.233      try {234        await chrome.storage.local?.set({ peach_recovery_pending: "1" });235      } catch { /* best-effort */ }236      setShowRecoveryWords(true);237      setLoading(false);238      return;239    }240    setError(getDisplayError(result.error, "failed to create vault"));241    setLoading(false);242  }243244  function openRestore(surface: Exclude<RestoreSurface, null>) {245    if (!props.ready || loading()) return;246    setPassword("");247    setConfirmPassword("");248    setError("");249    setRestoreSurface(surface);250  }251252  // ── All surfaces routed reactively via Switch/Match ───────────────────253  return (254    <Switch>255      {/* ── Restore tab view ──────────────────────────────────────── */}256      <Match when={restoreSurface() === "restore"}>257        <RestoreTabView258          hasVault={props.hasVault}259          onBack={() => setRestoreSurface(null)}260          onUnlock={async () => {261            setRestoreSurface(null);262            await props.onUnlock();263          }}264        />265      </Match>266267      {/* ── Recovery words display after fresh vault creation ────────── */}268      <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>269        <RecoveryWordDisplayView270          onDone={props.onUnlock}271        />272      </Match>273274      {/* ── Recovery word entry ─────────────────────────────────────── */}275      <Match when={recoverSurface() === "words"}>276        <RecoveryWordEntryView277          onCancel={() => setRecoverSurface(null)}278          onRecoveryComplete={props.onUnlock}279        />280      </Match>281282      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}283      <Match when={props.hasVault}>284        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">285          <header class="peach-screen-header lock-screen-header">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-lock-hero peach-lock-hero--unlock">295            <div class="peach-lock-hero-art" aria-hidden="true">296              <PeachMark class="peach-lock-hero-mark" />297            </div>298            <span class="peach-lock-eyebrow">vault locked</span>299            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">300              unlock301              <br />302              your vault.303            </h1>304          </div>305306          <LockRuneCanvas />307308          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">309            <label class="peach-label" for="unlock-password">310              master password311            </label>312            <input313              id="unlock-password"314              class="peach-underline-input peach-unlock-input lock-password-input"315              type="password"316              placeholder="••••••••••••"317              autocomplete="current-password"318              value={password()}319              disabled={!props.ready}320              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}321              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}322              onKeyDown={(event) => {323                if (event.key !== "Enter" || !props.ready) return;324                void handleUnlock();325              }}326            />327328            <Show when={error()}>329              <p class="peach-hint peach-hint-error">{error()}</p>330            </Show>331332            <button333              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"334              onClick={() => void handleUnlock()}335              disabled={!props.ready || loading()}336            >337              {loading() ? "unlocking..." : "unlock"}338            </button>339340            <Show when={biometricAvailable()}>341              <button342                class="peach-biometric-btn"343                type="button"344                onClick={() => void handleBiometricUnlock()}345                disabled={biometricBusy() || loading()}346                aria-label="Unlock with biometrics"347              >348                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">349                  <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>350                  <path d="M15 17v-3"/>351                  <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>352                  <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>353                  <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>354                  <path d="M5 15c0-1.7.3-3.3.8-4.7"/>355                  <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>356                </svg>357                {biometricBusy() ? "verifying…" : "use biometrics"}358              </button>359            </Show>360361            <button362              class="peach-lock-recover-link"363              type="button"364              onClick={() => setRecoverSurface("words")}365            >366              forgot your password?367            </button>368          </section>369        </section>370      </Match>371372      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}373      <Match when={true}>374        <section375          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"376          data-motion-phase="enter"377        >378          <header class="peach-screen-header peach-newvault-header">379            <Show when={props.onBackToOnboarding}>380              <button381                class="peach-newvault-back"382                type="button"383                onClick={() => props.onBackToOnboarding?.()}384                aria-label="Back to onboarding"385              >386                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">387                  <path d="M7 2L3 5.5L7 9" />388                </svg>389                back390              </button>391            </Show>392            <div class="peach-wordmark-stack">393              <PeachWordmark class="peach-wordmark-wrap" />394              <Show when={props.isPro}>395                <span class="peach-pro-mini-badge">pro</span>396              </Show>397            </div>398          </header>399400          <div class="peach-newvault-mark" aria-hidden="true">401            <PeachMark class="peach-newvault-mark-art" />402          </div>403404          <div class="peach-newvault-card">405            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>406            <hr class="peach-newvault-divider" />407408            <div class="peach-newvault-field">409              <div class="peach-newvault-label-row">410                <label class="peach-label" for="master-password">411                  master password412                </label>413                <button414                  class="peach-lock-reveal-btn"415                  type="button"416                  onClick={() => setShowPassword((value) => !value)}417                  aria-label={showPassword() ? "Hide password" : "Show password"}418                >419                  <Show420                    when={showPassword()}421                    fallback={422                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">423                        <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" />424                        <circle cx="7" cy="5" r="1.75" />425                      </svg>426                    }427                  >428                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">429                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />430                      <path d="M3.5 5.5L2.5 7.5" />431                      <path d="M7 6.5V8.5" />432                      <path d="M10.5 5.5L11.5 7.5" />433                    </svg>434                  </Show>435                  {showPassword() ? "hide" : "show"}436                </button>437              </div>438              <input439                id="master-password"440                class="peach-underline-input peach-newvault-input lock-password-input"441                type={showPassword() ? "text" : "password"}442                placeholder="••••••••••••"443                autocomplete="new-password"444                value={password()}445                disabled={!props.ready}446                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}447                onKeyDown={(event) => {448                  if (event.key !== "Enter" || !props.ready) return;449                  void handleCreate();450                }}451              />452              <Show453                when={password().length > 0 && passwordStrength() !== null}454                fallback={455                  <p class="peach-newvault-hint">at least 12 characters</p>456                }457              >458                <div class="peach-password-strength">459                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>460                    <div class="peach-strength-fill"></div>461                  </div>462                  <span class="peach-strength-label">{passwordStrength()}</span>463                </div>464              </Show>465            </div>466467            <div class="peach-newvault-field">468              <label class="peach-label" for="confirm-password">469                confirm password470              </label>471              <input472                id="confirm-password"473                class="peach-underline-input peach-newvault-input"474                type={showPassword() ? "text" : "password"}475                placeholder="••••••••••••"476                autocomplete="new-password"477                value={confirmPassword()}478                disabled={!props.ready}479                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}480                onKeyDown={(event) => {481                  if (event.key === "Enter" && props.ready) void handleCreate();482                }}483              />484            </div>485486            <Show when={error()}>487              <p class="peach-hint peach-hint-error">{error()}</p>488            </Show>489490            <button491              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"492              onClick={() => void handleCreate()}493              disabled={!props.ready || loading() || !canCreate()}494              aria-live="polite"495            >496              <Show497                when={!canCreate() && createBlockerMessage()}498                fallback={loading() ? "creating..." : "create vault"}499              >500                {createBlockerMessage()}501              </Show>502            </button>503504            <p class="peach-newvault-warning">505              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>506              <span>Save your 12 recovery words right after you create the vault — they're the only way in if you forget your password.</span>507            </p>508          </div>509510          <div class="peach-newvault-footer">511            <p class="peach-hint">Already have a backup?</p>512            <button513              class="peach-lock-restore-link"514              onClick={() => void openRestore("restore")}515              disabled={!props.ready}516            >517              › restore from backup518            </button>519            <Show when={props.onOpenCodexRestore}>520              <button521                class="peach-lock-restore-link"522                onClick={() => props.onOpenCodexRestore?.()}523                disabled={!props.ready}524              >525                › restore from peach codex526              </button>527            </Show>528          </div>529        </section>530      </Match>531    </Switch>532  );533}534535// ── Restore tab view ───────────────────────────────────────────────────────536537type RestoreTab = "symbols" | "csv";538539function tabLabel(tab: RestoreTab): string {540  return (541    { symbols: "PeachScript", csv: "CSV" }[tab]542  );543}544545function RestoreTabView(props: {546  hasVault: boolean;547  onBack: () => void;548  onUnlock: () => void | Promise<void>;549}) {550  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");551  const [showIntro, setShowIntro] = createSignal(false);552  const [pendingUnlock] = createSignal(false);553554  async function handleRestoreSuccess() {555    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);556    if (!seen) {557      setShowIntro(true);558    } else {559      await props.onUnlock();560    }561  }562563  async function dismissIntro() {564    await storageSet("peach_shown_restore_intro", "true").catch(() => {});565    setShowIntro(false);566    await props.onUnlock();567  }568569  return (570    <section571      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"572      data-motion-phase="enter"573    >574      <RuneBackground opacity={0.05} />575576      <header class="peach-screen-header lock-screen-header">577        <PeachWordmark class="peach-wordmark-wrap" />578        <button class="peach-lock-restore-link" onClick={props.onBack}>579          ‹ back580        </button>581      </header>582583      <div class="peach-newvault-card peach-restore-tabcard">584        <div class="peach-restore-tabs" role="tablist">585          <For each={["symbols", "csv"] as RestoreTab[]}>586            {(tab) => (587              <button588                class="peach-restore-tab"589                classList={{ "is-active": activeTab() === tab }}590                onClick={() => setActiveTab(tab)}591                role="tab"592                aria-selected={activeTab() === tab}593              >594                {tabLabel(tab)}595              </button>596            )}597          </For>598        </div>599        <div class="peach-restore-content">600          <Show when={activeTab() === "symbols"}>601            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />602          </Show>603          <Show when={activeTab() === "csv"}>604            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />605          </Show>606        </div>607      </div>608609      {/* One-time intro popup after first restore */}610      <Show when={showIntro()}>611        <div class="peach-restore-intro-overlay">612          <div class="peach-restore-intro-card">613            <p class="peach-restore-intro-headline">welcome to peach.</p>614            <div class="peach-restore-intro-body">615              <p>Your vault lives only on this device. No server has a copy.</p>616              <p>The inline bubble fills passwords automatically when you visit sites.</p>617              <p>Export your vault from Settings before switching devices.</p>618            </div>619            <button620              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"621              onClick={() => void dismissIntro()}622              disabled={pendingUnlock()}623            >624              got it625            </button>626          </div>627        </div>628      </Show>629    </section>630  );631}632633// ── PeachScript tab ────────────────────────────────────────────────────────634635function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {636  const [scriptInput, setScriptInput] = createSignal("");637  const [backupPass, setBackupPass] = createSignal("");638  const [working, setWorking] = createSignal(false);639  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);640  const [error, setError] = createSignal<string | null>(null);641  let canvasRef: HTMLCanvasElement | undefined;642  let animFrameId = 0;643  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];644645  const scriptValid = createMemo(() => isPeachScript(scriptInput()));646647  function stopAnim() {648    cancelAnimationFrame(animFrameId);649    animFrameId = 0;650  }651652  function startChaos(canvas: HTMLCanvasElement) {653    const ctx = canvas.getContext("2d")!;654    const W = canvas.width;655    const H = canvas.height;656    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>657      String.fromCodePoint(0xe000 + i)658    );659    if (runes.length === 0) {660      runes = Array.from({ length: 60 }, () => ({661        x: Math.random() * W,662        y: Math.random() * H,663        vx: (Math.random() - 0.5) * 3,664        vy: (Math.random() - 0.5) * 3,665        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,666        size: 14 + Math.random() * 20,667        angle: Math.random() * Math.PI * 2,668        va: (Math.random() - 0.5) * 0.1,669      }));670    }671672    function draw() {673      ctx.clearRect(0, 0, W, H);674      for (const r of runes) {675        r.x = (r.x + r.vx + W) % W;676        r.y = (r.y + r.vy + H) % H;677        r.angle += r.va;678        ctx.save();679        ctx.translate(r.x, r.y);680        ctx.rotate(r.angle);681        ctx.font = `${r.size}px PeachScript, monospace`;682        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);683        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;684        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";685        ctx.shadowBlur = 8;686        ctx.fillText(r.char, 0, 0);687        ctx.restore();688      }689      animFrameId = requestAnimationFrame(draw);690    }691    draw();692  }693694  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {695    stopAnim();696    const ctx = canvas.getContext("2d")!;697    const W = canvas.width;698    const H = canvas.height;699    const cols = 8;700    const rows = 6;701    const cellW = W / cols;702    const cellH = H / rows;703    const targets = runes.slice(0, cols * rows).map((_, i) => ({704      x: (i % cols) * cellW + cellW / 2,705      y: Math.floor(i / cols) * cellH + cellH / 2,706    }));707    const start = performance.now();708    const DURATION = 400;709710    function drawSnap(now: number) {711      const t = Math.min((now - start) / DURATION, 1);712      const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;713      ctx.clearRect(0, 0, W, H);714      runes.slice(0, targets.length).forEach((r, i) => {715        const tx = targets[i]!.x;716        const ty = targets[i]!.y;717        const cx = r.x + (tx - r.x) * ease;718        const cy = r.y + (ty - r.y) * ease;719        ctx.font = `16px PeachScript, monospace`;720        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;721        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";722        ctx.shadowBlur = 6;723        ctx.fillText(r.char, cx - 8, cy + 8);724      });725      if (t < 1) {726        animFrameId = requestAnimationFrame(drawSnap);727      } else {728        onDone();729      }730    }731    animFrameId = requestAnimationFrame(drawSnap);732  }733734  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {735    stopAnim();736    const ctx = canvas.getContext("2d")!;737    const W = canvas.width;738    const H = canvas.height;739    const start = performance.now();740    const DURATION = 900;741742    function drawBloom(now: number) {743      const t = Math.min((now - start) / DURATION, 1);744      ctx.clearRect(0, 0, W, H);745      // Runes dissolve746      const runeAlpha = 1 - Math.min(t / 0.4, 1);747      if (runeAlpha > 0) {748        for (const r of runes.slice(0, 48)) {749          ctx.font = `16px PeachScript, monospace`;750          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;751          ctx.fillText(r.char, r.x - 8, r.y + 8);752        }753      }754      // Peach mark bloom755      const markT = Math.max(0, (t - 0.2) / 0.5);756      const markScale = Math.min(markT * 1.2, 1);757      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;758      if (markAlpha > 0) {759        ctx.save();760        ctx.globalAlpha = markAlpha;761        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;762        ctx.shadowBlur = 30;763        ctx.translate(W / 2, H / 2);764        ctx.scale(markScale, markScale);765        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);766        ctx.restore();767      }768      if (t < 1) {769        animFrameId = requestAnimationFrame(drawBloom);770      } else {771        onDone();772      }773    }774    animFrameId = requestAnimationFrame(drawBloom);775  }776777  onCleanup(() => stopAnim());778779  async function handleRestore() {780    if (!scriptValid() || !backupPass().trim() || working()) return;781    setError(null);782    setWorking(true);783    setAnimating("chaos");784785    const canvas = canvasRef;786    if (canvas) startChaos(canvas);787788    try {789      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));790      const payload = await parsePayload(bytes, backupPass());791792      if (!props.hasVault) {793        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });794        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");795      }796797      const rr = await sendMessage({798        type: "RESTORE_ENTRIES",799        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },800      });801      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");802803      if (payload.license?.key) {804        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});805      }806807      setAnimating("snap");808      stopAnim();809      if (canvas) {810        snapToGrid(canvas, () => {811          setAnimating("bloom");812          bloom(canvas, async () => {813            setAnimating(null);814            await props.onSuccess();815          });816        });817      } else {818        await props.onSuccess();819      }820    } catch (err) {821      stopAnim();822      setAnimating(null);823      const code = err instanceof Error ? err.message : String(err);824      setError(825        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"826          ? "That passphrase didn't work."827          : code === "CREATE_VAULT_FAILED"828            ? "Couldn't create a new local vault."829            : "Could not read this backup.",830      );831    } finally {832      setWorking(false);833    }834  }835836  return (837    <div class="peach-rt-symbols">838      <Show when={animating() !== null}>839        <canvas840          ref={(el) => { canvasRef = el; }}841          class="peach-rt-anim-canvas"842          width={280}843          height={200}844        />845      </Show>846      <Show when={animating() === null}>847        <textarea848          class="peach-restore-symbols-textarea"849          placeholder="paste peachscript…"850          rows={3}851          value={scriptInput()}852          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}853        />854        <div class="peach-rt-divider">— or —</div>855        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">856          open .peach file857          <input858            type="file"859            accept=".peach,text/plain"860            style="display:none"861            onChange={async (e) => {862              const file = (e.currentTarget as HTMLInputElement).files?.[0];863              if (!file) return;864              try {865                setScriptInput(await file.text());866              } catch {867                setError("Couldn't read file.");868              } finally {869                (e.currentTarget as HTMLInputElement).value = "";870              }871            }}872          />873        </label>874        <input875          class="peach-restore-symbols-pass"876          type="password"877          placeholder="backup password"878          value={backupPass()}879          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}880          onKeyDown={(e) => {881            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {882              e.preventDefault();883              void handleRestore();884            }885          }}886        />887        <button888          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"889          onClick={() => void handleRestore()}890          disabled={!scriptValid() || !backupPass().trim() || working()}891        >892          restore vault →893        </button>894        <Show when={error()}>895          <p class="peach-rt-error">{error()}</p>896        </Show>897      </Show>898    </div>899  );900}901902// ── CSV tab ────────────────────────────────────────────────────────────────903904function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {905  const [importing, setImporting] = createSignal(false);906  const [error, setError] = createSignal<string | null>(null);907908  async function handleFile(e: Event) {909    const input = e.currentTarget as HTMLInputElement;910    const file = input.files?.[0];911    input.value = "";912    if (!file) return;913    setImporting(true);914    setError(null);915    try {916      const text = await file.text();917      const parsed = await parseImportSource(text);918      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");919920      if (props.hasVault === false) {921        // No vault yet — this path shouldn't normally occur for CSV (no backup password922        // means no way to create a locked vault). Create an unlocked placeholder vault.923        // The user can set a master password from settings.924        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });925        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");926      }927928      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });929      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");930      if (parsed.licenseKey) {931        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});932      }933      await props.onSuccess();934    } catch (err) {935      const code = err instanceof Error ? err.message : String(err);936      setError(937        code === "NO_ENTRIES" ? "No entries found in this file." :938        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :939        "Import failed. Check the file format and try again."940      );941    } finally {942      setImporting(false);943    }944  }945946  return (947    <div class="peach-rt-csv">948      <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>949        {importing() ? "importing…" : "open csv file"}950        <input951          type="file"952          accept=".csv,.json,.peach,application/x-peach"953          style="display:none"954          onChange={handleFile}955          disabled={importing()}956        />957      </label>958      <p class="peach-rt-csv-sources">959        Imports from Bitwarden, 1Password, Proton Pass,<br />960        Chrome, Safari, and Edge.961      </p>962      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>963      <Show when={error()}>964        <p class="peach-rt-error">{error()}</p>965      </Show>966    </div>967  );968}969970// ── Helpers ────────────────────────────────────────────────────────────────971972function getDisplayError(error: string | undefined, fallback: string): string {973  if (!error) return fallback;974  if (975    error === "SERVICE_WORKER_UNAVAILABLE" ||976    error.includes("Receiving end does not exist") ||977    error.includes("message port closed")978  ) {979    return "background service unavailable";980  }981  return error;982}