Lock.tsx

46.3 KB · raw

1import { For, Match, Show, Switch, createMemo, createSignal, onCleanup, onMount } from "solid-js";2import { sendMessage } from "../App";3import { LockRuneCanvas } from "../components/LockRuneCanvas";4import { PeachMark, PeachWordmark } from "../components/PeachMark";5import { RecoveryWordEntryView } from "./RecoveryWordEntry";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import { RuneBackground } from "../components/RuneBackground";8import { 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  onUnlock: () => void | Promise<void>;37  /** Optional escape hatch: rewind from the create-vault screen back to38   *  Onboarding. Only meaningful when the user reached create-vault from39   *  Onboarding in the first place. */40  onBackToOnboarding?: () => void;41}4243type RestoreSurface = "restore" | null;44type RecoverSurface = "words" | "new-password" | null;4546export function LockView(props: LockProps) {47  const [password, setPassword] = createSignal("");48  const [confirmPassword, setConfirmPassword] = createSignal("");49  const [error, setError] = createSignal("");50  const [loading, setLoading] = createSignal(false);51  // If the popup was reopened as a detached window for file picking, go straight52  // to the restore view so the user lands where they intended.53  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(54    new URLSearchParams(location.search).has("detached") ? "restore" : null55  );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(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.154      setShowRecoveryWords(true);155      setLoading(false);156      return;157    }158    setError(getDisplayError(result.error, "failed to create vault"));159    setLoading(false);160  }161162  async function openRestore(surface: Exclude<RestoreSurface, null>) {163    if (!props.ready || loading()) return;164    setPassword("");165    setConfirmPassword("");166    setError("");167168    // The toolbar popup closes when a native file dialog steals focus (macOS).169    // Reopen as a detached chrome.windows.create popup, which survives focus170    // loss. The detached window initialises directly into the restore view via171    // the ?detached URL param.172    const isDetached = new URLSearchParams(location.search).has("detached");173    if (!isDetached) {174      try {175        const url = chrome.runtime.getURL("src/popup/index.html") + "?detached=1";176        await chrome.windows.create({ url, type: "popup", width: 340, height: 560, focused: true });177        window.close();178        return;179      } catch {180        // Not in extension context (dev mode) — fall through to direct open.181      }182    }183184    setRestoreSurface(surface);185  }186187  // ── All surfaces routed reactively via Switch/Match ───────────────────188  return (189    <Switch>190      {/* ── Restore tab view ──────────────────────────────────────── */}191      <Match when={restoreSurface() === "restore"}>192        <RestoreTabView193          hasVault={props.hasVault}194          isPro={props.isPro ?? false}195          onBack={() => setRestoreSurface(null)}196          onUnlock={async () => {197            setRestoreSurface(null);198            await props.onUnlock();199          }}200        />201      </Match>202203      {/* ── Recovery words display after fresh vault creation ────────── */}204      <Match when={showRecoveryWords() && !props.hasVault}>205        <RecoveryWordDisplayView206          onDone={props.onUnlock}207        />208      </Match>209210      {/* ── Recovery word entry ─────────────────────────────────────── */}211      <Match when={recoverSurface() === "words"}>212        <RecoveryWordEntryView213          onCancel={() => setRecoverSurface(null)}214          onRecoveryComplete={props.onUnlock}215        />216      </Match>217218      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}219      <Match when={props.hasVault}>220        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">221          <header class="peach-screen-header lock-screen-header">222            <div class="peach-wordmark-stack">223              <PeachWordmark class="peach-wordmark-wrap" />224              <Show when={props.isPro}>225                <span class="peach-pro-mini-badge">pro</span>226              </Show>227            </div>228          </header>229230          <div class="peach-lock-hero peach-lock-hero--unlock">231            <div class="peach-lock-hero-art" aria-hidden="true">232              <PeachMark class="peach-lock-hero-mark" />233            </div>234            <span class="peach-lock-eyebrow">vault locked</span>235            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">236              unlock237              <br />238              your vault.239            </h1>240          </div>241242          <LockRuneCanvas />243244          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">245            <label class="peach-label" for="unlock-password">246              master password247            </label>248            <input249              id="unlock-password"250              class="peach-underline-input peach-unlock-input lock-password-input"251              type="password"252              placeholder="••••••••••••"253              autocomplete="current-password"254              value={password()}255              disabled={!props.ready}256              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}257              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}258              onKeyDown={(event) => {259                if (event.key !== "Enter" || !props.ready) return;260                void handleUnlock();261              }}262            />263264            <Show when={error()}>265              <p class="peach-hint peach-hint-error">{error()}</p>266            </Show>267268            <button269              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"270              onClick={() => void handleUnlock()}271              disabled={!props.ready || loading()}272            >273              {loading() ? "unlocking..." : "unlock"}274            </button>275276            <button277              class="peach-lock-recover-link"278              type="button"279              onClick={() => setRecoverSurface("words")}280            >281              forgot your password?282            </button>283          </section>284        </section>285      </Match>286287      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}288      <Match when={true}>289        <section290          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"291          data-motion-phase="enter"292        >293          <header class="peach-screen-header peach-newvault-header">294            <Show when={props.onBackToOnboarding}>295              <button296                class="peach-newvault-back"297                type="button"298                onClick={() => props.onBackToOnboarding?.()}299                aria-label="Back to onboarding"300              >301                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">302                  <path d="M7 2L3 5.5L7 9" />303                </svg>304                back305              </button>306            </Show>307            <div class="peach-wordmark-stack">308              <PeachWordmark class="peach-wordmark-wrap" />309              <Show when={props.isPro}>310                <span class="peach-pro-mini-badge">pro</span>311              </Show>312            </div>313          </header>314315          <div class="peach-newvault-mark" aria-hidden="true">316            <PeachMark class="peach-newvault-mark-art" />317          </div>318319          <div class="peach-newvault-card">320            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>321            <hr class="peach-newvault-divider" />322323            <div class="peach-newvault-field">324              <div class="peach-newvault-label-row">325                <label class="peach-label" for="master-password">326                  master password327                </label>328                <button329                  class="peach-lock-reveal-btn"330                  type="button"331                  onClick={() => setShowPassword((value) => !value)}332                  aria-label={showPassword() ? "Hide password" : "Show password"}333                >334                  <Show335                    when={showPassword()}336                    fallback={337                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">338                        <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" />339                        <circle cx="7" cy="5" r="1.75" />340                      </svg>341                    }342                  >343                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">344                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />345                      <path d="M3.5 5.5L2.5 7.5" />346                      <path d="M7 6.5V8.5" />347                      <path d="M10.5 5.5L11.5 7.5" />348                    </svg>349                  </Show>350                  {showPassword() ? "hide" : "show"}351                </button>352              </div>353              <input354                id="master-password"355                class="peach-underline-input peach-newvault-input lock-password-input"356                type={showPassword() ? "text" : "password"}357                placeholder="••••••••••••"358                autocomplete="new-password"359                value={password()}360                disabled={!props.ready}361                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}362                onKeyDown={(event) => {363                  if (event.key !== "Enter" || !props.ready) return;364                  void handleCreate();365                }}366              />367              <Show368                when={password().length > 0 && passwordStrength() !== null}369                fallback={370                  <p class="peach-newvault-hint">at least 12 characters</p>371                }372              >373                <div class="peach-password-strength">374                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>375                    <div class="peach-strength-fill"></div>376                  </div>377                  <span class="peach-strength-label">{passwordStrength()}</span>378                </div>379              </Show>380            </div>381382            <div class="peach-newvault-field">383              <label class="peach-label" for="confirm-password">384                confirm password385              </label>386              <input387                id="confirm-password"388                class="peach-underline-input peach-newvault-input"389                type={showPassword() ? "text" : "password"}390                placeholder="••••••••••••"391                autocomplete="new-password"392                value={confirmPassword()}393                disabled={!props.ready}394                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}395                onKeyDown={(event) => {396                  if (event.key === "Enter" && props.ready) void handleCreate();397                }}398              />399            </div>400401            <Show when={error()}>402              <p class="peach-hint peach-hint-error">{error()}</p>403            </Show>404405            <button406              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"407              onClick={() => void handleCreate()}408              disabled={!props.ready || loading() || !canCreate()}409              aria-live="polite"410            >411              <Show412                when={!canCreate() && createBlockerMessage()}413                fallback={loading() ? "creating..." : "create vault"}414              >415                {createBlockerMessage()}416              </Show>417            </button>418419            <p class="peach-newvault-warning">420              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>421              <span>If you forget it, your vault cannot be opened. There is no reset.</span>422            </p>423          </div>424425          <div class="peach-newvault-footer">426            <p class="peach-hint">Already have a backup?</p>427            <button428              class="peach-lock-restore-link"429              onClick={() => void openRestore("restore")}430              disabled={!props.ready}431            >432              › restore from backup433            </button>434          </div>435        </section>436      </Match>437    </Switch>438  );439}440441// ── Restore tab view ───────────────────────────────────────────────────────442443type RestoreTab = "symbols" | "mayan" | "csv" | "phone";444445function tabLabel(tab: RestoreTab): string {446  return (447    { symbols: "PeachScript", mayan: "Mayan Code", csv: "CSV", phone: "From Phone" }[tab]448  );449}450451function RestoreTabView(props: {452  hasVault: boolean;453  isPro: boolean;454  onBack: () => void;455  onUnlock: () => void | Promise<void>;456}) {457  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");458  const [showIntro, setShowIntro] = createSignal(false);459  const [pendingUnlock, setPendingUnlock] = createSignal(false);460461  const isDetachedWindow = new URLSearchParams(location.search).has("detached");462463  async function reattachPopup() {464    if (!isDetachedWindow) return;465    // Reopen the normal toolbar popup then discard the detached window.466    try { await (chrome.action as { openPopup?: () => Promise<void> }).openPopup?.(); } catch {}467    window.close();468  }469470  async function handleRestoreSuccess() {471    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);472    if (!seen) {473      setShowIntro(true);474    } else {475      await props.onUnlock();476      await reattachPopup();477    }478  }479480  async function dismissIntro() {481    await storageSet("peach_shown_restore_intro", "true").catch(() => {});482    setShowIntro(false);483    await props.onUnlock();484    await reattachPopup();485  }486487  return (488    <section489      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"490      data-motion-phase="enter"491    >492      <RuneBackground opacity={0.05} />493494      <header class="peach-screen-header lock-screen-header">495        <PeachWordmark class="peach-wordmark-wrap" />496        <button class="peach-lock-restore-link" onClick={props.onBack}>497          ‹ back498        </button>499      </header>500501      <div class="peach-newvault-card peach-restore-tabcard">502        <div class="peach-restore-tabs" role="tablist">503          <For each={["symbols", "mayan", "csv", "phone"] as RestoreTab[]}>504            {(tab) => (505              <button506                class="peach-restore-tab"507                classList={{ "is-active": activeTab() === tab }}508                onClick={() => setActiveTab(tab)}509                role="tab"510                aria-selected={activeTab() === tab}511              >512                {tabLabel(tab)}513              </button>514            )}515          </For>516        </div>517        <div class="peach-restore-content">518          <Show when={activeTab() === "symbols"}>519            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />520          </Show>521          <Show when={activeTab() === "mayan"}>522            <RestoreMayan hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />523          </Show>524          <Show when={activeTab() === "csv"}>525            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />526          </Show>527          <Show when={activeTab() === "phone"}>528            <RestoreFromPhone isPro={props.isPro} onSuccess={handleRestoreSuccess} />529          </Show>530        </div>531      </div>532533      {/* One-time intro popup after first restore */}534      <Show when={showIntro()}>535        <div class="peach-restore-intro-overlay">536          <div class="peach-restore-intro-card">537            <p class="peach-restore-intro-headline">welcome to peach.</p>538            <div class="peach-restore-intro-body">539              <p>Your vault lives only on this device. No server has a copy.</p>540              <p>The inline bubble fills passwords automatically when you visit sites.</p>541              <p>Export your vault from Settings before switching devices.</p>542            </div>543            <button544              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"545              onClick={() => void dismissIntro()}546              disabled={pendingUnlock()}547            >548              got it549            </button>550          </div>551        </div>552      </Show>553    </section>554  );555}556557// ── Mayan image decode helper ──────────────────────────────────────────────558559async function handleMayanDecode(base64: string): Promise<560  { ok: true; payload: Uint8Array } | { ok: false; reason: string }561> {562  const { decodeMayan } = await import("../../crypto/mayan");563  const img = new Image();564  const dataUrl = base64.startsWith("data:") ? base64 : `data:image/*;base64,${base64}`;565  await new Promise<void>((resolve, reject) => {566    img.onload = () => resolve();567    img.onerror = () => reject(new Error("Couldn't read the image."));568    img.src = dataUrl;569  });570  const canvas = document.createElement("canvas");571  // Cap resolution so adaptive-binarize stays bounded.572  const MAX_DIM = 1500;573  const scale = Math.min(1, MAX_DIM / Math.max(img.width, img.height));574  canvas.width = Math.round(img.width * scale);575  canvas.height = Math.round(img.height * scale);576  const ctx = canvas.getContext("2d");577  if (!ctx) return { ok: false, reason: "Couldn't open a 2D canvas." };578  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);579  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);580  const payload = decodeMayan(imageData);581  if (!payload) {582    return {583      ok: false,584      reason: "Could not read this Mayan code. Try a clearer image.",585    };586  }587  return { ok: true, payload };588}589590// ── PeachScript tab ────────────────────────────────────────────────────────591592function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {593  const [scriptInput, setScriptInput] = createSignal("");594  const [backupPass, setBackupPass] = createSignal("");595  const [working, setWorking] = createSignal(false);596  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);597  const [error, setError] = createSignal<string | null>(null);598  let canvasRef: HTMLCanvasElement | undefined;599  let animFrameId = 0;600  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];601602  const scriptValid = createMemo(() => isPeachScript(scriptInput()));603604  function stopAnim() {605    cancelAnimationFrame(animFrameId);606    animFrameId = 0;607  }608609  function startChaos(canvas: HTMLCanvasElement) {610    const ctx = canvas.getContext("2d")!;611    const W = canvas.width;612    const H = canvas.height;613    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>614      String.fromCodePoint(0xe000 + i)615    );616    if (runes.length === 0) {617      runes = Array.from({ length: 60 }, () => ({618        x: Math.random() * W,619        y: Math.random() * H,620        vx: (Math.random() - 0.5) * 3,621        vy: (Math.random() - 0.5) * 3,622        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,623        size: 14 + Math.random() * 20,624        angle: Math.random() * Math.PI * 2,625        va: (Math.random() - 0.5) * 0.1,626      }));627    }628629    function draw() {630      ctx.clearRect(0, 0, W, H);631      for (const r of runes) {632        r.x = (r.x + r.vx + W) % W;633        r.y = (r.y + r.vy + H) % H;634        r.angle += r.va;635        ctx.save();636        ctx.translate(r.x, r.y);637        ctx.rotate(r.angle);638        ctx.font = `${r.size}px PeachScript, monospace`;639        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);640        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;641        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";642        ctx.shadowBlur = 8;643        ctx.fillText(r.char, 0, 0);644        ctx.restore();645      }646      animFrameId = requestAnimationFrame(draw);647    }648    draw();649  }650651  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {652    stopAnim();653    const ctx = canvas.getContext("2d")!;654    const W = canvas.width;655    const H = canvas.height;656    const cols = 8;657    const rows = 6;658    const cellW = W / cols;659    const cellH = H / rows;660    const targets = runes.slice(0, cols * rows).map((_, i) => ({661      x: (i % cols) * cellW + cellW / 2,662      y: Math.floor(i / cols) * cellH + cellH / 2,663    }));664    const start = performance.now();665    const DURATION = 400;666667    function drawSnap(now: number) {668      const t = Math.min((now - start) / DURATION, 1);669      const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;670      ctx.clearRect(0, 0, W, H);671      runes.slice(0, targets.length).forEach((r, i) => {672        const tx = targets[i]!.x;673        const ty = targets[i]!.y;674        const cx = r.x + (tx - r.x) * ease;675        const cy = r.y + (ty - r.y) * ease;676        ctx.font = `16px PeachScript, monospace`;677        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;678        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";679        ctx.shadowBlur = 6;680        ctx.fillText(r.char, cx - 8, cy + 8);681      });682      if (t < 1) {683        animFrameId = requestAnimationFrame(drawSnap);684      } else {685        onDone();686      }687    }688    animFrameId = requestAnimationFrame(drawSnap);689  }690691  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {692    stopAnim();693    const ctx = canvas.getContext("2d")!;694    const W = canvas.width;695    const H = canvas.height;696    const start = performance.now();697    const DURATION = 900;698699    function drawBloom(now: number) {700      const t = Math.min((now - start) / DURATION, 1);701      ctx.clearRect(0, 0, W, H);702      // Runes dissolve703      const runeAlpha = 1 - Math.min(t / 0.4, 1);704      if (runeAlpha > 0) {705        for (const r of runes.slice(0, 48)) {706          ctx.font = `16px PeachScript, monospace`;707          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;708          ctx.fillText(r.char, r.x - 8, r.y + 8);709        }710      }711      // Peach mark bloom712      const markT = Math.max(0, (t - 0.2) / 0.5);713      const markScale = Math.min(markT * 1.2, 1);714      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;715      if (markAlpha > 0) {716        ctx.save();717        ctx.globalAlpha = markAlpha;718        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;719        ctx.shadowBlur = 30;720        ctx.translate(W / 2, H / 2);721        ctx.scale(markScale, markScale);722        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);723        ctx.restore();724      }725      if (t < 1) {726        animFrameId = requestAnimationFrame(drawBloom);727      } else {728        onDone();729      }730    }731    animFrameId = requestAnimationFrame(drawBloom);732  }733734  onCleanup(() => stopAnim());735736  async function handleRestore() {737    if (!scriptValid() || !backupPass().trim() || working()) return;738    setError(null);739    setWorking(true);740    setAnimating("chaos");741742    const canvas = canvasRef;743    if (canvas) startChaos(canvas);744745    try {746      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));747      const payload = await parsePayload(bytes, backupPass());748749      if (!props.hasVault) {750        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });751        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");752      }753754      const rr = await sendMessage({755        type: "RESTORE_ENTRIES",756        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },757      });758      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");759760      if (payload.license?.key) {761        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});762      }763764      setAnimating("snap");765      stopAnim();766      if (canvas) {767        snapToGrid(canvas, () => {768          setAnimating("bloom");769          bloom(canvas, async () => {770            setAnimating(null);771            await props.onSuccess();772          });773        });774      } else {775        await props.onSuccess();776      }777    } catch (err) {778      stopAnim();779      setAnimating(null);780      const code = err instanceof Error ? err.message : String(err);781      setError(782        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"783          ? "That passphrase didn't work."784          : code === "CREATE_VAULT_FAILED"785            ? "Couldn't create a new local vault."786            : "Could not read this backup.",787      );788    } finally {789      setWorking(false);790    }791  }792793  return (794    <div class="peach-rt-symbols">795      <Show when={animating() !== null}>796        <canvas797          ref={(el) => { canvasRef = el; }}798          class="peach-rt-anim-canvas"799          width={280}800          height={200}801        />802      </Show>803      <Show when={animating() === null}>804        <textarea805          class="peach-restore-symbols-textarea"806          placeholder="paste PeachScript here…"807          rows={3}808          value={scriptInput()}809          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}810        />811        <div class="peach-rt-divider">— or —</div>812        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">813          open .peach file814          <input815            type="file"816            accept=".peach,text/plain"817            style="display:none"818            onChange={async (e) => {819              const file = (e.currentTarget as HTMLInputElement).files?.[0];820              if (!file) return;821              try {822                setScriptInput(await file.text());823              } catch {824                setError("Couldn't read file.");825              } finally {826                (e.currentTarget as HTMLInputElement).value = "";827              }828            }}829          />830        </label>831        <input832          class="peach-restore-symbols-pass"833          type="password"834          placeholder="backup password"835          value={backupPass()}836          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}837          onKeyDown={(e) => {838            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {839              e.preventDefault();840              void handleRestore();841            }842          }}843        />844        <button845          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"846          onClick={() => void handleRestore()}847          disabled={!scriptValid() || !backupPass().trim() || working()}848        >849          restore vault →850        </button>851        <Show when={error()}>852          <p class="peach-rt-error">{error()}</p>853        </Show>854      </Show>855    </div>856  );857}858859// ── Mayan Code tab ─────────────────────────────────────────────────────────860861function RestoreMayan(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {862  const [decoding, setDecoding] = createSignal(false);863  const [decoded, setDecoded] = createSignal<Uint8Array | null>(null);864  const [entryLine, setEntryLine] = createSignal<string | null>(null);865  const [backupPass, setBackupPass] = createSignal("");866  const [restoring, setRestoring] = createSignal(false);867  const [error, setError] = createSignal<string | null>(null);868869  async function processFile(file: File) {870    setDecoding(true);871    setDecoded(null);872    setEntryLine(null);873    setError(null);874    try {875      const { decodeMayan } = await import("../../crypto/mayan");876      const bitmap = await createImageBitmap(file);877      const MAX_DIM = 1500;878      const scale = Math.min(1, MAX_DIM / Math.max(bitmap.width, bitmap.height));879      const canvas = document.createElement("canvas");880      canvas.width = Math.round(bitmap.width * scale);881      canvas.height = Math.round(bitmap.height * scale);882      const ctx = canvas.getContext("2d")!;883      ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);884      bitmap.close();885      const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);886      const payload = decodeMayan(imageData);887      if (!payload) { setError("Could not read this Mayan code. Try a clearer image."); return; }888      setDecoded(payload);889    } catch {890      setError("Could not read this Mayan code. Try a clearer image.");891    } finally {892      setDecoding(false);893    }894  }895896  async function handleRestore() {897    const bytes = decoded();898    if (!bytes || !backupPass().trim() || restoring()) return;899    setRestoring(true);900    setError(null);901    try {902      const payload = await parsePayload(bytes, backupPass());903      const count = payload.entries.length;904      const date = new Date(905        Math.max(...payload.entries.map((e) => e.modifiedAt ?? e.createdAt ?? 0))906      );907      const monthYear = date.getFullYear() > 2000908        ? date.toLocaleDateString("en-US", { month: "long", year: "numeric" })909        : null;910      setEntryLine(monthYear ? `${count} entries · ${monthYear}` : `${count} entries`);911912      if (!props.hasVault) {913        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });914        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");915      }916      const rr = await sendMessage({ type: "RESTORE_ENTRIES", payload: { entries: payload.entries, mode: "merge", settings: payload.settings } });917      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");918      if (payload.license?.key) {919        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});920      }921      await props.onSuccess();922    } catch (err) {923      const code = err instanceof Error ? err.message : String(err);924      setError(925        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"926          ? "Wrong backup password."927          : code === "CREATE_VAULT_FAILED"928            ? "Couldn't create a new local vault."929            : "Restore failed. The backup may be corrupted.",930      );931    } finally {932      setRestoring(false);933    }934  }935936  return (937    <div class="peach-rt-mayan">938      <Show when={decoded() === null}>939        <label class="peach-rt-upload-zone" classList={{ "is-busy": decoding() }}>940          <span class="peach-rt-upload-label">941            {decoding() ? "decoding…" : "tap to upload image"}942          </span>943          <input944            type="file"945            accept="image/*"946            style="display:none"947            onChange={(e) => {948              const file = (e.currentTarget as HTMLInputElement).files?.[0];949              if (file) void processFile(file);950              (e.currentTarget as HTMLInputElement).value = "";951            }}952            disabled={decoding()}953          />954        </label>955        <Show when={error()}>956          <p class="peach-rt-error">{error()}</p>957        </Show>958      </Show>959      <Show when={decoded() !== null}>960        <Show when={entryLine()}>961          <p class="peach-rt-entry-line">{entryLine()}</p>962        </Show>963        <input964          class="peach-restore-symbols-pass"965          type="password"966          placeholder="backup password"967          ref={(el) => queueMicrotask(() => el.focus())}968          value={backupPass()}969          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}970          onKeyDown={(e) => {971            if (e.key === "Enter" && !restoring()) { e.preventDefault(); void handleRestore(); }972          }}973        />974        <button975          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"976          onClick={() => void handleRestore()}977          disabled={!backupPass().trim() || restoring()}978        >979          {restoring() ? "restoring…" : "restore vault →"}980        </button>981        <button class="peach-btn peach-btn--ghost peach-btn--sm" style="margin-top:4px" onClick={() => { setDecoded(null); setEntryLine(null); setError(null); }}>982          ← try another image983        </button>984        <Show when={error()}>985          <p class="peach-rt-error">{error()}</p>986        </Show>987      </Show>988    </div>989  );990}991992// ── CSV tab ────────────────────────────────────────────────────────────────993994function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {995  const [importing, setImporting] = createSignal(false);996  const [error, setError] = createSignal<string | null>(null);997998  async function handleFile(e: Event) {999    const input = e.currentTarget as HTMLInputElement;1000    const file = input.files?.[0];1001    input.value = "";1002    if (!file) return;1003    setImporting(true);1004    setError(null);1005    try {1006      const text = await file.text();1007      const parsed = await parseImportSource(text);1008      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");10091010      if (props.hasVault === false) {1011        // No vault yet — this path shouldn't normally occur for CSV (no backup password1012        // means no way to create a locked vault). Create an unlocked placeholder vault.1013        // The user can set a master password from settings.1014        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });1015        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1016      }10171018      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1019      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1020      if (parsed.licenseKey) {1021        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});1022      }1023      await props.onSuccess();1024    } catch (err) {1025      const code = err instanceof Error ? err.message : String(err);1026      setError(1027        code === "NO_ENTRIES" ? "No entries found in this file." :1028        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1029        "Import failed. Check the file format and try again."1030      );1031    } finally {1032      setImporting(false);1033    }1034  }10351036  return (1037    <div class="peach-rt-csv">1038      <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>1039        {importing() ? "importing…" : "open CSV file"}1040        <input1041          type="file"1042          accept=".csv,.json,.peach,application/x-peach"1043          style="display:none"1044          onChange={handleFile}1045          disabled={importing()}1046        />1047      </label>1048      <p class="peach-rt-csv-sources">1049        Imports from Bitwarden, 1Password, Proton Pass,<br />1050        Chrome, Safari, and Edge.1051      </p>1052      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1053      <Show when={error()}>1054        <p class="peach-rt-error">{error()}</p>1055      </Show>1056    </div>1057  );1058}10591060// ── From Phone tab ─────────────────────────────────────────────────────────10611062type PhoneState = "waiting" | "connected" | "trust" | "error";10631064function RestoreFromPhone(props: { isPro: boolean; onSuccess: () => void | Promise<void> }) {1065  const [state, setState] = createSignal<PhoneState>("waiting");1066  const [code, setCode] = createSignal<string | null>(null);1067  const [qrUrl, setQrUrl] = createSignal<string | null>(null);1068  const [expired, setExpired] = createSignal(false);1069  const [deviceName, setDeviceName] = createSignal<string | null>(null);1070  const [accepting, setAccepting] = createSignal(false);1071  const [serverError, setServerError] = createSignal<string | null>(null);1072  let expireTimer: ReturnType<typeof setTimeout> | null = null;10731074  function clearExpire() {1075    if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }1076  }10771078  async function startPairing() {1079    setExpired(false);1080    setCode(null);1081    setQrUrl(null);1082    setServerError(null);1083    clearExpire();1084    const res = await sendMessage({ type: "START_PAIRING_SERVER" });1085    const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };1086    if (!r.ok || !r.data) {1087      setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");1088      return;1089    }1090    const token = r.data.token;1091    const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;1092    setCode(formatted);1093    const qrData = `peach://handoff?token=${token}`;1094    try {1095      const QRCode = (await import("qrcode")).default;1096      const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });1097      setQrUrl(url);1098    } catch {1099      // QR optional — code is the primary affordance1100    }1101    const remaining = Math.max(0, r.data.expiresAt - Date.now());1102    expireTimer = setTimeout(() => {1103      setCode(null);1104      setQrUrl(null);1105      setExpired(true);1106    }, remaining);1107  }11081109  function handleRuntimeMsg(msg: unknown) {1110    const m = msg as { type?: string; payload?: { deviceName?: string } };1111    if (m?.type === "PAIRING_COMPLETED") {1112      clearExpire();1113      setDeviceName(m.payload?.deviceName ?? "your phone");1114      setState("trust");1115    }1116  }11171118  onMount(() => {1119    if (!props.isPro) return;1120    void startPairing();1121    // @ts-ignore — chrome runtime1122    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1123      chrome.runtime.onMessage.addListener(handleRuntimeMsg);1124    }1125  });11261127  onCleanup(() => {1128    clearExpire();1129    // @ts-ignore1130    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1131      chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1132    }1133    sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1134  });11351136  async function handleAccept(trust: boolean) {1137    setAccepting(true);1138    if (trust) {1139      await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1140    }1141    await props.onSuccess();1142  }11431144  if (!props.isPro) {1145    return (1146      <div class="peach-rt-phone">1147        <p class="peach-rt-phone-gate-text">Transfer requires Peach Pro.</p>1148        <button1149          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1150          onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro" })}1151        >1152          Get Peach Pro1153        </button>1154        <button1155          class="peach-btn peach-btn--ghost peach-btn--sm"1156          style="margin-top:6px"1157          onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro#redeem" })}1158        >1159          I already have a license1160        </button>1161      </div>1162    );1163  }11641165  return (1166    <div class="peach-rt-phone">1167      <Show when={state() === "waiting"}>1168        <Show when={!expired()}>1169          <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1170            <p class="peach-rt-phone-code">{code()}</p>1171            <Show when={qrUrl() !== null}>1172              <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1173            </Show>1174            <p class="peach-rt-phone-hint">1175              Open Peach on your phone and tap<br />1176              Settings → Send to desktop.<br />1177              Same WiFi required.1178            </p>1179          </Show>1180          <Show when={serverError()}>1181            <p class="peach-rt-error">{serverError()}</p>1182          </Show>1183        </Show>1184        <Show when={expired()}>1185          <p class="peach-rt-phone-hint">Code expired.</p>1186          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1187            Generate new code1188          </button>1189        </Show>1190      </Show>11911192      <Show when={state() === "connected"}>1193        <p class="peach-rt-phone-hint">1194          Connected to {deviceName() ?? "your phone"}.<br />1195          Receiving vault…1196        </p>1197      </Show>11981199      <Show when={state() === "trust"}>1200        <p class="peach-rt-phone-hint" style="margin-bottom:10px">1201          Vault received from {deviceName() ?? "your phone"}.<br />1202          Trust this network for sync?1203        </p>1204        <button1205          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1206          onClick={() => void handleAccept(true)}1207          disabled={accepting()}1208        >1209          Yes, trust this network1210        </button>1211        <button1212          class="peach-btn peach-btn--ghost peach-btn--sm"1213          style="margin-top:6px"1214          onClick={() => void handleAccept(false)}1215          disabled={accepting()}1216        >1217          Not now1218        </button>1219      </Show>12201221      <Show when={state() === "error"}>1222        <p class="peach-rt-phone-hint">1223          Could not connect. Check that both devices<br />1224          are on the same WiFi network.1225        </p>1226        <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1227          Try again1228        </button>1229      </Show>1230    </div>1231  );1232}12331234// ── Helpers ────────────────────────────────────────────────────────────────12351236function getDisplayError(error: string | undefined, fallback: string): string {1237  if (!error) return fallback;1238  if (1239    error === "SERVICE_WORKER_UNAVAILABLE" ||1240    error.includes("Receiving end does not exist") ||1241    error.includes("message port closed")1242  ) {1243    return "background service unavailable";1244  }1245  return error;1246}