Lock.tsx

50.1 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 } from "../../crypto/peachScript";10import { parsePayload } from "../../crypto/peachPayload";11import { parseImportSource } from "../../shared/import";12import { IMPORT_LIMITS } from "../../shared/import-limits";1314const peachMarkImg = new Image();15peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(16  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" fill="none">' +17  '<path d="M 124 64 C 134 32 168 16 200 22 C 198 56 168 72 130 66 Z" fill="#92AC65"/>' +18  '<path d="M 130 64 C 152 46 178 30 196 24" stroke="#5E7A42" stroke-width="2.6" fill="none" stroke-linecap="round"/>' +19  '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 185 222 222 185 220 130 C 218 78 170 45 120 60 Z" fill="#FFB082"/>' +20  '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 105 195 75 150 100 90 C 105 75 112 65 120 60 Z" fill="#E5805C"/>' +21  '<ellipse cx="178" cy="108" rx="20" ry="11" fill="#FFD0A8" opacity="0.7" transform="rotate(-26 178 108)"/>' +22  '</svg>'23)}`;2425// Lazy-load @zxcvbn-ts — heavy (dictionaries ~700 kB) and only needed during26// vault creation. Replaces the unmaintained [email protected] (LV-8); result.score27// keeps the same 0-4 scale so downstream UI is unchanged.28let _zxcvbn: ((password: string) => { score: number }) | null = null;29async function getZxcvbn() {30  if (_zxcvbn) return _zxcvbn;31  const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([32    import("@zxcvbn-ts/core"),33    import("@zxcvbn-ts/language-common"),34    import("@zxcvbn-ts/language-en"),35  ]);36  zxcvbnOptions.setOptions({37    translations: en.translations,38    graphs: common.adjacencyGraphs,39    dictionary: { ...common.dictionary, ...en.dictionary },40  });41  _zxcvbn = (password: string) => zxcvbn(password);42  return _zxcvbn;43}4445interface LockProps {46  hasVault: boolean;47  ready: boolean;48  isPro?: boolean;49  /** When true, render the recovery-words display directly instead of the50   *  unlock UI. Used after a popup close+reopen during the create-vault51   *  recovery confirmation flow (peach_recovery_pending flag). */52  forceRecoveryWords?: boolean;53  onUnlock: () => void | Promise<void>;54  /** Optional escape hatch: rewind from the create-vault screen back to55   *  Onboarding. Only meaningful when the user reached create-vault from56   *  Onboarding in the first place. */57  onBackToOnboarding?: () => void;58  /** Optional: open the Peach Codex restore flow (camera scan of printed59    *  Codex pages). Surfaced as a small footer link on the new-vault screen. */60  onOpenCodexRestore?: () => void;61  /** Open directly on backup restore when onboarding routed the user there. */62  initialRestore?: boolean;63  /** Resume the restore surface on the source the user had picked. Set from64    *  RESTORE_SOURCE_STORAGE_KEY at boot — a popup close (the OS file picker65    *  kills it) must land back on the same source form, not the picker. */66  initialRestoreSource?: RestoreSource;67}6869/** The restore source picked before a popup close, so boot can resume the70 *  same source form instead of rewinding to the source picker. Written by71 *  RestoreView, read/cleared by App. */72export const RESTORE_SOURCE_STORAGE_KEY = "peach_onboarding_restore_source";7374type RestoreSurface = "restore" | null;75type RecoverSurface = "words" | "new-password" | null;7677export function LockView(props: LockProps) {78  const [password, setPassword] = createSignal("");79  const [confirmPassword, setConfirmPassword] = createSignal("");80  const [error, setError] = createSignal("");81  const [loading, setLoading] = createSignal(false);82  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(83    props.initialRestore ? "restore" : null,84  );85  const [showPassword, setShowPassword] = createSignal(false);86  const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);87  // After vault creation succeeds, set this to show the recovery words screen.88  // Only meaningful when hasVault === false.89  const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);9091  const MIN_LENGTH = 12;92  const MIN_ZXCVBN_SCORE = 2;9394  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);95  const [pinAvailable, setPinAvailable] = createSignal(false);96  const [usingPin, setUsingPin] = createSignal(false);97  const [pin, setPin] = createSignal("");9899  const canCreate = createMemo(100    () =>101      password().length >= MIN_LENGTH &&102      confirmPassword().length >= MIN_LENGTH &&103      password() === confirmPassword() &&104      strengthScore() !== null &&105      strengthScore()! >= MIN_ZXCVBN_SCORE106  );107108  const passwordStrength = createMemo(() => {109    const score = strengthScore();110    if (score === null) return null;111    if (score <= 1) return "weak";112    if (score === 2) return "fair";113    if (score === 3) return "good";114    return "strong";115  });116117  const createBlockerMessage = createMemo(() => {118    if (!password()) return null;119    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;120    if (!confirmPassword()) return "confirm your password";121    if (password() !== confirmPassword()) return "passwords don't match";122    if (strengthScore() === null) return "checking strength…";123    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — use a longer passphrase";124    return null;125  });126127  // Update strength asynchronously when password changes128  createMemo(() => {129    const value = password();130    if (!value) { setStrengthScore(null); return; }131    getZxcvbn().then((zxcvbn) => {132      if (password() === value) setStrengthScore(zxcvbn(value).score);133    }).catch(() => {134      // zxcvbn unavailable — fall back to length-only heuristic135      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);136    });137  });138139  async function handleUnlock() {140    if (!password().trim()) return;141    setLoading(true);142    setError("");143    const response = await sendMessage({144      type: "UNLOCK",145      payload: { password: password() },146    });147    const result = response as { ok: boolean; error?: string };148    if (result.ok) {149      await props.onUnlock();150      setLoading(false);151      return;152    }153    setError(getDisplayError(result.error, "incorrect password"));154    setPassword("");155    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;156    passwordField?.classList.add("shake");157    setTimeout(() => passwordField?.classList.remove("shake"), 300);158    setLoading(false);159  }160161  async function handlePinUnlock() {162    if (!/^\d{4,12}$/.test(pin())) return;163    setLoading(true);164    setError("");165    const response = await sendMessage({166      type: "UNLOCK_WITH_PIN",167      payload: { pin: pin() },168    });169    const result = response as { ok: boolean; error?: string };170    if (result.ok) {171      await props.onUnlock();172      setLoading(false);173      return;174    }175    if (result.error === "pin_attempts_exceeded") {176      setUsingPin(false);177      setError("too many incorrect PIN attempts — use your master password");178    } else if (result.error === "pin_session_expired") {179      setUsingPin(false);180      setError("PIN is unavailable until you unlock with your master password");181    } else {182      setError(getDisplayError(result.error, "incorrect PIN"));183    }184    setPin("");185    const pinField = document.querySelector(".lock-pin-input") as HTMLInputElement | undefined;186    pinField?.classList.add("shake");187    setTimeout(() => pinField?.classList.remove("shake"), 300);188    setLoading(false);189  }190191  async function checkPinAvailable(): Promise<void> {192    if (!props.hasVault) return;193    try {194      const response = await sendMessage({ type: "GET_PIN_STATUS" });195      const status = (response as {196        ok: boolean;197        data?: { configured?: boolean; available?: boolean; attemptsRemaining?: number; pinPreferred?: boolean };198      }).data;199      const enabled = status?.configured === true && status.available === true;200      setPinAvailable(enabled);201      if (enabled) {202        const settingsResponse = await sendMessage({ type: "GET_SETTINGS" });203        const settings = (settingsResponse as { ok: boolean; data?: { pinPreferred?: boolean } }).data;204        if (settings?.pinPreferred === true) setUsingPin(true);205      }206    } catch { /* settings are optional at the lock screen */ }207  }208209  onMount(() => {210    void checkPinAvailable();211  });212213  async function handleCreate() {214    if (!password().trim()) return;215    if (password().length < MIN_LENGTH) {216      setError(`password must be at least ${MIN_LENGTH} characters`);217      return;218    }219    if (password() !== confirmPassword()) {220      setError("passwords do not match");221      return;222    }223    const score = strengthScore();224    if (score === null || score < MIN_ZXCVBN_SCORE) {225      setError("password is too weak — use a longer passphrase");226      return;227    }228    setLoading(true);229    setError("");230    const response = await sendMessage({231      type: "CREATE_VAULT",232      payload: { password: password() },233    });234    const result = response as { ok: boolean; error?: string };235    if (result.ok) {236      // Don't unlock yet — show the recovery words screen first. The237      // background owns this session-scoped exemption so content scripts238      // cannot forge it through storage.local.239      await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });240      setShowRecoveryWords(true);241      setLoading(false);242      return;243    }244    setError(getDisplayError(result.error, "failed to create vault"));245    setLoading(false);246  }247248  // ── All surfaces routed reactively via Switch/Match ───────────────────249  return (250    <Switch>251      {/* ── Restore surface ───────────────────────────────────────── */}252      <Match when={restoreSurface() === "restore"}>253        <RestoreView254          hasVault={props.hasVault}255          onOpenCodexRestore={props.onOpenCodexRestore}256          initialSource={props.initialRestoreSource}257          onBack={() => {258            // The restore tab is only reached from the onboarding carousel,259            // so back returns there — not to the create-vault form.260            if (props.onBackToOnboarding) props.onBackToOnboarding();261            else setRestoreSurface(null);262          }}263          onUnlock={async () => {264            setRestoreSurface(null);265            await props.onUnlock();266          }}267        />268      </Match>269270      {/* ── Recovery words display after fresh vault creation ────────── */}271      {/* Read props.forceRecoveryWords directly, not just the boot-time272          signal copy: when the vault is locked with a pending confirmation,273          App flips the flag after UNLOCK without remounting LockView. */}274      <Match275        when={276          (showRecoveryWords() || props.forceRecoveryWords) &&277          (!props.hasVault || props.forceRecoveryWords)278        }279      >280        <RecoveryWordDisplayView281          onDone={props.onUnlock}282        />283      </Match>284285      {/* ── Recovery word entry ─────────────────────────────────────── */}286      <Match when={recoverSurface() === "words"}>287        <RecoveryWordEntryView288          onCancel={() => setRecoverSurface(null)}289          onRecoveryComplete={props.onUnlock}290        />291      </Match>292293      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}294      <Match when={props.hasVault}>295        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">296          <RuneBackground opacity={0.07} />297298          <header class="peach-screen-header lock-screen-header">299            <div class="peach-wordmark-stack">300              <PeachWordmark class="peach-wordmark-wrap" />301              <Show when={props.isPro}>302                <span class="peach-pro-mini-badge">pro</span>303              </Show>304            </div>305          </header>306307          <div class="peach-lock-hero peach-lock-hero--unlock">308            <PeachMark class="peach-lock-mark" />309            <span class="peach-lock-eyebrow">vault locked</span>310            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">311              unlock312              <br />313              your vault.314            </h1>315          </div>316317          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">318            <Show when={usingPin()} fallback={319              <>320                <label class="peach-label" for="unlock-password">321                  master password322                </label>323                <input324                  id="unlock-password"325                  class="peach-underline-input peach-unlock-input lock-password-input"326                  type="password"327                  placeholder="••••••••••••"328                  autocomplete="current-password"329                  value={password()}330                  disabled={!props.ready}331                  ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}332                  onInput={(event) => setPassword((event.target as HTMLInputElement).value)}333                  onKeyDown={(event) => {334                    if (event.key !== "Enter" || !props.ready) return;335                    void handleUnlock();336                  }}337                />338              </>339            }>340              <label class="peach-label" for="unlock-pin">341                PIN342              </label>343              <input344                id="unlock-pin"345                class="peach-underline-input peach-unlock-input lock-pin-input"346                type="password"347                inputmode="numeric"348                maxlength="12"349                placeholder="••••••••••••"350                autocomplete="off"351                value={pin()}352                disabled={!props.ready}353                ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}354                onInput={(event) => setPin((event.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}355                onKeyDown={(event) => {356                  if (event.key !== "Enter" || !props.ready) return;357                  void handlePinUnlock();358                }}359              />360              <button361                class="peach-lock-recover-link"362                type="button"363                onClick={() => { setUsingPin(false); setError(""); }}364              >365                use master password366              </button>367            </Show>368369            <Show when={error()}>370              <p class="peach-hint peach-hint-error">{error()}</p>371            </Show>372373            <button374              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"375              onClick={() => void (usingPin() ? handlePinUnlock() : handleUnlock())}376              disabled={!props.ready || loading()}377            >378              {loading() ? "unlocking..." : usingPin() ? "unlock with PIN" : "unlock"}379            </button>380381            <Show when={!usingPin() && pinAvailable()}>382              <button383                class="peach-lock-recover-link"384                type="button"385                onClick={() => { setUsingPin(true); setError(""); }}386              >387                use PIN388              </button>389            </Show>390391            <button392              class="peach-lock-recover-link"393              type="button"394              onClick={() => setRecoverSurface("words")}395            >396              forgot your password?397            </button>398          </section>399        </section>400      </Match>401402      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}403      <Match when={true}>404        <section405          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"406          data-motion-phase="enter"407        >408          <header class="peach-screen-header peach-newvault-header">409            <div class="peach-wordmark-stack">410              <PeachWordmark class="peach-wordmark-wrap" />411              <Show when={props.isPro}>412                <span class="peach-pro-mini-badge">pro</span>413              </Show>414            </div>415            <Show when={props.onBackToOnboarding}>416              <button417                class="peach-newvault-back"418                type="button"419                onClick={() => props.onBackToOnboarding?.()}420                aria-label="Back to onboarding"421              >422                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">423                  <path d="M7 2L3 5.5L7 9" />424                </svg>425                back426              </button>427            </Show>428          </header>429430          <div class="peach-newvault-mark" aria-hidden="true">431            <PeachMark class="peach-newvault-mark-art" />432          </div>433434          <div class="peach-newvault-card">435            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>436            <hr class="peach-newvault-divider" />437438            <div class="peach-newvault-field">439              <div class="peach-newvault-label-row">440                <label class="peach-label" for="master-password">441                  master password442                </label>443                <button444                  class="peach-lock-reveal-btn"445                  type="button"446                  onClick={() => setShowPassword((value) => !value)}447                  aria-label={showPassword() ? "Hide password" : "Show password"}448                >449                  <Show450                    when={showPassword()}451                    fallback={452                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">453                        <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" />454                        <circle cx="7" cy="5" r="1.75" />455                      </svg>456                    }457                  >458                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">459                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />460                      <path d="M3.5 5.5L2.5 7.5" />461                      <path d="M7 6.5V8.5" />462                      <path d="M10.5 5.5L11.5 7.5" />463                    </svg>464                  </Show>465                  {showPassword() ? "hide" : "show"}466                </button>467              </div>468              <input469                id="master-password"470                class="peach-underline-input peach-newvault-input lock-password-input"471                type={showPassword() ? "text" : "password"}472                placeholder="••••••••••••"473                autocomplete="new-password"474                value={password()}475                disabled={!props.ready}476                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}477                onKeyDown={(event) => {478                  if (event.key !== "Enter" || !props.ready) return;479                  void handleCreate();480                }}481              />482              <Show483                when={password().length > 0 && passwordStrength() !== null}484                fallback={485                  <p class="peach-newvault-hint">486                    At least 12 characters.487                  </p>488                }489              >490                <div class="peach-password-strength">491                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>492                    <div class="peach-strength-fill"></div>493                  </div>494                  <span class="peach-strength-label">{passwordStrength()}</span>495                </div>496              </Show>497            </div>498499            <div class="peach-newvault-field">500              <label class="peach-label" for="confirm-password">501                confirm password502              </label>503              <input504                id="confirm-password"505                class="peach-underline-input peach-newvault-input"506                type={showPassword() ? "text" : "password"}507                placeholder="••••••••••••"508                autocomplete="new-password"509                value={confirmPassword()}510                disabled={!props.ready}511                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}512                onKeyDown={(event) => {513                  if (event.key === "Enter" && props.ready) void handleCreate();514                }}515              />516            </div>517518            <Show when={error()}>519              <p class="peach-hint peach-hint-error">{error()}</p>520            </Show>521522            <button523              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"524              onClick={() => void handleCreate()}525              disabled={!props.ready || loading() || !canCreate()}526              aria-live="polite"527            >528              <Show529                when={!canCreate() && createBlockerMessage()}530                fallback={loading() ? "creating..." : "create vault"}531              >532                {createBlockerMessage()}533              </Show>534            </button>535536            <p class="peach-newvault-warning">537              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>538              <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>539            </p>540          </div>541        </section>542      </Match>543    </Switch>544  );545}546547// ── Restore surface ────────────────────────────────────────────────────────548549export type RestoreSource = "symbols" | "codex" | "csv";550551const RESTORE_SOURCES: { id: RestoreSource; name: string; detail: string }[] = [552  { id: "symbols", name: "PeachScript", detail: "Paste the rune string, or open a .peach file." },553  { id: "codex", name: "Codex", detail: "Scan printed pages, or import the PDF." },554  { id: "csv", name: "CSV", detail: "A .csv or .json file from another password manager." },555];556557const RESTORE_DEEP_LINKS: Record<"symbols" | "csv", "peachscript" | "csv"> = {558  symbols: "peachscript",559  csv: "csv",560};561562// The Chrome action popup dismisses itself the moment the OS file picker563// steals focus, so a picker opened from it never delivers its file. From the564// action popup we reopen this surface in a standalone window and let that565// window own the dialog — same trick as CodexRestoreView.openPdfPicker().566// The window is created by the service worker: a window created by the popup567// itself is cascade-closed by Chrome when the popup dismisses. App.tsx routes568// the deep link back here (?restore=peachscript|csv) at boot, before first569// render, so the window mounts straight on the right source.570async function detachForFilePicker(route: "peachscript" | "csv"): Promise<boolean> {571  try {572    // Already inside a detached window — it can host the dialog directly.573    if (new URLSearchParams(window.location.search).get("restore")) return false;574  } catch { /* no location — fall through to the capability check */ }575  if (typeof chrome === "undefined" || typeof chrome.runtime?.sendMessage !== "function") {576    return false;577  }578  try {579      const res = (await sendMessage({580        type: "OPEN_DETACHED_POPUP",581        payload: { route },582      })) as { ok: boolean; data?: { detached?: boolean } };583      if (!res?.data?.detached) return false;584      // The action popup dismisses itself when the new window steals focus —585      // calling window.close() here cascade-closes the detached child too.586      return true;587  } catch {588    return false;589  }590}591592/** True when this window was opened via ?restore=<link> specifically to own593 *  the file dialog — mirrors CodexRestoreView's autoPickPdf detection. */594function restoreDeepLinkActive(link: "peachscript" | "csv"): boolean {595  try {596    return new URLSearchParams(window.location.search).get("restore") === link;597  } catch {598    return false;599  }600}601602function RestoreView(props: {603  hasVault: boolean;604  onOpenCodexRestore?: () => void;605  onBack: () => void;606  onUnlock: () => void | Promise<void>;607  initialSource?: RestoreSource;608}) {609  const [source, setSource] = createSignal<RestoreSource | null>(props.initialSource ?? null);610  const [showIntro, setShowIntro] = createSignal(false);611  const [pendingUnlock] = createSignal(false);612613  // The action popup dies the moment the OS file picker steals focus, so a614  // mid-restore close must resume the same source form — rewinding to the615  // picker reads as losing progress. The resume marker mirrors the picked616  // source; App clears it with the rest of the onboarding-exit state.617  function chooseSource(next: RestoreSource | null) {618    setSource(next);619    if (next) void storageSet(RESTORE_SOURCE_STORAGE_KEY, next).catch(() => {});620    else void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});621  }622623  async function handleRestoreSuccess() {624    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);625    if (!seen) {626      setShowIntro(true);627    } else {628      await props.onUnlock();629    }630  }631632  async function dismissIntro() {633    await storageSet("peach_shown_restore_intro", "true").catch(() => {});634    setShowIntro(false);635    await props.onUnlock();636  }637638  return (639    <section640      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault peach-restore"641      data-motion-phase="enter"642    >643      <RuneBackground opacity={0.05} />644645      <header class="peach-screen-header peach-newvault-header">646        <PeachWordmark class="peach-wordmark-wrap" />647        <button648          class="peach-newvault-back peach-restore-back"649          type="button"650          onClick={props.onBack}651          aria-label="Back to onboarding"652        >653          <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">654            <path d="M7 2L3 5.5L7 9" />655          </svg>656          back657        </button>658      </header>659660      <div class="peach-newvault-card peach-restore-card">661        <Show662          when={source()}663          keyed664          fallback={665            <>666              <h1 class="peach-newvault-headline">{"welcome\nback."}</h1>667              <hr class="peach-newvault-divider" />668              <div class="peach-restore-sources">669                <For each={RESTORE_SOURCES}>670                  {(entry) => (671                    <button672                      type="button"673                      class="peach-onboarding-choice peach-restore-source"674                      onClick={() => chooseSource(entry.id)}675                    >676                      <span class="peach-onboarding-choice-label">{entry.name}</span>677                      <span class="peach-onboarding-choice-detail">{entry.detail}</span>678                    </button>679                  )}680                </For>681              </div>682            </>683          }684        >685          {(active) => (686            <>687              <div class="peach-restore-source-head">688                <h2 class="peach-restore-source-title">689                  {RESTORE_SOURCES.find((entry) => entry.id === active)?.name}690                </h2>691                <button692                  type="button"693                  class="peach-restore-picker-back"694                  onClick={() => chooseSource(null)}695                >696                  ‹ sources697                </button>698              </div>699              <hr class="peach-newvault-divider" />700              <Show when={active === "symbols"}>701                <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />702              </Show>703              <Show when={active === "codex"}>704                <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />705              </Show>706              <Show when={active === "csv"}>707                <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />708              </Show>709            </>710          )}711        </Show>712      </div>713714      {/* One-time intro popup after first restore */}715      <Show when={showIntro()}>716        <div class="peach-restore-intro-overlay">717          <div class="peach-restore-intro-card">718            <p class="peach-restore-intro-headline">welcome to peach.</p>719            <div class="peach-restore-intro-body">720              <p>Your vault lives only on this device. No server has a copy.</p>721              <p>The inline bubble fills passwords automatically when you visit sites.</p>722              <p>Export your vault from Settings before switching devices.</p>723            </div>724            <button725              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"726              onClick={() => void dismissIntro()}727              disabled={pendingUnlock()}728            >729              got it730            </button>731          </div>732        </div>733      </Show>734    </section>735  );736}737738// ── Peach Codex source ─────────────────────────────────────────────────────739740function RestoreCodex(props: { onOpen: () => void }) {741  return (742    <div class="peach-restore-form">743      <p class="peach-restore-codex-copy">744        Scan printed Codex pages or import the Codex PDF. Codex restore is745        always available, even before you create a vault.746      </p>747      <button748        type="button"749        class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"750        onClick={props.onOpen}751      >752        open Codex restore753      </button>754    </div>755  );756}757758// ── PeachScript source ─────────────────────────────────────────────────────759760function RestoreSymbols(props: {761  hasVault: boolean;762  onSuccess: () => void | Promise<void>;763}) {764  const [scriptInput, setScriptInput] = createSignal("");765  const [backupPass, setBackupPass] = createSignal("");766  const [working, setWorking] = createSignal(false);767  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);768  const [error, setError] = createSignal<string | null>(null);769  let canvasRef: HTMLCanvasElement | undefined;770  let peachFileRef: HTMLInputElement | undefined;771  let animFrameId = 0;772  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];773774  const scriptValid = createMemo(() => isPeachScript(scriptInput()));775776  function stopAnim() {777    cancelAnimationFrame(animFrameId);778    animFrameId = 0;779  }780781  function startChaos(canvas: HTMLCanvasElement) {782    const ctx = canvas.getContext("2d")!;783    const W = canvas.width;784    const H = canvas.height;785    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>786      String.fromCodePoint(0xe000 + i)787    );788    if (runes.length === 0) {789      runes = Array.from({ length: 60 }, () => ({790        x: Math.random() * W,791        y: Math.random() * H,792        vx: (Math.random() - 0.5) * 3,793        vy: (Math.random() - 0.5) * 3,794        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,795        size: 14 + Math.random() * 20,796        angle: Math.random() * Math.PI * 2,797        va: (Math.random() - 0.5) * 0.1,798      }));799    }800801    function draw() {802      ctx.clearRect(0, 0, W, H);803      for (const r of runes) {804        r.x = (r.x + r.vx + W) % W;805        r.y = (r.y + r.vy + H) % H;806        r.angle += r.va;807        ctx.save();808        ctx.translate(r.x, r.y);809        ctx.rotate(r.angle);810        ctx.font = `${r.size}px PeachScript, monospace`;811        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);812        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;813        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";814        ctx.shadowBlur = 8;815        ctx.fillText(r.char, 0, 0);816        ctx.restore();817      }818      animFrameId = requestAnimationFrame(draw);819    }820    draw();821  }822823  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {824    stopAnim();825    const ctx = canvas.getContext("2d")!;826    const W = canvas.width;827    const H = canvas.height;828    const cols = 8;829    const rows = 6;830    const cellW = W / cols;831    const cellH = H / rows;832    const targets = runes.slice(0, cols * rows).map((_, i) => ({833      x: (i % cols) * cellW + cellW / 2,834      y: Math.floor(i / cols) * cellH + cellH / 2,835    }));836    const start = performance.now();837    const DURATION = 400;838839    function drawSnap(now: number) {840      const t = Math.min((now - start) / DURATION, 1);841      const ease = t < 1 ? 1 - (1 - t) ** 3 : 1;842      ctx.clearRect(0, 0, W, H);843      runes.slice(0, targets.length).forEach((r, i) => {844        const tx = targets[i]!.x;845        const ty = targets[i]!.y;846        const cx = r.x + (tx - r.x) * ease;847        const cy = r.y + (ty - r.y) * ease;848        ctx.font = `16px PeachScript, monospace`;849        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;850        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";851        ctx.shadowBlur = 6;852        ctx.fillText(r.char, cx - 8, cy + 8);853      });854      if (t < 1) {855        animFrameId = requestAnimationFrame(drawSnap);856      } else {857        onDone();858      }859    }860    animFrameId = requestAnimationFrame(drawSnap);861  }862863  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {864    stopAnim();865    const ctx = canvas.getContext("2d")!;866    const W = canvas.width;867    const H = canvas.height;868    const start = performance.now();869    const DURATION = 900;870871    function drawBloom(now: number) {872      const t = Math.min((now - start) / DURATION, 1);873      ctx.clearRect(0, 0, W, H);874      // Runes dissolve875      const runeAlpha = 1 - Math.min(t / 0.4, 1);876      if (runeAlpha > 0) {877        for (const r of runes.slice(0, 48)) {878          ctx.font = `16px PeachScript, monospace`;879          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;880          ctx.fillText(r.char, r.x - 8, r.y + 8);881        }882      }883      // Peach mark bloom884      const markT = Math.max(0, (t - 0.2) / 0.5);885      const markScale = Math.min(markT * 1.2, 1);886      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;887      if (markAlpha > 0) {888        ctx.save();889        ctx.globalAlpha = markAlpha;890        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;891        ctx.shadowBlur = 30;892        ctx.translate(W / 2, H / 2);893        ctx.scale(markScale, markScale);894        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);895        ctx.restore();896      }897      if (t < 1) {898        animFrameId = requestAnimationFrame(drawBloom);899      } else {900        onDone();901      }902    }903    animFrameId = requestAnimationFrame(drawBloom);904  }905906  onCleanup(() => stopAnim());907908  // This window was opened specifically to own the file dialog — open it as909  // soon as the input exists. Mirrors the ?codex=pdf auto-pick.910  onMount(() => {911    if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) {912      setTimeout(() => peachFileRef?.click(), 50);913    }914  });915916  // The action popup dies when the OS file picker steals focus; dropping the917  // file is the other import path that survives it.918  const [dragDepth, setDragDepth] = createSignal(0);919920  async function handleDrop(e: DragEvent) {921    e.preventDefault();922    setDragDepth(0);923    if (working() || animating() !== null) return;924    const file = e.dataTransfer?.files?.[0];925    if (!file) return;926    // PB-26-208: bound the file before file.text() — a .peach blob over the927    // import ceiling is rejected without reading a byte.928    if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {929      setError("That file is too large.");930      return;931    }932    try {933      setScriptInput(await file.text());934      setError(null);935    } catch {936      setError("Couldn't read file.");937    }938  }939940  async function handleRestore() {941    if (!scriptValid() || !backupPass().trim() || working()) return;942    setError(null);943    setWorking(true);944    setAnimating("chaos");945946    const canvas = canvasRef;947    if (canvas) startChaos(canvas);948949    try {950      // PB-26-208: decodePeachScript skips whitespace internally — no951      // separate normalise pass.952      const bytes = decodePeachScript(scriptInput());953      const payload = await parsePayload(bytes, backupPass());954955      if (!props.hasVault) {956        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });957        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");958      }959960      const rr = await sendMessage({961        type: "RESTORE_ENTRIES",962        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },963      });964      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");965966      if (payload.license?.key) {967        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});968      }969970      setAnimating("snap");971      stopAnim();972      if (canvas) {973        snapToGrid(canvas, () => {974          setAnimating("bloom");975          bloom(canvas, async () => {976            setAnimating(null);977            await props.onSuccess();978          });979        });980      } else {981        await props.onSuccess();982      }983    } catch (err) {984      stopAnim();985      setAnimating(null);986      const code = err instanceof Error ? err.message : String(err);987      setError(988        code === "INVALID_PASSPHRASE"989          ? "That passphrase didn't work."990          : code === "CREATE_VAULT_FAILED"991            ? "Couldn't create a new local vault."992            : "Could not read this backup.",993      );994    } finally {995      setWorking(false);996    }997  }998999  return (1000    <div1001      class="peach-restore-form"1002      classList={{ "is-dragover": dragDepth() > 0 }}1003      onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1004      onDragOver={(e) => e.preventDefault()}1005      onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1006      onDrop={(e) => void handleDrop(e)}1007    >1008      <Show when={animating() !== null}>1009        <canvas1010          ref={(el) => { canvasRef = el; }}1011          class="peach-restore-anim-canvas"1012          width={280}1013          height={200}1014        />1015      </Show>1016      <Show when={animating() === null}>1017        <Show1018          when={scriptValid()}1019          fallback={1020            <>1021              <textarea1022                class="peach-restore-script"1023                placeholder="paste peachscript…"1024                rows={3}1025                aria-label="Paste your PeachScript backup"1026                value={scriptInput()}1027                onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}1028              />1029              <div class="peach-restore-or" aria-hidden="true">or</div>1030              <label1031                class="peach-btn peach-btn--ghost peach-btn--sm peach-restore-file-btn"1032                onClick={(e) => {1033                  if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) return;1034                  e.preventDefault();1035                  void detachForFilePicker(RESTORE_DEEP_LINKS.symbols).then((detached) => {1036                    if (!detached) peachFileRef?.click();1037                  });1038                }}1039              >1040                open .peach file1041                <input1042                  ref={(el) => { peachFileRef = el; }}1043                  type="file"1044                  accept=".peach,text/plain"1045                  style="display:none"1046                  onChange={async (e) => {1047                    const file = (e.currentTarget as HTMLInputElement).files?.[0];1048                    if (!file) return;1049                    // PB-26-208: bound the file before file.text().1050                    if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {1051                      setError("That file is too large.");1052                      (e.currentTarget as HTMLInputElement).value = "";1053                      return;1054                    }1055                    try {1056                      setScriptInput(await file.text());1057                    } catch {1058                      setError("Couldn't read file.");1059                    } finally {1060                      (e.currentTarget as HTMLInputElement).value = "";1061                    }1062                  }}1063                />1064              </label>1065              <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1066            </>1067          }1068        >1069          <div class="peach-restore-rune-preview">1070            <RuneCanvas runeString={scriptInput()} width={256} height={120} />1071          </div>1072          <button1073            type="button"1074            class="peach-restore-picker-back"1075            onClick={() => { setScriptInput(""); setError(null); }}1076          >1077            ‹ paste again1078          </button>1079        </Show>1080        <div class="peach-newvault-field">1081          <label class="peach-label" for="restore-backup-pass">1082            backup password1083          </label>1084          <input1085            id="restore-backup-pass"1086            class="peach-underline-input peach-newvault-input"1087            type="password"1088            placeholder="••••••••••••"1089            autocomplete="current-password"1090            value={backupPass()}1091            onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}1092            onKeyDown={(e) => {1093              if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {1094                e.preventDefault();1095                void handleRestore();1096              }1097            }}1098          />1099        </div>1100        <button1101          class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"1102          onClick={() => void handleRestore()}1103          disabled={!scriptValid() || !backupPass().trim() || working()}1104        >1105          restore vault1106        </button>1107        <Show when={error()}>1108          <p class="peach-hint peach-hint-error">{error()}</p>1109        </Show>1110      </Show>1111    </div>1112  );1113}11141115// ── CSV source ─────────────────────────────────────────────────────────────11161117function RestoreCSV(props: {1118  hasVault: boolean;1119  onSuccess: () => void | Promise<void>;1120}) {1121  const [importing, setImporting] = createSignal(false);1122  const [error, setError] = createSignal<string | null>(null);1123  // CSV/JSON imports are plaintext and carry no password, so when there is no1124  // vault yet the user MUST choose a master password here — otherwise the1125  // imported credentials would be encrypted under an empty key and offer zero1126  // at-rest protection (audit HIGH). Mirrors the onboarding MIN_LENGTH policy.1127  const CSV_MIN_PASSWORD_LENGTH = 12;1128  const [newPass, setNewPass] = createSignal("");1129  const [confirmPass, setConfirmPass] = createSignal("");1130  const needsPassword = () => props.hasVault === false;1131  const passwordReady = () =>1132    !needsPassword() ||1133    (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());1134  // The action popup dies when the OS file picker steals focus — dropping the1135  // file is the import path that survives it (see RestoreSymbols).1136  const [dragDepth, setDragDepth] = createSignal(0);11371138  // Every route here (label click, drop, the disabled-attribute-guarded1139  // input change) passes gatePassword first, so the file is always accepted1140  // under a real master password — never an empty key.1141  async function processFile(file: File) {1142    if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {1143      setError("That file is too large.");1144      return;1145    }1146    setImporting(true);1147    setError(null);1148    try {1149      const text = await file.text();1150      const parsed = await parseImportSource(text);1151      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");11521153      if (props.hasVault === false) {1154        // Create the vault under the user-chosen master password — never an1155        // empty one (an empty key derives a publicly-known vault key).1156        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });1157        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1158      }11591160      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1161      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1162      await props.onSuccess();1163    } catch (err) {1164      const code = err instanceof Error ? err.message : String(err);1165      setError(1166        code === "NO_ENTRIES" ? "No entries found in this file." :1167        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1168        "Import failed. Check the file format and try again."1169      );1170    } finally {1171      setImporting(false);1172    }1173  }11741175  let csvFileRef: HTMLInputElement | undefined;11761177  onMount(() => {1178    // A fresh vault needs its master password before a file can be accepted —1179    // auto-picking would only drop the chosen file at the password gate, so1180    // the dialog waits for the explicit click in that case.1181    if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv) && !needsPassword()) {1182      setTimeout(() => csvFileRef?.click(), 50);1183    }1184  });11851186  function gatePassword(): boolean {1187    if (importing()) return false;1188    if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {1189      setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);1190      return false;1191    }1192    if (needsPassword() && newPass() !== confirmPass()) {1193      setError("Passwords do not match.");1194      return false;1195    }1196    return true;1197  }11981199  async function handleFile(e: Event) {1200    const input = e.currentTarget as HTMLInputElement;1201    const file = input.files?.[0];1202    input.value = "";1203    if (!file) return;1204    await processFile(file);1205  }12061207  async function handleDrop(e: DragEvent) {1208    e.preventDefault();1209    setDragDepth(0);1210    if (!gatePassword()) return;1211    const file = e.dataTransfer?.files?.[0];1212    if (!file) return;1213    await processFile(file);1214  }12151216  return (1217    <div1218      class="peach-restore-form"1219      classList={{ "is-dragover": dragDepth() > 0 }}1220      onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1221      onDragOver={(e) => e.preventDefault()}1222      onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1223      onDrop={(e) => void handleDrop(e)}1224    >1225      <Show when={needsPassword()}>1226        <div class="peach-newvault-field">1227          <label class="peach-label" for="restore-csv-pass">1228            new master password1229          </label>1230          <input1231            id="restore-csv-pass"1232            class="peach-underline-input peach-newvault-input"1233            type="password"1234            placeholder="••••••••••••"1235            autocomplete="new-password"1236            value={newPass()}1237            onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1238          />1239        </div>1240        <div class="peach-newvault-field">1241          <label class="peach-label" for="restore-csv-confirm">1242            confirm master password1243          </label>1244          <input1245            id="restore-csv-confirm"1246            class="peach-underline-input peach-newvault-input"1247            type="password"1248            placeholder="••••••••••••"1249            autocomplete="new-password"1250            value={confirmPass()}1251            onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1252          />1253        </div>1254      </Show>1255      <label1256        class="peach-btn peach-btn--accent peach-btn--md peach-restore-file-btn"1257        onClick={(e) => {1258          // In the detached window the dialog can open directly — let the1259          // label's real user gesture activate the input. In the action1260          // popup we must detach first (the dialog would dismiss the popup).1261          if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv)) return;1262          // The label would forward the click to the input even before the1263          // password gate passes; own the activation so the gate can speak.1264          e.preventDefault();1265          if (!gatePassword()) return;1266          void detachForFilePicker(RESTORE_DEEP_LINKS.csv).then((detached) => {1267            if (!detached) csvFileRef?.click();1268          });1269        }}1270      >1271        {importing() ? "importing…" : "open csv file"}1272        <input1273          ref={(el) => { csvFileRef = el; }}1274          type="file"1275          accept=".csv,.json,.peach,application/x-peach"1276          style="display:none"1277          onChange={handleFile}1278          disabled={importing() || !passwordReady()}1279        />1280      </label>1281      <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1282      <p class="peach-hint peach-restore-csv-sources">1283        Imports from Bitwarden, 1Password, Proton Pass,1284        <br />1285        Chrome, Safari, and Edge.1286      </p>1287      <p class="peach-newvault-warning">1288        <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>1289        <span>Plain-text file — delete it after the import.</span>1290      </p>1291      <Show when={error()}>1292        <p class="peach-hint peach-hint-error">{error()}</p>1293      </Show>1294    </div>1295  );1296}12971298// ── Helpers ────────────────────────────────────────────────────────────────12991300function getDisplayError(error: string | undefined, fallback: string): string {1301  if (!error) return fallback;1302  if (1303    error === "SERVICE_WORKER_UNAVAILABLE" ||1304    error.includes("Receiving end does not exist") ||1305    error.includes("message port closed")1306  ) {1307    return "background service unavailable";1308  }1309  // Never echo raw background errors — internal messages must not reach the UI.1310  return fallback;1311}