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 { storageGet, storageRemove, storageSet } from "../../shared/storage";8import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";9import { parsePayload } from "../../crypto/peachPayload";10import { parseImportSource } from "../../shared/import";11import { isNativePlatform, nativePlugin } from "../../shared/native";1213const peachMarkImg = new Image();14peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(15  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" fill="none">' +16  '<path d="M 124 64 C 134 32 168 16 200 22 C 198 56 168 72 130 66 Z" fill="#92AC65"/>' +17  '<path d="M 130 64 C 152 46 178 30 196 24" stroke="#5E7A42" stroke-width="2.6" fill="none" stroke-linecap="round"/>' +18  '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 185 222 222 185 220 130 C 218 78 170 45 120 60 Z" fill="#FFB082"/>' +19  '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 105 195 75 150 100 90 C 105 75 112 65 120 60 Z" fill="#E5805C"/>' +20  '<ellipse cx="178" cy="108" rx="20" ry="11" fill="#FFD0A8" opacity="0.7" transform="rotate(-26 178 108)"/>' +21  '</svg>'22)}`;2324// Lazy-load @zxcvbn-ts — heavy (dictionaries ~700 kB) and only needed during25// vault creation. Replaces the unmaintained [email protected] (LV-8); result.score26// keeps the same 0-4 scale so downstream UI is unchanged.27let _zxcvbn: ((password: string) => { score: number }) | null = null;28async function getZxcvbn() {29  if (_zxcvbn) return _zxcvbn;30  const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([31    import("@zxcvbn-ts/core"),32    import("@zxcvbn-ts/language-common"),33    import("@zxcvbn-ts/language-en"),34  ]);35  zxcvbnOptions.setOptions({36    translations: en.translations,37    graphs: common.adjacencyGraphs,38    dictionary: { ...common.dictionary, ...en.dictionary },39  });40  _zxcvbn = (password: string) => zxcvbn(password);41  return _zxcvbn;42}4344interface LockProps {45  hasVault: boolean;46  ready: boolean;47  isPro?: boolean;48  /** When true, render the recovery-words display directly instead of the49   *  unlock UI. Used after a popup close+reopen during the create-vault50   *  recovery confirmation flow (peach_recovery_pending flag). */51  forceRecoveryWords?: boolean;52  onUnlock: () => void | Promise<void>;53  /** Optional escape hatch: rewind from the create-vault screen back to54   *  Onboarding. Only meaningful when the user reached create-vault from55   *  Onboarding in the first place. */56  onBackToOnboarding?: () => void;57  /** Optional: open the Peach Codex restore flow (camera scan of printed58    *  Codex pages). Surfaced as a small footer link on the new-vault screen. */59  onOpenCodexRestore?: () => void;60  /** Open directly on backup restore when onboarding routed the user there. */61  initialRestore?: boolean;62  /** Resume the restore surface on the source the user had picked. Set from63    *  RESTORE_SOURCE_STORAGE_KEY at boot — a popup close (the OS file picker64    *  kills it) must land back on the same source form, not the picker. */65  initialRestoreSource?: RestoreSource;66}6768/** The restore source picked before a popup close, so boot can resume the69 *  same source form instead of rewinding to the source picker. Written by70 *  RestoreView, read/cleared by App. */71export const RESTORE_SOURCE_STORAGE_KEY = "peach_onboarding_restore_source";7273type RestoreSurface = "restore" | null;74type RecoverSurface = "words" | "new-password" | null;7576export function LockView(props: LockProps) {77  const [password, setPassword] = createSignal("");78  const [confirmPassword, setConfirmPassword] = createSignal("");79  const [error, setError] = createSignal("");80  const [loading, setLoading] = createSignal(false);81  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(82    props.initialRestore ? "restore" : null,83  );84  const [showPassword, setShowPassword] = createSignal(false);85  const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);86  // After vault creation succeeds, set this to show the recovery words screen.87  // Only meaningful when hasVault === false.88  const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);8990  const MIN_LENGTH = 12;91  const MIN_ZXCVBN_SCORE = 2;9293  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);94  const [biometricAvailable, setBiometricAvailable] = createSignal(false);95  const [biometricBusy, setBiometricBusy] = createSignal(false);96  const [pinAvailable, setPinAvailable] = createSignal(false);97  const [usingPin, setUsingPin] = createSignal(false);98  const [pin, setPin] = createSignal("");99100  const canCreate = createMemo(101    () =>102      password().length >= MIN_LENGTH &&103      confirmPassword().length >= MIN_LENGTH &&104      password() === confirmPassword() &&105      strengthScore() !== null &&106      strengthScore()! >= MIN_ZXCVBN_SCORE107  );108109  const passwordStrength = createMemo(() => {110    const score = strengthScore();111    if (score === null) return null;112    if (score <= 1) return "weak";113    if (score === 2) return "fair";114    if (score === 3) return "good";115    return "strong";116  });117118  const createBlockerMessage = createMemo(() => {119    if (!password()) return null;120    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;121    if (!confirmPassword()) return "confirm your password";122    if (password() !== confirmPassword()) return "passwords don't match";123    if (strengthScore() === null) return "checking strength…";124    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — use a longer passphrase";125    return null;126  });127128  // Update strength asynchronously when password changes129  createMemo(() => {130    const value = password();131    if (!value) { setStrengthScore(null); return; }132    getZxcvbn().then((zxcvbn) => {133      if (password() === value) setStrengthScore(zxcvbn(value).score);134    }).catch(() => {135      // zxcvbn unavailable — fall back to length-only heuristic136      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);137    });138  });139140  async function handleUnlock() {141    if (!password().trim()) return;142    setLoading(true);143    setError("");144    const response = await sendMessage({145      type: "UNLOCK",146      payload: { password: password() },147    });148    const result = response as { ok: boolean; error?: string };149    if (result.ok) {150      await props.onUnlock();151      setLoading(false);152      return;153    }154    setError(getDisplayError(result.error, "incorrect password"));155    setPassword("");156    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;157    passwordField?.classList.add("shake");158    setTimeout(() => passwordField?.classList.remove("shake"), 300);159    setLoading(false);160  }161162  async function handlePinUnlock() {163    if (!/^\d{4,12}$/.test(pin())) return;164    setLoading(true);165    setError("");166    const response = await sendMessage({167      type: "UNLOCK_WITH_PIN",168      payload: { pin: pin() },169    });170    const result = response as { ok: boolean; error?: string };171    if (result.ok) {172      await props.onUnlock();173      setLoading(false);174      return;175    }176    setError(getDisplayError(result.error, "incorrect PIN"));177    setPin("");178    const pinField = document.querySelector(".lock-pin-input") as HTMLInputElement | undefined;179    pinField?.classList.add("shake");180    setTimeout(() => pinField?.classList.remove("shake"), 300);181    setLoading(false);182  }183184  async function checkPinAvailable(): Promise<void> {185    if (!props.hasVault) return;186    try {187      const response = await sendMessage({ type: "GET_SETTINGS" });188      const settings = (response as { ok: boolean; data?: { pinEnabled?: boolean; pinPreferred?: boolean } }).data;189      const enabled = settings?.pinEnabled === true;190      setPinAvailable(enabled);191      if (enabled && settings?.pinPreferred === true) setUsingPin(true);192    } catch { /* settings are optional at the lock screen */ }193  }194195  async function checkBiometricAvailable(): Promise<void> {196    if (!props.hasVault || !isNativePlatform()) return;197    const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");198    const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");199    if (!keystore || !biometric) return;200    try {201      const stored = await keystore.hasMasterKey();202      if (!stored.exists) return;203      const avail = await biometric.isAvailable();204      if (!avail.available) return;205      setBiometricAvailable(true);206      window.setTimeout(() => { void handleBiometricUnlock(); }, 400);207    } catch { /* missing plugins or runtime error — silently skip */ }208  }209210  async function handleBiometricUnlock(): Promise<void> {211    if (biometricBusy()) return;212    const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");213    const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");214    if (!biometric || !keystore) return;215    setBiometricBusy(true);216    setError("");217    try {218      await biometric.authenticate({219        title: "Unlock Peach",220        subtitle: "Use biometrics to access your vault",221        negativeButtonText: "Use Password",222      });223      const { masterKeyHex } = await keystore.retrieveMasterKey();224      const res = (await sendMessage({225        type: "UNLOCK_WITH_KEY",226        payload: { masterKeyHex },227      })) as { ok: boolean; error?: string };228      if (res.ok) {229        await props.onUnlock();230        return;231      }232      if (res.error === "biometric_key_invalidated") {233        await sendMessage({234          type: "UPDATE_SETTINGS",235          payload: { biometricsEnabled: false },236        });237        setBiometricAvailable(false);238        setError("biometric key expired — re-enable in settings");239      } else {240        setError("biometric unlock failed");241      }242    } catch (e) {243      const reason = e instanceof Error ? e.message : String(e);244      if (reason.includes("user_cancelled")) return;245      if (reason.includes("biometric_key_invalidated")) {246        await sendMessage({247          type: "UPDATE_SETTINGS",248          payload: { biometricsEnabled: false },249        });250        setBiometricAvailable(false);251        setError("biometric key expired — re-enable in settings");252        return;253      }254      if (reason.includes("biometric_lockout")) {255        setError("biometric locked out — use your password");256      }257    } finally {258      setBiometricBusy(false);259    }260  }261262  onMount(() => {263    void checkBiometricAvailable();264    void checkPinAvailable();265  });266267  async function handleCreate() {268    if (!password().trim()) return;269    if (password().length < MIN_LENGTH) {270      setError(`password must be at least ${MIN_LENGTH} characters`);271      return;272    }273    if (password() !== confirmPassword()) {274      setError("passwords do not match");275      return;276    }277    const score = strengthScore();278    if (score === null || score < MIN_ZXCVBN_SCORE) {279      setError("password is too weak — use a longer passphrase");280      return;281    }282    setLoading(true);283    setError("");284    const response = await sendMessage({285      type: "CREATE_VAULT",286      payload: { password: password() },287    });288    const result = response as { ok: boolean; error?: string };289    if (result.ok) {290      // Don't unlock yet — show the recovery words screen first. Mark the291      // confirmation as pending in chrome.storage.local so it survives both292      // popup close (lock-on-close skips while set) and full browser restart293      // (next unlock re-routes to the recovery surface). Cleared when the294      // user types the 3 confirmation words successfully.295      try {296        await chrome.storage.local?.set({ peach_recovery_pending: "1" });297      } catch { /* best-effort */ }298      setShowRecoveryWords(true);299      setLoading(false);300      return;301    }302    setError(getDisplayError(result.error, "failed to create vault"));303    setLoading(false);304  }305306  // ── All surfaces routed reactively via Switch/Match ───────────────────307  return (308    <Switch>309      {/* ── Restore surface ───────────────────────────────────────── */}310      <Match when={restoreSurface() === "restore"}>311        <RestoreView312          hasVault={props.hasVault}313          onOpenCodexRestore={props.onOpenCodexRestore}314          initialSource={props.initialRestoreSource}315          onBack={() => {316            // The restore tab is only reached from the onboarding carousel,317            // so back returns there — not to the create-vault form.318            if (props.onBackToOnboarding) props.onBackToOnboarding();319            else setRestoreSurface(null);320          }}321          onUnlock={async () => {322            setRestoreSurface(null);323            await props.onUnlock();324          }}325        />326      </Match>327328      {/* ── Recovery words display after fresh vault creation ────────── */}329      {/* Read props.forceRecoveryWords directly, not just the boot-time330          signal copy: when the vault is locked with a pending confirmation,331          App flips the flag after UNLOCK without remounting LockView. */}332      <Match333        when={334          (showRecoveryWords() || props.forceRecoveryWords) &&335          (!props.hasVault || props.forceRecoveryWords)336        }337      >338        <RecoveryWordDisplayView339          onDone={props.onUnlock}340        />341      </Match>342343      {/* ── Recovery word entry ─────────────────────────────────────── */}344      <Match when={recoverSurface() === "words"}>345        <RecoveryWordEntryView346          onCancel={() => setRecoverSurface(null)}347          onRecoveryComplete={props.onUnlock}348        />349      </Match>350351      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}352      <Match when={props.hasVault}>353        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">354          <RuneBackground opacity={0.07} />355356          <header class="peach-screen-header lock-screen-header">357            <div class="peach-wordmark-stack">358              <PeachWordmark class="peach-wordmark-wrap" />359              <Show when={props.isPro}>360                <span class="peach-pro-mini-badge">pro</span>361              </Show>362            </div>363          </header>364365          <div class="peach-lock-hero peach-lock-hero--unlock">366            <PeachMark class="peach-lock-mark" />367            <span class="peach-lock-eyebrow">vault locked</span>368            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">369              unlock370              <br />371              your vault.372            </h1>373          </div>374375          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">376            <Show when={usingPin()} fallback={377              <>378                <label class="peach-label" for="unlock-password">379                  master password380                </label>381                <input382                  id="unlock-password"383                  class="peach-underline-input peach-unlock-input lock-password-input"384                  type="password"385                  placeholder="••••••••••••"386                  autocomplete="current-password"387                  value={password()}388                  disabled={!props.ready}389                  ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}390                  onInput={(event) => setPassword((event.target as HTMLInputElement).value)}391                  onKeyDown={(event) => {392                    if (event.key !== "Enter" || !props.ready) return;393                    void handleUnlock();394                  }}395                />396              </>397            }>398              <label class="peach-label" for="unlock-pin">399                PIN400              </label>401              <input402                id="unlock-pin"403                class="peach-underline-input peach-unlock-input lock-pin-input"404                type="password"405                inputmode="numeric"406                maxlength="12"407                placeholder="••••••••••••"408                autocomplete="off"409                value={pin()}410                disabled={!props.ready}411                ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}412                onInput={(event) => setPin((event.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}413                onKeyDown={(event) => {414                  if (event.key !== "Enter" || !props.ready) return;415                  void handlePinUnlock();416                }}417              />418              <button419                class="peach-lock-recover-link"420                type="button"421                onClick={() => { setUsingPin(false); setError(""); }}422              >423                use master password424              </button>425            </Show>426427            <Show when={error()}>428              <p class="peach-hint peach-hint-error">{error()}</p>429            </Show>430431            <button432              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"433              onClick={() => void (usingPin() ? handlePinUnlock() : handleUnlock())}434              disabled={!props.ready || loading()}435            >436              {loading() ? "unlocking..." : usingPin() ? "unlock with PIN" : "unlock"}437            </button>438439            <Show when={!usingPin() && pinAvailable()}>440              <button441                class="peach-lock-recover-link"442                type="button"443                onClick={() => { setUsingPin(true); setError(""); }}444              >445                use PIN446              </button>447            </Show>448449            <Show when={biometricAvailable()}>450              <button451                class="peach-biometric-btn"452                type="button"453                onClick={() => void handleBiometricUnlock()}454                disabled={biometricBusy() || loading()}455                aria-label="Unlock with biometrics"456              >457                <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">458                  <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>459                  <path d="M15 17v-3"/>460                  <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>461                  <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>462                  <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>463                  <path d="M5 15c0-1.7.3-3.3.8-4.7"/>464                  <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>465                </svg>466                {biometricBusy() ? "verifying…" : "use biometrics"}467              </button>468            </Show>469470            <button471              class="peach-lock-recover-link"472              type="button"473              onClick={() => setRecoverSurface("words")}474            >475              forgot your password?476            </button>477          </section>478        </section>479      </Match>480481      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}482      <Match when={true}>483        <section484          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"485          data-motion-phase="enter"486        >487          <header class="peach-screen-header peach-newvault-header">488            <div class="peach-wordmark-stack">489              <PeachWordmark class="peach-wordmark-wrap" />490              <Show when={props.isPro}>491                <span class="peach-pro-mini-badge">pro</span>492              </Show>493            </div>494            <Show when={props.onBackToOnboarding}>495              <button496                class="peach-newvault-back"497                type="button"498                onClick={() => props.onBackToOnboarding?.()}499                aria-label="Back to onboarding"500              >501                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">502                  <path d="M7 2L3 5.5L7 9" />503                </svg>504                back505              </button>506            </Show>507          </header>508509          <div class="peach-newvault-mark" aria-hidden="true">510            <PeachMark class="peach-newvault-mark-art" />511          </div>512513          <div class="peach-newvault-card">514            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>515            <hr class="peach-newvault-divider" />516517            <div class="peach-newvault-field">518              <div class="peach-newvault-label-row">519                <label class="peach-label" for="master-password">520                  master password521                </label>522                <button523                  class="peach-lock-reveal-btn"524                  type="button"525                  onClick={() => setShowPassword((value) => !value)}526                  aria-label={showPassword() ? "Hide password" : "Show password"}527                >528                  <Show529                    when={showPassword()}530                    fallback={531                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">532                        <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" />533                        <circle cx="7" cy="5" r="1.75" />534                      </svg>535                    }536                  >537                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">538                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />539                      <path d="M3.5 5.5L2.5 7.5" />540                      <path d="M7 6.5V8.5" />541                      <path d="M10.5 5.5L11.5 7.5" />542                    </svg>543                  </Show>544                  {showPassword() ? "hide" : "show"}545                </button>546              </div>547              <input548                id="master-password"549                class="peach-underline-input peach-newvault-input lock-password-input"550                type={showPassword() ? "text" : "password"}551                placeholder="••••••••••••"552                autocomplete="new-password"553                value={password()}554                disabled={!props.ready}555                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}556                onKeyDown={(event) => {557                  if (event.key !== "Enter" || !props.ready) return;558                  void handleCreate();559                }}560              />561              <Show562                when={password().length > 0 && passwordStrength() !== null}563                fallback={564                  <p class="peach-newvault-hint">565                    At least 12 characters.566                  </p>567                }568              >569                <div class="peach-password-strength">570                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>571                    <div class="peach-strength-fill"></div>572                  </div>573                  <span class="peach-strength-label">{passwordStrength()}</span>574                </div>575              </Show>576            </div>577578            <div class="peach-newvault-field">579              <label class="peach-label" for="confirm-password">580                confirm password581              </label>582              <input583                id="confirm-password"584                class="peach-underline-input peach-newvault-input"585                type={showPassword() ? "text" : "password"}586                placeholder="••••••••••••"587                autocomplete="new-password"588                value={confirmPassword()}589                disabled={!props.ready}590                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}591                onKeyDown={(event) => {592                  if (event.key === "Enter" && props.ready) void handleCreate();593                }}594              />595            </div>596597            <Show when={error()}>598              <p class="peach-hint peach-hint-error">{error()}</p>599            </Show>600601            <button602              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"603              onClick={() => void handleCreate()}604              disabled={!props.ready || loading() || !canCreate()}605              aria-live="polite"606            >607              <Show608                when={!canCreate() && createBlockerMessage()}609                fallback={loading() ? "creating..." : "create vault"}610              >611                {createBlockerMessage()}612              </Show>613            </button>614615            <p class="peach-newvault-warning">616              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>617              <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>618            </p>619          </div>620        </section>621      </Match>622    </Switch>623  );624}625626// ── Restore surface ────────────────────────────────────────────────────────627628export type RestoreSource = "symbols" | "codex" | "csv";629630const RESTORE_SOURCES: { id: RestoreSource; name: string; detail: string }[] = [631  { id: "symbols", name: "PeachScript", detail: "Paste the rune string, or open a .peach file." },632  { id: "codex", name: "Codex", detail: "Scan printed pages, or import the PDF." },633  { id: "csv", name: "CSV", detail: "A .csv or .json file from another password manager." },634];635636const RESTORE_DEEP_LINKS: Record<"symbols" | "csv", "peachscript" | "csv"> = {637  symbols: "peachscript",638  csv: "csv",639};640641// The Chrome action popup dismisses itself the moment the OS file picker642// steals focus, so a picker opened from it never delivers its file. From the643// action popup we reopen this surface in a standalone window and let that644// window own the dialog — same trick as CodexRestoreView.openPdfPicker().645// The window is created by the service worker: a window created by the popup646// itself is cascade-closed by Chrome when the popup dismisses. App.tsx routes647// the deep link back here (?restore=peachscript|csv) at boot, before first648// render, so the window mounts straight on the right source.649async function detachForFilePicker(route: "peachscript" | "csv"): Promise<boolean> {650  try {651    // Already inside a detached window — it can host the dialog directly.652    if (new URLSearchParams(window.location.search).get("restore")) return false;653  } catch { /* no location — fall through to the capability check */ }654  if (typeof chrome === "undefined" || typeof chrome.runtime?.sendMessage !== "function") {655    return false;656  }657  try {658      const res = (await sendMessage({659        type: "OPEN_DETACHED_POPUP",660        payload: { route },661      })) as { ok: boolean; data?: { detached?: boolean } };662      if (!res?.data?.detached) return false;663      // The action popup dismisses itself when the new window steals focus —664      // calling window.close() here cascade-closes the detached child too.665      return true;666  } catch {667    return false;668  }669}670671/** True when this window was opened via ?restore=<link> specifically to own672 *  the file dialog — mirrors CodexRestoreView's autoPickPdf detection. */673function restoreDeepLinkActive(link: "peachscript" | "csv"): boolean {674  try {675    return new URLSearchParams(window.location.search).get("restore") === link;676  } catch {677    return false;678  }679}680681function RestoreView(props: {682  hasVault: boolean;683  onOpenCodexRestore?: () => void;684  onBack: () => void;685  onUnlock: () => void | Promise<void>;686  initialSource?: RestoreSource;687}) {688  const [source, setSource] = createSignal<RestoreSource | null>(props.initialSource ?? null);689  const [showIntro, setShowIntro] = createSignal(false);690  const [pendingUnlock] = createSignal(false);691692  // The action popup dies the moment the OS file picker steals focus, so a693  // mid-restore close must resume the same source form — rewinding to the694  // picker reads as losing progress. The resume marker mirrors the picked695  // source; App clears it with the rest of the onboarding-exit state.696  function chooseSource(next: RestoreSource | null) {697    setSource(next);698    if (next) void storageSet(RESTORE_SOURCE_STORAGE_KEY, next).catch(() => {});699    else void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});700  }701702  async function handleRestoreSuccess() {703    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);704    if (!seen) {705      setShowIntro(true);706    } else {707      await props.onUnlock();708    }709  }710711  async function dismissIntro() {712    await storageSet("peach_shown_restore_intro", "true").catch(() => {});713    setShowIntro(false);714    await props.onUnlock();715  }716717  return (718    <section719      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault peach-restore"720      data-motion-phase="enter"721    >722      <RuneBackground opacity={0.05} />723724      <header class="peach-screen-header peach-newvault-header">725        <PeachWordmark class="peach-wordmark-wrap" />726        <button727          class="peach-newvault-back peach-restore-back"728          type="button"729          onClick={props.onBack}730          aria-label="Back to onboarding"731        >732          <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">733            <path d="M7 2L3 5.5L7 9" />734          </svg>735          back736        </button>737      </header>738739      <div class="peach-newvault-card peach-restore-card">740        <Show741          when={source()}742          keyed743          fallback={744            <>745              <h1 class="peach-newvault-headline">{"welcome\nback."}</h1>746              <hr class="peach-newvault-divider" />747              <div class="peach-restore-sources">748                <For each={RESTORE_SOURCES}>749                  {(entry) => (750                    <button751                      type="button"752                      class="peach-onboarding-choice peach-restore-source"753                      onClick={() => chooseSource(entry.id)}754                    >755                      <span class="peach-onboarding-choice-label">{entry.name}</span>756                      <span class="peach-onboarding-choice-detail">{entry.detail}</span>757                    </button>758                  )}759                </For>760              </div>761            </>762          }763        >764          {(active) => (765            <>766              <div class="peach-restore-source-head">767                <h2 class="peach-restore-source-title">768                  {RESTORE_SOURCES.find((entry) => entry.id === active)?.name}769                </h2>770                <button771                  type="button"772                  class="peach-restore-picker-back"773                  onClick={() => chooseSource(null)}774                >775                  ‹ sources776                </button>777              </div>778              <hr class="peach-newvault-divider" />779              <Show when={active === "symbols"}>780                <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />781              </Show>782              <Show when={active === "codex"}>783                <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />784              </Show>785              <Show when={active === "csv"}>786                <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />787              </Show>788            </>789          )}790        </Show>791      </div>792793      {/* One-time intro popup after first restore */}794      <Show when={showIntro()}>795        <div class="peach-restore-intro-overlay">796          <div class="peach-restore-intro-card">797            <p class="peach-restore-intro-headline">welcome to peach.</p>798            <div class="peach-restore-intro-body">799              <p>Your vault lives only on this device. No server has a copy.</p>800              <p>The inline bubble fills passwords automatically when you visit sites.</p>801              <p>Export your vault from Settings before switching devices.</p>802            </div>803            <button804              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"805              onClick={() => void dismissIntro()}806              disabled={pendingUnlock()}807            >808              got it809            </button>810          </div>811        </div>812      </Show>813    </section>814  );815}816817// ── Peach Codex source ─────────────────────────────────────────────────────818819function RestoreCodex(props: { onOpen: () => void }) {820  return (821    <div class="peach-restore-form">822      <p class="peach-restore-codex-copy">823        Scan printed Codex pages or import the Codex PDF. Codex restore is824        always available, even before you create a vault.825      </p>826      <button827        type="button"828        class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"829        onClick={props.onOpen}830      >831        open Codex restore832      </button>833    </div>834  );835}836837// ── PeachScript source ─────────────────────────────────────────────────────838839function RestoreSymbols(props: {840  hasVault: boolean;841  onSuccess: () => void | Promise<void>;842}) {843  const [scriptInput, setScriptInput] = createSignal("");844  const [backupPass, setBackupPass] = createSignal("");845  const [working, setWorking] = createSignal(false);846  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);847  const [error, setError] = createSignal<string | null>(null);848  let canvasRef: HTMLCanvasElement | undefined;849  let peachFileRef: HTMLInputElement | undefined;850  let animFrameId = 0;851  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];852853  const scriptValid = createMemo(() => isPeachScript(scriptInput()));854855  function stopAnim() {856    cancelAnimationFrame(animFrameId);857    animFrameId = 0;858  }859860  function startChaos(canvas: HTMLCanvasElement) {861    const ctx = canvas.getContext("2d")!;862    const W = canvas.width;863    const H = canvas.height;864    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>865      String.fromCodePoint(0xe000 + i)866    );867    if (runes.length === 0) {868      runes = Array.from({ length: 60 }, () => ({869        x: Math.random() * W,870        y: Math.random() * H,871        vx: (Math.random() - 0.5) * 3,872        vy: (Math.random() - 0.5) * 3,873        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,874        size: 14 + Math.random() * 20,875        angle: Math.random() * Math.PI * 2,876        va: (Math.random() - 0.5) * 0.1,877      }));878    }879880    function draw() {881      ctx.clearRect(0, 0, W, H);882      for (const r of runes) {883        r.x = (r.x + r.vx + W) % W;884        r.y = (r.y + r.vy + H) % H;885        r.angle += r.va;886        ctx.save();887        ctx.translate(r.x, r.y);888        ctx.rotate(r.angle);889        ctx.font = `${r.size}px PeachScript, monospace`;890        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);891        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;892        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";893        ctx.shadowBlur = 8;894        ctx.fillText(r.char, 0, 0);895        ctx.restore();896      }897      animFrameId = requestAnimationFrame(draw);898    }899    draw();900  }901902  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {903    stopAnim();904    const ctx = canvas.getContext("2d")!;905    const W = canvas.width;906    const H = canvas.height;907    const cols = 8;908    const rows = 6;909    const cellW = W / cols;910    const cellH = H / rows;911    const targets = runes.slice(0, cols * rows).map((_, i) => ({912      x: (i % cols) * cellW + cellW / 2,913      y: Math.floor(i / cols) * cellH + cellH / 2,914    }));915    const start = performance.now();916    const DURATION = 400;917918    function drawSnap(now: number) {919      const t = Math.min((now - start) / DURATION, 1);920      const ease = t < 1 ? 1 - (1 - t) ** 3 : 1;921      ctx.clearRect(0, 0, W, H);922      runes.slice(0, targets.length).forEach((r, i) => {923        const tx = targets[i]!.x;924        const ty = targets[i]!.y;925        const cx = r.x + (tx - r.x) * ease;926        const cy = r.y + (ty - r.y) * ease;927        ctx.font = `16px PeachScript, monospace`;928        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;929        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";930        ctx.shadowBlur = 6;931        ctx.fillText(r.char, cx - 8, cy + 8);932      });933      if (t < 1) {934        animFrameId = requestAnimationFrame(drawSnap);935      } else {936        onDone();937      }938    }939    animFrameId = requestAnimationFrame(drawSnap);940  }941942  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {943    stopAnim();944    const ctx = canvas.getContext("2d")!;945    const W = canvas.width;946    const H = canvas.height;947    const start = performance.now();948    const DURATION = 900;949950    function drawBloom(now: number) {951      const t = Math.min((now - start) / DURATION, 1);952      ctx.clearRect(0, 0, W, H);953      // Runes dissolve954      const runeAlpha = 1 - Math.min(t / 0.4, 1);955      if (runeAlpha > 0) {956        for (const r of runes.slice(0, 48)) {957          ctx.font = `16px PeachScript, monospace`;958          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;959          ctx.fillText(r.char, r.x - 8, r.y + 8);960        }961      }962      // Peach mark bloom963      const markT = Math.max(0, (t - 0.2) / 0.5);964      const markScale = Math.min(markT * 1.2, 1);965      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;966      if (markAlpha > 0) {967        ctx.save();968        ctx.globalAlpha = markAlpha;969        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;970        ctx.shadowBlur = 30;971        ctx.translate(W / 2, H / 2);972        ctx.scale(markScale, markScale);973        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);974        ctx.restore();975      }976      if (t < 1) {977        animFrameId = requestAnimationFrame(drawBloom);978      } else {979        onDone();980      }981    }982    animFrameId = requestAnimationFrame(drawBloom);983  }984985  onCleanup(() => stopAnim());986987  // This window was opened specifically to own the file dialog — open it as988  // soon as the input exists. Mirrors the ?codex=pdf auto-pick.989  onMount(() => {990    if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) {991      setTimeout(() => peachFileRef?.click(), 50);992    }993  });994995  // The action popup dies when the OS file picker steals focus; dropping the996  // file is the other import path that survives it.997  const [dragDepth, setDragDepth] = createSignal(0);998999  async function handleDrop(e: DragEvent) {1000    e.preventDefault();1001    setDragDepth(0);1002    if (working() || animating() !== null) return;1003    const file = e.dataTransfer?.files?.[0];1004    if (!file) return;1005    try {1006      setScriptInput(await file.text());1007      setError(null);1008    } catch {1009      setError("Couldn't read file.");1010    }1011  }10121013  async function handleRestore() {1014    if (!scriptValid() || !backupPass().trim() || working()) return;1015    setError(null);1016    setWorking(true);1017    setAnimating("chaos");10181019    const canvas = canvasRef;1020    if (canvas) startChaos(canvas);10211022    try {1023      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));1024      const payload = await parsePayload(bytes, backupPass());10251026      if (!props.hasVault) {1027        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });1028        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1029      }10301031      const rr = await sendMessage({1032        type: "RESTORE_ENTRIES",1033        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },1034      });1035      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");10361037      if (payload.license?.key) {1038        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});1039      }10401041      setAnimating("snap");1042      stopAnim();1043      if (canvas) {1044        snapToGrid(canvas, () => {1045          setAnimating("bloom");1046          bloom(canvas, async () => {1047            setAnimating(null);1048            await props.onSuccess();1049          });1050        });1051      } else {1052        await props.onSuccess();1053      }1054    } catch (err) {1055      stopAnim();1056      setAnimating(null);1057      const code = err instanceof Error ? err.message : String(err);1058      setError(1059        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"1060          ? "That passphrase didn't work."1061          : code === "CREATE_VAULT_FAILED"1062            ? "Couldn't create a new local vault."1063            : "Could not read this backup.",1064      );1065    } finally {1066      setWorking(false);1067    }1068  }10691070  return (1071    <div1072      class="peach-restore-form"1073      classList={{ "is-dragover": dragDepth() > 0 }}1074      onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1075      onDragOver={(e) => e.preventDefault()}1076      onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1077      onDrop={(e) => void handleDrop(e)}1078    >1079      <Show when={animating() !== null}>1080        <canvas1081          ref={(el) => { canvasRef = el; }}1082          class="peach-restore-anim-canvas"1083          width={280}1084          height={200}1085        />1086      </Show>1087      <Show when={animating() === null}>1088        <textarea1089          class="peach-restore-script"1090          placeholder="paste peachscript…"1091          rows={3}1092          aria-label="Paste your PeachScript backup"1093          value={scriptInput()}1094          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}1095        />1096        <div class="peach-restore-or" aria-hidden="true">or</div>1097        <label1098          class="peach-btn peach-btn--ghost peach-btn--sm peach-restore-file-btn"1099          onClick={(e) => {1100            // In the detached window the dialog can open directly — let the1101            // label's real user gesture activate the input. In the action1102            // popup we must detach first (the dialog would dismiss the popup).1103            if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) return;1104            e.preventDefault();1105            void detachForFilePicker(RESTORE_DEEP_LINKS.symbols).then((detached) => {1106              if (!detached) peachFileRef?.click();1107            });1108          }}1109        >1110          open .peach file1111          <input1112            ref={(el) => { peachFileRef = el; }}1113            type="file"1114            accept=".peach,text/plain"1115            style="display:none"1116            onChange={async (e) => {1117              const file = (e.currentTarget as HTMLInputElement).files?.[0];1118              if (!file) return;1119              try {1120                setScriptInput(await file.text());1121              } catch {1122                setError("Couldn't read file.");1123              } finally {1124                (e.currentTarget as HTMLInputElement).value = "";1125              }1126            }}1127          />1128        </label>1129        <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1130        <div class="peach-newvault-field">1131          <label class="peach-label" for="restore-backup-pass">1132            backup password1133          </label>1134          <input1135            id="restore-backup-pass"1136            class="peach-underline-input peach-newvault-input"1137            type="password"1138            placeholder="••••••••••••"1139            autocomplete="current-password"1140            value={backupPass()}1141            onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}1142            onKeyDown={(e) => {1143              if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {1144                e.preventDefault();1145                void handleRestore();1146              }1147            }}1148          />1149        </div>1150        <button1151          class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"1152          onClick={() => void handleRestore()}1153          disabled={!scriptValid() || !backupPass().trim() || working()}1154        >1155          restore vault1156        </button>1157        <Show when={error()}>1158          <p class="peach-hint peach-hint-error">{error()}</p>1159        </Show>1160      </Show>1161    </div>1162  );1163}11641165// ── CSV source ─────────────────────────────────────────────────────────────11661167function RestoreCSV(props: {1168  hasVault: boolean;1169  onSuccess: () => void | Promise<void>;1170}) {1171  const [importing, setImporting] = createSignal(false);1172  const [error, setError] = createSignal<string | null>(null);1173  // CSV/JSON imports are plaintext and carry no password, so when there is no1174  // vault yet the user MUST choose a master password here — otherwise the1175  // imported credentials would be encrypted under an empty key and offer zero1176  // at-rest protection (audit HIGH). Mirrors the onboarding MIN_LENGTH policy.1177  const CSV_MIN_PASSWORD_LENGTH = 12;1178  const [newPass, setNewPass] = createSignal("");1179  const [confirmPass, setConfirmPass] = createSignal("");1180  const needsPassword = () => props.hasVault === false;1181  const passwordReady = () =>1182    !needsPassword() ||1183    (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());1184  // The action popup dies when the OS file picker steals focus — dropping the1185  // file is the import path that survives it (see RestoreSymbols).1186  const [dragDepth, setDragDepth] = createSignal(0);11871188  // Every route here (label click, drop, the disabled-attribute-guarded1189  // input change) passes gatePassword first, so the file is always accepted1190  // under a real master password — never an empty key.1191  async function processFile(file: File) {1192    setImporting(true);1193    setError(null);1194    try {1195      const text = await file.text();1196      const parsed = await parseImportSource(text);1197      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");11981199      if (props.hasVault === false) {1200        // Create the vault under the user-chosen master password — never an1201        // empty one (an empty key derives a publicly-known vault key).1202        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });1203        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1204      }12051206      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1207      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1208      if (parsed.licenseKey) {1209        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});1210      }1211      await props.onSuccess();1212    } catch (err) {1213      const code = err instanceof Error ? err.message : String(err);1214      setError(1215        code === "NO_ENTRIES" ? "No entries found in this file." :1216        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1217        "Import failed. Check the file format and try again."1218      );1219    } finally {1220      setImporting(false);1221    }1222  }12231224  let csvFileRef: HTMLInputElement | undefined;12251226  onMount(() => {1227    // A fresh vault needs its master password before a file can be accepted —1228    // auto-picking would only drop the chosen file at the password gate, so1229    // the dialog waits for the explicit click in that case.1230    if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv) && !needsPassword()) {1231      setTimeout(() => csvFileRef?.click(), 50);1232    }1233  });12341235  function gatePassword(): boolean {1236    if (importing()) return false;1237    if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {1238      setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);1239      return false;1240    }1241    if (needsPassword() && newPass() !== confirmPass()) {1242      setError("Passwords do not match.");1243      return false;1244    }1245    return true;1246  }12471248  async function handleFile(e: Event) {1249    const input = e.currentTarget as HTMLInputElement;1250    const file = input.files?.[0];1251    input.value = "";1252    if (!file) return;1253    await processFile(file);1254  }12551256  async function handleDrop(e: DragEvent) {1257    e.preventDefault();1258    setDragDepth(0);1259    if (!gatePassword()) return;1260    const file = e.dataTransfer?.files?.[0];1261    if (!file) return;1262    await processFile(file);1263  }12641265  return (1266    <div1267      class="peach-restore-form"1268      classList={{ "is-dragover": dragDepth() > 0 }}1269      onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1270      onDragOver={(e) => e.preventDefault()}1271      onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1272      onDrop={(e) => void handleDrop(e)}1273    >1274      <Show when={needsPassword()}>1275        <div class="peach-newvault-field">1276          <label class="peach-label" for="restore-csv-pass">1277            new master password1278          </label>1279          <input1280            id="restore-csv-pass"1281            class="peach-underline-input peach-newvault-input"1282            type="password"1283            placeholder="••••••••••••"1284            autocomplete="new-password"1285            value={newPass()}1286            onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1287          />1288        </div>1289        <div class="peach-newvault-field">1290          <label class="peach-label" for="restore-csv-confirm">1291            confirm master password1292          </label>1293          <input1294            id="restore-csv-confirm"1295            class="peach-underline-input peach-newvault-input"1296            type="password"1297            placeholder="••••••••••••"1298            autocomplete="new-password"1299            value={confirmPass()}1300            onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1301          />1302        </div>1303      </Show>1304      <label1305        class="peach-btn peach-btn--accent peach-btn--md peach-restore-file-btn"1306        onClick={(e) => {1307          // In the detached window the dialog can open directly — let the1308          // label's real user gesture activate the input. In the action1309          // popup we must detach first (the dialog would dismiss the popup).1310          if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv)) return;1311          // The label would forward the click to the input even before the1312          // password gate passes; own the activation so the gate can speak.1313          e.preventDefault();1314          if (!gatePassword()) return;1315          void detachForFilePicker(RESTORE_DEEP_LINKS.csv).then((detached) => {1316            if (!detached) csvFileRef?.click();1317          });1318        }}1319      >1320        {importing() ? "importing…" : "open csv file"}1321        <input1322          ref={(el) => { csvFileRef = el; }}1323          type="file"1324          accept=".csv,.json,.peach,application/x-peach"1325          style="display:none"1326          onChange={handleFile}1327          disabled={importing() || !passwordReady()}1328        />1329      </label>1330      <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1331      <p class="peach-hint peach-restore-csv-sources">1332        Imports from Bitwarden, 1Password, Proton Pass,1333        <br />1334        Chrome, Safari, and Edge.1335      </p>1336      <p class="peach-newvault-warning">1337        <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>1338        <span>Plain-text file — delete it after the import.</span>1339      </p>1340      <Show when={error()}>1341        <p class="peach-hint peach-hint-error">{error()}</p>1342      </Show>1343    </div>1344  );1345}13461347// ── Helpers ────────────────────────────────────────────────────────────────13481349function getDisplayError(error: string | undefined, fallback: string): string {1350  if (!error) return fallback;1351  if (1352    error === "SERVICE_WORKER_UNAVAILABLE" ||1353    error.includes("Receiving end does not exist") ||1354    error.includes("message port closed")1355  ) {1356    return "background service unavailable";1357  }1358  return error;1359}