Lock.tsx

44.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 { RuneBackground } from "../components/RuneBackground";6import { ImportView } from "./Import";7import { storageGet, storageSet } from "../../shared/storage";8import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";9import { parsePayload } from "../../crypto/peachPayload";10import { parseImportSource } from "../../shared/import";1112const peachMarkImg = new Image();13peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(14  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">' +15  '<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"/>' +16  '<path d="M37 8.5C40.5 3.5 48 5 46 10.5C42 11 37 8.5 37 8.5Z" fill="#7B9060"/>' +17  '<path d="M32 5.5Q35.5 1 39 2.5" stroke="#7B9060" stroke-width="1.5" stroke-linecap="round"/>' +18  '</svg>'19)}`;2021// Lazy-load zxcvbn — it's heavy (~800 kB) and only needed during vault creation22let _zxcvbn: ((password: string) => { score: number }) | null = null;23async function getZxcvbn() {24  if (_zxcvbn) return _zxcvbn;25  const mod = await import("zxcvbn");26  _zxcvbn = mod.default;27  return _zxcvbn;28}2930interface LockProps {31  hasVault: boolean;32  ready: boolean;33  isPro?: boolean;34  onUnlock: () => void | Promise<void>;35  /** Optional escape hatch: rewind from the create-vault screen back to36   *  Onboarding. Only meaningful when the user reached create-vault from37   *  Onboarding in the first place. */38  onBackToOnboarding?: () => void;39}4041type RestoreSurface = "restore" | null;4243export function LockView(props: LockProps) {44  const [password, setPassword] = createSignal("");45  const [confirmPassword, setConfirmPassword] = createSignal("");46  const [error, setError] = createSignal("");47  const [loading, setLoading] = createSignal(false);48  // If the popup was reopened as a detached window for file picking, go straight49  // to the restore view so the user lands where they intended.50  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(51    new URLSearchParams(location.search).has("detached") ? "restore" : null52  );53  const [showPassword, setShowPassword] = createSignal(false);5455  const MIN_LENGTH = 12;56  const MIN_ZXCVBN_SCORE = 2;5758  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);5960  const canCreate = createMemo(61    () =>62      password().length >= MIN_LENGTH &&63      confirmPassword().length >= MIN_LENGTH &&64      password() === confirmPassword() &&65      strengthScore() !== null &&66      strengthScore()! >= MIN_ZXCVBN_SCORE67  );6869  const passwordStrength = createMemo(() => {70    const score = strengthScore();71    if (score === null) return null;72    if (score <= 1) return "weak";73    if (score === 2) return "fair";74    if (score === 3) return "good";75    return "strong";76  });7778  const createBlockerMessage = createMemo(() => {79    if (!password()) return null;80    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;81    if (!confirmPassword()) return "confirm your password";82    if (password() !== confirmPassword()) return "passwords don't match";83    if (strengthScore() === null) return "checking strength…";84    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";85    return null;86  });8788  // Update strength asynchronously when password changes89  createMemo(() => {90    const value = password();91    if (!value) { setStrengthScore(null); return; }92    getZxcvbn().then((zxcvbn) => {93      if (password() === value) setStrengthScore(zxcvbn(value).score);94    }).catch(() => {95      // zxcvbn unavailable — fall back to length-only heuristic96      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);97    });98  });99100  async function handleUnlock() {101    if (!password().trim()) return;102    setLoading(true);103    setError("");104    const response = await sendMessage({105      type: "UNLOCK",106      payload: { password: password() },107    });108    const result = response as { ok: boolean; error?: string };109    if (result.ok) {110      await props.onUnlock();111      setLoading(false);112      return;113    }114    setError(getDisplayError(result.error, "incorrect password"));115    setPassword("");116    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;117    passwordField?.classList.add("shake");118    setTimeout(() => passwordField?.classList.remove("shake"), 300);119    setLoading(false);120  }121122  async function handleCreate() {123    if (!password().trim()) return;124    if (password().length < MIN_LENGTH) {125      setError(`password must be at least ${MIN_LENGTH} characters`);126      return;127    }128    if (password() !== confirmPassword()) {129      setError("passwords do not match");130      return;131    }132    const score = strengthScore();133    if (score === null || score < MIN_ZXCVBN_SCORE) {134      setError("password is too weak — try a longer or more varied passphrase");135      return;136    }137    setLoading(true);138    setError("");139    const response = await sendMessage({140      type: "CREATE_VAULT",141      payload: { password: password() },142    });143    const result = response as { ok: boolean; error?: string };144    if (result.ok) {145      await props.onUnlock();146      setLoading(false);147      return;148    }149    setError(getDisplayError(result.error, "failed to create vault"));150    setLoading(false);151  }152153  async function openRestore(surface: Exclude<RestoreSurface, null>) {154    if (!props.ready || loading()) return;155    setPassword("");156    setConfirmPassword("");157    setError("");158159    // The toolbar popup closes when a native file dialog steals focus (macOS).160    // Reopen as a detached chrome.windows.create popup, which survives focus161    // loss. The detached window initialises directly into the restore view via162    // the ?detached URL param.163    const isDetached = new URLSearchParams(location.search).has("detached");164    if (!isDetached) {165      try {166        const url = chrome.runtime.getURL("src/popup/index.html") + "?detached=1";167        await chrome.windows.create({ url, type: "popup", width: 340, height: 560, focused: true });168        window.close();169        return;170      } catch {171        // Not in extension context (dev mode) — fall through to direct open.172      }173    }174175    setRestoreSurface(surface);176  }177178  // ── All surfaces routed reactively via Switch/Match ───────────────────179  return (180    <Switch>181      {/* ── Restore tab view ──────────────────────────────────────── */}182      <Match when={restoreSurface() === "restore"}>183        <RestoreTabView184          hasVault={props.hasVault}185          isPro={props.isPro ?? false}186          onBack={() => setRestoreSurface(null)}187          onUnlock={async () => {188            setRestoreSurface(null);189            await props.onUnlock();190          }}191        />192      </Match>193194      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}195      <Match when={props.hasVault}>196        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">197          <header class="peach-screen-header lock-screen-header">198            <div class="peach-wordmark-stack">199              <PeachWordmark class="peach-wordmark-wrap" />200              <Show when={props.isPro}>201                <span class="peach-pro-mini-badge">pro</span>202              </Show>203            </div>204          </header>205206          <div class="peach-lock-hero peach-lock-hero--unlock">207            <div class="peach-lock-hero-art" aria-hidden="true">208              <PeachMark class="peach-lock-hero-mark" />209            </div>210            <span class="peach-lock-eyebrow">vault locked</span>211            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">212              unlock213              <br />214              your vault.215            </h1>216          </div>217218          <LockRuneCanvas />219220          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">221            <label class="peach-label" for="unlock-password">222              master password223            </label>224            <input225              id="unlock-password"226              class="peach-underline-input peach-unlock-input lock-password-input"227              type="password"228              placeholder="••••••••••••"229              autocomplete="current-password"230              value={password()}231              disabled={!props.ready}232              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}233              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}234              onKeyDown={(event) => {235                if (event.key !== "Enter" || !props.ready) return;236                void handleUnlock();237              }}238            />239240            <Show when={error()}>241              <p class="peach-hint peach-hint-error">{error()}</p>242            </Show>243244            <button245              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"246              onClick={() => void handleUnlock()}247              disabled={!props.ready || loading()}248            >249              {loading() ? "unlocking..." : "unlock"}250            </button>251          </section>252        </section>253      </Match>254255      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}256      <Match when={true}>257        <section258          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"259          data-motion-phase="enter"260        >261          <header class="peach-screen-header peach-newvault-header">262            <Show when={props.onBackToOnboarding}>263              <button264                class="peach-newvault-back"265                type="button"266                onClick={() => props.onBackToOnboarding?.()}267                aria-label="Back to onboarding"268              >269                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">270                  <path d="M7 2L3 5.5L7 9" />271                </svg>272                back273              </button>274            </Show>275            <div class="peach-wordmark-stack">276              <PeachWordmark class="peach-wordmark-wrap" />277              <Show when={props.isPro}>278                <span class="peach-pro-mini-badge">pro</span>279              </Show>280            </div>281          </header>282283          <div class="peach-newvault-mark" aria-hidden="true">284            <PeachMark class="peach-newvault-mark-art" />285          </div>286287          <div class="peach-newvault-card">288            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>289            <hr class="peach-newvault-divider" />290291            <div class="peach-newvault-field">292              <div class="peach-newvault-label-row">293                <label class="peach-label" for="master-password">294                  master password295                </label>296                <button297                  class="peach-lock-reveal-btn"298                  type="button"299                  onClick={() => setShowPassword((value) => !value)}300                  aria-label={showPassword() ? "Hide password" : "Show password"}301                >302                  <Show303                    when={showPassword()}304                    fallback={305                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">306                        <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" />307                        <circle cx="7" cy="5" r="1.75" />308                      </svg>309                    }310                  >311                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">312                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />313                      <path d="M3.5 5.5L2.5 7.5" />314                      <path d="M7 6.5V8.5" />315                      <path d="M10.5 5.5L11.5 7.5" />316                    </svg>317                  </Show>318                  {showPassword() ? "hide" : "show"}319                </button>320              </div>321              <input322                id="master-password"323                class="peach-underline-input peach-newvault-input lock-password-input"324                type={showPassword() ? "text" : "password"}325                placeholder="••••••••••••"326                autocomplete="new-password"327                value={password()}328                disabled={!props.ready}329                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}330                onKeyDown={(event) => {331                  if (event.key !== "Enter" || !props.ready) return;332                  void handleCreate();333                }}334              />335              <Show336                when={password().length > 0 && passwordStrength() !== null}337                fallback={338                  <p class="peach-newvault-hint">at least 12 characters</p>339                }340              >341                <div class="peach-password-strength">342                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>343                    <div class="peach-strength-fill"></div>344                  </div>345                  <span class="peach-strength-label">{passwordStrength()}</span>346                </div>347              </Show>348            </div>349350            <div class="peach-newvault-field">351              <label class="peach-label" for="confirm-password">352                confirm password353              </label>354              <input355                id="confirm-password"356                class="peach-underline-input peach-newvault-input"357                type={showPassword() ? "text" : "password"}358                placeholder="••••••••••••"359                autocomplete="new-password"360                value={confirmPassword()}361                disabled={!props.ready}362                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}363                onKeyDown={(event) => {364                  if (event.key === "Enter" && props.ready) void handleCreate();365                }}366              />367            </div>368369            <Show when={error()}>370              <p class="peach-hint peach-hint-error">{error()}</p>371            </Show>372373            <button374              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"375              onClick={() => void handleCreate()}376              disabled={!props.ready || loading() || !canCreate()}377              aria-live="polite"378            >379              <Show380                when={!canCreate() && createBlockerMessage()}381                fallback={loading() ? "creating..." : "create vault"}382              >383                {createBlockerMessage()}384              </Show>385            </button>386387            <p class="peach-newvault-warning">388              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>389              <span>If you forget it, your vault cannot be opened. There is no reset.</span>390            </p>391          </div>392393          <div class="peach-newvault-footer">394            <p class="peach-hint">Already have a backup?</p>395            <button396              class="peach-lock-restore-link"397              onClick={() => void openRestore("restore")}398              disabled={!props.ready}399            >400              › restore from backup401            </button>402          </div>403        </section>404      </Match>405    </Switch>406  );407}408409// ── Restore tab view ───────────────────────────────────────────────────────410411type RestoreTab = "symbols" | "mayan" | "csv" | "phone";412413function tabLabel(tab: RestoreTab): string {414  return (415    { symbols: "PeachScript", mayan: "Mayan Code", csv: "CSV", phone: "From Phone" }[tab]416  );417}418419function RestoreTabView(props: {420  hasVault: boolean;421  isPro: boolean;422  onBack: () => void;423  onUnlock: () => void | Promise<void>;424}) {425  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");426  const [showIntro, setShowIntro] = createSignal(false);427  const [pendingUnlock, setPendingUnlock] = createSignal(false);428429  const isDetachedWindow = new URLSearchParams(location.search).has("detached");430431  async function reattachPopup() {432    if (!isDetachedWindow) return;433    // Reopen the normal toolbar popup then discard the detached window.434    try { await (chrome.action as { openPopup?: () => Promise<void> }).openPopup?.(); } catch {}435    window.close();436  }437438  async function handleRestoreSuccess() {439    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);440    if (!seen) {441      setShowIntro(true);442    } else {443      await props.onUnlock();444      await reattachPopup();445    }446  }447448  async function dismissIntro() {449    await storageSet("peach_shown_restore_intro", "true").catch(() => {});450    setShowIntro(false);451    await props.onUnlock();452    await reattachPopup();453  }454455  return (456    <section457      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"458      data-motion-phase="enter"459    >460      <RuneBackground opacity={0.05} />461462      <header class="peach-screen-header lock-screen-header">463        <PeachWordmark class="peach-wordmark-wrap" />464        <button class="peach-lock-restore-link" onClick={props.onBack}>465          ‹ back466        </button>467      </header>468469      <div class="peach-newvault-card peach-restore-tabcard">470        <div class="peach-restore-tabs" role="tablist">471          <For each={["symbols", "mayan", "csv", "phone"] as RestoreTab[]}>472            {(tab) => (473              <button474                class="peach-restore-tab"475                classList={{ "is-active": activeTab() === tab }}476                onClick={() => setActiveTab(tab)}477                role="tab"478                aria-selected={activeTab() === tab}479              >480                {tabLabel(tab)}481              </button>482            )}483          </For>484        </div>485        <div class="peach-restore-content">486          <Show when={activeTab() === "symbols"}>487            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />488          </Show>489          <Show when={activeTab() === "mayan"}>490            <RestoreMayan hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />491          </Show>492          <Show when={activeTab() === "csv"}>493            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />494          </Show>495          <Show when={activeTab() === "phone"}>496            <RestoreFromPhone isPro={props.isPro} onSuccess={handleRestoreSuccess} />497          </Show>498        </div>499      </div>500501      {/* One-time intro popup after first restore */}502      <Show when={showIntro()}>503        <div class="peach-restore-intro-overlay">504          <div class="peach-restore-intro-card">505            <p class="peach-restore-intro-headline">welcome to peach.</p>506            <div class="peach-restore-intro-body">507              <p>Your vault lives only on this device. No server has a copy.</p>508              <p>The inline bubble fills passwords automatically when you visit sites.</p>509              <p>Export your vault from Settings before switching devices.</p>510            </div>511            <button512              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"513              onClick={() => void dismissIntro()}514              disabled={pendingUnlock()}515            >516              got it517            </button>518          </div>519        </div>520      </Show>521    </section>522  );523}524525// ── Mayan image decode helper ──────────────────────────────────────────────526527async function handleMayanDecode(base64: string): Promise<528  { ok: true; payload: Uint8Array } | { ok: false; reason: string }529> {530  const { decodeMayan } = await import("../../crypto/mayan");531  const img = new Image();532  const dataUrl = base64.startsWith("data:") ? base64 : `data:image/*;base64,${base64}`;533  await new Promise<void>((resolve, reject) => {534    img.onload = () => resolve();535    img.onerror = () => reject(new Error("Couldn't read the image."));536    img.src = dataUrl;537  });538  const canvas = document.createElement("canvas");539  // Cap resolution so adaptive-binarize stays bounded.540  const MAX_DIM = 1500;541  const scale = Math.min(1, MAX_DIM / Math.max(img.width, img.height));542  canvas.width = Math.round(img.width * scale);543  canvas.height = Math.round(img.height * scale);544  const ctx = canvas.getContext("2d");545  if (!ctx) return { ok: false, reason: "Couldn't open a 2D canvas." };546  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);547  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);548  const payload = decodeMayan(imageData);549  if (!payload) {550    return {551      ok: false,552      reason: "Could not read this Mayan code. Try a clearer image.",553    };554  }555  return { ok: true, payload };556}557558// ── PeachScript tab ────────────────────────────────────────────────────────559560function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {561  const [scriptInput, setScriptInput] = createSignal("");562  const [backupPass, setBackupPass] = createSignal("");563  const [working, setWorking] = createSignal(false);564  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);565  const [error, setError] = createSignal<string | null>(null);566  let canvasRef: HTMLCanvasElement | undefined;567  let animFrameId = 0;568  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];569570  const scriptValid = createMemo(() => isPeachScript(scriptInput()));571572  function stopAnim() {573    cancelAnimationFrame(animFrameId);574    animFrameId = 0;575  }576577  function startChaos(canvas: HTMLCanvasElement) {578    const ctx = canvas.getContext("2d")!;579    const W = canvas.width;580    const H = canvas.height;581    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>582      String.fromCodePoint(0xe000 + i)583    );584    if (runes.length === 0) {585      runes = Array.from({ length: 60 }, () => ({586        x: Math.random() * W,587        y: Math.random() * H,588        vx: (Math.random() - 0.5) * 3,589        vy: (Math.random() - 0.5) * 3,590        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,591        size: 14 + Math.random() * 20,592        angle: Math.random() * Math.PI * 2,593        va: (Math.random() - 0.5) * 0.1,594      }));595    }596597    function draw() {598      ctx.clearRect(0, 0, W, H);599      for (const r of runes) {600        r.x = (r.x + r.vx + W) % W;601        r.y = (r.y + r.vy + H) % H;602        r.angle += r.va;603        ctx.save();604        ctx.translate(r.x, r.y);605        ctx.rotate(r.angle);606        ctx.font = `${r.size}px PeachScript, monospace`;607        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);608        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;609        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";610        ctx.shadowBlur = 8;611        ctx.fillText(r.char, 0, 0);612        ctx.restore();613      }614      animFrameId = requestAnimationFrame(draw);615    }616    draw();617  }618619  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {620    stopAnim();621    const ctx = canvas.getContext("2d")!;622    const W = canvas.width;623    const H = canvas.height;624    const cols = 8;625    const rows = 6;626    const cellW = W / cols;627    const cellH = H / rows;628    const targets = runes.slice(0, cols * rows).map((_, i) => ({629      x: (i % cols) * cellW + cellW / 2,630      y: Math.floor(i / cols) * cellH + cellH / 2,631    }));632    const start = performance.now();633    const DURATION = 400;634635    function drawSnap(now: number) {636      const t = Math.min((now - start) / DURATION, 1);637      const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;638      ctx.clearRect(0, 0, W, H);639      runes.slice(0, targets.length).forEach((r, i) => {640        const tx = targets[i]!.x;641        const ty = targets[i]!.y;642        const cx = r.x + (tx - r.x) * ease;643        const cy = r.y + (ty - r.y) * ease;644        ctx.font = `16px PeachScript, monospace`;645        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;646        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";647        ctx.shadowBlur = 6;648        ctx.fillText(r.char, cx - 8, cy + 8);649      });650      if (t < 1) {651        animFrameId = requestAnimationFrame(drawSnap);652      } else {653        onDone();654      }655    }656    animFrameId = requestAnimationFrame(drawSnap);657  }658659  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {660    stopAnim();661    const ctx = canvas.getContext("2d")!;662    const W = canvas.width;663    const H = canvas.height;664    const start = performance.now();665    const DURATION = 900;666667    function drawBloom(now: number) {668      const t = Math.min((now - start) / DURATION, 1);669      ctx.clearRect(0, 0, W, H);670      // Runes dissolve671      const runeAlpha = 1 - Math.min(t / 0.4, 1);672      if (runeAlpha > 0) {673        for (const r of runes.slice(0, 48)) {674          ctx.font = `16px PeachScript, monospace`;675          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;676          ctx.fillText(r.char, r.x - 8, r.y + 8);677        }678      }679      // Peach mark bloom680      const markT = Math.max(0, (t - 0.2) / 0.5);681      const markScale = Math.min(markT * 1.2, 1);682      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;683      if (markAlpha > 0) {684        ctx.save();685        ctx.globalAlpha = markAlpha;686        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;687        ctx.shadowBlur = 30;688        ctx.translate(W / 2, H / 2);689        ctx.scale(markScale, markScale);690        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);691        ctx.restore();692      }693      if (t < 1) {694        animFrameId = requestAnimationFrame(drawBloom);695      } else {696        onDone();697      }698    }699    animFrameId = requestAnimationFrame(drawBloom);700  }701702  onCleanup(() => stopAnim());703704  async function handleRestore() {705    if (!scriptValid() || !backupPass().trim() || working()) return;706    setError(null);707    setWorking(true);708    setAnimating("chaos");709710    const canvas = canvasRef;711    if (canvas) startChaos(canvas);712713    try {714      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));715      const payload = await parsePayload(bytes, backupPass());716717      if (!props.hasVault) {718        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });719        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");720      }721722      const rr = await sendMessage({723        type: "RESTORE_ENTRIES",724        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },725      });726      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");727728      if (payload.license?.key) {729        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});730      }731732      setAnimating("snap");733      stopAnim();734      if (canvas) {735        snapToGrid(canvas, () => {736          setAnimating("bloom");737          bloom(canvas, async () => {738            setAnimating(null);739            await props.onSuccess();740          });741        });742      } else {743        await props.onSuccess();744      }745    } catch (err) {746      stopAnim();747      setAnimating(null);748      const code = err instanceof Error ? err.message : String(err);749      setError(750        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"751          ? "That passphrase didn't work."752          : code === "CREATE_VAULT_FAILED"753            ? "Couldn't create a new local vault."754            : "Could not read this backup.",755      );756    } finally {757      setWorking(false);758    }759  }760761  return (762    <div class="peach-rt-symbols">763      <Show when={animating() !== null}>764        <canvas765          ref={(el) => { canvasRef = el; }}766          class="peach-rt-anim-canvas"767          width={280}768          height={200}769        />770      </Show>771      <Show when={animating() === null}>772        <textarea773          class="peach-restore-symbols-textarea"774          placeholder="paste PeachScript here…"775          rows={3}776          value={scriptInput()}777          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}778        />779        <div class="peach-rt-divider">— or —</div>780        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">781          open .peach file782          <input783            type="file"784            accept=".peach,text/plain"785            style="display:none"786            onChange={async (e) => {787              const file = (e.currentTarget as HTMLInputElement).files?.[0];788              if (!file) return;789              try {790                setScriptInput(await file.text());791              } catch {792                setError("Couldn't read file.");793              } finally {794                (e.currentTarget as HTMLInputElement).value = "";795              }796            }}797          />798        </label>799        <input800          class="peach-restore-symbols-pass"801          type="password"802          placeholder="backup password"803          value={backupPass()}804          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}805          onKeyDown={(e) => {806            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {807              e.preventDefault();808              void handleRestore();809            }810          }}811        />812        <button813          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"814          onClick={() => void handleRestore()}815          disabled={!scriptValid() || !backupPass().trim() || working()}816        >817          restore vault →818        </button>819        <Show when={error()}>820          <p class="peach-rt-error">{error()}</p>821        </Show>822      </Show>823    </div>824  );825}826827// ── Mayan Code tab ─────────────────────────────────────────────────────────828829function RestoreMayan(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {830  const [decoding, setDecoding] = createSignal(false);831  const [decoded, setDecoded] = createSignal<Uint8Array | null>(null);832  const [entryLine, setEntryLine] = createSignal<string | null>(null);833  const [backupPass, setBackupPass] = createSignal("");834  const [restoring, setRestoring] = createSignal(false);835  const [error, setError] = createSignal<string | null>(null);836837  async function processFile(file: File) {838    setDecoding(true);839    setDecoded(null);840    setEntryLine(null);841    setError(null);842    try {843      const { decodeMayan } = await import("../../crypto/mayan");844      const bitmap = await createImageBitmap(file);845      const MAX_DIM = 1500;846      const scale = Math.min(1, MAX_DIM / Math.max(bitmap.width, bitmap.height));847      const canvas = document.createElement("canvas");848      canvas.width = Math.round(bitmap.width * scale);849      canvas.height = Math.round(bitmap.height * scale);850      const ctx = canvas.getContext("2d")!;851      ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);852      bitmap.close();853      const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);854      const payload = decodeMayan(imageData);855      if (!payload) { setError("Could not read this Mayan code. Try a clearer image."); return; }856      setDecoded(payload);857    } catch {858      setError("Could not read this Mayan code. Try a clearer image.");859    } finally {860      setDecoding(false);861    }862  }863864  async function handleRestore() {865    const bytes = decoded();866    if (!bytes || !backupPass().trim() || restoring()) return;867    setRestoring(true);868    setError(null);869    try {870      const payload = await parsePayload(bytes, backupPass());871      const count = payload.entries.length;872      const date = new Date(873        Math.max(...payload.entries.map((e) => e.modifiedAt ?? e.createdAt ?? 0))874      );875      const monthYear = date.getFullYear() > 2000876        ? date.toLocaleDateString("en-US", { month: "long", year: "numeric" })877        : null;878      setEntryLine(monthYear ? `${count} entries · ${monthYear}` : `${count} entries`);879880      if (!props.hasVault) {881        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });882        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");883      }884      const rr = await sendMessage({ type: "RESTORE_ENTRIES", payload: { entries: payload.entries, mode: "merge", settings: payload.settings } });885      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");886      if (payload.license?.key) {887        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});888      }889      await props.onSuccess();890    } catch (err) {891      const code = err instanceof Error ? err.message : String(err);892      setError(893        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"894          ? "Wrong backup password."895          : code === "CREATE_VAULT_FAILED"896            ? "Couldn't create a new local vault."897            : "Restore failed. The backup may be corrupted.",898      );899    } finally {900      setRestoring(false);901    }902  }903904  return (905    <div class="peach-rt-mayan">906      <Show when={decoded() === null}>907        <label class="peach-rt-upload-zone" classList={{ "is-busy": decoding() }}>908          <span class="peach-rt-upload-label">909            {decoding() ? "decoding…" : "tap to upload image"}910          </span>911          <input912            type="file"913            accept="image/*"914            style="display:none"915            onChange={(e) => {916              const file = (e.currentTarget as HTMLInputElement).files?.[0];917              if (file) void processFile(file);918              (e.currentTarget as HTMLInputElement).value = "";919            }}920            disabled={decoding()}921          />922        </label>923        <Show when={error()}>924          <p class="peach-rt-error">{error()}</p>925        </Show>926      </Show>927      <Show when={decoded() !== null}>928        <Show when={entryLine()}>929          <p class="peach-rt-entry-line">{entryLine()}</p>930        </Show>931        <input932          class="peach-restore-symbols-pass"933          type="password"934          placeholder="backup password"935          ref={(el) => queueMicrotask(() => el.focus())}936          value={backupPass()}937          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}938          onKeyDown={(e) => {939            if (e.key === "Enter" && !restoring()) { e.preventDefault(); void handleRestore(); }940          }}941        />942        <button943          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"944          onClick={() => void handleRestore()}945          disabled={!backupPass().trim() || restoring()}946        >947          {restoring() ? "restoring…" : "restore vault →"}948        </button>949        <button class="peach-btn peach-btn--ghost peach-btn--sm" style="margin-top:4px" onClick={() => { setDecoded(null); setEntryLine(null); setError(null); }}>950          ← try another image951        </button>952        <Show when={error()}>953          <p class="peach-rt-error">{error()}</p>954        </Show>955      </Show>956    </div>957  );958}959960// ── CSV tab ────────────────────────────────────────────────────────────────961962function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {963  const [importing, setImporting] = createSignal(false);964  const [error, setError] = createSignal<string | null>(null);965966  async function handleFile(e: Event) {967    const input = e.currentTarget as HTMLInputElement;968    const file = input.files?.[0];969    input.value = "";970    if (!file) return;971    setImporting(true);972    setError(null);973    try {974      const text = await file.text();975      const parsed = await parseImportSource(text);976      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");977978      if (props.hasVault === false) {979        // No vault yet — this path shouldn't normally occur for CSV (no backup password980        // means no way to create a locked vault). Create an unlocked placeholder vault.981        // The user can set a master password from settings.982        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });983        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");984      }985986      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });987      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");988      if (parsed.licenseKey) {989        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});990      }991      await props.onSuccess();992    } catch (err) {993      const code = err instanceof Error ? err.message : String(err);994      setError(995        code === "NO_ENTRIES" ? "No entries found in this file." :996        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :997        "Import failed. Check the file format and try again."998      );999    } finally {1000      setImporting(false);1001    }1002  }10031004  return (1005    <div class="peach-rt-csv">1006      <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>1007        {importing() ? "importing…" : "open CSV file"}1008        <input1009          type="file"1010          accept=".csv,.json,.peach,application/x-peach"1011          style="display:none"1012          onChange={handleFile}1013          disabled={importing()}1014        />1015      </label>1016      <p class="peach-rt-csv-sources">1017        Imports from Bitwarden, 1Password, Proton Pass,<br />1018        Chrome, Safari, and Edge.1019      </p>1020      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1021      <Show when={error()}>1022        <p class="peach-rt-error">{error()}</p>1023      </Show>1024    </div>1025  );1026}10271028// ── From Phone tab ─────────────────────────────────────────────────────────10291030type PhoneState = "waiting" | "connected" | "trust" | "error";10311032function RestoreFromPhone(props: { isPro: boolean; onSuccess: () => void | Promise<void> }) {1033  const [state, setState] = createSignal<PhoneState>("waiting");1034  const [code, setCode] = createSignal<string | null>(null);1035  const [qrUrl, setQrUrl] = createSignal<string | null>(null);1036  const [expired, setExpired] = createSignal(false);1037  const [deviceName, setDeviceName] = createSignal<string | null>(null);1038  const [accepting, setAccepting] = createSignal(false);1039  const [serverError, setServerError] = createSignal<string | null>(null);1040  let expireTimer: ReturnType<typeof setTimeout> | null = null;10411042  function clearExpire() {1043    if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }1044  }10451046  async function startPairing() {1047    setExpired(false);1048    setCode(null);1049    setQrUrl(null);1050    setServerError(null);1051    clearExpire();1052    const res = await sendMessage({ type: "START_PAIRING_SERVER" });1053    const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };1054    if (!r.ok || !r.data) {1055      setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");1056      return;1057    }1058    const token = r.data.token;1059    const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;1060    setCode(formatted);1061    const qrData = `peach://handoff?token=${token}`;1062    try {1063      const QRCode = (await import("qrcode")).default;1064      const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });1065      setQrUrl(url);1066    } catch {1067      // QR optional — code is the primary affordance1068    }1069    const remaining = Math.max(0, r.data.expiresAt - Date.now());1070    expireTimer = setTimeout(() => {1071      setCode(null);1072      setQrUrl(null);1073      setExpired(true);1074    }, remaining);1075  }10761077  function handleRuntimeMsg(msg: unknown) {1078    const m = msg as { type?: string; payload?: { deviceName?: string } };1079    if (m?.type === "PAIRING_COMPLETED") {1080      clearExpire();1081      setDeviceName(m.payload?.deviceName ?? "your phone");1082      setState("trust");1083    }1084  }10851086  onMount(() => {1087    if (!props.isPro) return;1088    void startPairing();1089    // @ts-ignore — chrome runtime1090    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1091      chrome.runtime.onMessage.addListener(handleRuntimeMsg);1092    }1093  });10941095  onCleanup(() => {1096    clearExpire();1097    // @ts-ignore1098    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1099      chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1100    }1101    sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1102  });11031104  async function handleAccept(trust: boolean) {1105    setAccepting(true);1106    if (trust) {1107      await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1108    }1109    await props.onSuccess();1110  }11111112  if (!props.isPro) {1113    return (1114      <div class="peach-rt-phone">1115        <p class="peach-rt-phone-gate-text">Transfer requires Peach Pro.</p>1116        <button1117          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1118          onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro" })}1119        >1120          Get Peach Pro1121        </button>1122        <button1123          class="peach-btn peach-btn--ghost peach-btn--sm"1124          style="margin-top:6px"1125          onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro#redeem" })}1126        >1127          I already have a license1128        </button>1129      </div>1130    );1131  }11321133  return (1134    <div class="peach-rt-phone">1135      <Show when={state() === "waiting"}>1136        <Show when={!expired()}>1137          <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1138            <p class="peach-rt-phone-code">{code()}</p>1139            <Show when={qrUrl() !== null}>1140              <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1141            </Show>1142            <p class="peach-rt-phone-hint">1143              Open Peach on your phone and tap<br />1144              Settings → Send to desktop.<br />1145              Same WiFi required.1146            </p>1147          </Show>1148          <Show when={serverError()}>1149            <p class="peach-rt-error">{serverError()}</p>1150          </Show>1151        </Show>1152        <Show when={expired()}>1153          <p class="peach-rt-phone-hint">Code expired.</p>1154          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1155            Generate new code1156          </button>1157        </Show>1158      </Show>11591160      <Show when={state() === "connected"}>1161        <p class="peach-rt-phone-hint">1162          Connected to {deviceName() ?? "your phone"}.<br />1163          Receiving vault…1164        </p>1165      </Show>11661167      <Show when={state() === "trust"}>1168        <p class="peach-rt-phone-hint" style="margin-bottom:10px">1169          Vault received from {deviceName() ?? "your phone"}.<br />1170          Trust this network for sync?1171        </p>1172        <button1173          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1174          onClick={() => void handleAccept(true)}1175          disabled={accepting()}1176        >1177          Yes, trust this network1178        </button>1179        <button1180          class="peach-btn peach-btn--ghost peach-btn--sm"1181          style="margin-top:6px"1182          onClick={() => void handleAccept(false)}1183          disabled={accepting()}1184        >1185          Not now1186        </button>1187      </Show>11881189      <Show when={state() === "error"}>1190        <p class="peach-rt-phone-hint">1191          Could not connect. Check that both devices<br />1192          are on the same WiFi network.1193        </p>1194        <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1195          Try again1196        </button>1197      </Show>1198    </div>1199  );1200}12011202// ── Helpers ────────────────────────────────────────────────────────────────12031204function getDisplayError(error: string | undefined, fallback: string): string {1205  if (!error) return fallback;1206  if (1207    error === "SERVICE_WORKER_UNAVAILABLE" ||1208    error.includes("Receiving end does not exist") ||1209    error.includes("message port closed")1210  ) {1211    return "background service unavailable";1212  }1213  return error;1214}