Lock.tsx

41.9 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 { ImportView } from "./Import";9import { storageGet, storageSet } from "../../shared/storage";10import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";11import { parsePayload } from "../../crypto/peachPayload";12import { parseImportSource } from "../../shared/import";13import { isNativePlatform, nativePlugin } from "../../shared/native";1415const peachMarkImg = new Image();16peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(17  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">' +18  '<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"/>' +19  '<path d="M37 8.5C40.5 3.5 48 5 46 10.5C42 11 37 8.5 37 8.5Z" fill="#7B9060"/>' +20  '<path d="M32 5.5Q35.5 1 39 2.5" stroke="#7B9060" stroke-width="1.5" stroke-linecap="round"/>' +21  '</svg>'22)}`;2324// Lazy-load zxcvbn — it's heavy (~800 kB) and only needed during vault creation25let _zxcvbn: ((password: string) => { score: number }) | null = null;26async function getZxcvbn() {27  if (_zxcvbn) return _zxcvbn;28  const mod = await import("zxcvbn");29  _zxcvbn = mod.default;30  return _zxcvbn;31}3233interface LockProps {34  hasVault: boolean;35  ready: boolean;36  isPro?: boolean;37  /** When true, render the recovery-words display directly instead of the38   *  unlock UI. Used after a popup close+reopen during the create-vault39   *  recovery confirmation flow (peach_recovery_pending flag). */40  forceRecoveryWords?: boolean;41  onUnlock: () => void | Promise<void>;42  /** Optional escape hatch: rewind from the create-vault screen back to43   *  Onboarding. Only meaningful when the user reached create-vault from44   *  Onboarding in the first place. */45  onBackToOnboarding?: () => void;46}4748type RestoreSurface = "restore" | null;49type RecoverSurface = "words" | "new-password" | null;5051export function LockView(props: LockProps) {52  const [password, setPassword] = createSignal("");53  const [confirmPassword, setConfirmPassword] = createSignal("");54  const [error, setError] = createSignal("");55  const [loading, setLoading] = createSignal(false);56  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);57  const [showPassword, setShowPassword] = createSignal(false);58  const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);59  // After vault creation succeeds, set this to show the recovery words screen.60  // Only meaningful when hasVault === false.61  const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);62  const [recoveryWords, setRecoveryWords] = createSignal<string[]>([]);6364  const MIN_LENGTH = 12;65  const MIN_ZXCVBN_SCORE = 2;6667  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);68  const [biometricAvailable, setBiometricAvailable] = createSignal(false);69  const [biometricBusy, setBiometricBusy] = createSignal(false);7071  const canCreate = createMemo(72    () =>73      password().length >= MIN_LENGTH &&74      confirmPassword().length >= MIN_LENGTH &&75      password() === confirmPassword() &&76      strengthScore() !== null &&77      strengthScore()! >= MIN_ZXCVBN_SCORE78  );7980  const passwordStrength = createMemo(() => {81    const score = strengthScore();82    if (score === null) return null;83    if (score <= 1) return "weak";84    if (score === 2) return "fair";85    if (score === 3) return "good";86    return "strong";87  });8889  const createBlockerMessage = createMemo(() => {90    if (!password()) return null;91    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;92    if (!confirmPassword()) return "confirm your password";93    if (password() !== confirmPassword()) return "passwords don't match";94    if (strengthScore() === null) return "checking strength…";95    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";96    return null;97  });9899  // Update strength asynchronously when password changes100  createMemo(() => {101    const value = password();102    if (!value) { setStrengthScore(null); return; }103    getZxcvbn().then((zxcvbn) => {104      if (password() === value) setStrengthScore(zxcvbn(value).score);105    }).catch(() => {106      // zxcvbn unavailable — fall back to length-only heuristic107      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);108    });109  });110111  async function handleUnlock() {112    if (!password().trim()) return;113    setLoading(true);114    setError("");115    const response = await sendMessage({116      type: "UNLOCK",117      payload: { password: password() },118    });119    const result = response as { ok: boolean; error?: string };120    if (result.ok) {121      await props.onUnlock();122      setLoading(false);123      return;124    }125    setError(getDisplayError(result.error, "incorrect password"));126    setPassword("");127    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;128    passwordField?.classList.add("shake");129    setTimeout(() => passwordField?.classList.remove("shake"), 300);130    setLoading(false);131  }132133  async function checkBiometricAvailable(): Promise<void> {134    if (!props.hasVault || !isNativePlatform()) return;135    const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");136    const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");137    if (!keystore || !biometric) return;138    try {139      const stored = await keystore.hasMasterKey();140      if (!stored.exists) return;141      const avail = await biometric.isAvailable();142      if (!avail.available) return;143      setBiometricAvailable(true);144      window.setTimeout(() => { void handleBiometricUnlock(); }, 400);145    } catch { /* missing plugins or runtime error — silently skip */ }146  }147148  async function handleBiometricUnlock(): Promise<void> {149    if (biometricBusy()) return;150    const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");151    const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");152    if (!biometric || !keystore) return;153    setBiometricBusy(true);154    setError("");155    try {156      await biometric.authenticate({157        title: "Unlock Peach",158        subtitle: "Use biometrics to access your vault",159        negativeButtonText: "Use Password",160      });161      const { masterKeyHex } = await keystore.retrieveMasterKey();162      const res = (await sendMessage({163        type: "UNLOCK_WITH_KEY",164        payload: { masterKeyHex },165      })) as { ok: boolean; error?: string };166      if (res.ok) {167        await props.onUnlock();168        return;169      }170      if (res.error === "biometric_key_invalidated") {171        await sendMessage({172          type: "UPDATE_SETTINGS",173          payload: { biometricsEnabled: false },174        });175        setBiometricAvailable(false);176        setError("biometric key expired — re-enable in settings");177      } else {178        setError("biometric unlock failed");179      }180    } catch (e) {181      const reason = e instanceof Error ? e.message : String(e);182      if (reason.includes("user_cancelled")) return;183      if (reason.includes("biometric_key_invalidated")) {184        await sendMessage({185          type: "UPDATE_SETTINGS",186          payload: { biometricsEnabled: false },187        });188        setBiometricAvailable(false);189        setError("biometric key expired — re-enable in settings");190        return;191      }192      if (reason.includes("biometric_lockout")) {193        setError("biometric locked out — use your password");194      }195    } finally {196      setBiometricBusy(false);197    }198  }199200  onMount(() => { void checkBiometricAvailable(); });201202  async function handleCreate() {203    if (!password().trim()) return;204    if (password().length < MIN_LENGTH) {205      setError(`password must be at least ${MIN_LENGTH} characters`);206      return;207    }208    if (password() !== confirmPassword()) {209      setError("passwords do not match");210      return;211    }212    const score = strengthScore();213    if (score === null || score < MIN_ZXCVBN_SCORE) {214      setError("password is too weak — try a longer or more varied passphrase");215      return;216    }217    setLoading(true);218    setError("");219    const response = await sendMessage({220      type: "CREATE_VAULT",221      payload: { password: password() },222    });223    const result = response as { ok: boolean; error?: string };224    if (result.ok) {225      // Don't unlock yet — show the recovery words screen first. Mark the226      // confirmation as pending in chrome.storage.local so it survives both227      // popup close (lock-on-close skips while set) and full browser restart228      // (next unlock re-routes to the recovery surface). Cleared when the229      // user types the 3 confirmation words successfully.230      try {231        await chrome.storage.local?.set({ peach_recovery_pending: "1" });232      } catch { /* best-effort */ }233      setShowRecoveryWords(true);234      setLoading(false);235      return;236    }237    setError(getDisplayError(result.error, "failed to create vault"));238    setLoading(false);239  }240241  function openRestore(surface: Exclude<RestoreSurface, null>) {242    if (!props.ready || loading()) return;243    setPassword("");244    setConfirmPassword("");245    setError("");246    setRestoreSurface(surface);247  }248249  // ── All surfaces routed reactively via Switch/Match ───────────────────250  return (251    <Switch>252      {/* ── Restore tab view ──────────────────────────────────────── */}253      <Match when={restoreSurface() === "restore"}>254        <RestoreTabView255          hasVault={props.hasVault}256          onBack={() => setRestoreSurface(null)}257          onUnlock={async () => {258            setRestoreSurface(null);259            await props.onUnlock();260          }}261        />262      </Match>263264      {/* ── Recovery words display after fresh vault creation ────────── */}265      <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>266        <RecoveryWordDisplayView267          onDone={props.onUnlock}268        />269      </Match>270271      {/* ── Recovery word entry ─────────────────────────────────────── */}272      <Match when={recoverSurface() === "words"}>273        <RecoveryWordEntryView274          onCancel={() => setRecoverSurface(null)}275          onRecoveryComplete={props.onUnlock}276        />277      </Match>278279      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}280      <Match when={props.hasVault}>281        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">282          <header class="peach-screen-header lock-screen-header">283            <div class="peach-wordmark-stack">284              <PeachWordmark class="peach-wordmark-wrap" />285              <Show when={props.isPro}>286                <span class="peach-pro-mini-badge">pro</span>287              </Show>288            </div>289          </header>290291          <div class="peach-lock-hero peach-lock-hero--unlock">292            <div class="peach-lock-hero-art" aria-hidden="true">293              <PeachMark class="peach-lock-hero-mark" />294            </div>295            <span class="peach-lock-eyebrow">vault locked</span>296            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">297              unlock298              <br />299              your vault.300            </h1>301          </div>302303          <LockRuneCanvas />304305          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">306            <label class="peach-label" for="unlock-password">307              master password308            </label>309            <input310              id="unlock-password"311              class="peach-underline-input peach-unlock-input lock-password-input"312              type="password"313              placeholder="••••••••••••"314              autocomplete="current-password"315              value={password()}316              disabled={!props.ready}317              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}318              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}319              onKeyDown={(event) => {320                if (event.key !== "Enter" || !props.ready) return;321                void handleUnlock();322              }}323            />324325            <Show when={error()}>326              <p class="peach-hint peach-hint-error">{error()}</p>327            </Show>328329            <button330              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"331              onClick={() => void handleUnlock()}332              disabled={!props.ready || loading()}333            >334              {loading() ? "unlocking..." : "unlock"}335            </button>336337            <Show when={biometricAvailable()}>338              <button339                class="peach-biometric-btn"340                type="button"341                onClick={() => void handleBiometricUnlock()}342                disabled={biometricBusy() || loading()}343                aria-label="Unlock with biometrics"344              >345                <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">346                  <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>347                  <path d="M15 17v-3"/>348                  <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>349                  <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>350                  <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>351                  <path d="M5 15c0-1.7.3-3.3.8-4.7"/>352                  <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>353                </svg>354                {biometricBusy() ? "verifying…" : "use biometrics"}355              </button>356            </Show>357358            <button359              class="peach-lock-recover-link"360              type="button"361              onClick={() => setRecoverSurface("words")}362            >363              forgot your password?364            </button>365          </section>366        </section>367      </Match>368369      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}370      <Match when={true}>371        <section372          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"373          data-motion-phase="enter"374        >375          <header class="peach-screen-header peach-newvault-header">376            <Show when={props.onBackToOnboarding}>377              <button378                class="peach-newvault-back"379                type="button"380                onClick={() => props.onBackToOnboarding?.()}381                aria-label="Back to onboarding"382              >383                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">384                  <path d="M7 2L3 5.5L7 9" />385                </svg>386                back387              </button>388            </Show>389            <div class="peach-wordmark-stack">390              <PeachWordmark class="peach-wordmark-wrap" />391              <Show when={props.isPro}>392                <span class="peach-pro-mini-badge">pro</span>393              </Show>394            </div>395          </header>396397          <div class="peach-newvault-mark" aria-hidden="true">398            <PeachMark class="peach-newvault-mark-art" />399          </div>400401          <div class="peach-newvault-card">402            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>403            <hr class="peach-newvault-divider" />404405            <div class="peach-newvault-field">406              <div class="peach-newvault-label-row">407                <label class="peach-label" for="master-password">408                  master password409                </label>410                <button411                  class="peach-lock-reveal-btn"412                  type="button"413                  onClick={() => setShowPassword((value) => !value)}414                  aria-label={showPassword() ? "Hide password" : "Show password"}415                >416                  <Show417                    when={showPassword()}418                    fallback={419                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">420                        <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" />421                        <circle cx="7" cy="5" r="1.75" />422                      </svg>423                    }424                  >425                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">426                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />427                      <path d="M3.5 5.5L2.5 7.5" />428                      <path d="M7 6.5V8.5" />429                      <path d="M10.5 5.5L11.5 7.5" />430                    </svg>431                  </Show>432                  {showPassword() ? "hide" : "show"}433                </button>434              </div>435              <input436                id="master-password"437                class="peach-underline-input peach-newvault-input lock-password-input"438                type={showPassword() ? "text" : "password"}439                placeholder="••••••••••••"440                autocomplete="new-password"441                value={password()}442                disabled={!props.ready}443                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}444                onKeyDown={(event) => {445                  if (event.key !== "Enter" || !props.ready) return;446                  void handleCreate();447                }}448              />449              <Show450                when={password().length > 0 && passwordStrength() !== null}451                fallback={452                  <p class="peach-newvault-hint">at least 12 characters</p>453                }454              >455                <div class="peach-password-strength">456                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>457                    <div class="peach-strength-fill"></div>458                  </div>459                  <span class="peach-strength-label">{passwordStrength()}</span>460                </div>461              </Show>462            </div>463464            <div class="peach-newvault-field">465              <label class="peach-label" for="confirm-password">466                confirm password467              </label>468              <input469                id="confirm-password"470                class="peach-underline-input peach-newvault-input"471                type={showPassword() ? "text" : "password"}472                placeholder="••••••••••••"473                autocomplete="new-password"474                value={confirmPassword()}475                disabled={!props.ready}476                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}477                onKeyDown={(event) => {478                  if (event.key === "Enter" && props.ready) void handleCreate();479                }}480              />481            </div>482483            <Show when={error()}>484              <p class="peach-hint peach-hint-error">{error()}</p>485            </Show>486487            <button488              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"489              onClick={() => void handleCreate()}490              disabled={!props.ready || loading() || !canCreate()}491              aria-live="polite"492            >493              <Show494                when={!canCreate() && createBlockerMessage()}495                fallback={loading() ? "creating..." : "create vault"}496              >497                {createBlockerMessage()}498              </Show>499            </button>500501            <p class="peach-newvault-warning">502              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>503              <span>Save your 12 recovery words right after you create the vault — they're the only way in if you forget your password.</span>504            </p>505          </div>506507          <div class="peach-newvault-footer">508            <p class="peach-hint">Already have a backup?</p>509            <button510              class="peach-lock-restore-link"511              onClick={() => void openRestore("restore")}512              disabled={!props.ready}513            >514              › restore from backup515            </button>516          </div>517        </section>518      </Match>519    </Switch>520  );521}522523// ── Restore tab view ───────────────────────────────────────────────────────524525type RestoreTab = "symbols" | "csv" | "phone";526527function tabLabel(tab: RestoreTab): string {528  return (529    { symbols: "PeachScript", csv: "CSV", phone: "From Phone" }[tab]530  );531}532533function RestoreTabView(props: {534  hasVault: boolean;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, setPendingUnlock] = 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", "csv", "phone"] 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() === "csv"}>592            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />593          </Show>594          <Show when={activeTab() === "phone"}>595            <RestoreFromPhone 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// ── PeachScript tab ────────────────────────────────────────────────────────625626function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {627  const [scriptInput, setScriptInput] = createSignal("");628  const [backupPass, setBackupPass] = createSignal("");629  const [working, setWorking] = createSignal(false);630  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);631  const [error, setError] = createSignal<string | null>(null);632  let canvasRef: HTMLCanvasElement | undefined;633  let animFrameId = 0;634  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];635636  const scriptValid = createMemo(() => isPeachScript(scriptInput()));637638  function stopAnim() {639    cancelAnimationFrame(animFrameId);640    animFrameId = 0;641  }642643  function startChaos(canvas: HTMLCanvasElement) {644    const ctx = canvas.getContext("2d")!;645    const W = canvas.width;646    const H = canvas.height;647    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>648      String.fromCodePoint(0xe000 + i)649    );650    if (runes.length === 0) {651      runes = Array.from({ length: 60 }, () => ({652        x: Math.random() * W,653        y: Math.random() * H,654        vx: (Math.random() - 0.5) * 3,655        vy: (Math.random() - 0.5) * 3,656        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,657        size: 14 + Math.random() * 20,658        angle: Math.random() * Math.PI * 2,659        va: (Math.random() - 0.5) * 0.1,660      }));661    }662663    function draw() {664      ctx.clearRect(0, 0, W, H);665      for (const r of runes) {666        r.x = (r.x + r.vx + W) % W;667        r.y = (r.y + r.vy + H) % H;668        r.angle += r.va;669        ctx.save();670        ctx.translate(r.x, r.y);671        ctx.rotate(r.angle);672        ctx.font = `${r.size}px PeachScript, monospace`;673        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);674        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;675        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";676        ctx.shadowBlur = 8;677        ctx.fillText(r.char, 0, 0);678        ctx.restore();679      }680      animFrameId = requestAnimationFrame(draw);681    }682    draw();683  }684685  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {686    stopAnim();687    const ctx = canvas.getContext("2d")!;688    const W = canvas.width;689    const H = canvas.height;690    const cols = 8;691    const rows = 6;692    const cellW = W / cols;693    const cellH = H / rows;694    const targets = runes.slice(0, cols * rows).map((_, i) => ({695      x: (i % cols) * cellW + cellW / 2,696      y: Math.floor(i / cols) * cellH + cellH / 2,697    }));698    const start = performance.now();699    const DURATION = 400;700701    function drawSnap(now: number) {702      const t = Math.min((now - start) / DURATION, 1);703      const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;704      ctx.clearRect(0, 0, W, H);705      runes.slice(0, targets.length).forEach((r, i) => {706        const tx = targets[i]!.x;707        const ty = targets[i]!.y;708        const cx = r.x + (tx - r.x) * ease;709        const cy = r.y + (ty - r.y) * ease;710        ctx.font = `16px PeachScript, monospace`;711        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;712        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";713        ctx.shadowBlur = 6;714        ctx.fillText(r.char, cx - 8, cy + 8);715      });716      if (t < 1) {717        animFrameId = requestAnimationFrame(drawSnap);718      } else {719        onDone();720      }721    }722    animFrameId = requestAnimationFrame(drawSnap);723  }724725  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {726    stopAnim();727    const ctx = canvas.getContext("2d")!;728    const W = canvas.width;729    const H = canvas.height;730    const start = performance.now();731    const DURATION = 900;732733    function drawBloom(now: number) {734      const t = Math.min((now - start) / DURATION, 1);735      ctx.clearRect(0, 0, W, H);736      // Runes dissolve737      const runeAlpha = 1 - Math.min(t / 0.4, 1);738      if (runeAlpha > 0) {739        for (const r of runes.slice(0, 48)) {740          ctx.font = `16px PeachScript, monospace`;741          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;742          ctx.fillText(r.char, r.x - 8, r.y + 8);743        }744      }745      // Peach mark bloom746      const markT = Math.max(0, (t - 0.2) / 0.5);747      const markScale = Math.min(markT * 1.2, 1);748      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;749      if (markAlpha > 0) {750        ctx.save();751        ctx.globalAlpha = markAlpha;752        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;753        ctx.shadowBlur = 30;754        ctx.translate(W / 2, H / 2);755        ctx.scale(markScale, markScale);756        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);757        ctx.restore();758      }759      if (t < 1) {760        animFrameId = requestAnimationFrame(drawBloom);761      } else {762        onDone();763      }764    }765    animFrameId = requestAnimationFrame(drawBloom);766  }767768  onCleanup(() => stopAnim());769770  async function handleRestore() {771    if (!scriptValid() || !backupPass().trim() || working()) return;772    setError(null);773    setWorking(true);774    setAnimating("chaos");775776    const canvas = canvasRef;777    if (canvas) startChaos(canvas);778779    try {780      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));781      const payload = await parsePayload(bytes, backupPass());782783      if (!props.hasVault) {784        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });785        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");786      }787788      const rr = await sendMessage({789        type: "RESTORE_ENTRIES",790        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },791      });792      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");793794      if (payload.license?.key) {795        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});796      }797798      setAnimating("snap");799      stopAnim();800      if (canvas) {801        snapToGrid(canvas, () => {802          setAnimating("bloom");803          bloom(canvas, async () => {804            setAnimating(null);805            await props.onSuccess();806          });807        });808      } else {809        await props.onSuccess();810      }811    } catch (err) {812      stopAnim();813      setAnimating(null);814      const code = err instanceof Error ? err.message : String(err);815      setError(816        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"817          ? "That passphrase didn't work."818          : code === "CREATE_VAULT_FAILED"819            ? "Couldn't create a new local vault."820            : "Could not read this backup.",821      );822    } finally {823      setWorking(false);824    }825  }826827  return (828    <div class="peach-rt-symbols">829      <Show when={animating() !== null}>830        <canvas831          ref={(el) => { canvasRef = el; }}832          class="peach-rt-anim-canvas"833          width={280}834          height={200}835        />836      </Show>837      <Show when={animating() === null}>838        <textarea839          class="peach-restore-symbols-textarea"840          placeholder="paste peachscript…"841          rows={3}842          value={scriptInput()}843          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}844        />845        <div class="peach-rt-divider">— or —</div>846        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">847          open .peach file848          <input849            type="file"850            accept=".peach,text/plain"851            style="display:none"852            onChange={async (e) => {853              const file = (e.currentTarget as HTMLInputElement).files?.[0];854              if (!file) return;855              try {856                setScriptInput(await file.text());857              } catch {858                setError("Couldn't read file.");859              } finally {860                (e.currentTarget as HTMLInputElement).value = "";861              }862            }}863          />864        </label>865        <input866          class="peach-restore-symbols-pass"867          type="password"868          placeholder="backup password"869          value={backupPass()}870          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}871          onKeyDown={(e) => {872            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {873              e.preventDefault();874              void handleRestore();875            }876          }}877        />878        <button879          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"880          onClick={() => void handleRestore()}881          disabled={!scriptValid() || !backupPass().trim() || working()}882        >883          restore vault →884        </button>885        <Show when={error()}>886          <p class="peach-rt-error">{error()}</p>887        </Show>888      </Show>889    </div>890  );891}892893// ── CSV tab ────────────────────────────────────────────────────────────────894895function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {896  const [importing, setImporting] = createSignal(false);897  const [error, setError] = createSignal<string | null>(null);898899  async function handleFile(e: Event) {900    const input = e.currentTarget as HTMLInputElement;901    const file = input.files?.[0];902    input.value = "";903    if (!file) return;904    setImporting(true);905    setError(null);906    try {907      const text = await file.text();908      const parsed = await parseImportSource(text);909      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");910911      if (props.hasVault === false) {912        // No vault yet — this path shouldn't normally occur for CSV (no backup password913        // means no way to create a locked vault). Create an unlocked placeholder vault.914        // The user can set a master password from settings.915        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });916        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");917      }918919      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });920      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");921      if (parsed.licenseKey) {922        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});923      }924      await props.onSuccess();925    } catch (err) {926      const code = err instanceof Error ? err.message : String(err);927      setError(928        code === "NO_ENTRIES" ? "No entries found in this file." :929        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :930        "Import failed. Check the file format and try again."931      );932    } finally {933      setImporting(false);934    }935  }936937  return (938    <div class="peach-rt-csv">939      <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>940        {importing() ? "importing…" : "open csv file"}941        <input942          type="file"943          accept=".csv,.json,.peach,application/x-peach"944          style="display:none"945          onChange={handleFile}946          disabled={importing()}947        />948      </label>949      <p class="peach-rt-csv-sources">950        Imports from Bitwarden, 1Password, Proton Pass,<br />951        Chrome, Safari, and Edge.952      </p>953      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>954      <Show when={error()}>955        <p class="peach-rt-error">{error()}</p>956      </Show>957    </div>958  );959}960961// ── From Phone tab ─────────────────────────────────────────────────────────962963type PhoneState = "waiting" | "connected" | "trust" | "error";964965function RestoreFromPhone(props: { onSuccess: () => void | Promise<void> }) {966  const [state, setState] = createSignal<PhoneState>("waiting");967  const [code, setCode] = createSignal<string | null>(null);968  const [qrUrl, setQrUrl] = createSignal<string | null>(null);969  const [expired, setExpired] = createSignal(false);970  const [deviceName, setDeviceName] = createSignal<string | null>(null);971  const [accepting, setAccepting] = createSignal(false);972  const [serverError, setServerError] = createSignal<string | null>(null);973  let expireTimer: ReturnType<typeof setTimeout> | null = null;974975  function clearExpire() {976    if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }977  }978979  async function startPairing() {980    setExpired(false);981    setCode(null);982    setQrUrl(null);983    setServerError(null);984    clearExpire();985    const res = await sendMessage({ type: "START_PAIRING_SERVER" });986    const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };987    if (!r.ok || !r.data) {988      setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");989      return;990    }991    const token = r.data.token;992    const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;993    setCode(formatted);994    const qrData = `peach://handoff?token=${token}`;995    try {996      const QRCode = (await import("qrcode")).default;997      const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });998      setQrUrl(url);999    } catch {1000      // QR optional — code is the primary affordance1001    }1002    const remaining = Math.max(0, r.data.expiresAt - Date.now());1003    expireTimer = setTimeout(() => {1004      setCode(null);1005      setQrUrl(null);1006      setExpired(true);1007    }, remaining);1008  }10091010  function handleRuntimeMsg(msg: unknown) {1011    const m = msg as { type?: string; payload?: { deviceName?: string } };1012    if (m?.type === "PAIRING_COMPLETED") {1013      clearExpire();1014      setDeviceName(m.payload?.deviceName ?? "your phone");1015      setState("trust");1016    }1017  }10181019  onMount(() => {1020    void startPairing();1021    // @ts-ignore — chrome runtime1022    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1023      chrome.runtime.onMessage.addListener(handleRuntimeMsg);1024    }1025  });10261027  onCleanup(() => {1028    clearExpire();1029    // @ts-ignore1030    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1031      chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1032    }1033    sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1034  });10351036  async function handleAccept(trust: boolean) {1037    setAccepting(true);1038    if (trust) {1039      await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1040    }1041    await props.onSuccess();1042  }10431044  return (1045    <div class="peach-rt-phone">1046      <Show when={state() === "waiting"}>1047        <Show when={!expired()}>1048          <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1049            <p class="peach-rt-phone-code">{code()}</p>1050            <Show when={qrUrl() !== null}>1051              <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1052            </Show>1053            <p class="peach-rt-phone-hint">1054              Open Peach on your phone and tap<br />1055              Settings → Send to desktop.<br />1056              Same WiFi required.1057            </p>1058          </Show>1059          <Show when={serverError()}>1060            <p class="peach-rt-error">{serverError()}</p>1061          </Show>1062        </Show>1063        <Show when={expired()}>1064          <p class="peach-rt-phone-hint">Code expired.</p>1065          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1066            Generate new code1067          </button>1068        </Show>1069      </Show>10701071      <Show when={state() === "connected"}>1072        <p class="peach-rt-phone-hint">1073          Connected to {deviceName() ?? "your phone"}.<br />1074          Receiving vault…1075        </p>1076      </Show>10771078      <Show when={state() === "trust"}>1079        <p class="peach-rt-phone-hint" style="margin-bottom:10px">1080          Vault received from {deviceName() ?? "your phone"}.<br />1081          Trust this network for sync?1082        </p>1083        <button1084          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1085          onClick={() => void handleAccept(true)}1086          disabled={accepting()}1087        >1088          Yes, trust this network1089        </button>1090        <button1091          class="peach-btn peach-btn--ghost peach-btn--sm"1092          style="margin-top:6px"1093          onClick={() => void handleAccept(false)}1094          disabled={accepting()}1095        >1096          Not now1097        </button>1098      </Show>10991100      <Show when={state() === "error"}>1101        <p class="peach-rt-phone-hint">1102          Could not connect. Check that both devices<br />1103          are on the same WiFi network.1104        </p>1105        <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1106          Try again1107        </button>1108      </Show>1109    </div>1110  );1111}11121113// ── Helpers ────────────────────────────────────────────────────────────────11141115function getDisplayError(error: string | undefined, fallback: string): string {1116  if (!error) return fallback;1117  if (1118    error === "SERVICE_WORKER_UNAVAILABLE" ||1119    error.includes("Receiving end does not exist") ||1120    error.includes("message port closed")1121  ) {1122    return "background service unavailable";1123  }1124  return error;1125}