Lock.tsx

42.4 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  /** Optional: open the Peach Codex restore flow (camera scan of printed47   *  Codex pages). Surfaced as a small footer link on the new-vault screen. */48  onOpenCodexRestore?: () => void;49}5051type RestoreSurface = "restore" | null;52type RecoverSurface = "words" | "new-password" | null;5354export function LockView(props: LockProps) {55  const [password, setPassword] = createSignal("");56  const [confirmPassword, setConfirmPassword] = createSignal("");57  const [error, setError] = createSignal("");58  const [loading, setLoading] = createSignal(false);59  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);60  const [showPassword, setShowPassword] = createSignal(false);61  const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);62  // After vault creation succeeds, set this to show the recovery words screen.63  // Only meaningful when hasVault === false.64  const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);65  const [recoveryWords, setRecoveryWords] = createSignal<string[]>([]);6667  const MIN_LENGTH = 12;68  const MIN_ZXCVBN_SCORE = 2;6970  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);71  const [biometricAvailable, setBiometricAvailable] = createSignal(false);72  const [biometricBusy, setBiometricBusy] = createSignal(false);7374  const canCreate = createMemo(75    () =>76      password().length >= MIN_LENGTH &&77      confirmPassword().length >= MIN_LENGTH &&78      password() === confirmPassword() &&79      strengthScore() !== null &&80      strengthScore()! >= MIN_ZXCVBN_SCORE81  );8283  const passwordStrength = createMemo(() => {84    const score = strengthScore();85    if (score === null) return null;86    if (score <= 1) return "weak";87    if (score === 2) return "fair";88    if (score === 3) return "good";89    return "strong";90  });9192  const createBlockerMessage = createMemo(() => {93    if (!password()) return null;94    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;95    if (!confirmPassword()) return "confirm your password";96    if (password() !== confirmPassword()) return "passwords don't match";97    if (strengthScore() === null) return "checking strength…";98    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";99    return null;100  });101102  // Update strength asynchronously when password changes103  createMemo(() => {104    const value = password();105    if (!value) { setStrengthScore(null); return; }106    getZxcvbn().then((zxcvbn) => {107      if (password() === value) setStrengthScore(zxcvbn(value).score);108    }).catch(() => {109      // zxcvbn unavailable — fall back to length-only heuristic110      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);111    });112  });113114  async function handleUnlock() {115    if (!password().trim()) return;116    setLoading(true);117    setError("");118    const response = await sendMessage({119      type: "UNLOCK",120      payload: { password: password() },121    });122    const result = response as { ok: boolean; error?: string };123    if (result.ok) {124      await props.onUnlock();125      setLoading(false);126      return;127    }128    setError(getDisplayError(result.error, "incorrect password"));129    setPassword("");130    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;131    passwordField?.classList.add("shake");132    setTimeout(() => passwordField?.classList.remove("shake"), 300);133    setLoading(false);134  }135136  async function checkBiometricAvailable(): Promise<void> {137    if (!props.hasVault || !isNativePlatform()) return;138    const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");139    const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");140    if (!keystore || !biometric) return;141    try {142      const stored = await keystore.hasMasterKey();143      if (!stored.exists) return;144      const avail = await biometric.isAvailable();145      if (!avail.available) return;146      setBiometricAvailable(true);147      window.setTimeout(() => { void handleBiometricUnlock(); }, 400);148    } catch { /* missing plugins or runtime error — silently skip */ }149  }150151  async function handleBiometricUnlock(): Promise<void> {152    if (biometricBusy()) return;153    const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");154    const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");155    if (!biometric || !keystore) return;156    setBiometricBusy(true);157    setError("");158    try {159      await biometric.authenticate({160        title: "Unlock Peach",161        subtitle: "Use biometrics to access your vault",162        negativeButtonText: "Use Password",163      });164      const { masterKeyHex } = await keystore.retrieveMasterKey();165      const res = (await sendMessage({166        type: "UNLOCK_WITH_KEY",167        payload: { masterKeyHex },168      })) as { ok: boolean; error?: string };169      if (res.ok) {170        await props.onUnlock();171        return;172      }173      if (res.error === "biometric_key_invalidated") {174        await sendMessage({175          type: "UPDATE_SETTINGS",176          payload: { biometricsEnabled: false },177        });178        setBiometricAvailable(false);179        setError("biometric key expired — re-enable in settings");180      } else {181        setError("biometric unlock failed");182      }183    } catch (e) {184      const reason = e instanceof Error ? e.message : String(e);185      if (reason.includes("user_cancelled")) return;186      if (reason.includes("biometric_key_invalidated")) {187        await sendMessage({188          type: "UPDATE_SETTINGS",189          payload: { biometricsEnabled: false },190        });191        setBiometricAvailable(false);192        setError("biometric key expired — re-enable in settings");193        return;194      }195      if (reason.includes("biometric_lockout")) {196        setError("biometric locked out — use your password");197      }198    } finally {199      setBiometricBusy(false);200    }201  }202203  onMount(() => { void checkBiometricAvailable(); });204205  async function handleCreate() {206    if (!password().trim()) return;207    if (password().length < MIN_LENGTH) {208      setError(`password must be at least ${MIN_LENGTH} characters`);209      return;210    }211    if (password() !== confirmPassword()) {212      setError("passwords do not match");213      return;214    }215    const score = strengthScore();216    if (score === null || score < MIN_ZXCVBN_SCORE) {217      setError("password is too weak — try a longer or more varied passphrase");218      return;219    }220    setLoading(true);221    setError("");222    const response = await sendMessage({223      type: "CREATE_VAULT",224      payload: { password: password() },225    });226    const result = response as { ok: boolean; error?: string };227    if (result.ok) {228      // Don't unlock yet — show the recovery words screen first. Mark the229      // confirmation as pending in chrome.storage.local so it survives both230      // popup close (lock-on-close skips while set) and full browser restart231      // (next unlock re-routes to the recovery surface). Cleared when the232      // user types the 3 confirmation words successfully.233      try {234        await chrome.storage.local?.set({ peach_recovery_pending: "1" });235      } catch { /* best-effort */ }236      setShowRecoveryWords(true);237      setLoading(false);238      return;239    }240    setError(getDisplayError(result.error, "failed to create vault"));241    setLoading(false);242  }243244  function openRestore(surface: Exclude<RestoreSurface, null>) {245    if (!props.ready || loading()) return;246    setPassword("");247    setConfirmPassword("");248    setError("");249    setRestoreSurface(surface);250  }251252  // ── All surfaces routed reactively via Switch/Match ───────────────────253  return (254    <Switch>255      {/* ── Restore tab view ──────────────────────────────────────── */}256      <Match when={restoreSurface() === "restore"}>257        <RestoreTabView258          hasVault={props.hasVault}259          onBack={() => setRestoreSurface(null)}260          onUnlock={async () => {261            setRestoreSurface(null);262            await props.onUnlock();263          }}264        />265      </Match>266267      {/* ── Recovery words display after fresh vault creation ────────── */}268      <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>269        <RecoveryWordDisplayView270          onDone={props.onUnlock}271        />272      </Match>273274      {/* ── Recovery word entry ─────────────────────────────────────── */}275      <Match when={recoverSurface() === "words"}>276        <RecoveryWordEntryView277          onCancel={() => setRecoverSurface(null)}278          onRecoveryComplete={props.onUnlock}279        />280      </Match>281282      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}283      <Match when={props.hasVault}>284        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">285          <header class="peach-screen-header lock-screen-header">286            <div class="peach-wordmark-stack">287              <PeachWordmark class="peach-wordmark-wrap" />288              <Show when={props.isPro}>289                <span class="peach-pro-mini-badge">pro</span>290              </Show>291            </div>292          </header>293294          <div class="peach-lock-hero peach-lock-hero--unlock">295            <div class="peach-lock-hero-art" aria-hidden="true">296              <PeachMark class="peach-lock-hero-mark" />297            </div>298            <span class="peach-lock-eyebrow">vault locked</span>299            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">300              unlock301              <br />302              your vault.303            </h1>304          </div>305306          <LockRuneCanvas />307308          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">309            <label class="peach-label" for="unlock-password">310              master password311            </label>312            <input313              id="unlock-password"314              class="peach-underline-input peach-unlock-input lock-password-input"315              type="password"316              placeholder="••••••••••••"317              autocomplete="current-password"318              value={password()}319              disabled={!props.ready}320              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}321              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}322              onKeyDown={(event) => {323                if (event.key !== "Enter" || !props.ready) return;324                void handleUnlock();325              }}326            />327328            <Show when={error()}>329              <p class="peach-hint peach-hint-error">{error()}</p>330            </Show>331332            <button333              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"334              onClick={() => void handleUnlock()}335              disabled={!props.ready || loading()}336            >337              {loading() ? "unlocking..." : "unlock"}338            </button>339340            <Show when={biometricAvailable()}>341              <button342                class="peach-biometric-btn"343                type="button"344                onClick={() => void handleBiometricUnlock()}345                disabled={biometricBusy() || loading()}346                aria-label="Unlock with biometrics"347              >348                <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">349                  <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>350                  <path d="M15 17v-3"/>351                  <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>352                  <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>353                  <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>354                  <path d="M5 15c0-1.7.3-3.3.8-4.7"/>355                  <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>356                </svg>357                {biometricBusy() ? "verifying…" : "use biometrics"}358              </button>359            </Show>360361            <button362              class="peach-lock-recover-link"363              type="button"364              onClick={() => setRecoverSurface("words")}365            >366              forgot your password?367            </button>368          </section>369        </section>370      </Match>371372      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}373      <Match when={true}>374        <section375          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"376          data-motion-phase="enter"377        >378          <header class="peach-screen-header peach-newvault-header">379            <Show when={props.onBackToOnboarding}>380              <button381                class="peach-newvault-back"382                type="button"383                onClick={() => props.onBackToOnboarding?.()}384                aria-label="Back to onboarding"385              >386                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">387                  <path d="M7 2L3 5.5L7 9" />388                </svg>389                back390              </button>391            </Show>392            <div class="peach-wordmark-stack">393              <PeachWordmark class="peach-wordmark-wrap" />394              <Show when={props.isPro}>395                <span class="peach-pro-mini-badge">pro</span>396              </Show>397            </div>398          </header>399400          <div class="peach-newvault-mark" aria-hidden="true">401            <PeachMark class="peach-newvault-mark-art" />402          </div>403404          <div class="peach-newvault-card">405            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>406            <hr class="peach-newvault-divider" />407408            <div class="peach-newvault-field">409              <div class="peach-newvault-label-row">410                <label class="peach-label" for="master-password">411                  master password412                </label>413                <button414                  class="peach-lock-reveal-btn"415                  type="button"416                  onClick={() => setShowPassword((value) => !value)}417                  aria-label={showPassword() ? "Hide password" : "Show password"}418                >419                  <Show420                    when={showPassword()}421                    fallback={422                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">423                        <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" />424                        <circle cx="7" cy="5" r="1.75" />425                      </svg>426                    }427                  >428                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">429                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />430                      <path d="M3.5 5.5L2.5 7.5" />431                      <path d="M7 6.5V8.5" />432                      <path d="M10.5 5.5L11.5 7.5" />433                    </svg>434                  </Show>435                  {showPassword() ? "hide" : "show"}436                </button>437              </div>438              <input439                id="master-password"440                class="peach-underline-input peach-newvault-input lock-password-input"441                type={showPassword() ? "text" : "password"}442                placeholder="••••••••••••"443                autocomplete="new-password"444                value={password()}445                disabled={!props.ready}446                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}447                onKeyDown={(event) => {448                  if (event.key !== "Enter" || !props.ready) return;449                  void handleCreate();450                }}451              />452              <Show453                when={password().length > 0 && passwordStrength() !== null}454                fallback={455                  <p class="peach-newvault-hint">at least 12 characters</p>456                }457              >458                <div class="peach-password-strength">459                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>460                    <div class="peach-strength-fill"></div>461                  </div>462                  <span class="peach-strength-label">{passwordStrength()}</span>463                </div>464              </Show>465            </div>466467            <div class="peach-newvault-field">468              <label class="peach-label" for="confirm-password">469                confirm password470              </label>471              <input472                id="confirm-password"473                class="peach-underline-input peach-newvault-input"474                type={showPassword() ? "text" : "password"}475                placeholder="••••••••••••"476                autocomplete="new-password"477                value={confirmPassword()}478                disabled={!props.ready}479                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}480                onKeyDown={(event) => {481                  if (event.key === "Enter" && props.ready) void handleCreate();482                }}483              />484            </div>485486            <Show when={error()}>487              <p class="peach-hint peach-hint-error">{error()}</p>488            </Show>489490            <button491              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"492              onClick={() => void handleCreate()}493              disabled={!props.ready || loading() || !canCreate()}494              aria-live="polite"495            >496              <Show497                when={!canCreate() && createBlockerMessage()}498                fallback={loading() ? "creating..." : "create vault"}499              >500                {createBlockerMessage()}501              </Show>502            </button>503504            <p class="peach-newvault-warning">505              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>506              <span>Save your 12 recovery words right after you create the vault — they're the only way in if you forget your password.</span>507            </p>508          </div>509510          <div class="peach-newvault-footer">511            <p class="peach-hint">Already have a backup?</p>512            <button513              class="peach-lock-restore-link"514              onClick={() => void openRestore("restore")}515              disabled={!props.ready}516            >517              › restore from backup518            </button>519            <Show when={props.onOpenCodexRestore}>520              <button521                class="peach-lock-restore-link"522                onClick={() => props.onOpenCodexRestore?.()}523                disabled={!props.ready}524              >525                › restore from peach codex526              </button>527            </Show>528          </div>529        </section>530      </Match>531    </Switch>532  );533}534535// ── Restore tab view ───────────────────────────────────────────────────────536537type RestoreTab = "symbols" | "csv" | "phone";538539function tabLabel(tab: RestoreTab): string {540  return (541    { symbols: "PeachScript", csv: "CSV", phone: "From Phone" }[tab]542  );543}544545function RestoreTabView(props: {546  hasVault: boolean;547  onBack: () => void;548  onUnlock: () => void | Promise<void>;549}) {550  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");551  const [showIntro, setShowIntro] = createSignal(false);552  const [pendingUnlock, setPendingUnlock] = createSignal(false);553554  async function handleRestoreSuccess() {555    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);556    if (!seen) {557      setShowIntro(true);558    } else {559      await props.onUnlock();560    }561  }562563  async function dismissIntro() {564    await storageSet("peach_shown_restore_intro", "true").catch(() => {});565    setShowIntro(false);566    await props.onUnlock();567  }568569  return (570    <section571      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"572      data-motion-phase="enter"573    >574      <RuneBackground opacity={0.05} />575576      <header class="peach-screen-header lock-screen-header">577        <PeachWordmark class="peach-wordmark-wrap" />578        <button class="peach-lock-restore-link" onClick={props.onBack}>579          ‹ back580        </button>581      </header>582583      <div class="peach-newvault-card peach-restore-tabcard">584        <div class="peach-restore-tabs" role="tablist">585          <For each={["symbols", "csv", "phone"] as RestoreTab[]}>586            {(tab) => (587              <button588                class="peach-restore-tab"589                classList={{ "is-active": activeTab() === tab }}590                onClick={() => setActiveTab(tab)}591                role="tab"592                aria-selected={activeTab() === tab}593              >594                {tabLabel(tab)}595              </button>596            )}597          </For>598        </div>599        <div class="peach-restore-content">600          <Show when={activeTab() === "symbols"}>601            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />602          </Show>603          <Show when={activeTab() === "csv"}>604            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />605          </Show>606          <Show when={activeTab() === "phone"}>607            <RestoreFromPhone onSuccess={handleRestoreSuccess} />608          </Show>609        </div>610      </div>611612      {/* One-time intro popup after first restore */}613      <Show when={showIntro()}>614        <div class="peach-restore-intro-overlay">615          <div class="peach-restore-intro-card">616            <p class="peach-restore-intro-headline">welcome to peach.</p>617            <div class="peach-restore-intro-body">618              <p>Your vault lives only on this device. No server has a copy.</p>619              <p>The inline bubble fills passwords automatically when you visit sites.</p>620              <p>Export your vault from Settings before switching devices.</p>621            </div>622            <button623              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"624              onClick={() => void dismissIntro()}625              disabled={pendingUnlock()}626            >627              got it628            </button>629          </div>630        </div>631      </Show>632    </section>633  );634}635636// ── PeachScript tab ────────────────────────────────────────────────────────637638function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {639  const [scriptInput, setScriptInput] = createSignal("");640  const [backupPass, setBackupPass] = createSignal("");641  const [working, setWorking] = createSignal(false);642  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);643  const [error, setError] = createSignal<string | null>(null);644  let canvasRef: HTMLCanvasElement | undefined;645  let animFrameId = 0;646  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];647648  const scriptValid = createMemo(() => isPeachScript(scriptInput()));649650  function stopAnim() {651    cancelAnimationFrame(animFrameId);652    animFrameId = 0;653  }654655  function startChaos(canvas: HTMLCanvasElement) {656    const ctx = canvas.getContext("2d")!;657    const W = canvas.width;658    const H = canvas.height;659    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>660      String.fromCodePoint(0xe000 + i)661    );662    if (runes.length === 0) {663      runes = Array.from({ length: 60 }, () => ({664        x: Math.random() * W,665        y: Math.random() * H,666        vx: (Math.random() - 0.5) * 3,667        vy: (Math.random() - 0.5) * 3,668        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,669        size: 14 + Math.random() * 20,670        angle: Math.random() * Math.PI * 2,671        va: (Math.random() - 0.5) * 0.1,672      }));673    }674675    function draw() {676      ctx.clearRect(0, 0, W, H);677      for (const r of runes) {678        r.x = (r.x + r.vx + W) % W;679        r.y = (r.y + r.vy + H) % H;680        r.angle += r.va;681        ctx.save();682        ctx.translate(r.x, r.y);683        ctx.rotate(r.angle);684        ctx.font = `${r.size}px PeachScript, monospace`;685        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);686        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;687        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";688        ctx.shadowBlur = 8;689        ctx.fillText(r.char, 0, 0);690        ctx.restore();691      }692      animFrameId = requestAnimationFrame(draw);693    }694    draw();695  }696697  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {698    stopAnim();699    const ctx = canvas.getContext("2d")!;700    const W = canvas.width;701    const H = canvas.height;702    const cols = 8;703    const rows = 6;704    const cellW = W / cols;705    const cellH = H / rows;706    const targets = runes.slice(0, cols * rows).map((_, i) => ({707      x: (i % cols) * cellW + cellW / 2,708      y: Math.floor(i / cols) * cellH + cellH / 2,709    }));710    const start = performance.now();711    const DURATION = 400;712713    function drawSnap(now: number) {714      const t = Math.min((now - start) / DURATION, 1);715      const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;716      ctx.clearRect(0, 0, W, H);717      runes.slice(0, targets.length).forEach((r, i) => {718        const tx = targets[i]!.x;719        const ty = targets[i]!.y;720        const cx = r.x + (tx - r.x) * ease;721        const cy = r.y + (ty - r.y) * ease;722        ctx.font = `16px PeachScript, monospace`;723        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;724        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";725        ctx.shadowBlur = 6;726        ctx.fillText(r.char, cx - 8, cy + 8);727      });728      if (t < 1) {729        animFrameId = requestAnimationFrame(drawSnap);730      } else {731        onDone();732      }733    }734    animFrameId = requestAnimationFrame(drawSnap);735  }736737  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {738    stopAnim();739    const ctx = canvas.getContext("2d")!;740    const W = canvas.width;741    const H = canvas.height;742    const start = performance.now();743    const DURATION = 900;744745    function drawBloom(now: number) {746      const t = Math.min((now - start) / DURATION, 1);747      ctx.clearRect(0, 0, W, H);748      // Runes dissolve749      const runeAlpha = 1 - Math.min(t / 0.4, 1);750      if (runeAlpha > 0) {751        for (const r of runes.slice(0, 48)) {752          ctx.font = `16px PeachScript, monospace`;753          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;754          ctx.fillText(r.char, r.x - 8, r.y + 8);755        }756      }757      // Peach mark bloom758      const markT = Math.max(0, (t - 0.2) / 0.5);759      const markScale = Math.min(markT * 1.2, 1);760      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;761      if (markAlpha > 0) {762        ctx.save();763        ctx.globalAlpha = markAlpha;764        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;765        ctx.shadowBlur = 30;766        ctx.translate(W / 2, H / 2);767        ctx.scale(markScale, markScale);768        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);769        ctx.restore();770      }771      if (t < 1) {772        animFrameId = requestAnimationFrame(drawBloom);773      } else {774        onDone();775      }776    }777    animFrameId = requestAnimationFrame(drawBloom);778  }779780  onCleanup(() => stopAnim());781782  async function handleRestore() {783    if (!scriptValid() || !backupPass().trim() || working()) return;784    setError(null);785    setWorking(true);786    setAnimating("chaos");787788    const canvas = canvasRef;789    if (canvas) startChaos(canvas);790791    try {792      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));793      const payload = await parsePayload(bytes, backupPass());794795      if (!props.hasVault) {796        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });797        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");798      }799800      const rr = await sendMessage({801        type: "RESTORE_ENTRIES",802        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },803      });804      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");805806      if (payload.license?.key) {807        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});808      }809810      setAnimating("snap");811      stopAnim();812      if (canvas) {813        snapToGrid(canvas, () => {814          setAnimating("bloom");815          bloom(canvas, async () => {816            setAnimating(null);817            await props.onSuccess();818          });819        });820      } else {821        await props.onSuccess();822      }823    } catch (err) {824      stopAnim();825      setAnimating(null);826      const code = err instanceof Error ? err.message : String(err);827      setError(828        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"829          ? "That passphrase didn't work."830          : code === "CREATE_VAULT_FAILED"831            ? "Couldn't create a new local vault."832            : "Could not read this backup.",833      );834    } finally {835      setWorking(false);836    }837  }838839  return (840    <div class="peach-rt-symbols">841      <Show when={animating() !== null}>842        <canvas843          ref={(el) => { canvasRef = el; }}844          class="peach-rt-anim-canvas"845          width={280}846          height={200}847        />848      </Show>849      <Show when={animating() === null}>850        <textarea851          class="peach-restore-symbols-textarea"852          placeholder="paste peachscript…"853          rows={3}854          value={scriptInput()}855          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}856        />857        <div class="peach-rt-divider">— or —</div>858        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">859          open .peach file860          <input861            type="file"862            accept=".peach,text/plain"863            style="display:none"864            onChange={async (e) => {865              const file = (e.currentTarget as HTMLInputElement).files?.[0];866              if (!file) return;867              try {868                setScriptInput(await file.text());869              } catch {870                setError("Couldn't read file.");871              } finally {872                (e.currentTarget as HTMLInputElement).value = "";873              }874            }}875          />876        </label>877        <input878          class="peach-restore-symbols-pass"879          type="password"880          placeholder="backup password"881          value={backupPass()}882          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}883          onKeyDown={(e) => {884            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {885              e.preventDefault();886              void handleRestore();887            }888          }}889        />890        <button891          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"892          onClick={() => void handleRestore()}893          disabled={!scriptValid() || !backupPass().trim() || working()}894        >895          restore vault →896        </button>897        <Show when={error()}>898          <p class="peach-rt-error">{error()}</p>899        </Show>900      </Show>901    </div>902  );903}904905// ── CSV tab ────────────────────────────────────────────────────────────────906907function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {908  const [importing, setImporting] = createSignal(false);909  const [error, setError] = createSignal<string | null>(null);910911  async function handleFile(e: Event) {912    const input = e.currentTarget as HTMLInputElement;913    const file = input.files?.[0];914    input.value = "";915    if (!file) return;916    setImporting(true);917    setError(null);918    try {919      const text = await file.text();920      const parsed = await parseImportSource(text);921      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");922923      if (props.hasVault === false) {924        // No vault yet — this path shouldn't normally occur for CSV (no backup password925        // means no way to create a locked vault). Create an unlocked placeholder vault.926        // The user can set a master password from settings.927        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });928        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");929      }930931      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });932      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");933      if (parsed.licenseKey) {934        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});935      }936      await props.onSuccess();937    } catch (err) {938      const code = err instanceof Error ? err.message : String(err);939      setError(940        code === "NO_ENTRIES" ? "No entries found in this file." :941        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :942        "Import failed. Check the file format and try again."943      );944    } finally {945      setImporting(false);946    }947  }948949  return (950    <div class="peach-rt-csv">951      <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>952        {importing() ? "importing…" : "open csv file"}953        <input954          type="file"955          accept=".csv,.json,.peach,application/x-peach"956          style="display:none"957          onChange={handleFile}958          disabled={importing()}959        />960      </label>961      <p class="peach-rt-csv-sources">962        Imports from Bitwarden, 1Password, Proton Pass,<br />963        Chrome, Safari, and Edge.964      </p>965      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>966      <Show when={error()}>967        <p class="peach-rt-error">{error()}</p>968      </Show>969    </div>970  );971}972973// ── From Phone tab ─────────────────────────────────────────────────────────974975type PhoneState = "waiting" | "connected" | "trust" | "error";976977function RestoreFromPhone(props: { onSuccess: () => void | Promise<void> }) {978  const [state, setState] = createSignal<PhoneState>("waiting");979  const [code, setCode] = createSignal<string | null>(null);980  const [qrUrl, setQrUrl] = createSignal<string | null>(null);981  const [expired, setExpired] = createSignal(false);982  const [deviceName, setDeviceName] = createSignal<string | null>(null);983  const [accepting, setAccepting] = createSignal(false);984  const [serverError, setServerError] = createSignal<string | null>(null);985  let expireTimer: ReturnType<typeof setTimeout> | null = null;986987  function clearExpire() {988    if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }989  }990991  async function startPairing() {992    setExpired(false);993    setCode(null);994    setQrUrl(null);995    setServerError(null);996    clearExpire();997    const res = await sendMessage({ type: "START_PAIRING_SERVER" });998    const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };999    if (!r.ok || !r.data) {1000      setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");1001      return;1002    }1003    const token = r.data.token;1004    const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;1005    setCode(formatted);1006    const qrData = `peach://handoff?token=${token}`;1007    try {1008      const QRCode = (await import("qrcode")).default;1009      const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });1010      setQrUrl(url);1011    } catch {1012      // QR optional — code is the primary affordance1013    }1014    const remaining = Math.max(0, r.data.expiresAt - Date.now());1015    expireTimer = setTimeout(() => {1016      setCode(null);1017      setQrUrl(null);1018      setExpired(true);1019    }, remaining);1020  }10211022  function handleRuntimeMsg(msg: unknown) {1023    const m = msg as { type?: string; payload?: { deviceName?: string } };1024    if (m?.type === "PAIRING_COMPLETED") {1025      clearExpire();1026      setDeviceName(m.payload?.deviceName ?? "your phone");1027      setState("trust");1028    }1029  }10301031  onMount(() => {1032    void startPairing();1033    // @ts-ignore — chrome runtime1034    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1035      chrome.runtime.onMessage.addListener(handleRuntimeMsg);1036    }1037  });10381039  onCleanup(() => {1040    clearExpire();1041    // @ts-ignore1042    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1043      chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1044    }1045    sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1046  });10471048  async function handleAccept(trust: boolean) {1049    setAccepting(true);1050    if (trust) {1051      await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1052    }1053    await props.onSuccess();1054  }10551056  return (1057    <div class="peach-rt-phone">1058      <Show when={state() === "waiting"}>1059        <Show when={!expired()}>1060          <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1061            <p class="peach-rt-phone-code">{code()}</p>1062            <Show when={qrUrl() !== null}>1063              <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1064            </Show>1065            <p class="peach-rt-phone-hint">1066              Open Peach on your phone and tap<br />1067              Settings → Send to desktop.<br />1068              Same WiFi required.1069            </p>1070          </Show>1071          <Show when={serverError()}>1072            <p class="peach-rt-error">{serverError()}</p>1073          </Show>1074        </Show>1075        <Show when={expired()}>1076          <p class="peach-rt-phone-hint">Code expired.</p>1077          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1078            Generate new code1079          </button>1080        </Show>1081      </Show>10821083      <Show when={state() === "connected"}>1084        <p class="peach-rt-phone-hint">1085          Connected to {deviceName() ?? "your phone"}.<br />1086          Receiving vault…1087        </p>1088      </Show>10891090      <Show when={state() === "trust"}>1091        <p class="peach-rt-phone-hint" style="margin-bottom:10px">1092          Vault received from {deviceName() ?? "your phone"}.<br />1093          Trust this network for sync?1094        </p>1095        <button1096          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1097          onClick={() => void handleAccept(true)}1098          disabled={accepting()}1099        >1100          Yes, trust this network1101        </button>1102        <button1103          class="peach-btn peach-btn--ghost peach-btn--sm"1104          style="margin-top:6px"1105          onClick={() => void handleAccept(false)}1106          disabled={accepting()}1107        >1108          Not now1109        </button>1110      </Show>11111112      <Show when={state() === "error"}>1113        <p class="peach-rt-phone-hint">1114          Could not connect. Check that both devices<br />1115          are on the same WiFi network.1116        </p>1117        <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1118          Try again1119        </button>1120      </Show>1121    </div>1122  );1123}11241125// ── Helpers ────────────────────────────────────────────────────────────────11261127function getDisplayError(error: string | undefined, fallback: string): string {1128  if (!error) return fallback;1129  if (1130    error === "SERVICE_WORKER_UNAVAILABLE" ||1131    error.includes("Receiving end does not exist") ||1132    error.includes("message port closed")1133  ) {1134    return "background service unavailable";1135  }1136  return error;1137}