Lock.tsx

49.3 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    if (result.error === "pin_attempts_exceeded") {175      setUsingPin(false);176      setError("too many incorrect PIN attempts — use your master password");177    } else if (result.error === "pin_session_expired") {178      setUsingPin(false);179      setError("PIN is unavailable until you unlock with your master password");180    } else {181      setError(getDisplayError(result.error, "incorrect PIN"));182    }183    setPin("");184    const pinField = document.querySelector(".lock-pin-input") as HTMLInputElement | undefined;185    pinField?.classList.add("shake");186    setTimeout(() => pinField?.classList.remove("shake"), 300);187    setLoading(false);188  }189190  async function checkPinAvailable(): Promise<void> {191    if (!props.hasVault) return;192    try {193      const response = await sendMessage({ type: "GET_PIN_STATUS" });194      const status = (response as {195        ok: boolean;196        data?: { configured?: boolean; available?: boolean; attemptsRemaining?: number; pinPreferred?: boolean };197      }).data;198      const enabled = status?.configured === true && status.available === true;199      setPinAvailable(enabled);200      if (enabled) {201        const settingsResponse = await sendMessage({ type: "GET_SETTINGS" });202        const settings = (settingsResponse as { ok: boolean; data?: { pinPreferred?: boolean } }).data;203        if (settings?.pinPreferred === true) setUsingPin(true);204      }205    } catch { /* settings are optional at the lock screen */ }206  }207208  onMount(() => {209    void checkPinAvailable();210  });211212  async function handleCreate() {213    if (!password().trim()) return;214    if (password().length < MIN_LENGTH) {215      setError(`password must be at least ${MIN_LENGTH} characters`);216      return;217    }218    if (password() !== confirmPassword()) {219      setError("passwords do not match");220      return;221    }222    const score = strengthScore();223    if (score === null || score < MIN_ZXCVBN_SCORE) {224      setError("password is too weak — use a longer passphrase");225      return;226    }227    setLoading(true);228    setError("");229    const response = await sendMessage({230      type: "CREATE_VAULT",231      payload: { password: password() },232    });233    const result = response as { ok: boolean; error?: string };234    if (result.ok) {235      // Don't unlock yet — show the recovery words screen first. The236      // background owns this session-scoped exemption so content scripts237      // cannot forge it through storage.local.238      await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });239      setShowRecoveryWords(true);240      setLoading(false);241      return;242    }243    setError(getDisplayError(result.error, "failed to create vault"));244    setLoading(false);245  }246247  // ── All surfaces routed reactively via Switch/Match ───────────────────248  return (249    <Switch>250      {/* ── Restore surface ───────────────────────────────────────── */}251      <Match when={restoreSurface() === "restore"}>252        <RestoreView253          hasVault={props.hasVault}254          onOpenCodexRestore={props.onOpenCodexRestore}255          initialSource={props.initialRestoreSource}256          onBack={() => {257            // The restore tab is only reached from the onboarding carousel,258            // so back returns there — not to the create-vault form.259            if (props.onBackToOnboarding) props.onBackToOnboarding();260            else setRestoreSurface(null);261          }}262          onUnlock={async () => {263            setRestoreSurface(null);264            await props.onUnlock();265          }}266        />267      </Match>268269      {/* ── Recovery words display after fresh vault creation ────────── */}270      {/* Read props.forceRecoveryWords directly, not just the boot-time271          signal copy: when the vault is locked with a pending confirmation,272          App flips the flag after UNLOCK without remounting LockView. */}273      <Match274        when={275          (showRecoveryWords() || props.forceRecoveryWords) &&276          (!props.hasVault || props.forceRecoveryWords)277        }278      >279        <RecoveryWordDisplayView280          onDone={props.onUnlock}281        />282      </Match>283284      {/* ── Recovery word entry ─────────────────────────────────────── */}285      <Match when={recoverSurface() === "words"}>286        <RecoveryWordEntryView287          onCancel={() => setRecoverSurface(null)}288          onRecoveryComplete={props.onUnlock}289        />290      </Match>291292      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}293      <Match when={props.hasVault}>294        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">295          <RuneBackground opacity={0.07} />296297          <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            <PeachMark class="peach-lock-mark" />308            <span class="peach-lock-eyebrow">vault locked</span>309            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">310              unlock311              <br />312              your vault.313            </h1>314          </div>315316          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">317            <Show when={usingPin()} fallback={318              <>319                <label class="peach-label" for="unlock-password">320                  master password321                </label>322                <input323                  id="unlock-password"324                  class="peach-underline-input peach-unlock-input lock-password-input"325                  type="password"326                  placeholder="••••••••••••"327                  autocomplete="current-password"328                  value={password()}329                  disabled={!props.ready}330                  ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}331                  onInput={(event) => setPassword((event.target as HTMLInputElement).value)}332                  onKeyDown={(event) => {333                    if (event.key !== "Enter" || !props.ready) return;334                    void handleUnlock();335                  }}336                />337              </>338            }>339              <label class="peach-label" for="unlock-pin">340                PIN341              </label>342              <input343                id="unlock-pin"344                class="peach-underline-input peach-unlock-input lock-pin-input"345                type="password"346                inputmode="numeric"347                maxlength="12"348                placeholder="••••••••••••"349                autocomplete="off"350                value={pin()}351                disabled={!props.ready}352                ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}353                onInput={(event) => setPin((event.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}354                onKeyDown={(event) => {355                  if (event.key !== "Enter" || !props.ready) return;356                  void handlePinUnlock();357                }}358              />359              <button360                class="peach-lock-recover-link"361                type="button"362                onClick={() => { setUsingPin(false); setError(""); }}363              >364                use master password365              </button>366            </Show>367368            <Show when={error()}>369              <p class="peach-hint peach-hint-error">{error()}</p>370            </Show>371372            <button373              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"374              onClick={() => void (usingPin() ? handlePinUnlock() : handleUnlock())}375              disabled={!props.ready || loading()}376            >377              {loading() ? "unlocking..." : usingPin() ? "unlock with PIN" : "unlock"}378            </button>379380            <Show when={!usingPin() && pinAvailable()}>381              <button382                class="peach-lock-recover-link"383                type="button"384                onClick={() => { setUsingPin(true); setError(""); }}385              >386                use PIN387              </button>388            </Show>389390            <button391              class="peach-lock-recover-link"392              type="button"393              onClick={() => setRecoverSurface("words")}394            >395              forgot your password?396            </button>397          </section>398        </section>399      </Match>400401      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}402      <Match when={true}>403        <section404          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"405          data-motion-phase="enter"406        >407          <header class="peach-screen-header peach-newvault-header">408            <div class="peach-wordmark-stack">409              <PeachWordmark class="peach-wordmark-wrap" />410              <Show when={props.isPro}>411                <span class="peach-pro-mini-badge">pro</span>412              </Show>413            </div>414            <Show when={props.onBackToOnboarding}>415              <button416                class="peach-newvault-back"417                type="button"418                onClick={() => props.onBackToOnboarding?.()}419                aria-label="Back to onboarding"420              >421                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">422                  <path d="M7 2L3 5.5L7 9" />423                </svg>424                back425              </button>426            </Show>427          </header>428429          <div class="peach-newvault-mark" aria-hidden="true">430            <PeachMark class="peach-newvault-mark-art" />431          </div>432433          <div class="peach-newvault-card">434            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>435            <hr class="peach-newvault-divider" />436437            <div class="peach-newvault-field">438              <div class="peach-newvault-label-row">439                <label class="peach-label" for="master-password">440                  master password441                </label>442                <button443                  class="peach-lock-reveal-btn"444                  type="button"445                  onClick={() => setShowPassword((value) => !value)}446                  aria-label={showPassword() ? "Hide password" : "Show password"}447                >448                  <Show449                    when={showPassword()}450                    fallback={451                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">452                        <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" />453                        <circle cx="7" cy="5" r="1.75" />454                      </svg>455                    }456                  >457                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">458                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />459                      <path d="M3.5 5.5L2.5 7.5" />460                      <path d="M7 6.5V8.5" />461                      <path d="M10.5 5.5L11.5 7.5" />462                    </svg>463                  </Show>464                  {showPassword() ? "hide" : "show"}465                </button>466              </div>467              <input468                id="master-password"469                class="peach-underline-input peach-newvault-input lock-password-input"470                type={showPassword() ? "text" : "password"}471                placeholder="••••••••••••"472                autocomplete="new-password"473                value={password()}474                disabled={!props.ready}475                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}476                onKeyDown={(event) => {477                  if (event.key !== "Enter" || !props.ready) return;478                  void handleCreate();479                }}480              />481              <Show482                when={password().length > 0 && passwordStrength() !== null}483                fallback={484                  <p class="peach-newvault-hint">485                    At least 12 characters.486                  </p>487                }488              >489                <div class="peach-password-strength">490                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>491                    <div class="peach-strength-fill"></div>492                  </div>493                  <span class="peach-strength-label">{passwordStrength()}</span>494                </div>495              </Show>496            </div>497498            <div class="peach-newvault-field">499              <label class="peach-label" for="confirm-password">500                confirm password501              </label>502              <input503                id="confirm-password"504                class="peach-underline-input peach-newvault-input"505                type={showPassword() ? "text" : "password"}506                placeholder="••••••••••••"507                autocomplete="new-password"508                value={confirmPassword()}509                disabled={!props.ready}510                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}511                onKeyDown={(event) => {512                  if (event.key === "Enter" && props.ready) void handleCreate();513                }}514              />515            </div>516517            <Show when={error()}>518              <p class="peach-hint peach-hint-error">{error()}</p>519            </Show>520521            <button522              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"523              onClick={() => void handleCreate()}524              disabled={!props.ready || loading() || !canCreate()}525              aria-live="polite"526            >527              <Show528                when={!canCreate() && createBlockerMessage()}529                fallback={loading() ? "creating..." : "create vault"}530              >531                {createBlockerMessage()}532              </Show>533            </button>534535            <p class="peach-newvault-warning">536              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>537              <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>538            </p>539          </div>540        </section>541      </Match>542    </Switch>543  );544}545546// ── Restore surface ────────────────────────────────────────────────────────547548export type RestoreSource = "symbols" | "codex" | "csv";549550const RESTORE_SOURCES: { id: RestoreSource; name: string; detail: string }[] = [551  { id: "symbols", name: "PeachScript", detail: "Paste the rune string, or open a .peach file." },552  { id: "codex", name: "Codex", detail: "Scan printed pages, or import the PDF." },553  { id: "csv", name: "CSV", detail: "A .csv or .json file from another password manager." },554];555556const RESTORE_DEEP_LINKS: Record<"symbols" | "csv", "peachscript" | "csv"> = {557  symbols: "peachscript",558  csv: "csv",559};560561// The Chrome action popup dismisses itself the moment the OS file picker562// steals focus, so a picker opened from it never delivers its file. From the563// action popup we reopen this surface in a standalone window and let that564// window own the dialog — same trick as CodexRestoreView.openPdfPicker().565// The window is created by the service worker: a window created by the popup566// itself is cascade-closed by Chrome when the popup dismisses. App.tsx routes567// the deep link back here (?restore=peachscript|csv) at boot, before first568// render, so the window mounts straight on the right source.569async function detachForFilePicker(route: "peachscript" | "csv"): Promise<boolean> {570  try {571    // Already inside a detached window — it can host the dialog directly.572    if (new URLSearchParams(window.location.search).get("restore")) return false;573  } catch { /* no location — fall through to the capability check */ }574  if (typeof chrome === "undefined" || typeof chrome.runtime?.sendMessage !== "function") {575    return false;576  }577  try {578      const res = (await sendMessage({579        type: "OPEN_DETACHED_POPUP",580        payload: { route },581      })) as { ok: boolean; data?: { detached?: boolean } };582      if (!res?.data?.detached) return false;583      // The action popup dismisses itself when the new window steals focus —584      // calling window.close() here cascade-closes the detached child too.585      return true;586  } catch {587    return false;588  }589}590591/** True when this window was opened via ?restore=<link> specifically to own592 *  the file dialog — mirrors CodexRestoreView's autoPickPdf detection. */593function restoreDeepLinkActive(link: "peachscript" | "csv"): boolean {594  try {595    return new URLSearchParams(window.location.search).get("restore") === link;596  } catch {597    return false;598  }599}600601function RestoreView(props: {602  hasVault: boolean;603  onOpenCodexRestore?: () => void;604  onBack: () => void;605  onUnlock: () => void | Promise<void>;606  initialSource?: RestoreSource;607}) {608  const [source, setSource] = createSignal<RestoreSource | null>(props.initialSource ?? null);609  const [showIntro, setShowIntro] = createSignal(false);610  const [pendingUnlock] = createSignal(false);611612  // The action popup dies the moment the OS file picker steals focus, so a613  // mid-restore close must resume the same source form — rewinding to the614  // picker reads as losing progress. The resume marker mirrors the picked615  // source; App clears it with the rest of the onboarding-exit state.616  function chooseSource(next: RestoreSource | null) {617    setSource(next);618    if (next) void storageSet(RESTORE_SOURCE_STORAGE_KEY, next).catch(() => {});619    else void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});620  }621622  async function handleRestoreSuccess() {623    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);624    if (!seen) {625      setShowIntro(true);626    } else {627      await props.onUnlock();628    }629  }630631  async function dismissIntro() {632    await storageSet("peach_shown_restore_intro", "true").catch(() => {});633    setShowIntro(false);634    await props.onUnlock();635  }636637  return (638    <section639      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault peach-restore"640      data-motion-phase="enter"641    >642      <RuneBackground opacity={0.05} />643644      <header class="peach-screen-header peach-newvault-header">645        <PeachWordmark class="peach-wordmark-wrap" />646        <button647          class="peach-newvault-back peach-restore-back"648          type="button"649          onClick={props.onBack}650          aria-label="Back to onboarding"651        >652          <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">653            <path d="M7 2L3 5.5L7 9" />654          </svg>655          back656        </button>657      </header>658659      <div class="peach-newvault-card peach-restore-card">660        <Show661          when={source()}662          keyed663          fallback={664            <>665              <h1 class="peach-newvault-headline">{"welcome\nback."}</h1>666              <hr class="peach-newvault-divider" />667              <div class="peach-restore-sources">668                <For each={RESTORE_SOURCES}>669                  {(entry) => (670                    <button671                      type="button"672                      class="peach-onboarding-choice peach-restore-source"673                      onClick={() => chooseSource(entry.id)}674                    >675                      <span class="peach-onboarding-choice-label">{entry.name}</span>676                      <span class="peach-onboarding-choice-detail">{entry.detail}</span>677                    </button>678                  )}679                </For>680              </div>681            </>682          }683        >684          {(active) => (685            <>686              <div class="peach-restore-source-head">687                <h2 class="peach-restore-source-title">688                  {RESTORE_SOURCES.find((entry) => entry.id === active)?.name}689                </h2>690                <button691                  type="button"692                  class="peach-restore-picker-back"693                  onClick={() => chooseSource(null)}694                >695                  ‹ sources696                </button>697              </div>698              <hr class="peach-newvault-divider" />699              <Show when={active === "symbols"}>700                <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />701              </Show>702              <Show when={active === "codex"}>703                <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />704              </Show>705              <Show when={active === "csv"}>706                <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />707              </Show>708            </>709          )}710        </Show>711      </div>712713      {/* One-time intro popup after first restore */}714      <Show when={showIntro()}>715        <div class="peach-restore-intro-overlay">716          <div class="peach-restore-intro-card">717            <p class="peach-restore-intro-headline">welcome to peach.</p>718            <div class="peach-restore-intro-body">719              <p>Your vault lives only on this device. No server has a copy.</p>720              <p>The inline bubble fills passwords automatically when you visit sites.</p>721              <p>Export your vault from Settings before switching devices.</p>722            </div>723            <button724              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"725              onClick={() => void dismissIntro()}726              disabled={pendingUnlock()}727            >728              got it729            </button>730          </div>731        </div>732      </Show>733    </section>734  );735}736737// ── Peach Codex source ─────────────────────────────────────────────────────738739function RestoreCodex(props: { onOpen: () => void }) {740  return (741    <div class="peach-restore-form">742      <p class="peach-restore-codex-copy">743        Scan printed Codex pages or import the Codex PDF. Codex restore is744        always available, even before you create a vault.745      </p>746      <button747        type="button"748        class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"749        onClick={props.onOpen}750      >751        open Codex restore752      </button>753    </div>754  );755}756757// ── PeachScript source ─────────────────────────────────────────────────────758759function RestoreSymbols(props: {760  hasVault: boolean;761  onSuccess: () => void | Promise<void>;762}) {763  const [scriptInput, setScriptInput] = createSignal("");764  const [backupPass, setBackupPass] = createSignal("");765  const [working, setWorking] = createSignal(false);766  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);767  const [error, setError] = createSignal<string | null>(null);768  let canvasRef: HTMLCanvasElement | undefined;769  let peachFileRef: HTMLInputElement | undefined;770  let animFrameId = 0;771  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];772773  const scriptValid = createMemo(() => isPeachScript(scriptInput()));774775  function stopAnim() {776    cancelAnimationFrame(animFrameId);777    animFrameId = 0;778  }779780  function startChaos(canvas: HTMLCanvasElement) {781    const ctx = canvas.getContext("2d")!;782    const W = canvas.width;783    const H = canvas.height;784    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>785      String.fromCodePoint(0xe000 + i)786    );787    if (runes.length === 0) {788      runes = Array.from({ length: 60 }, () => ({789        x: Math.random() * W,790        y: Math.random() * H,791        vx: (Math.random() - 0.5) * 3,792        vy: (Math.random() - 0.5) * 3,793        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,794        size: 14 + Math.random() * 20,795        angle: Math.random() * Math.PI * 2,796        va: (Math.random() - 0.5) * 0.1,797      }));798    }799800    function draw() {801      ctx.clearRect(0, 0, W, H);802      for (const r of runes) {803        r.x = (r.x + r.vx + W) % W;804        r.y = (r.y + r.vy + H) % H;805        r.angle += r.va;806        ctx.save();807        ctx.translate(r.x, r.y);808        ctx.rotate(r.angle);809        ctx.font = `${r.size}px PeachScript, monospace`;810        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);811        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;812        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";813        ctx.shadowBlur = 8;814        ctx.fillText(r.char, 0, 0);815        ctx.restore();816      }817      animFrameId = requestAnimationFrame(draw);818    }819    draw();820  }821822  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {823    stopAnim();824    const ctx = canvas.getContext("2d")!;825    const W = canvas.width;826    const H = canvas.height;827    const cols = 8;828    const rows = 6;829    const cellW = W / cols;830    const cellH = H / rows;831    const targets = runes.slice(0, cols * rows).map((_, i) => ({832      x: (i % cols) * cellW + cellW / 2,833      y: Math.floor(i / cols) * cellH + cellH / 2,834    }));835    const start = performance.now();836    const DURATION = 400;837838    function drawSnap(now: number) {839      const t = Math.min((now - start) / DURATION, 1);840      const ease = t < 1 ? 1 - (1 - t) ** 3 : 1;841      ctx.clearRect(0, 0, W, H);842      runes.slice(0, targets.length).forEach((r, i) => {843        const tx = targets[i]!.x;844        const ty = targets[i]!.y;845        const cx = r.x + (tx - r.x) * ease;846        const cy = r.y + (ty - r.y) * ease;847        ctx.font = `16px PeachScript, monospace`;848        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;849        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";850        ctx.shadowBlur = 6;851        ctx.fillText(r.char, cx - 8, cy + 8);852      });853      if (t < 1) {854        animFrameId = requestAnimationFrame(drawSnap);855      } else {856        onDone();857      }858    }859    animFrameId = requestAnimationFrame(drawSnap);860  }861862  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {863    stopAnim();864    const ctx = canvas.getContext("2d")!;865    const W = canvas.width;866    const H = canvas.height;867    const start = performance.now();868    const DURATION = 900;869870    function drawBloom(now: number) {871      const t = Math.min((now - start) / DURATION, 1);872      ctx.clearRect(0, 0, W, H);873      // Runes dissolve874      const runeAlpha = 1 - Math.min(t / 0.4, 1);875      if (runeAlpha > 0) {876        for (const r of runes.slice(0, 48)) {877          ctx.font = `16px PeachScript, monospace`;878          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;879          ctx.fillText(r.char, r.x - 8, r.y + 8);880        }881      }882      // Peach mark bloom883      const markT = Math.max(0, (t - 0.2) / 0.5);884      const markScale = Math.min(markT * 1.2, 1);885      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;886      if (markAlpha > 0) {887        ctx.save();888        ctx.globalAlpha = markAlpha;889        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;890        ctx.shadowBlur = 30;891        ctx.translate(W / 2, H / 2);892        ctx.scale(markScale, markScale);893        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);894        ctx.restore();895      }896      if (t < 1) {897        animFrameId = requestAnimationFrame(drawBloom);898      } else {899        onDone();900      }901    }902    animFrameId = requestAnimationFrame(drawBloom);903  }904905  onCleanup(() => stopAnim());906907  // This window was opened specifically to own the file dialog — open it as908  // soon as the input exists. Mirrors the ?codex=pdf auto-pick.909  onMount(() => {910    if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) {911      setTimeout(() => peachFileRef?.click(), 50);912    }913  });914915  // The action popup dies when the OS file picker steals focus; dropping the916  // file is the other import path that survives it.917  const [dragDepth, setDragDepth] = createSignal(0);918919  async function handleDrop(e: DragEvent) {920    e.preventDefault();921    setDragDepth(0);922    if (working() || animating() !== null) return;923    const file = e.dataTransfer?.files?.[0];924    if (!file) return;925    try {926      setScriptInput(await file.text());927      setError(null);928    } catch {929      setError("Couldn't read file.");930    }931  }932933  async function handleRestore() {934    if (!scriptValid() || !backupPass().trim() || working()) return;935    setError(null);936    setWorking(true);937    setAnimating("chaos");938939    const canvas = canvasRef;940    if (canvas) startChaos(canvas);941942    try {943      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));944      const payload = await parsePayload(bytes, backupPass());945946      if (!props.hasVault) {947        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });948        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");949      }950951      const rr = await sendMessage({952        type: "RESTORE_ENTRIES",953        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },954      });955      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");956957      if (payload.license?.key) {958        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});959      }960961      setAnimating("snap");962      stopAnim();963      if (canvas) {964        snapToGrid(canvas, () => {965          setAnimating("bloom");966          bloom(canvas, async () => {967            setAnimating(null);968            await props.onSuccess();969          });970        });971      } else {972        await props.onSuccess();973      }974    } catch (err) {975      stopAnim();976      setAnimating(null);977      const code = err instanceof Error ? err.message : String(err);978      setError(979        code === "INVALID_PASSPHRASE"980          ? "That passphrase didn't work."981          : code === "CREATE_VAULT_FAILED"982            ? "Couldn't create a new local vault."983            : "Could not read this backup.",984      );985    } finally {986      setWorking(false);987    }988  }989990  return (991    <div992      class="peach-restore-form"993      classList={{ "is-dragover": dragDepth() > 0 }}994      onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}995      onDragOver={(e) => e.preventDefault()}996      onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}997      onDrop={(e) => void handleDrop(e)}998    >999      <Show when={animating() !== null}>1000        <canvas1001          ref={(el) => { canvasRef = el; }}1002          class="peach-restore-anim-canvas"1003          width={280}1004          height={200}1005        />1006      </Show>1007      <Show when={animating() === null}>1008        <Show1009          when={scriptValid()}1010          fallback={1011            <>1012              <textarea1013                class="peach-restore-script"1014                placeholder="paste peachscript…"1015                rows={3}1016                aria-label="Paste your PeachScript backup"1017                value={scriptInput()}1018                onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}1019              />1020              <div class="peach-restore-or" aria-hidden="true">or</div>1021              <label1022                class="peach-btn peach-btn--ghost peach-btn--sm peach-restore-file-btn"1023                onClick={(e) => {1024                  if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) return;1025                  e.preventDefault();1026                  void detachForFilePicker(RESTORE_DEEP_LINKS.symbols).then((detached) => {1027                    if (!detached) peachFileRef?.click();1028                  });1029                }}1030              >1031                open .peach file1032                <input1033                  ref={(el) => { peachFileRef = el; }}1034                  type="file"1035                  accept=".peach,text/plain"1036                  style="display:none"1037                  onChange={async (e) => {1038                    const file = (e.currentTarget as HTMLInputElement).files?.[0];1039                    if (!file) return;1040                    try {1041                      setScriptInput(await file.text());1042                    } catch {1043                      setError("Couldn't read file.");1044                    } finally {1045                      (e.currentTarget as HTMLInputElement).value = "";1046                    }1047                  }}1048                />1049              </label>1050              <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1051            </>1052          }1053        >1054          <div class="peach-restore-rune-preview">1055            <RuneCanvas runeString={scriptInput()} width={256} height={120} />1056          </div>1057          <button1058            type="button"1059            class="peach-restore-picker-back"1060            onClick={() => { setScriptInput(""); setError(null); }}1061          >1062            ‹ paste again1063          </button>1064        </Show>1065        <div class="peach-newvault-field">1066          <label class="peach-label" for="restore-backup-pass">1067            backup password1068          </label>1069          <input1070            id="restore-backup-pass"1071            class="peach-underline-input peach-newvault-input"1072            type="password"1073            placeholder="••••••••••••"1074            autocomplete="current-password"1075            value={backupPass()}1076            onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}1077            onKeyDown={(e) => {1078              if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {1079                e.preventDefault();1080                void handleRestore();1081              }1082            }}1083          />1084        </div>1085        <button1086          class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"1087          onClick={() => void handleRestore()}1088          disabled={!scriptValid() || !backupPass().trim() || working()}1089        >1090          restore vault1091        </button>1092        <Show when={error()}>1093          <p class="peach-hint peach-hint-error">{error()}</p>1094        </Show>1095      </Show>1096    </div>1097  );1098}10991100// ── CSV source ─────────────────────────────────────────────────────────────11011102function RestoreCSV(props: {1103  hasVault: boolean;1104  onSuccess: () => void | Promise<void>;1105}) {1106  const [importing, setImporting] = createSignal(false);1107  const [error, setError] = createSignal<string | null>(null);1108  // CSV/JSON imports are plaintext and carry no password, so when there is no1109  // vault yet the user MUST choose a master password here — otherwise the1110  // imported credentials would be encrypted under an empty key and offer zero1111  // at-rest protection (audit HIGH). Mirrors the onboarding MIN_LENGTH policy.1112  const CSV_MIN_PASSWORD_LENGTH = 12;1113  const [newPass, setNewPass] = createSignal("");1114  const [confirmPass, setConfirmPass] = createSignal("");1115  const needsPassword = () => props.hasVault === false;1116  const passwordReady = () =>1117    !needsPassword() ||1118    (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());1119  // The action popup dies when the OS file picker steals focus — dropping the1120  // file is the import path that survives it (see RestoreSymbols).1121  const [dragDepth, setDragDepth] = createSignal(0);11221123  // Every route here (label click, drop, the disabled-attribute-guarded1124  // input change) passes gatePassword first, so the file is always accepted1125  // under a real master password — never an empty key.1126  async function processFile(file: File) {1127    setImporting(true);1128    setError(null);1129    try {1130      const text = await file.text();1131      const parsed = await parseImportSource(text);1132      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");11331134      if (props.hasVault === false) {1135        // Create the vault under the user-chosen master password — never an1136        // empty one (an empty key derives a publicly-known vault key).1137        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });1138        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1139      }11401141      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1142      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1143      await props.onSuccess();1144    } catch (err) {1145      const code = err instanceof Error ? err.message : String(err);1146      setError(1147        code === "NO_ENTRIES" ? "No entries found in this file." :1148        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1149        "Import failed. Check the file format and try again."1150      );1151    } finally {1152      setImporting(false);1153    }1154  }11551156  let csvFileRef: HTMLInputElement | undefined;11571158  onMount(() => {1159    // A fresh vault needs its master password before a file can be accepted —1160    // auto-picking would only drop the chosen file at the password gate, so1161    // the dialog waits for the explicit click in that case.1162    if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv) && !needsPassword()) {1163      setTimeout(() => csvFileRef?.click(), 50);1164    }1165  });11661167  function gatePassword(): boolean {1168    if (importing()) return false;1169    if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {1170      setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);1171      return false;1172    }1173    if (needsPassword() && newPass() !== confirmPass()) {1174      setError("Passwords do not match.");1175      return false;1176    }1177    return true;1178  }11791180  async function handleFile(e: Event) {1181    const input = e.currentTarget as HTMLInputElement;1182    const file = input.files?.[0];1183    input.value = "";1184    if (!file) return;1185    await processFile(file);1186  }11871188  async function handleDrop(e: DragEvent) {1189    e.preventDefault();1190    setDragDepth(0);1191    if (!gatePassword()) return;1192    const file = e.dataTransfer?.files?.[0];1193    if (!file) return;1194    await processFile(file);1195  }11961197  return (1198    <div1199      class="peach-restore-form"1200      classList={{ "is-dragover": dragDepth() > 0 }}1201      onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1202      onDragOver={(e) => e.preventDefault()}1203      onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1204      onDrop={(e) => void handleDrop(e)}1205    >1206      <Show when={needsPassword()}>1207        <div class="peach-newvault-field">1208          <label class="peach-label" for="restore-csv-pass">1209            new master password1210          </label>1211          <input1212            id="restore-csv-pass"1213            class="peach-underline-input peach-newvault-input"1214            type="password"1215            placeholder="••••••••••••"1216            autocomplete="new-password"1217            value={newPass()}1218            onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1219          />1220        </div>1221        <div class="peach-newvault-field">1222          <label class="peach-label" for="restore-csv-confirm">1223            confirm master password1224          </label>1225          <input1226            id="restore-csv-confirm"1227            class="peach-underline-input peach-newvault-input"1228            type="password"1229            placeholder="••••••••••••"1230            autocomplete="new-password"1231            value={confirmPass()}1232            onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1233          />1234        </div>1235      </Show>1236      <label1237        class="peach-btn peach-btn--accent peach-btn--md peach-restore-file-btn"1238        onClick={(e) => {1239          // In the detached window the dialog can open directly — let the1240          // label's real user gesture activate the input. In the action1241          // popup we must detach first (the dialog would dismiss the popup).1242          if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv)) return;1243          // The label would forward the click to the input even before the1244          // password gate passes; own the activation so the gate can speak.1245          e.preventDefault();1246          if (!gatePassword()) return;1247          void detachForFilePicker(RESTORE_DEEP_LINKS.csv).then((detached) => {1248            if (!detached) csvFileRef?.click();1249          });1250        }}1251      >1252        {importing() ? "importing…" : "open csv file"}1253        <input1254          ref={(el) => { csvFileRef = el; }}1255          type="file"1256          accept=".csv,.json,.peach,application/x-peach"1257          style="display:none"1258          onChange={handleFile}1259          disabled={importing() || !passwordReady()}1260        />1261      </label>1262      <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1263      <p class="peach-hint peach-restore-csv-sources">1264        Imports from Bitwarden, 1Password, Proton Pass,1265        <br />1266        Chrome, Safari, and Edge.1267      </p>1268      <p class="peach-newvault-warning">1269        <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>1270        <span>Plain-text file — delete it after the import.</span>1271      </p>1272      <Show when={error()}>1273        <p class="peach-hint peach-hint-error">{error()}</p>1274      </Show>1275    </div>1276  );1277}12781279// ── Helpers ────────────────────────────────────────────────────────────────12801281function getDisplayError(error: string | undefined, fallback: string): string {1282  if (!error) return fallback;1283  if (1284    error === "SERVICE_WORKER_UNAVAILABLE" ||1285    error.includes("Receiving end does not exist") ||1286    error.includes("message port closed")1287  ) {1288    return "background service unavailable";1289  }1290  // Never echo raw background errors — internal messages must not reach the UI.1291  return fallback;1292}