Lock.tsx

48.8 KB · raw

1import { For, Match, Show, Switch, createMemo, createSignal, onCleanup, onMount } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark, PeachWordmark } from "../components/PeachMark";4import { RecoveryWordEntryView } from "./RecoveryWordEntry";5import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";6import { RuneBackground } from "../components/RuneBackground";7import { RuneCanvas } from "../components/RuneCanvas";8import { storageGet, storageRemove, storageSet } from "../../shared/storage";9import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";10import { parsePayload } from "../../crypto/peachPayload";11import { parseImportSource } from "../../shared/import";1213const peachMarkImg = new Image();14peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(15  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" fill="none">' +16  '<path d="M 124 64 C 134 32 168 16 200 22 C 198 56 168 72 130 66 Z" fill="#92AC65"/>' +17  '<path d="M 130 64 C 152 46 178 30 196 24" stroke="#5E7A42" stroke-width="2.6" fill="none" stroke-linecap="round"/>' +18  '<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"/>' +19  '<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"/>' +20  '<ellipse cx="178" cy="108" rx="20" ry="11" fill="#FFD0A8" opacity="0.7" transform="rotate(-26 178 108)"/>' +21  '</svg>'22)}`;2324// Lazy-load @zxcvbn-ts — heavy (dictionaries ~700 kB) and only needed during25// vault creation. Replaces the unmaintained [email protected] (LV-8); result.score26// keeps the same 0-4 scale so downstream UI is unchanged.27let _zxcvbn: ((password: string) => { score: number }) | null = null;28async function getZxcvbn() {29  if (_zxcvbn) return _zxcvbn;30  const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([31    import("@zxcvbn-ts/core"),32    import("@zxcvbn-ts/language-common"),33    import("@zxcvbn-ts/language-en"),34  ]);35  zxcvbnOptions.setOptions({36    translations: en.translations,37    graphs: common.adjacencyGraphs,38    dictionary: { ...common.dictionary, ...en.dictionary },39  });40  _zxcvbn = (password: string) => zxcvbn(password);41  return _zxcvbn;42}4344interface LockProps {45  hasVault: boolean;46  ready: boolean;47  isPro?: boolean;48  /** When true, render the recovery-words display directly instead of the49   *  unlock UI. Used after a popup close+reopen during the create-vault50   *  recovery confirmation flow (peach_recovery_pending flag). */51  forceRecoveryWords?: boolean;52  onUnlock: () => void | Promise<void>;53  /** Optional escape hatch: rewind from the create-vault screen back to54   *  Onboarding. Only meaningful when the user reached create-vault from55   *  Onboarding in the first place. */56  onBackToOnboarding?: () => void;57  /** Optional: open the Peach Codex restore flow (camera scan of printed58    *  Codex pages). Surfaced as a small footer link on the new-vault screen. */59  onOpenCodexRestore?: () => void;60  /** Open directly on backup restore when onboarding routed the user there. */61  initialRestore?: boolean;62  /** Resume the restore surface on the source the user had picked. Set from63    *  RESTORE_SOURCE_STORAGE_KEY at boot — a popup close (the OS file picker64    *  kills it) must land back on the same source form, not the picker. */65  initialRestoreSource?: RestoreSource;66}6768/** The restore source picked before a popup close, so boot can resume the69 *  same source form instead of rewinding to the source picker. Written by70 *  RestoreView, read/cleared by App. */71export const RESTORE_SOURCE_STORAGE_KEY = "peach_onboarding_restore_source";7273type RestoreSurface = "restore" | null;74type RecoverSurface = "words" | "new-password" | null;7576export function LockView(props: LockProps) {77  const [password, setPassword] = createSignal("");78  const [confirmPassword, setConfirmPassword] = createSignal("");79  const [error, setError] = createSignal("");80  const [loading, setLoading] = createSignal(false);81  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(82    props.initialRestore ? "restore" : null,83  );84  const [showPassword, setShowPassword] = createSignal(false);85  const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);86  // After vault creation succeeds, set this to show the recovery words screen.87  // Only meaningful when hasVault === false.88  const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);8990  const MIN_LENGTH = 12;91  const MIN_ZXCVBN_SCORE = 2;9293  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);94  const [pinAvailable, setPinAvailable] = createSignal(false);95  const [usingPin, setUsingPin] = createSignal(false);96  const [pin, setPin] = createSignal("");9798  const canCreate = createMemo(99    () =>100      password().length >= MIN_LENGTH &&101      confirmPassword().length >= MIN_LENGTH &&102      password() === confirmPassword() &&103      strengthScore() !== null &&104      strengthScore()! >= MIN_ZXCVBN_SCORE105  );106107  const passwordStrength = createMemo(() => {108    const score = strengthScore();109    if (score === null) return null;110    if (score <= 1) return "weak";111    if (score === 2) return "fair";112    if (score === 3) return "good";113    return "strong";114  });115116  const createBlockerMessage = createMemo(() => {117    if (!password()) return null;118    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;119    if (!confirmPassword()) return "confirm your password";120    if (password() !== confirmPassword()) return "passwords don't match";121    if (strengthScore() === null) return "checking strength…";122    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — use a longer passphrase";123    return null;124  });125126  // Update strength asynchronously when password changes127  createMemo(() => {128    const value = password();129    if (!value) { setStrengthScore(null); return; }130    getZxcvbn().then((zxcvbn) => {131      if (password() === value) setStrengthScore(zxcvbn(value).score);132    }).catch(() => {133      // zxcvbn unavailable — fall back to length-only heuristic134      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);135    });136  });137138  async function handleUnlock() {139    if (!password().trim()) return;140    setLoading(true);141    setError("");142    const response = await sendMessage({143      type: "UNLOCK",144      payload: { password: password() },145    });146    const result = response as { ok: boolean; error?: string };147    if (result.ok) {148      await props.onUnlock();149      setLoading(false);150      return;151    }152    setError(getDisplayError(result.error, "incorrect password"));153    setPassword("");154    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;155    passwordField?.classList.add("shake");156    setTimeout(() => passwordField?.classList.remove("shake"), 300);157    setLoading(false);158  }159160  async function handlePinUnlock() {161    if (!/^\d{4,12}$/.test(pin())) return;162    setLoading(true);163    setError("");164    const response = await sendMessage({165      type: "UNLOCK_WITH_PIN",166      payload: { pin: pin() },167    });168    const result = response as { ok: boolean; error?: string };169    if (result.ok) {170      await props.onUnlock();171      setLoading(false);172      return;173    }174    setError(getDisplayError(result.error, "incorrect PIN"));175    setPin("");176    const pinField = document.querySelector(".lock-pin-input") as HTMLInputElement | undefined;177    pinField?.classList.add("shake");178    setTimeout(() => pinField?.classList.remove("shake"), 300);179    setLoading(false);180  }181182  async function checkPinAvailable(): Promise<void> {183    if (!props.hasVault) return;184    try {185      const response = await sendMessage({ type: "GET_SETTINGS" });186      const settings = (response as { ok: boolean; data?: { pinEnabled?: boolean; pinPreferred?: boolean } }).data;187      const enabled = settings?.pinEnabled === true;188      setPinAvailable(enabled);189      if (enabled && settings?.pinPreferred === true) setUsingPin(true);190    } catch { /* settings are optional at the lock screen */ }191  }192193  onMount(() => {194    void checkPinAvailable();195  });196197  async function handleCreate() {198    if (!password().trim()) return;199    if (password().length < MIN_LENGTH) {200      setError(`password must be at least ${MIN_LENGTH} characters`);201      return;202    }203    if (password() !== confirmPassword()) {204      setError("passwords do not match");205      return;206    }207    const score = strengthScore();208    if (score === null || score < MIN_ZXCVBN_SCORE) {209      setError("password is too weak — use a longer passphrase");210      return;211    }212    setLoading(true);213    setError("");214    const response = await sendMessage({215      type: "CREATE_VAULT",216      payload: { password: password() },217    });218    const result = response as { ok: boolean; error?: string };219    if (result.ok) {220      // Don't unlock yet — show the recovery words screen first. Mark the221      // confirmation as pending in chrome.storage.local so it survives both222      // popup close (lock-on-close skips while set) and full browser restart223      // (next unlock re-routes to the recovery surface). Cleared when the224      // user types the 3 confirmation words successfully.225      try {226        await chrome.storage.local?.set({ peach_recovery_pending: "1" });227      } catch { /* best-effort */ }228      setShowRecoveryWords(true);229      setLoading(false);230      return;231    }232    setError(getDisplayError(result.error, "failed to create vault"));233    setLoading(false);234  }235236  // ── All surfaces routed reactively via Switch/Match ───────────────────237  return (238    <Switch>239      {/* ── Restore surface ───────────────────────────────────────── */}240      <Match when={restoreSurface() === "restore"}>241        <RestoreView242          hasVault={props.hasVault}243          onOpenCodexRestore={props.onOpenCodexRestore}244          initialSource={props.initialRestoreSource}245          onBack={() => {246            // The restore tab is only reached from the onboarding carousel,247            // so back returns there — not to the create-vault form.248            if (props.onBackToOnboarding) props.onBackToOnboarding();249            else setRestoreSurface(null);250          }}251          onUnlock={async () => {252            setRestoreSurface(null);253            await props.onUnlock();254          }}255        />256      </Match>257258      {/* ── Recovery words display after fresh vault creation ────────── */}259      {/* Read props.forceRecoveryWords directly, not just the boot-time260          signal copy: when the vault is locked with a pending confirmation,261          App flips the flag after UNLOCK without remounting LockView. */}262      <Match263        when={264          (showRecoveryWords() || props.forceRecoveryWords) &&265          (!props.hasVault || props.forceRecoveryWords)266        }267      >268        <RecoveryWordDisplayView269          onDone={props.onUnlock}270        />271      </Match>272273      {/* ── Recovery word entry ─────────────────────────────────────── */}274      <Match when={recoverSurface() === "words"}>275        <RecoveryWordEntryView276          onCancel={() => setRecoverSurface(null)}277          onRecoveryComplete={props.onUnlock}278        />279      </Match>280281      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}282      <Match when={props.hasVault}>283        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">284          <RuneBackground opacity={0.07} />285286          <header class="peach-screen-header lock-screen-header">287            <div class="peach-wordmark-stack">288              <PeachWordmark class="peach-wordmark-wrap" />289              <Show when={props.isPro}>290                <span class="peach-pro-mini-badge">pro</span>291              </Show>292            </div>293          </header>294295          <div class="peach-lock-hero peach-lock-hero--unlock">296            <PeachMark class="peach-lock-mark" />297            <span class="peach-lock-eyebrow">vault locked</span>298            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">299              unlock300              <br />301              your vault.302            </h1>303          </div>304305          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">306            <Show when={usingPin()} fallback={307              <>308                <label class="peach-label" for="unlock-password">309                  master password310                </label>311                <input312                  id="unlock-password"313                  class="peach-underline-input peach-unlock-input lock-password-input"314                  type="password"315                  placeholder="••••••••••••"316                  autocomplete="current-password"317                  value={password()}318                  disabled={!props.ready}319                  ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}320                  onInput={(event) => setPassword((event.target as HTMLInputElement).value)}321                  onKeyDown={(event) => {322                    if (event.key !== "Enter" || !props.ready) return;323                    void handleUnlock();324                  }}325                />326              </>327            }>328              <label class="peach-label" for="unlock-pin">329                PIN330              </label>331              <input332                id="unlock-pin"333                class="peach-underline-input peach-unlock-input lock-pin-input"334                type="password"335                inputmode="numeric"336                maxlength="12"337                placeholder="••••••••••••"338                autocomplete="off"339                value={pin()}340                disabled={!props.ready}341                ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}342                onInput={(event) => setPin((event.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}343                onKeyDown={(event) => {344                  if (event.key !== "Enter" || !props.ready) return;345                  void handlePinUnlock();346                }}347              />348              <button349                class="peach-lock-recover-link"350                type="button"351                onClick={() => { setUsingPin(false); setError(""); }}352              >353                use master password354              </button>355            </Show>356357            <Show when={error()}>358              <p class="peach-hint peach-hint-error">{error()}</p>359            </Show>360361            <button362              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"363              onClick={() => void (usingPin() ? handlePinUnlock() : handleUnlock())}364              disabled={!props.ready || loading()}365            >366              {loading() ? "unlocking..." : usingPin() ? "unlock with PIN" : "unlock"}367            </button>368369            <Show when={!usingPin() && pinAvailable()}>370              <button371                class="peach-lock-recover-link"372                type="button"373                onClick={() => { setUsingPin(true); setError(""); }}374              >375                use PIN376              </button>377            </Show>378379            <button380              class="peach-lock-recover-link"381              type="button"382              onClick={() => setRecoverSurface("words")}383            >384              forgot your password?385            </button>386          </section>387        </section>388      </Match>389390      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}391      <Match when={true}>392        <section393          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"394          data-motion-phase="enter"395        >396          <header class="peach-screen-header peach-newvault-header">397            <div class="peach-wordmark-stack">398              <PeachWordmark class="peach-wordmark-wrap" />399              <Show when={props.isPro}>400                <span class="peach-pro-mini-badge">pro</span>401              </Show>402            </div>403            <Show when={props.onBackToOnboarding}>404              <button405                class="peach-newvault-back"406                type="button"407                onClick={() => props.onBackToOnboarding?.()}408                aria-label="Back to onboarding"409              >410                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">411                  <path d="M7 2L3 5.5L7 9" />412                </svg>413                back414              </button>415            </Show>416          </header>417418          <div class="peach-newvault-mark" aria-hidden="true">419            <PeachMark class="peach-newvault-mark-art" />420          </div>421422          <div class="peach-newvault-card">423            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>424            <hr class="peach-newvault-divider" />425426            <div class="peach-newvault-field">427              <div class="peach-newvault-label-row">428                <label class="peach-label" for="master-password">429                  master password430                </label>431                <button432                  class="peach-lock-reveal-btn"433                  type="button"434                  onClick={() => setShowPassword((value) => !value)}435                  aria-label={showPassword() ? "Hide password" : "Show password"}436                >437                  <Show438                    when={showPassword()}439                    fallback={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 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" />442                        <circle cx="7" cy="5" r="1.75" />443                      </svg>444                    }445                  >446                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">447                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />448                      <path d="M3.5 5.5L2.5 7.5" />449                      <path d="M7 6.5V8.5" />450                      <path d="M10.5 5.5L11.5 7.5" />451                    </svg>452                  </Show>453                  {showPassword() ? "hide" : "show"}454                </button>455              </div>456              <input457                id="master-password"458                class="peach-underline-input peach-newvault-input lock-password-input"459                type={showPassword() ? "text" : "password"}460                placeholder="••••••••••••"461                autocomplete="new-password"462                value={password()}463                disabled={!props.ready}464                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}465                onKeyDown={(event) => {466                  if (event.key !== "Enter" || !props.ready) return;467                  void handleCreate();468                }}469              />470              <Show471                when={password().length > 0 && passwordStrength() !== null}472                fallback={473                  <p class="peach-newvault-hint">474                    At least 12 characters.475                  </p>476                }477              >478                <div class="peach-password-strength">479                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>480                    <div class="peach-strength-fill"></div>481                  </div>482                  <span class="peach-strength-label">{passwordStrength()}</span>483                </div>484              </Show>485            </div>486487            <div class="peach-newvault-field">488              <label class="peach-label" for="confirm-password">489                confirm password490              </label>491              <input492                id="confirm-password"493                class="peach-underline-input peach-newvault-input"494                type={showPassword() ? "text" : "password"}495                placeholder="••••••••••••"496                autocomplete="new-password"497                value={confirmPassword()}498                disabled={!props.ready}499                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}500                onKeyDown={(event) => {501                  if (event.key === "Enter" && props.ready) void handleCreate();502                }}503              />504            </div>505506            <Show when={error()}>507              <p class="peach-hint peach-hint-error">{error()}</p>508            </Show>509510            <button511              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"512              onClick={() => void handleCreate()}513              disabled={!props.ready || loading() || !canCreate()}514              aria-live="polite"515            >516              <Show517                when={!canCreate() && createBlockerMessage()}518                fallback={loading() ? "creating..." : "create vault"}519              >520                {createBlockerMessage()}521              </Show>522            </button>523524            <p class="peach-newvault-warning">525              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>526              <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>527            </p>528          </div>529        </section>530      </Match>531    </Switch>532  );533}534535// ── Restore surface ────────────────────────────────────────────────────────536537export type RestoreSource = "symbols" | "codex" | "csv";538539const RESTORE_SOURCES: { id: RestoreSource; name: string; detail: string }[] = [540  { id: "symbols", name: "PeachScript", detail: "Paste the rune string, or open a .peach file." },541  { id: "codex", name: "Codex", detail: "Scan printed pages, or import the PDF." },542  { id: "csv", name: "CSV", detail: "A .csv or .json file from another password manager." },543];544545const RESTORE_DEEP_LINKS: Record<"symbols" | "csv", "peachscript" | "csv"> = {546  symbols: "peachscript",547  csv: "csv",548};549550// The Chrome action popup dismisses itself the moment the OS file picker551// steals focus, so a picker opened from it never delivers its file. From the552// action popup we reopen this surface in a standalone window and let that553// window own the dialog — same trick as CodexRestoreView.openPdfPicker().554// The window is created by the service worker: a window created by the popup555// itself is cascade-closed by Chrome when the popup dismisses. App.tsx routes556// the deep link back here (?restore=peachscript|csv) at boot, before first557// render, so the window mounts straight on the right source.558async function detachForFilePicker(route: "peachscript" | "csv"): Promise<boolean> {559  try {560    // Already inside a detached window — it can host the dialog directly.561    if (new URLSearchParams(window.location.search).get("restore")) return false;562  } catch { /* no location — fall through to the capability check */ }563  if (typeof chrome === "undefined" || typeof chrome.runtime?.sendMessage !== "function") {564    return false;565  }566  try {567      const res = (await sendMessage({568        type: "OPEN_DETACHED_POPUP",569        payload: { route },570      })) as { ok: boolean; data?: { detached?: boolean } };571      if (!res?.data?.detached) return false;572      // The action popup dismisses itself when the new window steals focus —573      // calling window.close() here cascade-closes the detached child too.574      return true;575  } catch {576    return false;577  }578}579580/** True when this window was opened via ?restore=<link> specifically to own581 *  the file dialog — mirrors CodexRestoreView's autoPickPdf detection. */582function restoreDeepLinkActive(link: "peachscript" | "csv"): boolean {583  try {584    return new URLSearchParams(window.location.search).get("restore") === link;585  } catch {586    return false;587  }588}589590function RestoreView(props: {591  hasVault: boolean;592  onOpenCodexRestore?: () => void;593  onBack: () => void;594  onUnlock: () => void | Promise<void>;595  initialSource?: RestoreSource;596}) {597  const [source, setSource] = createSignal<RestoreSource | null>(props.initialSource ?? null);598  const [showIntro, setShowIntro] = createSignal(false);599  const [pendingUnlock] = createSignal(false);600601  // The action popup dies the moment the OS file picker steals focus, so a602  // mid-restore close must resume the same source form — rewinding to the603  // picker reads as losing progress. The resume marker mirrors the picked604  // source; App clears it with the rest of the onboarding-exit state.605  function chooseSource(next: RestoreSource | null) {606    setSource(next);607    if (next) void storageSet(RESTORE_SOURCE_STORAGE_KEY, next).catch(() => {});608    else void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});609  }610611  async function handleRestoreSuccess() {612    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);613    if (!seen) {614      setShowIntro(true);615    } else {616      await props.onUnlock();617    }618  }619620  async function dismissIntro() {621    await storageSet("peach_shown_restore_intro", "true").catch(() => {});622    setShowIntro(false);623    await props.onUnlock();624  }625626  return (627    <section628      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault peach-restore"629      data-motion-phase="enter"630    >631      <RuneBackground opacity={0.05} />632633      <header class="peach-screen-header peach-newvault-header">634        <PeachWordmark class="peach-wordmark-wrap" />635        <button636          class="peach-newvault-back peach-restore-back"637          type="button"638          onClick={props.onBack}639          aria-label="Back to onboarding"640        >641          <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">642            <path d="M7 2L3 5.5L7 9" />643          </svg>644          back645        </button>646      </header>647648      <div class="peach-newvault-card peach-restore-card">649        <Show650          when={source()}651          keyed652          fallback={653            <>654              <h1 class="peach-newvault-headline">{"welcome\nback."}</h1>655              <hr class="peach-newvault-divider" />656              <div class="peach-restore-sources">657                <For each={RESTORE_SOURCES}>658                  {(entry) => (659                    <button660                      type="button"661                      class="peach-onboarding-choice peach-restore-source"662                      onClick={() => chooseSource(entry.id)}663                    >664                      <span class="peach-onboarding-choice-label">{entry.name}</span>665                      <span class="peach-onboarding-choice-detail">{entry.detail}</span>666                    </button>667                  )}668                </For>669              </div>670            </>671          }672        >673          {(active) => (674            <>675              <div class="peach-restore-source-head">676                <h2 class="peach-restore-source-title">677                  {RESTORE_SOURCES.find((entry) => entry.id === active)?.name}678                </h2>679                <button680                  type="button"681                  class="peach-restore-picker-back"682                  onClick={() => chooseSource(null)}683                >684                  ‹ sources685                </button>686              </div>687              <hr class="peach-newvault-divider" />688              <Show when={active === "symbols"}>689                <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />690              </Show>691              <Show when={active === "codex"}>692                <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />693              </Show>694              <Show when={active === "csv"}>695                <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />696              </Show>697            </>698          )}699        </Show>700      </div>701702      {/* One-time intro popup after first restore */}703      <Show when={showIntro()}>704        <div class="peach-restore-intro-overlay">705          <div class="peach-restore-intro-card">706            <p class="peach-restore-intro-headline">welcome to peach.</p>707            <div class="peach-restore-intro-body">708              <p>Your vault lives only on this device. No server has a copy.</p>709              <p>The inline bubble fills passwords automatically when you visit sites.</p>710              <p>Export your vault from Settings before switching devices.</p>711            </div>712            <button713              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"714              onClick={() => void dismissIntro()}715              disabled={pendingUnlock()}716            >717              got it718            </button>719          </div>720        </div>721      </Show>722    </section>723  );724}725726// ── Peach Codex source ─────────────────────────────────────────────────────727728function RestoreCodex(props: { onOpen: () => void }) {729  return (730    <div class="peach-restore-form">731      <p class="peach-restore-codex-copy">732        Scan printed Codex pages or import the Codex PDF. Codex restore is733        always available, even before you create a vault.734      </p>735      <button736        type="button"737        class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"738        onClick={props.onOpen}739      >740        open Codex restore741      </button>742    </div>743  );744}745746// ── PeachScript source ─────────────────────────────────────────────────────747748function RestoreSymbols(props: {749  hasVault: boolean;750  onSuccess: () => void | Promise<void>;751}) {752  const [scriptInput, setScriptInput] = createSignal("");753  const [backupPass, setBackupPass] = createSignal("");754  const [working, setWorking] = createSignal(false);755  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);756  const [error, setError] = createSignal<string | null>(null);757  let canvasRef: HTMLCanvasElement | undefined;758  let peachFileRef: HTMLInputElement | undefined;759  let animFrameId = 0;760  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];761762  const scriptValid = createMemo(() => isPeachScript(scriptInput()));763764  function stopAnim() {765    cancelAnimationFrame(animFrameId);766    animFrameId = 0;767  }768769  function startChaos(canvas: HTMLCanvasElement) {770    const ctx = canvas.getContext("2d")!;771    const W = canvas.width;772    const H = canvas.height;773    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>774      String.fromCodePoint(0xe000 + i)775    );776    if (runes.length === 0) {777      runes = Array.from({ length: 60 }, () => ({778        x: Math.random() * W,779        y: Math.random() * H,780        vx: (Math.random() - 0.5) * 3,781        vy: (Math.random() - 0.5) * 3,782        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,783        size: 14 + Math.random() * 20,784        angle: Math.random() * Math.PI * 2,785        va: (Math.random() - 0.5) * 0.1,786      }));787    }788789    function draw() {790      ctx.clearRect(0, 0, W, H);791      for (const r of runes) {792        r.x = (r.x + r.vx + W) % W;793        r.y = (r.y + r.vy + H) % H;794        r.angle += r.va;795        ctx.save();796        ctx.translate(r.x, r.y);797        ctx.rotate(r.angle);798        ctx.font = `${r.size}px PeachScript, monospace`;799        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);800        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;801        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";802        ctx.shadowBlur = 8;803        ctx.fillText(r.char, 0, 0);804        ctx.restore();805      }806      animFrameId = requestAnimationFrame(draw);807    }808    draw();809  }810811  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {812    stopAnim();813    const ctx = canvas.getContext("2d")!;814    const W = canvas.width;815    const H = canvas.height;816    const cols = 8;817    const rows = 6;818    const cellW = W / cols;819    const cellH = H / rows;820    const targets = runes.slice(0, cols * rows).map((_, i) => ({821      x: (i % cols) * cellW + cellW / 2,822      y: Math.floor(i / cols) * cellH + cellH / 2,823    }));824    const start = performance.now();825    const DURATION = 400;826827    function drawSnap(now: number) {828      const t = Math.min((now - start) / DURATION, 1);829      const ease = t < 1 ? 1 - (1 - t) ** 3 : 1;830      ctx.clearRect(0, 0, W, H);831      runes.slice(0, targets.length).forEach((r, i) => {832        const tx = targets[i]!.x;833        const ty = targets[i]!.y;834        const cx = r.x + (tx - r.x) * ease;835        const cy = r.y + (ty - r.y) * ease;836        ctx.font = `16px PeachScript, monospace`;837        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;838        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";839        ctx.shadowBlur = 6;840        ctx.fillText(r.char, cx - 8, cy + 8);841      });842      if (t < 1) {843        animFrameId = requestAnimationFrame(drawSnap);844      } else {845        onDone();846      }847    }848    animFrameId = requestAnimationFrame(drawSnap);849  }850851  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {852    stopAnim();853    const ctx = canvas.getContext("2d")!;854    const W = canvas.width;855    const H = canvas.height;856    const start = performance.now();857    const DURATION = 900;858859    function drawBloom(now: number) {860      const t = Math.min((now - start) / DURATION, 1);861      ctx.clearRect(0, 0, W, H);862      // Runes dissolve863      const runeAlpha = 1 - Math.min(t / 0.4, 1);864      if (runeAlpha > 0) {865        for (const r of runes.slice(0, 48)) {866          ctx.font = `16px PeachScript, monospace`;867          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;868          ctx.fillText(r.char, r.x - 8, r.y + 8);869        }870      }871      // Peach mark bloom872      const markT = Math.max(0, (t - 0.2) / 0.5);873      const markScale = Math.min(markT * 1.2, 1);874      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;875      if (markAlpha > 0) {876        ctx.save();877        ctx.globalAlpha = markAlpha;878        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;879        ctx.shadowBlur = 30;880        ctx.translate(W / 2, H / 2);881        ctx.scale(markScale, markScale);882        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);883        ctx.restore();884      }885      if (t < 1) {886        animFrameId = requestAnimationFrame(drawBloom);887      } else {888        onDone();889      }890    }891    animFrameId = requestAnimationFrame(drawBloom);892  }893894  onCleanup(() => stopAnim());895896  // This window was opened specifically to own the file dialog — open it as897  // soon as the input exists. Mirrors the ?codex=pdf auto-pick.898  onMount(() => {899    if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) {900      setTimeout(() => peachFileRef?.click(), 50);901    }902  });903904  // The action popup dies when the OS file picker steals focus; dropping the905  // file is the other import path that survives it.906  const [dragDepth, setDragDepth] = createSignal(0);907908  async function handleDrop(e: DragEvent) {909    e.preventDefault();910    setDragDepth(0);911    if (working() || animating() !== null) return;912    const file = e.dataTransfer?.files?.[0];913    if (!file) return;914    try {915      setScriptInput(await file.text());916      setError(null);917    } catch {918      setError("Couldn't read file.");919    }920  }921922  async function handleRestore() {923    if (!scriptValid() || !backupPass().trim() || working()) return;924    setError(null);925    setWorking(true);926    setAnimating("chaos");927928    const canvas = canvasRef;929    if (canvas) startChaos(canvas);930931    try {932      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));933      const payload = await parsePayload(bytes, backupPass());934935      if (!props.hasVault) {936        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });937        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");938      }939940      const rr = await sendMessage({941        type: "RESTORE_ENTRIES",942        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },943      });944      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");945946      if (payload.license?.key) {947        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});948      }949950      setAnimating("snap");951      stopAnim();952      if (canvas) {953        snapToGrid(canvas, () => {954          setAnimating("bloom");955          bloom(canvas, async () => {956            setAnimating(null);957            await props.onSuccess();958          });959        });960      } else {961        await props.onSuccess();962      }963    } catch (err) {964      stopAnim();965      setAnimating(null);966      const code = err instanceof Error ? err.message : String(err);967      setError(968        code === "INVALID_PASSPHRASE"969          ? "That passphrase didn't work."970          : code === "CREATE_VAULT_FAILED"971            ? "Couldn't create a new local vault."972            : "Could not read this backup.",973      );974    } finally {975      setWorking(false);976    }977  }978979  return (980    <div981      class="peach-restore-form"982      classList={{ "is-dragover": dragDepth() > 0 }}983      onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}984      onDragOver={(e) => e.preventDefault()}985      onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}986      onDrop={(e) => void handleDrop(e)}987    >988      <Show when={animating() !== null}>989        <canvas990          ref={(el) => { canvasRef = el; }}991          class="peach-restore-anim-canvas"992          width={280}993          height={200}994        />995      </Show>996      <Show when={animating() === null}>997        <Show998          when={scriptValid()}999          fallback={1000            <>1001              <textarea1002                class="peach-restore-script"1003                placeholder="paste peachscript…"1004                rows={3}1005                aria-label="Paste your PeachScript backup"1006                value={scriptInput()}1007                onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}1008              />1009              <div class="peach-restore-or" aria-hidden="true">or</div>1010              <label1011                class="peach-btn peach-btn--ghost peach-btn--sm peach-restore-file-btn"1012                onClick={(e) => {1013                  if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) return;1014                  e.preventDefault();1015                  void detachForFilePicker(RESTORE_DEEP_LINKS.symbols).then((detached) => {1016                    if (!detached) peachFileRef?.click();1017                  });1018                }}1019              >1020                open .peach file1021                <input1022                  ref={(el) => { peachFileRef = el; }}1023                  type="file"1024                  accept=".peach,text/plain"1025                  style="display:none"1026                  onChange={async (e) => {1027                    const file = (e.currentTarget as HTMLInputElement).files?.[0];1028                    if (!file) return;1029                    try {1030                      setScriptInput(await file.text());1031                    } catch {1032                      setError("Couldn't read file.");1033                    } finally {1034                      (e.currentTarget as HTMLInputElement).value = "";1035                    }1036                  }}1037                />1038              </label>1039              <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1040            </>1041          }1042        >1043          <div class="peach-restore-rune-preview">1044            <RuneCanvas runeString={scriptInput()} width={256} height={120} />1045          </div>1046          <button1047            type="button"1048            class="peach-restore-picker-back"1049            onClick={() => { setScriptInput(""); setError(null); }}1050          >1051            ‹ paste again1052          </button>1053        </Show>1054        <div class="peach-newvault-field">1055          <label class="peach-label" for="restore-backup-pass">1056            backup password1057          </label>1058          <input1059            id="restore-backup-pass"1060            class="peach-underline-input peach-newvault-input"1061            type="password"1062            placeholder="••••••••••••"1063            autocomplete="current-password"1064            value={backupPass()}1065            onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}1066            onKeyDown={(e) => {1067              if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {1068                e.preventDefault();1069                void handleRestore();1070              }1071            }}1072          />1073        </div>1074        <button1075          class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"1076          onClick={() => void handleRestore()}1077          disabled={!scriptValid() || !backupPass().trim() || working()}1078        >1079          restore vault1080        </button>1081        <Show when={error()}>1082          <p class="peach-hint peach-hint-error">{error()}</p>1083        </Show>1084      </Show>1085    </div>1086  );1087}10881089// ── CSV source ─────────────────────────────────────────────────────────────10901091function RestoreCSV(props: {1092  hasVault: boolean;1093  onSuccess: () => void | Promise<void>;1094}) {1095  const [importing, setImporting] = createSignal(false);1096  const [error, setError] = createSignal<string | null>(null);1097  // CSV/JSON imports are plaintext and carry no password, so when there is no1098  // vault yet the user MUST choose a master password here — otherwise the1099  // imported credentials would be encrypted under an empty key and offer zero1100  // at-rest protection (audit HIGH). Mirrors the onboarding MIN_LENGTH policy.1101  const CSV_MIN_PASSWORD_LENGTH = 12;1102  const [newPass, setNewPass] = createSignal("");1103  const [confirmPass, setConfirmPass] = createSignal("");1104  const needsPassword = () => props.hasVault === false;1105  const passwordReady = () =>1106    !needsPassword() ||1107    (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());1108  // The action popup dies when the OS file picker steals focus — dropping the1109  // file is the import path that survives it (see RestoreSymbols).1110  const [dragDepth, setDragDepth] = createSignal(0);11111112  // Every route here (label click, drop, the disabled-attribute-guarded1113  // input change) passes gatePassword first, so the file is always accepted1114  // under a real master password — never an empty key.1115  async function processFile(file: File) {1116    setImporting(true);1117    setError(null);1118    try {1119      const text = await file.text();1120      const parsed = await parseImportSource(text);1121      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");11221123      if (props.hasVault === false) {1124        // Create the vault under the user-chosen master password — never an1125        // empty one (an empty key derives a publicly-known vault key).1126        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });1127        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1128      }11291130      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1131      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1132      await props.onSuccess();1133    } catch (err) {1134      const code = err instanceof Error ? err.message : String(err);1135      setError(1136        code === "NO_ENTRIES" ? "No entries found in this file." :1137        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1138        "Import failed. Check the file format and try again."1139      );1140    } finally {1141      setImporting(false);1142    }1143  }11441145  let csvFileRef: HTMLInputElement | undefined;11461147  onMount(() => {1148    // A fresh vault needs its master password before a file can be accepted —1149    // auto-picking would only drop the chosen file at the password gate, so1150    // the dialog waits for the explicit click in that case.1151    if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv) && !needsPassword()) {1152      setTimeout(() => csvFileRef?.click(), 50);1153    }1154  });11551156  function gatePassword(): boolean {1157    if (importing()) return false;1158    if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {1159      setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);1160      return false;1161    }1162    if (needsPassword() && newPass() !== confirmPass()) {1163      setError("Passwords do not match.");1164      return false;1165    }1166    return true;1167  }11681169  async function handleFile(e: Event) {1170    const input = e.currentTarget as HTMLInputElement;1171    const file = input.files?.[0];1172    input.value = "";1173    if (!file) return;1174    await processFile(file);1175  }11761177  async function handleDrop(e: DragEvent) {1178    e.preventDefault();1179    setDragDepth(0);1180    if (!gatePassword()) return;1181    const file = e.dataTransfer?.files?.[0];1182    if (!file) return;1183    await processFile(file);1184  }11851186  return (1187    <div1188      class="peach-restore-form"1189      classList={{ "is-dragover": dragDepth() > 0 }}1190      onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1191      onDragOver={(e) => e.preventDefault()}1192      onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1193      onDrop={(e) => void handleDrop(e)}1194    >1195      <Show when={needsPassword()}>1196        <div class="peach-newvault-field">1197          <label class="peach-label" for="restore-csv-pass">1198            new master password1199          </label>1200          <input1201            id="restore-csv-pass"1202            class="peach-underline-input peach-newvault-input"1203            type="password"1204            placeholder="••••••••••••"1205            autocomplete="new-password"1206            value={newPass()}1207            onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1208          />1209        </div>1210        <div class="peach-newvault-field">1211          <label class="peach-label" for="restore-csv-confirm">1212            confirm master password1213          </label>1214          <input1215            id="restore-csv-confirm"1216            class="peach-underline-input peach-newvault-input"1217            type="password"1218            placeholder="••••••••••••"1219            autocomplete="new-password"1220            value={confirmPass()}1221            onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1222          />1223        </div>1224      </Show>1225      <label1226        class="peach-btn peach-btn--accent peach-btn--md peach-restore-file-btn"1227        onClick={(e) => {1228          // In the detached window the dialog can open directly — let the1229          // label's real user gesture activate the input. In the action1230          // popup we must detach first (the dialog would dismiss the popup).1231          if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv)) return;1232          // The label would forward the click to the input even before the1233          // password gate passes; own the activation so the gate can speak.1234          e.preventDefault();1235          if (!gatePassword()) return;1236          void detachForFilePicker(RESTORE_DEEP_LINKS.csv).then((detached) => {1237            if (!detached) csvFileRef?.click();1238          });1239        }}1240      >1241        {importing() ? "importing…" : "open csv file"}1242        <input1243          ref={(el) => { csvFileRef = el; }}1244          type="file"1245          accept=".csv,.json,.peach,application/x-peach"1246          style="display:none"1247          onChange={handleFile}1248          disabled={importing() || !passwordReady()}1249        />1250      </label>1251      <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1252      <p class="peach-hint peach-restore-csv-sources">1253        Imports from Bitwarden, 1Password, Proton Pass,1254        <br />1255        Chrome, Safari, and Edge.1256      </p>1257      <p class="peach-newvault-warning">1258        <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>1259        <span>Plain-text file — delete it after the import.</span>1260      </p>1261      <Show when={error()}>1262        <p class="peach-hint peach-hint-error">{error()}</p>1263      </Show>1264    </div>1265  );1266}12671268// ── Helpers ────────────────────────────────────────────────────────────────12691270function getDisplayError(error: string | undefined, fallback: string): string {1271  if (!error) return fallback;1272  if (1273    error === "SERVICE_WORKER_UNAVAILABLE" ||1274    error.includes("Receiving end does not exist") ||1275    error.includes("message port closed")1276  ) {1277    return "background service unavailable";1278  }1279  return error;1280}