index.ts

48.6 KB · raw

1import {2  detectCardForm,3  detectLoginFields,4  findOTPDigitBoxGroup,5  getDeepActiveInput,6  getPageDomain,7  isAnyCredentialForm,8  isEmailFirstForm,9  isLoginForm,10  isSignupForm,11  isTOTPField,12  isVisible,13} from "./detect";14import {15  CardSelectorEntry,16  injectFieldIcon,17  SelectorEntry,18  showAutofillSelector,19  showCardSelector,20  showPasskeyAvailable,21  showPasskeyProGate,22  showPasskeyRegistrationOffer,23  showRememberSiteToast,24  showTotpCopyToast,25  showTOTPSelector,26} from "./inject";27import { detectBrowser } from "../shared/browser";28import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";29import { fillCardForm, fillFields, fillOTPDigitBoxes, fillTOTPCode, setNativeValue } from "./fill";30import { resolveAutofillInteractionTargets } from "./interaction";31import { setupSaveDetection } from "./save";32import { contentMLDetector } from "./content-ml-detector";33import { detectPasswordPolicy } from "./policy";34import type { FieldPrediction } from "../shared/ml";35import type { SavedIdentity, VaultEntry } from "../shared/types";3637// WeakMap (not WeakSet) — we keep the per-input cleanup callback as the value38// so we can tear down listeners if we ever need to re-process the same field.39// When the input is GC'd (SPA nav, React re-render), the map entry goes with40// it and we can re-inject without leaking the old handler.41const processedInputs = new WeakMap<HTMLInputElement, () => void>();42const AUTOFILL_FIELD_TYPES = new Set(["password", "username", "email"]);4344let currentDismiss: (() => void) | null = null;45// Field whose selector is currently open — used to avoid dismiss+reopen flicker46let currentAnchorField: HTMLInputElement | null = null;47// Prevents two concurrent handleFieldInteraction calls for the same field48const pendingInteractions = new WeakSet<HTMLInputElement>();49// Tracks the last field the user explicitly dismissed (Escape / click-outside / Tab).50// Suppresses spurious reopens until focus moves to a different field. Layer 151// of the dismissal model — see Projects.md §4.12.52let lastDismissedField: HTMLInputElement | null = null;5354// ── Dismissal lock (Layer 2) ────────────────────────────────────────55// After any user-initiated dismiss, briefly block ALL reopens regardless of56// which field is focused. Catches the case where a page rapidly moves focus57// between fields after Escape — without this, focus moving to a SECOND field58// in <100ms would re-trigger the popup before the user finished dismissing.59const DISMISS_LOCK_MS = 800;60let dismissLock = false;61let dismissLockTimer: ReturnType<typeof setTimeout> | null = null;62// Set when the inline popup was summoned specifically for a passkey-unlock63// flow. The VAULT_UNLOCKED handler checks this to skip re-triggering autofill64// (the passkey toast handles the next step instead).65let passkeySummonActive = false;66// Set while the passkey approval toast is visible. Prevents handleFieldInteraction67// from opening the autofill selector alongside it. Cleared by a window message68// posted by inject.tsx when the toast closes (and by DISMISS_PASSKEY_TOAST).69let passkeyToastActive = false;70function startDismissLock(): void {71  dismissLock = true;72  if (dismissLockTimer) clearTimeout(dismissLockTimer);73  dismissLockTimer = setTimeout(() => { dismissLock = false; }, DISMISS_LOCK_MS);74}75function canOpen(): boolean { return !dismissLock; }7677// ── Fill-success lock ───────────────────────────────────────────────78// After Peach autofills a form, the popup must NOT reappear on the filled79// fields, on the submit button (some sites refocus it briefly), or on any80// field in that form — even when host-page DOM mutations or the form's own81// validation flow refocus an input. Implemented as a WeakSet of forms plus82// a page-level timestamp:83//   - filledForms: every form Peach has filled this page session. Survives84//     until the form leaves the DOM (WeakSet GC).85//   - pageFilledAt: timestamp of last fill. Used as a 3s belt-and-suspenders86//     window for fields that aren't inside a <form> element (some SPAs).87// SPA navigation resets pageFilledAt via the urlObserver below; filledForms88// self-empties as old form elements get GC'd.89const filledForms = new WeakSet<HTMLFormElement>();90let pageFilledAt: number | null = null;91const PAGE_FILL_GRACE_MS = 3000;9293function recordFillSuccess(...fields: (HTMLInputElement | null | undefined)[]): void {94  for (const f of fields) {95    if (!f) continue;96    const form = f.closest("form");97    if (form) filledForms.add(form);98  }99  pageFilledAt = Date.now();100}101102export /* @testable */ function wasAlreadyFilled(field: HTMLInputElement): boolean {103  const form = field.closest("form");104  if (form && filledForms.has(form)) return true;105  if (pageFilledAt !== null && Date.now() - pageFilledAt < PAGE_FILL_GRACE_MS) return true;106  return false;107}108109function clearFillStateForExplicitSummon(field: HTMLInputElement): void {110  const form = field.closest("form");111  if (form) filledForms.delete(form);112  pageFilledAt = null;113}114115// Some sites briefly focus the submit button when the user clicks it (visual116// feedback, blur/focus dance for validation animations). Catch those at the117// focusin layer so we don't re-trigger handleFieldInteraction for an event118// target that was never going to be a credential field anyway.119function isSubmitButton(el: EventTarget | null): boolean {120  if (!(el instanceof HTMLElement)) return false;121  if (el instanceof HTMLButtonElement && el.type !== "button") return true;122  if (el instanceof HTMLInputElement && el.type === "submit") return true;123  if (el.getAttribute("type") === "submit") return true;124  const text = (el.textContent?.trim() ?? el.getAttribute("value") ?? "").toLowerCase();125  if (!text || text.length > 24) return false;126  return /^(submit|sign[ -]?in|log[ -]?in|continue|next|go)$/i.test(text);127}128129function debounce<T extends (...args: unknown[]) => void>(130  fn: T,131  ms: number132): (...args: Parameters<T>) => void {133  let timer: ReturnType<typeof setTimeout>;134  return (...args: Parameters<T>) => {135    clearTimeout(timer);136    timer = setTimeout(() => fn(...args), ms);137  };138}139140function showToast(message: string): void {141  const toast = document.createElement("div");142  toast.style.cssText = `143    position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);144    background: #131109; color: #F0E8D8; padding: 8px 16px;145    border-radius: 8px; font-family: 'IBM Plex Mono', monospace;146    font-size: 11px; z-index: 2147483647; opacity: 0;147    transition: opacity 200ms ease;148    border: 1px solid rgba(240,232,216,0.07);149    pointer-events: none;150  `;151  toast.textContent = message;152  document.body.appendChild(toast);153  requestAnimationFrame(() => { toast.style.opacity = "1"; });154  setTimeout(() => {155    toast.style.opacity = "0";156    setTimeout(() => toast.remove(), 200);157  }, 2000);158}159160function showSearchPalette(): void {161  const host = document.createElement("div");162  host.style.cssText = "position:fixed;top:80px;left:50%;transform:translateX(-50%);z-index:2147483647;width:340px;";163  document.body.appendChild(host);164  const shadow = host.attachShadow({ mode: "closed" });165166  const style = document.createElement("style");167  style.textContent = `168    .p{background:#131109;border:1px solid rgba(240,232,216,.12);border-radius:12px;169      font-family:'IBM Plex Mono',monospace;font-size:12px;color:#F0E8D8;170      box-shadow:0 8px 24px rgba(0,0,0,.5);overflow:hidden}171    .h{padding:10px 12px 8px;border-bottom:1px solid rgba(240,232,216,.08);172      font-size:11px;color:rgba(240,232,216,.5);display:flex;justify-content:space-between;align-items:center}173    .x{background:none;border:none;color:rgba(240,232,216,.4);cursor:pointer;font-size:14px;padding:0;line-height:1}174    .s{padding:8px 12px}175    .s input{width:100%;box-sizing:border-box;background:rgba(240,232,216,.06);176      border:1px solid rgba(240,232,216,.12);border-radius:6px;color:#F0E8D8;177      padding:6px 8px;font-family:inherit;font-size:12px;outline:none}178    .l{max-height:220px;overflow-y:auto}179    .e{padding:8px 12px;cursor:pointer;display:flex;flex-direction:column;gap:2px}180    .e:hover{background:rgba(240,232,216,.06)}181    .et{font-size:12px}182    .eu{font-size:10px;color:rgba(240,232,216,.45)}183    .em{padding:12px;color:rgba(240,232,216,.4);text-align:center}184  `;185186  let allEntries: SelectorEntry[] = [];187  let query = "";188189  const panel = document.createElement("div");190  panel.className = "p";191192  const hdr = document.createElement("div");193  hdr.className = "h";194  const hdrText = document.createElement("span");195  hdrText.textContent = "No form detected — search your vault";196  const closeBtn = document.createElement("button");197  closeBtn.className = "x";198  closeBtn.textContent = "✕";199  hdr.appendChild(hdrText);200  hdr.appendChild(closeBtn);201202  const searchWrap = document.createElement("div");203  searchWrap.className = "s";204  const searchInput = document.createElement("input");205  searchInput.placeholder = "search...";206  searchWrap.appendChild(searchInput);207208  const list = document.createElement("div");209  list.className = "l";210211  panel.appendChild(hdr);212  panel.appendChild(searchWrap);213  panel.appendChild(list);214  shadow.appendChild(style);215  shadow.appendChild(panel);216217  function close() { host.remove(); }218219  closeBtn.onclick = close;220221  function onKeyDown(e: KeyboardEvent) {222    if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKeyDown, true); }223  }224  document.addEventListener("keydown", onKeyDown, true);225226  function render() {227    const q = query.toLowerCase();228    const filtered = q229      ? allEntries.filter(e => e.title.toLowerCase().includes(q) || (e.username ?? "").toLowerCase().includes(q))230      : allEntries;231    list.innerHTML = "";232    if (filtered.length === 0) {233      const em = document.createElement("div");234      em.className = "em";235      em.textContent = query ? "no matches" : "vault is empty";236      list.appendChild(em);237      return;238    }239    for (const entry of filtered.slice(0, 20)) {240      const row = document.createElement("div");241      row.className = "e";242      const title = document.createElement("div");243      title.className = "et";244      title.textContent = entry.title;245      row.appendChild(title);246      if (entry.username) {247        const user = document.createElement("div");248        user.className = "eu";249        user.textContent = entry.username;250        row.appendChild(user);251      }252      row.onclick = async () => {253        // MV-1: vault-wide search list is a cross-domain summon — the user254        // explicitly picked this entry to fill on this page.255        const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, explicitSummon: true } });256        const r = resp as { ok: boolean; data?: { password?: string } };257        if (r.ok && r.data?.password) {258          let alreadyWritten = false;259          try {260            await navigator.clipboard.writeText(r.data.password);261            alreadyWritten = true;262          } catch (err) {263            console.warn("[Peach] clipboard write denied:", err);264          }265          const copyResp = await chrome.runtime.sendMessage({266            type: "COPY",267            payload: { text: r.data.password, label: "Password", alreadyWritten },268          }) as { ok?: boolean } | undefined;269          if (alreadyWritten || copyResp?.ok) {270            showToast("Password copied");271          } else {272            showToast("Could not copy password — clipboard blocked");273          }274        } else {275          showToast("Could not copy password");276        }277        close();278        document.removeEventListener("keydown", onKeyDown, true);279      };280      list.appendChild(row);281    }282  }283284  searchInput.oninput = () => { query = searchInput.value; render(); };285286  // MV-2: the summon palette is an explicit user gesture (keyboard shortcut /287  // toolbar icon), so it may load the vault-wide inventory.288  fetchAutofillEntries(getPageDomain(), true, true).then(result => {289    if (result.locked) { showToast("Vault is locked — open Peach to unlock"); close(); return; }290    allEntries = result.entries;291    render();292    searchInput.focus();293  });294}295296function findTOTPField(root: Document | Element = document): HTMLInputElement | null {297  const inputs = root.querySelectorAll<HTMLInputElement>("input");298  for (const input of inputs) {299    if (isTOTPField(input)) return input;300  }301  return null;302}303304function findSameLoginTOTPField(passwordEl: HTMLInputElement): HTMLInputElement | null {305  const form = passwordEl.closest("form");306  return findTOTPField(form ?? document);307}308309310async function handleAutofill(311  _field: HTMLInputElement,312  usernameEl: HTMLInputElement | null,313  passwordEl: HTMLInputElement | null,314  entry: VaultEntry,315  emailEl?: HTMLInputElement | null316): Promise<HTMLInputElement | null> {317  if (!entry.username && !entry.password && !entry.email) return null;318  const pageDomain = getPageDomain();319  const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;320  // Fill the email field with entry.email if set; fall back to entry.username when321  // it looks like an email address (sites that store email-as-username and have a322  // separate email field on signup forms).323  const emailValue = entry.email ?? (entry.username?.includes("@") ? entry.username : "");324  fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain, emailEl, emailValue);325  let filledTOTPField: HTMLInputElement | null = null;326327  if (entry.canary) {328    chrome.runtime.sendMessage({329      type: "FIRE_CANARY_PING",330      payload: {331        webhookUrl: entry.canary.webhookUrl,332        entryTitle: entry.title,333        trigger: "autofilled",334      },335    }).catch(() => {});336    chrome.runtime.sendMessage({337      type: "UPDATE_ENTRY",338      payload: {339        id: entry.id,340        patch: {341          canary: {342            ...entry.canary,343            lastPinged: Date.now(),344            pingCount: (entry.canary.pingCount ?? 0) + 1,345          },346        },347      },348    }).catch(() => {});349  }350351  // The background sends a freshly-generated 6-digit code (totpCode), never the352  // base32 seed (SEV-3-C). Fill the code straight in.353  const totpCode = (entry as { totpCode?: string }).totpCode;354  if (totpCode) {355    const code = totpCode;356    const totpField = passwordEl ? findSameLoginTOTPField(passwordEl) : null;357    if (fillTOTPCode(totpField, code)) {358      filledTOTPField = totpField;359    }360    let alreadyWritten = false;361    try {362      await navigator.clipboard.writeText(code);363      alreadyWritten = true;364    } catch (err) {365      console.warn("[Peach] clipboard write denied:", err);366    }367    const response = await chrome.runtime.sendMessage({368      type: "COPY",369      payload: { text: code, label: "TOTP", alreadyWritten },370    });371    const res = response as { ok?: boolean; clearSeconds?: number };372    if (filledTOTPField) {373      showToast("2FA code filled");374    } else if (alreadyWritten || res.ok) {375      // Don't render the live code into the toast — the page can read it from the376      // DOM and consume the one-time code (audit LOW). The code is already on the377      // clipboard; the toast confirms the copy and shows how long until the378      // clipboard self-clears (Addendum 2). 30 mirrors DEFAULT_CLEAR_SECONDS.379      showTotpCopyToast(res.clearSeconds ?? 30);380    } else {381      showToast("Could not copy TOTP — clipboard blocked");382    }383  }384385  return filledTOTPField;386}387388async function fetchAutofillEntries(389  domain: string,390  includeAll = false,391  userGesture = false392): Promise<{ entries: SelectorEntry[]; locked: boolean }> {393  const response = await chrome.runtime.sendMessage({394    type: "AUTOFILL_QUERY",395    payload: { domain, includeAll, userGesture },396  });397  const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };398  if (!res.ok && res.error === "vault_locked") {399    return { entries: [], locked: true };400  }401  return { entries: res.data ?? [], locked: false };402}403404async function fetchTOTPEntries(405  domain: string406): Promise<{ entries: SelectorEntry[]; locked: boolean }> {407  const response = await chrome.runtime.sendMessage({408    type: "AUTOFILL_QUERY",409    payload: { domain, kind: "totp" },410  });411  const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };412  if (!res.ok && res.error === "vault_locked") {413    return { entries: [], locked: true };414  }415  return { entries: res.data ?? [], locked: false };416}417418419function resolveAutofillFieldType(420  el: HTMLInputElement,421  predictedType?: FieldPrediction["type"] | null422): "password" | "username" | "email" | null {423  if (424    predictedType === "password" ||425    predictedType === "username" ||426    predictedType === "email"427  ) {428    return predictedType;429  }430431  const detected = detectLoginFields().find((field) => field.el === el)?.type;432  if (detected && AUTOFILL_FIELD_TYPES.has(detected)) {433    return detected;434  }435436  const htmlType = el.type.toLowerCase();437  if (htmlType === "password") return "password";438  if (htmlType === "email") return "email";439  return null;440}441442async function openAutofillSelector(443  anchorField: HTMLInputElement,444  usernameEl: HTMLInputElement | null,445  passwordEl: HTMLInputElement | null,446  domainEntries: SelectorEntry[],447  loadSearchableEntries: () => Promise<SelectorEntry[]>,448  domain: string,449  identityKind: "email" | "username",450  emailEl?: HTMLInputElement | null451): Promise<void> {452  // No saved entries for this site → signup/new-account mode.453  // Fetch vault identities for the identity-first surface and locate454  // the confirm-password field so "Copy & fill" can populate it too.455  const isSignup = domainEntries.length === 0;456  let savedIdentities: SavedIdentity[] | undefined;457  let confirmEl: HTMLInputElement | null = null;458  if (isSignup) {459    try {460      const idResp = await chrome.runtime.sendMessage({ type: "GET_IDENTITIES" }).catch(() => null) as461        { ok: boolean; data?: SavedIdentity[] } | null;462      if (idResp?.ok && Array.isArray(idResp.data)) savedIdentities = idResp.data;463    } catch { /* silent fallback */ }464465    const form = (passwordEl ?? usernameEl)?.closest("form") ?? document.body;466    const allPwInputs = Array.from(form.querySelectorAll<HTMLInputElement>("input[type='password']"));467    confirmEl = allPwInputs.find(468      (el) => el !== passwordEl && /confirm|repeat|verify/i.test(el.name + el.id + el.placeholder)469    ) ?? (allPwInputs.length >= 2 ? allPwInputs.find((el) => el !== passwordEl) ?? null : null);470  }471472  currentDismiss?.();473  currentAnchorField = anchorField;474  currentDismiss = showAutofillSelector(475    anchorField,476    domainEntries,477    async (id: string) => {478      currentDismiss = null;479      currentAnchorField = null;480      const entryResponse = await chrome.runtime.sendMessage({481        type: "GET_ENTRY_FOR_FILL",482        payload: { id },483      });484      const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;485      if (entry) {486        const filledTOTPField = await handleAutofill(anchorField, usernameEl, passwordEl, entry, emailEl);487        recordFillSuccess(anchorField, usernameEl, passwordEl, filledTOTPField);488        // Offer to remember the current site for this entry only if none of489        // its known domains (primary + additional) match — otherwise the490        // user already mapped it and the prompt would be noise.491        const pageDomain = location.hostname.replace(/^www\./, "");492        const entryDomains = new Set<string>();493        for (const u of [entry.url, ...(entry.additionalUrls ?? [])]) {494          try {495            if (u) entryDomains.add(new URL(u).hostname.replace(/^www\./, ""));496          } catch { /* ignore unparseable */ }497        }498        if (pageDomain && entryDomains.size > 0 && !entryDomains.has(pageDomain)) {499          showRememberSiteToast(pageDomain, entry.id);500        }501      }502    },503    {504      domain,505      loadSearchableEntries,506      isSignup,507      identityKind,508      hasEmailField: !!emailEl,509      savedIdentities,510      onFillFields: (username, password, email) => {511        const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;512        fillFields(usernameEl, passwordEl, username, password, form, getPageDomain(), emailEl, email);513        if (password && confirmEl) setNativeValue(confirmEl, password);514        recordFillSuccess(anchorField, usernameEl, passwordEl);515      },516      onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },517    }518  );519}520521async function openTOTPSelector(anchorField: HTMLInputElement, otpDigitBoxes?: HTMLInputElement[]): Promise<void> {522  if (!canOpen()) return;523  if (currentDismiss && currentAnchorField === anchorField) return;524  if (anchorField !== lastDismissedField) lastDismissedField = null;525  if (anchorField === lastDismissedField) return;526  if (wasAlreadyFilled(anchorField)) return;527528  const domain = getPageDomain();529  const result = await fetchTOTPEntries(domain);530531  if (result.locked) {532    currentDismiss?.();533    currentAnchorField = anchorField;534    currentDismiss = showAutofillSelector(535      anchorField,536      [],537      () => {},538      {539        domain,540        locked: true,541        onUnlocked: () => void openTOTPSelector(anchorField),542        onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },543      }544    );545    return;546  }547548  if (result.entries.length === 0) {549    lastDismissedField = anchorField;550    startDismissLock();551    showToast("No 2FA entries for this site — add a code in Peach first");552    return;553  }554555  currentDismiss?.();556  currentAnchorField = anchorField;557  currentDismiss = showTOTPSelector(558    anchorField,559    result.entries,560    async (id: string) => {561      try {562        const entryResponse = await chrome.runtime.sendMessage({563          type: "GET_ENTRY_FOR_FILL",564          payload: { id },565        }).catch(() => null) as { ok: boolean; data?: VaultEntry } | null;566        const entry = entryResponse?.ok ? entryResponse.data : undefined;567        const code = (entry as { totpCode?: string } | undefined)?.totpCode;568        if (!code) return;569        if (otpDigitBoxes && otpDigitBoxes.length > 1) {570          fillOTPDigitBoxes(otpDigitBoxes, code);571        } else {572          setNativeValue(anchorField, code);573        }574        recordFillSuccess(anchorField);575        showToast("2FA code filled");576      } finally {577        currentDismiss = null;578        currentAnchorField = null;579      }580    },581    {582      domain,583      onDismiss: () => {584        lastDismissedField = anchorField;585        startDismissLock();586        currentDismiss = null;587        currentAnchorField = null;588      },589    }590  );591}592593594export /* @testable */ async function handleFieldInteraction(595  anchorField?: HTMLInputElement | null,596  explicitSummon = false597): Promise<void> {598  const field = anchorField ?? getDeepActiveInput();599  if (!field) return;600  if (!isVisible(field)) return;601602  // Passkey toast is visible — don't open the credential selector alongside it.603  if (passkeyToastActive) return;604605  // Layer 2: post-dismiss global lock. Catches rapid focus moves between606  // fields immediately after Escape/click-outside.607  if (!canOpen()) return;608609  // If a selector is already open for this exact field, don't flicker it.610  if (currentDismiss && currentAnchorField === field) return;611612  // If focus moved to a different field, the previous per-field dismiss lock613  // no longer applies.614  if (field !== lastDismissedField) lastDismissedField = null;615  // Layer 1: the user explicitly dismissed this field's selector — don't616  // reopen it until focus has genuinely moved away and come back.617  if (field === lastDismissedField) return;618619  // Fill-success lock: Peach already filled this form (or fired a fill620  // anywhere on the page within the grace window). Sites often refocus an621  // input on submit click, on validation, or on a transition to a success622  // state — none of those should re-open the popup.623  if (wasAlreadyFilled(field)) return;624625  // Prevent two concurrent calls for the same field (e.g. focusin + icon click626  // firing simultaneously — the second call would dismiss the first's selector).627  if (pendingInteractions.has(field)) return;628  pendingInteractions.add(field);629630  try {631    // Multi-digit OTP entry: individual maxLength=1 boxes get no icon (too small)632    // but the TOTP selector opens on focus and fills every box at once.633    const otpGroup = findOTPDigitBoxGroup(field);634    if (otpGroup) {635      await openTOTPSelector(otpGroup[0]!, otpGroup);636      return;637    }638639    if (isTOTPField(field)) {640      const formFields = detectLoginFields();641      if (!isLoginForm(formFields) && !isSignupForm(formFields)) {642        await openTOTPSelector(field);643        return;644      }645    }646647    const fieldType = field.type.toLowerCase();648649    const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {650      return preds[0] ?? null;651    });652653    const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {654      const timeout = setTimeout(() => resolve(null), 150);655      mlPromise.then((pred) => {656        clearTimeout(timeout);657        resolve(pred);658      });659    });660661    if (passkeyToastActive) return;662663    if (!explicitSummon && fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {664      return;665    }666667    const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);668    if (autofillFieldType) {669      reattachListeners(field, autofillFieldType);670    }671672    let fields = detectLoginFields();673    const targets = resolveAutofillInteractionTargets(field, fields, explicitSummon);674    if (!targets.shouldOpen) {675      return;676    }677678    const domain = getPageDomain();679    const firstResult = await fetchAutofillEntries(domain);680    if (passkeyToastActive) return;681682    // Vault is locked — show an inline unlock prompt.683    // Re-triggering after unlock is handled by the VAULT_UNLOCKED message so684    // there is no double-trigger race between onUnlocked and the broadcast.685    if (firstResult.locked) {686      currentDismiss?.();687      currentAnchorField = field;688      currentDismiss = showAutofillSelector(689        field,690        [],691        () => {},692        { domain, locked: true, onDismiss: () => { lastDismissedField = field; startDismissLock(); } }693      );694      return;695    }696697    const entries = firstResult.entries;698    // MV-2: the vault-wide list is NOT fetched here. It is loaded lazily,699    // only when the user activates the search affordance inside the inline700    // popup (an explicit gesture), via the thunk below. Passive form focus701    // never ships the full account inventory into the page's content-script702    // world — and never hands out the entry ids that made MV-1 a full-vault703    // primitive.704    const loadSearchableEntries = async (): Promise<SelectorEntry[]> =>705      (await fetchAutofillEntries(domain, true, true)).entries;706707    if (passkeyToastActive) return;708709    const pwField = fields.find((f) => f.type === "password");710    // For email-first flows there is no password field on this step — that is expected.711    if (!explicitSummon && !pwField && !isEmailFirstForm(fields)) return;712    const usernameEl = targets.usernameEl;713    const passwordEl = targets.passwordEl;714    const emailEl = targets.emailEl;715716    await openAutofillSelector(717      field,718      usernameEl,719      passwordEl,720      entries,721      loadSearchableEntries,722      domain,723      autofillFieldType === "email" ? "email" : "username",724      emailEl,725    );726  } finally {727    pendingInteractions.delete(field);728  }729}730731async function onFocusIn(e: Event): Promise<void> {732  // Submit-button focus: catch the brief refocus some sites do when the733  // user clicks the login/submit button. Without this guard, that focus734  // event reaches handleFieldInteraction → getDeepActiveInput, and on735  // pages where the button is also a labelled <input> we briefly attempt736  // to open the popup over the very button the user just clicked.737  if (isSubmitButton(e.target)) return;738  await handleFieldInteraction();739}740741function reattachListeners(742  el: HTMLInputElement,743  detectedType?: "password" | "username" | "email",744  contextFields?: ReturnType<typeof detectLoginFields>745): void {746  if (processedInputs.has(el)) return;747  if (!resolveAutofillFieldType(el, detectedType)) return;748  // Only inject icon when a credential form is present on the page749  const fields = contextFields ?? detectLoginFields();750  if (!isAnyCredentialForm(fields)) return;751752  const cleanup = injectFieldIcon(el, () => {753    currentDismiss?.();754    currentDismiss = null;755    lastDismissedField = null; // icon click is an explicit user open — clear per-field lock756    dismissLock = false; // ...and the global lock757    if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }758    clearFillStateForExplicitSummon(el);759    el.focus({ preventScroll: true });760    void handleFieldInteraction(el, true);761  });762  processedInputs.set(el, cleanup);763}764765const debouncedReattach = debounce(() => {766  const allFields = detectLoginFields();767  if (!isAnyCredentialForm(allFields)) return;768  const newFields = allFields.filter(769    (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)770  );771  for (const f of newFields) {772    reattachListeners(f.el, f.type, allFields);773  }774  // 200ms debounce — below this, Peach and the host page's reactive layer775  // fight over the same layout properties every frame on React/Vue/Angular776  // login forms (v1.4.14 regression, Projects.md §4.7). Do not lower.777}, 200);778779function setupMutationObserver(): void {780  const observer = new MutationObserver(debouncedReattach);781  observer.observe(document.body, {782    childList: true,783    subtree: true,784    attributes: true,785    attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],786  });787}788789export /* @testable */ function initialScan(aggressionLevel = 0, isFinalPass = false) {790  const allFields = detectLoginFields();791  const hasKnownForm = isAnyCredentialForm(allFields);792793  if (aggressionLevel === 0 && !hasKnownForm) return;794795  const newFields = allFields.filter(796    (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)797  );798799  for (const f of newFields) {800    reattachListeners(f.el, f.type, allFields);801  }802803  // Level 2, final pass: inject icon on any visible password field even if804  // the form-context gate in reattachListeners would normally skip it.805  if (aggressionLevel >= 2 && isFinalPass && newFields.length === 0) {806    const pwInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');807    for (const el of pwInputs) {808      if (isVisible(el) && !processedInputs.has(el)) {809        const cleanup = injectFieldIcon(el, () => {810          currentDismiss?.();811          currentDismiss = null;812          lastDismissedField = null;813          dismissLock = false;814          if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }815          clearFillStateForExplicitSummon(el);816          el.focus({ preventScroll: true });817          void handleFieldInteraction(el, true);818        });819        processedInputs.set(el, cleanup);820      }821    }822  }823}824825// @testable seam — gives unit tests read/write access to module-private state826// without exposing it in the production API surface.827export const _testSeam = {828  getLastDismissedField(): HTMLInputElement | null { return lastDismissedField; },829  setLastDismissedField(f: HTMLInputElement | null): void { lastDismissedField = f; },830  isDismissLocked(): boolean { return dismissLock; },831  DISMISS_LOCK_MS,832} as const;833834// ── TOTP-only fields ───────────────────────────────────────────────────────835// After a username/password submission, many sites swap the whole form for a836// lone "verification code" input. The credential selector intentionally gates837// on login/signup forms, so TOTP fields need a narrow path of their own.838const processedTOTPInputs = new WeakMap<HTMLInputElement, () => void>();839840function hasLoginOrSignupForm(): boolean {841  const fields = detectLoginFields();842  return isAnyCredentialForm(fields);843}844845function injectTOTPFieldIcon(field: HTMLInputElement): void {846  if (processedTOTPInputs.has(field)) return;847  const cleanup = injectFieldIcon(field, () => {848    currentDismiss?.();849    currentDismiss = null;850    currentAnchorField = null;851    lastDismissedField = null;852    dismissLock = false;853    if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }854    clearFillStateForExplicitSummon(field);855    field.focus({ preventScroll: true });856    void openTOTPSelector(field);857  });858  processedTOTPInputs.set(field, cleanup);859}860861function scanTOTPFields(): void {862  if (hasLoginOrSignupForm()) return;863  const inputs = document.querySelectorAll<HTMLInputElement>("input");864  for (const input of inputs) {865    if (!isTOTPField(input)) continue;866    injectTOTPFieldIcon(input);867  }868}869870function setupTOTPAutofill(): void {871  setTimeout(scanTOTPFields, 0);872  setTimeout(scanTOTPFields, 500);873  setTimeout(scanTOTPFields, 1500);874875  const debouncedScan = debounce(scanTOTPFields, 200);876  new MutationObserver(debouncedScan).observe(document.body, {877    childList: true,878    subtree: true,879    attributes: true,880    attributeFilter: ["autocomplete", "name", "id", "placeholder", "hidden"],881  });882883  document.addEventListener("focusin", (e) => {884    const target = e.target;885    if (!(target instanceof HTMLInputElement)) return;886    if (hasLoginOrSignupForm()) return;887    if (!isTOTPField(target)) return;888    injectTOTPFieldIcon(target);889  }, true);890}891892document.addEventListener("focusin", onFocusIn);893894setupMutationObserver();895setupTOTPAutofill();896setupSaveDetection();897setupCardAutofill();898899// SPA navigation reset: the content script does not reload across in-app900// route changes (history.pushState, hash navigation, framework routers).901// When the URL changes we clear the page-level fill timestamp so the next902// page works normally; filledForms (a WeakSet) self-empties as old form903// elements leave the DOM. subtree:false because route swaps reliably904// mutate document.body's direct children — subtree:true would fire on905// every internal DOM update and cost us a re-check per mutation.906let lastObservedUrl = location.href;907new MutationObserver(() => {908  if (location.href !== lastObservedUrl) {909    lastObservedUrl = location.href;910    pageFilledAt = null;911  }912}).observe(document.body, { childList: true, subtree: false });913914// Async init: check recall map before running the initial scans so we can915// apply the right aggression level for domains the user has needed before.916(async () => {917  let aggressionLevel = 0;918  try {919    const resp = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });920    const s = (resp as { ok: boolean; data?: { manualSummonDomains?: Record<string, { summonCount: number }> } }).data;921    const domain = getPageDomain();922    const record = domain ? s?.manualSummonDomains?.[domain] : undefined;923    aggressionLevel = !record ? 0 : record.summonCount >= 2 ? 2 : 1;924  } catch { /* SW may be starting up; proceed with normal scan */ }925926  setTimeout(() => initialScan(aggressionLevel, false), 0);927  setTimeout(() => initialScan(aggressionLevel, false), 500);928  setTimeout(() => initialScan(aggressionLevel, true), 1500);929})();930931// ── Card autofill (State 7) ─────────────────────────────────────────932// Card forms get a parallel injection path. The login icon-injection gate933// in reattachListeners deliberately skips non-text fields, so we wire934// cards independently rather than retrofit AUTOFILL_FIELD_TYPES — keeping935// card detection contained makes the boundary easy to follow.936const processedCardInputs = new WeakMap<HTMLInputElement, () => void>();937let currentCardDismiss: (() => void) | null = null;938let currentCardAnchor: HTMLInputElement | null = null;939let lastDismissedCardField: HTMLInputElement | null = null;940941async function fetchSavedCards(): Promise<CardSelectorEntry[]> {942  const resp = await chrome.runtime.sendMessage({943    type: "AUTOFILL_QUERY",944    payload: { domain: getPageDomain(), kind: "card" },945  }).catch(() => null);946  const r = resp as { ok: boolean; data?: CardSelectorEntry[] } | null;947  return r?.ok ? (r.data ?? []) : [];948}949950async function openCardSelector(anchor: HTMLInputElement): Promise<void> {951  if (!canOpen()) return;952  if (currentCardDismiss && currentCardAnchor === anchor) return;953  if (anchor === lastDismissedCardField) return;954  if (anchor !== lastDismissedCardField) lastDismissedCardField = null;955  if (wasAlreadyFilled(anchor)) return;956957  const cards = await fetchSavedCards();958  if (cards.length === 0) return; // no saved cards → nothing to offer959960  const cardForm = detectCardForm();961  if (!cardForm?.number) return;962963  currentCardDismiss?.();964  currentCardAnchor = anchor;965  currentCardDismiss = showCardSelector(966    anchor,967    cards,968    {969      domain: getPageDomain(),970      onFill: (entry) => {971        if (!cardForm) return;972        fillCardForm(cardForm, {973          cardNumber: entry.cardNumber ?? "",974          expMonth: entry.expMonth ?? "",975          expYear: entry.expYear ?? "",976          cvc: entry.cvc ?? "",977          cardholderName: entry.cardholderName ?? "",978        });979        // All four card field types share the same form — recording the980        // number field's form is sufficient to suppress reopens on any of981        // them via wasAlreadyFilled().982        recordFillSuccess(cardForm.number, cardForm.cvc, cardForm.expiryCombo, cardForm.name);983        currentCardDismiss = null;984        currentCardAnchor = null;985      },986      onDismiss: () => {987        lastDismissedCardField = anchor;988        startDismissLock();989        currentCardDismiss = null;990        currentCardAnchor = null;991      },992    }993  );994}995996function injectCardIcon(field: HTMLInputElement): void {997  if (processedCardInputs.has(field)) return;998  const cleanup = injectFieldIcon(field, () => {999    currentCardDismiss?.();1000    currentCardDismiss = null;1001    lastDismissedCardField = null;1002    dismissLock = false;1003    if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1004    clearFillStateForExplicitSummon(field);1005    field.focus({ preventScroll: true });1006    void openCardSelector(field);1007  });1008  processedCardInputs.set(field, cleanup);1009}10101011function setupCardAutofill(): void {1012  function scan() {1013    const cardForm = detectCardForm();1014    if (!cardForm?.number) return;1015    if (!isVisible(cardForm.number)) return;1016    injectCardIcon(cardForm.number);1017  }10181019  // Initial detection sweep — same retry cadence as login fields so1020  // late-hydrating SPA checkout pages get caught.1021  setTimeout(scan, 0);1022  setTimeout(scan, 500);1023  setTimeout(scan, 1500);10241025  // Re-scan on DOM mutation (separate from the login MutationObserver1026  // because card forms typically appear on a single navigation event,1027  // not field-by-field — coarser observation is sufficient here).1028  let pending = false;1029  new MutationObserver(() => {1030    if (pending) return;1031    pending = true;1032    setTimeout(() => { pending = false; scan(); }, 200);1033  }).observe(document.body, { childList: true, subtree: true });10341035  // Field-focus path: on focus of a cc-number input, ensure icon is1036  // present and consider opening the selector when the user explicitly1037  // clicks into the card number field.1038  document.addEventListener("focusin", (e) => {1039    const target = e.target;1040    if (!(target instanceof HTMLInputElement)) return;1041    const cardForm = detectCardForm();1042    if (cardForm?.number === target) {1043      injectCardIcon(target);1044    }1045  }, true);1046}10471048chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {1049  // Ignore anything not originating from our own background/service worker.1050  if (sender.id !== chrome.runtime.id) return;1051  if (msg?.type === "GET_SITE_PASSWORD_POLICY") {1052    const policy = detectPasswordPolicy();1053    sendResponse({ ok: true, data: policy });1054    return true;1055  }1056  if (msg?.type === "PING") {1057    sendResponse({ ok: true });1058    return true;1059  }1060  if (msg?.type === "SUMMON_AUTOFILL") {1061    const source = (msg as { type: string; source?: string }).source;1062    const isShortcut = source === "shortcut";1063    const isPasskeyUnlock = source === "passkey-unlock";10641065    const activeInput = getDeepActiveInput();1066    const fields = detectLoginFields();1067    const hasForm = isAnyCredentialForm(fields);10681069    if (hasForm) {1070      // Prefer the currently focused field if it's a login field, otherwise1071      // fall back to the first password field, then username/email.1072      const inForm = activeInput && fields.some(f => f.el === activeInput);1073      const target = inForm1074        ? activeInput1075        : (fields.find(f => f.type === "password")?.el1076            ?? fields.find(f => f.type === "username" || f.type === "email")?.el);1077      if (target) {1078        // Explicit user summon (shortcut or extension button) — bypass all1079        // suppression layers. The user is asking for the popup right now.1080        clearFillStateForExplicitSummon(target);1081        dismissLock = false;1082        if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1083        lastDismissedField = null;1084        void handleFieldInteraction(target);1085      }1086    } else if (isPasskeyUnlock) {1087      // Vault is locked during a passkey GET request. Show the inline unlock1088      // prompt anchored to whatever element is focused on the page (typically1089      // the passkey/sign-in button the user just tapped).1090      const anchor = (document.activeElement instanceof HTMLElement && document.activeElement !== document.body)1091        ? document.activeElement as unknown as HTMLInputElement1092        : null;1093      if (anchor) {1094        currentDismiss?.();1095        currentAnchorField = null;1096        passkeySummonActive = true;1097        currentDismiss = showAutofillSelector(anchor, [], () => {}, {1098          domain: getPageDomain(),1099          locked: true,1100          onDismiss: () => { passkeySummonActive = false; },1101        });1102      }1103    } else if (isShortcut) {1104      // Shortcut on a page with no login form — show clipboard-copy search mode.1105      showSearchPalette();1106    }11071108    // Record this domain for the recall system when triggered via shortcut.1109    if (isShortcut) {1110      const domain = getPageDomain();1111      if (domain) {1112        chrome.runtime.sendMessage({ type: "RECORD_MANUAL_SUMMON", payload: { domain } }).catch(() => {});1113      }1114    }11151116    sendResponse({ ok: hasForm || isShortcut || isPasskeyUnlock });1117    return true;1118  }1119  if (msg?.type === "PASSKEY_PRO_GATE") {1120    // Background rejected a passkey request for a free user. Show State 111121    // so they know what happened and can choose to upgrade.1122    showPasskeyProGate();1123    sendResponse({ ok: true });1124    return true;1125  }1126  if (msg?.type === "SHOW_PASSKEY_TOAST") {1127    // Block autofill selector for the lifetime of the toast and dismiss anything open.1128    passkeyToastActive = true;1129    if (currentDismiss) {1130      currentDismiss();1131      currentDismiss = null;1132      currentAnchorField = null;1133    }1134    return false; // inject.tsx owns the toast and calls sendResponse1135  }1136  if (msg?.type === "DISMISS_PASSKEY_TOAST") {1137    passkeyToastActive = false;1138    return false; // inject.tsx handles the actual dismissal1139  }1140  if (msg?.type === "VAULT_UNLOCKED") {1141    if (passkeySummonActive) {1142      // This unlock was for a pending passkey GET request. Just dismiss the1143      // inline popup — the passkey approval toast takes over from here.1144      passkeySummonActive = false;1145      if (currentDismiss) {1146        currentDismiss();1147        currentDismiss = null;1148        currentAnchorField = null;1149      }1150      sendResponse({ ok: true });1151      return true;1152    }1153    // Normal autofill path: close the locked-state prompt and re-open with1154    // fresh entry data at the same anchor field.1155    const savedField = currentAnchorField;1156    if (currentDismiss) {1157      currentDismiss();1158      currentDismiss = null;1159      currentAnchorField = null;1160    }1161    void handleFieldInteraction(savedField ?? undefined);1162    sendResponse({ ok: true });1163    return true;1164  }1165  return true;1166});11671168// inject.tsx posts this when the passkey toast closes (approve, deny, or timeout)1169// so we know it's safe to allow autofill again.1170window.addEventListener("message", (e: MessageEvent) => {1171  // Reject postMessages from cross-frame senders — only the same window's1172  // injected toast may flip this flag.1173  if (e.source !== window) return;1174  const d = e.data as { source?: string; type?: string } | undefined;1175  if (d?.source === "peach-passkey-toast" && d?.type === "toast-closed") {1176    passkeyToastActive = false;1177  }1178});11791180// ── Firefox passkey detection bridge (legacy observer path) ─────────────1181//1182// Pre-v2.12.0 the MAIN-world `passkeys-firefox.ts` was an observer — it1183// emitted `peach-passkey-firefox` postMessages so this content script could1184// surface State 12 (an inline "Save passkey to Peach" offer). v2.12.01185// replaced the observer with a full interception proxy (the page never1186// reaches a real navigator.credentials.create on Firefox anymore), and the1187// approval UI is opened by the background as part of the ceremony itself.1188// When the proxy is active the offer would be redundant noise, so the1189// listener gates on the flag. Once the proxy is removed (Firefox ships1190// webAuthenticationProxy) this whole block goes too.1191if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1192  let lastPasskeyOfferAt = 0;1193  const PASSKEY_OFFER_COOLDOWN_MS = 30_000;11941195  window.addEventListener("message", (event: MessageEvent) => {1196    if (event.source !== window) return;1197    const data = event.data as { source?: string; type?: string; rpId?: string; rpName?: string; userName?: string; userDisplayName?: string } | undefined;1198    if (!data || data.source !== "peach-passkey-firefox") return;1199    if (detectBrowser() !== "firefox") return;1200    if (data.type !== "create") return;12011202    // Cooldown: a single page interaction can fire multiple credentials.create1203    // calls. Show the offer at most once per 30 seconds per page session.1204    const now = Date.now();1205    if (now - lastPasskeyOfferAt < PASSKEY_OFFER_COOLDOWN_MS) return;1206    lastPasskeyOfferAt = now;12071208    const domain = location.hostname.replace(/^www\./, "");1209    chrome.runtime.sendMessage({1210      type: "GET_PASSKEY_OFFER_DECLINED",1211      payload: { domain },1212    }).then((res: { ok: boolean; data?: boolean } | undefined) => {1213      if (res?.data === true) return;1214      const rpId = data.rpId ?? domain;1215      showPasskeyRegistrationOffer({1216        rpId,1217        rpName: data.rpName,1218        userName: data.userName,1219        userDisplayName: data.userDisplayName,1220      });1221    }).catch(() => {});1222  });1223}12241225// Surface State 11 (passkey-available) only when it adds value:1226//   - Cross-browser entry: Chrome passkey on Firefox or vice versa — neither1227//     browser will auto-route, so the banner explains the re-register need.1228//   - Firefox + same-browser entry: Firefox has no equivalent of1229//     webAuthenticationProxy, so without a banner the user has no signal that1230//     Peach is the source.1231// On Chrome with a same-browser entry: skip — webAuthenticationProxy handles1232// the page's own credentials.get transparently and the popup approval view1233// already surfaces it. A banner here would just be noise.1234//1235// Dedupe across navigation: each navigation re-injects this content script.1236// chrome.storage.session keyed by domain is the natural per-tab/per-session1237// memory; session storage clears when the user closes the browser.1238{1239  function checkPasskeyAvailability() {1240    const domain = location.hostname.replace(/^www\./, "");1241    if (!domain) return;1242    const sessionKey = `peach_state11_shown:${domain}`;1243    chrome.storage.session?.get(sessionKey).then((stored) => {1244      if (stored && (stored as Record<string, unknown>)[sessionKey]) return;1245      chrome.runtime.sendMessage({1246        type: "GET_PASSKEY_FOR_DOMAIN",1247        payload: { domain },1248      }).then((res: { ok: boolean; data?: { rpId: string; userName: string; browser?: "chrome" | "firefox" } | null } | undefined) => {1249        const data = res?.data;1250        if (!data) return;1251        const current = detectBrowser();1252        const isCrossBrowser =1253          data.browser && current !== "unknown" && data.browser !== current;1254        const valuableOnFirefox = current === "firefox";1255        if (!isCrossBrowser && !valuableOnFirefox) return;1256        chrome.storage.session?.set({ [sessionKey]: Date.now() }).catch(() => {});1257        showPasskeyAvailable({1258          rpId: data.rpId,1259          userName: data.userName,1260          fromBrowser: data.browser,1261          currentBrowser: current,1262        });1263      }).catch(() => {});1264    }).catch(() => {});1265  }12661267  // Defer until DOM is interactive — content_scripts run at document_idle but1268  // some pages still mutate visibly afterwards. A short delay also avoids1269  // surfacing the State 11 toast while the user is in the middle of typing.1270  if (document.readyState === "loading") {1271    document.addEventListener("DOMContentLoaded", () => setTimeout(checkPasskeyAvailability, 800));1272  } else {1273    setTimeout(checkPasskeyAvailability, 800);1274  }1275}