Lock.tsx

40.5 KB · raw

1import { For, Match, Show, Switch, createMemo, createSignal, onCleanup, onMount } from "solid-js";2import { sendMessage } from "../App";3import { LockRuneCanvas } from "../components/LockRuneCanvas";4import { PeachMark, PeachWordmark } from "../components/PeachMark";5import { RecoveryWordEntryView } from "./RecoveryWordEntry";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import { RuneBackground } from "../components/RuneBackground";8import { storageGet, storageSet } from "../../shared/storage";9import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";10import { parsePayload } from "../../crypto/peachPayload";11import { parseImportSource } from "../../shared/import";12import { isNativePlatform, nativePlugin } from "../../shared/native";1314const peachMarkImg = new Image();15peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(16  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" fill="none">' +17  '<path d="M 124 64 C 134 32 168 16 200 22 C 198 56 168 72 130 66 Z" fill="#92AC65"/>' +18  '<path d="M 130 64 C 152 46 178 30 196 24" stroke="#5E7A42" stroke-width="2.6" fill="none" stroke-linecap="round"/>' +19  '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 185 222 222 185 220 130 C 218 78 170 45 120 60 Z" fill="#FFB082"/>' +20  '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 105 195 75 150 100 90 C 105 75 112 65 120 60 Z" fill="#E5805C"/>' +21  '<ellipse cx="178" cy="108" rx="20" ry="11" fill="#FFD0A8" opacity="0.7" transform="rotate(-26 178 108)"/>' +22  '</svg>'23)}`;2425// Lazy-load @zxcvbn-ts — heavy (dictionaries ~700 kB) and only needed during26// vault creation. Replaces the unmaintained [email protected] (LV-8); result.score27// keeps the same 0-4 scale so downstream UI is unchanged.28let _zxcvbn: ((password: string) => { score: number }) | null = null;29async function getZxcvbn() {30  if (_zxcvbn) return _zxcvbn;31  const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([32    import("@zxcvbn-ts/core"),33    import("@zxcvbn-ts/language-common"),34    import("@zxcvbn-ts/language-en"),35  ]);36  zxcvbnOptions.setOptions({37    translations: en.translations,38    graphs: common.adjacencyGraphs,39    dictionary: { ...common.dictionary, ...en.dictionary },40  });41  _zxcvbn = (password: string) => zxcvbn(password);42  return _zxcvbn;43}4445interface LockProps {46  hasVault: boolean;47  ready: boolean;48  isPro?: boolean;49  /** When true, render the recovery-words display directly instead of the50   *  unlock UI. Used after a popup close+reopen during the create-vault51   *  recovery confirmation flow (peach_recovery_pending flag). */52  forceRecoveryWords?: boolean;53  onUnlock: () => void | Promise<void>;54  /** Optional escape hatch: rewind from the create-vault screen back to55   *  Onboarding. Only meaningful when the user reached create-vault from56   *  Onboarding in the first place. */57  onBackToOnboarding?: () => void;58  /** Optional: open the Peach Codex restore flow (camera scan of printed59   *  Codex pages). Surfaced as a small footer link on the new-vault screen. */60  onOpenCodexRestore?: () => void;61  /** Open directly on backup restore when onboarding routed the user there. */62  initialRestore?: boolean;63}6465type RestoreSurface = "restore" | null;66type RecoverSurface = "words" | "new-password" | null;6768export function LockView(props: LockProps) {69  const [password, setPassword] = createSignal("");70  const [confirmPassword, setConfirmPassword] = createSignal("");71  const [error, setError] = createSignal("");72  const [loading, setLoading] = createSignal(false);73  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(74    props.initialRestore ? "restore" : null,75  );76  const [showPassword, setShowPassword] = createSignal(false);77  const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);78  // After vault creation succeeds, set this to show the recovery words screen.79  // Only meaningful when hasVault === false.80  const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);8182  const MIN_LENGTH = 12;83  const MIN_ZXCVBN_SCORE = 2;8485  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);86  const [biometricAvailable, setBiometricAvailable] = createSignal(false);87  const [biometricBusy, setBiometricBusy] = createSignal(false);8889  const canCreate = createMemo(90    () =>91      password().length >= MIN_LENGTH &&92      confirmPassword().length >= MIN_LENGTH &&93      password() === confirmPassword() &&94      strengthScore() !== null &&95      strengthScore()! >= MIN_ZXCVBN_SCORE96  );9798  const passwordStrength = createMemo(() => {99    const score = strengthScore();100    if (score === null) return null;101    if (score <= 1) return "weak";102    if (score === 2) return "fair";103    if (score === 3) return "good";104    return "strong";105  });106107  const createBlockerMessage = createMemo(() => {108    if (!password()) return null;109    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;110    if (!confirmPassword()) return "confirm your password";111    if (password() !== confirmPassword()) return "passwords don't match";112    if (strengthScore() === null) return "checking strength…";113    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — use a longer passphrase";114    return null;115  });116117  // Update strength asynchronously when password changes118  createMemo(() => {119    const value = password();120    if (!value) { setStrengthScore(null); return; }121    getZxcvbn().then((zxcvbn) => {122      if (password() === value) setStrengthScore(zxcvbn(value).score);123    }).catch(() => {124      // zxcvbn unavailable — fall back to length-only heuristic125      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);126    });127  });128129  async function handleUnlock() {130    if (!password().trim()) return;131    setLoading(true);132    setError("");133    const response = await sendMessage({134      type: "UNLOCK",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, "incorrect password"));144    setPassword("");145    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;146    passwordField?.classList.add("shake");147    setTimeout(() => passwordField?.classList.remove("shake"), 300);148    setLoading(false);149  }150151  async function checkBiometricAvailable(): Promise<void> {152    if (!props.hasVault || !isNativePlatform()) return;153    const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");154    const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");155    if (!keystore || !biometric) return;156    try {157      const stored = await keystore.hasMasterKey();158      if (!stored.exists) return;159      const avail = await biometric.isAvailable();160      if (!avail.available) return;161      setBiometricAvailable(true);162      window.setTimeout(() => { void handleBiometricUnlock(); }, 400);163    } catch { /* missing plugins or runtime error — silently skip */ }164  }165166  async function handleBiometricUnlock(): Promise<void> {167    if (biometricBusy()) return;168    const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");169    const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");170    if (!biometric || !keystore) return;171    setBiometricBusy(true);172    setError("");173    try {174      await biometric.authenticate({175        title: "Unlock Peach",176        subtitle: "Use biometrics to access your vault",177        negativeButtonText: "Use Password",178      });179      const { masterKeyHex } = await keystore.retrieveMasterKey();180      const res = (await sendMessage({181        type: "UNLOCK_WITH_KEY",182        payload: { masterKeyHex },183      })) as { ok: boolean; error?: string };184      if (res.ok) {185        await props.onUnlock();186        return;187      }188      if (res.error === "biometric_key_invalidated") {189        await sendMessage({190          type: "UPDATE_SETTINGS",191          payload: { biometricsEnabled: false },192        });193        setBiometricAvailable(false);194        setError("biometric key expired — re-enable in settings");195      } else {196        setError("biometric unlock failed");197      }198    } catch (e) {199      const reason = e instanceof Error ? e.message : String(e);200      if (reason.includes("user_cancelled")) return;201      if (reason.includes("biometric_key_invalidated")) {202        await sendMessage({203          type: "UPDATE_SETTINGS",204          payload: { biometricsEnabled: false },205        });206        setBiometricAvailable(false);207        setError("biometric key expired — re-enable in settings");208        return;209      }210      if (reason.includes("biometric_lockout")) {211        setError("biometric locked out — use your password");212      }213    } finally {214      setBiometricBusy(false);215    }216  }217218  onMount(() => { void checkBiometricAvailable(); });219220  async function handleCreate() {221    if (!password().trim()) return;222    if (password().length < MIN_LENGTH) {223      setError(`password must be at least ${MIN_LENGTH} characters`);224      return;225    }226    if (password() !== confirmPassword()) {227      setError("passwords do not match");228      return;229    }230    const score = strengthScore();231    if (score === null || score < MIN_ZXCVBN_SCORE) {232      setError("password is too weak — use a longer passphrase");233      return;234    }235    setLoading(true);236    setError("");237    const response = await sendMessage({238      type: "CREATE_VAULT",239      payload: { password: password() },240    });241    const result = response as { ok: boolean; error?: string };242    if (result.ok) {243      // Don't unlock yet — show the recovery words screen first. Mark the244      // confirmation as pending in chrome.storage.local so it survives both245      // popup close (lock-on-close skips while set) and full browser restart246      // (next unlock re-routes to the recovery surface). Cleared when the247      // user types the 3 confirmation words successfully.248      try {249        await chrome.storage.local?.set({ peach_recovery_pending: "1" });250      } catch { /* best-effort */ }251      setShowRecoveryWords(true);252      setLoading(false);253      return;254    }255    setError(getDisplayError(result.error, "failed to create vault"));256    setLoading(false);257  }258259  function openRestore(surface: Exclude<RestoreSurface, null>) {260    if (!props.ready || loading()) return;261    setPassword("");262    setConfirmPassword("");263    setError("");264    setRestoreSurface(surface);265  }266267  // ── All surfaces routed reactively via Switch/Match ───────────────────268  return (269    <Switch>270      {/* ── Restore tab view ──────────────────────────────────────── */}271      <Match when={restoreSurface() === "restore"}>272        <RestoreTabView273          hasVault={props.hasVault}274          onOpenCodexRestore={props.onOpenCodexRestore}275          onBack={() => setRestoreSurface(null)}276          onUnlock={async () => {277            setRestoreSurface(null);278            await props.onUnlock();279          }}280        />281      </Match>282283      {/* ── Recovery words display after fresh vault creation ────────── */}284      <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>285        <RecoveryWordDisplayView286          onDone={props.onUnlock}287        />288      </Match>289290      {/* ── Recovery word entry ─────────────────────────────────────── */}291      <Match when={recoverSurface() === "words"}>292        <RecoveryWordEntryView293          onCancel={() => setRecoverSurface(null)}294          onRecoveryComplete={props.onUnlock}295        />296      </Match>297298      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}299      <Match when={props.hasVault}>300        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">301          <header class="peach-screen-header lock-screen-header">302            <div class="peach-wordmark-stack">303              <PeachWordmark class="peach-wordmark-wrap" />304              <Show when={props.isPro}>305                <span class="peach-pro-mini-badge">pro</span>306              </Show>307            </div>308          </header>309310          <div class="peach-lock-hero peach-lock-hero--unlock">311            <div class="peach-lock-hero-art" aria-hidden="true">312              <PeachMark class="peach-lock-hero-mark" />313            </div>314            <span class="peach-lock-eyebrow">vault locked</span>315            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">316              unlock317              <br />318              your vault.319            </h1>320          </div>321322          <LockRuneCanvas />323324          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">325            <label class="peach-label" for="unlock-password">326              master password327            </label>328            <input329              id="unlock-password"330              class="peach-underline-input peach-unlock-input lock-password-input"331              type="password"332              placeholder="••••••••••••"333              autocomplete="current-password"334              value={password()}335              disabled={!props.ready}336              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}337              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}338              onKeyDown={(event) => {339                if (event.key !== "Enter" || !props.ready) return;340                void handleUnlock();341              }}342            />343344            <Show when={error()}>345              <p class="peach-hint peach-hint-error">{error()}</p>346            </Show>347348            <button349              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"350              onClick={() => void handleUnlock()}351              disabled={!props.ready || loading()}352            >353              {loading() ? "unlocking..." : "unlock"}354            </button>355356            <Show when={biometricAvailable()}>357              <button358                class="peach-biometric-btn"359                type="button"360                onClick={() => void handleBiometricUnlock()}361                disabled={biometricBusy() || loading()}362                aria-label="Unlock with biometrics"363              >364                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">365                  <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>366                  <path d="M15 17v-3"/>367                  <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>368                  <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>369                  <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>370                  <path d="M5 15c0-1.7.3-3.3.8-4.7"/>371                  <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>372                </svg>373                {biometricBusy() ? "verifying…" : "use biometrics"}374              </button>375            </Show>376377            <button378              class="peach-lock-recover-link"379              type="button"380              onClick={() => setRecoverSurface("words")}381            >382              forgot your password?383            </button>384          </section>385        </section>386      </Match>387388      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}389      <Match when={true}>390        <section391          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"392          data-motion-phase="enter"393        >394          <header class="peach-screen-header peach-newvault-header">395            <Show when={props.onBackToOnboarding}>396              <button397                class="peach-newvault-back"398                type="button"399                onClick={() => props.onBackToOnboarding?.()}400                aria-label="Back to onboarding"401              >402                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">403                  <path d="M7 2L3 5.5L7 9" />404                </svg>405                back406              </button>407            </Show>408            <div class="peach-wordmark-stack">409              <PeachWordmark class="peach-wordmark-wrap" />410              <Show when={props.isPro}>411                <span class="peach-pro-mini-badge">pro</span>412              </Show>413            </div>414          </header>415416          <div class="peach-newvault-mark" aria-hidden="true">417            <PeachMark class="peach-newvault-mark-art" />418          </div>419420          <div class="peach-newvault-card">421            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>422            <hr class="peach-newvault-divider" />423424            <div class="peach-newvault-field">425              <div class="peach-newvault-label-row">426                <label class="peach-label" for="master-password">427                  master password428                </label>429                <button430                  class="peach-lock-reveal-btn"431                  type="button"432                  onClick={() => setShowPassword((value) => !value)}433                  aria-label={showPassword() ? "Hide password" : "Show password"}434                >435                  <Show436                    when={showPassword()}437                    fallback={438                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">439                        <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" />440                        <circle cx="7" cy="5" r="1.75" />441                      </svg>442                    }443                  >444                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">445                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />446                      <path d="M3.5 5.5L2.5 7.5" />447                      <path d="M7 6.5V8.5" />448                      <path d="M10.5 5.5L11.5 7.5" />449                    </svg>450                  </Show>451                  {showPassword() ? "hide" : "show"}452                </button>453              </div>454              <input455                id="master-password"456                class="peach-underline-input peach-newvault-input lock-password-input"457                type={showPassword() ? "text" : "password"}458                placeholder="••••••••••••"459                autocomplete="new-password"460                value={password()}461                disabled={!props.ready}462                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}463                onKeyDown={(event) => {464                  if (event.key !== "Enter" || !props.ready) return;465                  void handleCreate();466                }}467              />468              <Show469                when={password().length > 0 && passwordStrength() !== null}470                fallback={471                  <p class="peach-newvault-hint">472                    At least 12 characters. The vault is created once both fields match and pass the strength check.473                  </p>474                }475              >476                <div class="peach-password-strength">477                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>478                    <div class="peach-strength-fill"></div>479                  </div>480                  <span class="peach-strength-label">{passwordStrength()}</span>481                </div>482              </Show>483            </div>484485            <div class="peach-newvault-field">486              <label class="peach-label" for="confirm-password">487                confirm password488              </label>489              <input490                id="confirm-password"491                class="peach-underline-input peach-newvault-input"492                type={showPassword() ? "text" : "password"}493                placeholder="••••••••••••"494                autocomplete="new-password"495                value={confirmPassword()}496                disabled={!props.ready}497                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}498                onKeyDown={(event) => {499                  if (event.key === "Enter" && props.ready) void handleCreate();500                }}501              />502            </div>503504            <Show when={error()}>505              <p class="peach-hint peach-hint-error">{error()}</p>506            </Show>507508            <button509              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"510              onClick={() => void handleCreate()}511              disabled={!props.ready || loading() || !canCreate()}512              aria-live="polite"513            >514              <Show515                when={!canCreate() && createBlockerMessage()}516                fallback={loading() ? "creating..." : "create vault"}517              >518                {createBlockerMessage()}519              </Show>520            </button>521522            {/* Restore lives directly under the primary CTA, not in a page523                footer — the 520px popup clipped a bottom footer below the524                fold, so a fresh install looked like "new vault" was the only525                path. These must stay on-screen without scrolling. */}526            <div class="peach-newvault-restore">527              <p class="peach-hint">Already have a backup?</p>528              <button529                class="peach-lock-restore-link"530                onClick={() => void openRestore("restore")}531                disabled={!props.ready}532              >533                › restore from backup534              </button>535              <Show when={props.onOpenCodexRestore}>536                <button537                  class="peach-lock-restore-link"538                  onClick={() => props.onOpenCodexRestore?.()}539                  disabled={!props.ready}540                >541                  › restore from peach codex542                </button>543              </Show>544            </div>545546            <p class="peach-newvault-warning">547              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>548              <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>549            </p>550          </div>551        </section>552      </Match>553    </Switch>554  );555}556557// ── Restore tab view ───────────────────────────────────────────────────────558559type RestoreTab = "symbols" | "codex" | "csv";560561function tabLabel(tab: RestoreTab): string {562  return (563    { symbols: "PeachScript", codex: "Codex", csv: "CSV" }[tab]564  );565}566567function RestoreTabView(props: {568  hasVault: boolean;569  onOpenCodexRestore?: () => void;570  onBack: () => void;571  onUnlock: () => void | Promise<void>;572}) {573  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");574  const [showIntro, setShowIntro] = createSignal(false);575  const [pendingUnlock] = createSignal(false);576577  async function handleRestoreSuccess() {578    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);579    if (!seen) {580      setShowIntro(true);581    } else {582      await props.onUnlock();583    }584  }585586  async function dismissIntro() {587    await storageSet("peach_shown_restore_intro", "true").catch(() => {});588    setShowIntro(false);589    await props.onUnlock();590  }591592  return (593    <section594      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"595      data-motion-phase="enter"596    >597      <RuneBackground opacity={0.05} />598599      <header class="peach-screen-header lock-screen-header">600        <PeachWordmark class="peach-wordmark-wrap" />601        <button class="peach-lock-restore-link" onClick={props.onBack}>602          ‹ back603        </button>604      </header>605606      <div class="peach-newvault-card peach-restore-tabcard">607        <div class="peach-restore-tabs" role="tablist">608          <For each={["symbols", "codex", "csv"] as RestoreTab[]}>609            {(tab) => (610              <button611                class="peach-restore-tab"612                classList={{ "is-active": activeTab() === tab }}613                onClick={() => setActiveTab(tab)}614                role="tab"615                aria-selected={activeTab() === tab}616              >617                {tabLabel(tab)}618              </button>619            )}620          </For>621        </div>622        <div class="peach-restore-content">623          <Show when={activeTab() === "symbols"}>624            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />625          </Show>626          <Show when={activeTab() === "codex"}>627            <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />628          </Show>629          <Show when={activeTab() === "csv"}>630            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />631          </Show>632        </div>633      </div>634635      {/* One-time intro popup after first restore */}636      <Show when={showIntro()}>637        <div class="peach-restore-intro-overlay">638          <div class="peach-restore-intro-card">639            <p class="peach-restore-intro-headline">welcome to peach.</p>640            <div class="peach-restore-intro-body">641              <p>Your vault lives only on this device. No server has a copy.</p>642              <p>The inline bubble fills passwords automatically when you visit sites.</p>643              <p>Export your vault from Settings before switching devices.</p>644            </div>645            <button646              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"647              onClick={() => void dismissIntro()}648              disabled={pendingUnlock()}649            >650              got it651            </button>652          </div>653        </div>654      </Show>655    </section>656  );657}658659// ── Peach Codex tab ────────────────────────────────────────────────────────660661function RestoreCodex(props: { onOpen: () => void }) {662  return (663    <div class="peach-restore-codex">664      <p class="peach-restore-codex-title">Restore from Peach Codex</p>665      <p class="peach-restore-codex-copy">666        Scan printed Codex pages or import the Codex PDF. Codex restore is667        always available, even before you create a vault.668      </p>669      <button670        type="button"671        class="peach-btn peach-btn--accent peach-btn--md peach-restore-codex-button"672        onClick={props.onOpen}673      >674        open Codex restore →675      </button>676    </div>677  );678}679680// ── PeachScript tab ────────────────────────────────────────────────────────681682function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {683  const [scriptInput, setScriptInput] = createSignal("");684  const [backupPass, setBackupPass] = createSignal("");685  const [working, setWorking] = createSignal(false);686  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);687  const [error, setError] = createSignal<string | null>(null);688  let canvasRef: HTMLCanvasElement | undefined;689  let animFrameId = 0;690  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];691692  const scriptValid = createMemo(() => isPeachScript(scriptInput()));693694  function stopAnim() {695    cancelAnimationFrame(animFrameId);696    animFrameId = 0;697  }698699  function startChaos(canvas: HTMLCanvasElement) {700    const ctx = canvas.getContext("2d")!;701    const W = canvas.width;702    const H = canvas.height;703    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>704      String.fromCodePoint(0xe000 + i)705    );706    if (runes.length === 0) {707      runes = Array.from({ length: 60 }, () => ({708        x: Math.random() * W,709        y: Math.random() * H,710        vx: (Math.random() - 0.5) * 3,711        vy: (Math.random() - 0.5) * 3,712        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,713        size: 14 + Math.random() * 20,714        angle: Math.random() * Math.PI * 2,715        va: (Math.random() - 0.5) * 0.1,716      }));717    }718719    function draw() {720      ctx.clearRect(0, 0, W, H);721      for (const r of runes) {722        r.x = (r.x + r.vx + W) % W;723        r.y = (r.y + r.vy + H) % H;724        r.angle += r.va;725        ctx.save();726        ctx.translate(r.x, r.y);727        ctx.rotate(r.angle);728        ctx.font = `${r.size}px PeachScript, monospace`;729        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);730        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;731        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";732        ctx.shadowBlur = 8;733        ctx.fillText(r.char, 0, 0);734        ctx.restore();735      }736      animFrameId = requestAnimationFrame(draw);737    }738    draw();739  }740741  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {742    stopAnim();743    const ctx = canvas.getContext("2d")!;744    const W = canvas.width;745    const H = canvas.height;746    const cols = 8;747    const rows = 6;748    const cellW = W / cols;749    const cellH = H / rows;750    const targets = runes.slice(0, cols * rows).map((_, i) => ({751      x: (i % cols) * cellW + cellW / 2,752      y: Math.floor(i / cols) * cellH + cellH / 2,753    }));754    const start = performance.now();755    const DURATION = 400;756757    function drawSnap(now: number) {758      const t = Math.min((now - start) / DURATION, 1);759      const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;760      ctx.clearRect(0, 0, W, H);761      runes.slice(0, targets.length).forEach((r, i) => {762        const tx = targets[i]!.x;763        const ty = targets[i]!.y;764        const cx = r.x + (tx - r.x) * ease;765        const cy = r.y + (ty - r.y) * ease;766        ctx.font = `16px PeachScript, monospace`;767        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;768        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";769        ctx.shadowBlur = 6;770        ctx.fillText(r.char, cx - 8, cy + 8);771      });772      if (t < 1) {773        animFrameId = requestAnimationFrame(drawSnap);774      } else {775        onDone();776      }777    }778    animFrameId = requestAnimationFrame(drawSnap);779  }780781  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {782    stopAnim();783    const ctx = canvas.getContext("2d")!;784    const W = canvas.width;785    const H = canvas.height;786    const start = performance.now();787    const DURATION = 900;788789    function drawBloom(now: number) {790      const t = Math.min((now - start) / DURATION, 1);791      ctx.clearRect(0, 0, W, H);792      // Runes dissolve793      const runeAlpha = 1 - Math.min(t / 0.4, 1);794      if (runeAlpha > 0) {795        for (const r of runes.slice(0, 48)) {796          ctx.font = `16px PeachScript, monospace`;797          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;798          ctx.fillText(r.char, r.x - 8, r.y + 8);799        }800      }801      // Peach mark bloom802      const markT = Math.max(0, (t - 0.2) / 0.5);803      const markScale = Math.min(markT * 1.2, 1);804      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;805      if (markAlpha > 0) {806        ctx.save();807        ctx.globalAlpha = markAlpha;808        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;809        ctx.shadowBlur = 30;810        ctx.translate(W / 2, H / 2);811        ctx.scale(markScale, markScale);812        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);813        ctx.restore();814      }815      if (t < 1) {816        animFrameId = requestAnimationFrame(drawBloom);817      } else {818        onDone();819      }820    }821    animFrameId = requestAnimationFrame(drawBloom);822  }823824  onCleanup(() => stopAnim());825826  async function handleRestore() {827    if (!scriptValid() || !backupPass().trim() || working()) return;828    setError(null);829    setWorking(true);830    setAnimating("chaos");831832    const canvas = canvasRef;833    if (canvas) startChaos(canvas);834835    try {836      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));837      const payload = await parsePayload(bytes, backupPass());838839      if (!props.hasVault) {840        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });841        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");842      }843844      const rr = await sendMessage({845        type: "RESTORE_ENTRIES",846        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },847      });848      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");849850      if (payload.license?.key) {851        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});852      }853854      setAnimating("snap");855      stopAnim();856      if (canvas) {857        snapToGrid(canvas, () => {858          setAnimating("bloom");859          bloom(canvas, async () => {860            setAnimating(null);861            await props.onSuccess();862          });863        });864      } else {865        await props.onSuccess();866      }867    } catch (err) {868      stopAnim();869      setAnimating(null);870      const code = err instanceof Error ? err.message : String(err);871      setError(872        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"873          ? "That passphrase didn't work."874          : code === "CREATE_VAULT_FAILED"875            ? "Couldn't create a new local vault."876            : "Could not read this backup.",877      );878    } finally {879      setWorking(false);880    }881  }882883  return (884    <div class="peach-rt-symbols">885      <Show when={animating() !== null}>886        <canvas887          ref={(el) => { canvasRef = el; }}888          class="peach-rt-anim-canvas"889          width={280}890          height={200}891        />892      </Show>893      <Show when={animating() === null}>894        <textarea895          class="peach-restore-symbols-textarea"896          placeholder="paste peachscript…"897          rows={3}898          value={scriptInput()}899          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}900        />901        <div class="peach-rt-divider">— or —</div>902        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">903          open .peach file904          <input905            type="file"906            accept=".peach,text/plain"907            style="display:none"908            onChange={async (e) => {909              const file = (e.currentTarget as HTMLInputElement).files?.[0];910              if (!file) return;911              try {912                setScriptInput(await file.text());913              } catch {914                setError("Couldn't read file.");915              } finally {916                (e.currentTarget as HTMLInputElement).value = "";917              }918            }}919          />920        </label>921        <input922          class="peach-restore-symbols-pass"923          type="password"924          placeholder="backup password"925          value={backupPass()}926          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}927          onKeyDown={(e) => {928            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {929              e.preventDefault();930              void handleRestore();931            }932          }}933        />934        <button935          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"936          onClick={() => void handleRestore()}937          disabled={!scriptValid() || !backupPass().trim() || working()}938        >939          restore vault →940        </button>941        <Show when={error()}>942          <p class="peach-rt-error">{error()}</p>943        </Show>944      </Show>945    </div>946  );947}948949// ── CSV tab ────────────────────────────────────────────────────────────────950951function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {952  const [importing, setImporting] = createSignal(false);953  const [error, setError] = createSignal<string | null>(null);954  // CSV/JSON imports are plaintext and carry no password, so when there is no955  // vault yet the user MUST choose a master password here — otherwise the956  // imported credentials would be encrypted under an empty key and offer zero957  // at-rest protection (audit HIGH). Mirrors the onboarding MIN_LENGTH policy.958  const CSV_MIN_PASSWORD_LENGTH = 12;959  const [newPass, setNewPass] = createSignal("");960  const [confirmPass, setConfirmPass] = createSignal("");961  const needsPassword = () => props.hasVault === false;962  const passwordReady = () =>963    !needsPassword() ||964    (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());965966  async function handleFile(e: Event) {967    const input = e.currentTarget as HTMLInputElement;968    const file = input.files?.[0];969    input.value = "";970    if (!file) return;971    if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {972      setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);973      return;974    }975    if (needsPassword() && newPass() !== confirmPass()) {976      setError("Passwords do not match.");977      return;978    }979    setImporting(true);980    setError(null);981    try {982      const text = await file.text();983      const parsed = await parseImportSource(text);984      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");985986      if (props.hasVault === false) {987        // Create the vault under the user-chosen master password — never an988        // empty one (an empty key derives a publicly-known vault key).989        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });990        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");991      }992993      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });994      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");995      if (parsed.licenseKey) {996        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});997      }998      await props.onSuccess();999    } catch (err) {1000      const code = err instanceof Error ? err.message : String(err);1001      setError(1002        code === "NO_ENTRIES" ? "No entries found in this file." :1003        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1004        "Import failed. Check the file format and try again."1005      );1006    } finally {1007      setImporting(false);1008    }1009  }10101011  return (1012    <div class="peach-rt-csv">1013      <Show when={needsPassword()}>1014        <input1015          class="peach-restore-symbols-pass"1016          type="password"1017          placeholder="new master password"1018          value={newPass()}1019          onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1020        />1021        <input1022          class="peach-restore-symbols-pass"1023          type="password"1024          placeholder="confirm master password"1025          value={confirmPass()}1026          onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1027        />1028      </Show>1029      <label1030        class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn"1031        classList={{ "is-busy": importing(), "is-disabled": !passwordReady() }}1032      >1033        {importing() ? "importing…" : "open csv file"}1034        <input1035          type="file"1036          accept=".csv,.json,.peach,application/x-peach"1037          style="display:none"1038          onChange={handleFile}1039          disabled={importing() || !passwordReady()}1040        />1041      </label>1042      <p class="peach-rt-csv-sources">1043        Imports from Bitwarden, 1Password, Proton Pass,<br />1044        Chrome, Safari, and Edge.1045      </p>1046      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1047      <Show when={error()}>1048        <p class="peach-rt-error">{error()}</p>1049      </Show>1050    </div>1051  );1052}10531054// ── Helpers ────────────────────────────────────────────────────────────────10551056function getDisplayError(error: string | undefined, fallback: string): string {1057  if (!error) return fallback;1058  if (1059    error === "SERVICE_WORKER_UNAVAILABLE" ||1060    error.includes("Receiving end does not exist") ||1061    error.includes("message port closed")1062  ) {1063    return "background service unavailable";1064  }1065  return error;1066}