Lock.tsx

47.2 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";1415// Lazy-load zxcvbn — it's heavy (~800 kB) and only needed during vault creation16let _zxcvbn: ((password: string) => { score: number }) | null = null;17async function getZxcvbn() {18  if (_zxcvbn) return _zxcvbn;19  const mod = await import("zxcvbn");20  _zxcvbn = mod.default;21  return _zxcvbn;22}2324interface LockProps {25  hasVault: boolean;26  ready: boolean;27  isPro?: boolean;28  onUnlock: () => void | Promise<void>;29  /** Optional escape hatch: rewind from the create-vault screen back to30   *  Onboarding. Only meaningful when the user reached create-vault from31   *  Onboarding in the first place. */32  onBackToOnboarding?: () => void;33}3435type RestoreSurface = "restore" | null;3637export function LockView(props: LockProps) {38  const [password, setPassword] = createSignal("");39  const [confirmPassword, setConfirmPassword] = createSignal("");40  const [error, setError] = createSignal("");41  const [loading, setLoading] = createSignal(false);42  // If the popup was reopened as a detached window for file picking, go straight43  // to the restore view so the user lands where they intended.44  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(45    new URLSearchParams(location.search).has("detached") ? "restore" : null46  );47  const [showPassword, setShowPassword] = createSignal(false);4849  const MIN_LENGTH = 12;50  const MIN_ZXCVBN_SCORE = 2;5152  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);5354  const canCreate = createMemo(55    () =>56      password().length >= MIN_LENGTH &&57      confirmPassword().length >= MIN_LENGTH &&58      password() === confirmPassword() &&59      strengthScore() !== null &&60      strengthScore()! >= MIN_ZXCVBN_SCORE61  );6263  const passwordStrength = createMemo(() => {64    const score = strengthScore();65    if (score === null) return null;66    if (score <= 1) return "weak";67    if (score === 2) return "fair";68    if (score === 3) return "good";69    return "strong";70  });7172  const createBlockerMessage = createMemo(() => {73    if (!password()) return null;74    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;75    if (!confirmPassword()) return "confirm your password";76    if (password() !== confirmPassword()) return "passwords don't match";77    if (strengthScore() === null) return "checking strength…";78    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";79    return null;80  });8182  // Update strength asynchronously when password changes83  createMemo(() => {84    const value = password();85    if (!value) { setStrengthScore(null); return; }86    getZxcvbn().then((zxcvbn) => {87      if (password() === value) setStrengthScore(zxcvbn(value).score);88    }).catch(() => {89      // zxcvbn unavailable — fall back to length-only heuristic90      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);91    });92  });9394  async function handleUnlock() {95    if (!password().trim()) return;96    setLoading(true);97    setError("");98    const response = await sendMessage({99      type: "UNLOCK",100      payload: { password: password() },101    });102    const result = response as { ok: boolean; error?: string };103    if (result.ok) {104      await props.onUnlock();105      setLoading(false);106      return;107    }108    setError(getDisplayError(result.error, "incorrect password"));109    setPassword("");110    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;111    passwordField?.classList.add("shake");112    setTimeout(() => passwordField?.classList.remove("shake"), 300);113    setLoading(false);114  }115116  async function handleCreate() {117    if (!password().trim()) return;118    if (password().length < MIN_LENGTH) {119      setError(`password must be at least ${MIN_LENGTH} characters`);120      return;121    }122    if (password() !== confirmPassword()) {123      setError("passwords do not match");124      return;125    }126    const score = strengthScore();127    if (score === null || score < MIN_ZXCVBN_SCORE) {128      setError("password is too weak — try a longer or more varied passphrase");129      return;130    }131    setLoading(true);132    setError("");133    const response = await sendMessage({134      type: "CREATE_VAULT",135      payload: { password: password() },136    });137    const result = response as { ok: boolean; error?: string };138    if (result.ok) {139      await props.onUnlock();140      setLoading(false);141      return;142    }143    setError(getDisplayError(result.error, "failed to create vault"));144    setLoading(false);145  }146147  async function openRestore(surface: Exclude<RestoreSurface, null>) {148    if (!props.ready || loading()) return;149    setPassword("");150    setConfirmPassword("");151    setError("");152153    // The toolbar popup closes when a native file dialog steals focus (macOS).154    // Reopen as a detached chrome.windows.create popup, which survives focus155    // loss. The detached window initialises directly into the restore view via156    // the ?detached URL param.157    const isDetached = new URLSearchParams(location.search).has("detached");158    if (!isDetached) {159      try {160        const url = chrome.runtime.getURL("src/popup/index.html") + "?detached=1";161        await chrome.windows.create({ url, type: "popup", width: 340, height: 560, focused: true });162        window.close();163        return;164      } catch {165        // Not in extension context (dev mode) — fall through to direct open.166      }167    }168169    setRestoreSurface(surface);170  }171172  // ── All surfaces routed reactively via Switch/Match ───────────────────173  return (174    <Switch>175      {/* ── Restore tab view ──────────────────────────────────────── */}176      <Match when={restoreSurface() === "restore"}>177        <RestoreTabView178          hasVault={props.hasVault}179          isPro={props.isPro ?? false}180          onBack={() => setRestoreSurface(null)}181          onUnlock={async () => {182            setRestoreSurface(null);183            await props.onUnlock();184          }}185        />186      </Match>187188      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}189      <Match when={props.hasVault}>190        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">191          <header class="peach-screen-header lock-screen-header">192            <div class="peach-wordmark-stack">193              <PeachWordmark class="peach-wordmark-wrap" />194              <Show when={props.isPro}>195                <span class="peach-pro-mini-badge">pro</span>196              </Show>197            </div>198          </header>199200          <div class="peach-lock-hero peach-lock-hero--unlock">201            <div class="peach-lock-hero-art" aria-hidden="true">202              <PeachMark class="peach-lock-hero-mark" />203            </div>204            <span class="peach-lock-eyebrow">vault locked</span>205            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">206              unlock207              <br />208              your vault.209            </h1>210          </div>211212          <LockRuneCanvas />213214          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">215            <label class="peach-label" for="unlock-password">216              master password217            </label>218            <input219              id="unlock-password"220              class="peach-underline-input peach-unlock-input lock-password-input"221              type="password"222              placeholder="••••••••••••"223              autocomplete="current-password"224              value={password()}225              disabled={!props.ready}226              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}227              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}228              onKeyDown={(event) => {229                if (event.key !== "Enter" || !props.ready) return;230                void handleUnlock();231              }}232            />233234            <Show when={error()}>235              <p class="peach-hint peach-hint-error">{error()}</p>236            </Show>237238            <button239              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"240              onClick={() => void handleUnlock()}241              disabled={!props.ready || loading()}242            >243              {loading() ? "unlocking..." : "unlock"}244            </button>245246            <Show when={Capacitor.isNativePlatform()}>247              <BiometricUnlockButton ready={props.ready} loading={loading} onUnlock={props.onUnlock} />248            </Show>249          </section>250        </section>251      </Match>252253      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}254      <Match when={true}>255        <section256          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"257          data-motion-phase="enter"258        >259          <header class="peach-screen-header peach-newvault-header">260            <Show when={props.onBackToOnboarding}>261              <button262                class="peach-newvault-back"263                type="button"264                onClick={() => props.onBackToOnboarding?.()}265                aria-label="Back to onboarding"266              >267                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">268                  <path d="M7 2L3 5.5L7 9" />269                </svg>270                back271              </button>272            </Show>273            <div class="peach-wordmark-stack">274              <PeachWordmark class="peach-wordmark-wrap" />275              <Show when={props.isPro}>276                <span class="peach-pro-mini-badge">pro</span>277              </Show>278            </div>279          </header>280281          <div class="peach-newvault-mark" aria-hidden="true">282            <PeachMark class="peach-newvault-mark-art" />283          </div>284285          <div class="peach-newvault-card">286            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>287            <hr class="peach-newvault-divider" />288289            <div class="peach-newvault-field">290              <div class="peach-newvault-label-row">291                <label class="peach-label" for="master-password">292                  master password293                </label>294                <button295                  class="peach-lock-reveal-btn"296                  type="button"297                  onClick={() => setShowPassword((value) => !value)}298                  aria-label={showPassword() ? "Hide password" : "Show password"}299                >300                  <Show301                    when={showPassword()}302                    fallback={303                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">304                        <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" />305                        <circle cx="7" cy="5" r="1.75" />306                      </svg>307                    }308                  >309                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">310                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />311                      <path d="M3.5 5.5L2.5 7.5" />312                      <path d="M7 6.5V8.5" />313                      <path d="M10.5 5.5L11.5 7.5" />314                    </svg>315                  </Show>316                  {showPassword() ? "hide" : "show"}317                </button>318              </div>319              <input320                id="master-password"321                class="peach-underline-input peach-newvault-input lock-password-input"322                type={showPassword() ? "text" : "password"}323                placeholder="••••••••••••"324                autocomplete="new-password"325                value={password()}326                disabled={!props.ready}327                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}328                onKeyDown={(event) => {329                  if (event.key !== "Enter" || !props.ready) return;330                  void handleCreate();331                }}332              />333              <Show334                when={password().length > 0 && passwordStrength() !== null}335                fallback={336                  <p class="peach-newvault-hint">at least 12 characters</p>337                }338              >339                <div class="peach-password-strength">340                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>341                    <div class="peach-strength-fill"></div>342                  </div>343                  <span class="peach-strength-label">{passwordStrength()}</span>344                </div>345              </Show>346            </div>347348            <div class="peach-newvault-field">349              <label class="peach-label" for="confirm-password">350                confirm password351              </label>352              <input353                id="confirm-password"354                class="peach-underline-input peach-newvault-input"355                type={showPassword() ? "text" : "password"}356                placeholder="••••••••••••"357                autocomplete="new-password"358                value={confirmPassword()}359                disabled={!props.ready}360                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}361                onKeyDown={(event) => {362                  if (event.key === "Enter" && props.ready) void handleCreate();363                }}364              />365            </div>366367            <Show when={error()}>368              <p class="peach-hint peach-hint-error">{error()}</p>369            </Show>370371            <button372              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"373              onClick={() => void handleCreate()}374              disabled={!props.ready || loading() || !canCreate()}375              aria-live="polite"376            >377              <Show378                when={!canCreate() && createBlockerMessage()}379                fallback={loading() ? "creating..." : "create vault"}380              >381                {createBlockerMessage()}382              </Show>383            </button>384385            <p class="peach-newvault-warning">386              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>387              <span>If you forget it, your vault cannot be opened. There is no reset.</span>388            </p>389          </div>390391          <div class="peach-newvault-footer">392            <p class="peach-hint">Already have a backup?</p>393            <button394              class="peach-lock-restore-link"395              onClick={() => void openRestore("restore")}396              disabled={!props.ready}397            >398              › restore from backup399            </button>400          </div>401        </section>402      </Match>403    </Switch>404  );405}406407// ── Restore tab view ───────────────────────────────────────────────────────408409type RestoreTab = "symbols" | "mayan" | "csv" | "phone";410411function tabLabel(tab: RestoreTab): string {412  return (413    { symbols: "PeachScript", mayan: "Mayan Code", csv: "CSV", phone: "From Phone" }[tab]414  );415}416417function RestoreTabView(props: {418  hasVault: boolean;419  isPro: boolean;420  onBack: () => void;421  onUnlock: () => void | Promise<void>;422}) {423  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");424  const [showIntro, setShowIntro] = createSignal(false);425  const [pendingUnlock, setPendingUnlock] = createSignal(false);426427  const isDetachedWindow = new URLSearchParams(location.search).has("detached");428429  async function reattachPopup() {430    if (!isDetachedWindow) return;431    // Reopen the normal toolbar popup then discard the detached window.432    try { await (chrome.action as { openPopup?: () => Promise<void> }).openPopup?.(); } catch {}433    window.close();434  }435436  async function handleRestoreSuccess() {437    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);438    if (!seen) {439      setShowIntro(true);440    } else {441      await props.onUnlock();442      await reattachPopup();443    }444  }445446  async function dismissIntro() {447    await storageSet("peach_shown_restore_intro", "true").catch(() => {});448    setShowIntro(false);449    await props.onUnlock();450    await reattachPopup();451  }452453  return (454    <section455      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"456      data-motion-phase="enter"457    >458      <RuneBackground opacity={0.05} />459460      <header class="peach-screen-header lock-screen-header">461        <PeachWordmark class="peach-wordmark-wrap" />462        <button class="peach-lock-restore-link" onClick={props.onBack}>463          ‹ back464        </button>465      </header>466467      <div class="peach-newvault-card peach-restore-tabcard">468        <div class="peach-restore-tabs" role="tablist">469          <For each={["symbols", "mayan", "csv", "phone"] as RestoreTab[]}>470            {(tab) => (471              <button472                class="peach-restore-tab"473                classList={{ "is-active": activeTab() === tab }}474                onClick={() => setActiveTab(tab)}475                role="tab"476                aria-selected={activeTab() === tab}477              >478                {tabLabel(tab)}479              </button>480            )}481          </For>482        </div>483        <div class="peach-restore-content">484          <Show when={activeTab() === "symbols"}>485            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />486          </Show>487          <Show when={activeTab() === "mayan"}>488            <RestoreMayan hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />489          </Show>490          <Show when={activeTab() === "csv"}>491            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />492          </Show>493          <Show when={activeTab() === "phone"}>494            <RestoreFromPhone isPro={props.isPro} onSuccess={handleRestoreSuccess} />495          </Show>496        </div>497      </div>498499      {/* One-time intro popup after first restore */}500      <Show when={showIntro()}>501        <div class="peach-restore-intro-overlay">502          <div class="peach-restore-intro-card">503            <p class="peach-restore-intro-headline">welcome to peach.</p>504            <div class="peach-restore-intro-body">505              <p>Your vault lives only on this device. No server has a copy.</p>506              <p>The inline bubble fills passwords automatically when you visit sites.</p>507              <p>Export your vault from Settings before switching devices.</p>508            </div>509            <button510              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"511              onClick={() => void dismissIntro()}512              disabled={pendingUnlock()}513            >514              got it515            </button>516          </div>517        </div>518      </Show>519    </section>520  );521}522523// ── Mayan image decode helper ──────────────────────────────────────────────524525async function handleMayanDecode(base64: string): Promise<526  { ok: true; payload: Uint8Array } | { ok: false; reason: string }527> {528  const { decodeMayan } = await import("../../crypto/mayan");529  const img = new Image();530  const dataUrl = base64.startsWith("data:") ? base64 : `data:image/*;base64,${base64}`;531  await new Promise<void>((resolve, reject) => {532    img.onload = () => resolve();533    img.onerror = () => reject(new Error("Couldn't read the image."));534    img.src = dataUrl;535  });536  const canvas = document.createElement("canvas");537  // Cap resolution so adaptive-binarize stays bounded.538  const MAX_DIM = 1500;539  const scale = Math.min(1, MAX_DIM / Math.max(img.width, img.height));540  canvas.width = Math.round(img.width * scale);541  canvas.height = Math.round(img.height * scale);542  const ctx = canvas.getContext("2d");543  if (!ctx) return { ok: false, reason: "Couldn't open a 2D canvas." };544  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);545  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);546  const payload = decodeMayan(imageData);547  if (!payload) {548    return {549      ok: false,550      reason: "Could not read this Mayan code. Try a clearer image.",551    };552  }553  return { ok: true, payload };554}555556// ── PeachScript tab ────────────────────────────────────────────────────────557558function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {559  const [scriptInput, setScriptInput] = createSignal("");560  const [backupPass, setBackupPass] = createSignal("");561  const [working, setWorking] = createSignal(false);562  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);563  const [error, setError] = createSignal<string | null>(null);564  let canvasRef: HTMLCanvasElement | undefined;565  let animFrameId = 0;566  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];567568  const scriptValid = createMemo(() => isPeachScript(scriptInput()));569570  function stopAnim() {571    cancelAnimationFrame(animFrameId);572    animFrameId = 0;573  }574575  function startChaos(canvas: HTMLCanvasElement) {576    const ctx = canvas.getContext("2d")!;577    const W = canvas.width;578    const H = canvas.height;579    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>580      String.fromCodePoint(0xe000 + i)581    );582    if (runes.length === 0) {583      runes = Array.from({ length: 60 }, () => ({584        x: Math.random() * W,585        y: Math.random() * H,586        vx: (Math.random() - 0.5) * 3,587        vy: (Math.random() - 0.5) * 3,588        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,589        size: 14 + Math.random() * 20,590        angle: Math.random() * Math.PI * 2,591        va: (Math.random() - 0.5) * 0.1,592      }));593    }594595    function draw() {596      ctx.clearRect(0, 0, W, H);597      for (const r of runes) {598        r.x = (r.x + r.vx + W) % W;599        r.y = (r.y + r.vy + H) % H;600        r.angle += r.va;601        ctx.save();602        ctx.translate(r.x, r.y);603        ctx.rotate(r.angle);604        ctx.font = `${r.size}px PeachScript, monospace`;605        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);606        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;607        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";608        ctx.shadowBlur = 8;609        ctx.fillText(r.char, 0, 0);610        ctx.restore();611      }612      animFrameId = requestAnimationFrame(draw);613    }614    draw();615  }616617  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {618    stopAnim();619    const ctx = canvas.getContext("2d")!;620    const W = canvas.width;621    const H = canvas.height;622    const cols = 8;623    const rows = 6;624    const cellW = W / cols;625    const cellH = H / rows;626    const targets = runes.slice(0, cols * rows).map((_, i) => ({627      x: (i % cols) * cellW + cellW / 2,628      y: Math.floor(i / cols) * cellH + cellH / 2,629    }));630    const start = performance.now();631    const DURATION = 400;632633    function drawSnap(now: number) {634      const t = Math.min((now - start) / DURATION, 1);635      const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;636      ctx.clearRect(0, 0, W, H);637      runes.slice(0, targets.length).forEach((r, i) => {638        const tx = targets[i]!.x;639        const ty = targets[i]!.y;640        const cx = r.x + (tx - r.x) * ease;641        const cy = r.y + (ty - r.y) * ease;642        ctx.font = `16px PeachScript, monospace`;643        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;644        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";645        ctx.shadowBlur = 6;646        ctx.fillText(r.char, cx - 8, cy + 8);647      });648      if (t < 1) {649        animFrameId = requestAnimationFrame(drawSnap);650      } else {651        onDone();652      }653    }654    animFrameId = requestAnimationFrame(drawSnap);655  }656657  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {658    stopAnim();659    const ctx = canvas.getContext("2d")!;660    const W = canvas.width;661    const H = canvas.height;662    const start = performance.now();663    const DURATION = 900;664665    function drawBloom(now: number) {666      const t = Math.min((now - start) / DURATION, 1);667      ctx.clearRect(0, 0, W, H);668      // Runes dissolve669      const runeAlpha = 1 - Math.min(t / 0.4, 1);670      if (runeAlpha > 0) {671        for (const r of runes.slice(0, 48)) {672          ctx.font = `16px PeachScript, monospace`;673          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;674          ctx.fillText(r.char, r.x - 8, r.y + 8);675        }676      }677      // Peach mark bloom678      const markT = Math.max(0, (t - 0.2) / 0.5);679      const markScale = Math.min(markT * 1.2, 1);680      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;681      if (markAlpha > 0) {682        ctx.save();683        ctx.translate(W / 2, H / 2);684        ctx.scale(markScale, markScale);685        ctx.font = `bold 64px serif`;686        ctx.fillStyle = `rgba(232, 149, 109, ${markAlpha})`;687        ctx.shadowColor = `rgba(232, 149, 109, ${markAlpha * 0.8})`;688        ctx.shadowBlur = 30;689        ctx.textAlign = "center";690        ctx.textBaseline = "middle";691        ctx.fillText("🍑", 0, 0);692        ctx.restore();693      }694      if (t < 1) {695        animFrameId = requestAnimationFrame(drawBloom);696      } else {697        onDone();698      }699    }700    animFrameId = requestAnimationFrame(drawBloom);701  }702703  onCleanup(() => stopAnim());704705  async function handleRestore() {706    if (!scriptValid() || !backupPass().trim() || working()) return;707    setError(null);708    setWorking(true);709    setAnimating("chaos");710711    const canvas = canvasRef;712    if (canvas) startChaos(canvas);713714    try {715      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));716      const payload = await parsePayload(bytes, backupPass());717718      if (!props.hasVault) {719        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });720        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");721      }722723      const rr = await sendMessage({724        type: "RESTORE_ENTRIES",725        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },726      });727      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");728729      if (payload.license?.key) {730        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});731      }732733      setAnimating("snap");734      stopAnim();735      if (canvas) {736        snapToGrid(canvas, () => {737          setAnimating("bloom");738          bloom(canvas, async () => {739            setAnimating(null);740            await props.onSuccess();741          });742        });743      } else {744        await props.onSuccess();745      }746    } catch (err) {747      stopAnim();748      setAnimating(null);749      const code = err instanceof Error ? err.message : String(err);750      setError(751        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"752          ? "That passphrase didn't work."753          : code === "CREATE_VAULT_FAILED"754            ? "Couldn't create a new local vault."755            : "Could not read this backup.",756      );757    } finally {758      setWorking(false);759    }760  }761762  return (763    <div class="peach-rt-symbols">764      <Show when={animating() !== null}>765        <canvas766          ref={(el) => { canvasRef = el; }}767          class="peach-rt-anim-canvas"768          width={280}769          height={200}770        />771      </Show>772      <Show when={animating() === null}>773        <textarea774          class="peach-restore-symbols-textarea"775          placeholder="paste PeachScript here…"776          rows={3}777          value={scriptInput()}778          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}779        />780        <div class="peach-rt-divider">— or —</div>781        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">782          open .peach file783          <input784            type="file"785            accept=".peach,text/plain"786            style="display:none"787            onChange={async (e) => {788              const file = (e.currentTarget as HTMLInputElement).files?.[0];789              if (!file) return;790              try {791                setScriptInput(await file.text());792              } catch {793                setError("Couldn't read file.");794              } finally {795                (e.currentTarget as HTMLInputElement).value = "";796              }797            }}798          />799        </label>800        <input801          class="peach-restore-symbols-pass"802          type="password"803          placeholder="backup password"804          value={backupPass()}805          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}806          onKeyDown={(e) => {807            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {808              e.preventDefault();809              void handleRestore();810            }811          }}812        />813        <button814          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"815          onClick={() => void handleRestore()}816          disabled={!scriptValid() || !backupPass().trim() || working()}817        >818          restore vault →819        </button>820        <Show when={error()}>821          <p class="peach-rt-error">{error()}</p>822        </Show>823      </Show>824    </div>825  );826}827828// ── Mayan Code tab ─────────────────────────────────────────────────────────829830function RestoreMayan(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {831  const [decoding, setDecoding] = createSignal(false);832  const [decoded, setDecoded] = createSignal<Uint8Array | null>(null);833  const [entryLine, setEntryLine] = createSignal<string | null>(null);834  const [backupPass, setBackupPass] = createSignal("");835  const [restoring, setRestoring] = createSignal(false);836  const [error, setError] = createSignal<string | null>(null);837838  const isAndroid = createMemo(() =>839    Capacitor.isNativePlatform() && Capacitor.getPlatform() === "android"840  );841842  async function processFile(file: File) {843    setDecoding(true);844    setDecoded(null);845    setEntryLine(null);846    setError(null);847    try {848      const { decodeMayan } = await import("../../crypto/mayan");849      const bitmap = await createImageBitmap(file);850      const MAX_DIM = 1500;851      const scale = Math.min(1, MAX_DIM / Math.max(bitmap.width, bitmap.height));852      const canvas = document.createElement("canvas");853      canvas.width = Math.round(bitmap.width * scale);854      canvas.height = Math.round(bitmap.height * scale);855      const ctx = canvas.getContext("2d")!;856      ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);857      bitmap.close();858      const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);859      const payload = decodeMayan(imageData);860      if (!payload) { setError("Could not read this Mayan code. Try a clearer image."); return; }861      setDecoded(payload);862    } catch {863      setError("Could not read this Mayan code. Try a clearer image.");864    } finally {865      setDecoding(false);866    }867  }868869  async function handleCameraScan() {870    setError(null);871    try {872      const { Camera, CameraResultType, CameraSource } = await import("@capacitor/camera");873      const image = await Camera.getPhoto({ quality: 90, resultType: CameraResultType.Base64, source: CameraSource.Camera, allowEditing: false });874      if (!image.base64String) return;875      setDecoding(true);876      const result = await handleMayanDecode(image.base64String);877      setDecoding(false);878      if (!result.ok) { setError(result.reason); return; }879      setDecoded(result.payload);880    } catch (err) {881      setDecoding(false);882      const msg = err instanceof Error ? err.message : "";883      if (msg && !/cancel/i.test(msg)) setError(msg);884    }885  }886887  async function handleRestore() {888    const bytes = decoded();889    if (!bytes || !backupPass().trim() || restoring()) return;890    setRestoring(true);891    setError(null);892    try {893      const payload = await parsePayload(bytes, backupPass());894      const count = payload.entries.length;895      const date = new Date(896        Math.max(...payload.entries.map((e) => e.modifiedAt ?? e.createdAt ?? 0))897      );898      const monthYear = date.getFullYear() > 2000899        ? date.toLocaleDateString("en-US", { month: "long", year: "numeric" })900        : null;901      setEntryLine(monthYear ? `${count} entries · ${monthYear}` : `${count} entries`);902903      if (!props.hasVault) {904        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });905        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");906      }907      const rr = await sendMessage({ type: "RESTORE_ENTRIES", payload: { entries: payload.entries, mode: "merge", settings: payload.settings } });908      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");909      if (payload.license?.key) {910        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});911      }912      await props.onSuccess();913    } catch (err) {914      const code = err instanceof Error ? err.message : String(err);915      setError(916        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"917          ? "Wrong backup password."918          : code === "CREATE_VAULT_FAILED"919            ? "Couldn't create a new local vault."920            : "Restore failed. The backup may be corrupted.",921      );922    } finally {923      setRestoring(false);924    }925  }926927  return (928    <div class="peach-rt-mayan">929      <Show when={decoded() === null}>930        <label class="peach-rt-upload-zone" classList={{ "is-busy": decoding() }}>931          <span class="peach-rt-upload-label">932            {decoding() ? "decoding…" : "tap to upload image"}933          </span>934          <input935            type="file"936            accept="image/*"937            style="display:none"938            onChange={(e) => {939              const file = (e.currentTarget as HTMLInputElement).files?.[0];940              if (file) void processFile(file);941              (e.currentTarget as HTMLInputElement).value = "";942            }}943            disabled={decoding()}944          />945        </label>946        <Show when={isAndroid()}>947          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void handleCameraScan()} disabled={decoding()}>948            scan with camera949          </button>950        </Show>951        <Show when={error()}>952          <p class="peach-rt-error">{error()}</p>953        </Show>954      </Show>955      <Show when={decoded() !== null}>956        <Show when={entryLine()}>957          <p class="peach-rt-entry-line">{entryLine()}</p>958        </Show>959        <input960          class="peach-restore-symbols-pass"961          type="password"962          placeholder="backup password"963          ref={(el) => queueMicrotask(() => el.focus())}964          value={backupPass()}965          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}966          onKeyDown={(e) => {967            if (e.key === "Enter" && !restoring()) { e.preventDefault(); void handleRestore(); }968          }}969        />970        <button971          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"972          onClick={() => void handleRestore()}973          disabled={!backupPass().trim() || restoring()}974        >975          {restoring() ? "restoring…" : "restore vault →"}976        </button>977        <button class="peach-btn peach-btn--ghost peach-btn--sm" style="margin-top:4px" onClick={() => { setDecoded(null); setEntryLine(null); setError(null); }}>978          ← try another image979        </button>980        <Show when={error()}>981          <p class="peach-rt-error">{error()}</p>982        </Show>983      </Show>984    </div>985  );986}987988// ── CSV tab ────────────────────────────────────────────────────────────────989990function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {991  const [importing, setImporting] = createSignal(false);992  const [error, setError] = createSignal<string | null>(null);993994  async function handleFile(e: Event) {995    const input = e.currentTarget as HTMLInputElement;996    const file = input.files?.[0];997    input.value = "";998    if (!file) return;999    setImporting(true);1000    setError(null);1001    try {1002      const text = await file.text();1003      const parsed = await parseImportSource(text);1004      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");10051006      if (props.hasVault === false) {1007        // No vault yet — this path shouldn't normally occur for CSV (no backup password1008        // means no way to create a locked vault). Create an unlocked placeholder vault.1009        // The user can set a master password from settings.1010        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });1011        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1012      }10131014      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1015      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1016      if (parsed.licenseKey) {1017        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});1018      }1019      await props.onSuccess();1020    } catch (err) {1021      const code = err instanceof Error ? err.message : String(err);1022      setError(1023        code === "NO_ENTRIES" ? "No entries found in this file." :1024        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1025        "Import failed. Check the file format and try again."1026      );1027    } finally {1028      setImporting(false);1029    }1030  }10311032  return (1033    <div class="peach-rt-csv">1034      <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>1035        {importing() ? "importing…" : "open CSV file"}1036        <input1037          type="file"1038          accept=".csv,.json,.peach,application/x-peach"1039          style="display:none"1040          onChange={handleFile}1041          disabled={importing()}1042        />1043      </label>1044      <p class="peach-rt-csv-sources">1045        Imports from Bitwarden, 1Password, Proton Pass,<br />1046        Chrome, Safari, and Edge.1047      </p>1048      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1049      <Show when={error()}>1050        <p class="peach-rt-error">{error()}</p>1051      </Show>1052    </div>1053  );1054}10551056// ── From Phone tab ─────────────────────────────────────────────────────────10571058type PhoneState = "waiting" | "connected" | "trust" | "error";10591060function RestoreFromPhone(props: { isPro: boolean; onSuccess: () => void | Promise<void> }) {1061  const [state, setState] = createSignal<PhoneState>("waiting");1062  const [code, setCode] = createSignal<string | null>(null);1063  const [qrUrl, setQrUrl] = createSignal<string | null>(null);1064  const [expired, setExpired] = createSignal(false);1065  const [deviceName, setDeviceName] = createSignal<string | null>(null);1066  const [accepting, setAccepting] = createSignal(false);1067  const [serverError, setServerError] = createSignal<string | null>(null);1068  let expireTimer: ReturnType<typeof setTimeout> | null = null;10691070  function clearExpire() {1071    if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }1072  }10731074  async function startPairing() {1075    setExpired(false);1076    setCode(null);1077    setQrUrl(null);1078    setServerError(null);1079    clearExpire();1080    const res = await sendMessage({ type: "START_PAIRING_SERVER" });1081    const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };1082    if (!r.ok || !r.data) {1083      setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");1084      return;1085    }1086    const token = r.data.token;1087    const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;1088    setCode(formatted);1089    const qrData = `peach://handoff?token=${token}`;1090    try {1091      const QRCode = (await import("qrcode")).default;1092      const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });1093      setQrUrl(url);1094    } catch {1095      // QR optional — code is the primary affordance1096    }1097    const remaining = Math.max(0, r.data.expiresAt - Date.now());1098    expireTimer = setTimeout(() => {1099      setCode(null);1100      setQrUrl(null);1101      setExpired(true);1102    }, remaining);1103  }11041105  function handleRuntimeMsg(msg: unknown) {1106    const m = msg as { type?: string; payload?: { deviceName?: string } };1107    if (m?.type === "PAIRING_COMPLETED") {1108      clearExpire();1109      setDeviceName(m.payload?.deviceName ?? "your phone");1110      setState("trust");1111    }1112  }11131114  onMount(() => {1115    if (!props.isPro) return;1116    void startPairing();1117    // @ts-ignore — chrome runtime1118    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1119      chrome.runtime.onMessage.addListener(handleRuntimeMsg);1120    }1121  });11221123  onCleanup(() => {1124    clearExpire();1125    // @ts-ignore1126    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1127      chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1128    }1129    sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1130  });11311132  async function handleAccept(trust: boolean) {1133    setAccepting(true);1134    if (trust) {1135      await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1136    }1137    await props.onSuccess();1138  }11391140  if (!props.isPro) {1141    return (1142      <div class="peach-rt-phone">1143        <p class="peach-rt-phone-gate-text">Transfer requires Peach Pro.</p>1144        <button1145          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1146          onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro" })}1147        >1148          Get Peach Pro1149        </button>1150        <button1151          class="peach-btn peach-btn--ghost peach-btn--sm"1152          style="margin-top:6px"1153          onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro#redeem" })}1154        >1155          I already have a license1156        </button>1157      </div>1158    );1159  }11601161  return (1162    <div class="peach-rt-phone">1163      <Show when={state() === "waiting"}>1164        <Show when={!expired()}>1165          <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1166            <p class="peach-rt-phone-code">{code()}</p>1167            <Show when={qrUrl() !== null}>1168              <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1169            </Show>1170            <p class="peach-rt-phone-hint">1171              Open Peach on your phone and tap<br />1172              Settings → Send to desktop.<br />1173              Same WiFi required.1174            </p>1175          </Show>1176          <Show when={serverError()}>1177            <p class="peach-rt-error">{serverError()}</p>1178          </Show>1179        </Show>1180        <Show when={expired()}>1181          <p class="peach-rt-phone-hint">Code expired.</p>1182          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1183            Generate new code1184          </button>1185        </Show>1186      </Show>11871188      <Show when={state() === "connected"}>1189        <p class="peach-rt-phone-hint">1190          Connected to {deviceName() ?? "your phone"}.<br />1191          Receiving vault…1192        </p>1193      </Show>11941195      <Show when={state() === "trust"}>1196        <p class="peach-rt-phone-hint" style="margin-bottom:10px">1197          Vault received from {deviceName() ?? "your phone"}.<br />1198          Trust this network for sync?1199        </p>1200        <button1201          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1202          onClick={() => void handleAccept(true)}1203          disabled={accepting()}1204        >1205          Yes, trust this network1206        </button>1207        <button1208          class="peach-btn peach-btn--ghost peach-btn--sm"1209          style="margin-top:6px"1210          onClick={() => void handleAccept(false)}1211          disabled={accepting()}1212        >1213          Not now1214        </button>1215      </Show>12161217      <Show when={state() === "error"}>1218        <p class="peach-rt-phone-hint">1219          Could not connect. Check that both devices<br />1220          are on the same WiFi network.1221        </p>1222        <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1223          Try again1224        </button>1225      </Show>1226    </div>1227  );1228}12291230// ── Biometric unlock ────────────────────────────────────────────────────────12311232async function handleBiometricUnlock(props: { onUnlock: () => void | Promise<void> }) {1233  const unlocked = await biometricUnlock();1234  if (unlocked) {1235    await props.onUnlock();1236  }1237}12381239function BiometricUnlockButton(props: { ready: boolean; loading: () => boolean; onUnlock: () => void | Promise<void> }) {1240  const [bioAvailable, setBioAvailable] = createSignal(false);1241  onMount(async () => {1242    setBioAvailable(await biometricAvailable());1243  });1244  return (1245    <Show when={bioAvailable()}>1246      <button1247        class="peach-btn peach-btn--subtle peach-btn--md peach-biometric-btn"1248        onClick={() => void handleBiometricUnlock(props)}1249        disabled={!props.ready || props.loading()}1250        type="button"1251      >1252        <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1253          <path d="M8 1a3 3 0 0 0-3 3v4a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />1254          <path d="M5 8v1a3 3 0 0 0 6 0V8" />1255          <line x1="8" y1="12" x2="8" y2="15" />1256          <line x1="5" y1="15" x2="11" y2="15" />1257        </svg>1258        use biometrics1259      </button>1260    </Show>1261  );1262}12631264// ── Helpers ────────────────────────────────────────────────────────────────12651266function getDisplayError(error: string | undefined, fallback: string): string {1267  if (!error) return fallback;1268  if (1269    error === "SERVICE_WORKER_UNAVAILABLE" ||1270    error.includes("Receiving end does not exist") ||1271    error.includes("message port closed")1272  ) {1273    return "background service unavailable";1274  }1275  return error;1276}