Lock.tsx

37.3 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";1314const peachMarkImg = new Image();15peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(16  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">' +17  '<path d="M32 54C16 54 8 44 8 31C8 18 17 10 27 10C28 7 30 5.5 32 5.5C34 5.5 36 7 37 10C47 10 56 18 56 31C56 44 48 54 32 54Z" fill="#D96830"/>' +18  '<path d="M37 8.5C40.5 3.5 48 5 46 10.5C42 11 37 8.5 37 8.5Z" fill="#7B9060"/>' +19  '<path d="M32 5.5Q35.5 1 39 2.5" stroke="#7B9060" stroke-width="1.5" stroke-linecap="round"/>' +20  '</svg>'21)}`;2223// Lazy-load zxcvbn — it's heavy (~800 kB) and only needed during vault creation24let _zxcvbn: ((password: string) => { score: number }) | null = null;25async function getZxcvbn() {26  if (_zxcvbn) return _zxcvbn;27  const mod = await import("zxcvbn");28  _zxcvbn = mod.default;29  return _zxcvbn;30}3132interface LockProps {33  hasVault: boolean;34  ready: boolean;35  isPro?: boolean;36  onUnlock: () => void | Promise<void>;37  /** Optional escape hatch: rewind from the create-vault screen back to38   *  Onboarding. Only meaningful when the user reached create-vault from39   *  Onboarding in the first place. */40  onBackToOnboarding?: () => void;41}4243type RestoreSurface = "restore" | null;44type RecoverSurface = "words" | "new-password" | null;4546export function LockView(props: LockProps) {47  const [password, setPassword] = createSignal("");48  const [confirmPassword, setConfirmPassword] = createSignal("");49  const [error, setError] = createSignal("");50  const [loading, setLoading] = createSignal(false);51  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);52  const [showPassword, setShowPassword] = createSignal(false);53  const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);54  // After vault creation succeeds, set this to show the recovery words screen.55  // Only meaningful when hasVault === false.56  const [showRecoveryWords, setShowRecoveryWords] = createSignal(false);57  const [recoveryWords, setRecoveryWords] = createSignal<string[]>([]);5859  const MIN_LENGTH = 12;60  const MIN_ZXCVBN_SCORE = 2;6162  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);6364  const canCreate = createMemo(65    () =>66      password().length >= MIN_LENGTH &&67      confirmPassword().length >= MIN_LENGTH &&68      password() === confirmPassword() &&69      strengthScore() !== null &&70      strengthScore()! >= MIN_ZXCVBN_SCORE71  );7273  const passwordStrength = createMemo(() => {74    const score = strengthScore();75    if (score === null) return null;76    if (score <= 1) return "weak";77    if (score === 2) return "fair";78    if (score === 3) return "good";79    return "strong";80  });8182  const createBlockerMessage = createMemo(() => {83    if (!password()) return null;84    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;85    if (!confirmPassword()) return "confirm your password";86    if (password() !== confirmPassword()) return "passwords don't match";87    if (strengthScore() === null) return "checking strength…";88    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";89    return null;90  });9192  // Update strength asynchronously when password changes93  createMemo(() => {94    const value = password();95    if (!value) { setStrengthScore(null); return; }96    getZxcvbn().then((zxcvbn) => {97      if (password() === value) setStrengthScore(zxcvbn(value).score);98    }).catch(() => {99      // zxcvbn unavailable — fall back to length-only heuristic100      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);101    });102  });103104  async function handleUnlock() {105    if (!password().trim()) return;106    setLoading(true);107    setError("");108    const response = await sendMessage({109      type: "UNLOCK",110      payload: { password: password() },111    });112    const result = response as { ok: boolean; error?: string };113    if (result.ok) {114      await props.onUnlock();115      setLoading(false);116      return;117    }118    setError(getDisplayError(result.error, "incorrect password"));119    setPassword("");120    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;121    passwordField?.classList.add("shake");122    setTimeout(() => passwordField?.classList.remove("shake"), 300);123    setLoading(false);124  }125126  async function handleCreate() {127    if (!password().trim()) return;128    if (password().length < MIN_LENGTH) {129      setError(`password must be at least ${MIN_LENGTH} characters`);130      return;131    }132    if (password() !== confirmPassword()) {133      setError("passwords do not match");134      return;135    }136    const score = strengthScore();137    if (score === null || score < MIN_ZXCVBN_SCORE) {138      setError("password is too weak — try a longer or more varied passphrase");139      return;140    }141    setLoading(true);142    setError("");143    const response = await sendMessage({144      type: "CREATE_VAULT",145      payload: { password: password() },146    });147    const result = response as { ok: boolean; error?: string };148    if (result.ok) {149      // Don't unlock yet — show the recovery words screen first.150      setShowRecoveryWords(true);151      setLoading(false);152      return;153    }154    setError(getDisplayError(result.error, "failed to create vault"));155    setLoading(false);156  }157158  function openRestore(surface: Exclude<RestoreSurface, null>) {159    if (!props.ready || loading()) return;160    setPassword("");161    setConfirmPassword("");162    setError("");163    setRestoreSurface(surface);164  }165166  // ── All surfaces routed reactively via Switch/Match ───────────────────167  return (168    <Switch>169      {/* ── Restore tab view ──────────────────────────────────────── */}170      <Match when={restoreSurface() === "restore"}>171        <RestoreTabView172          hasVault={props.hasVault}173          onBack={() => setRestoreSurface(null)}174          onUnlock={async () => {175            setRestoreSurface(null);176            await props.onUnlock();177          }}178        />179      </Match>180181      {/* ── Recovery words display after fresh vault creation ────────── */}182      <Match when={showRecoveryWords() && !props.hasVault}>183        <RecoveryWordDisplayView184          onDone={props.onUnlock}185        />186      </Match>187188      {/* ── Recovery word entry ─────────────────────────────────────── */}189      <Match when={recoverSurface() === "words"}>190        <RecoveryWordEntryView191          onCancel={() => setRecoverSurface(null)}192          onRecoveryComplete={props.onUnlock}193        />194      </Match>195196      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}197      <Match when={props.hasVault}>198        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">199          <header class="peach-screen-header lock-screen-header">200            <div class="peach-wordmark-stack">201              <PeachWordmark class="peach-wordmark-wrap" />202              <Show when={props.isPro}>203                <span class="peach-pro-mini-badge">pro</span>204              </Show>205            </div>206          </header>207208          <div class="peach-lock-hero peach-lock-hero--unlock">209            <div class="peach-lock-hero-art" aria-hidden="true">210              <PeachMark class="peach-lock-hero-mark" />211            </div>212            <span class="peach-lock-eyebrow">vault locked</span>213            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">214              unlock215              <br />216              your vault.217            </h1>218          </div>219220          <LockRuneCanvas />221222          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">223            <label class="peach-label" for="unlock-password">224              master password225            </label>226            <input227              id="unlock-password"228              class="peach-underline-input peach-unlock-input lock-password-input"229              type="password"230              placeholder="••••••••••••"231              autocomplete="current-password"232              value={password()}233              disabled={!props.ready}234              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}235              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}236              onKeyDown={(event) => {237                if (event.key !== "Enter" || !props.ready) return;238                void handleUnlock();239              }}240            />241242            <Show when={error()}>243              <p class="peach-hint peach-hint-error">{error()}</p>244            </Show>245246            <button247              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"248              onClick={() => void handleUnlock()}249              disabled={!props.ready || loading()}250            >251              {loading() ? "unlocking..." : "unlock"}252            </button>253254            <button255              class="peach-lock-recover-link"256              type="button"257              onClick={() => setRecoverSurface("words")}258            >259              forgot your password?260            </button>261          </section>262        </section>263      </Match>264265      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}266      <Match when={true}>267        <section268          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"269          data-motion-phase="enter"270        >271          <header class="peach-screen-header peach-newvault-header">272            <Show when={props.onBackToOnboarding}>273              <button274                class="peach-newvault-back"275                type="button"276                onClick={() => props.onBackToOnboarding?.()}277                aria-label="Back to onboarding"278              >279                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">280                  <path d="M7 2L3 5.5L7 9" />281                </svg>282                back283              </button>284            </Show>285            <div class="peach-wordmark-stack">286              <PeachWordmark class="peach-wordmark-wrap" />287              <Show when={props.isPro}>288                <span class="peach-pro-mini-badge">pro</span>289              </Show>290            </div>291          </header>292293          <div class="peach-newvault-mark" aria-hidden="true">294            <PeachMark class="peach-newvault-mark-art" />295          </div>296297          <div class="peach-newvault-card">298            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>299            <hr class="peach-newvault-divider" />300301            <div class="peach-newvault-field">302              <div class="peach-newvault-label-row">303                <label class="peach-label" for="master-password">304                  master password305                </label>306                <button307                  class="peach-lock-reveal-btn"308                  type="button"309                  onClick={() => setShowPassword((value) => !value)}310                  aria-label={showPassword() ? "Hide password" : "Show password"}311                >312                  <Show313                    when={showPassword()}314                    fallback={315                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">316                        <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" />317                        <circle cx="7" cy="5" r="1.75" />318                      </svg>319                    }320                  >321                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">322                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />323                      <path d="M3.5 5.5L2.5 7.5" />324                      <path d="M7 6.5V8.5" />325                      <path d="M10.5 5.5L11.5 7.5" />326                    </svg>327                  </Show>328                  {showPassword() ? "hide" : "show"}329                </button>330              </div>331              <input332                id="master-password"333                class="peach-underline-input peach-newvault-input lock-password-input"334                type={showPassword() ? "text" : "password"}335                placeholder="••••••••••••"336                autocomplete="new-password"337                value={password()}338                disabled={!props.ready}339                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}340                onKeyDown={(event) => {341                  if (event.key !== "Enter" || !props.ready) return;342                  void handleCreate();343                }}344              />345              <Show346                when={password().length > 0 && passwordStrength() !== null}347                fallback={348                  <p class="peach-newvault-hint">at least 12 characters</p>349                }350              >351                <div class="peach-password-strength">352                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>353                    <div class="peach-strength-fill"></div>354                  </div>355                  <span class="peach-strength-label">{passwordStrength()}</span>356                </div>357              </Show>358            </div>359360            <div class="peach-newvault-field">361              <label class="peach-label" for="confirm-password">362                confirm password363              </label>364              <input365                id="confirm-password"366                class="peach-underline-input peach-newvault-input"367                type={showPassword() ? "text" : "password"}368                placeholder="••••••••••••"369                autocomplete="new-password"370                value={confirmPassword()}371                disabled={!props.ready}372                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}373                onKeyDown={(event) => {374                  if (event.key === "Enter" && props.ready) void handleCreate();375                }}376              />377            </div>378379            <Show when={error()}>380              <p class="peach-hint peach-hint-error">{error()}</p>381            </Show>382383            <button384              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"385              onClick={() => void handleCreate()}386              disabled={!props.ready || loading() || !canCreate()}387              aria-live="polite"388            >389              <Show390                when={!canCreate() && createBlockerMessage()}391                fallback={loading() ? "creating..." : "create vault"}392              >393                {createBlockerMessage()}394              </Show>395            </button>396397            <p class="peach-newvault-warning">398              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>399              <span>Save your 12 recovery words right after you create the vault — they're the only way in if you forget your password.</span>400            </p>401          </div>402403          <div class="peach-newvault-footer">404            <p class="peach-hint">Already have a backup?</p>405            <button406              class="peach-lock-restore-link"407              onClick={() => void openRestore("restore")}408              disabled={!props.ready}409            >410              › restore from backup411            </button>412          </div>413        </section>414      </Match>415    </Switch>416  );417}418419// ── Restore tab view ───────────────────────────────────────────────────────420421type RestoreTab = "symbols" | "csv" | "phone";422423function tabLabel(tab: RestoreTab): string {424  return (425    { symbols: "PeachScript", csv: "CSV", phone: "From Phone" }[tab]426  );427}428429function RestoreTabView(props: {430  hasVault: boolean;431  onBack: () => void;432  onUnlock: () => void | Promise<void>;433}) {434  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");435  const [showIntro, setShowIntro] = createSignal(false);436  const [pendingUnlock, setPendingUnlock] = createSignal(false);437438  async function handleRestoreSuccess() {439    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);440    if (!seen) {441      setShowIntro(true);442    } else {443      await props.onUnlock();444    }445  }446447  async function dismissIntro() {448    await storageSet("peach_shown_restore_intro", "true").catch(() => {});449    setShowIntro(false);450    await props.onUnlock();451  }452453  return (454    <section455      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"456      data-motion-phase="enter"457    >458      <RuneBackground opacity={0.05} />459460      <header class="peach-screen-header lock-screen-header">461        <PeachWordmark class="peach-wordmark-wrap" />462        <button class="peach-lock-restore-link" onClick={props.onBack}>463          ‹ back464        </button>465      </header>466467      <div class="peach-newvault-card peach-restore-tabcard">468        <div class="peach-restore-tabs" role="tablist">469          <For each={["symbols", "csv", "phone"] as RestoreTab[]}>470            {(tab) => (471              <button472                class="peach-restore-tab"473                classList={{ "is-active": activeTab() === tab }}474                onClick={() => setActiveTab(tab)}475                role="tab"476                aria-selected={activeTab() === tab}477              >478                {tabLabel(tab)}479              </button>480            )}481          </For>482        </div>483        <div class="peach-restore-content">484          <Show when={activeTab() === "symbols"}>485            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />486          </Show>487          <Show when={activeTab() === "csv"}>488            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />489          </Show>490          <Show when={activeTab() === "phone"}>491            <RestoreFromPhone onSuccess={handleRestoreSuccess} />492          </Show>493        </div>494      </div>495496      {/* One-time intro popup after first restore */}497      <Show when={showIntro()}>498        <div class="peach-restore-intro-overlay">499          <div class="peach-restore-intro-card">500            <p class="peach-restore-intro-headline">welcome to peach.</p>501            <div class="peach-restore-intro-body">502              <p>Your vault lives only on this device. No server has a copy.</p>503              <p>The inline bubble fills passwords automatically when you visit sites.</p>504              <p>Export your vault from Settings before switching devices.</p>505            </div>506            <button507              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"508              onClick={() => void dismissIntro()}509              disabled={pendingUnlock()}510            >511              got it512            </button>513          </div>514        </div>515      </Show>516    </section>517  );518}519520// ── PeachScript tab ────────────────────────────────────────────────────────521522function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {523  const [scriptInput, setScriptInput] = createSignal("");524  const [backupPass, setBackupPass] = createSignal("");525  const [working, setWorking] = createSignal(false);526  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);527  const [error, setError] = createSignal<string | null>(null);528  let canvasRef: HTMLCanvasElement | undefined;529  let animFrameId = 0;530  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];531532  const scriptValid = createMemo(() => isPeachScript(scriptInput()));533534  function stopAnim() {535    cancelAnimationFrame(animFrameId);536    animFrameId = 0;537  }538539  function startChaos(canvas: HTMLCanvasElement) {540    const ctx = canvas.getContext("2d")!;541    const W = canvas.width;542    const H = canvas.height;543    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>544      String.fromCodePoint(0xe000 + i)545    );546    if (runes.length === 0) {547      runes = Array.from({ length: 60 }, () => ({548        x: Math.random() * W,549        y: Math.random() * H,550        vx: (Math.random() - 0.5) * 3,551        vy: (Math.random() - 0.5) * 3,552        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,553        size: 14 + Math.random() * 20,554        angle: Math.random() * Math.PI * 2,555        va: (Math.random() - 0.5) * 0.1,556      }));557    }558559    function draw() {560      ctx.clearRect(0, 0, W, H);561      for (const r of runes) {562        r.x = (r.x + r.vx + W) % W;563        r.y = (r.y + r.vy + H) % H;564        r.angle += r.va;565        ctx.save();566        ctx.translate(r.x, r.y);567        ctx.rotate(r.angle);568        ctx.font = `${r.size}px PeachScript, monospace`;569        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);570        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;571        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";572        ctx.shadowBlur = 8;573        ctx.fillText(r.char, 0, 0);574        ctx.restore();575      }576      animFrameId = requestAnimationFrame(draw);577    }578    draw();579  }580581  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {582    stopAnim();583    const ctx = canvas.getContext("2d")!;584    const W = canvas.width;585    const H = canvas.height;586    const cols = 8;587    const rows = 6;588    const cellW = W / cols;589    const cellH = H / rows;590    const targets = runes.slice(0, cols * rows).map((_, i) => ({591      x: (i % cols) * cellW + cellW / 2,592      y: Math.floor(i / cols) * cellH + cellH / 2,593    }));594    const start = performance.now();595    const DURATION = 400;596597    function drawSnap(now: number) {598      const t = Math.min((now - start) / DURATION, 1);599      const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;600      ctx.clearRect(0, 0, W, H);601      runes.slice(0, targets.length).forEach((r, i) => {602        const tx = targets[i]!.x;603        const ty = targets[i]!.y;604        const cx = r.x + (tx - r.x) * ease;605        const cy = r.y + (ty - r.y) * ease;606        ctx.font = `16px PeachScript, monospace`;607        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;608        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";609        ctx.shadowBlur = 6;610        ctx.fillText(r.char, cx - 8, cy + 8);611      });612      if (t < 1) {613        animFrameId = requestAnimationFrame(drawSnap);614      } else {615        onDone();616      }617    }618    animFrameId = requestAnimationFrame(drawSnap);619  }620621  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {622    stopAnim();623    const ctx = canvas.getContext("2d")!;624    const W = canvas.width;625    const H = canvas.height;626    const start = performance.now();627    const DURATION = 900;628629    function drawBloom(now: number) {630      const t = Math.min((now - start) / DURATION, 1);631      ctx.clearRect(0, 0, W, H);632      // Runes dissolve633      const runeAlpha = 1 - Math.min(t / 0.4, 1);634      if (runeAlpha > 0) {635        for (const r of runes.slice(0, 48)) {636          ctx.font = `16px PeachScript, monospace`;637          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;638          ctx.fillText(r.char, r.x - 8, r.y + 8);639        }640      }641      // Peach mark bloom642      const markT = Math.max(0, (t - 0.2) / 0.5);643      const markScale = Math.min(markT * 1.2, 1);644      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;645      if (markAlpha > 0) {646        ctx.save();647        ctx.globalAlpha = markAlpha;648        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;649        ctx.shadowBlur = 30;650        ctx.translate(W / 2, H / 2);651        ctx.scale(markScale, markScale);652        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);653        ctx.restore();654      }655      if (t < 1) {656        animFrameId = requestAnimationFrame(drawBloom);657      } else {658        onDone();659      }660    }661    animFrameId = requestAnimationFrame(drawBloom);662  }663664  onCleanup(() => stopAnim());665666  async function handleRestore() {667    if (!scriptValid() || !backupPass().trim() || working()) return;668    setError(null);669    setWorking(true);670    setAnimating("chaos");671672    const canvas = canvasRef;673    if (canvas) startChaos(canvas);674675    try {676      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));677      const payload = await parsePayload(bytes, backupPass());678679      if (!props.hasVault) {680        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });681        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");682      }683684      const rr = await sendMessage({685        type: "RESTORE_ENTRIES",686        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },687      });688      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");689690      if (payload.license?.key) {691        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});692      }693694      setAnimating("snap");695      stopAnim();696      if (canvas) {697        snapToGrid(canvas, () => {698          setAnimating("bloom");699          bloom(canvas, async () => {700            setAnimating(null);701            await props.onSuccess();702          });703        });704      } else {705        await props.onSuccess();706      }707    } catch (err) {708      stopAnim();709      setAnimating(null);710      const code = err instanceof Error ? err.message : String(err);711      setError(712        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"713          ? "That passphrase didn't work."714          : code === "CREATE_VAULT_FAILED"715            ? "Couldn't create a new local vault."716            : "Could not read this backup.",717      );718    } finally {719      setWorking(false);720    }721  }722723  return (724    <div class="peach-rt-symbols">725      <Show when={animating() !== null}>726        <canvas727          ref={(el) => { canvasRef = el; }}728          class="peach-rt-anim-canvas"729          width={280}730          height={200}731        />732      </Show>733      <Show when={animating() === null}>734        <textarea735          class="peach-restore-symbols-textarea"736          placeholder="paste PeachScript here…"737          rows={3}738          value={scriptInput()}739          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}740        />741        <div class="peach-rt-divider">— or —</div>742        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">743          open .peach file744          <input745            type="file"746            accept=".peach,text/plain"747            style="display:none"748            onChange={async (e) => {749              const file = (e.currentTarget as HTMLInputElement).files?.[0];750              if (!file) return;751              try {752                setScriptInput(await file.text());753              } catch {754                setError("Couldn't read file.");755              } finally {756                (e.currentTarget as HTMLInputElement).value = "";757              }758            }}759          />760        </label>761        <input762          class="peach-restore-symbols-pass"763          type="password"764          placeholder="backup password"765          value={backupPass()}766          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}767          onKeyDown={(e) => {768            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {769              e.preventDefault();770              void handleRestore();771            }772          }}773        />774        <button775          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"776          onClick={() => void handleRestore()}777          disabled={!scriptValid() || !backupPass().trim() || working()}778        >779          restore vault →780        </button>781        <Show when={error()}>782          <p class="peach-rt-error">{error()}</p>783        </Show>784      </Show>785    </div>786  );787}788789// ── CSV tab ────────────────────────────────────────────────────────────────790791function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {792  const [importing, setImporting] = createSignal(false);793  const [error, setError] = createSignal<string | null>(null);794795  async function handleFile(e: Event) {796    const input = e.currentTarget as HTMLInputElement;797    const file = input.files?.[0];798    input.value = "";799    if (!file) return;800    setImporting(true);801    setError(null);802    try {803      const text = await file.text();804      const parsed = await parseImportSource(text);805      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");806807      if (props.hasVault === false) {808        // No vault yet — this path shouldn't normally occur for CSV (no backup password809        // means no way to create a locked vault). Create an unlocked placeholder vault.810        // The user can set a master password from settings.811        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });812        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");813      }814815      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });816      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");817      if (parsed.licenseKey) {818        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});819      }820      await props.onSuccess();821    } catch (err) {822      const code = err instanceof Error ? err.message : String(err);823      setError(824        code === "NO_ENTRIES" ? "No entries found in this file." :825        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :826        "Import failed. Check the file format and try again."827      );828    } finally {829      setImporting(false);830    }831  }832833  return (834    <div class="peach-rt-csv">835      <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>836        {importing() ? "importing…" : "open CSV file"}837        <input838          type="file"839          accept=".csv,.json,.peach,application/x-peach"840          style="display:none"841          onChange={handleFile}842          disabled={importing()}843        />844      </label>845      <p class="peach-rt-csv-sources">846        Imports from Bitwarden, 1Password, Proton Pass,<br />847        Chrome, Safari, and Edge.848      </p>849      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>850      <Show when={error()}>851        <p class="peach-rt-error">{error()}</p>852      </Show>853    </div>854  );855}856857// ── From Phone tab ─────────────────────────────────────────────────────────858859type PhoneState = "waiting" | "connected" | "trust" | "error";860861function RestoreFromPhone(props: { onSuccess: () => void | Promise<void> }) {862  const [state, setState] = createSignal<PhoneState>("waiting");863  const [code, setCode] = createSignal<string | null>(null);864  const [qrUrl, setQrUrl] = createSignal<string | null>(null);865  const [expired, setExpired] = createSignal(false);866  const [deviceName, setDeviceName] = createSignal<string | null>(null);867  const [accepting, setAccepting] = createSignal(false);868  const [serverError, setServerError] = createSignal<string | null>(null);869  let expireTimer: ReturnType<typeof setTimeout> | null = null;870871  function clearExpire() {872    if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }873  }874875  async function startPairing() {876    setExpired(false);877    setCode(null);878    setQrUrl(null);879    setServerError(null);880    clearExpire();881    const res = await sendMessage({ type: "START_PAIRING_SERVER" });882    const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };883    if (!r.ok || !r.data) {884      setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");885      return;886    }887    const token = r.data.token;888    const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;889    setCode(formatted);890    const qrData = `peach://handoff?token=${token}`;891    try {892      const QRCode = (await import("qrcode")).default;893      const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });894      setQrUrl(url);895    } catch {896      // QR optional — code is the primary affordance897    }898    const remaining = Math.max(0, r.data.expiresAt - Date.now());899    expireTimer = setTimeout(() => {900      setCode(null);901      setQrUrl(null);902      setExpired(true);903    }, remaining);904  }905906  function handleRuntimeMsg(msg: unknown) {907    const m = msg as { type?: string; payload?: { deviceName?: string } };908    if (m?.type === "PAIRING_COMPLETED") {909      clearExpire();910      setDeviceName(m.payload?.deviceName ?? "your phone");911      setState("trust");912    }913  }914915  onMount(() => {916    void startPairing();917    // @ts-ignore — chrome runtime918    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {919      chrome.runtime.onMessage.addListener(handleRuntimeMsg);920    }921  });922923  onCleanup(() => {924    clearExpire();925    // @ts-ignore926    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {927      chrome.runtime.onMessage.removeListener(handleRuntimeMsg);928    }929    sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});930  });931932  async function handleAccept(trust: boolean) {933    setAccepting(true);934    if (trust) {935      await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});936    }937    await props.onSuccess();938  }939940  return (941    <div class="peach-rt-phone">942      <Show when={state() === "waiting"}>943        <Show when={!expired()}>944          <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>945            <p class="peach-rt-phone-code">{code()}</p>946            <Show when={qrUrl() !== null}>947              <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />948            </Show>949            <p class="peach-rt-phone-hint">950              Open Peach on your phone and tap<br />951              Settings → Send to desktop.<br />952              Same WiFi required.953            </p>954          </Show>955          <Show when={serverError()}>956            <p class="peach-rt-error">{serverError()}</p>957          </Show>958        </Show>959        <Show when={expired()}>960          <p class="peach-rt-phone-hint">Code expired.</p>961          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>962            Generate new code963          </button>964        </Show>965      </Show>966967      <Show when={state() === "connected"}>968        <p class="peach-rt-phone-hint">969          Connected to {deviceName() ?? "your phone"}.<br />970          Receiving vault…971        </p>972      </Show>973974      <Show when={state() === "trust"}>975        <p class="peach-rt-phone-hint" style="margin-bottom:10px">976          Vault received from {deviceName() ?? "your phone"}.<br />977          Trust this network for sync?978        </p>979        <button980          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"981          onClick={() => void handleAccept(true)}982          disabled={accepting()}983        >984          Yes, trust this network985        </button>986        <button987          class="peach-btn peach-btn--ghost peach-btn--sm"988          style="margin-top:6px"989          onClick={() => void handleAccept(false)}990          disabled={accepting()}991        >992          Not now993        </button>994      </Show>995996      <Show when={state() === "error"}>997        <p class="peach-rt-phone-hint">998          Could not connect. Check that both devices<br />999          are on the same WiFi network.1000        </p>1001        <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1002          Try again1003        </button>1004      </Show>1005    </div>1006  );1007}10081009// ── Helpers ────────────────────────────────────────────────────────────────10101011function getDisplayError(error: string | undefined, fallback: string): string {1012  if (!error) return fallback;1013  if (1014    error === "SERVICE_WORKER_UNAVAILABLE" ||1015    error.includes("Receiving end does not exist") ||1016    error.includes("message port closed")1017  ) {1018    return "background service unavailable";1019  }1020  return error;1021}