Lock.tsx

46.4 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  async function handleRestoreSuccess() {428    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);429    if (!seen) {430      setShowIntro(true);431    } else {432      await props.onUnlock();433    }434  }435436  async function dismissIntro() {437    await storageSet("peach_shown_restore_intro", "true").catch(() => {});438    setShowIntro(false);439    await props.onUnlock();440  }441442  return (443    <section444      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"445      data-motion-phase="enter"446    >447      <RuneBackground opacity={0.05} />448449      <header class="peach-screen-header lock-screen-header">450        <PeachWordmark class="peach-wordmark-wrap" />451        <button class="peach-lock-restore-link" onClick={props.onBack}>452          ‹ back453        </button>454      </header>455456      <div class="peach-newvault-card peach-restore-tabcard">457        <div class="peach-restore-tabs" role="tablist">458          <For each={["symbols", "mayan", "csv", "phone"] as RestoreTab[]}>459            {(tab) => (460              <button461                class="peach-restore-tab"462                classList={{ "is-active": activeTab() === tab }}463                onClick={() => setActiveTab(tab)}464                role="tab"465                aria-selected={activeTab() === tab}466              >467                {tabLabel(tab)}468              </button>469            )}470          </For>471        </div>472        <div class="peach-restore-content">473          <Show when={activeTab() === "symbols"}>474            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />475          </Show>476          <Show when={activeTab() === "mayan"}>477            <RestoreMayan hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />478          </Show>479          <Show when={activeTab() === "csv"}>480            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />481          </Show>482          <Show when={activeTab() === "phone"}>483            <RestoreFromPhone isPro={props.isPro} onSuccess={handleRestoreSuccess} />484          </Show>485        </div>486      </div>487488      {/* One-time intro popup after first restore */}489      <Show when={showIntro()}>490        <div class="peach-restore-intro-overlay">491          <div class="peach-restore-intro-card">492            <p class="peach-restore-intro-headline">welcome to peach.</p>493            <div class="peach-restore-intro-body">494              <p>Your vault lives only on this device. No server has a copy.</p>495              <p>The inline bubble fills passwords automatically when you visit sites.</p>496              <p>Export your vault from Settings before switching devices.</p>497            </div>498            <button499              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"500              onClick={() => void dismissIntro()}501              disabled={pendingUnlock()}502            >503              got it504            </button>505          </div>506        </div>507      </Show>508    </section>509  );510}511512// ── Mayan image decode helper ──────────────────────────────────────────────513514async function handleMayanDecode(base64: string): Promise<515  { ok: true; payload: Uint8Array } | { ok: false; reason: string }516> {517  const { decodeMayan } = await import("../../crypto/mayan");518  const img = new Image();519  const dataUrl = base64.startsWith("data:") ? base64 : `data:image/*;base64,${base64}`;520  await new Promise<void>((resolve, reject) => {521    img.onload = () => resolve();522    img.onerror = () => reject(new Error("Couldn't read the image."));523    img.src = dataUrl;524  });525  const canvas = document.createElement("canvas");526  // Cap resolution so adaptive-binarize stays bounded.527  const MAX_DIM = 1500;528  const scale = Math.min(1, MAX_DIM / Math.max(img.width, img.height));529  canvas.width = Math.round(img.width * scale);530  canvas.height = Math.round(img.height * scale);531  const ctx = canvas.getContext("2d");532  if (!ctx) return { ok: false, reason: "Couldn't open a 2D canvas." };533  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);534  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);535  const payload = decodeMayan(imageData);536  if (!payload) {537    return {538      ok: false,539      reason: "Could not read this Mayan code. Try a clearer image.",540    };541  }542  return { ok: true, payload };543}544545// ── PeachScript tab ────────────────────────────────────────────────────────546547function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {548  const [scriptInput, setScriptInput] = createSignal("");549  const [backupPass, setBackupPass] = createSignal("");550  const [working, setWorking] = createSignal(false);551  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);552  const [error, setError] = createSignal<string | null>(null);553  let canvasRef: HTMLCanvasElement | undefined;554  let animFrameId = 0;555  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];556557  const scriptValid = createMemo(() => isPeachScript(scriptInput()));558559  function stopAnim() {560    cancelAnimationFrame(animFrameId);561    animFrameId = 0;562  }563564  function startChaos(canvas: HTMLCanvasElement) {565    const ctx = canvas.getContext("2d")!;566    const W = canvas.width;567    const H = canvas.height;568    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>569      String.fromCodePoint(0xe000 + i)570    );571    if (runes.length === 0) {572      runes = Array.from({ length: 60 }, () => ({573        x: Math.random() * W,574        y: Math.random() * H,575        vx: (Math.random() - 0.5) * 3,576        vy: (Math.random() - 0.5) * 3,577        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,578        size: 14 + Math.random() * 20,579        angle: Math.random() * Math.PI * 2,580        va: (Math.random() - 0.5) * 0.1,581      }));582    }583584    function draw() {585      ctx.clearRect(0, 0, W, H);586      for (const r of runes) {587        r.x = (r.x + r.vx + W) % W;588        r.y = (r.y + r.vy + H) % H;589        r.angle += r.va;590        ctx.save();591        ctx.translate(r.x, r.y);592        ctx.rotate(r.angle);593        ctx.font = `${r.size}px PeachScript, monospace`;594        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);595        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;596        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";597        ctx.shadowBlur = 8;598        ctx.fillText(r.char, 0, 0);599        ctx.restore();600      }601      animFrameId = requestAnimationFrame(draw);602    }603    draw();604  }605606  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {607    stopAnim();608    const ctx = canvas.getContext("2d")!;609    const W = canvas.width;610    const H = canvas.height;611    const cols = 8;612    const rows = 6;613    const cellW = W / cols;614    const cellH = H / rows;615    const targets = runes.slice(0, cols * rows).map((_, i) => ({616      x: (i % cols) * cellW + cellW / 2,617      y: Math.floor(i / cols) * cellH + cellH / 2,618    }));619    const start = performance.now();620    const DURATION = 400;621622    function drawSnap(now: number) {623      const t = Math.min((now - start) / DURATION, 1);624      const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;625      ctx.clearRect(0, 0, W, H);626      runes.slice(0, targets.length).forEach((r, i) => {627        const tx = targets[i]!.x;628        const ty = targets[i]!.y;629        const cx = r.x + (tx - r.x) * ease;630        const cy = r.y + (ty - r.y) * ease;631        ctx.font = `16px PeachScript, monospace`;632        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;633        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";634        ctx.shadowBlur = 6;635        ctx.fillText(r.char, cx - 8, cy + 8);636      });637      if (t < 1) {638        animFrameId = requestAnimationFrame(drawSnap);639      } else {640        onDone();641      }642    }643    animFrameId = requestAnimationFrame(drawSnap);644  }645646  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {647    stopAnim();648    const ctx = canvas.getContext("2d")!;649    const W = canvas.width;650    const H = canvas.height;651    const start = performance.now();652    const DURATION = 900;653654    function drawBloom(now: number) {655      const t = Math.min((now - start) / DURATION, 1);656      ctx.clearRect(0, 0, W, H);657      // Runes dissolve658      const runeAlpha = 1 - Math.min(t / 0.4, 1);659      if (runeAlpha > 0) {660        for (const r of runes.slice(0, 48)) {661          ctx.font = `16px PeachScript, monospace`;662          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;663          ctx.fillText(r.char, r.x - 8, r.y + 8);664        }665      }666      // Peach mark bloom667      const markT = Math.max(0, (t - 0.2) / 0.5);668      const markScale = Math.min(markT * 1.2, 1);669      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;670      if (markAlpha > 0) {671        ctx.save();672        ctx.translate(W / 2, H / 2);673        ctx.scale(markScale, markScale);674        ctx.font = `bold 64px serif`;675        ctx.fillStyle = `rgba(232, 149, 109, ${markAlpha})`;676        ctx.shadowColor = `rgba(232, 149, 109, ${markAlpha * 0.8})`;677        ctx.shadowBlur = 30;678        ctx.textAlign = "center";679        ctx.textBaseline = "middle";680        ctx.fillText("🍑", 0, 0);681        ctx.restore();682      }683      if (t < 1) {684        animFrameId = requestAnimationFrame(drawBloom);685      } else {686        onDone();687      }688    }689    animFrameId = requestAnimationFrame(drawBloom);690  }691692  onCleanup(() => stopAnim());693694  async function handleRestore() {695    if (!scriptValid() || !backupPass().trim() || working()) return;696    setError(null);697    setWorking(true);698    setAnimating("chaos");699700    const canvas = canvasRef;701    if (canvas) startChaos(canvas);702703    try {704      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));705      const payload = await parsePayload(bytes, backupPass());706707      if (!props.hasVault) {708        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });709        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");710      }711712      const rr = await sendMessage({713        type: "RESTORE_ENTRIES",714        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },715      });716      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");717718      if (payload.license?.key) {719        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});720      }721722      setAnimating("snap");723      stopAnim();724      if (canvas) {725        snapToGrid(canvas, () => {726          setAnimating("bloom");727          bloom(canvas, async () => {728            setAnimating(null);729            await props.onSuccess();730          });731        });732      } else {733        await props.onSuccess();734      }735    } catch (err) {736      stopAnim();737      setAnimating(null);738      const code = err instanceof Error ? err.message : String(err);739      setError(740        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"741          ? "That passphrase didn't work."742          : code === "CREATE_VAULT_FAILED"743            ? "Couldn't create a new local vault."744            : "Could not read this backup.",745      );746    } finally {747      setWorking(false);748    }749  }750751  return (752    <div class="peach-rt-symbols">753      <Show when={animating() !== null}>754        <canvas755          ref={(el) => { canvasRef = el; }}756          class="peach-rt-anim-canvas"757          width={280}758          height={200}759        />760      </Show>761      <Show when={animating() === null}>762        <textarea763          class="peach-restore-symbols-textarea"764          placeholder="paste PeachScript here…"765          rows={3}766          value={scriptInput()}767          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}768        />769        <div class="peach-rt-divider">— or —</div>770        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">771          open .peach file772          <input773            type="file"774            accept=".peach,text/plain"775            style="display:none"776            onChange={async (e) => {777              const file = (e.currentTarget as HTMLInputElement).files?.[0];778              if (!file) return;779              try {780                setScriptInput(await file.text());781              } catch {782                setError("Couldn't read file.");783              } finally {784                (e.currentTarget as HTMLInputElement).value = "";785              }786            }}787          />788        </label>789        <input790          class="peach-restore-symbols-pass"791          type="password"792          placeholder="backup password"793          value={backupPass()}794          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}795          onKeyDown={(e) => {796            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {797              e.preventDefault();798              void handleRestore();799            }800          }}801        />802        <button803          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"804          onClick={() => void handleRestore()}805          disabled={!scriptValid() || !backupPass().trim() || working()}806        >807          restore vault →808        </button>809        <Show when={error()}>810          <p class="peach-rt-error">{error()}</p>811        </Show>812      </Show>813    </div>814  );815}816817// ── Mayan Code tab ─────────────────────────────────────────────────────────818819function RestoreMayan(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {820  const [decoding, setDecoding] = createSignal(false);821  const [decoded, setDecoded] = createSignal<Uint8Array | null>(null);822  const [entryLine, setEntryLine] = createSignal<string | null>(null);823  const [backupPass, setBackupPass] = createSignal("");824  const [restoring, setRestoring] = createSignal(false);825  const [error, setError] = createSignal<string | null>(null);826827  const isAndroid = createMemo(() =>828    Capacitor.isNativePlatform() && Capacitor.getPlatform() === "android"829  );830831  async function processFile(file: File) {832    setDecoding(true);833    setDecoded(null);834    setEntryLine(null);835    setError(null);836    try {837      const reader = new FileReader();838      const base64 = await new Promise<string>((res, rej) => {839        reader.onload = () => res(reader.result as string);840        reader.onerror = () => rej(new Error("read error"));841        reader.readAsDataURL(file);842      });843      const result = await handleMayanDecode(base64);844      if (!result.ok) { setError(result.reason); return; }845      setDecoded(result.payload);846    } catch {847      setError("Could not read this Mayan code. Try a clearer image.");848    } finally {849      setDecoding(false);850    }851  }852853  async function handleCameraScan() {854    setError(null);855    try {856      const { Camera, CameraResultType, CameraSource } = await import("@capacitor/camera");857      const image = await Camera.getPhoto({ quality: 90, resultType: CameraResultType.Base64, source: CameraSource.Camera, allowEditing: false });858      if (!image.base64String) return;859      setDecoding(true);860      const result = await handleMayanDecode(image.base64String);861      setDecoding(false);862      if (!result.ok) { setError(result.reason); return; }863      setDecoded(result.payload);864    } catch (err) {865      setDecoding(false);866      const msg = err instanceof Error ? err.message : "";867      if (msg && !/cancel/i.test(msg)) setError(msg);868    }869  }870871  async function handleRestore() {872    const bytes = decoded();873    if (!bytes || !backupPass().trim() || restoring()) return;874    setRestoring(true);875    setError(null);876    try {877      const payload = await parsePayload(bytes, backupPass());878      const count = payload.entries.length;879      const date = new Date(880        Math.max(...payload.entries.map((e) => e.modifiedAt ?? e.createdAt ?? 0))881      );882      const monthYear = date.getFullYear() > 2000883        ? date.toLocaleDateString("en-US", { month: "long", year: "numeric" })884        : null;885      setEntryLine(monthYear ? `${count} entries · ${monthYear}` : `${count} entries`);886887      if (!props.hasVault) {888        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });889        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");890      }891      const rr = await sendMessage({ type: "RESTORE_ENTRIES", payload: { entries: payload.entries, mode: "merge", settings: payload.settings } });892      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");893      if (payload.license?.key) {894        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});895      }896      await props.onSuccess();897    } catch (err) {898      const code = err instanceof Error ? err.message : String(err);899      setError(900        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"901          ? "Wrong backup password."902          : code === "CREATE_VAULT_FAILED"903            ? "Couldn't create a new local vault."904            : "Restore failed. The backup may be corrupted.",905      );906    } finally {907      setRestoring(false);908    }909  }910911  return (912    <div class="peach-rt-mayan">913      <Show when={decoded() === null}>914        <label class="peach-rt-upload-zone" classList={{ "is-busy": decoding() }}>915          <span class="peach-rt-upload-label">916            {decoding() ? "decoding…" : "tap to upload image"}917          </span>918          <input919            type="file"920            accept="image/*"921            style="display:none"922            onChange={(e) => {923              const file = (e.currentTarget as HTMLInputElement).files?.[0];924              if (file) void processFile(file);925              (e.currentTarget as HTMLInputElement).value = "";926            }}927            disabled={decoding()}928          />929        </label>930        <Show when={isAndroid()}>931          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void handleCameraScan()} disabled={decoding()}>932            scan with camera933          </button>934        </Show>935        <Show when={error()}>936          <p class="peach-rt-error">{error()}</p>937        </Show>938      </Show>939      <Show when={decoded() !== null}>940        <Show when={entryLine()}>941          <p class="peach-rt-entry-line">{entryLine()}</p>942        </Show>943        <input944          class="peach-restore-symbols-pass"945          type="password"946          placeholder="backup password"947          ref={(el) => queueMicrotask(() => el.focus())}948          value={backupPass()}949          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}950          onKeyDown={(e) => {951            if (e.key === "Enter" && !restoring()) { e.preventDefault(); void handleRestore(); }952          }}953        />954        <button955          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"956          onClick={() => void handleRestore()}957          disabled={!backupPass().trim() || restoring()}958        >959          {restoring() ? "restoring…" : "restore vault →"}960        </button>961        <button class="peach-btn peach-btn--ghost peach-btn--sm" style="margin-top:4px" onClick={() => { setDecoded(null); setEntryLine(null); setError(null); }}>962          ← try another image963        </button>964        <Show when={error()}>965          <p class="peach-rt-error">{error()}</p>966        </Show>967      </Show>968    </div>969  );970}971972// ── CSV tab ────────────────────────────────────────────────────────────────973974function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {975  const [importing, setImporting] = createSignal(false);976  const [error, setError] = createSignal<string | null>(null);977978  async function handleFile(e: Event) {979    const input = e.currentTarget as HTMLInputElement;980    const file = input.files?.[0];981    input.value = "";982    if (!file) return;983    setImporting(true);984    setError(null);985    try {986      const text = await file.text();987      const parsed = await parseImportSource(text);988      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");989990      if (props.hasVault === false) {991        // No vault yet — this path shouldn't normally occur for CSV (no backup password992        // means no way to create a locked vault). Create an unlocked placeholder vault.993        // The user can set a master password from settings.994        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });995        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");996      }997998      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });999      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1000      if (parsed.licenseKey) {1001        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});1002      }1003      await props.onSuccess();1004    } catch (err) {1005      const code = err instanceof Error ? err.message : String(err);1006      setError(1007        code === "NO_ENTRIES" ? "No entries found in this file." :1008        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1009        "Import failed. Check the file format and try again."1010      );1011    } finally {1012      setImporting(false);1013    }1014  }10151016  return (1017    <div class="peach-rt-csv">1018      <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>1019        {importing() ? "importing…" : "open CSV file"}1020        <input1021          type="file"1022          accept=".csv,.json,.peach,application/x-peach"1023          style="display:none"1024          onChange={handleFile}1025          disabled={importing()}1026        />1027      </label>1028      <p class="peach-rt-csv-sources">1029        Imports from Bitwarden, 1Password, Proton Pass,<br />1030        Chrome, Safari, and Edge.1031      </p>1032      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1033      <Show when={error()}>1034        <p class="peach-rt-error">{error()}</p>1035      </Show>1036    </div>1037  );1038}10391040// ── From Phone tab ─────────────────────────────────────────────────────────10411042type PhoneState = "waiting" | "connected" | "trust" | "error";10431044function RestoreFromPhone(props: { isPro: boolean; onSuccess: () => void | Promise<void> }) {1045  const [state, setState] = createSignal<PhoneState>("waiting");1046  const [code, setCode] = createSignal<string | null>(null);1047  const [qrUrl, setQrUrl] = createSignal<string | null>(null);1048  const [expired, setExpired] = createSignal(false);1049  const [deviceName, setDeviceName] = createSignal<string | null>(null);1050  const [accepting, setAccepting] = createSignal(false);1051  const [serverError, setServerError] = createSignal<string | null>(null);1052  let expireTimer: ReturnType<typeof setTimeout> | null = null;10531054  function clearExpire() {1055    if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }1056  }10571058  async function startPairing() {1059    setExpired(false);1060    setCode(null);1061    setQrUrl(null);1062    setServerError(null);1063    clearExpire();1064    const res = await sendMessage({ type: "START_PAIRING_SERVER" });1065    const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };1066    if (!r.ok || !r.data) {1067      setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");1068      return;1069    }1070    const token = r.data.token;1071    const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;1072    setCode(formatted);1073    const qrData = `peach://handoff?token=${token}`;1074    try {1075      const QRCode = (await import("qrcode")).default;1076      const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });1077      setQrUrl(url);1078    } catch {1079      // QR optional — code is the primary affordance1080    }1081    const remaining = Math.max(0, r.data.expiresAt - Date.now());1082    expireTimer = setTimeout(() => {1083      setCode(null);1084      setQrUrl(null);1085      setExpired(true);1086    }, remaining);1087  }10881089  function handleRuntimeMsg(msg: unknown) {1090    const m = msg as { type?: string; payload?: { deviceName?: string } };1091    if (m?.type === "PAIRING_COMPLETED") {1092      clearExpire();1093      setDeviceName(m.payload?.deviceName ?? "your phone");1094      setState("trust");1095    }1096  }10971098  onMount(() => {1099    if (!props.isPro) return;1100    void startPairing();1101    // @ts-ignore — chrome runtime1102    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1103      chrome.runtime.onMessage.addListener(handleRuntimeMsg);1104    }1105  });11061107  onCleanup(() => {1108    clearExpire();1109    // @ts-ignore1110    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1111      chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1112    }1113    sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1114  });11151116  async function handleAccept(trust: boolean) {1117    setAccepting(true);1118    if (trust) {1119      await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1120    }1121    await props.onSuccess();1122  }11231124  if (!props.isPro) {1125    return (1126      <div class="peach-rt-phone">1127        <p class="peach-rt-phone-gate-text">Transfer requires Peach Pro.</p>1128        <button1129          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1130          onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro" })}1131        >1132          Get Peach Pro1133        </button>1134        <button1135          class="peach-btn peach-btn--ghost peach-btn--sm"1136          style="margin-top:6px"1137          onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro#redeem" })}1138        >1139          I already have a license1140        </button>1141      </div>1142    );1143  }11441145  return (1146    <div class="peach-rt-phone">1147      <Show when={state() === "waiting"}>1148        <Show when={!expired()}>1149          <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1150            <p class="peach-rt-phone-code">{code()}</p>1151            <Show when={qrUrl() !== null}>1152              <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1153            </Show>1154            <p class="peach-rt-phone-hint">1155              Open Peach on your phone and tap<br />1156              Settings → Send to desktop.<br />1157              Same WiFi required.1158            </p>1159          </Show>1160          <Show when={serverError()}>1161            <p class="peach-rt-error">{serverError()}</p>1162          </Show>1163        </Show>1164        <Show when={expired()}>1165          <p class="peach-rt-phone-hint">Code expired.</p>1166          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1167            Generate new code1168          </button>1169        </Show>1170      </Show>11711172      <Show when={state() === "connected"}>1173        <p class="peach-rt-phone-hint">1174          Connected to {deviceName() ?? "your phone"}.<br />1175          Receiving vault…1176        </p>1177      </Show>11781179      <Show when={state() === "trust"}>1180        <p class="peach-rt-phone-hint" style="margin-bottom:10px">1181          Vault received from {deviceName() ?? "your phone"}.<br />1182          Trust this network for sync?1183        </p>1184        <button1185          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1186          onClick={() => void handleAccept(true)}1187          disabled={accepting()}1188        >1189          Yes, trust this network1190        </button>1191        <button1192          class="peach-btn peach-btn--ghost peach-btn--sm"1193          style="margin-top:6px"1194          onClick={() => void handleAccept(false)}1195          disabled={accepting()}1196        >1197          Not now1198        </button>1199      </Show>12001201      <Show when={state() === "error"}>1202        <p class="peach-rt-phone-hint">1203          Could not connect. Check that both devices<br />1204          are on the same WiFi network.1205        </p>1206        <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1207          Try again1208        </button>1209      </Show>1210    </div>1211  );1212}12131214// ── Biometric unlock ────────────────────────────────────────────────────────12151216async function handleBiometricUnlock(props: { onUnlock: () => void | Promise<void> }) {1217  const unlocked = await biometricUnlock();1218  if (unlocked) {1219    await props.onUnlock();1220  }1221}12221223function BiometricUnlockButton(props: { ready: boolean; loading: () => boolean; onUnlock: () => void | Promise<void> }) {1224  const [bioAvailable, setBioAvailable] = createSignal(false);1225  onMount(async () => {1226    setBioAvailable(await biometricAvailable());1227  });1228  return (1229    <Show when={bioAvailable()}>1230      <button1231        class="peach-btn peach-btn--subtle peach-btn--md peach-biometric-btn"1232        onClick={() => void handleBiometricUnlock(props)}1233        disabled={!props.ready || props.loading()}1234        type="button"1235      >1236        <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1237          <path d="M8 1a3 3 0 0 0-3 3v4a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />1238          <path d="M5 8v1a3 3 0 0 0 6 0V8" />1239          <line x1="8" y1="12" x2="8" y2="15" />1240          <line x1="5" y1="15" x2="11" y2="15" />1241        </svg>1242        use biometrics1243      </button>1244    </Show>1245  );1246}12471248// ── Helpers ────────────────────────────────────────────────────────────────12491250function getDisplayError(error: string | undefined, fallback: string): string {1251  if (!error) return fallback;1252  if (1253    error === "SERVICE_WORKER_UNAVAILABLE" ||1254    error.includes("Receiving end does not exist") ||1255    error.includes("message port closed")1256  ) {1257    return "background service unavailable";1258  }1259  return error;1260}