Lock.tsx

45.5 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  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);43  const [showPassword, setShowPassword] = createSignal(false);4445  const MIN_LENGTH = 12;46  const MIN_ZXCVBN_SCORE = 2;4748  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);4950  const canCreate = createMemo(51    () =>52      password().length >= MIN_LENGTH &&53      confirmPassword().length >= MIN_LENGTH &&54      password() === confirmPassword() &&55      strengthScore() !== null &&56      strengthScore()! >= MIN_ZXCVBN_SCORE57  );5859  const passwordStrength = createMemo(() => {60    const score = strengthScore();61    if (score === null) return null;62    if (score <= 1) return "weak";63    if (score === 2) return "fair";64    if (score === 3) return "good";65    return "strong";66  });6768  const createBlockerMessage = createMemo(() => {69    if (!password()) return null;70    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;71    if (!confirmPassword()) return "confirm your password";72    if (password() !== confirmPassword()) return "passwords don't match";73    if (strengthScore() === null) return "checking strength…";74    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";75    return null;76  });7778  // Update strength asynchronously when password changes79  createMemo(() => {80    const value = password();81    if (!value) { setStrengthScore(null); return; }82    getZxcvbn().then((zxcvbn) => {83      if (password() === value) setStrengthScore(zxcvbn(value).score);84    }).catch(() => {85      // zxcvbn unavailable — fall back to length-only heuristic86      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);87    });88  });8990  async function handleUnlock() {91    if (!password().trim()) return;92    setLoading(true);93    setError("");94    const response = await sendMessage({95      type: "UNLOCK",96      payload: { password: password() },97    });98    const result = response as { ok: boolean; error?: string };99    if (result.ok) {100      await props.onUnlock();101      setLoading(false);102      return;103    }104    setError(getDisplayError(result.error, "incorrect password"));105    setPassword("");106    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;107    passwordField?.classList.add("shake");108    setTimeout(() => passwordField?.classList.remove("shake"), 300);109    setLoading(false);110  }111112  async function handleCreate() {113    if (!password().trim()) return;114    if (password().length < MIN_LENGTH) {115      setError(`password must be at least ${MIN_LENGTH} characters`);116      return;117    }118    if (password() !== confirmPassword()) {119      setError("passwords do not match");120      return;121    }122    const score = strengthScore();123    if (score === null || score < MIN_ZXCVBN_SCORE) {124      setError("password is too weak — try a longer or more varied passphrase");125      return;126    }127    setLoading(true);128    setError("");129    const response = await sendMessage({130      type: "CREATE_VAULT",131      payload: { password: password() },132    });133    const result = response as { ok: boolean; error?: string };134    if (result.ok) {135      await props.onUnlock();136      setLoading(false);137      return;138    }139    setError(getDisplayError(result.error, "failed to create vault"));140    setLoading(false);141  }142143  function openRestore(surface: Exclude<RestoreSurface, null>) {144    if (!props.ready || loading()) return;145    setPassword("");146    setConfirmPassword("");147    setError("");148    setRestoreSurface(surface);149  }150151  // ── All surfaces routed reactively via Switch/Match ───────────────────152  return (153    <Switch>154      {/* ── Restore tab view ──────────────────────────────────────── */}155      <Match when={restoreSurface() === "restore"}>156        <RestoreTabView157          hasVault={props.hasVault}158          isPro={props.isPro ?? false}159          onBack={() => setRestoreSurface(null)}160          onUnlock={async () => {161            setRestoreSurface(null);162            await props.onUnlock();163          }}164        />165      </Match>166167      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}168      <Match when={props.hasVault}>169        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">170          <header class="peach-screen-header lock-screen-header">171            <div class="peach-wordmark-stack">172              <PeachWordmark class="peach-wordmark-wrap" />173              <Show when={props.isPro}>174                <span class="peach-pro-mini-badge">pro</span>175              </Show>176            </div>177          </header>178179          <div class="peach-lock-hero peach-lock-hero--unlock">180            <div class="peach-lock-hero-art" aria-hidden="true">181              <PeachMark class="peach-lock-hero-mark" />182            </div>183            <span class="peach-lock-eyebrow">vault locked</span>184            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">185              unlock186              <br />187              your vault.188            </h1>189          </div>190191          <LockRuneCanvas />192193          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">194            <label class="peach-label" for="unlock-password">195              master password196            </label>197            <input198              id="unlock-password"199              class="peach-underline-input peach-unlock-input lock-password-input"200              type="password"201              placeholder="••••••••••••"202              autocomplete="current-password"203              value={password()}204              disabled={!props.ready}205              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}206              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}207              onKeyDown={(event) => {208                if (event.key !== "Enter" || !props.ready) return;209                void handleUnlock();210              }}211            />212213            <Show when={error()}>214              <p class="peach-hint peach-hint-error">{error()}</p>215            </Show>216217            <button218              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"219              onClick={() => void handleUnlock()}220              disabled={!props.ready || loading()}221            >222              {loading() ? "unlocking..." : "unlock"}223            </button>224225            <Show when={Capacitor.isNativePlatform()}>226              <BiometricUnlockButton ready={props.ready} loading={loading} onUnlock={props.onUnlock} />227            </Show>228          </section>229        </section>230      </Match>231232      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}233      <Match when={true}>234        <section235          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"236          data-motion-phase="enter"237        >238          <header class="peach-screen-header peach-newvault-header">239            <Show when={props.onBackToOnboarding}>240              <button241                class="peach-newvault-back"242                type="button"243                onClick={() => props.onBackToOnboarding?.()}244                aria-label="Back to onboarding"245              >246                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">247                  <path d="M7 2L3 5.5L7 9" />248                </svg>249                back250              </button>251            </Show>252            <div class="peach-wordmark-stack">253              <PeachWordmark class="peach-wordmark-wrap" />254              <Show when={props.isPro}>255                <span class="peach-pro-mini-badge">pro</span>256              </Show>257            </div>258          </header>259260          <div class="peach-newvault-mark" aria-hidden="true">261            <PeachMark class="peach-newvault-mark-art" />262          </div>263264          <div class="peach-newvault-card">265            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>266            <hr class="peach-newvault-divider" />267268            <div class="peach-newvault-field">269              <div class="peach-newvault-label-row">270                <label class="peach-label" for="master-password">271                  master password272                </label>273                <button274                  class="peach-lock-reveal-btn"275                  type="button"276                  onClick={() => setShowPassword((value) => !value)}277                  aria-label={showPassword() ? "Hide password" : "Show password"}278                >279                  <Show280                    when={showPassword()}281                    fallback={282                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">283                        <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" />284                        <circle cx="7" cy="5" r="1.75" />285                      </svg>286                    }287                  >288                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">289                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />290                      <path d="M3.5 5.5L2.5 7.5" />291                      <path d="M7 6.5V8.5" />292                      <path d="M10.5 5.5L11.5 7.5" />293                    </svg>294                  </Show>295                  {showPassword() ? "hide" : "show"}296                </button>297              </div>298              <input299                id="master-password"300                class="peach-underline-input peach-newvault-input lock-password-input"301                type={showPassword() ? "text" : "password"}302                placeholder="••••••••••••"303                autocomplete="new-password"304                value={password()}305                disabled={!props.ready}306                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}307                onKeyDown={(event) => {308                  if (event.key !== "Enter" || !props.ready) return;309                  void handleCreate();310                }}311              />312              <Show313                when={password().length > 0 && passwordStrength() !== null}314                fallback={315                  <p class="peach-newvault-hint">at least 12 characters</p>316                }317              >318                <div class="peach-password-strength">319                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>320                    <div class="peach-strength-fill"></div>321                  </div>322                  <span class="peach-strength-label">{passwordStrength()}</span>323                </div>324              </Show>325            </div>326327            <div class="peach-newvault-field">328              <label class="peach-label" for="confirm-password">329                confirm password330              </label>331              <input332                id="confirm-password"333                class="peach-underline-input peach-newvault-input"334                type={showPassword() ? "text" : "password"}335                placeholder="••••••••••••"336                autocomplete="new-password"337                value={confirmPassword()}338                disabled={!props.ready}339                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}340                onKeyDown={(event) => {341                  if (event.key === "Enter" && props.ready) void handleCreate();342                }}343              />344            </div>345346            <Show when={error()}>347              <p class="peach-hint peach-hint-error">{error()}</p>348            </Show>349350            <button351              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"352              onClick={() => void handleCreate()}353              disabled={!props.ready || loading() || !canCreate()}354              aria-live="polite"355            >356              <Show357                when={!canCreate() && createBlockerMessage()}358                fallback={loading() ? "creating..." : "create vault"}359              >360                {createBlockerMessage()}361              </Show>362            </button>363364            <p class="peach-newvault-warning">365              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>366              <span>If you forget it, your vault cannot be opened. There is no reset.</span>367            </p>368          </div>369370          <div class="peach-newvault-footer">371            <p class="peach-hint">Already have a backup?</p>372            <button373              class="peach-lock-restore-link"374              onClick={() => openRestore("restore")}375              disabled={!props.ready}376            >377              › restore from backup378            </button>379          </div>380        </section>381      </Match>382    </Switch>383  );384}385386// ── Restore tab view ───────────────────────────────────────────────────────387388type RestoreTab = "symbols" | "mayan" | "csv" | "phone";389390function tabLabel(tab: RestoreTab): string {391  return (392    { symbols: "PeachScript", mayan: "Mayan Code", csv: "CSV", phone: "From Phone" }[tab]393  );394}395396function RestoreTabView(props: {397  hasVault: boolean;398  isPro: boolean;399  onBack: () => void;400  onUnlock: () => void | Promise<void>;401}) {402  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");403  const [showIntro, setShowIntro] = createSignal(false);404  const [pendingUnlock, setPendingUnlock] = createSignal(false);405406  async function handleRestoreSuccess() {407    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);408    if (!seen) {409      setShowIntro(true);410    } else {411      await props.onUnlock();412    }413  }414415  async function dismissIntro() {416    await storageSet("peach_shown_restore_intro", "true").catch(() => {});417    setShowIntro(false);418    await props.onUnlock();419  }420421  return (422    <section423      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"424      data-motion-phase="enter"425    >426      <RuneBackground opacity={0.05} />427428      <header class="peach-screen-header lock-screen-header">429        <PeachWordmark class="peach-wordmark-wrap" />430        <button class="peach-lock-restore-link" onClick={props.onBack}>431          ‹ back432        </button>433      </header>434435      <div class="peach-newvault-card peach-restore-tabcard">436        <div class="peach-restore-tabs" role="tablist">437          <For each={["symbols", "mayan", "csv", "phone"] as RestoreTab[]}>438            {(tab) => (439              <button440                class="peach-restore-tab"441                classList={{ "is-active": activeTab() === tab }}442                onClick={() => setActiveTab(tab)}443                role="tab"444                aria-selected={activeTab() === tab}445              >446                {tabLabel(tab)}447              </button>448            )}449          </For>450        </div>451        <div class="peach-restore-content">452          <Show when={activeTab() === "symbols"}>453            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />454          </Show>455          <Show when={activeTab() === "mayan"}>456            <RestoreMayan hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />457          </Show>458          <Show when={activeTab() === "csv"}>459            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />460          </Show>461          <Show when={activeTab() === "phone"}>462            <RestoreFromPhone isPro={props.isPro} onSuccess={handleRestoreSuccess} />463          </Show>464        </div>465      </div>466467      {/* One-time intro popup after first restore */}468      <Show when={showIntro()}>469        <div class="peach-restore-intro-overlay">470          <div class="peach-restore-intro-card">471            <p class="peach-restore-intro-headline">welcome to peach.</p>472            <div class="peach-restore-intro-body">473              <p>Your vault lives only on this device. No server has a copy.</p>474              <p>The inline bubble fills passwords automatically when you visit sites.</p>475              <p>Export your vault from Settings before switching devices.</p>476            </div>477            <button478              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"479              onClick={() => void dismissIntro()}480              disabled={pendingUnlock()}481            >482              got it483            </button>484          </div>485        </div>486      </Show>487    </section>488  );489}490491// ── Mayan image decode helper ──────────────────────────────────────────────492493async function handleMayanDecode(base64: string): Promise<494  { ok: true; payload: Uint8Array } | { ok: false; reason: string }495> {496  const { decodeMayan } = await import("../../crypto/mayan");497  const img = new Image();498  const dataUrl = base64.startsWith("data:") ? base64 : `data:image/*;base64,${base64}`;499  await new Promise<void>((resolve, reject) => {500    img.onload = () => resolve();501    img.onerror = () => reject(new Error("Couldn't read the image."));502    img.src = dataUrl;503  });504  const canvas = document.createElement("canvas");505  // Cap resolution so adaptive-binarize stays bounded.506  const MAX_DIM = 1500;507  const scale = Math.min(1, MAX_DIM / Math.max(img.width, img.height));508  canvas.width = Math.round(img.width * scale);509  canvas.height = Math.round(img.height * scale);510  const ctx = canvas.getContext("2d");511  if (!ctx) return { ok: false, reason: "Couldn't open a 2D canvas." };512  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);513  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);514  const payload = decodeMayan(imageData);515  if (!payload) {516    return {517      ok: false,518      reason: "Could not read this Mayan code. Try a clearer image.",519    };520  }521  return { ok: true, payload };522}523524// ── PeachScript tab ────────────────────────────────────────────────────────525526function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {527  const [scriptInput, setScriptInput] = createSignal("");528  const [backupPass, setBackupPass] = createSignal("");529  const [working, setWorking] = createSignal(false);530  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);531  const [error, setError] = createSignal<string | null>(null);532  let canvasRef: HTMLCanvasElement | undefined;533  let animFrameId = 0;534  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];535536  const scriptValid = createMemo(() => isPeachScript(scriptInput()));537538  function stopAnim() {539    cancelAnimationFrame(animFrameId);540    animFrameId = 0;541  }542543  function startChaos(canvas: HTMLCanvasElement) {544    const ctx = canvas.getContext("2d")!;545    const W = canvas.width;546    const H = canvas.height;547    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>548      String.fromCodePoint(0xe000 + i)549    );550    if (runes.length === 0) {551      runes = Array.from({ length: 60 }, () => ({552        x: Math.random() * W,553        y: Math.random() * H,554        vx: (Math.random() - 0.5) * 3,555        vy: (Math.random() - 0.5) * 3,556        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,557        size: 14 + Math.random() * 20,558        angle: Math.random() * Math.PI * 2,559        va: (Math.random() - 0.5) * 0.1,560      }));561    }562563    function draw() {564      ctx.clearRect(0, 0, W, H);565      for (const r of runes) {566        r.x = (r.x + r.vx + W) % W;567        r.y = (r.y + r.vy + H) % H;568        r.angle += r.va;569        ctx.save();570        ctx.translate(r.x, r.y);571        ctx.rotate(r.angle);572        ctx.font = `${r.size}px PeachScript, monospace`;573        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);574        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;575        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";576        ctx.shadowBlur = 8;577        ctx.fillText(r.char, 0, 0);578        ctx.restore();579      }580      animFrameId = requestAnimationFrame(draw);581    }582    draw();583  }584585  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {586    stopAnim();587    const ctx = canvas.getContext("2d")!;588    const W = canvas.width;589    const H = canvas.height;590    const cols = 8;591    const rows = 6;592    const cellW = W / cols;593    const cellH = H / rows;594    const targets = runes.slice(0, cols * rows).map((_, i) => ({595      x: (i % cols) * cellW + cellW / 2,596      y: Math.floor(i / cols) * cellH + cellH / 2,597    }));598    const start = performance.now();599    const DURATION = 400;600601    function drawSnap(now: number) {602      const t = Math.min((now - start) / DURATION, 1);603      const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;604      ctx.clearRect(0, 0, W, H);605      runes.slice(0, targets.length).forEach((r, i) => {606        const tx = targets[i]!.x;607        const ty = targets[i]!.y;608        const cx = r.x + (tx - r.x) * ease;609        const cy = r.y + (ty - r.y) * ease;610        ctx.font = `16px PeachScript, monospace`;611        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;612        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";613        ctx.shadowBlur = 6;614        ctx.fillText(r.char, cx - 8, cy + 8);615      });616      if (t < 1) {617        animFrameId = requestAnimationFrame(drawSnap);618      } else {619        onDone();620      }621    }622    animFrameId = requestAnimationFrame(drawSnap);623  }624625  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {626    stopAnim();627    const ctx = canvas.getContext("2d")!;628    const W = canvas.width;629    const H = canvas.height;630    const start = performance.now();631    const DURATION = 900;632633    function drawBloom(now: number) {634      const t = Math.min((now - start) / DURATION, 1);635      ctx.clearRect(0, 0, W, H);636      // Runes dissolve637      const runeAlpha = 1 - Math.min(t / 0.4, 1);638      if (runeAlpha > 0) {639        for (const r of runes.slice(0, 48)) {640          ctx.font = `16px PeachScript, monospace`;641          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;642          ctx.fillText(r.char, r.x - 8, r.y + 8);643        }644      }645      // Peach mark bloom646      const markT = Math.max(0, (t - 0.2) / 0.5);647      const markScale = Math.min(markT * 1.2, 1);648      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;649      if (markAlpha > 0) {650        ctx.save();651        ctx.translate(W / 2, H / 2);652        ctx.scale(markScale, markScale);653        ctx.font = `bold 64px serif`;654        ctx.fillStyle = `rgba(232, 149, 109, ${markAlpha})`;655        ctx.shadowColor = `rgba(232, 149, 109, ${markAlpha * 0.8})`;656        ctx.shadowBlur = 30;657        ctx.textAlign = "center";658        ctx.textBaseline = "middle";659        ctx.fillText("🍑", 0, 0);660        ctx.restore();661      }662      if (t < 1) {663        animFrameId = requestAnimationFrame(drawBloom);664      } else {665        onDone();666      }667    }668    animFrameId = requestAnimationFrame(drawBloom);669  }670671  onCleanup(() => stopAnim());672673  async function handleRestore() {674    if (!scriptValid() || !backupPass().trim() || working()) return;675    setError(null);676    setWorking(true);677    setAnimating("chaos");678679    const canvas = canvasRef;680    if (canvas) startChaos(canvas);681682    try {683      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));684      const payload = await parsePayload(bytes, backupPass());685686      if (!props.hasVault) {687        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });688        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");689      }690691      const rr = await sendMessage({692        type: "RESTORE_ENTRIES",693        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },694      });695      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");696697      if (payload.license?.key) {698        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});699      }700701      setAnimating("snap");702      stopAnim();703      if (canvas) {704        snapToGrid(canvas, () => {705          setAnimating("bloom");706          bloom(canvas, async () => {707            setAnimating(null);708            await props.onSuccess();709          });710        });711      } else {712        await props.onSuccess();713      }714    } catch (err) {715      stopAnim();716      setAnimating(null);717      const code = err instanceof Error ? err.message : String(err);718      setError(719        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"720          ? "That passphrase didn't work."721          : code === "CREATE_VAULT_FAILED"722            ? "Couldn't create a new local vault."723            : "Could not read this backup.",724      );725    } finally {726      setWorking(false);727    }728  }729730  return (731    <div class="peach-rt-symbols">732      <Show when={animating() !== null}>733        <canvas734          ref={(el) => { canvasRef = el; }}735          class="peach-rt-anim-canvas"736          width={280}737          height={200}738        />739      </Show>740      <Show when={animating() === null}>741        <textarea742          class="peach-restore-symbols-textarea"743          placeholder="paste PeachScript here…"744          rows={3}745          value={scriptInput()}746          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}747        />748        <div class="peach-rt-divider">— or —</div>749        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">750          open .peach file751          <input752            type="file"753            accept=".peach,text/plain"754            style="display:none"755            onChange={async (e) => {756              const file = (e.currentTarget as HTMLInputElement).files?.[0];757              if (!file) return;758              try {759                setScriptInput(await file.text());760              } catch {761                setError("Couldn't read file.");762              } finally {763                (e.currentTarget as HTMLInputElement).value = "";764              }765            }}766          />767        </label>768        <input769          class="peach-restore-symbols-pass"770          type="password"771          placeholder="backup password"772          value={backupPass()}773          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}774          onKeyDown={(e) => {775            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {776              e.preventDefault();777              void handleRestore();778            }779          }}780        />781        <button782          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"783          onClick={() => void handleRestore()}784          disabled={!scriptValid() || !backupPass().trim() || working()}785        >786          restore vault →787        </button>788        <Show when={error()}>789          <p class="peach-rt-error">{error()}</p>790        </Show>791      </Show>792    </div>793  );794}795796// ── Mayan Code tab ─────────────────────────────────────────────────────────797798function RestoreMayan(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {799  const [decoding, setDecoding] = createSignal(false);800  const [decoded, setDecoded] = createSignal<Uint8Array | null>(null);801  const [entryLine, setEntryLine] = createSignal<string | null>(null);802  const [backupPass, setBackupPass] = createSignal("");803  const [restoring, setRestoring] = createSignal(false);804  const [error, setError] = createSignal<string | null>(null);805806  const isAndroid = createMemo(() =>807    Capacitor.isNativePlatform() && Capacitor.getPlatform() === "android"808  );809810  async function processFile(file: File) {811    setDecoding(true);812    setDecoded(null);813    setEntryLine(null);814    setError(null);815    try {816      const reader = new FileReader();817      const base64 = await new Promise<string>((res, rej) => {818        reader.onload = () => res(reader.result as string);819        reader.onerror = () => rej(new Error("read error"));820        reader.readAsDataURL(file);821      });822      const result = await handleMayanDecode(base64);823      if (!result.ok) { setError(result.reason); return; }824      setDecoded(result.payload);825    } catch {826      setError("Could not read this Mayan code. Try a clearer image.");827    } finally {828      setDecoding(false);829    }830  }831832  async function handleCameraScan() {833    setError(null);834    try {835      const { Camera, CameraResultType, CameraSource } = await import("@capacitor/camera");836      const image = await Camera.getPhoto({ quality: 90, resultType: CameraResultType.Base64, source: CameraSource.Camera, allowEditing: false });837      if (!image.base64String) return;838      setDecoding(true);839      const result = await handleMayanDecode(image.base64String);840      setDecoding(false);841      if (!result.ok) { setError(result.reason); return; }842      setDecoded(result.payload);843    } catch (err) {844      setDecoding(false);845      const msg = err instanceof Error ? err.message : "";846      if (msg && !/cancel/i.test(msg)) setError(msg);847    }848  }849850  async function handleRestore() {851    const bytes = decoded();852    if (!bytes || !backupPass().trim() || restoring()) return;853    setRestoring(true);854    setError(null);855    try {856      const payload = await parsePayload(bytes, backupPass());857      const count = payload.entries.length;858      const date = new Date(859        Math.max(...payload.entries.map((e) => e.modifiedAt ?? e.createdAt ?? 0))860      );861      const monthYear = date.getFullYear() > 2000862        ? date.toLocaleDateString("en-US", { month: "long", year: "numeric" })863        : null;864      setEntryLine(monthYear ? `${count} entries · ${monthYear}` : `${count} entries`);865866      if (!props.hasVault) {867        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });868        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");869      }870      const rr = await sendMessage({ type: "RESTORE_ENTRIES", payload: { entries: payload.entries, mode: "merge", settings: payload.settings } });871      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");872      if (payload.license?.key) {873        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});874      }875      await props.onSuccess();876    } catch (err) {877      const code = err instanceof Error ? err.message : String(err);878      setError(879        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"880          ? "Wrong backup password."881          : code === "CREATE_VAULT_FAILED"882            ? "Couldn't create a new local vault."883            : "Restore failed. The backup may be corrupted.",884      );885    } finally {886      setRestoring(false);887    }888  }889890  return (891    <div class="peach-rt-mayan">892      <Show when={decoded() === null}>893        <label class="peach-rt-upload-zone" classList={{ "is-busy": decoding() }}>894          <span class="peach-rt-upload-label">895            {decoding() ? "decoding…" : "tap to upload image"}896          </span>897          <input898            type="file"899            accept="image/*"900            style="display:none"901            onChange={(e) => {902              const file = (e.currentTarget as HTMLInputElement).files?.[0];903              if (file) void processFile(file);904              (e.currentTarget as HTMLInputElement).value = "";905            }}906            disabled={decoding()}907          />908        </label>909        <Show when={isAndroid()}>910          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void handleCameraScan()} disabled={decoding()}>911            scan with camera912          </button>913        </Show>914        <Show when={error()}>915          <p class="peach-rt-error">{error()}</p>916        </Show>917      </Show>918      <Show when={decoded() !== null}>919        <Show when={entryLine()}>920          <p class="peach-rt-entry-line">{entryLine()}</p>921        </Show>922        <input923          class="peach-restore-symbols-pass"924          type="password"925          placeholder="backup password"926          ref={(el) => queueMicrotask(() => el.focus())}927          value={backupPass()}928          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}929          onKeyDown={(e) => {930            if (e.key === "Enter" && !restoring()) { e.preventDefault(); void handleRestore(); }931          }}932        />933        <button934          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"935          onClick={() => void handleRestore()}936          disabled={!backupPass().trim() || restoring()}937        >938          {restoring() ? "restoring…" : "restore vault →"}939        </button>940        <button class="peach-btn peach-btn--ghost peach-btn--sm" style="margin-top:4px" onClick={() => { setDecoded(null); setEntryLine(null); setError(null); }}>941          ← try another image942        </button>943        <Show when={error()}>944          <p class="peach-rt-error">{error()}</p>945        </Show>946      </Show>947    </div>948  );949}950951// ── CSV tab ────────────────────────────────────────────────────────────────952953function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {954  const [importing, setImporting] = createSignal(false);955  const [error, setError] = createSignal<string | null>(null);956957  async function handleFile(e: Event) {958    const input = e.currentTarget as HTMLInputElement;959    const file = input.files?.[0];960    input.value = "";961    if (!file) return;962    setImporting(true);963    setError(null);964    try {965      const text = await file.text();966      const parsed = await parseImportSource(text);967      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");968969      if (props.hasVault === false) {970        // No vault yet — this path shouldn't normally occur for CSV (no backup password971        // means no way to create a locked vault). Create an unlocked placeholder vault.972        // The user can set a master password from settings.973        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });974        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");975      }976977      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });978      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");979      if (parsed.licenseKey) {980        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});981      }982      await props.onSuccess();983    } catch (err) {984      const code = err instanceof Error ? err.message : String(err);985      setError(986        code === "NO_ENTRIES" ? "No entries found in this file." :987        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :988        "Import failed. Check the file format and try again."989      );990    } finally {991      setImporting(false);992    }993  }994995  return (996    <div class="peach-rt-csv">997      <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>998        {importing() ? "importing…" : "open CSV file"}999        <input1000          type="file"1001          accept=".csv,.json,.peach,application/x-peach"1002          style="display:none"1003          onChange={handleFile}1004          disabled={importing()}1005        />1006      </label>1007      <p class="peach-rt-csv-sources">1008        Imports from Bitwarden, 1Password, Proton Pass,<br />1009        Chrome, Safari, and Edge.1010      </p>1011      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1012      <Show when={error()}>1013        <p class="peach-rt-error">{error()}</p>1014      </Show>1015    </div>1016  );1017}10181019// ── From Phone tab ─────────────────────────────────────────────────────────10201021type PhoneState = "waiting" | "connected" | "trust" | "error";10221023function RestoreFromPhone(props: { isPro: boolean; onSuccess: () => void | Promise<void> }) {1024  const [state, setState] = createSignal<PhoneState>("waiting");1025  const [code, setCode] = createSignal<string | null>(null);1026  const [qrUrl, setQrUrl] = createSignal<string | null>(null);1027  const [expired, setExpired] = createSignal(false);1028  const [deviceName, setDeviceName] = createSignal<string | null>(null);1029  const [accepting, setAccepting] = createSignal(false);1030  const [serverError, setServerError] = createSignal<string | null>(null);1031  let expireTimer: ReturnType<typeof setTimeout> | null = null;10321033  function clearExpire() {1034    if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }1035  }10361037  async function startPairing() {1038    setExpired(false);1039    setCode(null);1040    setQrUrl(null);1041    setServerError(null);1042    clearExpire();1043    const res = await sendMessage({ type: "START_PAIRING_SERVER" });1044    const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };1045    if (!r.ok || !r.data) {1046      setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");1047      return;1048    }1049    const token = r.data.token;1050    const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;1051    setCode(formatted);1052    const qrData = `peach://handoff?token=${token}`;1053    try {1054      const QRCode = (await import("qrcode")).default;1055      const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });1056      setQrUrl(url);1057    } catch {1058      // QR optional — code is the primary affordance1059    }1060    const remaining = Math.max(0, r.data.expiresAt - Date.now());1061    expireTimer = setTimeout(() => {1062      setCode(null);1063      setQrUrl(null);1064      setExpired(true);1065    }, remaining);1066  }10671068  function handleRuntimeMsg(msg: unknown) {1069    const m = msg as { type?: string; payload?: { deviceName?: string } };1070    if (m?.type === "PAIRING_COMPLETED") {1071      clearExpire();1072      setDeviceName(m.payload?.deviceName ?? "your phone");1073      setState("trust");1074    }1075  }10761077  onMount(() => {1078    if (!props.isPro) return;1079    void startPairing();1080    // @ts-ignore — chrome runtime1081    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1082      chrome.runtime.onMessage.addListener(handleRuntimeMsg);1083    }1084  });10851086  onCleanup(() => {1087    clearExpire();1088    // @ts-ignore1089    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1090      chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1091    }1092    sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1093  });10941095  async function handleAccept(trust: boolean) {1096    setAccepting(true);1097    if (trust) {1098      await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1099    }1100    await props.onSuccess();1101  }11021103  if (!props.isPro) {1104    return (1105      <div class="peach-rt-phone">1106        <p class="peach-rt-phone-gate-text">Transfer requires Peach Pro.</p>1107        <button1108          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1109          onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro" })}1110        >1111          Get Peach Pro1112        </button>1113        <button1114          class="peach-btn peach-btn--ghost peach-btn--sm"1115          style="margin-top:6px"1116          onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro#redeem" })}1117        >1118          I already have a license1119        </button>1120      </div>1121    );1122  }11231124  return (1125    <div class="peach-rt-phone">1126      <Show when={state() === "waiting"}>1127        <Show when={!expired()}>1128          <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1129            <p class="peach-rt-phone-code">{code()}</p>1130            <Show when={qrUrl() !== null}>1131              <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1132            </Show>1133            <p class="peach-rt-phone-hint">1134              Open Peach on your phone and tap<br />1135              Settings → Send to desktop.<br />1136              Same WiFi required.1137            </p>1138          </Show>1139          <Show when={serverError()}>1140            <p class="peach-rt-error">{serverError()}</p>1141          </Show>1142        </Show>1143        <Show when={expired()}>1144          <p class="peach-rt-phone-hint">Code expired.</p>1145          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1146            Generate new code1147          </button>1148        </Show>1149      </Show>11501151      <Show when={state() === "connected"}>1152        <p class="peach-rt-phone-hint">1153          Connected to {deviceName() ?? "your phone"}.<br />1154          Receiving vault…1155        </p>1156      </Show>11571158      <Show when={state() === "trust"}>1159        <p class="peach-rt-phone-hint" style="margin-bottom:10px">1160          Vault received from {deviceName() ?? "your phone"}.<br />1161          Trust this network for sync?1162        </p>1163        <button1164          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1165          onClick={() => void handleAccept(true)}1166          disabled={accepting()}1167        >1168          Yes, trust this network1169        </button>1170        <button1171          class="peach-btn peach-btn--ghost peach-btn--sm"1172          style="margin-top:6px"1173          onClick={() => void handleAccept(false)}1174          disabled={accepting()}1175        >1176          Not now1177        </button>1178      </Show>11791180      <Show when={state() === "error"}>1181        <p class="peach-rt-phone-hint">1182          Could not connect. Check that both devices<br />1183          are on the same WiFi network.1184        </p>1185        <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1186          Try again1187        </button>1188      </Show>1189    </div>1190  );1191}11921193// ── Biometric unlock ────────────────────────────────────────────────────────11941195async function handleBiometricUnlock(props: { onUnlock: () => void | Promise<void> }) {1196  const unlocked = await biometricUnlock();1197  if (unlocked) {1198    await props.onUnlock();1199  }1200}12011202function BiometricUnlockButton(props: { ready: boolean; loading: () => boolean; onUnlock: () => void | Promise<void> }) {1203  const [bioAvailable, setBioAvailable] = createSignal(false);1204  onMount(async () => {1205    setBioAvailable(await biometricAvailable());1206  });1207  return (1208    <Show when={bioAvailable()}>1209      <button1210        class="peach-btn peach-btn--subtle peach-btn--md peach-biometric-btn"1211        onClick={() => void handleBiometricUnlock(props)}1212        disabled={!props.ready || props.loading()}1213        type="button"1214      >1215        <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1216          <path d="M8 1a3 3 0 0 0-3 3v4a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />1217          <path d="M5 8v1a3 3 0 0 0 6 0V8" />1218          <line x1="8" y1="12" x2="8" y2="15" />1219          <line x1="5" y1="15" x2="11" y2="15" />1220        </svg>1221        use biometrics1222      </button>1223    </Show>1224  );1225}12261227// ── Helpers ────────────────────────────────────────────────────────────────12281229function getDisplayError(error: string | undefined, fallback: string): string {1230  if (!error) return fallback;1231  if (1232    error === "SERVICE_WORKER_UNAVAILABLE" ||1233    error.includes("Receiving end does not exist") ||1234    error.includes("message port closed")1235  ) {1236    return "background service unavailable";1237  }1238  return error;1239}