Lock.tsx

38.0 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 in chrome.storage.local so it survives both155      // popup close (lock-on-close skips while set) and full browser restart156      // (next unlock re-routes to the recovery surface). Cleared when the157      // user types the 3 confirmation words successfully.158      try {159        await chrome.storage.local?.set({ peach_recovery_pending: "1" });160      } catch { /* best-effort */ }161      setShowRecoveryWords(true);162      setLoading(false);163      return;164    }165    setError(getDisplayError(result.error, "failed to create vault"));166    setLoading(false);167  }168169  function openRestore(surface: Exclude<RestoreSurface, null>) {170    if (!props.ready || loading()) return;171    setPassword("");172    setConfirmPassword("");173    setError("");174    setRestoreSurface(surface);175  }176177  // ── All surfaces routed reactively via Switch/Match ───────────────────178  return (179    <Switch>180      {/* ── Restore tab view ──────────────────────────────────────── */}181      <Match when={restoreSurface() === "restore"}>182        <RestoreTabView183          hasVault={props.hasVault}184          onBack={() => setRestoreSurface(null)}185          onUnlock={async () => {186            setRestoreSurface(null);187            await props.onUnlock();188          }}189        />190      </Match>191192      {/* ── Recovery words display after fresh vault creation ────────── */}193      <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>194        <RecoveryWordDisplayView195          onDone={props.onUnlock}196        />197      </Match>198199      {/* ── Recovery word entry ─────────────────────────────────────── */}200      <Match when={recoverSurface() === "words"}>201        <RecoveryWordEntryView202          onCancel={() => setRecoverSurface(null)}203          onRecoveryComplete={props.onUnlock}204        />205      </Match>206207      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}208      <Match when={props.hasVault}>209        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">210          <header class="peach-screen-header lock-screen-header">211            <div class="peach-wordmark-stack">212              <PeachWordmark class="peach-wordmark-wrap" />213              <Show when={props.isPro}>214                <span class="peach-pro-mini-badge">pro</span>215              </Show>216            </div>217          </header>218219          <div class="peach-lock-hero peach-lock-hero--unlock">220            <div class="peach-lock-hero-art" aria-hidden="true">221              <PeachMark class="peach-lock-hero-mark" />222            </div>223            <span class="peach-lock-eyebrow">vault locked</span>224            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">225              unlock226              <br />227              your vault.228            </h1>229          </div>230231          <LockRuneCanvas />232233          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">234            <label class="peach-label" for="unlock-password">235              master password236            </label>237            <input238              id="unlock-password"239              class="peach-underline-input peach-unlock-input lock-password-input"240              type="password"241              placeholder="••••••••••••"242              autocomplete="current-password"243              value={password()}244              disabled={!props.ready}245              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}246              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}247              onKeyDown={(event) => {248                if (event.key !== "Enter" || !props.ready) return;249                void handleUnlock();250              }}251            />252253            <Show when={error()}>254              <p class="peach-hint peach-hint-error">{error()}</p>255            </Show>256257            <button258              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"259              onClick={() => void handleUnlock()}260              disabled={!props.ready || loading()}261            >262              {loading() ? "unlocking..." : "unlock"}263            </button>264265            <button266              class="peach-lock-recover-link"267              type="button"268              onClick={() => setRecoverSurface("words")}269            >270              forgot your password?271            </button>272          </section>273        </section>274      </Match>275276      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}277      <Match when={true}>278        <section279          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"280          data-motion-phase="enter"281        >282          <header class="peach-screen-header peach-newvault-header">283            <Show when={props.onBackToOnboarding}>284              <button285                class="peach-newvault-back"286                type="button"287                onClick={() => props.onBackToOnboarding?.()}288                aria-label="Back to onboarding"289              >290                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">291                  <path d="M7 2L3 5.5L7 9" />292                </svg>293                back294              </button>295            </Show>296            <div class="peach-wordmark-stack">297              <PeachWordmark class="peach-wordmark-wrap" />298              <Show when={props.isPro}>299                <span class="peach-pro-mini-badge">pro</span>300              </Show>301            </div>302          </header>303304          <div class="peach-newvault-mark" aria-hidden="true">305            <PeachMark class="peach-newvault-mark-art" />306          </div>307308          <div class="peach-newvault-card">309            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>310            <hr class="peach-newvault-divider" />311312            <div class="peach-newvault-field">313              <div class="peach-newvault-label-row">314                <label class="peach-label" for="master-password">315                  master password316                </label>317                <button318                  class="peach-lock-reveal-btn"319                  type="button"320                  onClick={() => setShowPassword((value) => !value)}321                  aria-label={showPassword() ? "Hide password" : "Show password"}322                >323                  <Show324                    when={showPassword()}325                    fallback={326                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">327                        <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" />328                        <circle cx="7" cy="5" r="1.75" />329                      </svg>330                    }331                  >332                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">333                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />334                      <path d="M3.5 5.5L2.5 7.5" />335                      <path d="M7 6.5V8.5" />336                      <path d="M10.5 5.5L11.5 7.5" />337                    </svg>338                  </Show>339                  {showPassword() ? "hide" : "show"}340                </button>341              </div>342              <input343                id="master-password"344                class="peach-underline-input peach-newvault-input lock-password-input"345                type={showPassword() ? "text" : "password"}346                placeholder="••••••••••••"347                autocomplete="new-password"348                value={password()}349                disabled={!props.ready}350                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}351                onKeyDown={(event) => {352                  if (event.key !== "Enter" || !props.ready) return;353                  void handleCreate();354                }}355              />356              <Show357                when={password().length > 0 && passwordStrength() !== null}358                fallback={359                  <p class="peach-newvault-hint">at least 12 characters</p>360                }361              >362                <div class="peach-password-strength">363                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>364                    <div class="peach-strength-fill"></div>365                  </div>366                  <span class="peach-strength-label">{passwordStrength()}</span>367                </div>368              </Show>369            </div>370371            <div class="peach-newvault-field">372              <label class="peach-label" for="confirm-password">373                confirm password374              </label>375              <input376                id="confirm-password"377                class="peach-underline-input peach-newvault-input"378                type={showPassword() ? "text" : "password"}379                placeholder="••••••••••••"380                autocomplete="new-password"381                value={confirmPassword()}382                disabled={!props.ready}383                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}384                onKeyDown={(event) => {385                  if (event.key === "Enter" && props.ready) void handleCreate();386                }}387              />388            </div>389390            <Show when={error()}>391              <p class="peach-hint peach-hint-error">{error()}</p>392            </Show>393394            <button395              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"396              onClick={() => void handleCreate()}397              disabled={!props.ready || loading() || !canCreate()}398              aria-live="polite"399            >400              <Show401                when={!canCreate() && createBlockerMessage()}402                fallback={loading() ? "creating..." : "create vault"}403              >404                {createBlockerMessage()}405              </Show>406            </button>407408            <p class="peach-newvault-warning">409              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>410              <span>Save your 12 recovery words right after you create the vault — they're the only way in if you forget your password.</span>411            </p>412          </div>413414          <div class="peach-newvault-footer">415            <p class="peach-hint">Already have a backup?</p>416            <button417              class="peach-lock-restore-link"418              onClick={() => void openRestore("restore")}419              disabled={!props.ready}420            >421              › restore from backup422            </button>423          </div>424        </section>425      </Match>426    </Switch>427  );428}429430// ── Restore tab view ───────────────────────────────────────────────────────431432type RestoreTab = "symbols" | "csv" | "phone";433434function tabLabel(tab: RestoreTab): string {435  return (436    { symbols: "PeachScript", csv: "CSV", phone: "From Phone" }[tab]437  );438}439440function RestoreTabView(props: {441  hasVault: boolean;442  onBack: () => void;443  onUnlock: () => void | Promise<void>;444}) {445  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");446  const [showIntro, setShowIntro] = createSignal(false);447  const [pendingUnlock, setPendingUnlock] = createSignal(false);448449  async function handleRestoreSuccess() {450    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);451    if (!seen) {452      setShowIntro(true);453    } else {454      await props.onUnlock();455    }456  }457458  async function dismissIntro() {459    await storageSet("peach_shown_restore_intro", "true").catch(() => {});460    setShowIntro(false);461    await props.onUnlock();462  }463464  return (465    <section466      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"467      data-motion-phase="enter"468    >469      <RuneBackground opacity={0.05} />470471      <header class="peach-screen-header lock-screen-header">472        <PeachWordmark class="peach-wordmark-wrap" />473        <button class="peach-lock-restore-link" onClick={props.onBack}>474          ‹ back475        </button>476      </header>477478      <div class="peach-newvault-card peach-restore-tabcard">479        <div class="peach-restore-tabs" role="tablist">480          <For each={["symbols", "csv", "phone"] as RestoreTab[]}>481            {(tab) => (482              <button483                class="peach-restore-tab"484                classList={{ "is-active": activeTab() === tab }}485                onClick={() => setActiveTab(tab)}486                role="tab"487                aria-selected={activeTab() === tab}488              >489                {tabLabel(tab)}490              </button>491            )}492          </For>493        </div>494        <div class="peach-restore-content">495          <Show when={activeTab() === "symbols"}>496            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />497          </Show>498          <Show when={activeTab() === "csv"}>499            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />500          </Show>501          <Show when={activeTab() === "phone"}>502            <RestoreFromPhone onSuccess={handleRestoreSuccess} />503          </Show>504        </div>505      </div>506507      {/* One-time intro popup after first restore */}508      <Show when={showIntro()}>509        <div class="peach-restore-intro-overlay">510          <div class="peach-restore-intro-card">511            <p class="peach-restore-intro-headline">welcome to peach.</p>512            <div class="peach-restore-intro-body">513              <p>Your vault lives only on this device. No server has a copy.</p>514              <p>The inline bubble fills passwords automatically when you visit sites.</p>515              <p>Export your vault from Settings before switching devices.</p>516            </div>517            <button518              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"519              onClick={() => void dismissIntro()}520              disabled={pendingUnlock()}521            >522              got it523            </button>524          </div>525        </div>526      </Show>527    </section>528  );529}530531// ── PeachScript tab ────────────────────────────────────────────────────────532533function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {534  const [scriptInput, setScriptInput] = createSignal("");535  const [backupPass, setBackupPass] = createSignal("");536  const [working, setWorking] = createSignal(false);537  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);538  const [error, setError] = createSignal<string | null>(null);539  let canvasRef: HTMLCanvasElement | undefined;540  let animFrameId = 0;541  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];542543  const scriptValid = createMemo(() => isPeachScript(scriptInput()));544545  function stopAnim() {546    cancelAnimationFrame(animFrameId);547    animFrameId = 0;548  }549550  function startChaos(canvas: HTMLCanvasElement) {551    const ctx = canvas.getContext("2d")!;552    const W = canvas.width;553    const H = canvas.height;554    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>555      String.fromCodePoint(0xe000 + i)556    );557    if (runes.length === 0) {558      runes = Array.from({ length: 60 }, () => ({559        x: Math.random() * W,560        y: Math.random() * H,561        vx: (Math.random() - 0.5) * 3,562        vy: (Math.random() - 0.5) * 3,563        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,564        size: 14 + Math.random() * 20,565        angle: Math.random() * Math.PI * 2,566        va: (Math.random() - 0.5) * 0.1,567      }));568    }569570    function draw() {571      ctx.clearRect(0, 0, W, H);572      for (const r of runes) {573        r.x = (r.x + r.vx + W) % W;574        r.y = (r.y + r.vy + H) % H;575        r.angle += r.va;576        ctx.save();577        ctx.translate(r.x, r.y);578        ctx.rotate(r.angle);579        ctx.font = `${r.size}px PeachScript, monospace`;580        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);581        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;582        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";583        ctx.shadowBlur = 8;584        ctx.fillText(r.char, 0, 0);585        ctx.restore();586      }587      animFrameId = requestAnimationFrame(draw);588    }589    draw();590  }591592  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {593    stopAnim();594    const ctx = canvas.getContext("2d")!;595    const W = canvas.width;596    const H = canvas.height;597    const cols = 8;598    const rows = 6;599    const cellW = W / cols;600    const cellH = H / rows;601    const targets = runes.slice(0, cols * rows).map((_, i) => ({602      x: (i % cols) * cellW + cellW / 2,603      y: Math.floor(i / cols) * cellH + cellH / 2,604    }));605    const start = performance.now();606    const DURATION = 400;607608    function drawSnap(now: number) {609      const t = Math.min((now - start) / DURATION, 1);610      const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;611      ctx.clearRect(0, 0, W, H);612      runes.slice(0, targets.length).forEach((r, i) => {613        const tx = targets[i]!.x;614        const ty = targets[i]!.y;615        const cx = r.x + (tx - r.x) * ease;616        const cy = r.y + (ty - r.y) * ease;617        ctx.font = `16px PeachScript, monospace`;618        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;619        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";620        ctx.shadowBlur = 6;621        ctx.fillText(r.char, cx - 8, cy + 8);622      });623      if (t < 1) {624        animFrameId = requestAnimationFrame(drawSnap);625      } else {626        onDone();627      }628    }629    animFrameId = requestAnimationFrame(drawSnap);630  }631632  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {633    stopAnim();634    const ctx = canvas.getContext("2d")!;635    const W = canvas.width;636    const H = canvas.height;637    const start = performance.now();638    const DURATION = 900;639640    function drawBloom(now: number) {641      const t = Math.min((now - start) / DURATION, 1);642      ctx.clearRect(0, 0, W, H);643      // Runes dissolve644      const runeAlpha = 1 - Math.min(t / 0.4, 1);645      if (runeAlpha > 0) {646        for (const r of runes.slice(0, 48)) {647          ctx.font = `16px PeachScript, monospace`;648          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;649          ctx.fillText(r.char, r.x - 8, r.y + 8);650        }651      }652      // Peach mark bloom653      const markT = Math.max(0, (t - 0.2) / 0.5);654      const markScale = Math.min(markT * 1.2, 1);655      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;656      if (markAlpha > 0) {657        ctx.save();658        ctx.globalAlpha = markAlpha;659        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;660        ctx.shadowBlur = 30;661        ctx.translate(W / 2, H / 2);662        ctx.scale(markScale, markScale);663        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);664        ctx.restore();665      }666      if (t < 1) {667        animFrameId = requestAnimationFrame(drawBloom);668      } else {669        onDone();670      }671    }672    animFrameId = requestAnimationFrame(drawBloom);673  }674675  onCleanup(() => stopAnim());676677  async function handleRestore() {678    if (!scriptValid() || !backupPass().trim() || working()) return;679    setError(null);680    setWorking(true);681    setAnimating("chaos");682683    const canvas = canvasRef;684    if (canvas) startChaos(canvas);685686    try {687      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));688      const payload = await parsePayload(bytes, backupPass());689690      if (!props.hasVault) {691        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });692        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");693      }694695      const rr = await sendMessage({696        type: "RESTORE_ENTRIES",697        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },698      });699      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");700701      if (payload.license?.key) {702        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});703      }704705      setAnimating("snap");706      stopAnim();707      if (canvas) {708        snapToGrid(canvas, () => {709          setAnimating("bloom");710          bloom(canvas, async () => {711            setAnimating(null);712            await props.onSuccess();713          });714        });715      } else {716        await props.onSuccess();717      }718    } catch (err) {719      stopAnim();720      setAnimating(null);721      const code = err instanceof Error ? err.message : String(err);722      setError(723        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"724          ? "That passphrase didn't work."725          : code === "CREATE_VAULT_FAILED"726            ? "Couldn't create a new local vault."727            : "Could not read this backup.",728      );729    } finally {730      setWorking(false);731    }732  }733734  return (735    <div class="peach-rt-symbols">736      <Show when={animating() !== null}>737        <canvas738          ref={(el) => { canvasRef = el; }}739          class="peach-rt-anim-canvas"740          width={280}741          height={200}742        />743      </Show>744      <Show when={animating() === null}>745        <textarea746          class="peach-restore-symbols-textarea"747          placeholder="paste peachscript…"748          rows={3}749          value={scriptInput()}750          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}751        />752        <div class="peach-rt-divider">— or —</div>753        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">754          open .peach file755          <input756            type="file"757            accept=".peach,text/plain"758            style="display:none"759            onChange={async (e) => {760              const file = (e.currentTarget as HTMLInputElement).files?.[0];761              if (!file) return;762              try {763                setScriptInput(await file.text());764              } catch {765                setError("Couldn't read file.");766              } finally {767                (e.currentTarget as HTMLInputElement).value = "";768              }769            }}770          />771        </label>772        <input773          class="peach-restore-symbols-pass"774          type="password"775          placeholder="backup password"776          value={backupPass()}777          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}778          onKeyDown={(e) => {779            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {780              e.preventDefault();781              void handleRestore();782            }783          }}784        />785        <button786          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"787          onClick={() => void handleRestore()}788          disabled={!scriptValid() || !backupPass().trim() || working()}789        >790          restore vault →791        </button>792        <Show when={error()}>793          <p class="peach-rt-error">{error()}</p>794        </Show>795      </Show>796    </div>797  );798}799800// ── CSV tab ────────────────────────────────────────────────────────────────801802function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {803  const [importing, setImporting] = createSignal(false);804  const [error, setError] = createSignal<string | null>(null);805806  async function handleFile(e: Event) {807    const input = e.currentTarget as HTMLInputElement;808    const file = input.files?.[0];809    input.value = "";810    if (!file) return;811    setImporting(true);812    setError(null);813    try {814      const text = await file.text();815      const parsed = await parseImportSource(text);816      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");817818      if (props.hasVault === false) {819        // No vault yet — this path shouldn't normally occur for CSV (no backup password820        // means no way to create a locked vault). Create an unlocked placeholder vault.821        // The user can set a master password from settings.822        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });823        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");824      }825826      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });827      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");828      if (parsed.licenseKey) {829        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});830      }831      await props.onSuccess();832    } catch (err) {833      const code = err instanceof Error ? err.message : String(err);834      setError(835        code === "NO_ENTRIES" ? "No entries found in this file." :836        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :837        "Import failed. Check the file format and try again."838      );839    } finally {840      setImporting(false);841    }842  }843844  return (845    <div class="peach-rt-csv">846      <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>847        {importing() ? "importing…" : "open csv file"}848        <input849          type="file"850          accept=".csv,.json,.peach,application/x-peach"851          style="display:none"852          onChange={handleFile}853          disabled={importing()}854        />855      </label>856      <p class="peach-rt-csv-sources">857        Imports from Bitwarden, 1Password, Proton Pass,<br />858        Chrome, Safari, and Edge.859      </p>860      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>861      <Show when={error()}>862        <p class="peach-rt-error">{error()}</p>863      </Show>864    </div>865  );866}867868// ── From Phone tab ─────────────────────────────────────────────────────────869870type PhoneState = "waiting" | "connected" | "trust" | "error";871872function RestoreFromPhone(props: { onSuccess: () => void | Promise<void> }) {873  const [state, setState] = createSignal<PhoneState>("waiting");874  const [code, setCode] = createSignal<string | null>(null);875  const [qrUrl, setQrUrl] = createSignal<string | null>(null);876  const [expired, setExpired] = createSignal(false);877  const [deviceName, setDeviceName] = createSignal<string | null>(null);878  const [accepting, setAccepting] = createSignal(false);879  const [serverError, setServerError] = createSignal<string | null>(null);880  let expireTimer: ReturnType<typeof setTimeout> | null = null;881882  function clearExpire() {883    if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }884  }885886  async function startPairing() {887    setExpired(false);888    setCode(null);889    setQrUrl(null);890    setServerError(null);891    clearExpire();892    const res = await sendMessage({ type: "START_PAIRING_SERVER" });893    const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };894    if (!r.ok || !r.data) {895      setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");896      return;897    }898    const token = r.data.token;899    const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;900    setCode(formatted);901    const qrData = `peach://handoff?token=${token}`;902    try {903      const QRCode = (await import("qrcode")).default;904      const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });905      setQrUrl(url);906    } catch {907      // QR optional — code is the primary affordance908    }909    const remaining = Math.max(0, r.data.expiresAt - Date.now());910    expireTimer = setTimeout(() => {911      setCode(null);912      setQrUrl(null);913      setExpired(true);914    }, remaining);915  }916917  function handleRuntimeMsg(msg: unknown) {918    const m = msg as { type?: string; payload?: { deviceName?: string } };919    if (m?.type === "PAIRING_COMPLETED") {920      clearExpire();921      setDeviceName(m.payload?.deviceName ?? "your phone");922      setState("trust");923    }924  }925926  onMount(() => {927    void startPairing();928    // @ts-ignore — chrome runtime929    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {930      chrome.runtime.onMessage.addListener(handleRuntimeMsg);931    }932  });933934  onCleanup(() => {935    clearExpire();936    // @ts-ignore937    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {938      chrome.runtime.onMessage.removeListener(handleRuntimeMsg);939    }940    sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});941  });942943  async function handleAccept(trust: boolean) {944    setAccepting(true);945    if (trust) {946      await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});947    }948    await props.onSuccess();949  }950951  return (952    <div class="peach-rt-phone">953      <Show when={state() === "waiting"}>954        <Show when={!expired()}>955          <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>956            <p class="peach-rt-phone-code">{code()}</p>957            <Show when={qrUrl() !== null}>958              <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />959            </Show>960            <p class="peach-rt-phone-hint">961              Open Peach on your phone and tap<br />962              Settings → Send to desktop.<br />963              Same WiFi required.964            </p>965          </Show>966          <Show when={serverError()}>967            <p class="peach-rt-error">{serverError()}</p>968          </Show>969        </Show>970        <Show when={expired()}>971          <p class="peach-rt-phone-hint">Code expired.</p>972          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>973            Generate new code974          </button>975        </Show>976      </Show>977978      <Show when={state() === "connected"}>979        <p class="peach-rt-phone-hint">980          Connected to {deviceName() ?? "your phone"}.<br />981          Receiving vault…982        </p>983      </Show>984985      <Show when={state() === "trust"}>986        <p class="peach-rt-phone-hint" style="margin-bottom:10px">987          Vault received from {deviceName() ?? "your phone"}.<br />988          Trust this network for sync?989        </p>990        <button991          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"992          onClick={() => void handleAccept(true)}993          disabled={accepting()}994        >995          Yes, trust this network996        </button>997        <button998          class="peach-btn peach-btn--ghost peach-btn--sm"999          style="margin-top:6px"1000          onClick={() => void handleAccept(false)}1001          disabled={accepting()}1002        >1003          Not now1004        </button>1005      </Show>10061007      <Show when={state() === "error"}>1008        <p class="peach-rt-phone-hint">1009          Could not connect. Check that both devices<br />1010          are on the same WiFi network.1011        </p>1012        <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1013          Try again1014        </button>1015      </Show>1016    </div>1017  );1018}10191020// ── Helpers ────────────────────────────────────────────────────────────────10211022function getDisplayError(error: string | undefined, fallback: string): string {1023  if (!error) return fallback;1024  if (1025    error === "SERVICE_WORKER_UNAVAILABLE" ||1026    error.includes("Receiving end does not exist") ||1027    error.includes("message port closed")1028  ) {1029    return "background service unavailable";1030  }1031  return error;1032}