Lock.tsx

52.3 KB · raw

1import { For, Match, Show, Switch, createMemo, createSignal, onCleanup, onMount } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark, PeachWordmark } from "../components/PeachMark";4import { RecoveryWordEntryView } from "./RecoveryWordEntry";5import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";6import { RuneBackground } from "../components/RuneBackground";7import { RuneCanvas } from "../components/RuneCanvas";8import { storageGet, storageRemove, storageSet } from "../../shared/storage";9import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";10import { parsePayload } from "../../crypto/peachPayload";11import { parseImportSource } from "../../shared/import";12import { isNativePlatform, nativePlugin } from "../../shared/native";1314const peachMarkImg = new Image();15peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(16  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" fill="none">' +17  '<path d="M 124 64 C 134 32 168 16 200 22 C 198 56 168 72 130 66 Z" fill="#92AC65"/>' +18  '<path d="M 130 64 C 152 46 178 30 196 24" stroke="#5E7A42" stroke-width="2.6" fill="none" stroke-linecap="round"/>' +19  '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 185 222 222 185 220 130 C 218 78 170 45 120 60 Z" fill="#FFB082"/>' +20  '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 105 195 75 150 100 90 C 105 75 112 65 120 60 Z" fill="#E5805C"/>' +21  '<ellipse cx="178" cy="108" rx="20" ry="11" fill="#FFD0A8" opacity="0.7" transform="rotate(-26 178 108)"/>' +22  '</svg>'23)}`;2425// Lazy-load @zxcvbn-ts — heavy (dictionaries ~700 kB) and only needed during26// vault creation. Replaces the unmaintained [email protected] (LV-8); result.score27// keeps the same 0-4 scale so downstream UI is unchanged.28let _zxcvbn: ((password: string) => { score: number }) | null = null;29async function getZxcvbn() {30  if (_zxcvbn) return _zxcvbn;31  const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([32    import("@zxcvbn-ts/core"),33    import("@zxcvbn-ts/language-common"),34    import("@zxcvbn-ts/language-en"),35  ]);36  zxcvbnOptions.setOptions({37    translations: en.translations,38    graphs: common.adjacencyGraphs,39    dictionary: { ...common.dictionary, ...en.dictionary },40  });41  _zxcvbn = (password: string) => zxcvbn(password);42  return _zxcvbn;43}4445interface LockProps {46  hasVault: boolean;47  ready: boolean;48  isPro?: boolean;49  /** When true, render the recovery-words display directly instead of the50   *  unlock UI. Used after a popup close+reopen during the create-vault51   *  recovery confirmation flow (peach_recovery_pending flag). */52  forceRecoveryWords?: boolean;53  onUnlock: () => void | Promise<void>;54  /** Optional escape hatch: rewind from the create-vault screen back to55   *  Onboarding. Only meaningful when the user reached create-vault from56   *  Onboarding in the first place. */57  onBackToOnboarding?: () => void;58  /** Optional: open the Peach Codex restore flow (camera scan of printed59    *  Codex pages). Surfaced as a small footer link on the new-vault screen. */60  onOpenCodexRestore?: () => void;61  /** 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 [biometricAvailable, setBiometricAvailable] = createSignal(false);96  const [biometricBusy, setBiometricBusy] = createSignal(false);97  const [pinAvailable, setPinAvailable] = createSignal(false);98  const [usingPin, setUsingPin] = createSignal(false);99  const [pin, setPin] = createSignal("");100101  const canCreate = createMemo(102    () =>103      password().length >= MIN_LENGTH &&104      confirmPassword().length >= MIN_LENGTH &&105      password() === confirmPassword() &&106      strengthScore() !== null &&107      strengthScore()! >= MIN_ZXCVBN_SCORE108  );109110  const passwordStrength = createMemo(() => {111    const score = strengthScore();112    if (score === null) return null;113    if (score <= 1) return "weak";114    if (score === 2) return "fair";115    if (score === 3) return "good";116    return "strong";117  });118119  const createBlockerMessage = createMemo(() => {120    if (!password()) return null;121    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;122    if (!confirmPassword()) return "confirm your password";123    if (password() !== confirmPassword()) return "passwords don't match";124    if (strengthScore() === null) return "checking strength…";125    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — use a longer passphrase";126    return null;127  });128129  // Update strength asynchronously when password changes130  createMemo(() => {131    const value = password();132    if (!value) { setStrengthScore(null); return; }133    getZxcvbn().then((zxcvbn) => {134      if (password() === value) setStrengthScore(zxcvbn(value).score);135    }).catch(() => {136      // zxcvbn unavailable — fall back to length-only heuristic137      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);138    });139  });140141  async function handleUnlock() {142    if (!password().trim()) return;143    setLoading(true);144    setError("");145    const response = await sendMessage({146      type: "UNLOCK",147      payload: { password: password() },148    });149    const result = response as { ok: boolean; error?: string };150    if (result.ok) {151      await props.onUnlock();152      setLoading(false);153      return;154    }155    setError(getDisplayError(result.error, "incorrect password"));156    setPassword("");157    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;158    passwordField?.classList.add("shake");159    setTimeout(() => passwordField?.classList.remove("shake"), 300);160    setLoading(false);161  }162163  async function handlePinUnlock() {164    if (!/^\d{4,12}$/.test(pin())) return;165    setLoading(true);166    setError("");167    const response = await sendMessage({168      type: "UNLOCK_WITH_PIN",169      payload: { pin: pin() },170    });171    const result = response as { ok: boolean; error?: string };172    if (result.ok) {173      await props.onUnlock();174      setLoading(false);175      return;176    }177    setError(getDisplayError(result.error, "incorrect PIN"));178    setPin("");179    const pinField = document.querySelector(".lock-pin-input") as HTMLInputElement | undefined;180    pinField?.classList.add("shake");181    setTimeout(() => pinField?.classList.remove("shake"), 300);182    setLoading(false);183  }184185  async function checkPinAvailable(): Promise<void> {186    if (!props.hasVault) return;187    try {188      const response = await sendMessage({ type: "GET_SETTINGS" });189      const settings = (response as { ok: boolean; data?: { pinEnabled?: boolean; pinPreferred?: boolean } }).data;190      const enabled = settings?.pinEnabled === true;191      setPinAvailable(enabled);192      if (enabled && settings?.pinPreferred === true) setUsingPin(true);193    } catch { /* settings are optional at the lock screen */ }194  }195196  async function checkBiometricAvailable(): Promise<void> {197    if (!props.hasVault || !isNativePlatform()) return;198    const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");199    const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");200    if (!keystore || !biometric) return;201    try {202      const stored = await keystore.hasMasterKey();203      if (!stored.exists) return;204      const avail = await biometric.isAvailable();205      if (!avail.available) return;206      setBiometricAvailable(true);207      window.setTimeout(() => { void handleBiometricUnlock(); }, 400);208    } catch { /* missing plugins or runtime error — silently skip */ }209  }210211  async function handleBiometricUnlock(): Promise<void> {212    if (biometricBusy()) return;213    const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");214    const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");215    if (!biometric || !keystore) return;216    setBiometricBusy(true);217    setError("");218    try {219      await biometric.authenticate({220        title: "Unlock Peach",221        subtitle: "Use biometrics to access your vault",222        negativeButtonText: "Use Password",223      });224      const { masterKeyHex } = await keystore.retrieveMasterKey();225      const res = (await sendMessage({226        type: "UNLOCK_WITH_KEY",227        payload: { masterKeyHex },228      })) as { ok: boolean; error?: string };229      if (res.ok) {230        await props.onUnlock();231        return;232      }233      if (res.error === "biometric_key_invalidated") {234        await sendMessage({235          type: "UPDATE_SETTINGS",236          payload: { biometricsEnabled: false },237        });238        setBiometricAvailable(false);239        setError("biometric key expired — re-enable in settings");240      } else {241        setError("biometric unlock failed");242      }243    } catch (e) {244      const reason = e instanceof Error ? e.message : String(e);245      if (reason.includes("user_cancelled")) return;246      if (reason.includes("biometric_key_invalidated")) {247        await sendMessage({248          type: "UPDATE_SETTINGS",249          payload: { biometricsEnabled: false },250        });251        setBiometricAvailable(false);252        setError("biometric key expired — re-enable in settings");253        return;254      }255      if (reason.includes("biometric_lockout")) {256        setError("biometric locked out — use your password");257      }258    } finally {259      setBiometricBusy(false);260    }261  }262263  onMount(() => {264    void checkBiometricAvailable();265    void checkPinAvailable();266  });267268  async function handleCreate() {269    if (!password().trim()) return;270    if (password().length < MIN_LENGTH) {271      setError(`password must be at least ${MIN_LENGTH} characters`);272      return;273    }274    if (password() !== confirmPassword()) {275      setError("passwords do not match");276      return;277    }278    const score = strengthScore();279    if (score === null || score < MIN_ZXCVBN_SCORE) {280      setError("password is too weak — use a longer passphrase");281      return;282    }283    setLoading(true);284    setError("");285    const response = await sendMessage({286      type: "CREATE_VAULT",287      payload: { password: password() },288    });289    const result = response as { ok: boolean; error?: string };290    if (result.ok) {291      // Don't unlock yet — show the recovery words screen first. Mark the292      // confirmation as pending in chrome.storage.local so it survives both293      // popup close (lock-on-close skips while set) and full browser restart294      // (next unlock re-routes to the recovery surface). Cleared when the295      // user types the 3 confirmation words successfully.296      try {297        await chrome.storage.local?.set({ peach_recovery_pending: "1" });298      } catch { /* best-effort */ }299      setShowRecoveryWords(true);300      setLoading(false);301      return;302    }303    setError(getDisplayError(result.error, "failed to create vault"));304    setLoading(false);305  }306307  // ── All surfaces routed reactively via Switch/Match ───────────────────308  return (309    <Switch>310      {/* ── Restore surface ───────────────────────────────────────── */}311      <Match when={restoreSurface() === "restore"}>312        <RestoreView313          hasVault={props.hasVault}314          onOpenCodexRestore={props.onOpenCodexRestore}315          initialSource={props.initialRestoreSource}316          onBack={() => {317            // The restore tab is only reached from the onboarding carousel,318            // so back returns there — not to the create-vault form.319            if (props.onBackToOnboarding) props.onBackToOnboarding();320            else setRestoreSurface(null);321          }}322          onUnlock={async () => {323            setRestoreSurface(null);324            await props.onUnlock();325          }}326        />327      </Match>328329      {/* ── Recovery words display after fresh vault creation ────────── */}330      {/* Read props.forceRecoveryWords directly, not just the boot-time331          signal copy: when the vault is locked with a pending confirmation,332          App flips the flag after UNLOCK without remounting LockView. */}333      <Match334        when={335          (showRecoveryWords() || props.forceRecoveryWords) &&336          (!props.hasVault || props.forceRecoveryWords)337        }338      >339        <RecoveryWordDisplayView340          onDone={props.onUnlock}341        />342      </Match>343344      {/* ── Recovery word entry ─────────────────────────────────────── */}345      <Match when={recoverSurface() === "words"}>346        <RecoveryWordEntryView347          onCancel={() => setRecoverSurface(null)}348          onRecoveryComplete={props.onUnlock}349        />350      </Match>351352      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}353      <Match when={props.hasVault}>354        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">355          <RuneBackground opacity={0.07} />356357          <header class="peach-screen-header lock-screen-header">358            <div class="peach-wordmark-stack">359              <PeachWordmark class="peach-wordmark-wrap" />360              <Show when={props.isPro}>361                <span class="peach-pro-mini-badge">pro</span>362              </Show>363            </div>364          </header>365366          <div class="peach-lock-hero peach-lock-hero--unlock">367            <PeachMark class="peach-lock-mark" />368            <span class="peach-lock-eyebrow">vault locked</span>369            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">370              unlock371              <br />372              your vault.373            </h1>374          </div>375376          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">377            <Show when={usingPin()} fallback={378              <>379                <label class="peach-label" for="unlock-password">380                  master password381                </label>382                <input383                  id="unlock-password"384                  class="peach-underline-input peach-unlock-input lock-password-input"385                  type="password"386                  placeholder="••••••••••••"387                  autocomplete="current-password"388                  value={password()}389                  disabled={!props.ready}390                  ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}391                  onInput={(event) => setPassword((event.target as HTMLInputElement).value)}392                  onKeyDown={(event) => {393                    if (event.key !== "Enter" || !props.ready) return;394                    void handleUnlock();395                  }}396                />397              </>398            }>399              <label class="peach-label" for="unlock-pin">400                PIN401              </label>402              <input403                id="unlock-pin"404                class="peach-underline-input peach-unlock-input lock-pin-input"405                type="password"406                inputmode="numeric"407                maxlength="12"408                placeholder="••••••••••••"409                autocomplete="off"410                value={pin()}411                disabled={!props.ready}412                ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}413                onInput={(event) => setPin((event.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}414                onKeyDown={(event) => {415                  if (event.key !== "Enter" || !props.ready) return;416                  void handlePinUnlock();417                }}418              />419              <button420                class="peach-lock-recover-link"421                type="button"422                onClick={() => { setUsingPin(false); setError(""); }}423              >424                use master password425              </button>426            </Show>427428            <Show when={error()}>429              <p class="peach-hint peach-hint-error">{error()}</p>430            </Show>431432            <button433              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"434              onClick={() => void (usingPin() ? handlePinUnlock() : handleUnlock())}435              disabled={!props.ready || loading()}436            >437              {loading() ? "unlocking..." : usingPin() ? "unlock with PIN" : "unlock"}438            </button>439440            <Show when={!usingPin() && pinAvailable()}>441              <button442                class="peach-lock-recover-link"443                type="button"444                onClick={() => { setUsingPin(true); setError(""); }}445              >446                use PIN447              </button>448            </Show>449450            <Show when={biometricAvailable()}>451              <button452                class="peach-biometric-btn"453                type="button"454                onClick={() => void handleBiometricUnlock()}455                disabled={biometricBusy() || loading()}456                aria-label="Unlock with biometrics"457              >458                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">459                  <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>460                  <path d="M15 17v-3"/>461                  <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>462                  <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>463                  <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>464                  <path d="M5 15c0-1.7.3-3.3.8-4.7"/>465                  <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>466                </svg>467                {biometricBusy() ? "verifying…" : "use biometrics"}468              </button>469            </Show>470471            <button472              class="peach-lock-recover-link"473              type="button"474              onClick={() => setRecoverSurface("words")}475            >476              forgot your password?477            </button>478          </section>479        </section>480      </Match>481482      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}483      <Match when={true}>484        <section485          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"486          data-motion-phase="enter"487        >488          <header class="peach-screen-header peach-newvault-header">489            <div class="peach-wordmark-stack">490              <PeachWordmark class="peach-wordmark-wrap" />491              <Show when={props.isPro}>492                <span class="peach-pro-mini-badge">pro</span>493              </Show>494            </div>495            <Show when={props.onBackToOnboarding}>496              <button497                class="peach-newvault-back"498                type="button"499                onClick={() => props.onBackToOnboarding?.()}500                aria-label="Back to onboarding"501              >502                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">503                  <path d="M7 2L3 5.5L7 9" />504                </svg>505                back506              </button>507            </Show>508          </header>509510          <div class="peach-newvault-mark" aria-hidden="true">511            <PeachMark class="peach-newvault-mark-art" />512          </div>513514          <div class="peach-newvault-card">515            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>516            <hr class="peach-newvault-divider" />517518            <div class="peach-newvault-field">519              <div class="peach-newvault-label-row">520                <label class="peach-label" for="master-password">521                  master password522                </label>523                <button524                  class="peach-lock-reveal-btn"525                  type="button"526                  onClick={() => setShowPassword((value) => !value)}527                  aria-label={showPassword() ? "Hide password" : "Show password"}528                >529                  <Show530                    when={showPassword()}531                    fallback={532                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">533                        <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" />534                        <circle cx="7" cy="5" r="1.75" />535                      </svg>536                    }537                  >538                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">539                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />540                      <path d="M3.5 5.5L2.5 7.5" />541                      <path d="M7 6.5V8.5" />542                      <path d="M10.5 5.5L11.5 7.5" />543                    </svg>544                  </Show>545                  {showPassword() ? "hide" : "show"}546                </button>547              </div>548              <input549                id="master-password"550                class="peach-underline-input peach-newvault-input lock-password-input"551                type={showPassword() ? "text" : "password"}552                placeholder="••••••••••••"553                autocomplete="new-password"554                value={password()}555                disabled={!props.ready}556                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}557                onKeyDown={(event) => {558                  if (event.key !== "Enter" || !props.ready) return;559                  void handleCreate();560                }}561              />562              <Show563                when={password().length > 0 && passwordStrength() !== null}564                fallback={565                  <p class="peach-newvault-hint">566                    At least 12 characters.567                  </p>568                }569              >570                <div class="peach-password-strength">571                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>572                    <div class="peach-strength-fill"></div>573                  </div>574                  <span class="peach-strength-label">{passwordStrength()}</span>575                </div>576              </Show>577            </div>578579            <div class="peach-newvault-field">580              <label class="peach-label" for="confirm-password">581                confirm password582              </label>583              <input584                id="confirm-password"585                class="peach-underline-input peach-newvault-input"586                type={showPassword() ? "text" : "password"}587                placeholder="••••••••••••"588                autocomplete="new-password"589                value={confirmPassword()}590                disabled={!props.ready}591                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}592                onKeyDown={(event) => {593                  if (event.key === "Enter" && props.ready) void handleCreate();594                }}595              />596            </div>597598            <Show when={error()}>599              <p class="peach-hint peach-hint-error">{error()}</p>600            </Show>601602            <button603              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"604              onClick={() => void handleCreate()}605              disabled={!props.ready || loading() || !canCreate()}606              aria-live="polite"607            >608              <Show609                when={!canCreate() && createBlockerMessage()}610                fallback={loading() ? "creating..." : "create vault"}611              >612                {createBlockerMessage()}613              </Show>614            </button>615616            <p class="peach-newvault-warning">617              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>618              <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>619            </p>620          </div>621        </section>622      </Match>623    </Switch>624  );625}626627// ── Restore surface ────────────────────────────────────────────────────────628629export type RestoreSource = "symbols" | "codex" | "csv";630631const RESTORE_SOURCES: { id: RestoreSource; name: string; detail: string }[] = [632  { id: "symbols", name: "PeachScript", detail: "Paste the rune string, or open a .peach file." },633  { id: "codex", name: "Codex", detail: "Scan printed pages, or import the PDF." },634  { id: "csv", name: "CSV", detail: "A .csv or .json file from another password manager." },635];636637const RESTORE_DEEP_LINKS: Record<"symbols" | "csv", "peachscript" | "csv"> = {638  symbols: "peachscript",639  csv: "csv",640};641642// The Chrome action popup dismisses itself the moment the OS file picker643// steals focus, so a picker opened from it never delivers its file. From the644// action popup we reopen this surface in a standalone window and let that645// window own the dialog — same trick as CodexRestoreView.openPdfPicker().646// The window is created by the service worker: a window created by the popup647// itself is cascade-closed by Chrome when the popup dismisses. App.tsx routes648// the deep link back here (?restore=peachscript|csv) at boot, before first649// render, so the window mounts straight on the right source.650async function detachForFilePicker(route: "peachscript" | "csv"): Promise<boolean> {651  try {652    // Already inside a detached window — it can host the dialog directly.653    if (new URLSearchParams(window.location.search).get("restore")) return false;654  } catch { /* no location — fall through to the capability check */ }655  if (typeof chrome === "undefined" || typeof chrome.runtime?.sendMessage !== "function") {656    return false;657  }658  try {659      const res = (await sendMessage({660        type: "OPEN_DETACHED_POPUP",661        payload: { route },662      })) as { ok: boolean; data?: { detached?: boolean } };663      if (!res?.data?.detached) return false;664      // The action popup dismisses itself when the new window steals focus —665      // calling window.close() here cascade-closes the detached child too.666      return true;667  } catch {668    return false;669  }670}671672/** True when this window was opened via ?restore=<link> specifically to own673 *  the file dialog — mirrors CodexRestoreView's autoPickPdf detection. */674function restoreDeepLinkActive(link: "peachscript" | "csv"): boolean {675  try {676    return new URLSearchParams(window.location.search).get("restore") === link;677  } catch {678    return false;679  }680}681682function RestoreView(props: {683  hasVault: boolean;684  onOpenCodexRestore?: () => void;685  onBack: () => void;686  onUnlock: () => void | Promise<void>;687  initialSource?: RestoreSource;688}) {689  const [source, setSource] = createSignal<RestoreSource | null>(props.initialSource ?? null);690  const [showIntro, setShowIntro] = createSignal(false);691  const [pendingUnlock] = createSignal(false);692693  // The action popup dies the moment the OS file picker steals focus, so a694  // mid-restore close must resume the same source form — rewinding to the695  // picker reads as losing progress. The resume marker mirrors the picked696  // source; App clears it with the rest of the onboarding-exit state.697  function chooseSource(next: RestoreSource | null) {698    setSource(next);699    if (next) void storageSet(RESTORE_SOURCE_STORAGE_KEY, next).catch(() => {});700    else void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});701  }702703  async function handleRestoreSuccess() {704    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);705    if (!seen) {706      setShowIntro(true);707    } else {708      await props.onUnlock();709    }710  }711712  async function dismissIntro() {713    await storageSet("peach_shown_restore_intro", "true").catch(() => {});714    setShowIntro(false);715    await props.onUnlock();716  }717718  return (719    <section720      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault peach-restore"721      data-motion-phase="enter"722    >723      <RuneBackground opacity={0.05} />724725      <header class="peach-screen-header peach-newvault-header">726        <PeachWordmark class="peach-wordmark-wrap" />727        <button728          class="peach-newvault-back peach-restore-back"729          type="button"730          onClick={props.onBack}731          aria-label="Back to onboarding"732        >733          <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">734            <path d="M7 2L3 5.5L7 9" />735          </svg>736          back737        </button>738      </header>739740      <div class="peach-newvault-card peach-restore-card">741        <Show742          when={source()}743          keyed744          fallback={745            <>746              <h1 class="peach-newvault-headline">{"welcome\nback."}</h1>747              <hr class="peach-newvault-divider" />748              <div class="peach-restore-sources">749                <For each={RESTORE_SOURCES}>750                  {(entry) => (751                    <button752                      type="button"753                      class="peach-onboarding-choice peach-restore-source"754                      onClick={() => chooseSource(entry.id)}755                    >756                      <span class="peach-onboarding-choice-label">{entry.name}</span>757                      <span class="peach-onboarding-choice-detail">{entry.detail}</span>758                    </button>759                  )}760                </For>761              </div>762            </>763          }764        >765          {(active) => (766            <>767              <div class="peach-restore-source-head">768                <h2 class="peach-restore-source-title">769                  {RESTORE_SOURCES.find((entry) => entry.id === active)?.name}770                </h2>771                <button772                  type="button"773                  class="peach-restore-picker-back"774                  onClick={() => chooseSource(null)}775                >776                  ‹ sources777                </button>778              </div>779              <hr class="peach-newvault-divider" />780              <Show when={active === "symbols"}>781                <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />782              </Show>783              <Show when={active === "codex"}>784                <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />785              </Show>786              <Show when={active === "csv"}>787                <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />788              </Show>789            </>790          )}791        </Show>792      </div>793794      {/* One-time intro popup after first restore */}795      <Show when={showIntro()}>796        <div class="peach-restore-intro-overlay">797          <div class="peach-restore-intro-card">798            <p class="peach-restore-intro-headline">welcome to peach.</p>799            <div class="peach-restore-intro-body">800              <p>Your vault lives only on this device. No server has a copy.</p>801              <p>The inline bubble fills passwords automatically when you visit sites.</p>802              <p>Export your vault from Settings before switching devices.</p>803            </div>804            <button805              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"806              onClick={() => void dismissIntro()}807              disabled={pendingUnlock()}808            >809              got it810            </button>811          </div>812        </div>813      </Show>814    </section>815  );816}817818// ── Peach Codex source ─────────────────────────────────────────────────────819820function RestoreCodex(props: { onOpen: () => void }) {821  return (822    <div class="peach-restore-form">823      <p class="peach-restore-codex-copy">824        Scan printed Codex pages or import the Codex PDF. Codex restore is825        always available, even before you create a vault.826      </p>827      <button828        type="button"829        class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"830        onClick={props.onOpen}831      >832        open Codex restore833      </button>834    </div>835  );836}837838// ── PeachScript source ─────────────────────────────────────────────────────839840function RestoreSymbols(props: {841  hasVault: boolean;842  onSuccess: () => void | Promise<void>;843}) {844  const [scriptInput, setScriptInput] = createSignal("");845  const [backupPass, setBackupPass] = createSignal("");846  const [working, setWorking] = createSignal(false);847  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);848  const [error, setError] = createSignal<string | null>(null);849  let canvasRef: HTMLCanvasElement | undefined;850  let peachFileRef: HTMLInputElement | undefined;851  let animFrameId = 0;852  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];853854  const scriptValid = createMemo(() => isPeachScript(scriptInput()));855856  function stopAnim() {857    cancelAnimationFrame(animFrameId);858    animFrameId = 0;859  }860861  function startChaos(canvas: HTMLCanvasElement) {862    const ctx = canvas.getContext("2d")!;863    const W = canvas.width;864    const H = canvas.height;865    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>866      String.fromCodePoint(0xe000 + i)867    );868    if (runes.length === 0) {869      runes = Array.from({ length: 60 }, () => ({870        x: Math.random() * W,871        y: Math.random() * H,872        vx: (Math.random() - 0.5) * 3,873        vy: (Math.random() - 0.5) * 3,874        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,875        size: 14 + Math.random() * 20,876        angle: Math.random() * Math.PI * 2,877        va: (Math.random() - 0.5) * 0.1,878      }));879    }880881    function draw() {882      ctx.clearRect(0, 0, W, H);883      for (const r of runes) {884        r.x = (r.x + r.vx + W) % W;885        r.y = (r.y + r.vy + H) % H;886        r.angle += r.va;887        ctx.save();888        ctx.translate(r.x, r.y);889        ctx.rotate(r.angle);890        ctx.font = `${r.size}px PeachScript, monospace`;891        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);892        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;893        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";894        ctx.shadowBlur = 8;895        ctx.fillText(r.char, 0, 0);896        ctx.restore();897      }898      animFrameId = requestAnimationFrame(draw);899    }900    draw();901  }902903  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {904    stopAnim();905    const ctx = canvas.getContext("2d")!;906    const W = canvas.width;907    const H = canvas.height;908    const cols = 8;909    const rows = 6;910    const cellW = W / cols;911    const cellH = H / rows;912    const targets = runes.slice(0, cols * rows).map((_, i) => ({913      x: (i % cols) * cellW + cellW / 2,914      y: Math.floor(i / cols) * cellH + cellH / 2,915    }));916    const start = performance.now();917    const DURATION = 400;918919    function drawSnap(now: number) {920      const t = Math.min((now - start) / DURATION, 1);921      const ease = t < 1 ? 1 - (1 - t) ** 3 : 1;922      ctx.clearRect(0, 0, W, H);923      runes.slice(0, targets.length).forEach((r, i) => {924        const tx = targets[i]!.x;925        const ty = targets[i]!.y;926        const cx = r.x + (tx - r.x) * ease;927        const cy = r.y + (ty - r.y) * ease;928        ctx.font = `16px PeachScript, monospace`;929        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;930        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";931        ctx.shadowBlur = 6;932        ctx.fillText(r.char, cx - 8, cy + 8);933      });934      if (t < 1) {935        animFrameId = requestAnimationFrame(drawSnap);936      } else {937        onDone();938      }939    }940    animFrameId = requestAnimationFrame(drawSnap);941  }942943  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {944    stopAnim();945    const ctx = canvas.getContext("2d")!;946    const W = canvas.width;947    const H = canvas.height;948    const start = performance.now();949    const DURATION = 900;950951    function drawBloom(now: number) {952      const t = Math.min((now - start) / DURATION, 1);953      ctx.clearRect(0, 0, W, H);954      // Runes dissolve955      const runeAlpha = 1 - Math.min(t / 0.4, 1);956      if (runeAlpha > 0) {957        for (const r of runes.slice(0, 48)) {958          ctx.font = `16px PeachScript, monospace`;959          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;960          ctx.fillText(r.char, r.x - 8, r.y + 8);961        }962      }963      // Peach mark bloom964      const markT = Math.max(0, (t - 0.2) / 0.5);965      const markScale = Math.min(markT * 1.2, 1);966      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;967      if (markAlpha > 0) {968        ctx.save();969        ctx.globalAlpha = markAlpha;970        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;971        ctx.shadowBlur = 30;972        ctx.translate(W / 2, H / 2);973        ctx.scale(markScale, markScale);974        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);975        ctx.restore();976      }977      if (t < 1) {978        animFrameId = requestAnimationFrame(drawBloom);979      } else {980        onDone();981      }982    }983    animFrameId = requestAnimationFrame(drawBloom);984  }985986  onCleanup(() => stopAnim());987988  // This window was opened specifically to own the file dialog — open it as989  // soon as the input exists. Mirrors the ?codex=pdf auto-pick.990  onMount(() => {991    if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) {992      setTimeout(() => peachFileRef?.click(), 50);993    }994  });995996  // The action popup dies when the OS file picker steals focus; dropping the997  // file is the other import path that survives it.998  const [dragDepth, setDragDepth] = createSignal(0);9991000  async function handleDrop(e: DragEvent) {1001    e.preventDefault();1002    setDragDepth(0);1003    if (working() || animating() !== null) return;1004    const file = e.dataTransfer?.files?.[0];1005    if (!file) return;1006    try {1007      setScriptInput(await file.text());1008      setError(null);1009    } catch {1010      setError("Couldn't read file.");1011    }1012  }10131014  async function handleRestore() {1015    if (!scriptValid() || !backupPass().trim() || working()) return;1016    setError(null);1017    setWorking(true);1018    setAnimating("chaos");10191020    const canvas = canvasRef;1021    if (canvas) startChaos(canvas);10221023    try {1024      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));1025      const payload = await parsePayload(bytes, backupPass());10261027      if (!props.hasVault) {1028        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });1029        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1030      }10311032      const rr = await sendMessage({1033        type: "RESTORE_ENTRIES",1034        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },1035      });1036      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");10371038      if (payload.license?.key) {1039        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});1040      }10411042      setAnimating("snap");1043      stopAnim();1044      if (canvas) {1045        snapToGrid(canvas, () => {1046          setAnimating("bloom");1047          bloom(canvas, async () => {1048            setAnimating(null);1049            await props.onSuccess();1050          });1051        });1052      } else {1053        await props.onSuccess();1054      }1055    } catch (err) {1056      stopAnim();1057      setAnimating(null);1058      const code = err instanceof Error ? err.message : String(err);1059      setError(1060        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"1061          ? "That passphrase didn't work."1062          : code === "CREATE_VAULT_FAILED"1063            ? "Couldn't create a new local vault."1064            : "Could not read this backup.",1065      );1066    } finally {1067      setWorking(false);1068    }1069  }10701071  return (1072    <div1073      class="peach-restore-form"1074      classList={{ "is-dragover": dragDepth() > 0 }}1075      onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1076      onDragOver={(e) => e.preventDefault()}1077      onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1078      onDrop={(e) => void handleDrop(e)}1079    >1080      <Show when={animating() !== null}>1081        <canvas1082          ref={(el) => { canvasRef = el; }}1083          class="peach-restore-anim-canvas"1084          width={280}1085          height={200}1086        />1087      </Show>1088      <Show when={animating() === null}>1089        <textarea1090          class="peach-restore-script"1091          placeholder="paste peachscript…"1092          rows={3}1093          aria-label="Paste your PeachScript backup"1094          value={scriptInput()}1095          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}1096        />1097        <div class="peach-restore-or" aria-hidden="true">or</div>1098        <label1099          class="peach-btn peach-btn--ghost peach-btn--sm peach-restore-file-btn"1100          onClick={(e) => {1101            // In the detached window the dialog can open directly — let the1102            // label's real user gesture activate the input. In the action1103            // popup we must detach first (the dialog would dismiss the popup).1104            if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) return;1105            e.preventDefault();1106            void detachForFilePicker(RESTORE_DEEP_LINKS.symbols).then((detached) => {1107              if (!detached) peachFileRef?.click();1108            });1109          }}1110        >1111          open .peach file1112          <input1113            ref={(el) => { peachFileRef = el; }}1114            type="file"1115            accept=".peach,text/plain"1116            style="display:none"1117            onChange={async (e) => {1118              const file = (e.currentTarget as HTMLInputElement).files?.[0];1119              if (!file) return;1120              try {1121                setScriptInput(await file.text());1122              } catch {1123                setError("Couldn't read file.");1124              } finally {1125                (e.currentTarget as HTMLInputElement).value = "";1126              }1127            }}1128          />1129        </label>1130        <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1131        <div class="peach-newvault-field">1132          <label class="peach-label" for="restore-backup-pass">1133            backup password1134          </label>1135          <input1136            id="restore-backup-pass"1137            class="peach-underline-input peach-newvault-input"1138            type="password"1139            placeholder="••••••••••••"1140            autocomplete="current-password"1141            value={backupPass()}1142            onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}1143            onKeyDown={(e) => {1144              if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {1145                e.preventDefault();1146                void handleRestore();1147              }1148            }}1149          />1150        </div>1151        <button1152          class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"1153          onClick={() => void handleRestore()}1154          disabled={!scriptValid() || !backupPass().trim() || working()}1155        >1156          restore vault1157        </button>1158        <Show when={error()}>1159          <p class="peach-hint peach-hint-error">{error()}</p>1160        </Show>1161      </Show>1162    </div>1163  );1164}11651166// ── CSV source ─────────────────────────────────────────────────────────────11671168function RestoreCSV(props: {1169  hasVault: boolean;1170  onSuccess: () => void | Promise<void>;1171}) {1172  const [importing, setImporting] = createSignal(false);1173  const [error, setError] = createSignal<string | null>(null);1174  // CSV/JSON imports are plaintext and carry no password, so when there is no1175  // vault yet the user MUST choose a master password here — otherwise the1176  // imported credentials would be encrypted under an empty key and offer zero1177  // at-rest protection (audit HIGH). Mirrors the onboarding MIN_LENGTH policy.1178  const CSV_MIN_PASSWORD_LENGTH = 12;1179  const [newPass, setNewPass] = createSignal("");1180  const [confirmPass, setConfirmPass] = createSignal("");1181  const needsPassword = () => props.hasVault === false;1182  const passwordReady = () =>1183    !needsPassword() ||1184    (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());1185  // The action popup dies when the OS file picker steals focus — dropping the1186  // file is the import path that survives it (see RestoreSymbols).1187  const [dragDepth, setDragDepth] = createSignal(0);11881189  // Every route here (label click, drop, the disabled-attribute-guarded1190  // input change) passes gatePassword first, so the file is always accepted1191  // under a real master password — never an empty key.1192  async function processFile(file: File) {1193    setImporting(true);1194    setError(null);1195    try {1196      const text = await file.text();1197      const parsed = await parseImportSource(text);1198      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");11991200      if (props.hasVault === false) {1201        // Create the vault under the user-chosen master password — never an1202        // empty one (an empty key derives a publicly-known vault key).1203        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });1204        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1205      }12061207      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1208      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1209      if (parsed.licenseKey) {1210        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});1211      }1212      await props.onSuccess();1213    } catch (err) {1214      const code = err instanceof Error ? err.message : String(err);1215      setError(1216        code === "NO_ENTRIES" ? "No entries found in this file." :1217        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1218        "Import failed. Check the file format and try again."1219      );1220    } finally {1221      setImporting(false);1222    }1223  }12241225  let csvFileRef: HTMLInputElement | undefined;12261227  onMount(() => {1228    // A fresh vault needs its master password before a file can be accepted —1229    // auto-picking would only drop the chosen file at the password gate, so1230    // the dialog waits for the explicit click in that case.1231    if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv) && !needsPassword()) {1232      setTimeout(() => csvFileRef?.click(), 50);1233    }1234  });12351236  function gatePassword(): boolean {1237    if (importing()) return false;1238    if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {1239      setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);1240      return false;1241    }1242    if (needsPassword() && newPass() !== confirmPass()) {1243      setError("Passwords do not match.");1244      return false;1245    }1246    return true;1247  }12481249  async function handleFile(e: Event) {1250    const input = e.currentTarget as HTMLInputElement;1251    const file = input.files?.[0];1252    input.value = "";1253    if (!file) return;1254    await processFile(file);1255  }12561257  async function handleDrop(e: DragEvent) {1258    e.preventDefault();1259    setDragDepth(0);1260    if (!gatePassword()) return;1261    const file = e.dataTransfer?.files?.[0];1262    if (!file) return;1263    await processFile(file);1264  }12651266  return (1267    <div1268      class="peach-restore-form"1269      classList={{ "is-dragover": dragDepth() > 0 }}1270      onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1271      onDragOver={(e) => e.preventDefault()}1272      onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1273      onDrop={(e) => void handleDrop(e)}1274    >1275      <Show when={needsPassword()}>1276        <div class="peach-newvault-field">1277          <label class="peach-label" for="restore-csv-pass">1278            new master password1279          </label>1280          <input1281            id="restore-csv-pass"1282            class="peach-underline-input peach-newvault-input"1283            type="password"1284            placeholder="••••••••••••"1285            autocomplete="new-password"1286            value={newPass()}1287            onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1288          />1289        </div>1290        <div class="peach-newvault-field">1291          <label class="peach-label" for="restore-csv-confirm">1292            confirm master password1293          </label>1294          <input1295            id="restore-csv-confirm"1296            class="peach-underline-input peach-newvault-input"1297            type="password"1298            placeholder="••••••••••••"1299            autocomplete="new-password"1300            value={confirmPass()}1301            onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1302          />1303        </div>1304      </Show>1305      <label1306        class="peach-btn peach-btn--accent peach-btn--md peach-restore-file-btn"1307        onClick={(e) => {1308          // In the detached window the dialog can open directly — let the1309          // label's real user gesture activate the input. In the action1310          // popup we must detach first (the dialog would dismiss the popup).1311          if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv)) return;1312          // The label would forward the click to the input even before the1313          // password gate passes; own the activation so the gate can speak.1314          e.preventDefault();1315          if (!gatePassword()) return;1316          void detachForFilePicker(RESTORE_DEEP_LINKS.csv).then((detached) => {1317            if (!detached) csvFileRef?.click();1318          });1319        }}1320      >1321        {importing() ? "importing…" : "open csv file"}1322        <input1323          ref={(el) => { csvFileRef = el; }}1324          type="file"1325          accept=".csv,.json,.peach,application/x-peach"1326          style="display:none"1327          onChange={handleFile}1328          disabled={importing() || !passwordReady()}1329        />1330      </label>1331      <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1332      <p class="peach-hint peach-restore-csv-sources">1333        Imports from Bitwarden, 1Password, Proton Pass,1334        <br />1335        Chrome, Safari, and Edge.1336      </p>1337      <p class="peach-newvault-warning">1338        <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>1339        <span>Plain-text file — delete it after the import.</span>1340      </p>1341      <Show when={error()}>1342        <p class="peach-hint peach-hint-error">{error()}</p>1343      </Show>1344    </div>1345  );1346}13471348// ── Helpers ────────────────────────────────────────────────────────────────13491350function getDisplayError(error: string | undefined, fallback: string): string {1351  if (!error) return fallback;1352  if (1353    error === "SERVICE_WORKER_UNAVAILABLE" ||1354    error.includes("Receiving end does not exist") ||1355    error.includes("message port closed")1356  ) {1357    return "background service unavailable";1358  }1359  return error;1360}