Lock.tsx

39.2 KB · raw

1import { For, Match, Show, Switch, createMemo, createSignal, onCleanup, onMount } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark, PeachWordmark } from "../components/PeachMark";4import { RecoveryWordEntryView } from "./RecoveryWordEntry";5import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";6import { RuneBackground } from "../components/RuneBackground";7import { storageGet, storageSet } from "../../shared/storage";8import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";9import { parsePayload } from "../../crypto/peachPayload";10import { parseImportSource } from "../../shared/import";11import { isNativePlatform, nativePlugin } from "../../shared/native";1213const peachMarkImg = new Image();14peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(15  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" fill="none">' +16  '<path d="M 124 64 C 134 32 168 16 200 22 C 198 56 168 72 130 66 Z" fill="#92AC65"/>' +17  '<path d="M 130 64 C 152 46 178 30 196 24" stroke="#5E7A42" stroke-width="2.6" fill="none" stroke-linecap="round"/>' +18  '<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"/>' +19  '<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"/>' +20  '<ellipse cx="178" cy="108" rx="20" ry="11" fill="#FFD0A8" opacity="0.7" transform="rotate(-26 178 108)"/>' +21  '</svg>'22)}`;2324// Lazy-load @zxcvbn-ts — heavy (dictionaries ~700 kB) and only needed during25// vault creation. Replaces the unmaintained [email protected] (LV-8); result.score26// keeps the same 0-4 scale so downstream UI is unchanged.27let _zxcvbn: ((password: string) => { score: number }) | null = null;28async function getZxcvbn() {29  if (_zxcvbn) return _zxcvbn;30  const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([31    import("@zxcvbn-ts/core"),32    import("@zxcvbn-ts/language-common"),33    import("@zxcvbn-ts/language-en"),34  ]);35  zxcvbnOptions.setOptions({36    translations: en.translations,37    graphs: common.adjacencyGraphs,38    dictionary: { ...common.dictionary, ...en.dictionary },39  });40  _zxcvbn = (password: string) => zxcvbn(password);41  return _zxcvbn;42}4344interface LockProps {45  hasVault: boolean;46  ready: boolean;47  isPro?: boolean;48  /** When true, render the recovery-words display directly instead of the49   *  unlock UI. Used after a popup close+reopen during the create-vault50   *  recovery confirmation flow (peach_recovery_pending flag). */51  forceRecoveryWords?: boolean;52  onUnlock: () => void | Promise<void>;53  /** Optional escape hatch: rewind from the create-vault screen back to54   *  Onboarding. Only meaningful when the user reached create-vault from55   *  Onboarding in the first place. */56  onBackToOnboarding?: () => void;57  /** Optional: open the Peach Codex restore flow (camera scan of printed58   *  Codex pages). Surfaced as a small footer link on the new-vault screen. */59  onOpenCodexRestore?: () => void;60  /** Open directly on backup restore when onboarding routed the user there. */61  initialRestore?: boolean;62}6364type RestoreSurface = "restore" | null;65type RecoverSurface = "words" | "new-password" | null;6667export function LockView(props: LockProps) {68  const [password, setPassword] = createSignal("");69  const [confirmPassword, setConfirmPassword] = createSignal("");70  const [error, setError] = createSignal("");71  const [loading, setLoading] = createSignal(false);72  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(73    props.initialRestore ? "restore" : null,74  );75  const [showPassword, setShowPassword] = createSignal(false);76  const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);77  // After vault creation succeeds, set this to show the recovery words screen.78  // Only meaningful when hasVault === false.79  const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);8081  const MIN_LENGTH = 12;82  const MIN_ZXCVBN_SCORE = 2;8384  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);85  const [biometricAvailable, setBiometricAvailable] = createSignal(false);86  const [biometricBusy, setBiometricBusy] = createSignal(false);8788  const canCreate = createMemo(89    () =>90      password().length >= MIN_LENGTH &&91      confirmPassword().length >= MIN_LENGTH &&92      password() === confirmPassword() &&93      strengthScore() !== null &&94      strengthScore()! >= MIN_ZXCVBN_SCORE95  );9697  const passwordStrength = createMemo(() => {98    const score = strengthScore();99    if (score === null) return null;100    if (score <= 1) return "weak";101    if (score === 2) return "fair";102    if (score === 3) return "good";103    return "strong";104  });105106  const createBlockerMessage = createMemo(() => {107    if (!password()) return null;108    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;109    if (!confirmPassword()) return "confirm your password";110    if (password() !== confirmPassword()) return "passwords don't match";111    if (strengthScore() === null) return "checking strength…";112    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — use a longer passphrase";113    return null;114  });115116  // Update strength asynchronously when password changes117  createMemo(() => {118    const value = password();119    if (!value) { setStrengthScore(null); return; }120    getZxcvbn().then((zxcvbn) => {121      if (password() === value) setStrengthScore(zxcvbn(value).score);122    }).catch(() => {123      // zxcvbn unavailable — fall back to length-only heuristic124      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);125    });126  });127128  async function handleUnlock() {129    if (!password().trim()) return;130    setLoading(true);131    setError("");132    const response = await sendMessage({133      type: "UNLOCK",134      payload: { password: password() },135    });136    const result = response as { ok: boolean; error?: string };137    if (result.ok) {138      await props.onUnlock();139      setLoading(false);140      return;141    }142    setError(getDisplayError(result.error, "incorrect password"));143    setPassword("");144    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;145    passwordField?.classList.add("shake");146    setTimeout(() => passwordField?.classList.remove("shake"), 300);147    setLoading(false);148  }149150  async function checkBiometricAvailable(): Promise<void> {151    if (!props.hasVault || !isNativePlatform()) return;152    const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");153    const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");154    if (!keystore || !biometric) return;155    try {156      const stored = await keystore.hasMasterKey();157      if (!stored.exists) return;158      const avail = await biometric.isAvailable();159      if (!avail.available) return;160      setBiometricAvailable(true);161      window.setTimeout(() => { void handleBiometricUnlock(); }, 400);162    } catch { /* missing plugins or runtime error — silently skip */ }163  }164165  async function handleBiometricUnlock(): Promise<void> {166    if (biometricBusy()) return;167    const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");168    const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");169    if (!biometric || !keystore) return;170    setBiometricBusy(true);171    setError("");172    try {173      await biometric.authenticate({174        title: "Unlock Peach",175        subtitle: "Use biometrics to access your vault",176        negativeButtonText: "Use Password",177      });178      const { masterKeyHex } = await keystore.retrieveMasterKey();179      const res = (await sendMessage({180        type: "UNLOCK_WITH_KEY",181        payload: { masterKeyHex },182      })) as { ok: boolean; error?: string };183      if (res.ok) {184        await props.onUnlock();185        return;186      }187      if (res.error === "biometric_key_invalidated") {188        await sendMessage({189          type: "UPDATE_SETTINGS",190          payload: { biometricsEnabled: false },191        });192        setBiometricAvailable(false);193        setError("biometric key expired — re-enable in settings");194      } else {195        setError("biometric unlock failed");196      }197    } catch (e) {198      const reason = e instanceof Error ? e.message : String(e);199      if (reason.includes("user_cancelled")) return;200      if (reason.includes("biometric_key_invalidated")) {201        await sendMessage({202          type: "UPDATE_SETTINGS",203          payload: { biometricsEnabled: false },204        });205        setBiometricAvailable(false);206        setError("biometric key expired — re-enable in settings");207        return;208      }209      if (reason.includes("biometric_lockout")) {210        setError("biometric locked out — use your password");211      }212    } finally {213      setBiometricBusy(false);214    }215  }216217  onMount(() => { void checkBiometricAvailable(); });218219  async function handleCreate() {220    if (!password().trim()) return;221    if (password().length < MIN_LENGTH) {222      setError(`password must be at least ${MIN_LENGTH} characters`);223      return;224    }225    if (password() !== confirmPassword()) {226      setError("passwords do not match");227      return;228    }229    const score = strengthScore();230    if (score === null || score < MIN_ZXCVBN_SCORE) {231      setError("password is too weak — use a longer passphrase");232      return;233    }234    setLoading(true);235    setError("");236    const response = await sendMessage({237      type: "CREATE_VAULT",238      payload: { password: password() },239    });240    const result = response as { ok: boolean; error?: string };241    if (result.ok) {242      // Don't unlock yet — show the recovery words screen first. Mark the243      // confirmation as pending in chrome.storage.local so it survives both244      // popup close (lock-on-close skips while set) and full browser restart245      // (next unlock re-routes to the recovery surface). Cleared when the246      // user types the 3 confirmation words successfully.247      try {248        await chrome.storage.local?.set({ peach_recovery_pending: "1" });249      } catch { /* best-effort */ }250      setShowRecoveryWords(true);251      setLoading(false);252      return;253    }254    setError(getDisplayError(result.error, "failed to create vault"));255    setLoading(false);256  }257258  // ── All surfaces routed reactively via Switch/Match ───────────────────259  return (260    <Switch>261      {/* ── Restore tab view ──────────────────────────────────────── */}262      <Match when={restoreSurface() === "restore"}>263        <RestoreTabView264          hasVault={props.hasVault}265          onOpenCodexRestore={props.onOpenCodexRestore}266          onBack={() => setRestoreSurface(null)}267          onUnlock={async () => {268            setRestoreSurface(null);269            await props.onUnlock();270          }}271        />272      </Match>273274      {/* ── Recovery words display after fresh vault creation ────────── */}275      <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>276        <RecoveryWordDisplayView277          onDone={props.onUnlock}278        />279      </Match>280281      {/* ── Recovery word entry ─────────────────────────────────────── */}282      <Match when={recoverSurface() === "words"}>283        <RecoveryWordEntryView284          onCancel={() => setRecoverSurface(null)}285          onRecoveryComplete={props.onUnlock}286        />287      </Match>288289      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}290      <Match when={props.hasVault}>291        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">292          <RuneBackground opacity={0.07} accentGlow />293294          <header class="peach-screen-header lock-screen-header">295            <div class="peach-wordmark-stack">296              <PeachWordmark class="peach-wordmark-wrap" />297              <Show when={props.isPro}>298                <span class="peach-pro-mini-badge">pro</span>299              </Show>300            </div>301          </header>302303          <div class="peach-lock-hero peach-lock-hero--unlock">304            <PeachMark class="peach-lock-mark" />305            <span class="peach-lock-eyebrow">vault locked</span>306            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">307              unlock308              <br />309              your vault.310            </h1>311          </div>312313          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">314            <label class="peach-label" for="unlock-password">315              master password316            </label>317            <input318              id="unlock-password"319              class="peach-underline-input peach-unlock-input lock-password-input"320              type="password"321              placeholder="••••••••••••"322              autocomplete="current-password"323              value={password()}324              disabled={!props.ready}325              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}326              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}327              onKeyDown={(event) => {328                if (event.key !== "Enter" || !props.ready) return;329                void handleUnlock();330              }}331            />332333            <Show when={error()}>334              <p class="peach-hint peach-hint-error">{error()}</p>335            </Show>336337            <button338              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"339              onClick={() => void handleUnlock()}340              disabled={!props.ready || loading()}341            >342              {loading() ? "unlocking..." : "unlock"}343            </button>344345            <Show when={biometricAvailable()}>346              <button347                class="peach-biometric-btn"348                type="button"349                onClick={() => void handleBiometricUnlock()}350                disabled={biometricBusy() || loading()}351                aria-label="Unlock with biometrics"352              >353                <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">354                  <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>355                  <path d="M15 17v-3"/>356                  <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>357                  <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>358                  <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>359                  <path d="M5 15c0-1.7.3-3.3.8-4.7"/>360                  <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>361                </svg>362                {biometricBusy() ? "verifying…" : "use biometrics"}363              </button>364            </Show>365366            <button367              class="peach-lock-recover-link"368              type="button"369              onClick={() => setRecoverSurface("words")}370            >371              forgot your password?372            </button>373          </section>374        </section>375      </Match>376377      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}378      <Match when={true}>379        <section380          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"381          data-motion-phase="enter"382        >383          <header class="peach-screen-header peach-newvault-header">384            <Show when={props.onBackToOnboarding}>385              <button386                class="peach-newvault-back"387                type="button"388                onClick={() => props.onBackToOnboarding?.()}389                aria-label="Back to onboarding"390              >391                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">392                  <path d="M7 2L3 5.5L7 9" />393                </svg>394                back395              </button>396            </Show>397            <div class="peach-wordmark-stack">398              <PeachWordmark class="peach-wordmark-wrap" />399              <Show when={props.isPro}>400                <span class="peach-pro-mini-badge">pro</span>401              </Show>402            </div>403          </header>404405          <div class="peach-newvault-mark" aria-hidden="true">406            <PeachMark class="peach-newvault-mark-art" />407          </div>408409          <div class="peach-newvault-card">410            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>411            <hr class="peach-newvault-divider" />412413            <div class="peach-newvault-field">414              <div class="peach-newvault-label-row">415                <label class="peach-label" for="master-password">416                  master password417                </label>418                <button419                  class="peach-lock-reveal-btn"420                  type="button"421                  onClick={() => setShowPassword((value) => !value)}422                  aria-label={showPassword() ? "Hide password" : "Show password"}423                >424                  <Show425                    when={showPassword()}426                    fallback={427                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">428                        <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" />429                        <circle cx="7" cy="5" r="1.75" />430                      </svg>431                    }432                  >433                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">434                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />435                      <path d="M3.5 5.5L2.5 7.5" />436                      <path d="M7 6.5V8.5" />437                      <path d="M10.5 5.5L11.5 7.5" />438                    </svg>439                  </Show>440                  {showPassword() ? "hide" : "show"}441                </button>442              </div>443              <input444                id="master-password"445                class="peach-underline-input peach-newvault-input lock-password-input"446                type={showPassword() ? "text" : "password"}447                placeholder="••••••••••••"448                autocomplete="new-password"449                value={password()}450                disabled={!props.ready}451                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}452                onKeyDown={(event) => {453                  if (event.key !== "Enter" || !props.ready) return;454                  void handleCreate();455                }}456              />457              <Show458                when={password().length > 0 && passwordStrength() !== null}459                fallback={460                  <p class="peach-newvault-hint">461                    At least 12 characters. The vault is created once both fields match and pass the strength check.462                  </p>463                }464              >465                <div class="peach-password-strength">466                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>467                    <div class="peach-strength-fill"></div>468                  </div>469                  <span class="peach-strength-label">{passwordStrength()}</span>470                </div>471              </Show>472            </div>473474            <div class="peach-newvault-field">475              <label class="peach-label" for="confirm-password">476                confirm password477              </label>478              <input479                id="confirm-password"480                class="peach-underline-input peach-newvault-input"481                type={showPassword() ? "text" : "password"}482                placeholder="••••••••••••"483                autocomplete="new-password"484                value={confirmPassword()}485                disabled={!props.ready}486                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}487                onKeyDown={(event) => {488                  if (event.key === "Enter" && props.ready) void handleCreate();489                }}490              />491            </div>492493            <Show when={error()}>494              <p class="peach-hint peach-hint-error">{error()}</p>495            </Show>496497            <button498              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"499              onClick={() => void handleCreate()}500              disabled={!props.ready || loading() || !canCreate()}501              aria-live="polite"502            >503              <Show504                when={!canCreate() && createBlockerMessage()}505                fallback={loading() ? "creating..." : "create vault"}506              >507                {createBlockerMessage()}508              </Show>509            </button>510511            <p class="peach-newvault-warning">512              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>513              <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>514            </p>515          </div>516        </section>517      </Match>518    </Switch>519  );520}521522// ── Restore tab view ───────────────────────────────────────────────────────523524type RestoreTab = "symbols" | "codex" | "csv";525526function tabLabel(tab: RestoreTab): string {527  return (528    { symbols: "PeachScript", codex: "Codex", csv: "CSV" }[tab]529  );530}531532function RestoreTabView(props: {533  hasVault: boolean;534  onOpenCodexRestore?: () => void;535  onBack: () => void;536  onUnlock: () => void | Promise<void>;537}) {538  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");539  const [showIntro, setShowIntro] = createSignal(false);540  const [pendingUnlock] = createSignal(false);541542  async function handleRestoreSuccess() {543    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);544    if (!seen) {545      setShowIntro(true);546    } else {547      await props.onUnlock();548    }549  }550551  async function dismissIntro() {552    await storageSet("peach_shown_restore_intro", "true").catch(() => {});553    setShowIntro(false);554    await props.onUnlock();555  }556557  return (558    <section559      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"560      data-motion-phase="enter"561    >562      <RuneBackground opacity={0.05} />563564      <header class="peach-screen-header lock-screen-header">565        <PeachWordmark class="peach-wordmark-wrap" />566        <button class="peach-lock-restore-link" onClick={props.onBack}>567          ‹ back568        </button>569      </header>570571      <div class="peach-newvault-card peach-restore-tabcard">572        <div class="peach-restore-tabs" role="tablist">573          <For each={["symbols", "codex", "csv"] as RestoreTab[]}>574            {(tab) => (575              <button576                class="peach-restore-tab"577                classList={{ "is-active": activeTab() === tab }}578                onClick={() => setActiveTab(tab)}579                role="tab"580                aria-selected={activeTab() === tab}581              >582                {tabLabel(tab)}583              </button>584            )}585          </For>586        </div>587        <div class="peach-restore-content">588          <Show when={activeTab() === "symbols"}>589            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />590          </Show>591          <Show when={activeTab() === "codex"}>592            <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />593          </Show>594          <Show when={activeTab() === "csv"}>595            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />596          </Show>597        </div>598      </div>599600      {/* One-time intro popup after first restore */}601      <Show when={showIntro()}>602        <div class="peach-restore-intro-overlay">603          <div class="peach-restore-intro-card">604            <p class="peach-restore-intro-headline">welcome to peach.</p>605            <div class="peach-restore-intro-body">606              <p>Your vault lives only on this device. No server has a copy.</p>607              <p>The inline bubble fills passwords automatically when you visit sites.</p>608              <p>Export your vault from Settings before switching devices.</p>609            </div>610            <button611              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"612              onClick={() => void dismissIntro()}613              disabled={pendingUnlock()}614            >615              got it616            </button>617          </div>618        </div>619      </Show>620    </section>621  );622}623624// ── Peach Codex tab ────────────────────────────────────────────────────────625626function RestoreCodex(props: { onOpen: () => void }) {627  return (628    <div class="peach-restore-codex">629      <p class="peach-restore-codex-title">Restore from Peach Codex</p>630      <p class="peach-restore-codex-copy">631        Scan printed Codex pages or import the Codex PDF. Codex restore is632        always available, even before you create a vault.633      </p>634      <button635        type="button"636        class="peach-btn peach-btn--accent peach-btn--md peach-restore-codex-button"637        onClick={props.onOpen}638      >639        open Codex restore →640      </button>641    </div>642  );643}644645// ── PeachScript tab ────────────────────────────────────────────────────────646647function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {648  const [scriptInput, setScriptInput] = createSignal("");649  const [backupPass, setBackupPass] = createSignal("");650  const [working, setWorking] = createSignal(false);651  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);652  const [error, setError] = createSignal<string | null>(null);653  let canvasRef: HTMLCanvasElement | undefined;654  let animFrameId = 0;655  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];656657  const scriptValid = createMemo(() => isPeachScript(scriptInput()));658659  function stopAnim() {660    cancelAnimationFrame(animFrameId);661    animFrameId = 0;662  }663664  function startChaos(canvas: HTMLCanvasElement) {665    const ctx = canvas.getContext("2d")!;666    const W = canvas.width;667    const H = canvas.height;668    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>669      String.fromCodePoint(0xe000 + i)670    );671    if (runes.length === 0) {672      runes = Array.from({ length: 60 }, () => ({673        x: Math.random() * W,674        y: Math.random() * H,675        vx: (Math.random() - 0.5) * 3,676        vy: (Math.random() - 0.5) * 3,677        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,678        size: 14 + Math.random() * 20,679        angle: Math.random() * Math.PI * 2,680        va: (Math.random() - 0.5) * 0.1,681      }));682    }683684    function draw() {685      ctx.clearRect(0, 0, W, H);686      for (const r of runes) {687        r.x = (r.x + r.vx + W) % W;688        r.y = (r.y + r.vy + H) % H;689        r.angle += r.va;690        ctx.save();691        ctx.translate(r.x, r.y);692        ctx.rotate(r.angle);693        ctx.font = `${r.size}px PeachScript, monospace`;694        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);695        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;696        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";697        ctx.shadowBlur = 8;698        ctx.fillText(r.char, 0, 0);699        ctx.restore();700      }701      animFrameId = requestAnimationFrame(draw);702    }703    draw();704  }705706  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {707    stopAnim();708    const ctx = canvas.getContext("2d")!;709    const W = canvas.width;710    const H = canvas.height;711    const cols = 8;712    const rows = 6;713    const cellW = W / cols;714    const cellH = H / rows;715    const targets = runes.slice(0, cols * rows).map((_, i) => ({716      x: (i % cols) * cellW + cellW / 2,717      y: Math.floor(i / cols) * cellH + cellH / 2,718    }));719    const start = performance.now();720    const DURATION = 400;721722    function drawSnap(now: number) {723      const t = Math.min((now - start) / DURATION, 1);724      const ease = t < 1 ? 1 - (1 - t) ** 3 : 1;725      ctx.clearRect(0, 0, W, H);726      runes.slice(0, targets.length).forEach((r, i) => {727        const tx = targets[i]!.x;728        const ty = targets[i]!.y;729        const cx = r.x + (tx - r.x) * ease;730        const cy = r.y + (ty - r.y) * ease;731        ctx.font = `16px PeachScript, monospace`;732        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;733        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";734        ctx.shadowBlur = 6;735        ctx.fillText(r.char, cx - 8, cy + 8);736      });737      if (t < 1) {738        animFrameId = requestAnimationFrame(drawSnap);739      } else {740        onDone();741      }742    }743    animFrameId = requestAnimationFrame(drawSnap);744  }745746  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {747    stopAnim();748    const ctx = canvas.getContext("2d")!;749    const W = canvas.width;750    const H = canvas.height;751    const start = performance.now();752    const DURATION = 900;753754    function drawBloom(now: number) {755      const t = Math.min((now - start) / DURATION, 1);756      ctx.clearRect(0, 0, W, H);757      // Runes dissolve758      const runeAlpha = 1 - Math.min(t / 0.4, 1);759      if (runeAlpha > 0) {760        for (const r of runes.slice(0, 48)) {761          ctx.font = `16px PeachScript, monospace`;762          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;763          ctx.fillText(r.char, r.x - 8, r.y + 8);764        }765      }766      // Peach mark bloom767      const markT = Math.max(0, (t - 0.2) / 0.5);768      const markScale = Math.min(markT * 1.2, 1);769      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;770      if (markAlpha > 0) {771        ctx.save();772        ctx.globalAlpha = markAlpha;773        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;774        ctx.shadowBlur = 30;775        ctx.translate(W / 2, H / 2);776        ctx.scale(markScale, markScale);777        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);778        ctx.restore();779      }780      if (t < 1) {781        animFrameId = requestAnimationFrame(drawBloom);782      } else {783        onDone();784      }785    }786    animFrameId = requestAnimationFrame(drawBloom);787  }788789  onCleanup(() => stopAnim());790791  async function handleRestore() {792    if (!scriptValid() || !backupPass().trim() || working()) return;793    setError(null);794    setWorking(true);795    setAnimating("chaos");796797    const canvas = canvasRef;798    if (canvas) startChaos(canvas);799800    try {801      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));802      const payload = await parsePayload(bytes, backupPass());803804      if (!props.hasVault) {805        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });806        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");807      }808809      const rr = await sendMessage({810        type: "RESTORE_ENTRIES",811        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },812      });813      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");814815      if (payload.license?.key) {816        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});817      }818819      setAnimating("snap");820      stopAnim();821      if (canvas) {822        snapToGrid(canvas, () => {823          setAnimating("bloom");824          bloom(canvas, async () => {825            setAnimating(null);826            await props.onSuccess();827          });828        });829      } else {830        await props.onSuccess();831      }832    } catch (err) {833      stopAnim();834      setAnimating(null);835      const code = err instanceof Error ? err.message : String(err);836      setError(837        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"838          ? "That passphrase didn't work."839          : code === "CREATE_VAULT_FAILED"840            ? "Couldn't create a new local vault."841            : "Could not read this backup.",842      );843    } finally {844      setWorking(false);845    }846  }847848  return (849    <div class="peach-rt-symbols">850      <Show when={animating() !== null}>851        <canvas852          ref={(el) => { canvasRef = el; }}853          class="peach-rt-anim-canvas"854          width={280}855          height={200}856        />857      </Show>858      <Show when={animating() === null}>859        <textarea860          class="peach-restore-symbols-textarea"861          placeholder="paste peachscript…"862          rows={3}863          value={scriptInput()}864          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}865        />866        <div class="peach-rt-divider">— or —</div>867        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">868          open .peach file869          <input870            type="file"871            accept=".peach,text/plain"872            style="display:none"873            onChange={async (e) => {874              const file = (e.currentTarget as HTMLInputElement).files?.[0];875              if (!file) return;876              try {877                setScriptInput(await file.text());878              } catch {879                setError("Couldn't read file.");880              } finally {881                (e.currentTarget as HTMLInputElement).value = "";882              }883            }}884          />885        </label>886        <input887          class="peach-restore-symbols-pass"888          type="password"889          placeholder="backup password"890          value={backupPass()}891          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}892          onKeyDown={(e) => {893            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {894              e.preventDefault();895              void handleRestore();896            }897          }}898        />899        <button900          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"901          onClick={() => void handleRestore()}902          disabled={!scriptValid() || !backupPass().trim() || working()}903        >904          restore vault →905        </button>906        <Show when={error()}>907          <p class="peach-rt-error">{error()}</p>908        </Show>909      </Show>910    </div>911  );912}913914// ── CSV tab ────────────────────────────────────────────────────────────────915916function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {917  const [importing, setImporting] = createSignal(false);918  const [error, setError] = createSignal<string | null>(null);919  // CSV/JSON imports are plaintext and carry no password, so when there is no920  // vault yet the user MUST choose a master password here — otherwise the921  // imported credentials would be encrypted under an empty key and offer zero922  // at-rest protection (audit HIGH). Mirrors the onboarding MIN_LENGTH policy.923  const CSV_MIN_PASSWORD_LENGTH = 12;924  const [newPass, setNewPass] = createSignal("");925  const [confirmPass, setConfirmPass] = createSignal("");926  const needsPassword = () => props.hasVault === false;927  const passwordReady = () =>928    !needsPassword() ||929    (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());930931  async function handleFile(e: Event) {932    const input = e.currentTarget as HTMLInputElement;933    const file = input.files?.[0];934    input.value = "";935    if (!file) return;936    if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {937      setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);938      return;939    }940    if (needsPassword() && newPass() !== confirmPass()) {941      setError("Passwords do not match.");942      return;943    }944    setImporting(true);945    setError(null);946    try {947      const text = await file.text();948      const parsed = await parseImportSource(text);949      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");950951      if (props.hasVault === false) {952        // Create the vault under the user-chosen master password — never an953        // empty one (an empty key derives a publicly-known vault key).954        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });955        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");956      }957958      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });959      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");960      if (parsed.licenseKey) {961        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});962      }963      await props.onSuccess();964    } catch (err) {965      const code = err instanceof Error ? err.message : String(err);966      setError(967        code === "NO_ENTRIES" ? "No entries found in this file." :968        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :969        "Import failed. Check the file format and try again."970      );971    } finally {972      setImporting(false);973    }974  }975976  return (977    <div class="peach-rt-csv">978      <Show when={needsPassword()}>979        <input980          class="peach-restore-symbols-pass"981          type="password"982          placeholder="new master password"983          value={newPass()}984          onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}985        />986        <input987          class="peach-restore-symbols-pass"988          type="password"989          placeholder="confirm master password"990          value={confirmPass()}991          onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}992        />993      </Show>994      <label995        class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn"996        classList={{ "is-busy": importing(), "is-disabled": !passwordReady() }}997      >998        {importing() ? "importing…" : "open csv file"}999        <input1000          type="file"1001          accept=".csv,.json,.peach,application/x-peach"1002          style="display:none"1003          onChange={handleFile}1004          disabled={importing() || !passwordReady()}1005        />1006      </label>1007      <p class="peach-rt-csv-sources">1008        Imports from Bitwarden, 1Password, Proton Pass,<br />1009        Chrome, Safari, and Edge.1010      </p>1011      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1012      <Show when={error()}>1013        <p class="peach-rt-error">{error()}</p>1014      </Show>1015    </div>1016  );1017}10181019// ── Helpers ────────────────────────────────────────────────────────────────10201021function getDisplayError(error: string | undefined, fallback: string): string {1022  if (!error) return fallback;1023  if (1024    error === "SERVICE_WORKER_UNAVAILABLE" ||1025    error.includes("Receiving end does not exist") ||1026    error.includes("message port closed")1027  ) {1028    return "background service unavailable";1029  }1030  return error;1031}