Lock.tsx

48.7 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. The221      // background owns this session-scoped exemption so content scripts222      // cannot forge it through storage.local.223      await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });224      setShowRecoveryWords(true);225      setLoading(false);226      return;227    }228    setError(getDisplayError(result.error, "failed to create vault"));229    setLoading(false);230  }231232  // ── All surfaces routed reactively via Switch/Match ───────────────────233  return (234    <Switch>235      {/* ── Restore surface ───────────────────────────────────────── */}236      <Match when={restoreSurface() === "restore"}>237        <RestoreView238          hasVault={props.hasVault}239          onOpenCodexRestore={props.onOpenCodexRestore}240          initialSource={props.initialRestoreSource}241          onBack={() => {242            // The restore tab is only reached from the onboarding carousel,243            // so back returns there — not to the create-vault form.244            if (props.onBackToOnboarding) props.onBackToOnboarding();245            else setRestoreSurface(null);246          }}247          onUnlock={async () => {248            setRestoreSurface(null);249            await props.onUnlock();250          }}251        />252      </Match>253254      {/* ── Recovery words display after fresh vault creation ────────── */}255      {/* Read props.forceRecoveryWords directly, not just the boot-time256          signal copy: when the vault is locked with a pending confirmation,257          App flips the flag after UNLOCK without remounting LockView. */}258      <Match259        when={260          (showRecoveryWords() || props.forceRecoveryWords) &&261          (!props.hasVault || props.forceRecoveryWords)262        }263      >264        <RecoveryWordDisplayView265          onDone={props.onUnlock}266        />267      </Match>268269      {/* ── Recovery word entry ─────────────────────────────────────── */}270      <Match when={recoverSurface() === "words"}>271        <RecoveryWordEntryView272          onCancel={() => setRecoverSurface(null)}273          onRecoveryComplete={props.onUnlock}274        />275      </Match>276277      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}278      <Match when={props.hasVault}>279        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">280          <RuneBackground opacity={0.07} />281282          <header class="peach-screen-header lock-screen-header">283            <div class="peach-wordmark-stack">284              <PeachWordmark class="peach-wordmark-wrap" />285              <Show when={props.isPro}>286                <span class="peach-pro-mini-badge">pro</span>287              </Show>288            </div>289          </header>290291          <div class="peach-lock-hero peach-lock-hero--unlock">292            <PeachMark class="peach-lock-mark" />293            <span class="peach-lock-eyebrow">vault locked</span>294            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">295              unlock296              <br />297              your vault.298            </h1>299          </div>300301          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">302            <Show when={usingPin()} fallback={303              <>304                <label class="peach-label" for="unlock-password">305                  master password306                </label>307                <input308                  id="unlock-password"309                  class="peach-underline-input peach-unlock-input lock-password-input"310                  type="password"311                  placeholder="••••••••••••"312                  autocomplete="current-password"313                  value={password()}314                  disabled={!props.ready}315                  ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}316                  onInput={(event) => setPassword((event.target as HTMLInputElement).value)}317                  onKeyDown={(event) => {318                    if (event.key !== "Enter" || !props.ready) return;319                    void handleUnlock();320                  }}321                />322              </>323            }>324              <label class="peach-label" for="unlock-pin">325                PIN326              </label>327              <input328                id="unlock-pin"329                class="peach-underline-input peach-unlock-input lock-pin-input"330                type="password"331                inputmode="numeric"332                maxlength="12"333                placeholder="••••••••••••"334                autocomplete="off"335                value={pin()}336                disabled={!props.ready}337                ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}338                onInput={(event) => setPin((event.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}339                onKeyDown={(event) => {340                  if (event.key !== "Enter" || !props.ready) return;341                  void handlePinUnlock();342                }}343              />344              <button345                class="peach-lock-recover-link"346                type="button"347                onClick={() => { setUsingPin(false); setError(""); }}348              >349                use master password350              </button>351            </Show>352353            <Show when={error()}>354              <p class="peach-hint peach-hint-error">{error()}</p>355            </Show>356357            <button358              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"359              onClick={() => void (usingPin() ? handlePinUnlock() : handleUnlock())}360              disabled={!props.ready || loading()}361            >362              {loading() ? "unlocking..." : usingPin() ? "unlock with PIN" : "unlock"}363            </button>364365            <Show when={!usingPin() && pinAvailable()}>366              <button367                class="peach-lock-recover-link"368                type="button"369                onClick={() => { setUsingPin(true); setError(""); }}370              >371                use PIN372              </button>373            </Show>374375            <button376              class="peach-lock-recover-link"377              type="button"378              onClick={() => setRecoverSurface("words")}379            >380              forgot your password?381            </button>382          </section>383        </section>384      </Match>385386      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}387      <Match when={true}>388        <section389          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"390          data-motion-phase="enter"391        >392          <header class="peach-screen-header peach-newvault-header">393            <div class="peach-wordmark-stack">394              <PeachWordmark class="peach-wordmark-wrap" />395              <Show when={props.isPro}>396                <span class="peach-pro-mini-badge">pro</span>397              </Show>398            </div>399            <Show when={props.onBackToOnboarding}>400              <button401                class="peach-newvault-back"402                type="button"403                onClick={() => props.onBackToOnboarding?.()}404                aria-label="Back to onboarding"405              >406                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">407                  <path d="M7 2L3 5.5L7 9" />408                </svg>409                back410              </button>411            </Show>412          </header>413414          <div class="peach-newvault-mark" aria-hidden="true">415            <PeachMark class="peach-newvault-mark-art" />416          </div>417418          <div class="peach-newvault-card">419            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>420            <hr class="peach-newvault-divider" />421422            <div class="peach-newvault-field">423              <div class="peach-newvault-label-row">424                <label class="peach-label" for="master-password">425                  master password426                </label>427                <button428                  class="peach-lock-reveal-btn"429                  type="button"430                  onClick={() => setShowPassword((value) => !value)}431                  aria-label={showPassword() ? "Hide password" : "Show password"}432                >433                  <Show434                    when={showPassword()}435                    fallback={436                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">437                        <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" />438                        <circle cx="7" cy="5" r="1.75" />439                      </svg>440                    }441                  >442                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">443                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />444                      <path d="M3.5 5.5L2.5 7.5" />445                      <path d="M7 6.5V8.5" />446                      <path d="M10.5 5.5L11.5 7.5" />447                    </svg>448                  </Show>449                  {showPassword() ? "hide" : "show"}450                </button>451              </div>452              <input453                id="master-password"454                class="peach-underline-input peach-newvault-input lock-password-input"455                type={showPassword() ? "text" : "password"}456                placeholder="••••••••••••"457                autocomplete="new-password"458                value={password()}459                disabled={!props.ready}460                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}461                onKeyDown={(event) => {462                  if (event.key !== "Enter" || !props.ready) return;463                  void handleCreate();464                }}465              />466              <Show467                when={password().length > 0 && passwordStrength() !== null}468                fallback={469                  <p class="peach-newvault-hint">470                    At least 12 characters.471                  </p>472                }473              >474                <div class="peach-password-strength">475                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>476                    <div class="peach-strength-fill"></div>477                  </div>478                  <span class="peach-strength-label">{passwordStrength()}</span>479                </div>480              </Show>481            </div>482483            <div class="peach-newvault-field">484              <label class="peach-label" for="confirm-password">485                confirm password486              </label>487              <input488                id="confirm-password"489                class="peach-underline-input peach-newvault-input"490                type={showPassword() ? "text" : "password"}491                placeholder="••••••••••••"492                autocomplete="new-password"493                value={confirmPassword()}494                disabled={!props.ready}495                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}496                onKeyDown={(event) => {497                  if (event.key === "Enter" && props.ready) void handleCreate();498                }}499              />500            </div>501502            <Show when={error()}>503              <p class="peach-hint peach-hint-error">{error()}</p>504            </Show>505506            <button507              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"508              onClick={() => void handleCreate()}509              disabled={!props.ready || loading() || !canCreate()}510              aria-live="polite"511            >512              <Show513                when={!canCreate() && createBlockerMessage()}514                fallback={loading() ? "creating..." : "create vault"}515              >516                {createBlockerMessage()}517              </Show>518            </button>519520            <p class="peach-newvault-warning">521              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>522              <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>523            </p>524          </div>525        </section>526      </Match>527    </Switch>528  );529}530531// ── Restore surface ────────────────────────────────────────────────────────532533export type RestoreSource = "symbols" | "codex" | "csv";534535const RESTORE_SOURCES: { id: RestoreSource; name: string; detail: string }[] = [536  { id: "symbols", name: "PeachScript", detail: "Paste the rune string, or open a .peach file." },537  { id: "codex", name: "Codex", detail: "Scan printed pages, or import the PDF." },538  { id: "csv", name: "CSV", detail: "A .csv or .json file from another password manager." },539];540541const RESTORE_DEEP_LINKS: Record<"symbols" | "csv", "peachscript" | "csv"> = {542  symbols: "peachscript",543  csv: "csv",544};545546// The Chrome action popup dismisses itself the moment the OS file picker547// steals focus, so a picker opened from it never delivers its file. From the548// action popup we reopen this surface in a standalone window and let that549// window own the dialog — same trick as CodexRestoreView.openPdfPicker().550// The window is created by the service worker: a window created by the popup551// itself is cascade-closed by Chrome when the popup dismisses. App.tsx routes552// the deep link back here (?restore=peachscript|csv) at boot, before first553// render, so the window mounts straight on the right source.554async function detachForFilePicker(route: "peachscript" | "csv"): Promise<boolean> {555  try {556    // Already inside a detached window — it can host the dialog directly.557    if (new URLSearchParams(window.location.search).get("restore")) return false;558  } catch { /* no location — fall through to the capability check */ }559  if (typeof chrome === "undefined" || typeof chrome.runtime?.sendMessage !== "function") {560    return false;561  }562  try {563      const res = (await sendMessage({564        type: "OPEN_DETACHED_POPUP",565        payload: { route },566      })) as { ok: boolean; data?: { detached?: boolean } };567      if (!res?.data?.detached) return false;568      // The action popup dismisses itself when the new window steals focus —569      // calling window.close() here cascade-closes the detached child too.570      return true;571  } catch {572    return false;573  }574}575576/** True when this window was opened via ?restore=<link> specifically to own577 *  the file dialog — mirrors CodexRestoreView's autoPickPdf detection. */578function restoreDeepLinkActive(link: "peachscript" | "csv"): boolean {579  try {580    return new URLSearchParams(window.location.search).get("restore") === link;581  } catch {582    return false;583  }584}585586function RestoreView(props: {587  hasVault: boolean;588  onOpenCodexRestore?: () => void;589  onBack: () => void;590  onUnlock: () => void | Promise<void>;591  initialSource?: RestoreSource;592}) {593  const [source, setSource] = createSignal<RestoreSource | null>(props.initialSource ?? null);594  const [showIntro, setShowIntro] = createSignal(false);595  const [pendingUnlock] = createSignal(false);596597  // The action popup dies the moment the OS file picker steals focus, so a598  // mid-restore close must resume the same source form — rewinding to the599  // picker reads as losing progress. The resume marker mirrors the picked600  // source; App clears it with the rest of the onboarding-exit state.601  function chooseSource(next: RestoreSource | null) {602    setSource(next);603    if (next) void storageSet(RESTORE_SOURCE_STORAGE_KEY, next).catch(() => {});604    else void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});605  }606607  async function handleRestoreSuccess() {608    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);609    if (!seen) {610      setShowIntro(true);611    } else {612      await props.onUnlock();613    }614  }615616  async function dismissIntro() {617    await storageSet("peach_shown_restore_intro", "true").catch(() => {});618    setShowIntro(false);619    await props.onUnlock();620  }621622  return (623    <section624      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault peach-restore"625      data-motion-phase="enter"626    >627      <RuneBackground opacity={0.05} />628629      <header class="peach-screen-header peach-newvault-header">630        <PeachWordmark class="peach-wordmark-wrap" />631        <button632          class="peach-newvault-back peach-restore-back"633          type="button"634          onClick={props.onBack}635          aria-label="Back to onboarding"636        >637          <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">638            <path d="M7 2L3 5.5L7 9" />639          </svg>640          back641        </button>642      </header>643644      <div class="peach-newvault-card peach-restore-card">645        <Show646          when={source()}647          keyed648          fallback={649            <>650              <h1 class="peach-newvault-headline">{"welcome\nback."}</h1>651              <hr class="peach-newvault-divider" />652              <div class="peach-restore-sources">653                <For each={RESTORE_SOURCES}>654                  {(entry) => (655                    <button656                      type="button"657                      class="peach-onboarding-choice peach-restore-source"658                      onClick={() => chooseSource(entry.id)}659                    >660                      <span class="peach-onboarding-choice-label">{entry.name}</span>661                      <span class="peach-onboarding-choice-detail">{entry.detail}</span>662                    </button>663                  )}664                </For>665              </div>666            </>667          }668        >669          {(active) => (670            <>671              <div class="peach-restore-source-head">672                <h2 class="peach-restore-source-title">673                  {RESTORE_SOURCES.find((entry) => entry.id === active)?.name}674                </h2>675                <button676                  type="button"677                  class="peach-restore-picker-back"678                  onClick={() => chooseSource(null)}679                >680                  ‹ sources681                </button>682              </div>683              <hr class="peach-newvault-divider" />684              <Show when={active === "symbols"}>685                <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />686              </Show>687              <Show when={active === "codex"}>688                <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />689              </Show>690              <Show when={active === "csv"}>691                <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />692              </Show>693            </>694          )}695        </Show>696      </div>697698      {/* One-time intro popup after first restore */}699      <Show when={showIntro()}>700        <div class="peach-restore-intro-overlay">701          <div class="peach-restore-intro-card">702            <p class="peach-restore-intro-headline">welcome to peach.</p>703            <div class="peach-restore-intro-body">704              <p>Your vault lives only on this device. No server has a copy.</p>705              <p>The inline bubble fills passwords automatically when you visit sites.</p>706              <p>Export your vault from Settings before switching devices.</p>707            </div>708            <button709              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"710              onClick={() => void dismissIntro()}711              disabled={pendingUnlock()}712            >713              got it714            </button>715          </div>716        </div>717      </Show>718    </section>719  );720}721722// ── Peach Codex source ─────────────────────────────────────────────────────723724function RestoreCodex(props: { onOpen: () => void }) {725  return (726    <div class="peach-restore-form">727      <p class="peach-restore-codex-copy">728        Scan printed Codex pages or import the Codex PDF. Codex restore is729        always available, even before you create a vault.730      </p>731      <button732        type="button"733        class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"734        onClick={props.onOpen}735      >736        open Codex restore737      </button>738    </div>739  );740}741742// ── PeachScript source ─────────────────────────────────────────────────────743744function RestoreSymbols(props: {745  hasVault: boolean;746  onSuccess: () => void | Promise<void>;747}) {748  const [scriptInput, setScriptInput] = createSignal("");749  const [backupPass, setBackupPass] = createSignal("");750  const [working, setWorking] = createSignal(false);751  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);752  const [error, setError] = createSignal<string | null>(null);753  let canvasRef: HTMLCanvasElement | undefined;754  let peachFileRef: HTMLInputElement | undefined;755  let animFrameId = 0;756  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];757758  const scriptValid = createMemo(() => isPeachScript(scriptInput()));759760  function stopAnim() {761    cancelAnimationFrame(animFrameId);762    animFrameId = 0;763  }764765  function startChaos(canvas: HTMLCanvasElement) {766    const ctx = canvas.getContext("2d")!;767    const W = canvas.width;768    const H = canvas.height;769    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>770      String.fromCodePoint(0xe000 + i)771    );772    if (runes.length === 0) {773      runes = Array.from({ length: 60 }, () => ({774        x: Math.random() * W,775        y: Math.random() * H,776        vx: (Math.random() - 0.5) * 3,777        vy: (Math.random() - 0.5) * 3,778        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,779        size: 14 + Math.random() * 20,780        angle: Math.random() * Math.PI * 2,781        va: (Math.random() - 0.5) * 0.1,782      }));783    }784785    function draw() {786      ctx.clearRect(0, 0, W, H);787      for (const r of runes) {788        r.x = (r.x + r.vx + W) % W;789        r.y = (r.y + r.vy + H) % H;790        r.angle += r.va;791        ctx.save();792        ctx.translate(r.x, r.y);793        ctx.rotate(r.angle);794        ctx.font = `${r.size}px PeachScript, monospace`;795        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);796        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;797        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";798        ctx.shadowBlur = 8;799        ctx.fillText(r.char, 0, 0);800        ctx.restore();801      }802      animFrameId = requestAnimationFrame(draw);803    }804    draw();805  }806807  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {808    stopAnim();809    const ctx = canvas.getContext("2d")!;810    const W = canvas.width;811    const H = canvas.height;812    const cols = 8;813    const rows = 6;814    const cellW = W / cols;815    const cellH = H / rows;816    const targets = runes.slice(0, cols * rows).map((_, i) => ({817      x: (i % cols) * cellW + cellW / 2,818      y: Math.floor(i / cols) * cellH + cellH / 2,819    }));820    const start = performance.now();821    const DURATION = 400;822823    function drawSnap(now: number) {824      const t = Math.min((now - start) / DURATION, 1);825      const ease = t < 1 ? 1 - (1 - t) ** 3 : 1;826      ctx.clearRect(0, 0, W, H);827      runes.slice(0, targets.length).forEach((r, i) => {828        const tx = targets[i]!.x;829        const ty = targets[i]!.y;830        const cx = r.x + (tx - r.x) * ease;831        const cy = r.y + (ty - r.y) * ease;832        ctx.font = `16px PeachScript, monospace`;833        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;834        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";835        ctx.shadowBlur = 6;836        ctx.fillText(r.char, cx - 8, cy + 8);837      });838      if (t < 1) {839        animFrameId = requestAnimationFrame(drawSnap);840      } else {841        onDone();842      }843    }844    animFrameId = requestAnimationFrame(drawSnap);845  }846847  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {848    stopAnim();849    const ctx = canvas.getContext("2d")!;850    const W = canvas.width;851    const H = canvas.height;852    const start = performance.now();853    const DURATION = 900;854855    function drawBloom(now: number) {856      const t = Math.min((now - start) / DURATION, 1);857      ctx.clearRect(0, 0, W, H);858      // Runes dissolve859      const runeAlpha = 1 - Math.min(t / 0.4, 1);860      if (runeAlpha > 0) {861        for (const r of runes.slice(0, 48)) {862          ctx.font = `16px PeachScript, monospace`;863          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;864          ctx.fillText(r.char, r.x - 8, r.y + 8);865        }866      }867      // Peach mark bloom868      const markT = Math.max(0, (t - 0.2) / 0.5);869      const markScale = Math.min(markT * 1.2, 1);870      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;871      if (markAlpha > 0) {872        ctx.save();873        ctx.globalAlpha = markAlpha;874        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;875        ctx.shadowBlur = 30;876        ctx.translate(W / 2, H / 2);877        ctx.scale(markScale, markScale);878        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);879        ctx.restore();880      }881      if (t < 1) {882        animFrameId = requestAnimationFrame(drawBloom);883      } else {884        onDone();885      }886    }887    animFrameId = requestAnimationFrame(drawBloom);888  }889890  onCleanup(() => stopAnim());891892  // This window was opened specifically to own the file dialog — open it as893  // soon as the input exists. Mirrors the ?codex=pdf auto-pick.894  onMount(() => {895    if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) {896      setTimeout(() => peachFileRef?.click(), 50);897    }898  });899900  // The action popup dies when the OS file picker steals focus; dropping the901  // file is the other import path that survives it.902  const [dragDepth, setDragDepth] = createSignal(0);903904  async function handleDrop(e: DragEvent) {905    e.preventDefault();906    setDragDepth(0);907    if (working() || animating() !== null) return;908    const file = e.dataTransfer?.files?.[0];909    if (!file) return;910    try {911      setScriptInput(await file.text());912      setError(null);913    } catch {914      setError("Couldn't read file.");915    }916  }917918  async function handleRestore() {919    if (!scriptValid() || !backupPass().trim() || working()) return;920    setError(null);921    setWorking(true);922    setAnimating("chaos");923924    const canvas = canvasRef;925    if (canvas) startChaos(canvas);926927    try {928      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));929      const payload = await parsePayload(bytes, backupPass());930931      if (!props.hasVault) {932        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });933        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");934      }935936      const rr = await sendMessage({937        type: "RESTORE_ENTRIES",938        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },939      });940      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");941942      if (payload.license?.key) {943        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});944      }945946      setAnimating("snap");947      stopAnim();948      if (canvas) {949        snapToGrid(canvas, () => {950          setAnimating("bloom");951          bloom(canvas, async () => {952            setAnimating(null);953            await props.onSuccess();954          });955        });956      } else {957        await props.onSuccess();958      }959    } catch (err) {960      stopAnim();961      setAnimating(null);962      const code = err instanceof Error ? err.message : String(err);963      setError(964        code === "INVALID_PASSPHRASE"965          ? "That passphrase didn't work."966          : code === "CREATE_VAULT_FAILED"967            ? "Couldn't create a new local vault."968            : "Could not read this backup.",969      );970    } finally {971      setWorking(false);972    }973  }974975  return (976    <div977      class="peach-restore-form"978      classList={{ "is-dragover": dragDepth() > 0 }}979      onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}980      onDragOver={(e) => e.preventDefault()}981      onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}982      onDrop={(e) => void handleDrop(e)}983    >984      <Show when={animating() !== null}>985        <canvas986          ref={(el) => { canvasRef = el; }}987          class="peach-restore-anim-canvas"988          width={280}989          height={200}990        />991      </Show>992      <Show when={animating() === null}>993        <Show994          when={scriptValid()}995          fallback={996            <>997              <textarea998                class="peach-restore-script"999                placeholder="paste peachscript…"1000                rows={3}1001                aria-label="Paste your PeachScript backup"1002                value={scriptInput()}1003                onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}1004              />1005              <div class="peach-restore-or" aria-hidden="true">or</div>1006              <label1007                class="peach-btn peach-btn--ghost peach-btn--sm peach-restore-file-btn"1008                onClick={(e) => {1009                  if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) return;1010                  e.preventDefault();1011                  void detachForFilePicker(RESTORE_DEEP_LINKS.symbols).then((detached) => {1012                    if (!detached) peachFileRef?.click();1013                  });1014                }}1015              >1016                open .peach file1017                <input1018                  ref={(el) => { peachFileRef = el; }}1019                  type="file"1020                  accept=".peach,text/plain"1021                  style="display:none"1022                  onChange={async (e) => {1023                    const file = (e.currentTarget as HTMLInputElement).files?.[0];1024                    if (!file) return;1025                    try {1026                      setScriptInput(await file.text());1027                    } catch {1028                      setError("Couldn't read file.");1029                    } finally {1030                      (e.currentTarget as HTMLInputElement).value = "";1031                    }1032                  }}1033                />1034              </label>1035              <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1036            </>1037          }1038        >1039          <div class="peach-restore-rune-preview">1040            <RuneCanvas runeString={scriptInput()} width={256} height={120} />1041          </div>1042          <button1043            type="button"1044            class="peach-restore-picker-back"1045            onClick={() => { setScriptInput(""); setError(null); }}1046          >1047            ‹ paste again1048          </button>1049        </Show>1050        <div class="peach-newvault-field">1051          <label class="peach-label" for="restore-backup-pass">1052            backup password1053          </label>1054          <input1055            id="restore-backup-pass"1056            class="peach-underline-input peach-newvault-input"1057            type="password"1058            placeholder="••••••••••••"1059            autocomplete="current-password"1060            value={backupPass()}1061            onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}1062            onKeyDown={(e) => {1063              if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {1064                e.preventDefault();1065                void handleRestore();1066              }1067            }}1068          />1069        </div>1070        <button1071          class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"1072          onClick={() => void handleRestore()}1073          disabled={!scriptValid() || !backupPass().trim() || working()}1074        >1075          restore vault1076        </button>1077        <Show when={error()}>1078          <p class="peach-hint peach-hint-error">{error()}</p>1079        </Show>1080      </Show>1081    </div>1082  );1083}10841085// ── CSV source ─────────────────────────────────────────────────────────────10861087function RestoreCSV(props: {1088  hasVault: boolean;1089  onSuccess: () => void | Promise<void>;1090}) {1091  const [importing, setImporting] = createSignal(false);1092  const [error, setError] = createSignal<string | null>(null);1093  // CSV/JSON imports are plaintext and carry no password, so when there is no1094  // vault yet the user MUST choose a master password here — otherwise the1095  // imported credentials would be encrypted under an empty key and offer zero1096  // at-rest protection (audit HIGH). Mirrors the onboarding MIN_LENGTH policy.1097  const CSV_MIN_PASSWORD_LENGTH = 12;1098  const [newPass, setNewPass] = createSignal("");1099  const [confirmPass, setConfirmPass] = createSignal("");1100  const needsPassword = () => props.hasVault === false;1101  const passwordReady = () =>1102    !needsPassword() ||1103    (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());1104  // The action popup dies when the OS file picker steals focus — dropping the1105  // file is the import path that survives it (see RestoreSymbols).1106  const [dragDepth, setDragDepth] = createSignal(0);11071108  // Every route here (label click, drop, the disabled-attribute-guarded1109  // input change) passes gatePassword first, so the file is always accepted1110  // under a real master password — never an empty key.1111  async function processFile(file: File) {1112    setImporting(true);1113    setError(null);1114    try {1115      const text = await file.text();1116      const parsed = await parseImportSource(text);1117      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");11181119      if (props.hasVault === false) {1120        // Create the vault under the user-chosen master password — never an1121        // empty one (an empty key derives a publicly-known vault key).1122        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });1123        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1124      }11251126      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1127      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1128      await props.onSuccess();1129    } catch (err) {1130      const code = err instanceof Error ? err.message : String(err);1131      setError(1132        code === "NO_ENTRIES" ? "No entries found in this file." :1133        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1134        "Import failed. Check the file format and try again."1135      );1136    } finally {1137      setImporting(false);1138    }1139  }11401141  let csvFileRef: HTMLInputElement | undefined;11421143  onMount(() => {1144    // A fresh vault needs its master password before a file can be accepted —1145    // auto-picking would only drop the chosen file at the password gate, so1146    // the dialog waits for the explicit click in that case.1147    if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv) && !needsPassword()) {1148      setTimeout(() => csvFileRef?.click(), 50);1149    }1150  });11511152  function gatePassword(): boolean {1153    if (importing()) return false;1154    if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {1155      setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);1156      return false;1157    }1158    if (needsPassword() && newPass() !== confirmPass()) {1159      setError("Passwords do not match.");1160      return false;1161    }1162    return true;1163  }11641165  async function handleFile(e: Event) {1166    const input = e.currentTarget as HTMLInputElement;1167    const file = input.files?.[0];1168    input.value = "";1169    if (!file) return;1170    await processFile(file);1171  }11721173  async function handleDrop(e: DragEvent) {1174    e.preventDefault();1175    setDragDepth(0);1176    if (!gatePassword()) return;1177    const file = e.dataTransfer?.files?.[0];1178    if (!file) return;1179    await processFile(file);1180  }11811182  return (1183    <div1184      class="peach-restore-form"1185      classList={{ "is-dragover": dragDepth() > 0 }}1186      onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1187      onDragOver={(e) => e.preventDefault()}1188      onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1189      onDrop={(e) => void handleDrop(e)}1190    >1191      <Show when={needsPassword()}>1192        <div class="peach-newvault-field">1193          <label class="peach-label" for="restore-csv-pass">1194            new master password1195          </label>1196          <input1197            id="restore-csv-pass"1198            class="peach-underline-input peach-newvault-input"1199            type="password"1200            placeholder="••••••••••••"1201            autocomplete="new-password"1202            value={newPass()}1203            onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1204          />1205        </div>1206        <div class="peach-newvault-field">1207          <label class="peach-label" for="restore-csv-confirm">1208            confirm master password1209          </label>1210          <input1211            id="restore-csv-confirm"1212            class="peach-underline-input peach-newvault-input"1213            type="password"1214            placeholder="••••••••••••"1215            autocomplete="new-password"1216            value={confirmPass()}1217            onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1218          />1219        </div>1220      </Show>1221      <label1222        class="peach-btn peach-btn--accent peach-btn--md peach-restore-file-btn"1223        onClick={(e) => {1224          // In the detached window the dialog can open directly — let the1225          // label's real user gesture activate the input. In the action1226          // popup we must detach first (the dialog would dismiss the popup).1227          if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv)) return;1228          // The label would forward the click to the input even before the1229          // password gate passes; own the activation so the gate can speak.1230          e.preventDefault();1231          if (!gatePassword()) return;1232          void detachForFilePicker(RESTORE_DEEP_LINKS.csv).then((detached) => {1233            if (!detached) csvFileRef?.click();1234          });1235        }}1236      >1237        {importing() ? "importing…" : "open csv file"}1238        <input1239          ref={(el) => { csvFileRef = el; }}1240          type="file"1241          accept=".csv,.json,.peach,application/x-peach"1242          style="display:none"1243          onChange={handleFile}1244          disabled={importing() || !passwordReady()}1245        />1246      </label>1247      <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1248      <p class="peach-hint peach-restore-csv-sources">1249        Imports from Bitwarden, 1Password, Proton Pass,1250        <br />1251        Chrome, Safari, and Edge.1252      </p>1253      <p class="peach-newvault-warning">1254        <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>1255        <span>Plain-text file — delete it after the import.</span>1256      </p>1257      <Show when={error()}>1258        <p class="peach-hint peach-hint-error">{error()}</p>1259      </Show>1260    </div>1261  );1262}12631264// ── Helpers ────────────────────────────────────────────────────────────────12651266function getDisplayError(error: string | undefined, fallback: string): string {1267  if (!error) return fallback;1268  if (1269    error === "SERVICE_WORKER_UNAVAILABLE" ||1270    error.includes("Receiving end does not exist") ||1271    error.includes("message port closed")1272  ) {1273    return "background service unavailable";1274  }1275  // Never echo raw background errors — internal messages must not reach the UI.1276  return fallback;1277}