Lock.tsx

36.7 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 64 64" fill="none">' +17  '<path d="M32 54C16 54 8 44 8 31C8 18 17 10 27 10C28 7 30 5.5 32 5.5C34 5.5 36 7 37 10C47 10 56 18 56 31C56 44 48 54 32 54Z" fill="#D96830"/>' +18  '<path d="M37 8.5C40.5 3.5 48 5 46 10.5C42 11 37 8.5 37 8.5Z" fill="#7B9060"/>' +19  '<path d="M32 5.5Q35.5 1 39 2.5" stroke="#7B9060" stroke-width="1.5" stroke-linecap="round"/>' +20  '</svg>'21)}`;2223// Lazy-load zxcvbn — it's heavy (~800 kB) and only needed during vault creation24let _zxcvbn: ((password: string) => { score: number }) | null = null;25async function getZxcvbn() {26  if (_zxcvbn) return _zxcvbn;27  const mod = await import("zxcvbn");28  _zxcvbn = mod.default;29  return _zxcvbn;30}3132interface LockProps {33  hasVault: boolean;34  ready: boolean;35  isPro?: boolean;36  /** When true, render the recovery-words display directly instead of the37   *  unlock UI. Used after a popup close+reopen during the create-vault38   *  recovery confirmation flow (peach_recovery_pending flag). */39  forceRecoveryWords?: boolean;40  onUnlock: () => void | Promise<void>;41  /** Optional escape hatch: rewind from the create-vault screen back to42   *  Onboarding. Only meaningful when the user reached create-vault from43   *  Onboarding in the first place. */44  onBackToOnboarding?: () => void;45  /** Optional: open the Peach Codex restore flow (camera scan of printed46   *  Codex pages). Surfaced as a small footer link on the new-vault screen. */47  onOpenCodexRestore?: () => void;48}4950type RestoreSurface = "restore" | null;51type RecoverSurface = "words" | "new-password" | null;5253export function LockView(props: LockProps) {54  const [password, setPassword] = createSignal("");55  const [confirmPassword, setConfirmPassword] = createSignal("");56  const [error, setError] = createSignal("");57  const [loading, setLoading] = createSignal(false);58  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);59  const [showPassword, setShowPassword] = createSignal(false);60  const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);61  // After vault creation succeeds, set this to show the recovery words screen.62  // Only meaningful when hasVault === false.63  const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);6465  const MIN_LENGTH = 12;66  const MIN_ZXCVBN_SCORE = 2;6768  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);69  const [biometricAvailable, setBiometricAvailable] = createSignal(false);70  const [biometricBusy, setBiometricBusy] = createSignal(false);7172  const canCreate = createMemo(73    () =>74      password().length >= MIN_LENGTH &&75      confirmPassword().length >= MIN_LENGTH &&76      password() === confirmPassword() &&77      strengthScore() !== null &&78      strengthScore()! >= MIN_ZXCVBN_SCORE79  );8081  const passwordStrength = createMemo(() => {82    const score = strengthScore();83    if (score === null) return null;84    if (score <= 1) return "weak";85    if (score === 2) return "fair";86    if (score === 3) return "good";87    return "strong";88  });8990  const createBlockerMessage = createMemo(() => {91    if (!password()) return null;92    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;93    if (!confirmPassword()) return "confirm your password";94    if (password() !== confirmPassword()) return "passwords don't match";95    if (strengthScore() === null) return "checking strength…";96    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";97    return null;98  });99100  // Update strength asynchronously when password changes101  createMemo(() => {102    const value = password();103    if (!value) { setStrengthScore(null); return; }104    getZxcvbn().then((zxcvbn) => {105      if (password() === value) setStrengthScore(zxcvbn(value).score);106    }).catch(() => {107      // zxcvbn unavailable — fall back to length-only heuristic108      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);109    });110  });111112  async function handleUnlock() {113    if (!password().trim()) return;114    setLoading(true);115    setError("");116    const response = await sendMessage({117      type: "UNLOCK",118      payload: { password: password() },119    });120    const result = response as { ok: boolean; error?: string };121    if (result.ok) {122      await props.onUnlock();123      setLoading(false);124      return;125    }126    setError(getDisplayError(result.error, "incorrect password"));127    setPassword("");128    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;129    passwordField?.classList.add("shake");130    setTimeout(() => passwordField?.classList.remove("shake"), 300);131    setLoading(false);132  }133134  async function checkBiometricAvailable(): Promise<void> {135    if (!props.hasVault || !isNativePlatform()) return;136    const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");137    const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");138    if (!keystore || !biometric) return;139    try {140      const stored = await keystore.hasMasterKey();141      if (!stored.exists) return;142      const avail = await biometric.isAvailable();143      if (!avail.available) return;144      setBiometricAvailable(true);145      window.setTimeout(() => { void handleBiometricUnlock(); }, 400);146    } catch { /* missing plugins or runtime error — silently skip */ }147  }148149  async function handleBiometricUnlock(): Promise<void> {150    if (biometricBusy()) return;151    const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");152    const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");153    if (!biometric || !keystore) return;154    setBiometricBusy(true);155    setError("");156    try {157      await biometric.authenticate({158        title: "Unlock Peach",159        subtitle: "Use biometrics to access your vault",160        negativeButtonText: "Use Password",161      });162      const { masterKeyHex } = await keystore.retrieveMasterKey();163      const res = (await sendMessage({164        type: "UNLOCK_WITH_KEY",165        payload: { masterKeyHex },166      })) as { ok: boolean; error?: string };167      if (res.ok) {168        await props.onUnlock();169        return;170      }171      if (res.error === "biometric_key_invalidated") {172        await sendMessage({173          type: "UPDATE_SETTINGS",174          payload: { biometricsEnabled: false },175        });176        setBiometricAvailable(false);177        setError("biometric key expired — re-enable in settings");178      } else {179        setError("biometric unlock failed");180      }181    } catch (e) {182      const reason = e instanceof Error ? e.message : String(e);183      if (reason.includes("user_cancelled")) return;184      if (reason.includes("biometric_key_invalidated")) {185        await sendMessage({186          type: "UPDATE_SETTINGS",187          payload: { biometricsEnabled: false },188        });189        setBiometricAvailable(false);190        setError("biometric key expired — re-enable in settings");191        return;192      }193      if (reason.includes("biometric_lockout")) {194        setError("biometric locked out — use your password");195      }196    } finally {197      setBiometricBusy(false);198    }199  }200201  onMount(() => { void checkBiometricAvailable(); });202203  async function handleCreate() {204    if (!password().trim()) return;205    if (password().length < MIN_LENGTH) {206      setError(`password must be at least ${MIN_LENGTH} characters`);207      return;208    }209    if (password() !== confirmPassword()) {210      setError("passwords do not match");211      return;212    }213    const score = strengthScore();214    if (score === null || score < MIN_ZXCVBN_SCORE) {215      setError("password is too weak — try a longer or more varied passphrase");216      return;217    }218    setLoading(true);219    setError("");220    const response = await sendMessage({221      type: "CREATE_VAULT",222      payload: { password: password() },223    });224    const result = response as { ok: boolean; error?: string };225    if (result.ok) {226      // Don't unlock yet — show the recovery words screen first. Mark the227      // confirmation as pending in chrome.storage.local so it survives both228      // popup close (lock-on-close skips while set) and full browser restart229      // (next unlock re-routes to the recovery surface). Cleared when the230      // user types the 3 confirmation words successfully.231      try {232        await chrome.storage.local?.set({ peach_recovery_pending: "1" });233      } catch { /* best-effort */ }234      setShowRecoveryWords(true);235      setLoading(false);236      return;237    }238    setError(getDisplayError(result.error, "failed to create vault"));239    setLoading(false);240  }241242  function openRestore(surface: Exclude<RestoreSurface, null>) {243    if (!props.ready || loading()) return;244    setPassword("");245    setConfirmPassword("");246    setError("");247    setRestoreSurface(surface);248  }249250  // ── All surfaces routed reactively via Switch/Match ───────────────────251  return (252    <Switch>253      {/* ── Restore tab view ──────────────────────────────────────── */}254      <Match when={restoreSurface() === "restore"}>255        <RestoreTabView256          hasVault={props.hasVault}257          onBack={() => setRestoreSurface(null)}258          onUnlock={async () => {259            setRestoreSurface(null);260            await props.onUnlock();261          }}262        />263      </Match>264265      {/* ── Recovery words display after fresh vault creation ────────── */}266      <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>267        <RecoveryWordDisplayView268          onDone={props.onUnlock}269        />270      </Match>271272      {/* ── Recovery word entry ─────────────────────────────────────── */}273      <Match when={recoverSurface() === "words"}>274        <RecoveryWordEntryView275          onCancel={() => setRecoverSurface(null)}276          onRecoveryComplete={props.onUnlock}277        />278      </Match>279280      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}281      <Match when={props.hasVault}>282        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">283          <header class="peach-screen-header lock-screen-header">284            <div class="peach-wordmark-stack">285              <PeachWordmark class="peach-wordmark-wrap" />286              <Show when={props.isPro}>287                <span class="peach-pro-mini-badge">pro</span>288              </Show>289            </div>290          </header>291292          <div class="peach-lock-hero peach-lock-hero--unlock">293            <div class="peach-lock-hero-art" aria-hidden="true">294              <PeachMark class="peach-lock-hero-mark" />295            </div>296            <span class="peach-lock-eyebrow">vault locked</span>297            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">298              unlock299              <br />300              your vault.301            </h1>302          </div>303304          <LockRuneCanvas />305306          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">307            <label class="peach-label" for="unlock-password">308              master password309            </label>310            <input311              id="unlock-password"312              class="peach-underline-input peach-unlock-input lock-password-input"313              type="password"314              placeholder="••••••••••••"315              autocomplete="current-password"316              value={password()}317              disabled={!props.ready}318              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}319              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}320              onKeyDown={(event) => {321                if (event.key !== "Enter" || !props.ready) return;322                void handleUnlock();323              }}324            />325326            <Show when={error()}>327              <p class="peach-hint peach-hint-error">{error()}</p>328            </Show>329330            <button331              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"332              onClick={() => void handleUnlock()}333              disabled={!props.ready || loading()}334            >335              {loading() ? "unlocking..." : "unlock"}336            </button>337338            <Show when={biometricAvailable()}>339              <button340                class="peach-biometric-btn"341                type="button"342                onClick={() => void handleBiometricUnlock()}343                disabled={biometricBusy() || loading()}344                aria-label="Unlock with biometrics"345              >346                <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">347                  <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>348                  <path d="M15 17v-3"/>349                  <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>350                  <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>351                  <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>352                  <path d="M5 15c0-1.7.3-3.3.8-4.7"/>353                  <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>354                </svg>355                {biometricBusy() ? "verifying…" : "use biometrics"}356              </button>357            </Show>358359            <button360              class="peach-lock-recover-link"361              type="button"362              onClick={() => setRecoverSurface("words")}363            >364              forgot your password?365            </button>366          </section>367        </section>368      </Match>369370      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}371      <Match when={true}>372        <section373          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"374          data-motion-phase="enter"375        >376          <header class="peach-screen-header peach-newvault-header">377            <Show when={props.onBackToOnboarding}>378              <button379                class="peach-newvault-back"380                type="button"381                onClick={() => props.onBackToOnboarding?.()}382                aria-label="Back to onboarding"383              >384                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">385                  <path d="M7 2L3 5.5L7 9" />386                </svg>387                back388              </button>389            </Show>390            <div class="peach-wordmark-stack">391              <PeachWordmark class="peach-wordmark-wrap" />392              <Show when={props.isPro}>393                <span class="peach-pro-mini-badge">pro</span>394              </Show>395            </div>396          </header>397398          <div class="peach-newvault-mark" aria-hidden="true">399            <PeachMark class="peach-newvault-mark-art" />400          </div>401402          <div class="peach-newvault-card">403            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>404            <hr class="peach-newvault-divider" />405406            <div class="peach-newvault-field">407              <div class="peach-newvault-label-row">408                <label class="peach-label" for="master-password">409                  master password410                </label>411                <button412                  class="peach-lock-reveal-btn"413                  type="button"414                  onClick={() => setShowPassword((value) => !value)}415                  aria-label={showPassword() ? "Hide password" : "Show password"}416                >417                  <Show418                    when={showPassword()}419                    fallback={420                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">421                        <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" />422                        <circle cx="7" cy="5" r="1.75" />423                      </svg>424                    }425                  >426                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">427                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />428                      <path d="M3.5 5.5L2.5 7.5" />429                      <path d="M7 6.5V8.5" />430                      <path d="M10.5 5.5L11.5 7.5" />431                    </svg>432                  </Show>433                  {showPassword() ? "hide" : "show"}434                </button>435              </div>436              <input437                id="master-password"438                class="peach-underline-input peach-newvault-input lock-password-input"439                type={showPassword() ? "text" : "password"}440                placeholder="••••••••••••"441                autocomplete="new-password"442                value={password()}443                disabled={!props.ready}444                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}445                onKeyDown={(event) => {446                  if (event.key !== "Enter" || !props.ready) return;447                  void handleCreate();448                }}449              />450              <Show451                when={password().length > 0 && passwordStrength() !== null}452                fallback={453                  <p class="peach-newvault-hint">at least 12 characters</p>454                }455              >456                <div class="peach-password-strength">457                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>458                    <div class="peach-strength-fill"></div>459                  </div>460                  <span class="peach-strength-label">{passwordStrength()}</span>461                </div>462              </Show>463            </div>464465            <div class="peach-newvault-field">466              <label class="peach-label" for="confirm-password">467                confirm password468              </label>469              <input470                id="confirm-password"471                class="peach-underline-input peach-newvault-input"472                type={showPassword() ? "text" : "password"}473                placeholder="••••••••••••"474                autocomplete="new-password"475                value={confirmPassword()}476                disabled={!props.ready}477                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}478                onKeyDown={(event) => {479                  if (event.key === "Enter" && props.ready) void handleCreate();480                }}481              />482            </div>483484            <Show when={error()}>485              <p class="peach-hint peach-hint-error">{error()}</p>486            </Show>487488            <button489              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"490              onClick={() => void handleCreate()}491              disabled={!props.ready || loading() || !canCreate()}492              aria-live="polite"493            >494              <Show495                when={!canCreate() && createBlockerMessage()}496                fallback={loading() ? "creating..." : "create vault"}497              >498                {createBlockerMessage()}499              </Show>500            </button>501502            <p class="peach-newvault-warning">503              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>504              <span>Save your 12 recovery words right after you create the vault — they're the only way in if you forget your password.</span>505            </p>506          </div>507508          <div class="peach-newvault-footer">509            <p class="peach-hint">Already have a backup?</p>510            <button511              class="peach-lock-restore-link"512              onClick={() => void openRestore("restore")}513              disabled={!props.ready}514            >515              › restore from backup516            </button>517            <Show when={props.onOpenCodexRestore}>518              <button519                class="peach-lock-restore-link"520                onClick={() => props.onOpenCodexRestore?.()}521                disabled={!props.ready}522              >523                › restore from peach codex524              </button>525            </Show>526          </div>527        </section>528      </Match>529    </Switch>530  );531}532533// ── Restore tab view ───────────────────────────────────────────────────────534535type RestoreTab = "symbols" | "csv";536537function tabLabel(tab: RestoreTab): string {538  return (539    { symbols: "PeachScript", csv: "CSV" }[tab]540  );541}542543function RestoreTabView(props: {544  hasVault: boolean;545  onBack: () => void;546  onUnlock: () => void | Promise<void>;547}) {548  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");549  const [showIntro, setShowIntro] = createSignal(false);550  const [pendingUnlock] = createSignal(false);551552  async function handleRestoreSuccess() {553    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);554    if (!seen) {555      setShowIntro(true);556    } else {557      await props.onUnlock();558    }559  }560561  async function dismissIntro() {562    await storageSet("peach_shown_restore_intro", "true").catch(() => {});563    setShowIntro(false);564    await props.onUnlock();565  }566567  return (568    <section569      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"570      data-motion-phase="enter"571    >572      <RuneBackground opacity={0.05} />573574      <header class="peach-screen-header lock-screen-header">575        <PeachWordmark class="peach-wordmark-wrap" />576        <button class="peach-lock-restore-link" onClick={props.onBack}>577          ‹ back578        </button>579      </header>580581      <div class="peach-newvault-card peach-restore-tabcard">582        <div class="peach-restore-tabs" role="tablist">583          <For each={["symbols", "csv"] as RestoreTab[]}>584            {(tab) => (585              <button586                class="peach-restore-tab"587                classList={{ "is-active": activeTab() === tab }}588                onClick={() => setActiveTab(tab)}589                role="tab"590                aria-selected={activeTab() === tab}591              >592                {tabLabel(tab)}593              </button>594            )}595          </For>596        </div>597        <div class="peach-restore-content">598          <Show when={activeTab() === "symbols"}>599            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />600          </Show>601          <Show when={activeTab() === "csv"}>602            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />603          </Show>604        </div>605      </div>606607      {/* One-time intro popup after first restore */}608      <Show when={showIntro()}>609        <div class="peach-restore-intro-overlay">610          <div class="peach-restore-intro-card">611            <p class="peach-restore-intro-headline">welcome to peach.</p>612            <div class="peach-restore-intro-body">613              <p>Your vault lives only on this device. No server has a copy.</p>614              <p>The inline bubble fills passwords automatically when you visit sites.</p>615              <p>Export your vault from Settings before switching devices.</p>616            </div>617            <button618              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"619              onClick={() => void dismissIntro()}620              disabled={pendingUnlock()}621            >622              got it623            </button>624          </div>625        </div>626      </Show>627    </section>628  );629}630631// ── PeachScript tab ────────────────────────────────────────────────────────632633function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {634  const [scriptInput, setScriptInput] = createSignal("");635  const [backupPass, setBackupPass] = createSignal("");636  const [working, setWorking] = createSignal(false);637  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);638  const [error, setError] = createSignal<string | null>(null);639  let canvasRef: HTMLCanvasElement | undefined;640  let animFrameId = 0;641  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];642643  const scriptValid = createMemo(() => isPeachScript(scriptInput()));644645  function stopAnim() {646    cancelAnimationFrame(animFrameId);647    animFrameId = 0;648  }649650  function startChaos(canvas: HTMLCanvasElement) {651    const ctx = canvas.getContext("2d")!;652    const W = canvas.width;653    const H = canvas.height;654    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>655      String.fromCodePoint(0xe000 + i)656    );657    if (runes.length === 0) {658      runes = Array.from({ length: 60 }, () => ({659        x: Math.random() * W,660        y: Math.random() * H,661        vx: (Math.random() - 0.5) * 3,662        vy: (Math.random() - 0.5) * 3,663        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,664        size: 14 + Math.random() * 20,665        angle: Math.random() * Math.PI * 2,666        va: (Math.random() - 0.5) * 0.1,667      }));668    }669670    function draw() {671      ctx.clearRect(0, 0, W, H);672      for (const r of runes) {673        r.x = (r.x + r.vx + W) % W;674        r.y = (r.y + r.vy + H) % H;675        r.angle += r.va;676        ctx.save();677        ctx.translate(r.x, r.y);678        ctx.rotate(r.angle);679        ctx.font = `${r.size}px PeachScript, monospace`;680        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);681        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;682        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";683        ctx.shadowBlur = 8;684        ctx.fillText(r.char, 0, 0);685        ctx.restore();686      }687      animFrameId = requestAnimationFrame(draw);688    }689    draw();690  }691692  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {693    stopAnim();694    const ctx = canvas.getContext("2d")!;695    const W = canvas.width;696    const H = canvas.height;697    const cols = 8;698    const rows = 6;699    const cellW = W / cols;700    const cellH = H / rows;701    const targets = runes.slice(0, cols * rows).map((_, i) => ({702      x: (i % cols) * cellW + cellW / 2,703      y: Math.floor(i / cols) * cellH + cellH / 2,704    }));705    const start = performance.now();706    const DURATION = 400;707708    function drawSnap(now: number) {709      const t = Math.min((now - start) / DURATION, 1);710      const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;711      ctx.clearRect(0, 0, W, H);712      runes.slice(0, targets.length).forEach((r, i) => {713        const tx = targets[i]!.x;714        const ty = targets[i]!.y;715        const cx = r.x + (tx - r.x) * ease;716        const cy = r.y + (ty - r.y) * ease;717        ctx.font = `16px PeachScript, monospace`;718        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;719        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";720        ctx.shadowBlur = 6;721        ctx.fillText(r.char, cx - 8, cy + 8);722      });723      if (t < 1) {724        animFrameId = requestAnimationFrame(drawSnap);725      } else {726        onDone();727      }728    }729    animFrameId = requestAnimationFrame(drawSnap);730  }731732  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {733    stopAnim();734    const ctx = canvas.getContext("2d")!;735    const W = canvas.width;736    const H = canvas.height;737    const start = performance.now();738    const DURATION = 900;739740    function drawBloom(now: number) {741      const t = Math.min((now - start) / DURATION, 1);742      ctx.clearRect(0, 0, W, H);743      // Runes dissolve744      const runeAlpha = 1 - Math.min(t / 0.4, 1);745      if (runeAlpha > 0) {746        for (const r of runes.slice(0, 48)) {747          ctx.font = `16px PeachScript, monospace`;748          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;749          ctx.fillText(r.char, r.x - 8, r.y + 8);750        }751      }752      // Peach mark bloom753      const markT = Math.max(0, (t - 0.2) / 0.5);754      const markScale = Math.min(markT * 1.2, 1);755      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;756      if (markAlpha > 0) {757        ctx.save();758        ctx.globalAlpha = markAlpha;759        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;760        ctx.shadowBlur = 30;761        ctx.translate(W / 2, H / 2);762        ctx.scale(markScale, markScale);763        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);764        ctx.restore();765      }766      if (t < 1) {767        animFrameId = requestAnimationFrame(drawBloom);768      } else {769        onDone();770      }771    }772    animFrameId = requestAnimationFrame(drawBloom);773  }774775  onCleanup(() => stopAnim());776777  async function handleRestore() {778    if (!scriptValid() || !backupPass().trim() || working()) return;779    setError(null);780    setWorking(true);781    setAnimating("chaos");782783    const canvas = canvasRef;784    if (canvas) startChaos(canvas);785786    try {787      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));788      const payload = await parsePayload(bytes, backupPass());789790      if (!props.hasVault) {791        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });792        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");793      }794795      const rr = await sendMessage({796        type: "RESTORE_ENTRIES",797        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },798      });799      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");800801      if (payload.license?.key) {802        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});803      }804805      setAnimating("snap");806      stopAnim();807      if (canvas) {808        snapToGrid(canvas, () => {809          setAnimating("bloom");810          bloom(canvas, async () => {811            setAnimating(null);812            await props.onSuccess();813          });814        });815      } else {816        await props.onSuccess();817      }818    } catch (err) {819      stopAnim();820      setAnimating(null);821      const code = err instanceof Error ? err.message : String(err);822      setError(823        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"824          ? "That passphrase didn't work."825          : code === "CREATE_VAULT_FAILED"826            ? "Couldn't create a new local vault."827            : "Could not read this backup.",828      );829    } finally {830      setWorking(false);831    }832  }833834  return (835    <div class="peach-rt-symbols">836      <Show when={animating() !== null}>837        <canvas838          ref={(el) => { canvasRef = el; }}839          class="peach-rt-anim-canvas"840          width={280}841          height={200}842        />843      </Show>844      <Show when={animating() === null}>845        <textarea846          class="peach-restore-symbols-textarea"847          placeholder="paste peachscript…"848          rows={3}849          value={scriptInput()}850          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}851        />852        <div class="peach-rt-divider">— or —</div>853        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">854          open .peach file855          <input856            type="file"857            accept=".peach,text/plain"858            style="display:none"859            onChange={async (e) => {860              const file = (e.currentTarget as HTMLInputElement).files?.[0];861              if (!file) return;862              try {863                setScriptInput(await file.text());864              } catch {865                setError("Couldn't read file.");866              } finally {867                (e.currentTarget as HTMLInputElement).value = "";868              }869            }}870          />871        </label>872        <input873          class="peach-restore-symbols-pass"874          type="password"875          placeholder="backup password"876          value={backupPass()}877          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}878          onKeyDown={(e) => {879            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {880              e.preventDefault();881              void handleRestore();882            }883          }}884        />885        <button886          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"887          onClick={() => void handleRestore()}888          disabled={!scriptValid() || !backupPass().trim() || working()}889        >890          restore vault →891        </button>892        <Show when={error()}>893          <p class="peach-rt-error">{error()}</p>894        </Show>895      </Show>896    </div>897  );898}899900// ── CSV tab ────────────────────────────────────────────────────────────────901902function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {903  const [importing, setImporting] = createSignal(false);904  const [error, setError] = createSignal<string | null>(null);905906  async function handleFile(e: Event) {907    const input = e.currentTarget as HTMLInputElement;908    const file = input.files?.[0];909    input.value = "";910    if (!file) return;911    setImporting(true);912    setError(null);913    try {914      const text = await file.text();915      const parsed = await parseImportSource(text);916      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");917918      if (props.hasVault === false) {919        // No vault yet — this path shouldn't normally occur for CSV (no backup password920        // means no way to create a locked vault). Create an unlocked placeholder vault.921        // The user can set a master password from settings.922        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });923        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");924      }925926      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });927      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");928      if (parsed.licenseKey) {929        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});930      }931      await props.onSuccess();932    } catch (err) {933      const code = err instanceof Error ? err.message : String(err);934      setError(935        code === "NO_ENTRIES" ? "No entries found in this file." :936        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :937        "Import failed. Check the file format and try again."938      );939    } finally {940      setImporting(false);941    }942  }943944  return (945    <div class="peach-rt-csv">946      <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>947        {importing() ? "importing…" : "open csv file"}948        <input949          type="file"950          accept=".csv,.json,.peach,application/x-peach"951          style="display:none"952          onChange={handleFile}953          disabled={importing()}954        />955      </label>956      <p class="peach-rt-csv-sources">957        Imports from Bitwarden, 1Password, Proton Pass,<br />958        Chrome, Safari, and Edge.959      </p>960      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>961      <Show when={error()}>962        <p class="peach-rt-error">{error()}</p>963      </Show>964    </div>965  );966}967968// ── Helpers ────────────────────────────────────────────────────────────────969970function getDisplayError(error: string | undefined, fallback: string): string {971  if (!error) return fallback;972  if (973    error === "SERVICE_WORKER_UNAVAILABLE" ||974    error.includes("Receiving end does not exist") ||975    error.includes("message port closed")976  ) {977    return "background service unavailable";978  }979  return error;980}