Lock.tsx

38.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          onOpenCodexRestore={props.onOpenCodexRestore}260          onBack={() => setRestoreSurface(null)}261          onUnlock={async () => {262            setRestoreSurface(null);263            await props.onUnlock();264          }}265        />266      </Match>267268      {/* ── Recovery words display after fresh vault creation ────────── */}269      <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>270        <RecoveryWordDisplayView271          onDone={props.onUnlock}272        />273      </Match>274275      {/* ── Recovery word entry ─────────────────────────────────────── */}276      <Match when={recoverSurface() === "words"}>277        <RecoveryWordEntryView278          onCancel={() => setRecoverSurface(null)}279          onRecoveryComplete={props.onUnlock}280        />281      </Match>282283      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}284      <Match when={props.hasVault}>285        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">286          <header class="peach-screen-header lock-screen-header">287            <div class="peach-wordmark-stack">288              <PeachWordmark class="peach-wordmark-wrap" />289              <Show when={props.isPro}>290                <span class="peach-pro-mini-badge">pro</span>291              </Show>292            </div>293          </header>294295          <div class="peach-lock-hero peach-lock-hero--unlock">296            <div class="peach-lock-hero-art" aria-hidden="true">297              <PeachMark class="peach-lock-hero-mark" />298            </div>299            <span class="peach-lock-eyebrow">vault locked</span>300            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">301              unlock302              <br />303              your vault.304            </h1>305          </div>306307          <LockRuneCanvas />308309          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">310            <label class="peach-label" for="unlock-password">311              master password312            </label>313            <input314              id="unlock-password"315              class="peach-underline-input peach-unlock-input lock-password-input"316              type="password"317              placeholder="••••••••••••"318              autocomplete="current-password"319              value={password()}320              disabled={!props.ready}321              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}322              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}323              onKeyDown={(event) => {324                if (event.key !== "Enter" || !props.ready) return;325                void handleUnlock();326              }}327            />328329            <Show when={error()}>330              <p class="peach-hint peach-hint-error">{error()}</p>331            </Show>332333            <button334              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"335              onClick={() => void handleUnlock()}336              disabled={!props.ready || loading()}337            >338              {loading() ? "unlocking..." : "unlock"}339            </button>340341            <Show when={biometricAvailable()}>342              <button343                class="peach-biometric-btn"344                type="button"345                onClick={() => void handleBiometricUnlock()}346                disabled={biometricBusy() || loading()}347                aria-label="Unlock with biometrics"348              >349                <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">350                  <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>351                  <path d="M15 17v-3"/>352                  <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>353                  <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>354                  <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>355                  <path d="M5 15c0-1.7.3-3.3.8-4.7"/>356                  <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>357                </svg>358                {biometricBusy() ? "verifying…" : "use biometrics"}359              </button>360            </Show>361362            <button363              class="peach-lock-recover-link"364              type="button"365              onClick={() => setRecoverSurface("words")}366            >367              forgot your password?368            </button>369          </section>370        </section>371      </Match>372373      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}374      <Match when={true}>375        <section376          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"377          data-motion-phase="enter"378        >379          <header class="peach-screen-header peach-newvault-header">380            <Show when={props.onBackToOnboarding}>381              <button382                class="peach-newvault-back"383                type="button"384                onClick={() => props.onBackToOnboarding?.()}385                aria-label="Back to onboarding"386              >387                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">388                  <path d="M7 2L3 5.5L7 9" />389                </svg>390                back391              </button>392            </Show>393            <div class="peach-wordmark-stack">394              <PeachWordmark class="peach-wordmark-wrap" />395              <Show when={props.isPro}>396                <span class="peach-pro-mini-badge">pro</span>397              </Show>398            </div>399          </header>400401          <div class="peach-newvault-mark" aria-hidden="true">402            <PeachMark class="peach-newvault-mark-art" />403          </div>404405          <div class="peach-newvault-card">406            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>407            <hr class="peach-newvault-divider" />408409            <div class="peach-newvault-field">410              <div class="peach-newvault-label-row">411                <label class="peach-label" for="master-password">412                  master password413                </label>414                <button415                  class="peach-lock-reveal-btn"416                  type="button"417                  onClick={() => setShowPassword((value) => !value)}418                  aria-label={showPassword() ? "Hide password" : "Show password"}419                >420                  <Show421                    when={showPassword()}422                    fallback={423                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">424                        <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" />425                        <circle cx="7" cy="5" r="1.75" />426                      </svg>427                    }428                  >429                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">430                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />431                      <path d="M3.5 5.5L2.5 7.5" />432                      <path d="M7 6.5V8.5" />433                      <path d="M10.5 5.5L11.5 7.5" />434                    </svg>435                  </Show>436                  {showPassword() ? "hide" : "show"}437                </button>438              </div>439              <input440                id="master-password"441                class="peach-underline-input peach-newvault-input lock-password-input"442                type={showPassword() ? "text" : "password"}443                placeholder="••••••••••••"444                autocomplete="new-password"445                value={password()}446                disabled={!props.ready}447                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}448                onKeyDown={(event) => {449                  if (event.key !== "Enter" || !props.ready) return;450                  void handleCreate();451                }}452              />453              <Show454                when={password().length > 0 && passwordStrength() !== null}455                fallback={456                  <p class="peach-newvault-hint">at least 12 characters</p>457                }458              >459                <div class="peach-password-strength">460                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>461                    <div class="peach-strength-fill"></div>462                  </div>463                  <span class="peach-strength-label">{passwordStrength()}</span>464                </div>465              </Show>466            </div>467468            <div class="peach-newvault-field">469              <label class="peach-label" for="confirm-password">470                confirm password471              </label>472              <input473                id="confirm-password"474                class="peach-underline-input peach-newvault-input"475                type={showPassword() ? "text" : "password"}476                placeholder="••••••••••••"477                autocomplete="new-password"478                value={confirmPassword()}479                disabled={!props.ready}480                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}481                onKeyDown={(event) => {482                  if (event.key === "Enter" && props.ready) void handleCreate();483                }}484              />485            </div>486487            <Show when={error()}>488              <p class="peach-hint peach-hint-error">{error()}</p>489            </Show>490491            <button492              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"493              onClick={() => void handleCreate()}494              disabled={!props.ready || loading() || !canCreate()}495              aria-live="polite"496            >497              <Show498                when={!canCreate() && createBlockerMessage()}499                fallback={loading() ? "creating..." : "create vault"}500              >501                {createBlockerMessage()}502              </Show>503            </button>504505            <p class="peach-newvault-warning">506              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>507              <span>Save your 12 recovery words right after you create the vault — they're the only way in if you forget your password.</span>508            </p>509          </div>510511          <div class="peach-newvault-footer">512            <p class="peach-hint">Already have a backup?</p>513            <button514              class="peach-lock-restore-link"515              onClick={() => void openRestore("restore")}516              disabled={!props.ready}517            >518              › restore from backup519            </button>520            <Show when={props.onOpenCodexRestore}>521              <button522                class="peach-lock-restore-link"523                onClick={() => props.onOpenCodexRestore?.()}524                disabled={!props.ready}525              >526                › restore from peach codex527              </button>528            </Show>529          </div>530        </section>531      </Match>532    </Switch>533  );534}535536// ── Restore tab view ───────────────────────────────────────────────────────537538type RestoreTab = "symbols" | "codex" | "csv";539540function tabLabel(tab: RestoreTab): string {541  return (542    { symbols: "PeachScript", codex: "Codex", csv: "CSV" }[tab]543  );544}545546function RestoreTabView(props: {547  hasVault: boolean;548  onOpenCodexRestore?: () => void;549  onBack: () => void;550  onUnlock: () => void | Promise<void>;551}) {552  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");553  const [showIntro, setShowIntro] = createSignal(false);554  const [pendingUnlock] = createSignal(false);555556  async function handleRestoreSuccess() {557    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);558    if (!seen) {559      setShowIntro(true);560    } else {561      await props.onUnlock();562    }563  }564565  async function dismissIntro() {566    await storageSet("peach_shown_restore_intro", "true").catch(() => {});567    setShowIntro(false);568    await props.onUnlock();569  }570571  return (572    <section573      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"574      data-motion-phase="enter"575    >576      <RuneBackground opacity={0.05} />577578      <header class="peach-screen-header lock-screen-header">579        <PeachWordmark class="peach-wordmark-wrap" />580        <button class="peach-lock-restore-link" onClick={props.onBack}>581          ‹ back582        </button>583      </header>584585      <div class="peach-newvault-card peach-restore-tabcard">586        <div class="peach-restore-tabs" role="tablist">587          <For each={["symbols", "codex", "csv"] as RestoreTab[]}>588            {(tab) => (589              <button590                class="peach-restore-tab"591                classList={{ "is-active": activeTab() === tab }}592                onClick={() => setActiveTab(tab)}593                role="tab"594                aria-selected={activeTab() === tab}595              >596                {tabLabel(tab)}597              </button>598            )}599          </For>600        </div>601        <div class="peach-restore-content">602          <Show when={activeTab() === "symbols"}>603            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />604          </Show>605          <Show when={activeTab() === "codex"}>606            <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />607          </Show>608          <Show when={activeTab() === "csv"}>609            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />610          </Show>611        </div>612      </div>613614      {/* One-time intro popup after first restore */}615      <Show when={showIntro()}>616        <div class="peach-restore-intro-overlay">617          <div class="peach-restore-intro-card">618            <p class="peach-restore-intro-headline">welcome to peach.</p>619            <div class="peach-restore-intro-body">620              <p>Your vault lives only on this device. No server has a copy.</p>621              <p>The inline bubble fills passwords automatically when you visit sites.</p>622              <p>Export your vault from Settings before switching devices.</p>623            </div>624            <button625              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"626              onClick={() => void dismissIntro()}627              disabled={pendingUnlock()}628            >629              got it630            </button>631          </div>632        </div>633      </Show>634    </section>635  );636}637638// ── Peach Codex tab ────────────────────────────────────────────────────────639640function RestoreCodex(props: { onOpen: () => void }) {641  return (642    <div class="peach-restore-codex">643      <p class="peach-restore-codex-title">Restore from Peach Codex</p>644      <p class="peach-restore-codex-copy">645        Scan printed Codex pages or import the Codex PDF. Codex restore is646        always available, even before you create a vault.647      </p>648      <button649        type="button"650        class="peach-btn peach-btn--accent peach-btn--md peach-restore-codex-button"651        onClick={props.onOpen}652      >653        open Codex restore →654      </button>655    </div>656  );657}658659// ── PeachScript tab ────────────────────────────────────────────────────────660661function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {662  const [scriptInput, setScriptInput] = createSignal("");663  const [backupPass, setBackupPass] = createSignal("");664  const [working, setWorking] = createSignal(false);665  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);666  const [error, setError] = createSignal<string | null>(null);667  let canvasRef: HTMLCanvasElement | undefined;668  let animFrameId = 0;669  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];670671  const scriptValid = createMemo(() => isPeachScript(scriptInput()));672673  function stopAnim() {674    cancelAnimationFrame(animFrameId);675    animFrameId = 0;676  }677678  function startChaos(canvas: HTMLCanvasElement) {679    const ctx = canvas.getContext("2d")!;680    const W = canvas.width;681    const H = canvas.height;682    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>683      String.fromCodePoint(0xe000 + i)684    );685    if (runes.length === 0) {686      runes = Array.from({ length: 60 }, () => ({687        x: Math.random() * W,688        y: Math.random() * H,689        vx: (Math.random() - 0.5) * 3,690        vy: (Math.random() - 0.5) * 3,691        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,692        size: 14 + Math.random() * 20,693        angle: Math.random() * Math.PI * 2,694        va: (Math.random() - 0.5) * 0.1,695      }));696    }697698    function draw() {699      ctx.clearRect(0, 0, W, H);700      for (const r of runes) {701        r.x = (r.x + r.vx + W) % W;702        r.y = (r.y + r.vy + H) % H;703        r.angle += r.va;704        ctx.save();705        ctx.translate(r.x, r.y);706        ctx.rotate(r.angle);707        ctx.font = `${r.size}px PeachScript, monospace`;708        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);709        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;710        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";711        ctx.shadowBlur = 8;712        ctx.fillText(r.char, 0, 0);713        ctx.restore();714      }715      animFrameId = requestAnimationFrame(draw);716    }717    draw();718  }719720  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {721    stopAnim();722    const ctx = canvas.getContext("2d")!;723    const W = canvas.width;724    const H = canvas.height;725    const cols = 8;726    const rows = 6;727    const cellW = W / cols;728    const cellH = H / rows;729    const targets = runes.slice(0, cols * rows).map((_, i) => ({730      x: (i % cols) * cellW + cellW / 2,731      y: Math.floor(i / cols) * cellH + cellH / 2,732    }));733    const start = performance.now();734    const DURATION = 400;735736    function drawSnap(now: number) {737      const t = Math.min((now - start) / DURATION, 1);738      const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;739      ctx.clearRect(0, 0, W, H);740      runes.slice(0, targets.length).forEach((r, i) => {741        const tx = targets[i]!.x;742        const ty = targets[i]!.y;743        const cx = r.x + (tx - r.x) * ease;744        const cy = r.y + (ty - r.y) * ease;745        ctx.font = `16px PeachScript, monospace`;746        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;747        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";748        ctx.shadowBlur = 6;749        ctx.fillText(r.char, cx - 8, cy + 8);750      });751      if (t < 1) {752        animFrameId = requestAnimationFrame(drawSnap);753      } else {754        onDone();755      }756    }757    animFrameId = requestAnimationFrame(drawSnap);758  }759760  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {761    stopAnim();762    const ctx = canvas.getContext("2d")!;763    const W = canvas.width;764    const H = canvas.height;765    const start = performance.now();766    const DURATION = 900;767768    function drawBloom(now: number) {769      const t = Math.min((now - start) / DURATION, 1);770      ctx.clearRect(0, 0, W, H);771      // Runes dissolve772      const runeAlpha = 1 - Math.min(t / 0.4, 1);773      if (runeAlpha > 0) {774        for (const r of runes.slice(0, 48)) {775          ctx.font = `16px PeachScript, monospace`;776          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;777          ctx.fillText(r.char, r.x - 8, r.y + 8);778        }779      }780      // Peach mark bloom781      const markT = Math.max(0, (t - 0.2) / 0.5);782      const markScale = Math.min(markT * 1.2, 1);783      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;784      if (markAlpha > 0) {785        ctx.save();786        ctx.globalAlpha = markAlpha;787        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;788        ctx.shadowBlur = 30;789        ctx.translate(W / 2, H / 2);790        ctx.scale(markScale, markScale);791        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);792        ctx.restore();793      }794      if (t < 1) {795        animFrameId = requestAnimationFrame(drawBloom);796      } else {797        onDone();798      }799    }800    animFrameId = requestAnimationFrame(drawBloom);801  }802803  onCleanup(() => stopAnim());804805  async function handleRestore() {806    if (!scriptValid() || !backupPass().trim() || working()) return;807    setError(null);808    setWorking(true);809    setAnimating("chaos");810811    const canvas = canvasRef;812    if (canvas) startChaos(canvas);813814    try {815      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));816      const payload = await parsePayload(bytes, backupPass());817818      if (!props.hasVault) {819        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });820        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");821      }822823      const rr = await sendMessage({824        type: "RESTORE_ENTRIES",825        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },826      });827      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");828829      if (payload.license?.key) {830        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});831      }832833      setAnimating("snap");834      stopAnim();835      if (canvas) {836        snapToGrid(canvas, () => {837          setAnimating("bloom");838          bloom(canvas, async () => {839            setAnimating(null);840            await props.onSuccess();841          });842        });843      } else {844        await props.onSuccess();845      }846    } catch (err) {847      stopAnim();848      setAnimating(null);849      const code = err instanceof Error ? err.message : String(err);850      setError(851        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"852          ? "That passphrase didn't work."853          : code === "CREATE_VAULT_FAILED"854            ? "Couldn't create a new local vault."855            : "Could not read this backup.",856      );857    } finally {858      setWorking(false);859    }860  }861862  return (863    <div class="peach-rt-symbols">864      <Show when={animating() !== null}>865        <canvas866          ref={(el) => { canvasRef = el; }}867          class="peach-rt-anim-canvas"868          width={280}869          height={200}870        />871      </Show>872      <Show when={animating() === null}>873        <textarea874          class="peach-restore-symbols-textarea"875          placeholder="paste peachscript…"876          rows={3}877          value={scriptInput()}878          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}879        />880        <div class="peach-rt-divider">— or —</div>881        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">882          open .peach file883          <input884            type="file"885            accept=".peach,text/plain"886            style="display:none"887            onChange={async (e) => {888              const file = (e.currentTarget as HTMLInputElement).files?.[0];889              if (!file) return;890              try {891                setScriptInput(await file.text());892              } catch {893                setError("Couldn't read file.");894              } finally {895                (e.currentTarget as HTMLInputElement).value = "";896              }897            }}898          />899        </label>900        <input901          class="peach-restore-symbols-pass"902          type="password"903          placeholder="backup password"904          value={backupPass()}905          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}906          onKeyDown={(e) => {907            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {908              e.preventDefault();909              void handleRestore();910            }911          }}912        />913        <button914          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"915          onClick={() => void handleRestore()}916          disabled={!scriptValid() || !backupPass().trim() || working()}917        >918          restore vault →919        </button>920        <Show when={error()}>921          <p class="peach-rt-error">{error()}</p>922        </Show>923      </Show>924    </div>925  );926}927928// ── CSV tab ────────────────────────────────────────────────────────────────929930function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {931  const [importing, setImporting] = createSignal(false);932  const [error, setError] = createSignal<string | null>(null);933934  async function handleFile(e: Event) {935    const input = e.currentTarget as HTMLInputElement;936    const file = input.files?.[0];937    input.value = "";938    if (!file) return;939    setImporting(true);940    setError(null);941    try {942      const text = await file.text();943      const parsed = await parseImportSource(text);944      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");945946      if (props.hasVault === false) {947        // No vault yet — this path shouldn't normally occur for CSV (no backup password948        // means no way to create a locked vault). Create an unlocked placeholder vault.949        // The user can set a master password from settings.950        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });951        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");952      }953954      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });955      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");956      if (parsed.licenseKey) {957        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});958      }959      await props.onSuccess();960    } catch (err) {961      const code = err instanceof Error ? err.message : String(err);962      setError(963        code === "NO_ENTRIES" ? "No entries found in this file." :964        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :965        "Import failed. Check the file format and try again."966      );967    } finally {968      setImporting(false);969    }970  }971972  return (973    <div class="peach-rt-csv">974      <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>975        {importing() ? "importing…" : "open csv file"}976        <input977          type="file"978          accept=".csv,.json,.peach,application/x-peach"979          style="display:none"980          onChange={handleFile}981          disabled={importing()}982        />983      </label>984      <p class="peach-rt-csv-sources">985        Imports from Bitwarden, 1Password, Proton Pass,<br />986        Chrome, Safari, and Edge.987      </p>988      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>989      <Show when={error()}>990        <p class="peach-rt-error">{error()}</p>991      </Show>992    </div>993  );994}995996// ── Helpers ────────────────────────────────────────────────────────────────997998function getDisplayError(error: string | undefined, fallback: string): string {999  if (!error) return fallback;1000  if (1001    error === "SERVICE_WORKER_UNAVAILABLE" ||1002    error.includes("Receiving end does not exist") ||1003    error.includes("message port closed")1004  ) {1005    return "background service unavailable";1006  }1007  return error;1008}