index.ts

39.6 KB · raw

1import {2  detectCardForm,3  detectLoginFields,4  getDeepActiveInput,5  getPageDomain,6  isLoginForm,7  isSignupForm,8  isVisible,9} from "./detect";10import {11  CardSelectorEntry,12  injectFieldIcon,13  observeAnchorPosition,14  SelectorEntry,15  showAutofillSelector,16  showCardSelector,17  showPasskeyAvailable,18  showPasskeyProGate,19  showPasskeyRegistrationOffer,20  showRememberSiteToast,21} from "./inject";22import { detectBrowser } from "../shared/browser";23import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";24import { fillCardForm, fillFields, setNativeValue } from "./fill";25import { setupSaveDetection } from "./save";26import { contentMLDetector } from "./content-ml-detector";27import { detectPasswordPolicy } from "./policy";28import type { FieldPrediction } from "../shared/ml";29import { generateTOTP } from "../crypto/totp";30import type { SavedIdentity, VaultEntry } from "../shared/types";3132// WeakMap (not WeakSet) — we keep the per-input cleanup callback as the value33// so we can tear down listeners if we ever need to re-process the same field.34// When the input is GC'd (SPA nav, React re-render), the map entry goes with35// it and we can re-inject without leaking the old handler.36const processedInputs = new WeakMap<HTMLInputElement, () => void>();37const AUTOFILL_FIELD_TYPES = new Set(["password", "username", "email"]);3839let currentDismiss: (() => void) | null = null;40// Field whose selector is currently open — used to avoid dismiss+reopen flicker41let currentAnchorField: HTMLInputElement | null = null;42// Prevents two concurrent handleFieldInteraction calls for the same field43const pendingInteractions = new WeakSet<HTMLInputElement>();44// Tracks the last field the user explicitly dismissed (Escape / click-outside / Tab).45// Suppresses spurious reopens until focus moves to a different field. Layer 146// of the dismissal model — see Projects.md §4.12.47let lastDismissedField: HTMLInputElement | null = null;4849// ── Dismissal lock (Layer 2) ────────────────────────────────────────50// After any user-initiated dismiss, briefly block ALL reopens regardless of51// which field is focused. Catches the case where a page rapidly moves focus52// between fields after Escape — without this, focus moving to a SECOND field53// in <100ms would re-trigger the popup before the user finished dismissing.54const DISMISS_LOCK_MS = 800;55let dismissLock = false;56let dismissLockTimer: ReturnType<typeof setTimeout> | null = null;57// Set when the inline popup was summoned specifically for a passkey-unlock58// flow. The VAULT_UNLOCKED handler checks this to skip re-triggering autofill59// (the passkey toast handles the next step instead).60let passkeySummonActive = false;61function startDismissLock(): void {62  dismissLock = true;63  if (dismissLockTimer) clearTimeout(dismissLockTimer);64  dismissLockTimer = setTimeout(() => { dismissLock = false; }, DISMISS_LOCK_MS);65}66function canOpen(): boolean { return !dismissLock; }6768// ── Fill-success lock ───────────────────────────────────────────────69// After Peach autofills a form, the popup must NOT reappear on the filled70// fields, on the submit button (some sites refocus it briefly), or on any71// field in that form — even when host-page DOM mutations or the form's own72// validation flow refocus an input. Implemented as a WeakSet of forms plus73// a page-level timestamp:74//   - filledForms: every form Peach has filled this page session. Survives75//     until the form leaves the DOM (WeakSet GC).76//   - pageFilledAt: timestamp of last fill. Used as a 3s belt-and-suspenders77//     window for fields that aren't inside a <form> element (some SPAs).78// SPA navigation resets pageFilledAt via the urlObserver below; filledForms79// self-empties as old form elements get GC'd.80const filledForms = new WeakSet<HTMLFormElement>();81let pageFilledAt: number | null = null;82const PAGE_FILL_GRACE_MS = 3000;8384function recordFillSuccess(...fields: (HTMLInputElement | null | undefined)[]): void {85  for (const f of fields) {86    if (!f) continue;87    const form = f.closest("form");88    if (form) filledForms.add(form);89  }90  pageFilledAt = Date.now();91}9293function wasAlreadyFilled(field: HTMLInputElement): boolean {94  const form = field.closest("form");95  if (form && filledForms.has(form)) return true;96  if (pageFilledAt !== null && Date.now() - pageFilledAt < PAGE_FILL_GRACE_MS) return true;97  return false;98}99100function clearFillStateForExplicitSummon(field: HTMLInputElement): void {101  const form = field.closest("form");102  if (form) filledForms.delete(form);103  pageFilledAt = null;104}105106// Some sites briefly focus the submit button when the user clicks it (visual107// feedback, blur/focus dance for validation animations). Catch those at the108// focusin layer so we don't re-trigger handleFieldInteraction for an event109// target that was never going to be a credential field anyway.110function isSubmitButton(el: EventTarget | null): boolean {111  if (!(el instanceof HTMLElement)) return false;112  if (el instanceof HTMLButtonElement && el.type !== "button") return true;113  if (el instanceof HTMLInputElement && el.type === "submit") return true;114  if (el.getAttribute("type") === "submit") return true;115  const text = (el.textContent?.trim() ?? el.getAttribute("value") ?? "").toLowerCase();116  if (!text || text.length > 24) return false;117  return /^(submit|sign[ -]?in|log[ -]?in|continue|next|go)$/i.test(text);118}119120function debounce<T extends (...args: unknown[]) => void>(121  fn: T,122  ms: number123): (...args: Parameters<T>) => void {124  let timer: ReturnType<typeof setTimeout>;125  return (...args: Parameters<T>) => {126    clearTimeout(timer);127    timer = setTimeout(() => fn(...args), ms);128  };129}130131function showToast(message: string): void {132  const toast = document.createElement("div");133  toast.style.cssText = `134    position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);135    background: #131109; color: #F0E8D8; padding: 8px 16px;136    border-radius: 8px; font-family: 'IBM Plex Mono', monospace;137    font-size: 11px; z-index: 2147483647; opacity: 0;138    transition: opacity 200ms ease;139    border: 1px solid rgba(240,232,216,0.07);140    pointer-events: none;141  `;142  toast.textContent = message;143  document.body.appendChild(toast);144  requestAnimationFrame(() => { toast.style.opacity = "1"; });145  setTimeout(() => {146    toast.style.opacity = "0";147    setTimeout(() => toast.remove(), 200);148  }, 2000);149}150151function showSearchPalette(): void {152  const host = document.createElement("div");153  host.style.cssText = "position:fixed;top:80px;left:50%;transform:translateX(-50%);z-index:2147483647;width:340px;";154  document.body.appendChild(host);155  const shadow = host.attachShadow({ mode: "closed" });156157  const style = document.createElement("style");158  style.textContent = `159    .p{background:#131109;border:1px solid rgba(240,232,216,.12);border-radius:12px;160      font-family:'IBM Plex Mono',monospace;font-size:12px;color:#F0E8D8;161      box-shadow:0 8px 24px rgba(0,0,0,.5);overflow:hidden}162    .h{padding:10px 12px 8px;border-bottom:1px solid rgba(240,232,216,.08);163      font-size:11px;color:rgba(240,232,216,.5);display:flex;justify-content:space-between;align-items:center}164    .x{background:none;border:none;color:rgba(240,232,216,.4);cursor:pointer;font-size:14px;padding:0;line-height:1}165    .s{padding:8px 12px}166    .s input{width:100%;box-sizing:border-box;background:rgba(240,232,216,.06);167      border:1px solid rgba(240,232,216,.12);border-radius:6px;color:#F0E8D8;168      padding:6px 8px;font-family:inherit;font-size:12px;outline:none}169    .l{max-height:220px;overflow-y:auto}170    .e{padding:8px 12px;cursor:pointer;display:flex;flex-direction:column;gap:2px}171    .e:hover{background:rgba(240,232,216,.06)}172    .et{font-size:12px}173    .eu{font-size:10px;color:rgba(240,232,216,.45)}174    .em{padding:12px;color:rgba(240,232,216,.4);text-align:center}175  `;176177  let allEntries: SelectorEntry[] = [];178  let query = "";179180  const panel = document.createElement("div");181  panel.className = "p";182183  const hdr = document.createElement("div");184  hdr.className = "h";185  const hdrText = document.createElement("span");186  hdrText.textContent = "No form detected — search your vault";187  const closeBtn = document.createElement("button");188  closeBtn.className = "x";189  closeBtn.textContent = "✕";190  hdr.appendChild(hdrText);191  hdr.appendChild(closeBtn);192193  const searchWrap = document.createElement("div");194  searchWrap.className = "s";195  const searchInput = document.createElement("input");196  searchInput.placeholder = "search...";197  searchWrap.appendChild(searchInput);198199  const list = document.createElement("div");200  list.className = "l";201202  panel.appendChild(hdr);203  panel.appendChild(searchWrap);204  panel.appendChild(list);205  shadow.appendChild(style);206  shadow.appendChild(panel);207208  function close() { host.remove(); }209210  closeBtn.onclick = close;211212  function onKeyDown(e: KeyboardEvent) {213    if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKeyDown, true); }214  }215  document.addEventListener("keydown", onKeyDown, true);216217  function render() {218    const q = query.toLowerCase();219    const filtered = q220      ? allEntries.filter(e => e.title.toLowerCase().includes(q) || (e.username ?? "").toLowerCase().includes(q))221      : allEntries;222    list.innerHTML = "";223    if (filtered.length === 0) {224      const em = document.createElement("div");225      em.className = "em";226      em.textContent = query ? "no matches" : "vault is empty";227      list.appendChild(em);228      return;229    }230    for (const entry of filtered.slice(0, 20)) {231      const row = document.createElement("div");232      row.className = "e";233      const title = document.createElement("div");234      title.className = "et";235      title.textContent = entry.title;236      row.appendChild(title);237      if (entry.username) {238        const user = document.createElement("div");239        user.className = "eu";240        user.textContent = entry.username;241        row.appendChild(user);242      }243      row.onclick = async () => {244        const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } });245        const r = resp as { ok: boolean; data?: { password?: string } };246        if (r.ok && r.data?.password) {247          let alreadyWritten = false;248          try { await navigator.clipboard.writeText(r.data.password); alreadyWritten = true; } catch {}249          await chrome.runtime.sendMessage({250            type: "COPY",251            payload: { text: r.data.password, label: "Password", alreadyWritten },252          });253          showToast("Password copied");254        } else {255          showToast("Could not copy password");256        }257        close();258        document.removeEventListener("keydown", onKeyDown, true);259      };260      list.appendChild(row);261    }262  }263264  searchInput.oninput = () => { query = searchInput.value; render(); };265266  fetchAutofillEntries(getPageDomain(), true).then(result => {267    if (result.locked) { showToast("Vault is locked — open Peach to unlock"); close(); return; }268    allEntries = result.entries;269    render();270    searchInput.focus();271  });272}273274function setNativeValue(el: HTMLInputElement, value: string): void {275  const nativeSetter = Object.getOwnPropertyDescriptor(276    HTMLInputElement.prototype, "value"277  )!.set!;278  nativeSetter.call(el, value);279  el.dispatchEvent(new Event("input", { bubbles: true }));280  el.dispatchEvent(new Event("change", { bubbles: true }));281}282283function findTOTPField(): HTMLInputElement | null {284  const inputs = document.querySelectorAll<HTMLInputElement>("input");285  for (const input of inputs) {286    if (!isVisible(input)) continue;287    const autocomplete = input.autocomplete.toLowerCase();288    const nameId = (input.name + input.id).toLowerCase();289    const nameIdPlaceholder = nameId + input.placeholder.toLowerCase();290    if (291      autocomplete === "one-time-code" ||292      /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(nameIdPlaceholder)293    ) {294      return input;295    }296    const labelEl = input.closest("label");297    if (labelEl && /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(labelEl.textContent?.toLowerCase() ?? "")) {298      return input;299    }300  }301  return null;302}303304async function autofillTOTP(totpField: HTMLInputElement, totpSecret: string): Promise<void> {305  await new Promise((r) => setTimeout(r, 400));306  if (totpField.value === "" || totpField.value.trim() === "") {307    const { code } = generateTOTP(totpSecret);308    setNativeValue(totpField, code);309    showToast("TOTP autofilled");310  }311}312313314async function handleAutofill(315  field: HTMLInputElement,316  usernameEl: HTMLInputElement | null,317  passwordEl: HTMLInputElement,318  entry: VaultEntry319): Promise<void> {320  if (!entry.username && !entry.password) return;321  const pageDomain = getPageDomain();322  const form = passwordEl.closest("form") as HTMLFormElement | null;323  fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain);324325  if (entry.canary) {326    chrome.runtime.sendMessage({327      type: "FIRE_CANARY_PING",328      payload: {329        webhookUrl: entry.canary.webhookUrl,330        entryTitle: entry.title,331        trigger: "autofilled",332      },333    }).catch(() => {});334    chrome.runtime.sendMessage({335      type: "UPDATE_ENTRY",336      payload: {337        id: entry.id,338        patch: {339          canary: {340            ...entry.canary,341            lastPinged: Date.now(),342            pingCount: (entry.canary.pingCount ?? 0) + 1,343          },344        },345      },346    }).catch(() => {});347  }348349  if (entry.totp) {350    const { code } = generateTOTP(entry.totp);351    let alreadyWritten = false;352    try { await navigator.clipboard.writeText(code); alreadyWritten = true; } catch {}353    const response = await chrome.runtime.sendMessage({354      type: "COPY",355      payload: { text: code, label: "TOTP", alreadyWritten },356    });357    if (alreadyWritten || (response as { ok?: boolean }).ok) {358      showToast("TOTP copied · " + code);359    }360    const totpField = findTOTPField();361    if (totpField) {362      autofillTOTP(totpField, entry.totp);363    }364  }365}366367async function fetchAutofillEntries(368  domain: string,369  includeAll = false370): Promise<{ entries: SelectorEntry[]; locked: boolean }> {371  const response = await chrome.runtime.sendMessage({372    type: "AUTOFILL_QUERY",373    payload: { domain, includeAll },374  });375  const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };376  if (!res.ok && res.error === "VAULT_LOCKED") {377    return { entries: [], locked: true };378  }379  return { entries: res.data ?? [], locked: false };380}381382383function resolveAutofillFieldType(384  el: HTMLInputElement,385  predictedType?: FieldPrediction["type"] | null386): "password" | "username" | "email" | null {387  if (388    predictedType === "password" ||389    predictedType === "username" ||390    predictedType === "email"391  ) {392    return predictedType;393  }394395  const detected = detectLoginFields().find((field) => field.el === el)?.type;396  if (detected && AUTOFILL_FIELD_TYPES.has(detected)) {397    return detected;398  }399400  const htmlType = el.type.toLowerCase();401  if (htmlType === "password") return "password";402  if (htmlType === "email") return "email";403  return null;404}405406async function openAutofillSelector(407  anchorField: HTMLInputElement,408  usernameEl: HTMLInputElement | null,409  passwordEl: HTMLInputElement,410  domainEntries: SelectorEntry[],411  searchableEntries: SelectorEntry[],412  domain: string413): Promise<void> {414  // No saved entries for this site → signup/new-account mode.415  // Fetch vault identities for the identity-first surface and locate416  // the confirm-password field so "Copy & fill" can populate it too.417  const isSignup = domainEntries.length === 0;418  let savedIdentities: SavedIdentity[] | undefined;419  let confirmEl: HTMLInputElement | null = null;420  if (isSignup) {421    try {422      const idResp = await chrome.runtime.sendMessage({ type: "GET_IDENTITIES" }).catch(() => null) as423        { ok: boolean; data?: SavedIdentity[] } | null;424      if (idResp?.ok && Array.isArray(idResp.data)) savedIdentities = idResp.data;425    } catch { /* silent fallback */ }426427    const form = passwordEl.closest("form") ?? document.body;428    const allPwInputs = Array.from(form.querySelectorAll<HTMLInputElement>("input[type='password']"));429    confirmEl = allPwInputs.find(430      (el) => el !== passwordEl && /confirm|repeat|verify/i.test(el.name + el.id + el.placeholder)431    ) ?? (allPwInputs.length >= 2 ? allPwInputs.find((el) => el !== passwordEl) ?? null : null);432  }433434  currentDismiss?.();435  currentAnchorField = anchorField;436  currentDismiss = showAutofillSelector(437    anchorField,438    domainEntries,439    async (id: string) => {440      currentDismiss = null;441      currentAnchorField = null;442      const entryResponse = await chrome.runtime.sendMessage({443        type: "GET_ENTRY_FOR_FILL",444        payload: { id },445      });446      const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;447      if (entry) {448        await handleAutofill(anchorField, usernameEl, passwordEl, entry);449        recordFillSuccess(anchorField, usernameEl, passwordEl);450        // Offer to remember the current site for this entry only if none of451        // its known domains (primary + additional) match — otherwise the452        // user already mapped it and the prompt would be noise.453        const pageDomain = location.hostname.replace(/^www\./, "");454        const entryDomains = new Set<string>();455        for (const u of [entry.url, ...(entry.additionalUrls ?? [])]) {456          try {457            if (u) entryDomains.add(new URL(u).hostname.replace(/^www\./, ""));458          } catch { /* ignore unparseable */ }459        }460        if (pageDomain && entryDomains.size > 0 && !entryDomains.has(pageDomain)) {461          showRememberSiteToast(pageDomain, entry.id);462        }463      }464    },465    {466      domain,467      searchableEntries,468      isSignup,469      savedIdentities,470      onFillFields: (username, password) => {471        const form = passwordEl.closest("form") as HTMLFormElement | null;472        fillFields(usernameEl, passwordEl, username, password, form, getPageDomain());473        if (password && confirmEl) setNativeValue(confirmEl, password);474        recordFillSuccess(anchorField, usernameEl, passwordEl);475      },476      onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },477    }478  );479}480481482async function handleFieldInteraction(anchorField?: HTMLInputElement | null): Promise<void> {483  const field = anchorField ?? getDeepActiveInput();484  if (!field) return;485  if (!isVisible(field)) return;486487  // Layer 2: post-dismiss global lock. Catches rapid focus moves between488  // fields immediately after Escape/click-outside.489  if (!canOpen()) return;490491  // If a selector is already open for this exact field, don't flicker it.492  if (currentDismiss && currentAnchorField === field) return;493494  // If focus moved to a different field, the previous per-field dismiss lock495  // no longer applies.496  if (field !== lastDismissedField) lastDismissedField = null;497  // Layer 1: the user explicitly dismissed this field's selector — don't498  // reopen it until focus has genuinely moved away and come back.499  if (field === lastDismissedField) return;500501  // Fill-success lock: Peach already filled this form (or fired a fill502  // anywhere on the page within the grace window). Sites often refocus an503  // input on submit click, on validation, or on a transition to a success504  // state — none of those should re-open the popup.505  if (wasAlreadyFilled(field)) return;506507  // Prevent two concurrent calls for the same field (e.g. focusin + icon click508  // firing simultaneously — the second call would dismiss the first's selector).509  if (pendingInteractions.has(field)) return;510  pendingInteractions.add(field);511512  try {513    const fieldType = field.type.toLowerCase();514515    const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {516      return preds[0] ?? null;517    });518519    const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {520      const timeout = setTimeout(() => resolve(null), 150);521      mlPromise.then((pred) => {522        clearTimeout(timeout);523        resolve(pred);524      });525    });526527    if (fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {528      return;529    }530531    const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);532    if (autofillFieldType) {533      reattachListeners(field, autofillFieldType);534    }535536    let fields = detectLoginFields();537    const loginForm = isLoginForm(fields);538    const signupForm = isSignupForm(fields);539    if (!loginForm && !signupForm) {540      return;541    }542543    const domain = getPageDomain();544    const firstResult = await fetchAutofillEntries(domain);545546    // Vault is locked — show an inline unlock prompt.547    // Re-triggering after unlock is handled by the VAULT_UNLOCKED message so548    // there is no double-trigger race between onUnlocked and the broadcast.549    if (firstResult.locked) {550      currentDismiss?.();551      currentAnchorField = field;552      currentDismiss = showAutofillSelector(553        field,554        [],555        () => {},556        { domain, locked: true, onDismiss: () => { lastDismissedField = field; startDismissLock(); } }557      );558      return;559    }560561    const entries = firstResult.entries;562    const allEntries = entries.length === 0563      ? (await fetchAutofillEntries(domain, true)).entries564      : entries;565566    let usernameEl: HTMLInputElement | null = null;567    const userField = fields.find((f) => f.type === "username" || f.type === "email");568    if (userField) usernameEl = userField.el;569    const pwField = fields.find((f) => f.type === "password");570    if (!pwField) return;571    const passwordEl = pwField.el;572573    await openAutofillSelector(field, usernameEl, passwordEl, entries, allEntries, domain);574  } finally {575    pendingInteractions.delete(field);576  }577}578579async function onFocusIn(e: Event): Promise<void> {580  // Submit-button focus: catch the brief refocus some sites do when the581  // user clicks the login/submit button. Without this guard, that focus582  // event reaches handleFieldInteraction → getDeepActiveInput, and on583  // pages where the button is also a labelled <input> we briefly attempt584  // to open the popup over the very button the user just clicked.585  if (isSubmitButton(e.target)) return;586  await handleFieldInteraction();587}588589function reattachListeners(590  el: HTMLInputElement,591  detectedType?: "password" | "username" | "email",592  contextFields?: ReturnType<typeof detectLoginFields>593): void {594  if (processedInputs.has(el)) return;595  if (!resolveAutofillFieldType(el, detectedType)) return;596  // Only inject icon when a real login/signup form is present on the page597  const fields = contextFields ?? detectLoginFields();598  if (!isLoginForm(fields) && !isSignupForm(fields)) return;599600  const cleanup = injectFieldIcon(el, () => {601    currentDismiss?.();602    currentDismiss = null;603    lastDismissedField = null; // icon click is an explicit user open — clear per-field lock604    dismissLock = false; // ...and the global lock605    if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }606    clearFillStateForExplicitSummon(el);607    el.focus({ preventScroll: true });608    void handleFieldInteraction(el);609  });610  processedInputs.set(el, cleanup);611}612613const debouncedReattach = debounce(() => {614  const allFields = detectLoginFields();615  // Don't inject anything if there's no real login/signup form on this page616  if (!isLoginForm(allFields) && !isSignupForm(allFields)) return;617  const newFields = allFields.filter(618    (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)619  );620  for (const f of newFields) {621    reattachListeners(f.el, f.type, allFields);622  }623  // 200ms debounce — below this, Peach and the host page's reactive layer624  // fight over the same layout properties every frame on React/Vue/Angular625  // login forms (v1.4.14 regression, Projects.md §4.7). Do not lower.626}, 200);627628function setupMutationObserver(): void {629  const observer = new MutationObserver(debouncedReattach);630  observer.observe(document.body, {631    childList: true,632    subtree: true,633    attributes: true,634    attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],635  });636}637638function initialScan(aggressionLevel = 0, isFinalPass = false) {639  const allFields = detectLoginFields();640  const hasKnownForm = isLoginForm(allFields) || isSignupForm(allFields);641642  if (aggressionLevel === 0 && !hasKnownForm) return;643644  const newFields = allFields.filter(645    (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)646  );647648  for (const f of newFields) {649    reattachListeners(f.el, f.type, allFields);650  }651652  // Level 2, final pass: inject icon on any visible password field even if653  // the form-context gate in reattachListeners would normally skip it.654  if (aggressionLevel >= 2 && isFinalPass && newFields.length === 0) {655    const pwInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');656    for (const el of pwInputs) {657      if (isVisible(el) && !processedInputs.has(el)) {658        const cleanup = injectFieldIcon(el, () => {659          currentDismiss?.();660          currentDismiss = null;661          lastDismissedField = null;662          dismissLock = false;663          if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }664          clearFillStateForExplicitSummon(el);665          el.focus({ preventScroll: true });666          void handleFieldInteraction(el);667        });668        processedInputs.set(el, cleanup);669      }670    }671  }672}673674document.addEventListener("focusin", onFocusIn);675676setupMutationObserver();677setupSaveDetection();678setupCardAutofill();679680// SPA navigation reset: the content script does not reload across in-app681// route changes (history.pushState, hash navigation, framework routers).682// When the URL changes we clear the page-level fill timestamp so the next683// page works normally; filledForms (a WeakSet) self-empties as old form684// elements leave the DOM. subtree:false because route swaps reliably685// mutate document.body's direct children — subtree:true would fire on686// every internal DOM update and cost us a re-check per mutation.687let lastObservedUrl = location.href;688new MutationObserver(() => {689  if (location.href !== lastObservedUrl) {690    lastObservedUrl = location.href;691    pageFilledAt = null;692  }693}).observe(document.body, { childList: true, subtree: false });694695// Async init: check recall map before running the initial scans so we can696// apply the right aggression level for domains the user has needed before.697(async () => {698  let aggressionLevel = 0;699  try {700    const resp = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });701    const s = (resp as { ok: boolean; data?: { manualSummonDomains?: Record<string, { summonCount: number }> } }).data;702    const domain = getPageDomain();703    const record = domain ? s?.manualSummonDomains?.[domain] : undefined;704    aggressionLevel = !record ? 0 : record.summonCount >= 2 ? 2 : 1;705  } catch { /* SW may be starting up; proceed with normal scan */ }706707  setTimeout(() => initialScan(aggressionLevel, false), 0);708  setTimeout(() => initialScan(aggressionLevel, false), 500);709  setTimeout(() => initialScan(aggressionLevel, true), 1500);710})();711712// ── Card autofill (State 7) ─────────────────────────────────────────713// Card forms get a parallel injection path. The login icon-injection gate714// in reattachListeners deliberately skips non-text fields, so we wire715// cards independently rather than retrofit AUTOFILL_FIELD_TYPES — keeping716// card detection contained makes the boundary easy to follow.717const processedCardInputs = new WeakMap<HTMLInputElement, () => void>();718let currentCardDismiss: (() => void) | null = null;719let currentCardAnchor: HTMLInputElement | null = null;720let lastDismissedCardField: HTMLInputElement | null = null;721722async function fetchSavedCards(): Promise<CardSelectorEntry[]> {723  const resp = await chrome.runtime.sendMessage({724    type: "AUTOFILL_QUERY",725    payload: { domain: getPageDomain(), kind: "card" },726  }).catch(() => null);727  const r = resp as { ok: boolean; data?: CardSelectorEntry[] } | null;728  return r?.ok ? (r.data ?? []) : [];729}730731async function openCardSelector(anchor: HTMLInputElement): Promise<void> {732  if (!canOpen()) return;733  if (currentCardDismiss && currentCardAnchor === anchor) return;734  if (anchor === lastDismissedCardField) return;735  if (anchor !== lastDismissedCardField) lastDismissedCardField = null;736  if (wasAlreadyFilled(anchor)) return;737738  const cards = await fetchSavedCards();739  if (cards.length === 0) return; // no saved cards → nothing to offer740741  const cardForm = detectCardForm();742  if (!cardForm?.number) return;743744  currentCardDismiss?.();745  currentCardAnchor = anchor;746  currentCardDismiss = showCardSelector(747    anchor,748    cards,749    {750      domain: getPageDomain(),751      onFill: (entry) => {752        if (!cardForm) return;753        fillCardForm(cardForm, {754          cardNumber: entry.cardNumber ?? "",755          expMonth: entry.expMonth ?? "",756          expYear: entry.expYear ?? "",757          cvc: entry.cvc ?? "",758          cardholderName: entry.cardholderName ?? "",759        });760        // All four card field types share the same form — recording the761        // number field's form is sufficient to suppress reopens on any of762        // them via wasAlreadyFilled().763        recordFillSuccess(cardForm.number, cardForm.cvc, cardForm.expiryCombo, cardForm.name);764        currentCardDismiss = null;765        currentCardAnchor = null;766      },767      onDismiss: () => {768        lastDismissedCardField = anchor;769        startDismissLock();770        currentCardDismiss = null;771        currentCardAnchor = null;772      },773    }774  );775}776777function injectCardIcon(field: HTMLInputElement): void {778  if (processedCardInputs.has(field)) return;779  const cleanup = injectFieldIcon(field, () => {780    currentCardDismiss?.();781    currentCardDismiss = null;782    lastDismissedCardField = null;783    dismissLock = false;784    if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }785    clearFillStateForExplicitSummon(field);786    field.focus({ preventScroll: true });787    void openCardSelector(field);788  });789  processedCardInputs.set(field, cleanup);790}791792function setupCardAutofill(): void {793  function scan() {794    const cardForm = detectCardForm();795    if (!cardForm?.number) return;796    if (!isVisible(cardForm.number)) return;797    injectCardIcon(cardForm.number);798  }799800  // Initial detection sweep — same retry cadence as login fields so801  // late-hydrating SPA checkout pages get caught.802  setTimeout(scan, 0);803  setTimeout(scan, 500);804  setTimeout(scan, 1500);805806  // Re-scan on DOM mutation (separate from the login MutationObserver807  // because card forms typically appear on a single navigation event,808  // not field-by-field — coarser observation is sufficient here).809  let pending = false;810  new MutationObserver(() => {811    if (pending) return;812    pending = true;813    setTimeout(() => { pending = false; scan(); }, 200);814  }).observe(document.body, { childList: true, subtree: true });815816  // Field-focus path: on focus of a cc-number input, ensure icon is817  // present and consider opening the selector when the user explicitly818  // clicks into the card number field.819  document.addEventListener("focusin", (e) => {820    const target = e.target;821    if (!(target instanceof HTMLInputElement)) return;822    const cardForm = detectCardForm();823    if (cardForm?.number === target) {824      injectCardIcon(target);825    }826  }, true);827}828829chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {830  // Ignore anything not originating from our own background/service worker.831  if (sender.id !== chrome.runtime.id) return;832  if (msg?.type === "GET_SITE_PASSWORD_POLICY") {833    const policy = detectPasswordPolicy();834    sendResponse({ ok: true, data: policy });835    return true;836  }837  if (msg?.type === "PING") {838    sendResponse({ ok: true });839    return true;840  }841  if (msg?.type === "SUMMON_AUTOFILL") {842    const source = (msg as { type: string; source?: string }).source;843    const isShortcut = source === "shortcut";844    const isPasskeyUnlock = source === "passkey-unlock";845846    const activeInput = getDeepActiveInput();847    const fields = detectLoginFields();848    const hasForm = isLoginForm(fields) || isSignupForm(fields);849850    if (hasForm) {851      // Prefer the currently focused field if it's a login field, otherwise852      // fall back to the first password field, then username/email.853      const inForm = activeInput && fields.some(f => f.el === activeInput);854      const target = inForm855        ? activeInput856        : (fields.find(f => f.type === "password")?.el857            ?? fields.find(f => f.type === "username" || f.type === "email")?.el);858      if (target) {859        // Explicit user summon (shortcut or extension button) — bypass all860        // suppression layers. The user is asking for the popup right now.861        clearFillStateForExplicitSummon(target);862        dismissLock = false;863        if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }864        lastDismissedField = null;865        void handleFieldInteraction(target);866      }867    } else if (isPasskeyUnlock) {868      // Vault is locked during a passkey GET request. Show the inline unlock869      // prompt anchored to whatever element is focused on the page (typically870      // the passkey/sign-in button the user just tapped).871      const anchor = (document.activeElement instanceof HTMLElement && document.activeElement !== document.body)872        ? document.activeElement as unknown as HTMLInputElement873        : null;874      if (anchor) {875        currentDismiss?.();876        currentAnchorField = null;877        passkeySummonActive = true;878        currentDismiss = showAutofillSelector(anchor, [], () => {}, {879          domain: getPageDomain(),880          locked: true,881          onDismiss: () => { passkeySummonActive = false; },882        });883      }884    } else if (isShortcut) {885      // Shortcut on a page with no login form — show clipboard-copy search mode.886      showSearchPalette();887    }888889    // Record this domain for the recall system when triggered via shortcut.890    if (isShortcut) {891      const domain = getPageDomain();892      if (domain) {893        chrome.runtime.sendMessage({ type: "RECORD_MANUAL_SUMMON", payload: { domain } }).catch(() => {});894      }895    }896897    sendResponse({ ok: hasForm || isShortcut || isPasskeyUnlock });898    return true;899  }900  if (msg?.type === "PASSKEY_PRO_GATE") {901    // Background rejected a passkey request for a free user. Show State 11902    // so they know what happened and can choose to upgrade.903    showPasskeyProGate();904    sendResponse({ ok: true });905    return true;906  }907  if (msg?.type === "VAULT_UNLOCKED") {908    if (passkeySummonActive) {909      // This unlock was for a pending passkey GET request. Just dismiss the910      // inline popup — the passkey approval toast takes over from here.911      passkeySummonActive = false;912      if (currentDismiss) {913        currentDismiss();914        currentDismiss = null;915        currentAnchorField = null;916      }917      sendResponse({ ok: true });918      return true;919    }920    // Normal autofill path: close the locked-state prompt and re-open with921    // fresh entry data at the same anchor field.922    const savedField = currentAnchorField;923    if (currentDismiss) {924      currentDismiss();925      currentDismiss = null;926      currentAnchorField = null;927    }928    void handleFieldInteraction(savedField ?? undefined);929    sendResponse({ ok: true });930    return true;931  }932  return true;933});934935// ── Firefox passkey detection bridge (legacy observer path) ─────────────936//937// Pre-v2.12.0 the MAIN-world `passkeys-firefox.ts` was an observer — it938// emitted `peach-passkey-firefox` postMessages so this content script could939// surface State 12 (an inline "Save passkey to Peach" offer). v2.12.0940// replaced the observer with a full interception proxy (the page never941// reaches a real navigator.credentials.create on Firefox anymore), and the942// approval UI is opened by the background as part of the ceremony itself.943// When the proxy is active the offer would be redundant noise, so the944// listener gates on the flag. Once the proxy is removed (Firefox ships945// webAuthenticationProxy) this whole block goes too.946if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {947  let lastPasskeyOfferAt = 0;948  const PASSKEY_OFFER_COOLDOWN_MS = 30_000;949950  window.addEventListener("message", (event: MessageEvent) => {951    if (event.source !== window) return;952    const data = event.data as { source?: string; type?: string; rpId?: string; rpName?: string; userName?: string; userDisplayName?: string } | undefined;953    if (!data || data.source !== "peach-passkey-firefox") return;954    if (detectBrowser() !== "firefox") return;955    if (data.type !== "create") return;956957    // Cooldown: a single page interaction can fire multiple credentials.create958    // calls. Show the offer at most once per 30 seconds per page session.959    const now = Date.now();960    if (now - lastPasskeyOfferAt < PASSKEY_OFFER_COOLDOWN_MS) return;961    lastPasskeyOfferAt = now;962963    const domain = location.hostname.replace(/^www\./, "");964    chrome.runtime.sendMessage({965      type: "GET_PASSKEY_OFFER_DECLINED",966      payload: { domain },967    }).then((res: { ok: boolean; data?: boolean } | undefined) => {968      if (res?.data === true) return;969      const rpId = data.rpId ?? domain;970      showPasskeyRegistrationOffer({971        rpId,972        rpName: data.rpName,973        userName: data.userName,974        userDisplayName: data.userDisplayName,975      });976    }).catch(() => {});977  });978}979980// Surface State 11 (passkey-available) only when it adds value:981//   - Cross-browser entry: Chrome passkey on Firefox or vice versa — neither982//     browser will auto-route, so the banner explains the re-register need.983//   - Firefox + same-browser entry: Firefox has no equivalent of984//     webAuthenticationProxy, so without a banner the user has no signal that985//     Peach is the source.986// On Chrome with a same-browser entry: skip — webAuthenticationProxy handles987// the page's own credentials.get transparently and the popup approval view988// already surfaces it. A banner here would just be noise.989//990// Dedupe across navigation: each navigation re-injects this content script.991// chrome.storage.session keyed by domain is the natural per-tab/per-session992// memory; session storage clears when the user closes the browser.993{994  function checkPasskeyAvailability() {995    const domain = location.hostname.replace(/^www\./, "");996    if (!domain) return;997    const sessionKey = `peach_state11_shown:${domain}`;998    chrome.storage.session?.get(sessionKey).then((stored) => {999      if (stored && (stored as Record<string, unknown>)[sessionKey]) return;1000      chrome.runtime.sendMessage({1001        type: "GET_PASSKEY_FOR_DOMAIN",1002        payload: { domain },1003      }).then((res: { ok: boolean; data?: { rpId: string; userName: string; browser?: "chrome" | "firefox" } | null } | undefined) => {1004        const data = res?.data;1005        if (!data) return;1006        const current = detectBrowser();1007        const isCrossBrowser =1008          data.browser && current !== "unknown" && data.browser !== current;1009        const valuableOnFirefox = current === "firefox";1010        if (!isCrossBrowser && !valuableOnFirefox) return;1011        chrome.storage.session?.set({ [sessionKey]: Date.now() }).catch(() => {});1012        showPasskeyAvailable({1013          rpId: data.rpId,1014          userName: data.userName,1015          fromBrowser: data.browser,1016          currentBrowser: current,1017        });1018      }).catch(() => {});1019    }).catch(() => {});1020  }10211022  // Defer until DOM is interactive — content_scripts run at document_idle but1023  // some pages still mutate visibly afterwards. A short delay also avoids1024  // surfacing the State 11 toast while the user is in the middle of typing.1025  if (document.readyState === "loading") {1026    document.addEventListener("DOMContentLoaded", () => setTimeout(checkPasskeyAvailability, 800));1027  } else {1028    setTimeout(checkPasskeyAvailability, 800);1029  }1030}