Lock.tsx

42.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 { storageGet, storageSet } from "../../shared/storage";9import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";10import { parsePayload } from "../../crypto/peachPayload";11import { parseImportSource } from "../../shared/import";12import { isNativePlatform, nativePlugin } from "../../shared/native";1314const peachMarkImg = new Image();15peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(16  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">' +17  '<path d="M32 54C16 54 8 44 8 31C8 18 17 10 27 10C28 7 30 5.5 32 5.5C34 5.5 36 7 37 10C47 10 56 18 56 31C56 44 48 54 32 54Z" fill="#D96830"/>' +18  '<path d="M37 8.5C40.5 3.5 48 5 46 10.5C42 11 37 8.5 37 8.5Z" fill="#7B9060"/>' +19  '<path d="M32 5.5Q35.5 1 39 2.5" stroke="#7B9060" stroke-width="1.5" stroke-linecap="round"/>' +20  '</svg>'21)}`;2223// Lazy-load zxcvbn — it's heavy (~800 kB) and only needed during vault creation24let _zxcvbn: ((password: string) => { score: number }) | null = null;25async function getZxcvbn() {26  if (_zxcvbn) return _zxcvbn;27  const mod = await import("zxcvbn");28  _zxcvbn = mod.default;29  return _zxcvbn;30}3132interface LockProps {33  hasVault: boolean;34  ready: boolean;35  isPro?: boolean;36  /** When true, render the recovery-words display directly instead of the37   *  unlock UI. Used after a popup close+reopen during the create-vault38   *  recovery confirmation flow (peach_recovery_pending flag). */39  forceRecoveryWords?: boolean;40  onUnlock: () => void | Promise<void>;41  /** Optional escape hatch: rewind from the create-vault screen back to42   *  Onboarding. Only meaningful when the user reached create-vault from43   *  Onboarding in the first place. */44  onBackToOnboarding?: () => void;45  /** Optional: open the Peach Codex restore flow (camera scan of printed46   *  Codex pages). Surfaced as a small footer link on the new-vault screen. */47  onOpenCodexRestore?: () => void;48}4950type RestoreSurface = "restore" | null;51type RecoverSurface = "words" | "new-password" | null;5253export function LockView(props: LockProps) {54  const [password, setPassword] = createSignal("");55  const [confirmPassword, setConfirmPassword] = createSignal("");56  const [error, setError] = createSignal("");57  const [loading, setLoading] = createSignal(false);58  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);59  const [showPassword, setShowPassword] = createSignal(false);60  const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);61  // After vault creation succeeds, set this to show the recovery words screen.62  // Only meaningful when hasVault === false.63  const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);6465  const MIN_LENGTH = 12;66  const MIN_ZXCVBN_SCORE = 2;6768  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);69  const [biometricAvailable, setBiometricAvailable] = createSignal(false);70  const [biometricBusy, setBiometricBusy] = createSignal(false);7172  const canCreate = createMemo(73    () =>74      password().length >= MIN_LENGTH &&75      confirmPassword().length >= MIN_LENGTH &&76      password() === confirmPassword() &&77      strengthScore() !== null &&78      strengthScore()! >= MIN_ZXCVBN_SCORE79  );8081  const passwordStrength = createMemo(() => {82    const score = strengthScore();83    if (score === null) return null;84    if (score <= 1) return "weak";85    if (score === 2) return "fair";86    if (score === 3) return "good";87    return "strong";88  });8990  const createBlockerMessage = createMemo(() => {91    if (!password()) return null;92    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;93    if (!confirmPassword()) return "confirm your password";94    if (password() !== confirmPassword()) return "passwords don't match";95    if (strengthScore() === null) return "checking strength…";96    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";97    return null;98  });99100  // Update strength asynchronously when password changes101  createMemo(() => {102    const value = password();103    if (!value) { setStrengthScore(null); return; }104    getZxcvbn().then((zxcvbn) => {105      if (password() === value) setStrengthScore(zxcvbn(value).score);106    }).catch(() => {107      // zxcvbn unavailable — fall back to length-only heuristic108      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);109    });110  });111112  async function handleUnlock() {113    if (!password().trim()) return;114    setLoading(true);115    setError("");116    const response = await sendMessage({117      type: "UNLOCK",118      payload: { password: password() },119    });120    const result = response as { ok: boolean; error?: string };121    if (result.ok) {122      await props.onUnlock();123      setLoading(false);124      return;125    }126    setError(getDisplayError(result.error, "incorrect password"));127    setPassword("");128    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;129    passwordField?.classList.add("shake");130    setTimeout(() => passwordField?.classList.remove("shake"), 300);131    setLoading(false);132  }133134  async function checkBiometricAvailable(): Promise<void> {135    if (!props.hasVault || !isNativePlatform()) return;136    const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");137    const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");138    if (!keystore || !biometric) return;139    try {140      const stored = await keystore.hasMasterKey();141      if (!stored.exists) return;142      const avail = await biometric.isAvailable();143      if (!avail.available) return;144      setBiometricAvailable(true);145      window.setTimeout(() => { void handleBiometricUnlock(); }, 400);146    } catch { /* missing plugins or runtime error — silently skip */ }147  }148149  async function handleBiometricUnlock(): Promise<void> {150    if (biometricBusy()) return;151    const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");152    const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");153    if (!biometric || !keystore) return;154    setBiometricBusy(true);155    setError("");156    try {157      await biometric.authenticate({158        title: "Unlock Peach",159        subtitle: "Use biometrics to access your vault",160        negativeButtonText: "Use Password",161      });162      const { masterKeyHex } = await keystore.retrieveMasterKey();163      const res = (await sendMessage({164        type: "UNLOCK_WITH_KEY",165        payload: { masterKeyHex },166      })) as { ok: boolean; error?: string };167      if (res.ok) {168        await props.onUnlock();169        return;170      }171      if (res.error === "biometric_key_invalidated") {172        await sendMessage({173          type: "UPDATE_SETTINGS",174          payload: { biometricsEnabled: false },175        });176        setBiometricAvailable(false);177        setError("biometric key expired — re-enable in settings");178      } else {179        setError("biometric unlock failed");180      }181    } catch (e) {182      const reason = e instanceof Error ? e.message : String(e);183      if (reason.includes("user_cancelled")) return;184      if (reason.includes("biometric_key_invalidated")) {185        await sendMessage({186          type: "UPDATE_SETTINGS",187          payload: { biometricsEnabled: false },188        });189        setBiometricAvailable(false);190        setError("biometric key expired — re-enable in settings");191        return;192      }193      if (reason.includes("biometric_lockout")) {194        setError("biometric locked out — use your password");195      }196    } finally {197      setBiometricBusy(false);198    }199  }200201  onMount(() => { void checkBiometricAvailable(); });202203  async function handleCreate() {204    if (!password().trim()) return;205    if (password().length < MIN_LENGTH) {206      setError(`password must be at least ${MIN_LENGTH} characters`);207      return;208    }209    if (password() !== confirmPassword()) {210      setError("passwords do not match");211      return;212    }213    const score = strengthScore();214    if (score === null || score < MIN_ZXCVBN_SCORE) {215      setError("password is too weak — try a longer or more varied passphrase");216      return;217    }218    setLoading(true);219    setError("");220    const response = await sendMessage({221      type: "CREATE_VAULT",222      payload: { password: password() },223    });224    const result = response as { ok: boolean; error?: string };225    if (result.ok) {226      // Don't unlock yet — show the recovery words screen first. Mark the227      // confirmation as pending in chrome.storage.local so it survives both228      // popup close (lock-on-close skips while set) and full browser restart229      // (next unlock re-routes to the recovery surface). Cleared when the230      // user types the 3 confirmation words successfully.231      try {232        await chrome.storage.local?.set({ peach_recovery_pending: "1" });233      } catch { /* best-effort */ }234      setShowRecoveryWords(true);235      setLoading(false);236      return;237    }238    setError(getDisplayError(result.error, "failed to create vault"));239    setLoading(false);240  }241242  function openRestore(surface: Exclude<RestoreSurface, null>) {243    if (!props.ready || loading()) return;244    setPassword("");245    setConfirmPassword("");246    setError("");247    setRestoreSurface(surface);248  }249250  // ── All surfaces routed reactively via Switch/Match ───────────────────251  return (252    <Switch>253      {/* ── Restore tab view ──────────────────────────────────────── */}254      <Match when={restoreSurface() === "restore"}>255        <RestoreTabView256          hasVault={props.hasVault}257          onBack={() => setRestoreSurface(null)}258          onUnlock={async () => {259            setRestoreSurface(null);260            await props.onUnlock();261          }}262        />263      </Match>264265      {/* ── Recovery words display after fresh vault creation ────────── */}266      <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>267        <RecoveryWordDisplayView268          onDone={props.onUnlock}269        />270      </Match>271272      {/* ── Recovery word entry ─────────────────────────────────────── */}273      <Match when={recoverSurface() === "words"}>274        <RecoveryWordEntryView275          onCancel={() => setRecoverSurface(null)}276          onRecoveryComplete={props.onUnlock}277        />278      </Match>279280      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}281      <Match when={props.hasVault}>282        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">283          <header class="peach-screen-header lock-screen-header">284            <div class="peach-wordmark-stack">285              <PeachWordmark class="peach-wordmark-wrap" />286              <Show when={props.isPro}>287                <span class="peach-pro-mini-badge">pro</span>288              </Show>289            </div>290          </header>291292          <div class="peach-lock-hero peach-lock-hero--unlock">293            <div class="peach-lock-hero-art" aria-hidden="true">294              <PeachMark class="peach-lock-hero-mark" />295            </div>296            <span class="peach-lock-eyebrow">vault locked</span>297            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">298              unlock299              <br />300              your vault.301            </h1>302          </div>303304          <LockRuneCanvas />305306          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">307            <label class="peach-label" for="unlock-password">308              master password309            </label>310            <input311              id="unlock-password"312              class="peach-underline-input peach-unlock-input lock-password-input"313              type="password"314              placeholder="••••••••••••"315              autocomplete="current-password"316              value={password()}317              disabled={!props.ready}318              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}319              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}320              onKeyDown={(event) => {321                if (event.key !== "Enter" || !props.ready) return;322                void handleUnlock();323              }}324            />325326            <Show when={error()}>327              <p class="peach-hint peach-hint-error">{error()}</p>328            </Show>329330            <button331              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"332              onClick={() => void handleUnlock()}333              disabled={!props.ready || loading()}334            >335              {loading() ? "unlocking..." : "unlock"}336            </button>337338            <Show when={biometricAvailable()}>339              <button340                class="peach-biometric-btn"341                type="button"342                onClick={() => void handleBiometricUnlock()}343                disabled={biometricBusy() || loading()}344                aria-label="Unlock with biometrics"345              >346                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">347                  <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>348                  <path d="M15 17v-3"/>349                  <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>350                  <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>351                  <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>352                  <path d="M5 15c0-1.7.3-3.3.8-4.7"/>353                  <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>354                </svg>355                {biometricBusy() ? "verifying…" : "use biometrics"}356              </button>357            </Show>358359            <button360              class="peach-lock-recover-link"361              type="button"362              onClick={() => setRecoverSurface("words")}363            >364              forgot your password?365            </button>366          </section>367        </section>368      </Match>369370      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}371      <Match when={true}>372        <section373          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"374          data-motion-phase="enter"375        >376          <header class="peach-screen-header peach-newvault-header">377            <Show when={props.onBackToOnboarding}>378              <button379                class="peach-newvault-back"380                type="button"381                onClick={() => props.onBackToOnboarding?.()}382                aria-label="Back to onboarding"383              >384                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">385                  <path d="M7 2L3 5.5L7 9" />386                </svg>387                back388              </button>389            </Show>390            <div class="peach-wordmark-stack">391              <PeachWordmark class="peach-wordmark-wrap" />392              <Show when={props.isPro}>393                <span class="peach-pro-mini-badge">pro</span>394              </Show>395            </div>396          </header>397398          <div class="peach-newvault-mark" aria-hidden="true">399            <PeachMark class="peach-newvault-mark-art" />400          </div>401402          <div class="peach-newvault-card">403            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>404            <hr class="peach-newvault-divider" />405406            <div class="peach-newvault-field">407              <div class="peach-newvault-label-row">408                <label class="peach-label" for="master-password">409                  master password410                </label>411                <button412                  class="peach-lock-reveal-btn"413                  type="button"414                  onClick={() => setShowPassword((value) => !value)}415                  aria-label={showPassword() ? "Hide password" : "Show password"}416                >417                  <Show418                    when={showPassword()}419                    fallback={420                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">421                        <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />422                        <circle cx="7" cy="5" r="1.75" />423                      </svg>424                    }425                  >426                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">427                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />428                      <path d="M3.5 5.5L2.5 7.5" />429                      <path d="M7 6.5V8.5" />430                      <path d="M10.5 5.5L11.5 7.5" />431                    </svg>432                  </Show>433                  {showPassword() ? "hide" : "show"}434                </button>435              </div>436              <input437                id="master-password"438                class="peach-underline-input peach-newvault-input lock-password-input"439                type={showPassword() ? "text" : "password"}440                placeholder="••••••••••••"441                autocomplete="new-password"442                value={password()}443                disabled={!props.ready}444                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}445                onKeyDown={(event) => {446                  if (event.key !== "Enter" || !props.ready) return;447                  void handleCreate();448                }}449              />450              <Show451                when={password().length > 0 && passwordStrength() !== null}452                fallback={453                  <p class="peach-newvault-hint">at least 12 characters</p>454                }455              >456                <div class="peach-password-strength">457                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>458                    <div class="peach-strength-fill"></div>459                  </div>460                  <span class="peach-strength-label">{passwordStrength()}</span>461                </div>462              </Show>463            </div>464465            <div class="peach-newvault-field">466              <label class="peach-label" for="confirm-password">467                confirm password468              </label>469              <input470                id="confirm-password"471                class="peach-underline-input peach-newvault-input"472                type={showPassword() ? "text" : "password"}473                placeholder="••••••••••••"474                autocomplete="new-password"475                value={confirmPassword()}476                disabled={!props.ready}477                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}478                onKeyDown={(event) => {479                  if (event.key === "Enter" && props.ready) void handleCreate();480                }}481              />482            </div>483484            <Show when={error()}>485              <p class="peach-hint peach-hint-error">{error()}</p>486            </Show>487488            <button489              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"490              onClick={() => void handleCreate()}491              disabled={!props.ready || loading() || !canCreate()}492              aria-live="polite"493            >494              <Show495                when={!canCreate() && createBlockerMessage()}496                fallback={loading() ? "creating..." : "create vault"}497              >498                {createBlockerMessage()}499              </Show>500            </button>501502            <p class="peach-newvault-warning">503              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>504              <span>Save your 12 recovery words right after you create the vault — they're the only way in if you forget your password.</span>505            </p>506          </div>507508          <div class="peach-newvault-footer">509            <p class="peach-hint">Already have a backup?</p>510            <button511              class="peach-lock-restore-link"512              onClick={() => void openRestore("restore")}513              disabled={!props.ready}514            >515              › restore from backup516            </button>517            <Show when={props.onOpenCodexRestore}>518              <button519                class="peach-lock-restore-link"520                onClick={() => props.onOpenCodexRestore?.()}521                disabled={!props.ready}522              >523                › restore from peach codex524              </button>525            </Show>526          </div>527        </section>528      </Match>529    </Switch>530  );531}532533// ── Restore tab view ───────────────────────────────────────────────────────534535type RestoreTab = "symbols" | "csv" | "phone";536537function tabLabel(tab: RestoreTab): string {538  return (539    { symbols: "PeachScript", csv: "CSV", phone: "From Phone" }[tab]540  );541}542543function RestoreTabView(props: {544  hasVault: boolean;545  onBack: () => void;546  onUnlock: () => void | Promise<void>;547}) {548  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");549  const [showIntro, setShowIntro] = createSignal(false);550  const [pendingUnlock] = createSignal(false);551552  async function handleRestoreSuccess() {553    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);554    if (!seen) {555      setShowIntro(true);556    } else {557      await props.onUnlock();558    }559  }560561  async function dismissIntro() {562    await storageSet("peach_shown_restore_intro", "true").catch(() => {});563    setShowIntro(false);564    await props.onUnlock();565  }566567  return (568    <section569      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"570      data-motion-phase="enter"571    >572      <RuneBackground opacity={0.05} />573574      <header class="peach-screen-header lock-screen-header">575        <PeachWordmark class="peach-wordmark-wrap" />576        <button class="peach-lock-restore-link" onClick={props.onBack}>577          ‹ back578        </button>579      </header>580581      <div class="peach-newvault-card peach-restore-tabcard">582        <div class="peach-restore-tabs" role="tablist">583          <For each={["symbols", "csv", "phone"] as RestoreTab[]}>584            {(tab) => (585              <button586                class="peach-restore-tab"587                classList={{ "is-active": activeTab() === tab }}588                onClick={() => setActiveTab(tab)}589                role="tab"590                aria-selected={activeTab() === tab}591              >592                {tabLabel(tab)}593              </button>594            )}595          </For>596        </div>597        <div class="peach-restore-content">598          <Show when={activeTab() === "symbols"}>599            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />600          </Show>601          <Show when={activeTab() === "csv"}>602            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />603          </Show>604          <Show when={activeTab() === "phone"}>605            <RestoreFromPhone onSuccess={handleRestoreSuccess} />606          </Show>607        </div>608      </div>609610      {/* One-time intro popup after first restore */}611      <Show when={showIntro()}>612        <div class="peach-restore-intro-overlay">613          <div class="peach-restore-intro-card">614            <p class="peach-restore-intro-headline">welcome to peach.</p>615            <div class="peach-restore-intro-body">616              <p>Your vault lives only on this device. No server has a copy.</p>617              <p>The inline bubble fills passwords automatically when you visit sites.</p>618              <p>Export your vault from Settings before switching devices.</p>619            </div>620            <button621              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"622              onClick={() => void dismissIntro()}623              disabled={pendingUnlock()}624            >625              got it626            </button>627          </div>628        </div>629      </Show>630    </section>631  );632}633634// ── PeachScript tab ────────────────────────────────────────────────────────635636function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {637  const [scriptInput, setScriptInput] = createSignal("");638  const [backupPass, setBackupPass] = createSignal("");639  const [working, setWorking] = createSignal(false);640  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);641  const [error, setError] = createSignal<string | null>(null);642  let canvasRef: HTMLCanvasElement | undefined;643  let animFrameId = 0;644  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];645646  const scriptValid = createMemo(() => isPeachScript(scriptInput()));647648  function stopAnim() {649    cancelAnimationFrame(animFrameId);650    animFrameId = 0;651  }652653  function startChaos(canvas: HTMLCanvasElement) {654    const ctx = canvas.getContext("2d")!;655    const W = canvas.width;656    const H = canvas.height;657    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>658      String.fromCodePoint(0xe000 + i)659    );660    if (runes.length === 0) {661      runes = Array.from({ length: 60 }, () => ({662        x: Math.random() * W,663        y: Math.random() * H,664        vx: (Math.random() - 0.5) * 3,665        vy: (Math.random() - 0.5) * 3,666        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,667        size: 14 + Math.random() * 20,668        angle: Math.random() * Math.PI * 2,669        va: (Math.random() - 0.5) * 0.1,670      }));671    }672673    function draw() {674      ctx.clearRect(0, 0, W, H);675      for (const r of runes) {676        r.x = (r.x + r.vx + W) % W;677        r.y = (r.y + r.vy + H) % H;678        r.angle += r.va;679        ctx.save();680        ctx.translate(r.x, r.y);681        ctx.rotate(r.angle);682        ctx.font = `${r.size}px PeachScript, monospace`;683        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);684        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;685        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";686        ctx.shadowBlur = 8;687        ctx.fillText(r.char, 0, 0);688        ctx.restore();689      }690      animFrameId = requestAnimationFrame(draw);691    }692    draw();693  }694695  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {696    stopAnim();697    const ctx = canvas.getContext("2d")!;698    const W = canvas.width;699    const H = canvas.height;700    const cols = 8;701    const rows = 6;702    const cellW = W / cols;703    const cellH = H / rows;704    const targets = runes.slice(0, cols * rows).map((_, i) => ({705      x: (i % cols) * cellW + cellW / 2,706      y: Math.floor(i / cols) * cellH + cellH / 2,707    }));708    const start = performance.now();709    const DURATION = 400;710711    function drawSnap(now: number) {712      const t = Math.min((now - start) / DURATION, 1);713      const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;714      ctx.clearRect(0, 0, W, H);715      runes.slice(0, targets.length).forEach((r, i) => {716        const tx = targets[i]!.x;717        const ty = targets[i]!.y;718        const cx = r.x + (tx - r.x) * ease;719        const cy = r.y + (ty - r.y) * ease;720        ctx.font = `16px PeachScript, monospace`;721        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;722        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";723        ctx.shadowBlur = 6;724        ctx.fillText(r.char, cx - 8, cy + 8);725      });726      if (t < 1) {727        animFrameId = requestAnimationFrame(drawSnap);728      } else {729        onDone();730      }731    }732    animFrameId = requestAnimationFrame(drawSnap);733  }734735  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {736    stopAnim();737    const ctx = canvas.getContext("2d")!;738    const W = canvas.width;739    const H = canvas.height;740    const start = performance.now();741    const DURATION = 900;742743    function drawBloom(now: number) {744      const t = Math.min((now - start) / DURATION, 1);745      ctx.clearRect(0, 0, W, H);746      // Runes dissolve747      const runeAlpha = 1 - Math.min(t / 0.4, 1);748      if (runeAlpha > 0) {749        for (const r of runes.slice(0, 48)) {750          ctx.font = `16px PeachScript, monospace`;751          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;752          ctx.fillText(r.char, r.x - 8, r.y + 8);753        }754      }755      // Peach mark bloom756      const markT = Math.max(0, (t - 0.2) / 0.5);757      const markScale = Math.min(markT * 1.2, 1);758      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;759      if (markAlpha > 0) {760        ctx.save();761        ctx.globalAlpha = markAlpha;762        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;763        ctx.shadowBlur = 30;764        ctx.translate(W / 2, H / 2);765        ctx.scale(markScale, markScale);766        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);767        ctx.restore();768      }769      if (t < 1) {770        animFrameId = requestAnimationFrame(drawBloom);771      } else {772        onDone();773      }774    }775    animFrameId = requestAnimationFrame(drawBloom);776  }777778  onCleanup(() => stopAnim());779780  async function handleRestore() {781    if (!scriptValid() || !backupPass().trim() || working()) return;782    setError(null);783    setWorking(true);784    setAnimating("chaos");785786    const canvas = canvasRef;787    if (canvas) startChaos(canvas);788789    try {790      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));791      const payload = await parsePayload(bytes, backupPass());792793      if (!props.hasVault) {794        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });795        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");796      }797798      const rr = await sendMessage({799        type: "RESTORE_ENTRIES",800        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },801      });802      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");803804      if (payload.license?.key) {805        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});806      }807808      setAnimating("snap");809      stopAnim();810      if (canvas) {811        snapToGrid(canvas, () => {812          setAnimating("bloom");813          bloom(canvas, async () => {814            setAnimating(null);815            await props.onSuccess();816          });817        });818      } else {819        await props.onSuccess();820      }821    } catch (err) {822      stopAnim();823      setAnimating(null);824      const code = err instanceof Error ? err.message : String(err);825      setError(826        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"827          ? "That passphrase didn't work."828          : code === "CREATE_VAULT_FAILED"829            ? "Couldn't create a new local vault."830            : "Could not read this backup.",831      );832    } finally {833      setWorking(false);834    }835  }836837  return (838    <div class="peach-rt-symbols">839      <Show when={animating() !== null}>840        <canvas841          ref={(el) => { canvasRef = el; }}842          class="peach-rt-anim-canvas"843          width={280}844          height={200}845        />846      </Show>847      <Show when={animating() === null}>848        <textarea849          class="peach-restore-symbols-textarea"850          placeholder="paste peachscript…"851          rows={3}852          value={scriptInput()}853          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}854        />855        <div class="peach-rt-divider">— or —</div>856        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">857          open .peach file858          <input859            type="file"860            accept=".peach,text/plain"861            style="display:none"862            onChange={async (e) => {863              const file = (e.currentTarget as HTMLInputElement).files?.[0];864              if (!file) return;865              try {866                setScriptInput(await file.text());867              } catch {868                setError("Couldn't read file.");869              } finally {870                (e.currentTarget as HTMLInputElement).value = "";871              }872            }}873          />874        </label>875        <input876          class="peach-restore-symbols-pass"877          type="password"878          placeholder="backup password"879          value={backupPass()}880          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}881          onKeyDown={(e) => {882            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {883              e.preventDefault();884              void handleRestore();885            }886          }}887        />888        <button889          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"890          onClick={() => void handleRestore()}891          disabled={!scriptValid() || !backupPass().trim() || working()}892        >893          restore vault →894        </button>895        <Show when={error()}>896          <p class="peach-rt-error">{error()}</p>897        </Show>898      </Show>899    </div>900  );901}902903// ── CSV tab ────────────────────────────────────────────────────────────────904905function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {906  const [importing, setImporting] = createSignal(false);907  const [error, setError] = createSignal<string | null>(null);908909  async function handleFile(e: Event) {910    const input = e.currentTarget as HTMLInputElement;911    const file = input.files?.[0];912    input.value = "";913    if (!file) return;914    setImporting(true);915    setError(null);916    try {917      const text = await file.text();918      const parsed = await parseImportSource(text);919      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");920921      if (props.hasVault === false) {922        // No vault yet — this path shouldn't normally occur for CSV (no backup password923        // means no way to create a locked vault). Create an unlocked placeholder vault.924        // The user can set a master password from settings.925        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });926        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");927      }928929      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });930      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");931      if (parsed.licenseKey) {932        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});933      }934      await props.onSuccess();935    } catch (err) {936      const code = err instanceof Error ? err.message : String(err);937      setError(938        code === "NO_ENTRIES" ? "No entries found in this file." :939        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :940        "Import failed. Check the file format and try again."941      );942    } finally {943      setImporting(false);944    }945  }946947  return (948    <div class="peach-rt-csv">949      <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>950        {importing() ? "importing…" : "open csv file"}951        <input952          type="file"953          accept=".csv,.json,.peach,application/x-peach"954          style="display:none"955          onChange={handleFile}956          disabled={importing()}957        />958      </label>959      <p class="peach-rt-csv-sources">960        Imports from Bitwarden, 1Password, Proton Pass,<br />961        Chrome, Safari, and Edge.962      </p>963      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>964      <Show when={error()}>965        <p class="peach-rt-error">{error()}</p>966      </Show>967    </div>968  );969}970971// ── From Phone tab ─────────────────────────────────────────────────────────972973type PhoneState = "waiting" | "connected" | "trust" | "error";974975function RestoreFromPhone(props: { onSuccess: () => void | Promise<void> }) {976  const [state, setState] = createSignal<PhoneState>("waiting");977  const [code, setCode] = createSignal<string | null>(null);978  const [qrUrl, setQrUrl] = createSignal<string | null>(null);979  const [expired, setExpired] = createSignal(false);980  const [deviceName, setDeviceName] = createSignal<string | null>(null);981  const [accepting, setAccepting] = createSignal(false);982  const [serverError, setServerError] = createSignal<string | null>(null);983  let expireTimer: ReturnType<typeof setTimeout> | null = null;984985  function clearExpire() {986    if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }987  }988989  async function startPairing() {990    setExpired(false);991    setCode(null);992    setQrUrl(null);993    setServerError(null);994    clearExpire();995    const res = await sendMessage({ type: "START_PAIRING_SERVER" });996    const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };997    if (!r.ok || !r.data) {998      setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");999      return;1000    }1001    const token = r.data.token;1002    const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;1003    setCode(formatted);1004    const qrData = `peach://handoff?token=${token}`;1005    try {1006      const QRCode = (await import("qrcode")).default;1007      const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });1008      setQrUrl(url);1009    } catch {1010      // QR optional — code is the primary affordance1011    }1012    const remaining = Math.max(0, r.data.expiresAt - Date.now());1013    expireTimer = setTimeout(() => {1014      setCode(null);1015      setQrUrl(null);1016      setExpired(true);1017    }, remaining);1018  }10191020  function handleRuntimeMsg(msg: unknown) {1021    const m = msg as { type?: string; payload?: { deviceName?: string } };1022    if (m?.type === "PAIRING_COMPLETED") {1023      clearExpire();1024      setDeviceName(m.payload?.deviceName ?? "your phone");1025      setState("trust");1026    }1027  }10281029  onMount(() => {1030    void startPairing();1031    // @ts-ignore — chrome runtime1032    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1033      chrome.runtime.onMessage.addListener(handleRuntimeMsg);1034    }1035  });10361037  onCleanup(() => {1038    clearExpire();1039    // @ts-ignore1040    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1041      chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1042    }1043    sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1044  });10451046  async function handleAccept(trust: boolean) {1047    setAccepting(true);1048    if (trust) {1049      await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1050    }1051    await props.onSuccess();1052  }10531054  return (1055    <div class="peach-rt-phone">1056      <Show when={state() === "waiting"}>1057        <Show when={!expired()}>1058          <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1059            <p class="peach-rt-phone-code">{code()}</p>1060            <Show when={qrUrl() !== null}>1061              <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1062            </Show>1063            <p class="peach-rt-phone-hint">1064              Open Peach on your phone and tap<br />1065              Settings → Send to desktop.<br />1066              Same WiFi required.1067            </p>1068          </Show>1069          <Show when={serverError()}>1070            <p class="peach-rt-error">{serverError()}</p>1071          </Show>1072        </Show>1073        <Show when={expired()}>1074          <p class="peach-rt-phone-hint">Code expired.</p>1075          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1076            Generate new code1077          </button>1078        </Show>1079      </Show>10801081      <Show when={state() === "connected"}>1082        <p class="peach-rt-phone-hint">1083          Connected to {deviceName() ?? "your phone"}.<br />1084          Receiving vault…1085        </p>1086      </Show>10871088      <Show when={state() === "trust"}>1089        <p class="peach-rt-phone-hint" style="margin-bottom:10px">1090          Vault received from {deviceName() ?? "your phone"}.<br />1091          Trust this network for sync?1092        </p>1093        <button1094          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1095          onClick={() => void handleAccept(true)}1096          disabled={accepting()}1097        >1098          Yes, trust this network1099        </button>1100        <button1101          class="peach-btn peach-btn--ghost peach-btn--sm"1102          style="margin-top:6px"1103          onClick={() => void handleAccept(false)}1104          disabled={accepting()}1105        >1106          Not now1107        </button>1108      </Show>11091110      <Show when={state() === "error"}>1111        <p class="peach-rt-phone-hint">1112          Could not connect. Check that both devices<br />1113          are on the same WiFi network.1114        </p>1115        <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1116          Try again1117        </button>1118      </Show>1119    </div>1120  );1121}11221123// ── Helpers ────────────────────────────────────────────────────────────────11241125function getDisplayError(error: string | undefined, fallback: string): string {1126  if (!error) return fallback;1127  if (1128    error === "SERVICE_WORKER_UNAVAILABLE" ||1129    error.includes("Receiving end does not exist") ||1130    error.includes("message port closed")1131  ) {1132    return "background service unavailable";1133  }1134  return error;1135}