1import {2 detectCardForm,3 detectLoginFields,4 findOTPDigitBoxGroup,5 findOtpSignalingGroup,6 getDeepActiveInput,7 getPageDomain,8 isAnyCredentialForm,9 isEmailFirstForm,10 isLoginForm,11 isSignupForm,12 isTOTPField,13 isVisible,14} from "./detect";15import {16 type CardSelectorEntry,17 injectFieldIcon,18 type SelectorEntry,19 showAutofillSelector,20 showCardSelector,21 showPasskeyAvailable,22 showPasskeyProGate,23 showRememberSiteToast,24 showTotpCopyToast,25 showTOTPSelector,26} from "./inject";27import { detectBrowser } from "../shared/browser";28import { fillAllowed, fillCardForm, fillFields, fillOTPDigitBoxes, fillTOTPCode, setNativeValue } from "./fill";29import { resolveAutofillInteractionTargets } from "./interaction";30import { setupSaveDetection } from "./save";31import { contentMLDetector } from "./content-ml-detector";32import { detectPasswordPolicy } from "./policy";33import type { FieldPrediction } from "../shared/ml";34import type { VaultEntry } from "../shared/types";35import { setPasskeyToastClosedListener } from "./passkey-toast/lifecycle";363738394041const processedInputs = new WeakMap<HTMLInputElement, () => void>();42const AUTOFILL_FIELD_TYPES = new Set(["password", "username", "email"]);4344let currentDismiss: (() => void) | null = null;4546let currentAnchorField: HTMLInputElement | null = null;4748const pendingInteractions = new WeakSet<HTMLInputElement>();49505152let lastDismissedField: HTMLInputElement | null = null;53545556575859const DISMISS_LOCK_MS = 800;60let dismissLock = false;61let dismissLockTimer: ReturnType<typeof setTimeout> | null = null;62636465let passkeySummonActive = false;66676869let passkeyToastActive = false;70let passkeyToastToken: string | null = null;71function startDismissLock(): void {72 dismissLock = true;73 if (dismissLockTimer) clearTimeout(dismissLockTimer);74 dismissLockTimer = setTimeout(() => { dismissLock = false; }, DISMISS_LOCK_MS);75}76function canOpen(): boolean { return !dismissLock; }7778798081828384858687888990const filledForms = new WeakSet<HTMLFormElement>();91let pageFilledAt: number | null = null;92const PAGE_FILL_GRACE_MS = 3000;9394function recordFillSuccess(...fields: (HTMLInputElement | null | undefined)[]): void {95 for (const f of fields) {96 if (!f) continue;97 const form = f.closest("form");98 if (form) filledForms.add(form);99 }100 pageFilledAt = Date.now();101}102103export function wasAlreadyFilled(field: HTMLInputElement): boolean {104 const form = field.closest("form");105 if (form && filledForms.has(form)) return true;106 if (pageFilledAt !== null && Date.now() - pageFilledAt < PAGE_FILL_GRACE_MS) return true;107 return false;108}109110function clearFillStateForExplicitSummon(field: HTMLInputElement): void {111 const form = field.closest("form");112 if (form) filledForms.delete(form);113 pageFilledAt = null;114}115116117118119120function isSubmitButton(el: EventTarget | null): boolean {121 if (!(el instanceof HTMLElement)) return false;122 if (el instanceof HTMLButtonElement && el.type !== "button") return true;123 if (el instanceof HTMLInputElement && el.type === "submit") return true;124 if (el.getAttribute("type") === "submit") return true;125 const text = (el.textContent?.trim() ?? el.getAttribute("value") ?? "").toLowerCase();126 if (!text || text.length > 24) return false;127 return /^(submit|sign[ -]?in|log[ -]?in|continue|next|go)$/i.test(text);128}129130function debounce<T extends (...args: unknown[]) => void>(131 fn: T,132 ms: number133): (...args: Parameters<T>) => void {134 let timer: ReturnType<typeof setTimeout>;135 return (...args: Parameters<T>) => {136 clearTimeout(timer);137 timer = setTimeout(() => fn(...args), ms);138 };139}140141function showToast(message: string): void {142 const toast = document.createElement("div");143 toast.style.cssText = `144 position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);145 background: #131109; color: #F0E8D8; padding: 8px 16px;146 border-radius: 8px; font-family: 'IBM Plex Mono', monospace;147 font-size: 11px; z-index: 2147483647; opacity: 0;148 transition: opacity 200ms ease;149 border: 1px solid rgba(240,232,216,0.07);150 pointer-events: none;151 `;152 toast.textContent = message;153 document.body.appendChild(toast);154 requestAnimationFrame(() => { toast.style.opacity = "1"; });155 setTimeout(() => {156 toast.style.opacity = "0";157 setTimeout(() => toast.remove(), 200);158 }, 2000);159}160161function showSearchPalette(capability?: string): void {162 const host = document.createElement("div");163 host.style.cssText = "position:fixed;top:80px;left:50%;transform:translateX(-50%);z-index:2147483647;width:340px;";164 document.body.appendChild(host);165 const shadow = host.attachShadow({ mode: "closed" });166167 const style = document.createElement("style");168 style.textContent = `169 .p{background:#131109;border:1px solid rgba(240,232,216,.12);border-radius:12px;170 font-family:'IBM Plex Mono',monospace;font-size:12px;color:#F0E8D8;171 box-shadow:0 8px 24px rgba(0,0,0,.5);overflow:hidden}172 .h{padding:10px 12px 8px;border-bottom:1px solid rgba(240,232,216,.08);173 font-size:11px;color:rgba(240,232,216,.5);display:flex;justify-content:space-between;align-items:center}174 .x{background:none;border:none;color:rgba(240,232,216,.4);cursor:pointer;font-size:14px;padding:0;line-height:1}175 .s{padding:8px 12px}176 .s input{width:100%;box-sizing:border-box;background:rgba(240,232,216,.06);177 border:1px solid rgba(240,232,216,.12);border-radius:6px;color:#F0E8D8;178 padding:6px 8px;font-family:inherit;font-size:12px;outline:none}179 .l{max-height:220px;overflow-y:auto}180 .e{padding:8px 12px;cursor:pointer;display:flex;flex-direction:column;gap:2px}181 .e:hover{background:rgba(240,232,216,.06)}182 .et{font-size:12px}183 .eu{font-size:10px;color:rgba(240,232,216,.45)}184 .em{padding:12px;color:rgba(240,232,216,.4);text-align:center}185 `;186187 let allEntries: SelectorEntry[] = [];188 let query = "";189190 const panel = document.createElement("div");191 panel.className = "p";192193 const hdr = document.createElement("div");194 hdr.className = "h";195 const hdrText = document.createElement("span");196 hdrText.textContent = "No form detected — search your vault";197 const closeBtn = document.createElement("button");198 closeBtn.className = "x";199 closeBtn.textContent = "✕";200 hdr.appendChild(hdrText);201 hdr.appendChild(closeBtn);202203 const searchWrap = document.createElement("div");204 searchWrap.className = "s";205 const searchInput = document.createElement("input");206 searchInput.placeholder = "search...";207 searchWrap.appendChild(searchInput);208209 const list = document.createElement("div");210 list.className = "l";211212 panel.appendChild(hdr);213 panel.appendChild(searchWrap);214 panel.appendChild(list);215 shadow.appendChild(style);216 shadow.appendChild(panel);217218 function close() { host.remove(); }219220 closeBtn.onclick = close;221222 function onKeyDown(e: KeyboardEvent) {223 if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKeyDown, true); }224 }225 document.addEventListener("keydown", onKeyDown, true);226227 function render() {228 const q = query.toLowerCase();229 const filtered = q230 ? allEntries.filter(e => e.title.toLowerCase().includes(q) || (e.username ?? "").toLowerCase().includes(q))231 : allEntries;232 list.innerHTML = "";233 if (filtered.length === 0) {234 const em = document.createElement("div");235 em.className = "em";236 em.textContent = query ? "no matches" : "vault is empty";237 list.appendChild(em);238 return;239 }240 for (const entry of filtered.slice(0, 20)) {241 const row = document.createElement("div");242 row.className = "e";243 const title = document.createElement("div");244 title.className = "et";245 title.textContent = entry.title;246 row.appendChild(title);247 if (entry.username) {248 const user = document.createElement("div");249 user.className = "eu";250 user.textContent = entry.username;251 row.appendChild(user);252 }253 row.onclick = async () => {254 255 256 const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability } });257 const r = resp as { ok: boolean; data?: { password?: string } };258 if (r.ok && r.data?.password) {259 let alreadyWritten = false;260 try {261 await navigator.clipboard.writeText(r.data.password);262 alreadyWritten = true;263 } catch (err) {264 console.warn("[Peach] clipboard write denied:", err);265 }266 const copyResp = await chrome.runtime.sendMessage({267 type: "COPY",268 payload: { text: r.data.password, label: "Password", alreadyWritten },269 }) as { ok?: boolean } | undefined;270 if (alreadyWritten || copyResp?.ok) {271 showToast("Password copied");272 } else {273 showToast("Could not copy password — clipboard blocked");274 }275 } else {276 showToast("Could not copy password");277 }278 close();279 document.removeEventListener("keydown", onKeyDown, true);280 };281 list.appendChild(row);282 }283 }284285 searchInput.oninput = () => { query = searchInput.value; render(); };286287 288 289 fetchAutofillEntries(getPageDomain(), true, capability).then(result => {290 if (result.locked) { showToast("Vault is locked — open Peach to unlock"); close(); return; }291 allEntries = result.entries;292 render();293 searchInput.focus();294 });295}296297function findTOTPField(root: Document | Element = document): HTMLInputElement | null {298 const inputs = root.querySelectorAll<HTMLInputElement>("input");299 for (const input of inputs) {300 if (isTOTPField(input)) return input;301 }302 return null;303}304305function findSameLoginTOTPField(passwordEl: HTMLInputElement): HTMLInputElement | null {306 const form = passwordEl.closest("form");307 return findTOTPField(form ?? document);308}309310311async function handleAutofill(312 _field: HTMLInputElement,313 usernameEl: HTMLInputElement | null,314 passwordEl: HTMLInputElement | null,315 entry: VaultEntry,316 emailEl?: HTMLInputElement | null317): Promise<{ filledTOTPField: HTMLInputElement | null; gatePassed: boolean }> {318 if (!entry.username && !entry.password && !entry.email) return { filledTOTPField: null, gatePassed: false };319 const pageDomain = getPageDomain();320 const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;321 322 323 324 const emailValue = entry.email ?? (entry.username?.includes("@") ? entry.username : "");325 const gatePassed = fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain, emailEl, emailValue);326 let filledTOTPField: HTMLInputElement | null = null;327328 if (entry.canary && gatePassed) {329 chrome.runtime.sendMessage({330 type: "FIRE_CANARY_PING",331 payload: {332 webhookUrl: entry.canary.webhookUrl,333 entryTitle: entry.title,334 trigger: "autofilled",335 },336 }).catch(() => {});337 chrome.runtime.sendMessage({338 type: "UPDATE_ENTRY",339 payload: {340 id: entry.id,341 patch: {342 canary: {343 ...entry.canary,344 lastPinged: Date.now(),345 pingCount: (entry.canary.pingCount ?? 0) + 1,346 },347 },348 },349 }).catch(() => {});350 }351352 353 354 const totpCode = (entry as { totpCode?: string }).totpCode;355 if (totpCode && gatePassed) {356 const code = totpCode;357 const totpField = passwordEl ? findSameLoginTOTPField(passwordEl) : null;358 if (fillTOTPCode(totpField, code, form, pageDomain)) {359 filledTOTPField = totpField;360 }361 let alreadyWritten = false;362 try {363 await navigator.clipboard.writeText(code);364 alreadyWritten = true;365 } catch (err) {366 console.warn("[Peach] clipboard write denied:", err);367 }368 const response = await chrome.runtime.sendMessage({369 type: "COPY",370 payload: { text: code, label: "TOTP", alreadyWritten },371 });372 const res = response as { ok?: boolean; clearSeconds?: number };373 if (filledTOTPField) {374 showToast("2FA code filled");375 } else if (alreadyWritten || res.ok) {376 377 378 379 380 showTotpCopyToast(res.clearSeconds ?? 30);381 } else {382 showToast("Could not copy TOTP — clipboard blocked");383 }384 }385386 return { filledTOTPField, gatePassed };387}388389async function fetchAutofillEntries(390 domain: string,391 includeAll = false,392 capability?: string393): Promise<{ entries: SelectorEntry[]; locked: boolean }> {394 const response = await chrome.runtime.sendMessage({395 type: "AUTOFILL_QUERY",396 payload: { domain, includeAll, capability },397 });398 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };399 if (!res.ok && res.error === "vault_locked") {400 return { entries: [], locked: true };401 }402 return { entries: res.data ?? [], locked: false };403}404405async function fetchTOTPEntries(406 domain: string,407 capability?: string,408): Promise<{ entries: SelectorEntry[]; locked: boolean }> {409 const response = await chrome.runtime.sendMessage({410 type: "AUTOFILL_QUERY",411 payload: { domain, kind: "totp", capability },412 });413 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };414 if (!res.ok && res.error === "vault_locked") {415 return { entries: [], locked: true };416 }417 return { entries: res.data ?? [], locked: false };418}419420421function resolveAutofillFieldType(422 el: HTMLInputElement,423 predictedType?: FieldPrediction["type"] | null424): "password" | "username" | "email" | null {425 if (426 predictedType === "password" ||427 predictedType === "username" ||428 predictedType === "email"429 ) {430 return predictedType;431 }432433 const detected = detectLoginFields().find((field) => field.el === el)?.type;434 if (detected && AUTOFILL_FIELD_TYPES.has(detected)) {435 return detected;436 }437438 const htmlType = el.type.toLowerCase();439 if (htmlType === "password") return "password";440 if (htmlType === "email") return "email";441 return null;442}443444async function openAutofillSelector(445 anchorField: HTMLInputElement,446 usernameEl: HTMLInputElement | null,447 passwordEl: HTMLInputElement | null,448 domainEntries: SelectorEntry[],449 loadSearchableEntries: () => Promise<SelectorEntry[]>,450 domain: string,451 identityKind: "email" | "username",452 emailEl?: HTMLInputElement | null,453 capability?: string,454): Promise<void> {455 456 457 const isSignup = domainEntries.length === 0;458 let confirmEl: HTMLInputElement | null = null;459 if (isSignup) {460 const form = (passwordEl ?? usernameEl)?.closest("form") ?? document.body;461 const allPwInputs = Array.from(form.querySelectorAll<HTMLInputElement>("input[type='password']"));462 confirmEl = allPwInputs.find(463 (el) => el !== passwordEl && /confirm|repeat|verify/i.test(el.name + el.id + el.placeholder)464 ) ?? (allPwInputs.length >= 2 ? allPwInputs.find((el) => el !== passwordEl) ?? null : null);465 }466467 currentDismiss?.();468 currentAnchorField = anchorField;469 currentDismiss = showAutofillSelector(470 anchorField,471 domainEntries,472 async (id: string) => {473 currentDismiss = null;474 currentAnchorField = null;475 const entryResponse = await chrome.runtime.sendMessage({476 type: "GET_ENTRY_FOR_FILL",477 payload: { id, capability },478 });479 const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;480 if (entry) {481 const { filledTOTPField, gatePassed } = await handleAutofill(anchorField, usernameEl, passwordEl, entry, emailEl);482 if (gatePassed) recordFillSuccess(anchorField, usernameEl, passwordEl, filledTOTPField);483 484 485 486 const pageDomain = location.hostname.replace(/^www\./, "");487 const entryDomains = new Set<string>();488 for (const u of [entry.url, ...(entry.additionalUrls ?? [])]) {489 try {490 if (u) entryDomains.add(new URL(u).hostname.replace(/^www\./, ""));491 } catch { }492 }493 if (pageDomain && entryDomains.size > 0 && !entryDomains.has(pageDomain)) {494 showRememberSiteToast(location.origin, entry.id);495 }496 }497 },498 {499 domain,500 capability,501 loadSearchableEntries,502 isSignup,503 identityKind,504 hasEmailField: !!emailEl,505 onFillFields: (username, password, email) => {506 const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;507 if (!fillFields(usernameEl, passwordEl, username, password, form, getPageDomain(), emailEl, email)) return;508 if (password && confirmEl) setNativeValue(confirmEl, password);509 recordFillSuccess(anchorField, usernameEl, passwordEl);510 },511 onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },512 }513 );514}515516async function openTOTPSelector(517 anchorField: HTMLInputElement,518 otpDigitBoxes?: HTMLInputElement[],519 capability?: string,520): Promise<void> {521 if (!canOpen()) return;522 if (currentDismiss && currentAnchorField === anchorField) return;523 if (anchorField !== lastDismissedField) lastDismissedField = null;524 if (anchorField === lastDismissedField) return;525 if (wasAlreadyFilled(anchorField)) return;526527 const domain = getPageDomain();528 const result = await fetchTOTPEntries(domain, capability);529530 if (result.locked) {531 currentDismiss?.();532 currentAnchorField = anchorField;533 currentDismiss = showAutofillSelector(534 anchorField,535 [],536 () => {},537 {538 domain,539 locked: true,540 onUnlocked: () => void openTOTPSelector(anchorField),541 onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },542 }543 );544 return;545 }546547 if (result.entries.length === 0) {548 lastDismissedField = anchorField;549 startDismissLock();550 showToast("No 2FA entries for this site — add a code in Peach first");551 return;552 }553554 currentDismiss?.();555 currentAnchorField = anchorField;556 currentDismiss = showTOTPSelector(557 anchorField,558 result.entries,559 async (id: string) => {560 try {561 const entryResponse = await chrome.runtime.sendMessage({562 type: "GET_ENTRY_FOR_FILL",563 payload: { id, capability },564 }).catch(() => null) as { ok: boolean; data?: VaultEntry } | null;565 const entry = entryResponse?.ok ? entryResponse.data : undefined;566 const code = (entry as { totpCode?: string } | undefined)?.totpCode;567 if (!code) return;568 if (!fillAllowed(anchorField.closest("form"), getPageDomain())) 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 async function handleFieldInteraction(595 anchorField?: HTMLInputElement | null,596 explicitSummon = false,597 capability?: string598): Promise<void> {599 const field = anchorField ?? getDeepActiveInput();600 if (!field) return;601 if (!isVisible(field)) return;602603 604 if (passkeyToastActive) return;605606 607 608 if (!canOpen()) return;609610 611 if (currentDismiss && currentAnchorField === field) return;612613 614 615 if (field !== lastDismissedField) lastDismissedField = null;616 617 618 if (field === lastDismissedField) return;619620 621 622 623 624 if (wasAlreadyFilled(field)) return;625626 627 628 if (pendingInteractions.has(field)) return;629 pendingInteractions.add(field);630631 try {632 633 634 635 636 637 const otpGroup = findOTPDigitBoxGroup(field) ?? findOtpSignalingGroup(field);638 if (otpGroup) {639 await openTOTPSelector(otpGroup[0]!, otpGroup, capability);640 return;641 }642643 if (isTOTPField(field)) {644 const formFields = detectLoginFields();645 if (!isLoginForm(formFields) && !isSignupForm(formFields)) {646 await openTOTPSelector(field, undefined, capability);647 return;648 }649 }650651 const fieldType = field.type.toLowerCase();652653 const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {654 return preds[0] ?? null;655 });656657 const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {658 const timeout = setTimeout(() => resolve(null), 150);659 mlPromise.then((pred) => {660 clearTimeout(timeout);661 resolve(pred);662 });663 });664665 if (passkeyToastActive) return;666667 if (!explicitSummon && fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {668 return;669 }670671 const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);672 if (autofillFieldType) {673 reattachListeners(field, autofillFieldType);674 }675676 const fields = detectLoginFields();677 const targets = resolveAutofillInteractionTargets(field, fields, explicitSummon);678 if (!targets.shouldOpen) {679 return;680 }681682 const domain = getPageDomain();683 const commandAuthorized = explicitSummon && capability !== undefined;684 const firstResult = await fetchAutofillEntries(685 domain,686 commandAuthorized,687 commandAuthorized ? capability : undefined,688 );689 if (passkeyToastActive) return;690691 692 693 694 if (firstResult.locked) {695 currentDismiss?.();696 currentAnchorField = field;697 currentDismiss = showAutofillSelector(698 field,699 [],700 () => {},701 { domain, locked: true, onDismiss: () => { lastDismissedField = field; startDismissLock(); } }702 );703 return;704 }705706 const entries = firstResult.entries;707 708 709 710 711 712 713 const loadSearchableEntries = async (): Promise<SelectorEntry[]> =>714 commandAuthorized715 ? entries716 : (await fetchAutofillEntries(domain)).entries;717718 if (passkeyToastActive) return;719720 const pwField = fields.find((f) => f.type === "password");721 722 if (!explicitSummon && !pwField && !isEmailFirstForm(fields)) return;723 const usernameEl = targets.usernameEl;724 const passwordEl = targets.passwordEl;725 const emailEl = targets.emailEl;726727 await openAutofillSelector(728 field,729 usernameEl,730 passwordEl,731 entries,732 loadSearchableEntries,733 domain,734 autofillFieldType === "email" ? "email" : "username",735 emailEl,736 commandAuthorized ? capability : undefined,737 );738 } finally {739 pendingInteractions.delete(field);740 }741}742743async function onFocusIn(e: Event): Promise<void> {744 745 746 747 748 749 if (isSubmitButton(e.target)) return;750751 const field = e.target instanceof HTMLInputElement ? e.target : null;752 if (field) {753 const fields = detectLoginFields();754 if (isAnyCredentialForm(fields)) {755 const detected = fields.find((candidate) => candidate.el === field)?.type;756 reattachListeners(field, detected, fields);757 }758 }759760 await handleFieldInteraction();761}762763function reattachListeners(764 el: HTMLInputElement,765 detectedType?: "password" | "username" | "email",766 contextFields?: ReturnType<typeof detectLoginFields>767): void {768 if (processedInputs.has(el)) return;769 if (!resolveAutofillFieldType(el, detectedType)) return;770 771 const fields = contextFields ?? detectLoginFields();772 if (!isAnyCredentialForm(fields)) return;773774 const cleanup = injectFieldIcon(el, () => {775 currentDismiss?.();776 currentDismiss = null;777 lastDismissedField = null; 778 dismissLock = false; 779 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }780 clearFillStateForExplicitSummon(el);781 el.focus({ preventScroll: true });782 void handleFieldInteraction(el, true);783 });784 processedInputs.set(el, cleanup);785}786787const debouncedReattach = debounce(() => {788 const allFields = detectLoginFields();789 if (!isAnyCredentialForm(allFields)) return;790 const newFields = allFields.filter(791 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)792 );793 for (const f of newFields) {794 reattachListeners(f.el, f.type, allFields);795 }796 797 798 799}, 200);800801function setupMutationObserver(): void {802 const observer = new MutationObserver(debouncedReattach);803 observer.observe(document.body, {804 childList: true,805 subtree: true,806 attributes: true,807 attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],808 });809}810811export function initialScan(aggressionLevel = 0, isFinalPass = false) {812 const allFields = detectLoginFields();813 const hasKnownForm = isAnyCredentialForm(allFields);814815 if (aggressionLevel === 0 && !hasKnownForm) return;816817 const newFields = allFields.filter(818 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)819 );820821 for (const f of newFields) {822 reattachListeners(f.el, f.type, allFields);823 }824825 826 827 if (aggressionLevel >= 2 && isFinalPass && newFields.length === 0) {828 const pwInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');829 for (const el of pwInputs) {830 if (isVisible(el) && !processedInputs.has(el)) {831 const cleanup = injectFieldIcon(el, () => {832 currentDismiss?.();833 currentDismiss = null;834 lastDismissedField = null;835 dismissLock = false;836 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }837 clearFillStateForExplicitSummon(el);838 el.focus({ preventScroll: true });839 void handleFieldInteraction(el, true);840 });841 processedInputs.set(el, cleanup);842 }843 }844 }845}846847848849export const _testSeam = {850 getLastDismissedField(): HTMLInputElement | null { return lastDismissedField; },851 setLastDismissedField(f: HTMLInputElement | null): void { lastDismissedField = f; },852 isDismissLocked(): boolean { return dismissLock; },853 DISMISS_LOCK_MS,854} as const;855856857858859860const processedTOTPInputs = new WeakMap<HTMLInputElement, () => void>();861862function hasLoginOrSignupForm(): boolean {863 const fields = detectLoginFields();864 return isAnyCredentialForm(fields);865}866867function injectTOTPFieldIcon(868 field: HTMLInputElement,869 otpDigitBoxes?: HTMLInputElement[]870): void {871 if (processedTOTPInputs.has(field)) return;872 const cleanup = injectFieldIcon(field, () => {873 currentDismiss?.();874 currentDismiss = null;875 currentAnchorField = null;876 lastDismissedField = null;877 dismissLock = false;878 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }879 clearFillStateForExplicitSummon(field);880 field.focus({ preventScroll: true });881 void openTOTPSelector(field, otpDigitBoxes);882 });883 processedTOTPInputs.set(field, cleanup);884}885886887888889890function resolveTOTPIconTarget(891 field: HTMLInputElement892): { anchor: HTMLInputElement; group?: HTMLInputElement[] } {893 const group = findOtpSignalingGroup(field);894 if (group) return { anchor: group[0]!, group };895 return { anchor: field };896}897898export function scanTOTPFields(): void {899 if (hasLoginOrSignupForm()) return;900 const inputs = document.querySelectorAll<HTMLInputElement>("input");901 for (const input of inputs) {902 if (!isTOTPField(input)) continue;903 const { anchor, group } = resolveTOTPIconTarget(input);904 injectTOTPFieldIcon(anchor, group);905 }906}907908function setupTOTPAutofill(): void {909 setTimeout(scanTOTPFields, 0);910 setTimeout(scanTOTPFields, 500);911 setTimeout(scanTOTPFields, 1500);912913 const debouncedScan = debounce(scanTOTPFields, 200);914 new MutationObserver(debouncedScan).observe(document.body, {915 childList: true,916 subtree: true,917 attributes: true,918 attributeFilter: ["autocomplete", "name", "id", "placeholder", "hidden"],919 });920921 document.addEventListener("focusin", (e) => {922 const target = e.target;923 if (!(target instanceof HTMLInputElement)) return;924 if (hasLoginOrSignupForm()) return;925 if (!isTOTPField(target)) return;926 const { anchor, group } = resolveTOTPIconTarget(target);927 injectTOTPFieldIcon(anchor, group);928 }, true);929}930931document.addEventListener("focusin", onFocusIn);932933setupMutationObserver();934setupTOTPAutofill();935setupSaveDetection();936setupCardAutofill();937938939940941942943944945let lastObservedUrl = globalThis.location.href;946new MutationObserver(() => {947 const currentUrl = globalThis.location?.href;948 if (!currentUrl || currentUrl === lastObservedUrl) return;949 lastObservedUrl = currentUrl;950 pageFilledAt = null;951}).observe(document.body, { childList: true, subtree: false });952953954955(async () => {956 let aggressionLevel = 0;957 try {958 const resp = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });959 const s = (resp as { ok: boolean; data?: { manualSummonDomains?: Record<string, { summonCount: number }> } }).data;960 const domain = getPageDomain();961 const record = domain ? s?.manualSummonDomains?.[domain] : undefined;962 aggressionLevel = !record ? 0 : record.summonCount >= 2 ? 2 : 1;963 } catch { }964965 setTimeout(() => initialScan(aggressionLevel, false), 0);966 setTimeout(() => initialScan(aggressionLevel, false), 500);967 setTimeout(() => initialScan(aggressionLevel, true), 1500);968})();969970971972973974975const processedCardInputs = new WeakMap<HTMLInputElement, () => void>();976let currentCardDismiss: (() => void) | null = null;977let currentCardAnchor: HTMLInputElement | null = null;978let lastDismissedCardField: HTMLInputElement | null = null;979980async function fetchSavedCards(capability?: string): Promise<CardSelectorEntry[]> {981 const resp = await chrome.runtime.sendMessage({982 type: "AUTOFILL_QUERY",983 payload: { domain: getPageDomain(), kind: "card", capability },984 }).catch(() => null);985 const r = resp as { ok: boolean; data?: CardSelectorEntry[] } | null;986 return r?.ok ? (r.data ?? []) : [];987}988989async function openCardSelector(anchor: HTMLInputElement, capability?: string): Promise<void> {990 if (!canOpen()) return;991 if (currentCardDismiss && currentCardAnchor === anchor) return;992 if (anchor === lastDismissedCardField) return;993 if (anchor !== lastDismissedCardField) lastDismissedCardField = null;994 if (wasAlreadyFilled(anchor)) return;995996 const cards = await fetchSavedCards(capability);997 if (cards.length === 0) return; 998999 const cardForm = detectCardForm();1000 if (!cardForm?.number) return;10011002 currentCardDismiss?.();1003 currentCardAnchor = anchor;1004 currentCardDismiss = showCardSelector(1005 anchor,1006 cards,1007 {1008 domain: getPageDomain(),1009 capability,1010 onFill: (entry) => {1011 if (!cardForm) return;1012 fillCardForm(cardForm, {1013 cardNumber: entry.cardNumber ?? "",1014 expMonth: entry.expMonth ?? "",1015 expYear: entry.expYear ?? "",1016 cvc: entry.cvc ?? "",1017 cardholderName: entry.cardholderName ?? "",1018 });1019 1020 1021 1022 recordFillSuccess(cardForm.number, cardForm.cvc, cardForm.expiryCombo, cardForm.name);1023 currentCardDismiss = null;1024 currentCardAnchor = null;1025 },1026 onDismiss: () => {1027 lastDismissedCardField = anchor;1028 startDismissLock();1029 currentCardDismiss = null;1030 currentCardAnchor = null;1031 },1032 }1033 );1034}10351036function injectCardIcon(field: HTMLInputElement): void {1037 if (processedCardInputs.has(field)) return;1038 const cleanup = injectFieldIcon(field, () => {1039 currentCardDismiss?.();1040 currentCardDismiss = null;1041 lastDismissedCardField = null;1042 dismissLock = false;1043 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1044 clearFillStateForExplicitSummon(field);1045 field.focus({ preventScroll: true });1046 void openCardSelector(field);1047 });1048 processedCardInputs.set(field, cleanup);1049}10501051function setupCardAutofill(): void {1052 function scan() {1053 const cardForm = detectCardForm();1054 if (!cardForm?.number) return;1055 if (!isVisible(cardForm.number)) return;1056 injectCardIcon(cardForm.number);1057 }10581059 1060 1061 setTimeout(scan, 0);1062 setTimeout(scan, 500);1063 setTimeout(scan, 1500);10641065 1066 1067 1068 let pending = false;1069 new MutationObserver(() => {1070 if (pending) return;1071 pending = true;1072 setTimeout(() => { pending = false; scan(); }, 200);1073 }).observe(document.body, { childList: true, subtree: true });10741075 1076 1077 1078 document.addEventListener("focusin", (e) => {1079 const target = e.target;1080 if (!(target instanceof HTMLInputElement)) return;1081 const cardForm = detectCardForm();1082 if (cardForm?.number === target) {1083 injectCardIcon(target);1084 }1085 }, true);1086}10871088chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {1089 1090 if (sender.id !== chrome.runtime.id) return;1091 if (msg?.type === "GET_SITE_PASSWORD_POLICY") {1092 const policy = detectPasswordPolicy();1093 sendResponse({ ok: true, data: policy });1094 return true;1095 }1096 if (msg?.type === "PING") {1097 sendResponse({ ok: true });1098 return true;1099 }1100 if (msg?.type === "SUMMON_AUTOFILL") {1101 const source = (msg as { type: string; source?: string; capability?: string }).source;1102 const capability = (msg as { capability?: string }).capability;1103 const isShortcut = source === "shortcut";1104 const isPasskeyUnlock = source === "passkey-unlock";11051106 const activeInput = getDeepActiveInput();1107 const fields = detectLoginFields();1108 const hasForm = isAnyCredentialForm(fields);1109 const cardForm = detectCardForm();11101111 if (hasForm) {1112 1113 1114 const inForm = activeInput && fields.some(f => f.el === activeInput);1115 const target = inForm1116 ? activeInput1117 : (fields.find(f => f.type === "password")?.el1118 ?? fields.find(f => f.type === "username" || f.type === "email")?.el);1119 if (target) {1120 1121 1122 clearFillStateForExplicitSummon(target);1123 dismissLock = false;1124 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1125 lastDismissedField = null;1126 void handleFieldInteraction(target, true, capability);1127 }1128 } else if (isShortcut && cardForm?.number) {1129 clearFillStateForExplicitSummon(cardForm.number);1130 dismissLock = false;1131 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1132 lastDismissedCardField = null;1133 void openCardSelector(cardForm.number, capability);1134 } else if (isPasskeyUnlock) {1135 1136 1137 1138 const anchor = (document.activeElement instanceof HTMLElement && document.activeElement !== document.body)1139 ? document.activeElement as unknown as HTMLInputElement1140 : null;1141 if (anchor) {1142 currentDismiss?.();1143 currentAnchorField = null;1144 passkeySummonActive = true;1145 currentDismiss = showAutofillSelector(anchor, [], () => {}, {1146 domain: getPageDomain(),1147 locked: true,1148 onDismiss: () => { passkeySummonActive = false; },1149 });1150 }1151 } else if (isShortcut) {1152 1153 showSearchPalette(capability);1154 }11551156 1157 if (isShortcut) {1158 const domain = getPageDomain();1159 if (domain) {1160 chrome.runtime.sendMessage({ type: "RECORD_MANUAL_SUMMON", payload: { domain } }).catch(() => {});1161 }1162 }11631164 sendResponse({ ok: hasForm || Boolean(cardForm?.number) || isShortcut || isPasskeyUnlock });1165 return true;1166 }1167 if (msg?.type === "PASSKEY_PRO_GATE") {1168 1169 1170 showPasskeyProGate();1171 sendResponse({ ok: true });1172 return true;1173 }1174 if (msg?.type === "SHOW_PASSKEY_TOAST") {1175 1176 passkeyToastActive = true;1177 passkeyToastToken = typeof (msg.payload as { toastToken?: unknown } | undefined)?.toastToken === "string"1178 ? (msg.payload as { toastToken: string }).toastToken1179 : null;1180 if (currentDismiss) {1181 currentDismiss();1182 currentDismiss = null;1183 currentAnchorField = null;1184 }1185 return false; 1186 }1187 if (msg?.type === "DISMISS_PASSKEY_TOAST") {1188 passkeyToastActive = false;1189 passkeyToastToken = null;1190 return false; 1191 }1192 if (msg?.type === "VAULT_UNLOCKED") {1193 if (passkeySummonActive) {1194 1195 1196 passkeySummonActive = false;1197 if (currentDismiss) {1198 currentDismiss();1199 currentDismiss = null;1200 currentAnchorField = null;1201 }1202 sendResponse({ ok: true });1203 return true;1204 }1205 1206 1207 const savedField = currentAnchorField;1208 if (currentDismiss) {1209 currentDismiss();1210 currentDismiss = null;1211 currentAnchorField = null;1212 }1213 void handleFieldInteraction(savedField ?? undefined);1214 sendResponse({ ok: true });1215 return true;1216 }1217 return true;1218});12191220setPasskeyToastClosedListener((toastToken) => {1221 if (toastToken === passkeyToastToken) {1222 passkeyToastActive = false;1223 passkeyToastToken = null;1224 }1225});1226122712281229123012311232123312341235123612371238123912401241{1242 function checkPasskeyAvailability() {1243 const domain = location.hostname.replace(/^www\./, "");1244 if (!domain) 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"; bannerShown?: boolean } | null } | undefined) => {1249 const data = res?.data;1250 if (!data || data.bannerShown) 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.runtime.sendMessage({1257 type: "MARK_PASSKEY_BANNER_SHOWN",1258 payload: { domain },1259 }).catch(() => {});1260 showPasskeyAvailable({1261 rpId: data.rpId,1262 userName: data.userName,1263 fromBrowser: data.browser,1264 currentBrowser: current,1265 });1266 }).catch(() => {});1267 }12681269 1270 1271 1272 if (document.readyState === "loading") {1273 document.addEventListener("DOMContentLoaded", () => setTimeout(checkPasskeyAvailability, 800));1274 } else {1275 setTimeout(checkPasskeyAvailability, 800);1276 }1277}