Lock.tsx

37.9 KB · raw

1import { For, Match, Show, Switch, createMemo, createSignal, onCleanup, onMount } from "solid-js";2import { sendMessage } from "../App";3import { LockRuneCanvas } from "../components/LockRuneCanvas";4import { PeachMark, PeachWordmark } from "../components/PeachMark";5import { RecoveryWordEntryView } from "./RecoveryWordEntry";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import { RuneBackground } from "../components/RuneBackground";8import { ImportView } from "./Import";9import { storageGet, storageSet } from "../../shared/storage";10import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";11import { parsePayload } from "../../crypto/peachPayload";12import { parseImportSource } from "../../shared/import";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}4647type RestoreSurface = "restore" | null;48type RecoverSurface = "words" | "new-password" | null;4950export function LockView(props: LockProps) {51  const [password, setPassword] = createSignal("");52  const [confirmPassword, setConfirmPassword] = createSignal("");53  const [error, setError] = createSignal("");54  const [loading, setLoading] = createSignal(false);55  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);56  const [showPassword, setShowPassword] = createSignal(false);57  const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);58  // After vault creation succeeds, set this to show the recovery words screen.59  // Only meaningful when hasVault === false.60  const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);61  const [recoveryWords, setRecoveryWords] = createSignal<string[]>([]);6263  const MIN_LENGTH = 12;64  const MIN_ZXCVBN_SCORE = 2;6566  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);6768  const canCreate = createMemo(69    () =>70      password().length >= MIN_LENGTH &&71      confirmPassword().length >= MIN_LENGTH &&72      password() === confirmPassword() &&73      strengthScore() !== null &&74      strengthScore()! >= MIN_ZXCVBN_SCORE75  );7677  const passwordStrength = createMemo(() => {78    const score = strengthScore();79    if (score === null) return null;80    if (score <= 1) return "weak";81    if (score === 2) return "fair";82    if (score === 3) return "good";83    return "strong";84  });8586  const createBlockerMessage = createMemo(() => {87    if (!password()) return null;88    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;89    if (!confirmPassword()) return "confirm your password";90    if (password() !== confirmPassword()) return "passwords don't match";91    if (strengthScore() === null) return "checking strength…";92    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";93    return null;94  });9596  // Update strength asynchronously when password changes97  createMemo(() => {98    const value = password();99    if (!value) { setStrengthScore(null); return; }100    getZxcvbn().then((zxcvbn) => {101      if (password() === value) setStrengthScore(zxcvbn(value).score);102    }).catch(() => {103      // zxcvbn unavailable — fall back to length-only heuristic104      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);105    });106  });107108  async function handleUnlock() {109    if (!password().trim()) return;110    setLoading(true);111    setError("");112    const response = await sendMessage({113      type: "UNLOCK",114      payload: { password: password() },115    });116    const result = response as { ok: boolean; error?: string };117    if (result.ok) {118      await props.onUnlock();119      setLoading(false);120      return;121    }122    setError(getDisplayError(result.error, "incorrect password"));123    setPassword("");124    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;125    passwordField?.classList.add("shake");126    setTimeout(() => passwordField?.classList.remove("shake"), 300);127    setLoading(false);128  }129130  async function handleCreate() {131    if (!password().trim()) return;132    if (password().length < MIN_LENGTH) {133      setError(`password must be at least ${MIN_LENGTH} characters`);134      return;135    }136    if (password() !== confirmPassword()) {137      setError("passwords do not match");138      return;139    }140    const score = strengthScore();141    if (score === null || score < MIN_ZXCVBN_SCORE) {142      setError("password is too weak — try a longer or more varied passphrase");143      return;144    }145    setLoading(true);146    setError("");147    const response = await sendMessage({148      type: "CREATE_VAULT",149      payload: { password: password() },150    });151    const result = response as { ok: boolean; error?: string };152    if (result.ok) {153      // Don't unlock yet — show the recovery words screen first. Mark the154      // confirmation as pending so lock-on-close / auto-lock won't discard155      // the in-memory key while the user is writing the words down. Cleared156      // when the user types the 3 confirmation words successfully.157      try {158        await chrome.storage.session?.set({ peach_recovery_pending: "1" });159      } catch { /* best-effort */ }160      setShowRecoveryWords(true);161      setLoading(false);162      return;163    }164    setError(getDisplayError(result.error, "failed to create vault"));165    setLoading(false);166  }167168  function openRestore(surface: Exclude<RestoreSurface, null>) {169    if (!props.ready || loading()) return;170    setPassword("");171    setConfirmPassword("");172    setError("");173    setRestoreSurface(surface);174  }175176  // ── All surfaces routed reactively via Switch/Match ───────────────────177  return (178    <Switch>179      {/* ── Restore tab view ──────────────────────────────────────── */}180      <Match when={restoreSurface() === "restore"}>181        <RestoreTabView182          hasVault={props.hasVault}183          onBack={() => setRestoreSurface(null)}184          onUnlock={async () => {185            setRestoreSurface(null);186            await props.onUnlock();187          }}188        />189      </Match>190191      {/* ── Recovery words display after fresh vault creation ────────── */}192      <Match when={showRecoveryWords() && !props.hasVault}>193        <RecoveryWordDisplayView194          onDone={props.onUnlock}195        />196      </Match>197198      {/* ── Recovery word entry ─────────────────────────────────────── */}199      <Match when={recoverSurface() === "words"}>200        <RecoveryWordEntryView201          onCancel={() => setRecoverSurface(null)}202          onRecoveryComplete={props.onUnlock}203        />204      </Match>205206      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}207      <Match when={props.hasVault}>208        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">209          <header class="peach-screen-header lock-screen-header">210            <div class="peach-wordmark-stack">211              <PeachWordmark class="peach-wordmark-wrap" />212              <Show when={props.isPro}>213                <span class="peach-pro-mini-badge">pro</span>214              </Show>215            </div>216          </header>217218          <div class="peach-lock-hero peach-lock-hero--unlock">219            <div class="peach-lock-hero-art" aria-hidden="true">220              <PeachMark class="peach-lock-hero-mark" />221            </div>222            <span class="peach-lock-eyebrow">vault locked</span>223            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">224              unlock225              <br />226              your vault.227            </h1>228          </div>229230          <LockRuneCanvas />231232          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">233            <label class="peach-label" for="unlock-password">234              master password235            </label>236            <input237              id="unlock-password"238              class="peach-underline-input peach-unlock-input lock-password-input"239              type="password"240              placeholder="••••••••••••"241              autocomplete="current-password"242              value={password()}243              disabled={!props.ready}244              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}245              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}246              onKeyDown={(event) => {247                if (event.key !== "Enter" || !props.ready) return;248                void handleUnlock();249              }}250            />251252            <Show when={error()}>253              <p class="peach-hint peach-hint-error">{error()}</p>254            </Show>255256            <button257              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"258              onClick={() => void handleUnlock()}259              disabled={!props.ready || loading()}260            >261              {loading() ? "unlocking..." : "unlock"}262            </button>263264            <button265              class="peach-lock-recover-link"266              type="button"267              onClick={() => setRecoverSurface("words")}268            >269              forgot your password?270            </button>271          </section>272        </section>273      </Match>274275      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}276      <Match when={true}>277        <section278          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"279          data-motion-phase="enter"280        >281          <header class="peach-screen-header peach-newvault-header">282            <Show when={props.onBackToOnboarding}>283              <button284                class="peach-newvault-back"285                type="button"286                onClick={() => props.onBackToOnboarding?.()}287                aria-label="Back to onboarding"288              >289                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">290                  <path d="M7 2L3 5.5L7 9" />291                </svg>292                back293              </button>294            </Show>295            <div class="peach-wordmark-stack">296              <PeachWordmark class="peach-wordmark-wrap" />297              <Show when={props.isPro}>298                <span class="peach-pro-mini-badge">pro</span>299              </Show>300            </div>301          </header>302303          <div class="peach-newvault-mark" aria-hidden="true">304            <PeachMark class="peach-newvault-mark-art" />305          </div>306307          <div class="peach-newvault-card">308            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>309            <hr class="peach-newvault-divider" />310311            <div class="peach-newvault-field">312              <div class="peach-newvault-label-row">313                <label class="peach-label" for="master-password">314                  master password315                </label>316                <button317                  class="peach-lock-reveal-btn"318                  type="button"319                  onClick={() => setShowPassword((value) => !value)}320                  aria-label={showPassword() ? "Hide password" : "Show password"}321                >322                  <Show323                    when={showPassword()}324                    fallback={325                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">326                        <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" />327                        <circle cx="7" cy="5" r="1.75" />328                      </svg>329                    }330                  >331                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">332                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />333                      <path d="M3.5 5.5L2.5 7.5" />334                      <path d="M7 6.5V8.5" />335                      <path d="M10.5 5.5L11.5 7.5" />336                    </svg>337                  </Show>338                  {showPassword() ? "hide" : "show"}339                </button>340              </div>341              <input342                id="master-password"343                class="peach-underline-input peach-newvault-input lock-password-input"344                type={showPassword() ? "text" : "password"}345                placeholder="••••••••••••"346                autocomplete="new-password"347                value={password()}348                disabled={!props.ready}349                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}350                onKeyDown={(event) => {351                  if (event.key !== "Enter" || !props.ready) return;352                  void handleCreate();353                }}354              />355              <Show356                when={password().length > 0 && passwordStrength() !== null}357                fallback={358                  <p class="peach-newvault-hint">at least 12 characters</p>359                }360              >361                <div class="peach-password-strength">362                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>363                    <div class="peach-strength-fill"></div>364                  </div>365                  <span class="peach-strength-label">{passwordStrength()}</span>366                </div>367              </Show>368            </div>369370            <div class="peach-newvault-field">371              <label class="peach-label" for="confirm-password">372                confirm password373              </label>374              <input375                id="confirm-password"376                class="peach-underline-input peach-newvault-input"377                type={showPassword() ? "text" : "password"}378                placeholder="••••••••••••"379                autocomplete="new-password"380                value={confirmPassword()}381                disabled={!props.ready}382                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}383                onKeyDown={(event) => {384                  if (event.key === "Enter" && props.ready) void handleCreate();385                }}386              />387            </div>388389            <Show when={error()}>390              <p class="peach-hint peach-hint-error">{error()}</p>391            </Show>392393            <button394              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"395              onClick={() => void handleCreate()}396              disabled={!props.ready || loading() || !canCreate()}397              aria-live="polite"398            >399              <Show400                when={!canCreate() && createBlockerMessage()}401                fallback={loading() ? "creating..." : "create vault"}402              >403                {createBlockerMessage()}404              </Show>405            </button>406407            <p class="peach-newvault-warning">408              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>409              <span>Save your 12 recovery words right after you create the vault — they're the only way in if you forget your password.</span>410            </p>411          </div>412413          <div class="peach-newvault-footer">414            <p class="peach-hint">Already have a backup?</p>415            <button416              class="peach-lock-restore-link"417              onClick={() => void openRestore("restore")}418              disabled={!props.ready}419            >420              › restore from backup421            </button>422          </div>423        </section>424      </Match>425    </Switch>426  );427}428429// ── Restore tab view ───────────────────────────────────────────────────────430431type RestoreTab = "symbols" | "csv" | "phone";432433function tabLabel(tab: RestoreTab): string {434  return (435    { symbols: "PeachScript", csv: "CSV", phone: "From Phone" }[tab]436  );437}438439function RestoreTabView(props: {440  hasVault: boolean;441  onBack: () => void;442  onUnlock: () => void | Promise<void>;443}) {444  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");445  const [showIntro, setShowIntro] = createSignal(false);446  const [pendingUnlock, setPendingUnlock] = createSignal(false);447448  async function handleRestoreSuccess() {449    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);450    if (!seen) {451      setShowIntro(true);452    } else {453      await props.onUnlock();454    }455  }456457  async function dismissIntro() {458    await storageSet("peach_shown_restore_intro", "true").catch(() => {});459    setShowIntro(false);460    await props.onUnlock();461  }462463  return (464    <section465      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"466      data-motion-phase="enter"467    >468      <RuneBackground opacity={0.05} />469470      <header class="peach-screen-header lock-screen-header">471        <PeachWordmark class="peach-wordmark-wrap" />472        <button class="peach-lock-restore-link" onClick={props.onBack}>473          ‹ back474        </button>475      </header>476477      <div class="peach-newvault-card peach-restore-tabcard">478        <div class="peach-restore-tabs" role="tablist">479          <For each={["symbols", "csv", "phone"] as RestoreTab[]}>480            {(tab) => (481              <button482                class="peach-restore-tab"483                classList={{ "is-active": activeTab() === tab }}484                onClick={() => setActiveTab(tab)}485                role="tab"486                aria-selected={activeTab() === tab}487              >488                {tabLabel(tab)}489              </button>490            )}491          </For>492        </div>493        <div class="peach-restore-content">494          <Show when={activeTab() === "symbols"}>495            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />496          </Show>497          <Show when={activeTab() === "csv"}>498            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />499          </Show>500          <Show when={activeTab() === "phone"}>501            <RestoreFromPhone onSuccess={handleRestoreSuccess} />502          </Show>503        </div>504      </div>505506      {/* One-time intro popup after first restore */}507      <Show when={showIntro()}>508        <div class="peach-restore-intro-overlay">509          <div class="peach-restore-intro-card">510            <p class="peach-restore-intro-headline">welcome to peach.</p>511            <div class="peach-restore-intro-body">512              <p>Your vault lives only on this device. No server has a copy.</p>513              <p>The inline bubble fills passwords automatically when you visit sites.</p>514              <p>Export your vault from Settings before switching devices.</p>515            </div>516            <button517              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"518              onClick={() => void dismissIntro()}519              disabled={pendingUnlock()}520            >521              got it522            </button>523          </div>524        </div>525      </Show>526    </section>527  );528}529530// ── PeachScript tab ────────────────────────────────────────────────────────531532function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {533  const [scriptInput, setScriptInput] = createSignal("");534  const [backupPass, setBackupPass] = createSignal("");535  const [working, setWorking] = createSignal(false);536  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);537  const [error, setError] = createSignal<string | null>(null);538  let canvasRef: HTMLCanvasElement | undefined;539  let animFrameId = 0;540  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];541542  const scriptValid = createMemo(() => isPeachScript(scriptInput()));543544  function stopAnim() {545    cancelAnimationFrame(animFrameId);546    animFrameId = 0;547  }548549  function startChaos(canvas: HTMLCanvasElement) {550    const ctx = canvas.getContext("2d")!;551    const W = canvas.width;552    const H = canvas.height;553    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>554      String.fromCodePoint(0xe000 + i)555    );556    if (runes.length === 0) {557      runes = Array.from({ length: 60 }, () => ({558        x: Math.random() * W,559        y: Math.random() * H,560        vx: (Math.random() - 0.5) * 3,561        vy: (Math.random() - 0.5) * 3,562        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,563        size: 14 + Math.random() * 20,564        angle: Math.random() * Math.PI * 2,565        va: (Math.random() - 0.5) * 0.1,566      }));567    }568569    function draw() {570      ctx.clearRect(0, 0, W, H);571      for (const r of runes) {572        r.x = (r.x + r.vx + W) % W;573        r.y = (r.y + r.vy + H) % H;574        r.angle += r.va;575        ctx.save();576        ctx.translate(r.x, r.y);577        ctx.rotate(r.angle);578        ctx.font = `${r.size}px PeachScript, monospace`;579        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);580        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;581        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";582        ctx.shadowBlur = 8;583        ctx.fillText(r.char, 0, 0);584        ctx.restore();585      }586      animFrameId = requestAnimationFrame(draw);587    }588    draw();589  }590591  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {592    stopAnim();593    const ctx = canvas.getContext("2d")!;594    const W = canvas.width;595    const H = canvas.height;596    const cols = 8;597    const rows = 6;598    const cellW = W / cols;599    const cellH = H / rows;600    const targets = runes.slice(0, cols * rows).map((_, i) => ({601      x: (i % cols) * cellW + cellW / 2,602      y: Math.floor(i / cols) * cellH + cellH / 2,603    }));604    const start = performance.now();605    const DURATION = 400;606607    function drawSnap(now: number) {608      const t = Math.min((now - start) / DURATION, 1);609      const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;610      ctx.clearRect(0, 0, W, H);611      runes.slice(0, targets.length).forEach((r, i) => {612        const tx = targets[i]!.x;613        const ty = targets[i]!.y;614        const cx = r.x + (tx - r.x) * ease;615        const cy = r.y + (ty - r.y) * ease;616        ctx.font = `16px PeachScript, monospace`;617        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;618        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";619        ctx.shadowBlur = 6;620        ctx.fillText(r.char, cx - 8, cy + 8);621      });622      if (t < 1) {623        animFrameId = requestAnimationFrame(drawSnap);624      } else {625        onDone();626      }627    }628    animFrameId = requestAnimationFrame(drawSnap);629  }630631  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {632    stopAnim();633    const ctx = canvas.getContext("2d")!;634    const W = canvas.width;635    const H = canvas.height;636    const start = performance.now();637    const DURATION = 900;638639    function drawBloom(now: number) {640      const t = Math.min((now - start) / DURATION, 1);641      ctx.clearRect(0, 0, W, H);642      // Runes dissolve643      const runeAlpha = 1 - Math.min(t / 0.4, 1);644      if (runeAlpha > 0) {645        for (const r of runes.slice(0, 48)) {646          ctx.font = `16px PeachScript, monospace`;647          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;648          ctx.fillText(r.char, r.x - 8, r.y + 8);649        }650      }651      // Peach mark bloom652      const markT = Math.max(0, (t - 0.2) / 0.5);653      const markScale = Math.min(markT * 1.2, 1);654      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;655      if (markAlpha > 0) {656        ctx.save();657        ctx.globalAlpha = markAlpha;658        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;659        ctx.shadowBlur = 30;660        ctx.translate(W / 2, H / 2);661        ctx.scale(markScale, markScale);662        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);663        ctx.restore();664      }665      if (t < 1) {666        animFrameId = requestAnimationFrame(drawBloom);667      } else {668        onDone();669      }670    }671    animFrameId = requestAnimationFrame(drawBloom);672  }673674  onCleanup(() => stopAnim());675676  async function handleRestore() {677    if (!scriptValid() || !backupPass().trim() || working()) return;678    setError(null);679    setWorking(true);680    setAnimating("chaos");681682    const canvas = canvasRef;683    if (canvas) startChaos(canvas);684685    try {686      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));687      const payload = await parsePayload(bytes, backupPass());688689      if (!props.hasVault) {690        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });691        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");692      }693694      const rr = await sendMessage({695        type: "RESTORE_ENTRIES",696        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },697      });698      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");699700      if (payload.license?.key) {701        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});702      }703704      setAnimating("snap");705      stopAnim();706      if (canvas) {707        snapToGrid(canvas, () => {708          setAnimating("bloom");709          bloom(canvas, async () => {710            setAnimating(null);711            await props.onSuccess();712          });713        });714      } else {715        await props.onSuccess();716      }717    } catch (err) {718      stopAnim();719      setAnimating(null);720      const code = err instanceof Error ? err.message : String(err);721      setError(722        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"723          ? "That passphrase didn't work."724          : code === "CREATE_VAULT_FAILED"725            ? "Couldn't create a new local vault."726            : "Could not read this backup.",727      );728    } finally {729      setWorking(false);730    }731  }732733  return (734    <div class="peach-rt-symbols">735      <Show when={animating() !== null}>736        <canvas737          ref={(el) => { canvasRef = el; }}738          class="peach-rt-anim-canvas"739          width={280}740          height={200}741        />742      </Show>743      <Show when={animating() === null}>744        <textarea745          class="peach-restore-symbols-textarea"746          placeholder="paste peachscript…"747          rows={3}748          value={scriptInput()}749          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}750        />751        <div class="peach-rt-divider">— or —</div>752        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">753          open .peach file754          <input755            type="file"756            accept=".peach,text/plain"757            style="display:none"758            onChange={async (e) => {759              const file = (e.currentTarget as HTMLInputElement).files?.[0];760              if (!file) return;761              try {762                setScriptInput(await file.text());763              } catch {764                setError("Couldn't read file.");765              } finally {766                (e.currentTarget as HTMLInputElement).value = "";767              }768            }}769          />770        </label>771        <input772          class="peach-restore-symbols-pass"773          type="password"774          placeholder="backup password"775          value={backupPass()}776          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}777          onKeyDown={(e) => {778            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {779              e.preventDefault();780              void handleRestore();781            }782          }}783        />784        <button785          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"786          onClick={() => void handleRestore()}787          disabled={!scriptValid() || !backupPass().trim() || working()}788        >789          restore vault →790        </button>791        <Show when={error()}>792          <p class="peach-rt-error">{error()}</p>793        </Show>794      </Show>795    </div>796  );797}798799// ── CSV tab ────────────────────────────────────────────────────────────────800801function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {802  const [importing, setImporting] = createSignal(false);803  const [error, setError] = createSignal<string | null>(null);804805  async function handleFile(e: Event) {806    const input = e.currentTarget as HTMLInputElement;807    const file = input.files?.[0];808    input.value = "";809    if (!file) return;810    setImporting(true);811    setError(null);812    try {813      const text = await file.text();814      const parsed = await parseImportSource(text);815      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");816817      if (props.hasVault === false) {818        // No vault yet — this path shouldn't normally occur for CSV (no backup password819        // means no way to create a locked vault). Create an unlocked placeholder vault.820        // The user can set a master password from settings.821        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });822        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");823      }824825      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });826      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");827      if (parsed.licenseKey) {828        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});829      }830      await props.onSuccess();831    } catch (err) {832      const code = err instanceof Error ? err.message : String(err);833      setError(834        code === "NO_ENTRIES" ? "No entries found in this file." :835        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :836        "Import failed. Check the file format and try again."837      );838    } finally {839      setImporting(false);840    }841  }842843  return (844    <div class="peach-rt-csv">845      <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>846        {importing() ? "importing…" : "open csv file"}847        <input848          type="file"849          accept=".csv,.json,.peach,application/x-peach"850          style="display:none"851          onChange={handleFile}852          disabled={importing()}853        />854      </label>855      <p class="peach-rt-csv-sources">856        Imports from Bitwarden, 1Password, Proton Pass,<br />857        Chrome, Safari, and Edge.858      </p>859      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>860      <Show when={error()}>861        <p class="peach-rt-error">{error()}</p>862      </Show>863    </div>864  );865}866867// ── From Phone tab ─────────────────────────────────────────────────────────868869type PhoneState = "waiting" | "connected" | "trust" | "error";870871function RestoreFromPhone(props: { onSuccess: () => void | Promise<void> }) {872  const [state, setState] = createSignal<PhoneState>("waiting");873  const [code, setCode] = createSignal<string | null>(null);874  const [qrUrl, setQrUrl] = createSignal<string | null>(null);875  const [expired, setExpired] = createSignal(false);876  const [deviceName, setDeviceName] = createSignal<string | null>(null);877  const [accepting, setAccepting] = createSignal(false);878  const [serverError, setServerError] = createSignal<string | null>(null);879  let expireTimer: ReturnType<typeof setTimeout> | null = null;880881  function clearExpire() {882    if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }883  }884885  async function startPairing() {886    setExpired(false);887    setCode(null);888    setQrUrl(null);889    setServerError(null);890    clearExpire();891    const res = await sendMessage({ type: "START_PAIRING_SERVER" });892    const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };893    if (!r.ok || !r.data) {894      setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");895      return;896    }897    const token = r.data.token;898    const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;899    setCode(formatted);900    const qrData = `peach://handoff?token=${token}`;901    try {902      const QRCode = (await import("qrcode")).default;903      const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });904      setQrUrl(url);905    } catch {906      // QR optional — code is the primary affordance907    }908    const remaining = Math.max(0, r.data.expiresAt - Date.now());909    expireTimer = setTimeout(() => {910      setCode(null);911      setQrUrl(null);912      setExpired(true);913    }, remaining);914  }915916  function handleRuntimeMsg(msg: unknown) {917    const m = msg as { type?: string; payload?: { deviceName?: string } };918    if (m?.type === "PAIRING_COMPLETED") {919      clearExpire();920      setDeviceName(m.payload?.deviceName ?? "your phone");921      setState("trust");922    }923  }924925  onMount(() => {926    void startPairing();927    // @ts-ignore — chrome runtime928    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {929      chrome.runtime.onMessage.addListener(handleRuntimeMsg);930    }931  });932933  onCleanup(() => {934    clearExpire();935    // @ts-ignore936    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {937      chrome.runtime.onMessage.removeListener(handleRuntimeMsg);938    }939    sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});940  });941942  async function handleAccept(trust: boolean) {943    setAccepting(true);944    if (trust) {945      await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});946    }947    await props.onSuccess();948  }949950  return (951    <div class="peach-rt-phone">952      <Show when={state() === "waiting"}>953        <Show when={!expired()}>954          <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>955            <p class="peach-rt-phone-code">{code()}</p>956            <Show when={qrUrl() !== null}>957              <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />958            </Show>959            <p class="peach-rt-phone-hint">960              Open Peach on your phone and tap<br />961              Settings → Send to desktop.<br />962              Same WiFi required.963            </p>964          </Show>965          <Show when={serverError()}>966            <p class="peach-rt-error">{serverError()}</p>967          </Show>968        </Show>969        <Show when={expired()}>970          <p class="peach-rt-phone-hint">Code expired.</p>971          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>972            Generate new code973          </button>974        </Show>975      </Show>976977      <Show when={state() === "connected"}>978        <p class="peach-rt-phone-hint">979          Connected to {deviceName() ?? "your phone"}.<br />980          Receiving vault…981        </p>982      </Show>983984      <Show when={state() === "trust"}>985        <p class="peach-rt-phone-hint" style="margin-bottom:10px">986          Vault received from {deviceName() ?? "your phone"}.<br />987          Trust this network for sync?988        </p>989        <button990          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"991          onClick={() => void handleAccept(true)}992          disabled={accepting()}993        >994          Yes, trust this network995        </button>996        <button997          class="peach-btn peach-btn--ghost peach-btn--sm"998          style="margin-top:6px"999          onClick={() => void handleAccept(false)}1000          disabled={accepting()}1001        >1002          Not now1003        </button>1004      </Show>10051006      <Show when={state() === "error"}>1007        <p class="peach-rt-phone-hint">1008          Could not connect. Check that both devices<br />1009          are on the same WiFi network.1010        </p>1011        <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1012          Try again1013        </button>1014      </Show>1015    </div>1016  );1017}10181019// ── Helpers ────────────────────────────────────────────────────────────────10201021function getDisplayError(error: string | undefined, fallback: string): string {1022  if (!error) return fallback;1023  if (1024    error === "SERVICE_WORKER_UNAVAILABLE" ||1025    error.includes("Receiving end does not exist") ||1026    error.includes("message port closed")1027  ) {1028    return "background service unavailable";1029  }1030  return error;1031}