Lock.tsx

47.6 KB · raw

1import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js";2import { Capacitor } from "@capacitor/core";34import { sendMessage } from "../App";5import { LockRuneCanvas } from "../components/LockRuneCanvas";6import { PeachMark, PeachWordmark } from "../components/PeachMark";7import { RuneBackground } from "../components/RuneBackground";8import { ImportView } from "./Import";9import { biometricUnlock, biometricAvailable } from "../../shared/biometric";10import { storageGet, storageSet } from "../../shared/storage";11import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";12import { parsePayload } from "../../crypto/peachPayload";13import { parseImportSource } from "../../shared/import";1415const peachMarkImg = new Image();16peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(17  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">' +18  '<path d="M32 54C16 54 8 44 8 31C8 18 17 10 27 10C28 7 30 5.5 32 5.5C34 5.5 36 7 37 10C47 10 56 18 56 31C56 44 48 54 32 54Z" fill="#D96830"/>' +19  '<path d="M37 8.5C40.5 3.5 48 5 46 10.5C42 11 37 8.5 37 8.5Z" fill="#7B9060"/>' +20  '<path d="M32 5.5Q35.5 1 39 2.5" stroke="#7B9060" stroke-width="1.5" stroke-linecap="round"/>' +21  '</svg>'22)}`;2324// Lazy-load zxcvbn — it's heavy (~800 kB) and only needed during vault creation25let _zxcvbn: ((password: string) => { score: number }) | null = null;26async function getZxcvbn() {27  if (_zxcvbn) return _zxcvbn;28  const mod = await import("zxcvbn");29  _zxcvbn = mod.default;30  return _zxcvbn;31}3233interface LockProps {34  hasVault: boolean;35  ready: boolean;36  isPro?: boolean;37  onUnlock: () => void | Promise<void>;38  /** Optional escape hatch: rewind from the create-vault screen back to39   *  Onboarding. Only meaningful when the user reached create-vault from40   *  Onboarding in the first place. */41  onBackToOnboarding?: () => void;42}4344type RestoreSurface = "restore" | 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);5758  const MIN_LENGTH = 12;59  const MIN_ZXCVBN_SCORE = 2;6061  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);6263  const canCreate = createMemo(64    () =>65      password().length >= MIN_LENGTH &&66      confirmPassword().length >= MIN_LENGTH &&67      password() === confirmPassword() &&68      strengthScore() !== null &&69      strengthScore()! >= MIN_ZXCVBN_SCORE70  );7172  const passwordStrength = createMemo(() => {73    const score = strengthScore();74    if (score === null) return null;75    if (score <= 1) return "weak";76    if (score === 2) return "fair";77    if (score === 3) return "good";78    return "strong";79  });8081  const createBlockerMessage = createMemo(() => {82    if (!password()) return null;83    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;84    if (!confirmPassword()) return "confirm your password";85    if (password() !== confirmPassword()) return "passwords don't match";86    if (strengthScore() === null) return "checking strength…";87    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";88    return null;89  });9091  // Update strength asynchronously when password changes92  createMemo(() => {93    const value = password();94    if (!value) { setStrengthScore(null); return; }95    getZxcvbn().then((zxcvbn) => {96      if (password() === value) setStrengthScore(zxcvbn(value).score);97    }).catch(() => {98      // zxcvbn unavailable — fall back to length-only heuristic99      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);100    });101  });102103  async function handleUnlock() {104    if (!password().trim()) return;105    setLoading(true);106    setError("");107    const response = await sendMessage({108      type: "UNLOCK",109      payload: { password: password() },110    });111    const result = response as { ok: boolean; error?: string };112    if (result.ok) {113      await props.onUnlock();114      setLoading(false);115      return;116    }117    setError(getDisplayError(result.error, "incorrect password"));118    setPassword("");119    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;120    passwordField?.classList.add("shake");121    setTimeout(() => passwordField?.classList.remove("shake"), 300);122    setLoading(false);123  }124125  async function handleCreate() {126    if (!password().trim()) return;127    if (password().length < MIN_LENGTH) {128      setError(`password must be at least ${MIN_LENGTH} characters`);129      return;130    }131    if (password() !== confirmPassword()) {132      setError("passwords do not match");133      return;134    }135    const score = strengthScore();136    if (score === null || score < MIN_ZXCVBN_SCORE) {137      setError("password is too weak — try a longer or more varied passphrase");138      return;139    }140    setLoading(true);141    setError("");142    const response = await sendMessage({143      type: "CREATE_VAULT",144      payload: { password: password() },145    });146    const result = response as { ok: boolean; error?: string };147    if (result.ok) {148      await props.onUnlock();149      setLoading(false);150      return;151    }152    setError(getDisplayError(result.error, "failed to create vault"));153    setLoading(false);154  }155156  async function openRestore(surface: Exclude<RestoreSurface, null>) {157    if (!props.ready || loading()) return;158    setPassword("");159    setConfirmPassword("");160    setError("");161162    // The toolbar popup closes when a native file dialog steals focus (macOS).163    // Reopen as a detached chrome.windows.create popup, which survives focus164    // loss. The detached window initialises directly into the restore view via165    // the ?detached URL param.166    const isDetached = new URLSearchParams(location.search).has("detached");167    if (!isDetached) {168      try {169        const url = chrome.runtime.getURL("src/popup/index.html") + "?detached=1";170        await chrome.windows.create({ url, type: "popup", width: 340, height: 560, focused: true });171        window.close();172        return;173      } catch {174        // Not in extension context (dev mode) — fall through to direct open.175      }176    }177178    setRestoreSurface(surface);179  }180181  // ── All surfaces routed reactively via Switch/Match ───────────────────182  return (183    <Switch>184      {/* ── Restore tab view ──────────────────────────────────────── */}185      <Match when={restoreSurface() === "restore"}>186        <RestoreTabView187          hasVault={props.hasVault}188          isPro={props.isPro ?? false}189          onBack={() => setRestoreSurface(null)}190          onUnlock={async () => {191            setRestoreSurface(null);192            await props.onUnlock();193          }}194        />195      </Match>196197      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}198      <Match when={props.hasVault}>199        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">200          <header class="peach-screen-header lock-screen-header">201            <div class="peach-wordmark-stack">202              <PeachWordmark class="peach-wordmark-wrap" />203              <Show when={props.isPro}>204                <span class="peach-pro-mini-badge">pro</span>205              </Show>206            </div>207          </header>208209          <div class="peach-lock-hero peach-lock-hero--unlock">210            <div class="peach-lock-hero-art" aria-hidden="true">211              <PeachMark class="peach-lock-hero-mark" />212            </div>213            <span class="peach-lock-eyebrow">vault locked</span>214            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">215              unlock216              <br />217              your vault.218            </h1>219          </div>220221          <LockRuneCanvas />222223          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">224            <label class="peach-label" for="unlock-password">225              master password226            </label>227            <input228              id="unlock-password"229              class="peach-underline-input peach-unlock-input lock-password-input"230              type="password"231              placeholder="••••••••••••"232              autocomplete="current-password"233              value={password()}234              disabled={!props.ready}235              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}236              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}237              onKeyDown={(event) => {238                if (event.key !== "Enter" || !props.ready) return;239                void handleUnlock();240              }}241            />242243            <Show when={error()}>244              <p class="peach-hint peach-hint-error">{error()}</p>245            </Show>246247            <button248              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"249              onClick={() => void handleUnlock()}250              disabled={!props.ready || loading()}251            >252              {loading() ? "unlocking..." : "unlock"}253            </button>254255            <Show when={Capacitor.isNativePlatform()}>256              <BiometricUnlockButton ready={props.ready} loading={loading} onUnlock={props.onUnlock} />257            </Show>258          </section>259        </section>260      </Match>261262      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}263      <Match when={true}>264        <section265          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"266          data-motion-phase="enter"267        >268          <header class="peach-screen-header peach-newvault-header">269            <Show when={props.onBackToOnboarding}>270              <button271                class="peach-newvault-back"272                type="button"273                onClick={() => props.onBackToOnboarding?.()}274                aria-label="Back to onboarding"275              >276                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">277                  <path d="M7 2L3 5.5L7 9" />278                </svg>279                back280              </button>281            </Show>282            <div class="peach-wordmark-stack">283              <PeachWordmark class="peach-wordmark-wrap" />284              <Show when={props.isPro}>285                <span class="peach-pro-mini-badge">pro</span>286              </Show>287            </div>288          </header>289290          <div class="peach-newvault-mark" aria-hidden="true">291            <PeachMark class="peach-newvault-mark-art" />292          </div>293294          <div class="peach-newvault-card">295            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>296            <hr class="peach-newvault-divider" />297298            <div class="peach-newvault-field">299              <div class="peach-newvault-label-row">300                <label class="peach-label" for="master-password">301                  master password302                </label>303                <button304                  class="peach-lock-reveal-btn"305                  type="button"306                  onClick={() => setShowPassword((value) => !value)}307                  aria-label={showPassword() ? "Hide password" : "Show password"}308                >309                  <Show310                    when={showPassword()}311                    fallback={312                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">313                        <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" />314                        <circle cx="7" cy="5" r="1.75" />315                      </svg>316                    }317                  >318                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">319                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />320                      <path d="M3.5 5.5L2.5 7.5" />321                      <path d="M7 6.5V8.5" />322                      <path d="M10.5 5.5L11.5 7.5" />323                    </svg>324                  </Show>325                  {showPassword() ? "hide" : "show"}326                </button>327              </div>328              <input329                id="master-password"330                class="peach-underline-input peach-newvault-input lock-password-input"331                type={showPassword() ? "text" : "password"}332                placeholder="••••••••••••"333                autocomplete="new-password"334                value={password()}335                disabled={!props.ready}336                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}337                onKeyDown={(event) => {338                  if (event.key !== "Enter" || !props.ready) return;339                  void handleCreate();340                }}341              />342              <Show343                when={password().length > 0 && passwordStrength() !== null}344                fallback={345                  <p class="peach-newvault-hint">at least 12 characters</p>346                }347              >348                <div class="peach-password-strength">349                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>350                    <div class="peach-strength-fill"></div>351                  </div>352                  <span class="peach-strength-label">{passwordStrength()}</span>353                </div>354              </Show>355            </div>356357            <div class="peach-newvault-field">358              <label class="peach-label" for="confirm-password">359                confirm password360              </label>361              <input362                id="confirm-password"363                class="peach-underline-input peach-newvault-input"364                type={showPassword() ? "text" : "password"}365                placeholder="••••••••••••"366                autocomplete="new-password"367                value={confirmPassword()}368                disabled={!props.ready}369                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}370                onKeyDown={(event) => {371                  if (event.key === "Enter" && props.ready) void handleCreate();372                }}373              />374            </div>375376            <Show when={error()}>377              <p class="peach-hint peach-hint-error">{error()}</p>378            </Show>379380            <button381              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"382              onClick={() => void handleCreate()}383              disabled={!props.ready || loading() || !canCreate()}384              aria-live="polite"385            >386              <Show387                when={!canCreate() && createBlockerMessage()}388                fallback={loading() ? "creating..." : "create vault"}389              >390                {createBlockerMessage()}391              </Show>392            </button>393394            <p class="peach-newvault-warning">395              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>396              <span>If you forget it, your vault cannot be opened. There is no reset.</span>397            </p>398          </div>399400          <div class="peach-newvault-footer">401            <p class="peach-hint">Already have a backup?</p>402            <button403              class="peach-lock-restore-link"404              onClick={() => void openRestore("restore")}405              disabled={!props.ready}406            >407              › restore from backup408            </button>409          </div>410        </section>411      </Match>412    </Switch>413  );414}415416// ── Restore tab view ───────────────────────────────────────────────────────417418type RestoreTab = "symbols" | "mayan" | "csv" | "phone";419420function tabLabel(tab: RestoreTab): string {421  return (422    { symbols: "PeachScript", mayan: "Mayan Code", csv: "CSV", phone: "From Phone" }[tab]423  );424}425426function RestoreTabView(props: {427  hasVault: boolean;428  isPro: boolean;429  onBack: () => void;430  onUnlock: () => void | Promise<void>;431}) {432  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");433  const [showIntro, setShowIntro] = createSignal(false);434  const [pendingUnlock, setPendingUnlock] = createSignal(false);435436  const isDetachedWindow = new URLSearchParams(location.search).has("detached");437438  async function reattachPopup() {439    if (!isDetachedWindow) return;440    // Reopen the normal toolbar popup then discard the detached window.441    try { await (chrome.action as { openPopup?: () => Promise<void> }).openPopup?.(); } catch {}442    window.close();443  }444445  async function handleRestoreSuccess() {446    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);447    if (!seen) {448      setShowIntro(true);449    } else {450      await props.onUnlock();451      await reattachPopup();452    }453  }454455  async function dismissIntro() {456    await storageSet("peach_shown_restore_intro", "true").catch(() => {});457    setShowIntro(false);458    await props.onUnlock();459    await reattachPopup();460  }461462  return (463    <section464      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"465      data-motion-phase="enter"466    >467      <RuneBackground opacity={0.05} />468469      <header class="peach-screen-header lock-screen-header">470        <PeachWordmark class="peach-wordmark-wrap" />471        <button class="peach-lock-restore-link" onClick={props.onBack}>472          ‹ back473        </button>474      </header>475476      <div class="peach-newvault-card peach-restore-tabcard">477        <div class="peach-restore-tabs" role="tablist">478          <For each={["symbols", "mayan", "csv", "phone"] as RestoreTab[]}>479            {(tab) => (480              <button481                class="peach-restore-tab"482                classList={{ "is-active": activeTab() === tab }}483                onClick={() => setActiveTab(tab)}484                role="tab"485                aria-selected={activeTab() === tab}486              >487                {tabLabel(tab)}488              </button>489            )}490          </For>491        </div>492        <div class="peach-restore-content">493          <Show when={activeTab() === "symbols"}>494            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />495          </Show>496          <Show when={activeTab() === "mayan"}>497            <RestoreMayan hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />498          </Show>499          <Show when={activeTab() === "csv"}>500            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />501          </Show>502          <Show when={activeTab() === "phone"}>503            <RestoreFromPhone isPro={props.isPro} onSuccess={handleRestoreSuccess} />504          </Show>505        </div>506      </div>507508      {/* One-time intro popup after first restore */}509      <Show when={showIntro()}>510        <div class="peach-restore-intro-overlay">511          <div class="peach-restore-intro-card">512            <p class="peach-restore-intro-headline">welcome to peach.</p>513            <div class="peach-restore-intro-body">514              <p>Your vault lives only on this device. No server has a copy.</p>515              <p>The inline bubble fills passwords automatically when you visit sites.</p>516              <p>Export your vault from Settings before switching devices.</p>517            </div>518            <button519              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"520              onClick={() => void dismissIntro()}521              disabled={pendingUnlock()}522            >523              got it524            </button>525          </div>526        </div>527      </Show>528    </section>529  );530}531532// ── Mayan image decode helper ──────────────────────────────────────────────533534async function handleMayanDecode(base64: string): Promise<535  { ok: true; payload: Uint8Array } | { ok: false; reason: string }536> {537  const { decodeMayan } = await import("../../crypto/mayan");538  const img = new Image();539  const dataUrl = base64.startsWith("data:") ? base64 : `data:image/*;base64,${base64}`;540  await new Promise<void>((resolve, reject) => {541    img.onload = () => resolve();542    img.onerror = () => reject(new Error("Couldn't read the image."));543    img.src = dataUrl;544  });545  const canvas = document.createElement("canvas");546  // Cap resolution so adaptive-binarize stays bounded.547  const MAX_DIM = 1500;548  const scale = Math.min(1, MAX_DIM / Math.max(img.width, img.height));549  canvas.width = Math.round(img.width * scale);550  canvas.height = Math.round(img.height * scale);551  const ctx = canvas.getContext("2d");552  if (!ctx) return { ok: false, reason: "Couldn't open a 2D canvas." };553  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);554  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);555  const payload = decodeMayan(imageData);556  if (!payload) {557    return {558      ok: false,559      reason: "Could not read this Mayan code. Try a clearer image.",560    };561  }562  return { ok: true, payload };563}564565// ── PeachScript tab ────────────────────────────────────────────────────────566567function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {568  const [scriptInput, setScriptInput] = createSignal("");569  const [backupPass, setBackupPass] = createSignal("");570  const [working, setWorking] = createSignal(false);571  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);572  const [error, setError] = createSignal<string | null>(null);573  let canvasRef: HTMLCanvasElement | undefined;574  let animFrameId = 0;575  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];576577  const scriptValid = createMemo(() => isPeachScript(scriptInput()));578579  function stopAnim() {580    cancelAnimationFrame(animFrameId);581    animFrameId = 0;582  }583584  function startChaos(canvas: HTMLCanvasElement) {585    const ctx = canvas.getContext("2d")!;586    const W = canvas.width;587    const H = canvas.height;588    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>589      String.fromCodePoint(0xe000 + i)590    );591    if (runes.length === 0) {592      runes = Array.from({ length: 60 }, () => ({593        x: Math.random() * W,594        y: Math.random() * H,595        vx: (Math.random() - 0.5) * 3,596        vy: (Math.random() - 0.5) * 3,597        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,598        size: 14 + Math.random() * 20,599        angle: Math.random() * Math.PI * 2,600        va: (Math.random() - 0.5) * 0.1,601      }));602    }603604    function draw() {605      ctx.clearRect(0, 0, W, H);606      for (const r of runes) {607        r.x = (r.x + r.vx + W) % W;608        r.y = (r.y + r.vy + H) % H;609        r.angle += r.va;610        ctx.save();611        ctx.translate(r.x, r.y);612        ctx.rotate(r.angle);613        ctx.font = `${r.size}px PeachScript, monospace`;614        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);615        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;616        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";617        ctx.shadowBlur = 8;618        ctx.fillText(r.char, 0, 0);619        ctx.restore();620      }621      animFrameId = requestAnimationFrame(draw);622    }623    draw();624  }625626  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {627    stopAnim();628    const ctx = canvas.getContext("2d")!;629    const W = canvas.width;630    const H = canvas.height;631    const cols = 8;632    const rows = 6;633    const cellW = W / cols;634    const cellH = H / rows;635    const targets = runes.slice(0, cols * rows).map((_, i) => ({636      x: (i % cols) * cellW + cellW / 2,637      y: Math.floor(i / cols) * cellH + cellH / 2,638    }));639    const start = performance.now();640    const DURATION = 400;641642    function drawSnap(now: number) {643      const t = Math.min((now - start) / DURATION, 1);644      const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;645      ctx.clearRect(0, 0, W, H);646      runes.slice(0, targets.length).forEach((r, i) => {647        const tx = targets[i]!.x;648        const ty = targets[i]!.y;649        const cx = r.x + (tx - r.x) * ease;650        const cy = r.y + (ty - r.y) * ease;651        ctx.font = `16px PeachScript, monospace`;652        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;653        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";654        ctx.shadowBlur = 6;655        ctx.fillText(r.char, cx - 8, cy + 8);656      });657      if (t < 1) {658        animFrameId = requestAnimationFrame(drawSnap);659      } else {660        onDone();661      }662    }663    animFrameId = requestAnimationFrame(drawSnap);664  }665666  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {667    stopAnim();668    const ctx = canvas.getContext("2d")!;669    const W = canvas.width;670    const H = canvas.height;671    const start = performance.now();672    const DURATION = 900;673674    function drawBloom(now: number) {675      const t = Math.min((now - start) / DURATION, 1);676      ctx.clearRect(0, 0, W, H);677      // Runes dissolve678      const runeAlpha = 1 - Math.min(t / 0.4, 1);679      if (runeAlpha > 0) {680        for (const r of runes.slice(0, 48)) {681          ctx.font = `16px PeachScript, monospace`;682          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;683          ctx.fillText(r.char, r.x - 8, r.y + 8);684        }685      }686      // Peach mark bloom687      const markT = Math.max(0, (t - 0.2) / 0.5);688      const markScale = Math.min(markT * 1.2, 1);689      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;690      if (markAlpha > 0) {691        ctx.save();692        ctx.globalAlpha = markAlpha;693        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;694        ctx.shadowBlur = 30;695        ctx.translate(W / 2, H / 2);696        ctx.scale(markScale, markScale);697        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);698        ctx.restore();699      }700      if (t < 1) {701        animFrameId = requestAnimationFrame(drawBloom);702      } else {703        onDone();704      }705    }706    animFrameId = requestAnimationFrame(drawBloom);707  }708709  onCleanup(() => stopAnim());710711  async function handleRestore() {712    if (!scriptValid() || !backupPass().trim() || working()) return;713    setError(null);714    setWorking(true);715    setAnimating("chaos");716717    const canvas = canvasRef;718    if (canvas) startChaos(canvas);719720    try {721      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));722      const payload = await parsePayload(bytes, backupPass());723724      if (!props.hasVault) {725        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });726        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");727      }728729      const rr = await sendMessage({730        type: "RESTORE_ENTRIES",731        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },732      });733      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");734735      if (payload.license?.key) {736        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});737      }738739      setAnimating("snap");740      stopAnim();741      if (canvas) {742        snapToGrid(canvas, () => {743          setAnimating("bloom");744          bloom(canvas, async () => {745            setAnimating(null);746            await props.onSuccess();747          });748        });749      } else {750        await props.onSuccess();751      }752    } catch (err) {753      stopAnim();754      setAnimating(null);755      const code = err instanceof Error ? err.message : String(err);756      setError(757        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"758          ? "That passphrase didn't work."759          : code === "CREATE_VAULT_FAILED"760            ? "Couldn't create a new local vault."761            : "Could not read this backup.",762      );763    } finally {764      setWorking(false);765    }766  }767768  return (769    <div class="peach-rt-symbols">770      <Show when={animating() !== null}>771        <canvas772          ref={(el) => { canvasRef = el; }}773          class="peach-rt-anim-canvas"774          width={280}775          height={200}776        />777      </Show>778      <Show when={animating() === null}>779        <textarea780          class="peach-restore-symbols-textarea"781          placeholder="paste PeachScript here…"782          rows={3}783          value={scriptInput()}784          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}785        />786        <div class="peach-rt-divider">— or —</div>787        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">788          open .peach file789          <input790            type="file"791            accept=".peach,text/plain"792            style="display:none"793            onChange={async (e) => {794              const file = (e.currentTarget as HTMLInputElement).files?.[0];795              if (!file) return;796              try {797                setScriptInput(await file.text());798              } catch {799                setError("Couldn't read file.");800              } finally {801                (e.currentTarget as HTMLInputElement).value = "";802              }803            }}804          />805        </label>806        <input807          class="peach-restore-symbols-pass"808          type="password"809          placeholder="backup password"810          value={backupPass()}811          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}812          onKeyDown={(e) => {813            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {814              e.preventDefault();815              void handleRestore();816            }817          }}818        />819        <button820          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"821          onClick={() => void handleRestore()}822          disabled={!scriptValid() || !backupPass().trim() || working()}823        >824          restore vault →825        </button>826        <Show when={error()}>827          <p class="peach-rt-error">{error()}</p>828        </Show>829      </Show>830    </div>831  );832}833834// ── Mayan Code tab ─────────────────────────────────────────────────────────835836function RestoreMayan(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {837  const [decoding, setDecoding] = createSignal(false);838  const [decoded, setDecoded] = createSignal<Uint8Array | null>(null);839  const [entryLine, setEntryLine] = createSignal<string | null>(null);840  const [backupPass, setBackupPass] = createSignal("");841  const [restoring, setRestoring] = createSignal(false);842  const [error, setError] = createSignal<string | null>(null);843844  const isAndroid = createMemo(() =>845    Capacitor.isNativePlatform() && Capacitor.getPlatform() === "android"846  );847848  async function processFile(file: File) {849    setDecoding(true);850    setDecoded(null);851    setEntryLine(null);852    setError(null);853    try {854      const { decodeMayan } = await import("../../crypto/mayan");855      const bitmap = await createImageBitmap(file);856      const MAX_DIM = 1500;857      const scale = Math.min(1, MAX_DIM / Math.max(bitmap.width, bitmap.height));858      const canvas = document.createElement("canvas");859      canvas.width = Math.round(bitmap.width * scale);860      canvas.height = Math.round(bitmap.height * scale);861      const ctx = canvas.getContext("2d")!;862      ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);863      bitmap.close();864      const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);865      const payload = decodeMayan(imageData);866      if (!payload) { setError("Could not read this Mayan code. Try a clearer image."); return; }867      setDecoded(payload);868    } catch {869      setError("Could not read this Mayan code. Try a clearer image.");870    } finally {871      setDecoding(false);872    }873  }874875  async function handleCameraScan() {876    setError(null);877    try {878      const { Camera, CameraResultType, CameraSource } = await import("@capacitor/camera");879      const image = await Camera.getPhoto({ quality: 90, resultType: CameraResultType.Base64, source: CameraSource.Camera, allowEditing: false });880      if (!image.base64String) return;881      setDecoding(true);882      const result = await handleMayanDecode(image.base64String);883      setDecoding(false);884      if (!result.ok) { setError(result.reason); return; }885      setDecoded(result.payload);886    } catch (err) {887      setDecoding(false);888      const msg = err instanceof Error ? err.message : "";889      if (msg && !/cancel/i.test(msg)) setError(msg);890    }891  }892893  async function handleRestore() {894    const bytes = decoded();895    if (!bytes || !backupPass().trim() || restoring()) return;896    setRestoring(true);897    setError(null);898    try {899      const payload = await parsePayload(bytes, backupPass());900      const count = payload.entries.length;901      const date = new Date(902        Math.max(...payload.entries.map((e) => e.modifiedAt ?? e.createdAt ?? 0))903      );904      const monthYear = date.getFullYear() > 2000905        ? date.toLocaleDateString("en-US", { month: "long", year: "numeric" })906        : null;907      setEntryLine(monthYear ? `${count} entries · ${monthYear}` : `${count} entries`);908909      if (!props.hasVault) {910        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });911        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");912      }913      const rr = await sendMessage({ type: "RESTORE_ENTRIES", payload: { entries: payload.entries, mode: "merge", settings: payload.settings } });914      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");915      if (payload.license?.key) {916        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});917      }918      await props.onSuccess();919    } catch (err) {920      const code = err instanceof Error ? err.message : String(err);921      setError(922        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"923          ? "Wrong backup password."924          : code === "CREATE_VAULT_FAILED"925            ? "Couldn't create a new local vault."926            : "Restore failed. The backup may be corrupted.",927      );928    } finally {929      setRestoring(false);930    }931  }932933  return (934    <div class="peach-rt-mayan">935      <Show when={decoded() === null}>936        <label class="peach-rt-upload-zone" classList={{ "is-busy": decoding() }}>937          <span class="peach-rt-upload-label">938            {decoding() ? "decoding…" : "tap to upload image"}939          </span>940          <input941            type="file"942            accept="image/*"943            style="display:none"944            onChange={(e) => {945              const file = (e.currentTarget as HTMLInputElement).files?.[0];946              if (file) void processFile(file);947              (e.currentTarget as HTMLInputElement).value = "";948            }}949            disabled={decoding()}950          />951        </label>952        <Show when={isAndroid()}>953          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void handleCameraScan()} disabled={decoding()}>954            scan with camera955          </button>956        </Show>957        <Show when={error()}>958          <p class="peach-rt-error">{error()}</p>959        </Show>960      </Show>961      <Show when={decoded() !== null}>962        <Show when={entryLine()}>963          <p class="peach-rt-entry-line">{entryLine()}</p>964        </Show>965        <input966          class="peach-restore-symbols-pass"967          type="password"968          placeholder="backup password"969          ref={(el) => queueMicrotask(() => el.focus())}970          value={backupPass()}971          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}972          onKeyDown={(e) => {973            if (e.key === "Enter" && !restoring()) { e.preventDefault(); void handleRestore(); }974          }}975        />976        <button977          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"978          onClick={() => void handleRestore()}979          disabled={!backupPass().trim() || restoring()}980        >981          {restoring() ? "restoring…" : "restore vault →"}982        </button>983        <button class="peach-btn peach-btn--ghost peach-btn--sm" style="margin-top:4px" onClick={() => { setDecoded(null); setEntryLine(null); setError(null); }}>984          ← try another image985        </button>986        <Show when={error()}>987          <p class="peach-rt-error">{error()}</p>988        </Show>989      </Show>990    </div>991  );992}993994// ── CSV tab ────────────────────────────────────────────────────────────────995996function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {997  const [importing, setImporting] = createSignal(false);998  const [error, setError] = createSignal<string | null>(null);9991000  async function handleFile(e: Event) {1001    const input = e.currentTarget as HTMLInputElement;1002    const file = input.files?.[0];1003    input.value = "";1004    if (!file) return;1005    setImporting(true);1006    setError(null);1007    try {1008      const text = await file.text();1009      const parsed = await parseImportSource(text);1010      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");10111012      if (props.hasVault === false) {1013        // No vault yet — this path shouldn't normally occur for CSV (no backup password1014        // means no way to create a locked vault). Create an unlocked placeholder vault.1015        // The user can set a master password from settings.1016        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });1017        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1018      }10191020      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1021      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1022      if (parsed.licenseKey) {1023        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});1024      }1025      await props.onSuccess();1026    } catch (err) {1027      const code = err instanceof Error ? err.message : String(err);1028      setError(1029        code === "NO_ENTRIES" ? "No entries found in this file." :1030        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1031        "Import failed. Check the file format and try again."1032      );1033    } finally {1034      setImporting(false);1035    }1036  }10371038  return (1039    <div class="peach-rt-csv">1040      <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>1041        {importing() ? "importing…" : "open CSV file"}1042        <input1043          type="file"1044          accept=".csv,.json,.peach,application/x-peach"1045          style="display:none"1046          onChange={handleFile}1047          disabled={importing()}1048        />1049      </label>1050      <p class="peach-rt-csv-sources">1051        Imports from Bitwarden, 1Password, Proton Pass,<br />1052        Chrome, Safari, and Edge.1053      </p>1054      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1055      <Show when={error()}>1056        <p class="peach-rt-error">{error()}</p>1057      </Show>1058    </div>1059  );1060}10611062// ── From Phone tab ─────────────────────────────────────────────────────────10631064type PhoneState = "waiting" | "connected" | "trust" | "error";10651066function RestoreFromPhone(props: { isPro: boolean; onSuccess: () => void | Promise<void> }) {1067  const [state, setState] = createSignal<PhoneState>("waiting");1068  const [code, setCode] = createSignal<string | null>(null);1069  const [qrUrl, setQrUrl] = createSignal<string | null>(null);1070  const [expired, setExpired] = createSignal(false);1071  const [deviceName, setDeviceName] = createSignal<string | null>(null);1072  const [accepting, setAccepting] = createSignal(false);1073  const [serverError, setServerError] = createSignal<string | null>(null);1074  let expireTimer: ReturnType<typeof setTimeout> | null = null;10751076  function clearExpire() {1077    if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }1078  }10791080  async function startPairing() {1081    setExpired(false);1082    setCode(null);1083    setQrUrl(null);1084    setServerError(null);1085    clearExpire();1086    const res = await sendMessage({ type: "START_PAIRING_SERVER" });1087    const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };1088    if (!r.ok || !r.data) {1089      setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");1090      return;1091    }1092    const token = r.data.token;1093    const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;1094    setCode(formatted);1095    const qrData = `peach://handoff?token=${token}`;1096    try {1097      const QRCode = (await import("qrcode")).default;1098      const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });1099      setQrUrl(url);1100    } catch {1101      // QR optional — code is the primary affordance1102    }1103    const remaining = Math.max(0, r.data.expiresAt - Date.now());1104    expireTimer = setTimeout(() => {1105      setCode(null);1106      setQrUrl(null);1107      setExpired(true);1108    }, remaining);1109  }11101111  function handleRuntimeMsg(msg: unknown) {1112    const m = msg as { type?: string; payload?: { deviceName?: string } };1113    if (m?.type === "PAIRING_COMPLETED") {1114      clearExpire();1115      setDeviceName(m.payload?.deviceName ?? "your phone");1116      setState("trust");1117    }1118  }11191120  onMount(() => {1121    if (!props.isPro) return;1122    void startPairing();1123    // @ts-ignore — chrome runtime1124    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1125      chrome.runtime.onMessage.addListener(handleRuntimeMsg);1126    }1127  });11281129  onCleanup(() => {1130    clearExpire();1131    // @ts-ignore1132    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1133      chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1134    }1135    sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1136  });11371138  async function handleAccept(trust: boolean) {1139    setAccepting(true);1140    if (trust) {1141      await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1142    }1143    await props.onSuccess();1144  }11451146  if (!props.isPro) {1147    return (1148      <div class="peach-rt-phone">1149        <p class="peach-rt-phone-gate-text">Transfer requires Peach Pro.</p>1150        <button1151          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1152          onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro" })}1153        >1154          Get Peach Pro1155        </button>1156        <button1157          class="peach-btn peach-btn--ghost peach-btn--sm"1158          style="margin-top:6px"1159          onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro#redeem" })}1160        >1161          I already have a license1162        </button>1163      </div>1164    );1165  }11661167  return (1168    <div class="peach-rt-phone">1169      <Show when={state() === "waiting"}>1170        <Show when={!expired()}>1171          <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1172            <p class="peach-rt-phone-code">{code()}</p>1173            <Show when={qrUrl() !== null}>1174              <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1175            </Show>1176            <p class="peach-rt-phone-hint">1177              Open Peach on your phone and tap<br />1178              Settings → Send to desktop.<br />1179              Same WiFi required.1180            </p>1181          </Show>1182          <Show when={serverError()}>1183            <p class="peach-rt-error">{serverError()}</p>1184          </Show>1185        </Show>1186        <Show when={expired()}>1187          <p class="peach-rt-phone-hint">Code expired.</p>1188          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1189            Generate new code1190          </button>1191        </Show>1192      </Show>11931194      <Show when={state() === "connected"}>1195        <p class="peach-rt-phone-hint">1196          Connected to {deviceName() ?? "your phone"}.<br />1197          Receiving vault…1198        </p>1199      </Show>12001201      <Show when={state() === "trust"}>1202        <p class="peach-rt-phone-hint" style="margin-bottom:10px">1203          Vault received from {deviceName() ?? "your phone"}.<br />1204          Trust this network for sync?1205        </p>1206        <button1207          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1208          onClick={() => void handleAccept(true)}1209          disabled={accepting()}1210        >1211          Yes, trust this network1212        </button>1213        <button1214          class="peach-btn peach-btn--ghost peach-btn--sm"1215          style="margin-top:6px"1216          onClick={() => void handleAccept(false)}1217          disabled={accepting()}1218        >1219          Not now1220        </button>1221      </Show>12221223      <Show when={state() === "error"}>1224        <p class="peach-rt-phone-hint">1225          Could not connect. Check that both devices<br />1226          are on the same WiFi network.1227        </p>1228        <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1229          Try again1230        </button>1231      </Show>1232    </div>1233  );1234}12351236// ── Biometric unlock ────────────────────────────────────────────────────────12371238async function handleBiometricUnlock(props: { onUnlock: () => void | Promise<void> }) {1239  const unlocked = await biometricUnlock();1240  if (unlocked) {1241    await props.onUnlock();1242  }1243}12441245function BiometricUnlockButton(props: { ready: boolean; loading: () => boolean; onUnlock: () => void | Promise<void> }) {1246  const [bioAvailable, setBioAvailable] = createSignal(false);1247  onMount(async () => {1248    setBioAvailable(await biometricAvailable());1249  });1250  return (1251    <Show when={bioAvailable()}>1252      <button1253        class="peach-btn peach-btn--subtle peach-btn--md peach-biometric-btn"1254        onClick={() => void handleBiometricUnlock(props)}1255        disabled={!props.ready || props.loading()}1256        type="button"1257      >1258        <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1259          <path d="M8 1a3 3 0 0 0-3 3v4a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />1260          <path d="M5 8v1a3 3 0 0 0 6 0V8" />1261          <line x1="8" y1="12" x2="8" y2="15" />1262          <line x1="5" y1="15" x2="11" y2="15" />1263        </svg>1264        use biometrics1265      </button>1266    </Show>1267  );1268}12691270// ── Helpers ────────────────────────────────────────────────────────────────12711272function getDisplayError(error: string | undefined, fallback: string): string {1273  if (!error) return fallback;1274  if (1275    error === "SERVICE_WORKER_UNAVAILABLE" ||1276    error.includes("Receiving end does not exist") ||1277    error.includes("message port closed")1278  ) {1279    return "background service unavailable";1280  }1281  return error;1282}