Lock.tsx

40.1 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}6263type RestoreSurface = "restore" | null;64type RecoverSurface = "words" | "new-password" | null;6566export function LockView(props: LockProps) {67  const [password, setPassword] = createSignal("");68  const [confirmPassword, setConfirmPassword] = createSignal("");69  const [error, setError] = createSignal("");70  const [loading, setLoading] = createSignal(false);71  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);72  const [showPassword, setShowPassword] = createSignal(false);73  const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);74  // After vault creation succeeds, set this to show the recovery words screen.75  // Only meaningful when hasVault === false.76  const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);7778  const MIN_LENGTH = 12;79  const MIN_ZXCVBN_SCORE = 2;8081  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);82  const [biometricAvailable, setBiometricAvailable] = createSignal(false);83  const [biometricBusy, setBiometricBusy] = createSignal(false);8485  const canCreate = createMemo(86    () =>87      password().length >= MIN_LENGTH &&88      confirmPassword().length >= MIN_LENGTH &&89      password() === confirmPassword() &&90      strengthScore() !== null &&91      strengthScore()! >= MIN_ZXCVBN_SCORE92  );9394  const passwordStrength = createMemo(() => {95    const score = strengthScore();96    if (score === null) return null;97    if (score <= 1) return "weak";98    if (score === 2) return "fair";99    if (score === 3) return "good";100    return "strong";101  });102103  const createBlockerMessage = createMemo(() => {104    if (!password()) return null;105    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;106    if (!confirmPassword()) return "confirm your password";107    if (password() !== confirmPassword()) return "passwords don't match";108    if (strengthScore() === null) return "checking strength…";109    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — use a longer passphrase";110    return null;111  });112113  // Update strength asynchronously when password changes114  createMemo(() => {115    const value = password();116    if (!value) { setStrengthScore(null); return; }117    getZxcvbn().then((zxcvbn) => {118      if (password() === value) setStrengthScore(zxcvbn(value).score);119    }).catch(() => {120      // zxcvbn unavailable — fall back to length-only heuristic121      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);122    });123  });124125  async function handleUnlock() {126    if (!password().trim()) return;127    setLoading(true);128    setError("");129    const response = await sendMessage({130      type: "UNLOCK",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, "incorrect password"));140    setPassword("");141    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;142    passwordField?.classList.add("shake");143    setTimeout(() => passwordField?.classList.remove("shake"), 300);144    setLoading(false);145  }146147  async function checkBiometricAvailable(): Promise<void> {148    if (!props.hasVault || !isNativePlatform()) return;149    const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");150    const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");151    if (!keystore || !biometric) return;152    try {153      const stored = await keystore.hasMasterKey();154      if (!stored.exists) return;155      const avail = await biometric.isAvailable();156      if (!avail.available) return;157      setBiometricAvailable(true);158      window.setTimeout(() => { void handleBiometricUnlock(); }, 400);159    } catch { /* missing plugins or runtime error — silently skip */ }160  }161162  async function handleBiometricUnlock(): Promise<void> {163    if (biometricBusy()) return;164    const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");165    const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");166    if (!biometric || !keystore) return;167    setBiometricBusy(true);168    setError("");169    try {170      await biometric.authenticate({171        title: "Unlock Peach",172        subtitle: "Use biometrics to access your vault",173        negativeButtonText: "Use Password",174      });175      const { masterKeyHex } = await keystore.retrieveMasterKey();176      const res = (await sendMessage({177        type: "UNLOCK_WITH_KEY",178        payload: { masterKeyHex },179      })) as { ok: boolean; error?: string };180      if (res.ok) {181        await props.onUnlock();182        return;183      }184      if (res.error === "biometric_key_invalidated") {185        await sendMessage({186          type: "UPDATE_SETTINGS",187          payload: { biometricsEnabled: false },188        });189        setBiometricAvailable(false);190        setError("biometric key expired — re-enable in settings");191      } else {192        setError("biometric unlock failed");193      }194    } catch (e) {195      const reason = e instanceof Error ? e.message : String(e);196      if (reason.includes("user_cancelled")) return;197      if (reason.includes("biometric_key_invalidated")) {198        await sendMessage({199          type: "UPDATE_SETTINGS",200          payload: { biometricsEnabled: false },201        });202        setBiometricAvailable(false);203        setError("biometric key expired — re-enable in settings");204        return;205      }206      if (reason.includes("biometric_lockout")) {207        setError("biometric locked out — use your password");208      }209    } finally {210      setBiometricBusy(false);211    }212  }213214  onMount(() => { void checkBiometricAvailable(); });215216  async function handleCreate() {217    if (!password().trim()) return;218    if (password().length < MIN_LENGTH) {219      setError(`password must be at least ${MIN_LENGTH} characters`);220      return;221    }222    if (password() !== confirmPassword()) {223      setError("passwords do not match");224      return;225    }226    const score = strengthScore();227    if (score === null || score < MIN_ZXCVBN_SCORE) {228      setError("password is too weak — use a longer passphrase");229      return;230    }231    setLoading(true);232    setError("");233    const response = await sendMessage({234      type: "CREATE_VAULT",235      payload: { password: password() },236    });237    const result = response as { ok: boolean; error?: string };238    if (result.ok) {239      // Don't unlock yet — show the recovery words screen first. Mark the240      // confirmation as pending in chrome.storage.local so it survives both241      // popup close (lock-on-close skips while set) and full browser restart242      // (next unlock re-routes to the recovery surface). Cleared when the243      // user types the 3 confirmation words successfully.244      try {245        await chrome.storage.local?.set({ peach_recovery_pending: "1" });246      } catch { /* best-effort */ }247      setShowRecoveryWords(true);248      setLoading(false);249      return;250    }251    setError(getDisplayError(result.error, "failed to create vault"));252    setLoading(false);253  }254255  function openRestore(surface: Exclude<RestoreSurface, null>) {256    if (!props.ready || loading()) return;257    setPassword("");258    setConfirmPassword("");259    setError("");260    setRestoreSurface(surface);261  }262263  // ── All surfaces routed reactively via Switch/Match ───────────────────264  return (265    <Switch>266      {/* ── Restore tab view ──────────────────────────────────────── */}267      <Match when={restoreSurface() === "restore"}>268        <RestoreTabView269          hasVault={props.hasVault}270          onOpenCodexRestore={props.onOpenCodexRestore}271          onBack={() => setRestoreSurface(null)}272          onUnlock={async () => {273            setRestoreSurface(null);274            await props.onUnlock();275          }}276        />277      </Match>278279      {/* ── Recovery words display after fresh vault creation ────────── */}280      <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>281        <RecoveryWordDisplayView282          onDone={props.onUnlock}283        />284      </Match>285286      {/* ── Recovery word entry ─────────────────────────────────────── */}287      <Match when={recoverSurface() === "words"}>288        <RecoveryWordEntryView289          onCancel={() => setRecoverSurface(null)}290          onRecoveryComplete={props.onUnlock}291        />292      </Match>293294      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}295      <Match when={props.hasVault}>296        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">297          <header class="peach-screen-header lock-screen-header">298            <div class="peach-wordmark-stack">299              <PeachWordmark class="peach-wordmark-wrap" />300              <Show when={props.isPro}>301                <span class="peach-pro-mini-badge">pro</span>302              </Show>303            </div>304          </header>305306          <div class="peach-lock-hero peach-lock-hero--unlock">307            <div class="peach-lock-hero-art" aria-hidden="true">308              <PeachMark class="peach-lock-hero-mark" />309            </div>310            <span class="peach-lock-eyebrow">vault locked</span>311            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">312              unlock313              <br />314              your vault.315            </h1>316          </div>317318          <LockRuneCanvas />319320          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">321            <label class="peach-label" for="unlock-password">322              master password323            </label>324            <input325              id="unlock-password"326              class="peach-underline-input peach-unlock-input lock-password-input"327              type="password"328              placeholder="••••••••••••"329              autocomplete="current-password"330              value={password()}331              disabled={!props.ready}332              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}333              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}334              onKeyDown={(event) => {335                if (event.key !== "Enter" || !props.ready) return;336                void handleUnlock();337              }}338            />339340            <Show when={error()}>341              <p class="peach-hint peach-hint-error">{error()}</p>342            </Show>343344            <button345              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"346              onClick={() => void handleUnlock()}347              disabled={!props.ready || loading()}348            >349              {loading() ? "unlocking..." : "unlock"}350            </button>351352            <Show when={biometricAvailable()}>353              <button354                class="peach-biometric-btn"355                type="button"356                onClick={() => void handleBiometricUnlock()}357                disabled={biometricBusy() || loading()}358                aria-label="Unlock with biometrics"359              >360                <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">361                  <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>362                  <path d="M15 17v-3"/>363                  <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>364                  <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>365                  <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>366                  <path d="M5 15c0-1.7.3-3.3.8-4.7"/>367                  <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>368                </svg>369                {biometricBusy() ? "verifying…" : "use biometrics"}370              </button>371            </Show>372373            <button374              class="peach-lock-recover-link"375              type="button"376              onClick={() => setRecoverSurface("words")}377            >378              forgot your password?379            </button>380          </section>381        </section>382      </Match>383384      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}385      <Match when={true}>386        <section387          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"388          data-motion-phase="enter"389        >390          <header class="peach-screen-header peach-newvault-header">391            <Show when={props.onBackToOnboarding}>392              <button393                class="peach-newvault-back"394                type="button"395                onClick={() => props.onBackToOnboarding?.()}396                aria-label="Back to onboarding"397              >398                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">399                  <path d="M7 2L3 5.5L7 9" />400                </svg>401                back402              </button>403            </Show>404            <div class="peach-wordmark-stack">405              <PeachWordmark class="peach-wordmark-wrap" />406              <Show when={props.isPro}>407                <span class="peach-pro-mini-badge">pro</span>408              </Show>409            </div>410          </header>411412          <div class="peach-newvault-mark" aria-hidden="true">413            <PeachMark class="peach-newvault-mark-art" />414          </div>415416          <div class="peach-newvault-card">417            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>418            <hr class="peach-newvault-divider" />419420            <div class="peach-newvault-field">421              <div class="peach-newvault-label-row">422                <label class="peach-label" for="master-password">423                  master password424                </label>425                <button426                  class="peach-lock-reveal-btn"427                  type="button"428                  onClick={() => setShowPassword((value) => !value)}429                  aria-label={showPassword() ? "Hide password" : "Show password"}430                >431                  <Show432                    when={showPassword()}433                    fallback={434                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">435                        <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" />436                        <circle cx="7" cy="5" r="1.75" />437                      </svg>438                    }439                  >440                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">441                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />442                      <path d="M3.5 5.5L2.5 7.5" />443                      <path d="M7 6.5V8.5" />444                      <path d="M10.5 5.5L11.5 7.5" />445                    </svg>446                  </Show>447                  {showPassword() ? "hide" : "show"}448                </button>449              </div>450              <input451                id="master-password"452                class="peach-underline-input peach-newvault-input lock-password-input"453                type={showPassword() ? "text" : "password"}454                placeholder="••••••••••••"455                autocomplete="new-password"456                value={password()}457                disabled={!props.ready}458                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}459                onKeyDown={(event) => {460                  if (event.key !== "Enter" || !props.ready) return;461                  void handleCreate();462                }}463              />464              <Show465                when={password().length > 0 && passwordStrength() !== null}466                fallback={467                  <p class="peach-newvault-hint">468                    Use at least 12 characters. Peach will only create the vault when the passwords match and the strength check passes.469                  </p>470                }471              >472                <div class="peach-password-strength">473                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>474                    <div class="peach-strength-fill"></div>475                  </div>476                  <span class="peach-strength-label">{passwordStrength()}</span>477                </div>478              </Show>479            </div>480481            <div class="peach-newvault-field">482              <label class="peach-label" for="confirm-password">483                confirm password484              </label>485              <input486                id="confirm-password"487                class="peach-underline-input peach-newvault-input"488                type={showPassword() ? "text" : "password"}489                placeholder="••••••••••••"490                autocomplete="new-password"491                value={confirmPassword()}492                disabled={!props.ready}493                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}494                onKeyDown={(event) => {495                  if (event.key === "Enter" && props.ready) void handleCreate();496                }}497              />498            </div>499500            <Show when={error()}>501              <p class="peach-hint peach-hint-error">{error()}</p>502            </Show>503504            <button505              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"506              onClick={() => void handleCreate()}507              disabled={!props.ready || loading() || !canCreate()}508              aria-live="polite"509            >510              <Show511                when={!canCreate() && createBlockerMessage()}512                fallback={loading() ? "creating..." : "create vault"}513              >514                {createBlockerMessage()}515              </Show>516            </button>517518            <p class="peach-newvault-warning">519              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>520              <span>Save your 12 recovery words right after you create the vault — they're the only way in if you forget your password.</span>521            </p>522          </div>523524          <div class="peach-newvault-footer">525            <p class="peach-hint">Already have a backup?</p>526            <button527              class="peach-lock-restore-link"528              onClick={() => void openRestore("restore")}529              disabled={!props.ready}530            >531              › restore from backup532            </button>533            <Show when={props.onOpenCodexRestore}>534              <button535                class="peach-lock-restore-link"536                onClick={() => props.onOpenCodexRestore?.()}537                disabled={!props.ready}538              >539                › restore from peach codex540              </button>541            </Show>542          </div>543        </section>544      </Match>545    </Switch>546  );547}548549// ── Restore tab view ───────────────────────────────────────────────────────550551type RestoreTab = "symbols" | "codex" | "csv";552553function tabLabel(tab: RestoreTab): string {554  return (555    { symbols: "PeachScript", codex: "Codex", csv: "CSV" }[tab]556  );557}558559function RestoreTabView(props: {560  hasVault: boolean;561  onOpenCodexRestore?: () => void;562  onBack: () => void;563  onUnlock: () => void | Promise<void>;564}) {565  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");566  const [showIntro, setShowIntro] = createSignal(false);567  const [pendingUnlock] = createSignal(false);568569  async function handleRestoreSuccess() {570    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);571    if (!seen) {572      setShowIntro(true);573    } else {574      await props.onUnlock();575    }576  }577578  async function dismissIntro() {579    await storageSet("peach_shown_restore_intro", "true").catch(() => {});580    setShowIntro(false);581    await props.onUnlock();582  }583584  return (585    <section586      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"587      data-motion-phase="enter"588    >589      <RuneBackground opacity={0.05} />590591      <header class="peach-screen-header lock-screen-header">592        <PeachWordmark class="peach-wordmark-wrap" />593        <button class="peach-lock-restore-link" onClick={props.onBack}>594          ‹ back595        </button>596      </header>597598      <div class="peach-newvault-card peach-restore-tabcard">599        <div class="peach-restore-tabs" role="tablist">600          <For each={["symbols", "codex", "csv"] as RestoreTab[]}>601            {(tab) => (602              <button603                class="peach-restore-tab"604                classList={{ "is-active": activeTab() === tab }}605                onClick={() => setActiveTab(tab)}606                role="tab"607                aria-selected={activeTab() === tab}608              >609                {tabLabel(tab)}610              </button>611            )}612          </For>613        </div>614        <div class="peach-restore-content">615          <Show when={activeTab() === "symbols"}>616            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />617          </Show>618          <Show when={activeTab() === "codex"}>619            <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />620          </Show>621          <Show when={activeTab() === "csv"}>622            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />623          </Show>624        </div>625      </div>626627      {/* One-time intro popup after first restore */}628      <Show when={showIntro()}>629        <div class="peach-restore-intro-overlay">630          <div class="peach-restore-intro-card">631            <p class="peach-restore-intro-headline">welcome to peach.</p>632            <div class="peach-restore-intro-body">633              <p>Your vault lives only on this device. No server has a copy.</p>634              <p>The inline bubble fills passwords automatically when you visit sites.</p>635              <p>Export your vault from Settings before switching devices.</p>636            </div>637            <button638              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"639              onClick={() => void dismissIntro()}640              disabled={pendingUnlock()}641            >642              got it643            </button>644          </div>645        </div>646      </Show>647    </section>648  );649}650651// ── Peach Codex tab ────────────────────────────────────────────────────────652653function RestoreCodex(props: { onOpen: () => void }) {654  return (655    <div class="peach-restore-codex">656      <p class="peach-restore-codex-title">Restore from Peach Codex</p>657      <p class="peach-restore-codex-copy">658        Scan printed Codex pages or import the Codex PDF. Codex restore is659        always available, even before you create a vault.660      </p>661      <button662        type="button"663        class="peach-btn peach-btn--accent peach-btn--md peach-restore-codex-button"664        onClick={props.onOpen}665      >666        open Codex restore →667      </button>668    </div>669  );670}671672// ── PeachScript tab ────────────────────────────────────────────────────────673674function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {675  const [scriptInput, setScriptInput] = createSignal("");676  const [backupPass, setBackupPass] = createSignal("");677  const [working, setWorking] = createSignal(false);678  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);679  const [error, setError] = createSignal<string | null>(null);680  let canvasRef: HTMLCanvasElement | undefined;681  let animFrameId = 0;682  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];683684  const scriptValid = createMemo(() => isPeachScript(scriptInput()));685686  function stopAnim() {687    cancelAnimationFrame(animFrameId);688    animFrameId = 0;689  }690691  function startChaos(canvas: HTMLCanvasElement) {692    const ctx = canvas.getContext("2d")!;693    const W = canvas.width;694    const H = canvas.height;695    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>696      String.fromCodePoint(0xe000 + i)697    );698    if (runes.length === 0) {699      runes = Array.from({ length: 60 }, () => ({700        x: Math.random() * W,701        y: Math.random() * H,702        vx: (Math.random() - 0.5) * 3,703        vy: (Math.random() - 0.5) * 3,704        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,705        size: 14 + Math.random() * 20,706        angle: Math.random() * Math.PI * 2,707        va: (Math.random() - 0.5) * 0.1,708      }));709    }710711    function draw() {712      ctx.clearRect(0, 0, W, H);713      for (const r of runes) {714        r.x = (r.x + r.vx + W) % W;715        r.y = (r.y + r.vy + H) % H;716        r.angle += r.va;717        ctx.save();718        ctx.translate(r.x, r.y);719        ctx.rotate(r.angle);720        ctx.font = `${r.size}px PeachScript, monospace`;721        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);722        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;723        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";724        ctx.shadowBlur = 8;725        ctx.fillText(r.char, 0, 0);726        ctx.restore();727      }728      animFrameId = requestAnimationFrame(draw);729    }730    draw();731  }732733  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {734    stopAnim();735    const ctx = canvas.getContext("2d")!;736    const W = canvas.width;737    const H = canvas.height;738    const cols = 8;739    const rows = 6;740    const cellW = W / cols;741    const cellH = H / rows;742    const targets = runes.slice(0, cols * rows).map((_, i) => ({743      x: (i % cols) * cellW + cellW / 2,744      y: Math.floor(i / cols) * cellH + cellH / 2,745    }));746    const start = performance.now();747    const DURATION = 400;748749    function drawSnap(now: number) {750      const t = Math.min((now - start) / DURATION, 1);751      const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;752      ctx.clearRect(0, 0, W, H);753      runes.slice(0, targets.length).forEach((r, i) => {754        const tx = targets[i]!.x;755        const ty = targets[i]!.y;756        const cx = r.x + (tx - r.x) * ease;757        const cy = r.y + (ty - r.y) * ease;758        ctx.font = `16px PeachScript, monospace`;759        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;760        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";761        ctx.shadowBlur = 6;762        ctx.fillText(r.char, cx - 8, cy + 8);763      });764      if (t < 1) {765        animFrameId = requestAnimationFrame(drawSnap);766      } else {767        onDone();768      }769    }770    animFrameId = requestAnimationFrame(drawSnap);771  }772773  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {774    stopAnim();775    const ctx = canvas.getContext("2d")!;776    const W = canvas.width;777    const H = canvas.height;778    const start = performance.now();779    const DURATION = 900;780781    function drawBloom(now: number) {782      const t = Math.min((now - start) / DURATION, 1);783      ctx.clearRect(0, 0, W, H);784      // Runes dissolve785      const runeAlpha = 1 - Math.min(t / 0.4, 1);786      if (runeAlpha > 0) {787        for (const r of runes.slice(0, 48)) {788          ctx.font = `16px PeachScript, monospace`;789          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;790          ctx.fillText(r.char, r.x - 8, r.y + 8);791        }792      }793      // Peach mark bloom794      const markT = Math.max(0, (t - 0.2) / 0.5);795      const markScale = Math.min(markT * 1.2, 1);796      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;797      if (markAlpha > 0) {798        ctx.save();799        ctx.globalAlpha = markAlpha;800        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;801        ctx.shadowBlur = 30;802        ctx.translate(W / 2, H / 2);803        ctx.scale(markScale, markScale);804        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);805        ctx.restore();806      }807      if (t < 1) {808        animFrameId = requestAnimationFrame(drawBloom);809      } else {810        onDone();811      }812    }813    animFrameId = requestAnimationFrame(drawBloom);814  }815816  onCleanup(() => stopAnim());817818  async function handleRestore() {819    if (!scriptValid() || !backupPass().trim() || working()) return;820    setError(null);821    setWorking(true);822    setAnimating("chaos");823824    const canvas = canvasRef;825    if (canvas) startChaos(canvas);826827    try {828      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));829      const payload = await parsePayload(bytes, backupPass());830831      if (!props.hasVault) {832        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });833        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");834      }835836      const rr = await sendMessage({837        type: "RESTORE_ENTRIES",838        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },839      });840      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");841842      if (payload.license?.key) {843        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});844      }845846      setAnimating("snap");847      stopAnim();848      if (canvas) {849        snapToGrid(canvas, () => {850          setAnimating("bloom");851          bloom(canvas, async () => {852            setAnimating(null);853            await props.onSuccess();854          });855        });856      } else {857        await props.onSuccess();858      }859    } catch (err) {860      stopAnim();861      setAnimating(null);862      const code = err instanceof Error ? err.message : String(err);863      setError(864        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"865          ? "That passphrase didn't work."866          : code === "CREATE_VAULT_FAILED"867            ? "Couldn't create a new local vault."868            : "Could not read this backup.",869      );870    } finally {871      setWorking(false);872    }873  }874875  return (876    <div class="peach-rt-symbols">877      <Show when={animating() !== null}>878        <canvas879          ref={(el) => { canvasRef = el; }}880          class="peach-rt-anim-canvas"881          width={280}882          height={200}883        />884      </Show>885      <Show when={animating() === null}>886        <textarea887          class="peach-restore-symbols-textarea"888          placeholder="paste peachscript…"889          rows={3}890          value={scriptInput()}891          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}892        />893        <div class="peach-rt-divider">— or —</div>894        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">895          open .peach file896          <input897            type="file"898            accept=".peach,text/plain"899            style="display:none"900            onChange={async (e) => {901              const file = (e.currentTarget as HTMLInputElement).files?.[0];902              if (!file) return;903              try {904                setScriptInput(await file.text());905              } catch {906                setError("Couldn't read file.");907              } finally {908                (e.currentTarget as HTMLInputElement).value = "";909              }910            }}911          />912        </label>913        <input914          class="peach-restore-symbols-pass"915          type="password"916          placeholder="backup password"917          value={backupPass()}918          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}919          onKeyDown={(e) => {920            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {921              e.preventDefault();922              void handleRestore();923            }924          }}925        />926        <button927          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"928          onClick={() => void handleRestore()}929          disabled={!scriptValid() || !backupPass().trim() || working()}930        >931          restore vault →932        </button>933        <Show when={error()}>934          <p class="peach-rt-error">{error()}</p>935        </Show>936      </Show>937    </div>938  );939}940941// ── CSV tab ────────────────────────────────────────────────────────────────942943function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {944  const [importing, setImporting] = createSignal(false);945  const [error, setError] = createSignal<string | null>(null);946  // CSV/JSON imports are plaintext and carry no password, so when there is no947  // vault yet the user MUST choose a master password here — otherwise the948  // imported credentials would be encrypted under an empty key and offer zero949  // at-rest protection (audit HIGH). Mirrors the onboarding MIN_LENGTH policy.950  const CSV_MIN_PASSWORD_LENGTH = 12;951  const [newPass, setNewPass] = createSignal("");952  const [confirmPass, setConfirmPass] = createSignal("");953  const needsPassword = () => props.hasVault === false;954  const passwordReady = () =>955    !needsPassword() ||956    (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());957958  async function handleFile(e: Event) {959    const input = e.currentTarget as HTMLInputElement;960    const file = input.files?.[0];961    input.value = "";962    if (!file) return;963    if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {964      setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);965      return;966    }967    if (needsPassword() && newPass() !== confirmPass()) {968      setError("Passwords do not match.");969      return;970    }971    setImporting(true);972    setError(null);973    try {974      const text = await file.text();975      const parsed = await parseImportSource(text);976      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");977978      if (props.hasVault === false) {979        // Create the vault under the user-chosen master password — never an980        // empty one (an empty key derives a publicly-known vault key).981        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });982        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");983      }984985      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });986      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");987      if (parsed.licenseKey) {988        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});989      }990      await props.onSuccess();991    } catch (err) {992      const code = err instanceof Error ? err.message : String(err);993      setError(994        code === "NO_ENTRIES" ? "No entries found in this file." :995        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :996        "Import failed. Check the file format and try again."997      );998    } finally {999      setImporting(false);1000    }1001  }10021003  return (1004    <div class="peach-rt-csv">1005      <Show when={needsPassword()}>1006        <input1007          class="peach-restore-symbols-pass"1008          type="password"1009          placeholder="new master password"1010          value={newPass()}1011          onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1012        />1013        <input1014          class="peach-restore-symbols-pass"1015          type="password"1016          placeholder="confirm master password"1017          value={confirmPass()}1018          onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1019        />1020      </Show>1021      <label1022        class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn"1023        classList={{ "is-busy": importing(), "is-disabled": !passwordReady() }}1024      >1025        {importing() ? "importing…" : "open csv file"}1026        <input1027          type="file"1028          accept=".csv,.json,.peach,application/x-peach"1029          style="display:none"1030          onChange={handleFile}1031          disabled={importing() || !passwordReady()}1032        />1033      </label>1034      <p class="peach-rt-csv-sources">1035        Imports from Bitwarden, 1Password, Proton Pass,<br />1036        Chrome, Safari, and Edge.1037      </p>1038      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1039      <Show when={error()}>1040        <p class="peach-rt-error">{error()}</p>1041      </Show>1042    </div>1043  );1044}10451046// ── Helpers ────────────────────────────────────────────────────────────────10471048function getDisplayError(error: string | undefined, fallback: string): string {1049  if (!error) return fallback;1050  if (1051    error === "SERVICE_WORKER_UNAVAILABLE" ||1052    error.includes("Receiving end does not exist") ||1053    error.includes("message port closed")1054  ) {1055    return "background service unavailable";1056  }1057  return error;1058}