index.ts

34.9 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  showPasskeyProGate,18  showRememberSiteToast,19} from "./inject";20import { fillCardForm, fillFields } from "./fill";21import { setupSaveDetection } from "./save";22import { contentMLDetector } from "./content-ml-detector";23import { detectPasswordPolicy } from "./policy";24import type { FieldPrediction } from "../shared/ml";25import { generateTOTP } from "../crypto/totp";26import type { SavedIdentity, VaultEntry } from "../shared/types";2728// WeakMap (not WeakSet) — we keep the per-input cleanup callback as the value29// so we can tear down listeners if we ever need to re-process the same field.30// When the input is GC'd (SPA nav, React re-render), the map entry goes with31// it and we can re-inject without leaking the old handler.32const processedInputs = new WeakMap<HTMLInputElement, () => void>();33const AUTOFILL_FIELD_TYPES = new Set(["password", "username", "email"]);3435let currentDismiss: (() => void) | null = null;36// Field whose selector is currently open — used to avoid dismiss+reopen flicker37let currentAnchorField: HTMLInputElement | null = null;38// Prevents two concurrent handleFieldInteraction calls for the same field39const pendingInteractions = new WeakSet<HTMLInputElement>();40// Tracks the last field the user explicitly dismissed (Escape / click-outside / Tab).41// Suppresses spurious reopens until focus moves to a different field. Layer 142// of the dismissal model — see Projects.md §4.12.43let lastDismissedField: HTMLInputElement | null = null;4445// ── Dismissal lock (Layer 2) ────────────────────────────────────────46// After any user-initiated dismiss, briefly block ALL reopens regardless of47// which field is focused. Catches the case where a page rapidly moves focus48// between fields after Escape — without this, focus moving to a SECOND field49// in <100ms would re-trigger the popup before the user finished dismissing.50const DISMISS_LOCK_MS = 800;51let dismissLock = false;52let dismissLockTimer: ReturnType<typeof setTimeout> | null = null;53function startDismissLock(): void {54  dismissLock = true;55  if (dismissLockTimer) clearTimeout(dismissLockTimer);56  dismissLockTimer = setTimeout(() => { dismissLock = false; }, DISMISS_LOCK_MS);57}58function canOpen(): boolean { return !dismissLock; }5960// ── Fill-success lock ───────────────────────────────────────────────61// After Peach autofills a form, the popup must NOT reappear on the filled62// fields, on the submit button (some sites refocus it briefly), or on any63// field in that form — even when host-page DOM mutations or the form's own64// validation flow refocus an input. Implemented as a WeakSet of forms plus65// a page-level timestamp:66//   - filledForms: every form Peach has filled this page session. Survives67//     until the form leaves the DOM (WeakSet GC).68//   - pageFilledAt: timestamp of last fill. Used as a 3s belt-and-suspenders69//     window for fields that aren't inside a <form> element (some SPAs).70// SPA navigation resets pageFilledAt via the urlObserver below; filledForms71// self-empties as old form elements get GC'd.72const filledForms = new WeakSet<HTMLFormElement>();73let pageFilledAt: number | null = null;74const PAGE_FILL_GRACE_MS = 3000;7576function recordFillSuccess(...fields: (HTMLInputElement | null | undefined)[]): void {77  for (const f of fields) {78    if (!f) continue;79    const form = f.closest("form");80    if (form) filledForms.add(form);81  }82  pageFilledAt = Date.now();83}8485function wasAlreadyFilled(field: HTMLInputElement): boolean {86  const form = field.closest("form");87  if (form && filledForms.has(form)) return true;88  if (pageFilledAt !== null && Date.now() - pageFilledAt < PAGE_FILL_GRACE_MS) return true;89  return false;90}9192function clearFillStateForExplicitSummon(field: HTMLInputElement): void {93  const form = field.closest("form");94  if (form) filledForms.delete(form);95  pageFilledAt = null;96}9798// Some sites briefly focus the submit button when the user clicks it (visual99// feedback, blur/focus dance for validation animations). Catch those at the100// focusin layer so we don't re-trigger handleFieldInteraction for an event101// target that was never going to be a credential field anyway.102function isSubmitButton(el: EventTarget | null): boolean {103  if (!(el instanceof HTMLElement)) return false;104  if (el instanceof HTMLButtonElement && el.type !== "button") return true;105  if (el instanceof HTMLInputElement && el.type === "submit") return true;106  if (el.getAttribute("type") === "submit") return true;107  const text = (el.textContent?.trim() ?? el.getAttribute("value") ?? "").toLowerCase();108  if (!text || text.length > 24) return false;109  return /^(submit|sign[ -]?in|log[ -]?in|continue|next|go)$/i.test(text);110}111112function debounce<T extends (...args: unknown[]) => void>(113  fn: T,114  ms: number115): (...args: Parameters<T>) => void {116  let timer: ReturnType<typeof setTimeout>;117  return (...args: Parameters<T>) => {118    clearTimeout(timer);119    timer = setTimeout(() => fn(...args), ms);120  };121}122123function showToast(message: string): void {124  const toast = document.createElement("div");125  toast.style.cssText = `126    position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);127    background: #131109; color: #F0E8D8; padding: 8px 16px;128    border-radius: 8px; font-family: 'IBM Plex Mono', monospace;129    font-size: 11px; z-index: 2147483647; opacity: 0;130    transition: opacity 200ms ease;131    border: 1px solid rgba(240,232,216,0.07);132    pointer-events: none;133  `;134  toast.textContent = message;135  document.body.appendChild(toast);136  requestAnimationFrame(() => { toast.style.opacity = "1"; });137  setTimeout(() => {138    toast.style.opacity = "0";139    setTimeout(() => toast.remove(), 200);140  }, 2000);141}142143function showSearchPalette(): void {144  const host = document.createElement("div");145  host.style.cssText = "position:fixed;top:80px;left:50%;transform:translateX(-50%);z-index:2147483647;width:340px;";146  document.body.appendChild(host);147  const shadow = host.attachShadow({ mode: "closed" });148149  const style = document.createElement("style");150  style.textContent = `151    .p{background:#131109;border:1px solid rgba(240,232,216,.12);border-radius:12px;152      font-family:'IBM Plex Mono',monospace;font-size:12px;color:#F0E8D8;153      box-shadow:0 8px 24px rgba(0,0,0,.5);overflow:hidden}154    .h{padding:10px 12px 8px;border-bottom:1px solid rgba(240,232,216,.08);155      font-size:11px;color:rgba(240,232,216,.5);display:flex;justify-content:space-between;align-items:center}156    .x{background:none;border:none;color:rgba(240,232,216,.4);cursor:pointer;font-size:14px;padding:0;line-height:1}157    .s{padding:8px 12px}158    .s input{width:100%;box-sizing:border-box;background:rgba(240,232,216,.06);159      border:1px solid rgba(240,232,216,.12);border-radius:6px;color:#F0E8D8;160      padding:6px 8px;font-family:inherit;font-size:12px;outline:none}161    .l{max-height:220px;overflow-y:auto}162    .e{padding:8px 12px;cursor:pointer;display:flex;flex-direction:column;gap:2px}163    .e:hover{background:rgba(240,232,216,.06)}164    .et{font-size:12px}165    .eu{font-size:10px;color:rgba(240,232,216,.45)}166    .em{padding:12px;color:rgba(240,232,216,.4);text-align:center}167  `;168169  let allEntries: SelectorEntry[] = [];170  let query = "";171172  const panel = document.createElement("div");173  panel.className = "p";174175  const hdr = document.createElement("div");176  hdr.className = "h";177  const hdrText = document.createElement("span");178  hdrText.textContent = "No form detected — search your vault";179  const closeBtn = document.createElement("button");180  closeBtn.className = "x";181  closeBtn.textContent = "✕";182  hdr.appendChild(hdrText);183  hdr.appendChild(closeBtn);184185  const searchWrap = document.createElement("div");186  searchWrap.className = "s";187  const searchInput = document.createElement("input");188  searchInput.placeholder = "search...";189  searchWrap.appendChild(searchInput);190191  const list = document.createElement("div");192  list.className = "l";193194  panel.appendChild(hdr);195  panel.appendChild(searchWrap);196  panel.appendChild(list);197  shadow.appendChild(style);198  shadow.appendChild(panel);199200  function close() { host.remove(); }201202  closeBtn.onclick = close;203204  function onKeyDown(e: KeyboardEvent) {205    if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKeyDown, true); }206  }207  document.addEventListener("keydown", onKeyDown, true);208209  function render() {210    const q = query.toLowerCase();211    const filtered = q212      ? allEntries.filter(e => e.title.toLowerCase().includes(q) || (e.username ?? "").toLowerCase().includes(q))213      : allEntries;214    list.innerHTML = "";215    if (filtered.length === 0) {216      const em = document.createElement("div");217      em.className = "em";218      em.textContent = query ? "no matches" : "vault is empty";219      list.appendChild(em);220      return;221    }222    for (const entry of filtered.slice(0, 20)) {223      const row = document.createElement("div");224      row.className = "e";225      const title = document.createElement("div");226      title.className = "et";227      title.textContent = entry.title;228      row.appendChild(title);229      if (entry.username) {230        const user = document.createElement("div");231        user.className = "eu";232        user.textContent = entry.username;233        row.appendChild(user);234      }235      row.onclick = async () => {236        const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } });237        const r = resp as { ok: boolean; data?: { password?: string } };238        if (r.ok && r.data?.password) {239          let alreadyWritten = false;240          try { await navigator.clipboard.writeText(r.data.password); alreadyWritten = true; } catch {}241          await chrome.runtime.sendMessage({242            type: "COPY",243            payload: { text: r.data.password, label: "Password", alreadyWritten },244          });245          showToast("Password copied");246        } else {247          showToast("Could not copy password");248        }249        close();250        document.removeEventListener("keydown", onKeyDown, true);251      };252      list.appendChild(row);253    }254  }255256  searchInput.oninput = () => { query = searchInput.value; render(); };257258  fetchAutofillEntries(getPageDomain(), true).then(result => {259    if (result.locked) { showToast("Vault is locked — open Peach to unlock"); close(); return; }260    allEntries = result.entries;261    render();262    searchInput.focus();263  });264}265266function setNativeValue(el: HTMLInputElement, value: string): void {267  const nativeSetter = Object.getOwnPropertyDescriptor(268    HTMLInputElement.prototype, "value"269  )!.set!;270  nativeSetter.call(el, value);271  el.dispatchEvent(new Event("input", { bubbles: true }));272  el.dispatchEvent(new Event("change", { bubbles: true }));273}274275function findTOTPField(): HTMLInputElement | null {276  const inputs = document.querySelectorAll<HTMLInputElement>("input");277  for (const input of inputs) {278    if (!isVisible(input)) continue;279    const autocomplete = input.autocomplete.toLowerCase();280    const nameId = (input.name + input.id).toLowerCase();281    const nameIdPlaceholder = nameId + input.placeholder.toLowerCase();282    if (283      autocomplete === "one-time-code" ||284      /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(nameIdPlaceholder)285    ) {286      return input;287    }288    const labelEl = input.closest("label");289    if (labelEl && /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(labelEl.textContent?.toLowerCase() ?? "")) {290      return input;291    }292  }293  return null;294}295296async function autofillTOTP(totpField: HTMLInputElement, totpSecret: string): Promise<void> {297  await new Promise((r) => setTimeout(r, 400));298  if (totpField.value === "" || totpField.value.trim() === "") {299    const { code } = generateTOTP(totpSecret);300    setNativeValue(totpField, code);301    showToast("TOTP autofilled");302  }303}304305306async function handleAutofill(307  field: HTMLInputElement,308  usernameEl: HTMLInputElement | null,309  passwordEl: HTMLInputElement,310  entry: VaultEntry311): Promise<void> {312  if (!entry.username && !entry.password) return;313  const pageDomain = getPageDomain();314  const form = passwordEl.closest("form") as HTMLFormElement | null;315  fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain);316317  if (entry.canary) {318    chrome.runtime.sendMessage({319      type: "FIRE_CANARY_PING",320      payload: {321        webhookUrl: entry.canary.webhookUrl,322        entryTitle: entry.title,323        trigger: "autofilled",324      },325    }).catch(() => {});326    chrome.runtime.sendMessage({327      type: "UPDATE_ENTRY",328      payload: {329        id: entry.id,330        patch: {331          canary: {332            ...entry.canary,333            lastPinged: Date.now(),334            pingCount: (entry.canary.pingCount ?? 0) + 1,335          },336        },337      },338    }).catch(() => {});339  }340341  if (entry.totp) {342    const { code } = generateTOTP(entry.totp);343    let alreadyWritten = false;344    try { await navigator.clipboard.writeText(code); alreadyWritten = true; } catch {}345    const response = await chrome.runtime.sendMessage({346      type: "COPY",347      payload: { text: code, label: "TOTP", alreadyWritten },348    });349    if (alreadyWritten || (response as { ok?: boolean }).ok) {350      showToast("TOTP copied · " + code);351    }352    const totpField = findTOTPField();353    if (totpField) {354      autofillTOTP(totpField, entry.totp);355    }356  }357}358359async function fetchAutofillEntries(360  domain: string,361  includeAll = false362): Promise<{ entries: SelectorEntry[]; locked: boolean }> {363  const response = await chrome.runtime.sendMessage({364    type: "AUTOFILL_QUERY",365    payload: { domain, includeAll },366  });367  const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };368  if (!res.ok && res.error === "VAULT_LOCKED") {369    return { entries: [], locked: true };370  }371  return { entries: res.data ?? [], locked: false };372}373374375function resolveAutofillFieldType(376  el: HTMLInputElement,377  predictedType?: FieldPrediction["type"] | null378): "password" | "username" | "email" | null {379  if (380    predictedType === "password" ||381    predictedType === "username" ||382    predictedType === "email"383  ) {384    return predictedType;385  }386387  const detected = detectLoginFields().find((field) => field.el === el)?.type;388  if (detected && AUTOFILL_FIELD_TYPES.has(detected)) {389    return detected;390  }391392  const htmlType = el.type.toLowerCase();393  if (htmlType === "password") return "password";394  if (htmlType === "email") return "email";395  return null;396}397398async function openAutofillSelector(399  anchorField: HTMLInputElement,400  usernameEl: HTMLInputElement | null,401  passwordEl: HTMLInputElement,402  domainEntries: SelectorEntry[],403  searchableEntries: SelectorEntry[],404  domain: string405): Promise<void> {406  currentDismiss?.();407  currentAnchorField = anchorField;408  currentDismiss = showAutofillSelector(409    anchorField,410    domainEntries,411    async (id: string) => {412      currentDismiss = null;413      currentAnchorField = null;414      const entryResponse = await chrome.runtime.sendMessage({415        type: "GET_ENTRY_FOR_FILL",416        payload: { id },417      });418      const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;419      if (entry) {420        await handleAutofill(anchorField, usernameEl, passwordEl, entry);421        recordFillSuccess(anchorField, usernameEl, passwordEl);422        // Offer to remember the current site for this entry only if none of423        // its known domains (primary + additional) match — otherwise the424        // user already mapped it and the prompt would be noise.425        const pageDomain = location.hostname.replace(/^www\./, "");426        const entryDomains = new Set<string>();427        for (const u of [entry.url, ...(entry.additionalUrls ?? [])]) {428          try {429            if (u) entryDomains.add(new URL(u).hostname.replace(/^www\./, ""));430          } catch { /* ignore unparseable */ }431        }432        if (pageDomain && entryDomains.size > 0 && !entryDomains.has(pageDomain)) {433          showRememberSiteToast(pageDomain, entry.id);434        }435      }436    },437    {438      domain,439      searchableEntries,440      // After State 5 (new entry) save: fill the detected fields with the441      // freshly-created credentials so the user can submit immediately.442      // On a login form the user created the entry to use here; on a signup443      // form the password was generated for this site. Same outcome either444      // way — the credentials land in the form and the popup exits.445      onFillFields: (username, password) => {446        const form = passwordEl.closest("form") as HTMLFormElement | null;447        fillFields(usernameEl, passwordEl, username, password, form, getPageDomain());448        recordFillSuccess(anchorField, usernameEl, passwordEl);449      },450      onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },451    }452  );453}454455async function openSignupSelector(456  usernameEl: HTMLInputElement,457  passwordEl: HTMLInputElement | null,458  domainEntries: SelectorEntry[],459  allEntries: SelectorEntry[],460  domain: string461): Promise<void> {462  // Fetch derived identity list (emails then usernames from vault).463  // Falls back to empty list silently so the popup still opens when the464  // vault is temporarily unreachable or returns no login entries.465  let savedIdentities: SavedIdentity[] = [];466  try {467    const idResp = await chrome.runtime.sendMessage({ type: "GET_IDENTITIES" }).catch(() => null) as468      { ok: boolean; data?: SavedIdentity[] } | null;469    if (idResp?.ok && Array.isArray(idResp.data)) savedIdentities = idResp.data;470  } catch { /* silent fallback */ }471472  // Find the confirm-password field (second password input, or one labelled473  // confirm/repeat/verify) so "Copy & fill" from the generator can fill it too.474  const form = passwordEl?.closest("form") ?? document.body;475  const allPwInputs = Array.from(form.querySelectorAll<HTMLInputElement>("input[type='password']"));476  const confirmEl = allPwInputs.find(477    (el) => el !== passwordEl && /confirm|repeat|verify/i.test(el.name + el.id + el.placeholder)478  ) ?? (allPwInputs.length >= 2 ? allPwInputs.find((el) => el !== passwordEl) ?? null : null);479480  currentDismiss?.();481  currentAnchorField = usernameEl;482  currentDismiss = showAutofillSelector(483    usernameEl,484    domainEntries,485    () => {},486    {487      domain,488      searchableEntries: allEntries,489      isSignup: true,490      savedIdentities,491      onFillFields: (username, password) => {492        if (username) setNativeValue(usernameEl, username);493        if (password && passwordEl) {494          setNativeValue(passwordEl, password);495          if (confirmEl) setNativeValue(confirmEl, password);496        }497        recordFillSuccess(usernameEl, passwordEl);498      },499      onDismiss: () => { lastDismissedField = usernameEl; startDismissLock(); },500    }501  );502}503504505async function handleFieldInteraction(anchorField?: HTMLInputElement | null): Promise<void> {506  const field = anchorField ?? getDeepActiveInput();507  if (!field) return;508  if (!isVisible(field)) return;509510  // Layer 2: post-dismiss global lock. Catches rapid focus moves between511  // fields immediately after Escape/click-outside.512  if (!canOpen()) return;513514  // If a selector is already open for this exact field, don't flicker it.515  if (currentDismiss && currentAnchorField === field) return;516517  // If focus moved to a different field, the previous per-field dismiss lock518  // no longer applies.519  if (field !== lastDismissedField) lastDismissedField = null;520  // Layer 1: the user explicitly dismissed this field's selector — don't521  // reopen it until focus has genuinely moved away and come back.522  if (field === lastDismissedField) return;523524  // Fill-success lock: Peach already filled this form (or fired a fill525  // anywhere on the page within the grace window). Sites often refocus an526  // input on submit click, on validation, or on a transition to a success527  // state — none of those should re-open the popup.528  if (wasAlreadyFilled(field)) return;529530  // Prevent two concurrent calls for the same field (e.g. focusin + icon click531  // firing simultaneously — the second call would dismiss the first's selector).532  if (pendingInteractions.has(field)) return;533  pendingInteractions.add(field);534535  try {536    const fieldType = field.type.toLowerCase();537538    const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {539      return preds[0] ?? null;540    });541542    const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {543      const timeout = setTimeout(() => resolve(null), 150);544      mlPromise.then((pred) => {545        clearTimeout(timeout);546        resolve(pred);547      });548    });549550    if (fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {551      return;552    }553554    const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);555    if (autofillFieldType) {556      reattachListeners(field, autofillFieldType);557    }558559    let fields = detectLoginFields();560    const loginForm = isLoginForm(fields);561    const signupForm = isSignupForm(fields);562    if (!loginForm && !signupForm) {563      return;564    }565566    const domain = getPageDomain();567    const firstResult = await fetchAutofillEntries(domain);568569    // Vault is locked — show an inline unlock prompt.570    // Re-triggering after unlock is handled by the VAULT_UNLOCKED message so571    // there is no double-trigger race between onUnlocked and the broadcast.572    if (firstResult.locked) {573      currentDismiss?.();574      currentAnchorField = field;575      currentDismiss = showAutofillSelector(576        field,577        [],578        () => {},579        { domain, locked: true, onDismiss: () => { lastDismissedField = field; startDismissLock(); } }580      );581      return;582    }583584    const entries = firstResult.entries;585    const allEntries = entries.length === 0586      ? (await fetchAutofillEntries(domain, true)).entries587      : entries;588589    let usernameEl: HTMLInputElement | null = null;590    const userField = fields.find((f) => f.type === "username" || f.type === "email");591    if (userField) usernameEl = userField.el;592    const pwField = fields.find((f) => f.type === "password");593    if (!pwField) return;594    const passwordEl = pwField.el;595596    if (signupForm && usernameEl && field === usernameEl) {597      await openSignupSelector(usernameEl, passwordEl, entries, allEntries, domain);598      return;599    }600601    await openAutofillSelector(field, usernameEl, passwordEl, entries, allEntries, domain);602  } finally {603    pendingInteractions.delete(field);604  }605}606607async function onFocusIn(e: Event): Promise<void> {608  // Submit-button focus: catch the brief refocus some sites do when the609  // user clicks the login/submit button. Without this guard, that focus610  // event reaches handleFieldInteraction → getDeepActiveInput, and on611  // pages where the button is also a labelled <input> we briefly attempt612  // to open the popup over the very button the user just clicked.613  if (isSubmitButton(e.target)) return;614  await handleFieldInteraction();615}616617function reattachListeners(618  el: HTMLInputElement,619  detectedType?: "password" | "username" | "email",620  contextFields?: ReturnType<typeof detectLoginFields>621): void {622  if (processedInputs.has(el)) return;623  if (!resolveAutofillFieldType(el, detectedType)) return;624  // Only inject icon when a real login/signup form is present on the page625  const fields = contextFields ?? detectLoginFields();626  if (!isLoginForm(fields) && !isSignupForm(fields)) return;627628  const cleanup = injectFieldIcon(el, () => {629    currentDismiss?.();630    currentDismiss = null;631    lastDismissedField = null; // icon click is an explicit user open — clear per-field lock632    dismissLock = false; // ...and the global lock633    if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }634    clearFillStateForExplicitSummon(el);635    el.focus({ preventScroll: true });636    void handleFieldInteraction(el);637  });638  processedInputs.set(el, cleanup);639}640641const debouncedReattach = debounce(() => {642  const allFields = detectLoginFields();643  // Don't inject anything if there's no real login/signup form on this page644  if (!isLoginForm(allFields) && !isSignupForm(allFields)) return;645  const newFields = allFields.filter(646    (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)647  );648  for (const f of newFields) {649    reattachListeners(f.el, f.type, allFields);650  }651  // 200ms debounce — below this, Peach and the host page's reactive layer652  // fight over the same layout properties every frame on React/Vue/Angular653  // login forms (v1.4.14 regression, Projects.md §4.7). Do not lower.654}, 200);655656function setupMutationObserver(): void {657  const observer = new MutationObserver(debouncedReattach);658  observer.observe(document.body, {659    childList: true,660    subtree: true,661    attributes: true,662    attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],663  });664}665666function initialScan(aggressionLevel = 0, isFinalPass = false) {667  const allFields = detectLoginFields();668  const hasKnownForm = isLoginForm(allFields) || isSignupForm(allFields);669670  if (aggressionLevel === 0 && !hasKnownForm) return;671672  const newFields = allFields.filter(673    (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)674  );675676  for (const f of newFields) {677    reattachListeners(f.el, f.type, allFields);678  }679680  // Level 2, final pass: inject icon on any visible password field even if681  // the form-context gate in reattachListeners would normally skip it.682  if (aggressionLevel >= 2 && isFinalPass && newFields.length === 0) {683    const pwInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');684    for (const el of pwInputs) {685      if (isVisible(el) && !processedInputs.has(el)) {686        const cleanup = injectFieldIcon(el, () => {687          currentDismiss?.();688          currentDismiss = null;689          lastDismissedField = null;690          dismissLock = false;691          if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }692          clearFillStateForExplicitSummon(el);693          el.focus({ preventScroll: true });694          void handleFieldInteraction(el);695        });696        processedInputs.set(el, cleanup);697      }698    }699  }700}701702document.addEventListener("focusin", onFocusIn);703704setupMutationObserver();705setupSaveDetection();706setupCardAutofill();707708// SPA navigation reset: the content script does not reload across in-app709// route changes (history.pushState, hash navigation, framework routers).710// When the URL changes we clear the page-level fill timestamp so the next711// page works normally; filledForms (a WeakSet) self-empties as old form712// elements leave the DOM. subtree:false because route swaps reliably713// mutate document.body's direct children — subtree:true would fire on714// every internal DOM update and cost us a re-check per mutation.715let lastObservedUrl = location.href;716new MutationObserver(() => {717  if (location.href !== lastObservedUrl) {718    lastObservedUrl = location.href;719    pageFilledAt = null;720  }721}).observe(document.body, { childList: true, subtree: false });722723// Async init: check recall map before running the initial scans so we can724// apply the right aggression level for domains the user has needed before.725(async () => {726  let aggressionLevel = 0;727  try {728    const resp = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });729    const s = (resp as { ok: boolean; data?: { manualSummonDomains?: Record<string, { summonCount: number }> } }).data;730    const domain = getPageDomain();731    const record = domain ? s?.manualSummonDomains?.[domain] : undefined;732    aggressionLevel = !record ? 0 : record.summonCount >= 2 ? 2 : 1;733  } catch { /* SW may be starting up; proceed with normal scan */ }734735  setTimeout(() => initialScan(aggressionLevel, false), 0);736  setTimeout(() => initialScan(aggressionLevel, false), 500);737  setTimeout(() => initialScan(aggressionLevel, true), 1500);738})();739740// ── Card autofill (State 7) ─────────────────────────────────────────741// Card forms get a parallel injection path. The login icon-injection gate742// in reattachListeners deliberately skips non-text fields, so we wire743// cards independently rather than retrofit AUTOFILL_FIELD_TYPES — keeping744// card detection contained makes the boundary easy to follow.745const processedCardInputs = new WeakMap<HTMLInputElement, () => void>();746let currentCardDismiss: (() => void) | null = null;747let currentCardAnchor: HTMLInputElement | null = null;748let lastDismissedCardField: HTMLInputElement | null = null;749750async function fetchSavedCards(): Promise<CardSelectorEntry[]> {751  const resp = await chrome.runtime.sendMessage({752    type: "AUTOFILL_QUERY",753    payload: { domain: getPageDomain(), kind: "card" },754  }).catch(() => null);755  const r = resp as { ok: boolean; data?: CardSelectorEntry[] } | null;756  return r?.ok ? (r.data ?? []) : [];757}758759async function openCardSelector(anchor: HTMLInputElement): Promise<void> {760  if (!canOpen()) return;761  if (currentCardDismiss && currentCardAnchor === anchor) return;762  if (anchor === lastDismissedCardField) return;763  if (anchor !== lastDismissedCardField) lastDismissedCardField = null;764  if (wasAlreadyFilled(anchor)) return;765766  const cards = await fetchSavedCards();767  if (cards.length === 0) return; // no saved cards → nothing to offer768769  const cardForm = detectCardForm();770  if (!cardForm?.number) return;771772  currentCardDismiss?.();773  currentCardAnchor = anchor;774  currentCardDismiss = showCardSelector(775    anchor,776    cards,777    {778      domain: getPageDomain(),779      onFill: (entry) => {780        if (!cardForm) return;781        fillCardForm(cardForm, {782          cardNumber: entry.cardNumber ?? "",783          expMonth: entry.expMonth ?? "",784          expYear: entry.expYear ?? "",785          cvc: entry.cvc ?? "",786          cardholderName: entry.cardholderName ?? "",787        });788        // All four card field types share the same form — recording the789        // number field's form is sufficient to suppress reopens on any of790        // them via wasAlreadyFilled().791        recordFillSuccess(cardForm.number, cardForm.cvc, cardForm.expiryCombo, cardForm.name);792        currentCardDismiss = null;793        currentCardAnchor = null;794      },795      onDismiss: () => {796        lastDismissedCardField = anchor;797        startDismissLock();798        currentCardDismiss = null;799        currentCardAnchor = null;800      },801    }802  );803}804805function injectCardIcon(field: HTMLInputElement): void {806  if (processedCardInputs.has(field)) return;807  const cleanup = injectFieldIcon(field, () => {808    currentCardDismiss?.();809    currentCardDismiss = null;810    lastDismissedCardField = null;811    dismissLock = false;812    if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }813    clearFillStateForExplicitSummon(field);814    field.focus({ preventScroll: true });815    void openCardSelector(field);816  });817  processedCardInputs.set(field, cleanup);818}819820function setupCardAutofill(): void {821  function scan() {822    const cardForm = detectCardForm();823    if (!cardForm?.number) return;824    if (!isVisible(cardForm.number)) return;825    injectCardIcon(cardForm.number);826  }827828  // Initial detection sweep — same retry cadence as login fields so829  // late-hydrating SPA checkout pages get caught.830  setTimeout(scan, 0);831  setTimeout(scan, 500);832  setTimeout(scan, 1500);833834  // Re-scan on DOM mutation (separate from the login MutationObserver835  // because card forms typically appear on a single navigation event,836  // not field-by-field — coarser observation is sufficient here).837  let pending = false;838  new MutationObserver(() => {839    if (pending) return;840    pending = true;841    setTimeout(() => { pending = false; scan(); }, 200);842  }).observe(document.body, { childList: true, subtree: true });843844  // Field-focus path: on focus of a cc-number input, ensure icon is845  // present and consider opening the selector when the user explicitly846  // clicks into the card number field.847  document.addEventListener("focusin", (e) => {848    const target = e.target;849    if (!(target instanceof HTMLInputElement)) return;850    const cardForm = detectCardForm();851    if (cardForm?.number === target) {852      injectCardIcon(target);853    }854  }, true);855}856857chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {858  // Ignore anything not originating from our own background/service worker.859  if (sender.id !== chrome.runtime.id) return;860  if (msg?.type === "GET_SITE_PASSWORD_POLICY") {861    const policy = detectPasswordPolicy();862    sendResponse({ ok: true, data: policy });863    return true;864  }865  if (msg?.type === "PING") {866    sendResponse({ ok: true });867    return true;868  }869  if (msg?.type === "SUMMON_AUTOFILL") {870    const source = (msg as { type: string; source?: string }).source;871    const isShortcut = source === "shortcut";872873    const activeInput = getDeepActiveInput();874    const fields = detectLoginFields();875    const hasForm = isLoginForm(fields) || isSignupForm(fields);876877    if (hasForm) {878      // Prefer the currently focused field if it's a login field, otherwise879      // fall back to the first password field, then username/email.880      const inForm = activeInput && fields.some(f => f.el === activeInput);881      const target = inForm882        ? activeInput883        : (fields.find(f => f.type === "password")?.el884            ?? fields.find(f => f.type === "username" || f.type === "email")?.el);885      if (target) {886        // Explicit user summon (shortcut or extension button) — bypass all887        // suppression layers. The user is asking for the popup right now.888        clearFillStateForExplicitSummon(target);889        dismissLock = false;890        if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }891        lastDismissedField = null;892        void handleFieldInteraction(target);893      }894    } else if (isShortcut) {895      // Shortcut on a page with no login form — show clipboard-copy search mode.896      showSearchPalette();897    }898899    // Record this domain for the recall system when triggered via shortcut.900    if (isShortcut) {901      const domain = getPageDomain();902      if (domain) {903        chrome.runtime.sendMessage({ type: "RECORD_MANUAL_SUMMON", payload: { domain } }).catch(() => {});904      }905    }906907    sendResponse({ ok: hasForm || isShortcut });908    return true;909  }910  if (msg?.type === "PASSKEY_PRO_GATE") {911    // Background rejected a passkey request for a free user. Show State 11912    // so they know what happened and can choose to upgrade.913    showPasskeyProGate();914    sendResponse({ ok: true });915    return true;916  }917  if (msg?.type === "VAULT_UNLOCKED") {918    // Close any open selector (e.g. the locked-state prompt) and re-open919    // with fresh entry data. Use the saved anchor field so we reopen exactly920    // where the user was — covers both inline unlock and popup unlock.921    const savedField = currentAnchorField;922    if (currentDismiss) {923      currentDismiss();924      currentDismiss = null;925      currentAnchorField = null;926    }927    void handleFieldInteraction(savedField ?? undefined);928    sendResponse({ ok: true });929    return true;930  }931  return true;932});