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 showPasskeyRegistrationOffer,24 showRememberSiteToast,25 showTotpCopyToast,26 showTOTPSelector,27} from "./inject";28import { detectBrowser } from "../shared/browser";29import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";30import { fillCardForm, fillFields, fillOTPDigitBoxes, fillTOTPCode, setNativeValue, checkFormAction } from "./fill";31import { resolveAutofillInteractionTargets } from "./interaction";32import { setupSaveDetection } from "./save";33import { contentMLDetector } from "./content-ml-detector";34import { detectPasswordPolicy } from "./policy";35import type { FieldPrediction } from "../shared/ml";36import type { SavedIdentity, VaultEntry } from "../shared/types";373839404142const processedInputs = new WeakMap<HTMLInputElement, () => void>();43const AUTOFILL_FIELD_TYPES = new Set(["password", "username", "email"]);4445let currentDismiss: (() => void) | null = null;4647let currentAnchorField: HTMLInputElement | null = null;4849const pendingInteractions = new WeakSet<HTMLInputElement>();50515253let lastDismissedField: HTMLInputElement | null = null;54555657585960const DISMISS_LOCK_MS = 800;61let dismissLock = false;62let dismissLockTimer: ReturnType<typeof setTimeout> | null = null;63646566let passkeySummonActive = false;67686970let passkeyToastActive = false;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(): 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, explicitSummon: true } });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, true).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 fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain, emailEl, emailValue);326 const gatePassed = !form || !pageDomain || checkFormAction(form, pageDomain);327 let filledTOTPField: HTMLInputElement | null = null;328329 if (entry.canary && gatePassed) {330 chrome.runtime.sendMessage({331 type: "FIRE_CANARY_PING",332 payload: {333 webhookUrl: entry.canary.webhookUrl,334 entryTitle: entry.title,335 trigger: "autofilled",336 },337 }).catch(() => {});338 chrome.runtime.sendMessage({339 type: "UPDATE_ENTRY",340 payload: {341 id: entry.id,342 patch: {343 canary: {344 ...entry.canary,345 lastPinged: Date.now(),346 pingCount: (entry.canary.pingCount ?? 0) + 1,347 },348 },349 },350 }).catch(() => {});351 }352353 354 355 const totpCode = (entry as { totpCode?: string }).totpCode;356 if (totpCode && gatePassed) {357 const code = totpCode;358 const totpField = passwordEl ? findSameLoginTOTPField(passwordEl) : null;359 if (fillTOTPCode(totpField, code, form, pageDomain)) {360 filledTOTPField = totpField;361 }362 let alreadyWritten = false;363 try {364 await navigator.clipboard.writeText(code);365 alreadyWritten = true;366 } catch (err) {367 console.warn("[Peach] clipboard write denied:", err);368 }369 const response = await chrome.runtime.sendMessage({370 type: "COPY",371 payload: { text: code, label: "TOTP", alreadyWritten },372 });373 const res = response as { ok?: boolean; clearSeconds?: number };374 if (filledTOTPField) {375 showToast("2FA code filled");376 } else if (alreadyWritten || res.ok) {377 378 379 380 381 showTotpCopyToast(res.clearSeconds ?? 30);382 } else {383 showToast("Could not copy TOTP — clipboard blocked");384 }385 }386387 return { filledTOTPField, gatePassed };388}389390async function fetchAutofillEntries(391 domain: string,392 includeAll = false,393 userGesture = false394): Promise<{ entries: SelectorEntry[]; locked: boolean }> {395 const response = await chrome.runtime.sendMessage({396 type: "AUTOFILL_QUERY",397 payload: { domain, includeAll, userGesture },398 });399 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };400 if (!res.ok && res.error === "vault_locked") {401 return { entries: [], locked: true };402 }403 return { entries: res.data ?? [], locked: false };404}405406async function fetchTOTPEntries(407 domain: string408): Promise<{ entries: SelectorEntry[]; locked: boolean }> {409 const response = await chrome.runtime.sendMessage({410 type: "AUTOFILL_QUERY",411 payload: { domain, kind: "totp" },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 | null453): Promise<void> {454 455 456 457 const isSignup = domainEntries.length === 0;458 let savedIdentities: SavedIdentity[] | undefined;459 let confirmEl: HTMLInputElement | null = null;460 if (isSignup) {461 try {462 const idResp = await chrome.runtime.sendMessage({ type: "GET_IDENTITIES" }).catch(() => null) as463 { ok: boolean; data?: SavedIdentity[] } | null;464 if (idResp?.ok && Array.isArray(idResp.data)) savedIdentities = idResp.data;465 } catch { }466467 const form = (passwordEl ?? usernameEl)?.closest("form") ?? document.body;468 const allPwInputs = Array.from(form.querySelectorAll<HTMLInputElement>("input[type='password']"));469 confirmEl = allPwInputs.find(470 (el) => el !== passwordEl && /confirm|repeat|verify/i.test(el.name + el.id + el.placeholder)471 ) ?? (allPwInputs.length >= 2 ? allPwInputs.find((el) => el !== passwordEl) ?? null : null);472 }473474 currentDismiss?.();475 currentAnchorField = anchorField;476 currentDismiss = showAutofillSelector(477 anchorField,478 domainEntries,479 async (id: string) => {480 currentDismiss = null;481 currentAnchorField = null;482 const entryResponse = await chrome.runtime.sendMessage({483 type: "GET_ENTRY_FOR_FILL",484 payload: { id },485 });486 const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;487 if (entry) {488 const { filledTOTPField, gatePassed } = await handleAutofill(anchorField, usernameEl, passwordEl, entry, emailEl);489 if (gatePassed) recordFillSuccess(anchorField, usernameEl, passwordEl, filledTOTPField);490 491 492 493 const pageDomain = location.hostname.replace(/^www\./, "");494 const entryDomains = new Set<string>();495 for (const u of [entry.url, ...(entry.additionalUrls ?? [])]) {496 try {497 if (u) entryDomains.add(new URL(u).hostname.replace(/^www\./, ""));498 } catch { }499 }500 if (pageDomain && entryDomains.size > 0 && !entryDomains.has(pageDomain)) {501 showRememberSiteToast(pageDomain, entry.id);502 }503 }504 },505 {506 domain,507 loadSearchableEntries,508 isSignup,509 identityKind,510 hasEmailField: !!emailEl,511 savedIdentities,512 onFillFields: (username, password, email) => {513 const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;514 fillFields(usernameEl, passwordEl, username, password, form, getPageDomain(), emailEl, email);515 if (password && confirmEl) setNativeValue(confirmEl, password);516 recordFillSuccess(anchorField, usernameEl, passwordEl);517 },518 onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },519 }520 );521}522523async function openTOTPSelector(anchorField: HTMLInputElement, otpDigitBoxes?: HTMLInputElement[]): Promise<void> {524 if (!canOpen()) return;525 if (currentDismiss && currentAnchorField === anchorField) return;526 if (anchorField !== lastDismissedField) lastDismissedField = null;527 if (anchorField === lastDismissedField) return;528 if (wasAlreadyFilled(anchorField)) return;529530 const domain = getPageDomain();531 const result = await fetchTOTPEntries(domain);532533 if (result.locked) {534 currentDismiss?.();535 currentAnchorField = anchorField;536 currentDismiss = showAutofillSelector(537 anchorField,538 [],539 () => {},540 {541 domain,542 locked: true,543 onUnlocked: () => void openTOTPSelector(anchorField),544 onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },545 }546 );547 return;548 }549550 if (result.entries.length === 0) {551 lastDismissedField = anchorField;552 startDismissLock();553 showToast("No 2FA entries for this site — add a code in Peach first");554 return;555 }556557 currentDismiss?.();558 currentAnchorField = anchorField;559 currentDismiss = showTOTPSelector(560 anchorField,561 result.entries,562 async (id: string) => {563 try {564 const entryResponse = await chrome.runtime.sendMessage({565 type: "GET_ENTRY_FOR_FILL",566 payload: { id },567 }).catch(() => null) as { ok: boolean; data?: VaultEntry } | null;568 const entry = entryResponse?.ok ? entryResponse.data : undefined;569 const code = (entry as { totpCode?: string } | undefined)?.totpCode;570 if (!code) return;571 if (otpDigitBoxes && otpDigitBoxes.length > 1) {572 fillOTPDigitBoxes(otpDigitBoxes, code);573 } else {574 setNativeValue(anchorField, code);575 }576 recordFillSuccess(anchorField);577 showToast("2FA code filled");578 } finally {579 currentDismiss = null;580 currentAnchorField = null;581 }582 },583 {584 domain,585 onDismiss: () => {586 lastDismissedField = anchorField;587 startDismissLock();588 currentDismiss = null;589 currentAnchorField = null;590 },591 }592 );593}594595596export async function handleFieldInteraction(597 anchorField?: HTMLInputElement | null,598 explicitSummon = false599): Promise<void> {600 const field = anchorField ?? getDeepActiveInput();601 if (!field) return;602 if (!isVisible(field)) return;603604 605 if (passkeyToastActive) return;606607 608 609 if (!canOpen()) return;610611 612 if (currentDismiss && currentAnchorField === field) return;613614 615 616 if (field !== lastDismissedField) lastDismissedField = null;617 618 619 if (field === lastDismissedField) return;620621 622 623 624 625 if (wasAlreadyFilled(field)) return;626627 628 629 if (pendingInteractions.has(field)) return;630 pendingInteractions.add(field);631632 try {633 634 635 636 637 638 const otpGroup = findOTPDigitBoxGroup(field) ?? findOtpSignalingGroup(field);639 if (otpGroup) {640 await openTOTPSelector(otpGroup[0]!, otpGroup);641 return;642 }643644 if (isTOTPField(field)) {645 const formFields = detectLoginFields();646 if (!isLoginForm(formFields) && !isSignupForm(formFields)) {647 await openTOTPSelector(field);648 return;649 }650 }651652 const fieldType = field.type.toLowerCase();653654 const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {655 return preds[0] ?? null;656 });657658 const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {659 const timeout = setTimeout(() => resolve(null), 150);660 mlPromise.then((pred) => {661 clearTimeout(timeout);662 resolve(pred);663 });664 });665666 if (passkeyToastActive) return;667668 if (!explicitSummon && fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {669 return;670 }671672 const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);673 if (autofillFieldType) {674 reattachListeners(field, autofillFieldType);675 }676677 const fields = detectLoginFields();678 const targets = resolveAutofillInteractionTargets(field, fields, explicitSummon);679 if (!targets.shouldOpen) {680 return;681 }682683 const domain = getPageDomain();684 const firstResult = await fetchAutofillEntries(domain);685 if (passkeyToastActive) return;686687 688 689 690 if (firstResult.locked) {691 currentDismiss?.();692 currentAnchorField = field;693 currentDismiss = showAutofillSelector(694 field,695 [],696 () => {},697 { domain, locked: true, onDismiss: () => { lastDismissedField = field; startDismissLock(); } }698 );699 return;700 }701702 const entries = firstResult.entries;703 704 705 706 707 708 709 const loadSearchableEntries = async (): Promise<SelectorEntry[]> =>710 (await fetchAutofillEntries(domain, true, true)).entries;711712 if (passkeyToastActive) return;713714 const pwField = fields.find((f) => f.type === "password");715 716 if (!explicitSummon && !pwField && !isEmailFirstForm(fields)) return;717 const usernameEl = targets.usernameEl;718 const passwordEl = targets.passwordEl;719 const emailEl = targets.emailEl;720721 await openAutofillSelector(722 field,723 usernameEl,724 passwordEl,725 entries,726 loadSearchableEntries,727 domain,728 autofillFieldType === "email" ? "email" : "username",729 emailEl,730 );731 } finally {732 pendingInteractions.delete(field);733 }734}735736async function onFocusIn(e: Event): Promise<void> {737 738 739 740 741 742 if (isSubmitButton(e.target)) return;743 await handleFieldInteraction();744}745746function reattachListeners(747 el: HTMLInputElement,748 detectedType?: "password" | "username" | "email",749 contextFields?: ReturnType<typeof detectLoginFields>750): void {751 if (processedInputs.has(el)) return;752 if (!resolveAutofillFieldType(el, detectedType)) return;753 754 const fields = contextFields ?? detectLoginFields();755 if (!isAnyCredentialForm(fields)) return;756757 const cleanup = injectFieldIcon(el, () => {758 currentDismiss?.();759 currentDismiss = null;760 lastDismissedField = null; 761 dismissLock = false; 762 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }763 clearFillStateForExplicitSummon(el);764 el.focus({ preventScroll: true });765 void handleFieldInteraction(el, true);766 });767 processedInputs.set(el, cleanup);768}769770const debouncedReattach = debounce(() => {771 const allFields = detectLoginFields();772 if (!isAnyCredentialForm(allFields)) return;773 const newFields = allFields.filter(774 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)775 );776 for (const f of newFields) {777 reattachListeners(f.el, f.type, allFields);778 }779 780 781 782}, 200);783784function setupMutationObserver(): void {785 const observer = new MutationObserver(debouncedReattach);786 observer.observe(document.body, {787 childList: true,788 subtree: true,789 attributes: true,790 attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],791 });792}793794export function initialScan(aggressionLevel = 0, isFinalPass = false) {795 const allFields = detectLoginFields();796 const hasKnownForm = isAnyCredentialForm(allFields);797798 if (aggressionLevel === 0 && !hasKnownForm) return;799800 const newFields = allFields.filter(801 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)802 );803804 for (const f of newFields) {805 reattachListeners(f.el, f.type, allFields);806 }807808 809 810 if (aggressionLevel >= 2 && isFinalPass && newFields.length === 0) {811 const pwInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');812 for (const el of pwInputs) {813 if (isVisible(el) && !processedInputs.has(el)) {814 const cleanup = injectFieldIcon(el, () => {815 currentDismiss?.();816 currentDismiss = null;817 lastDismissedField = null;818 dismissLock = false;819 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }820 clearFillStateForExplicitSummon(el);821 el.focus({ preventScroll: true });822 void handleFieldInteraction(el, true);823 });824 processedInputs.set(el, cleanup);825 }826 }827 }828}829830831832export const _testSeam = {833 getLastDismissedField(): HTMLInputElement | null { return lastDismissedField; },834 setLastDismissedField(f: HTMLInputElement | null): void { lastDismissedField = f; },835 isDismissLocked(): boolean { return dismissLock; },836 DISMISS_LOCK_MS,837} as const;838839840841842843const processedTOTPInputs = new WeakMap<HTMLInputElement, () => void>();844845function hasLoginOrSignupForm(): boolean {846 const fields = detectLoginFields();847 return isAnyCredentialForm(fields);848}849850function injectTOTPFieldIcon(851 field: HTMLInputElement,852 otpDigitBoxes?: HTMLInputElement[]853): void {854 if (processedTOTPInputs.has(field)) return;855 const cleanup = injectFieldIcon(field, () => {856 currentDismiss?.();857 currentDismiss = null;858 currentAnchorField = null;859 lastDismissedField = null;860 dismissLock = false;861 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }862 clearFillStateForExplicitSummon(field);863 field.focus({ preventScroll: true });864 void openTOTPSelector(field, otpDigitBoxes);865 });866 processedTOTPInputs.set(field, cleanup);867}868869870871872873function resolveTOTPIconTarget(874 field: HTMLInputElement875): { anchor: HTMLInputElement; group?: HTMLInputElement[] } {876 const group = findOtpSignalingGroup(field);877 if (group) return { anchor: group[0]!, group };878 return { anchor: field };879}880881export function scanTOTPFields(): void {882 if (hasLoginOrSignupForm()) return;883 const inputs = document.querySelectorAll<HTMLInputElement>("input");884 for (const input of inputs) {885 if (!isTOTPField(input)) continue;886 const { anchor, group } = resolveTOTPIconTarget(input);887 injectTOTPFieldIcon(anchor, group);888 }889}890891function setupTOTPAutofill(): void {892 setTimeout(scanTOTPFields, 0);893 setTimeout(scanTOTPFields, 500);894 setTimeout(scanTOTPFields, 1500);895896 const debouncedScan = debounce(scanTOTPFields, 200);897 new MutationObserver(debouncedScan).observe(document.body, {898 childList: true,899 subtree: true,900 attributes: true,901 attributeFilter: ["autocomplete", "name", "id", "placeholder", "hidden"],902 });903904 document.addEventListener("focusin", (e) => {905 const target = e.target;906 if (!(target instanceof HTMLInputElement)) return;907 if (hasLoginOrSignupForm()) return;908 if (!isTOTPField(target)) return;909 const { anchor, group } = resolveTOTPIconTarget(target);910 injectTOTPFieldIcon(anchor, group);911 }, true);912}913914document.addEventListener("focusin", onFocusIn);915916setupMutationObserver();917setupTOTPAutofill();918setupSaveDetection();919setupCardAutofill();920921922923924925926927928let lastObservedUrl = globalThis.location.href;929new MutationObserver(() => {930 const currentUrl = globalThis.location?.href;931 if (!currentUrl || currentUrl === lastObservedUrl) return;932 lastObservedUrl = currentUrl;933 pageFilledAt = null;934}).observe(document.body, { childList: true, subtree: false });935936937938(async () => {939 let aggressionLevel = 0;940 try {941 const resp = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });942 const s = (resp as { ok: boolean; data?: { manualSummonDomains?: Record<string, { summonCount: number }> } }).data;943 const domain = getPageDomain();944 const record = domain ? s?.manualSummonDomains?.[domain] : undefined;945 aggressionLevel = !record ? 0 : record.summonCount >= 2 ? 2 : 1;946 } catch { }947948 setTimeout(() => initialScan(aggressionLevel, false), 0);949 setTimeout(() => initialScan(aggressionLevel, false), 500);950 setTimeout(() => initialScan(aggressionLevel, true), 1500);951})();952953954955956957958const processedCardInputs = new WeakMap<HTMLInputElement, () => void>();959let currentCardDismiss: (() => void) | null = null;960let currentCardAnchor: HTMLInputElement | null = null;961let lastDismissedCardField: HTMLInputElement | null = null;962963async function fetchSavedCards(): Promise<CardSelectorEntry[]> {964 const resp = await chrome.runtime.sendMessage({965 type: "AUTOFILL_QUERY",966 payload: { domain: getPageDomain(), kind: "card" },967 }).catch(() => null);968 const r = resp as { ok: boolean; data?: CardSelectorEntry[] } | null;969 return r?.ok ? (r.data ?? []) : [];970}971972async function openCardSelector(anchor: HTMLInputElement): Promise<void> {973 if (!canOpen()) return;974 if (currentCardDismiss && currentCardAnchor === anchor) return;975 if (anchor === lastDismissedCardField) return;976 if (anchor !== lastDismissedCardField) lastDismissedCardField = null;977 if (wasAlreadyFilled(anchor)) return;978979 const cards = await fetchSavedCards();980 if (cards.length === 0) return; 981982 const cardForm = detectCardForm();983 if (!cardForm?.number) return;984985 currentCardDismiss?.();986 currentCardAnchor = anchor;987 currentCardDismiss = showCardSelector(988 anchor,989 cards,990 {991 domain: getPageDomain(),992 onFill: (entry) => {993 if (!cardForm) return;994 fillCardForm(cardForm, {995 cardNumber: entry.cardNumber ?? "",996 expMonth: entry.expMonth ?? "",997 expYear: entry.expYear ?? "",998 cvc: entry.cvc ?? "",999 cardholderName: entry.cardholderName ?? "",1000 });1001 1002 1003 1004 recordFillSuccess(cardForm.number, cardForm.cvc, cardForm.expiryCombo, cardForm.name);1005 currentCardDismiss = null;1006 currentCardAnchor = null;1007 },1008 onDismiss: () => {1009 lastDismissedCardField = anchor;1010 startDismissLock();1011 currentCardDismiss = null;1012 currentCardAnchor = null;1013 },1014 }1015 );1016}10171018function injectCardIcon(field: HTMLInputElement): void {1019 if (processedCardInputs.has(field)) return;1020 const cleanup = injectFieldIcon(field, () => {1021 currentCardDismiss?.();1022 currentCardDismiss = null;1023 lastDismissedCardField = null;1024 dismissLock = false;1025 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1026 clearFillStateForExplicitSummon(field);1027 field.focus({ preventScroll: true });1028 void openCardSelector(field);1029 });1030 processedCardInputs.set(field, cleanup);1031}10321033function setupCardAutofill(): void {1034 function scan() {1035 const cardForm = detectCardForm();1036 if (!cardForm?.number) return;1037 if (!isVisible(cardForm.number)) return;1038 injectCardIcon(cardForm.number);1039 }10401041 1042 1043 setTimeout(scan, 0);1044 setTimeout(scan, 500);1045 setTimeout(scan, 1500);10461047 1048 1049 1050 let pending = false;1051 new MutationObserver(() => {1052 if (pending) return;1053 pending = true;1054 setTimeout(() => { pending = false; scan(); }, 200);1055 }).observe(document.body, { childList: true, subtree: true });10561057 1058 1059 1060 document.addEventListener("focusin", (e) => {1061 const target = e.target;1062 if (!(target instanceof HTMLInputElement)) return;1063 const cardForm = detectCardForm();1064 if (cardForm?.number === target) {1065 injectCardIcon(target);1066 }1067 }, true);1068}10691070chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {1071 1072 if (sender.id !== chrome.runtime.id) return;1073 if (msg?.type === "GET_SITE_PASSWORD_POLICY") {1074 const policy = detectPasswordPolicy();1075 sendResponse({ ok: true, data: policy });1076 return true;1077 }1078 if (msg?.type === "PING") {1079 sendResponse({ ok: true });1080 return true;1081 }1082 if (msg?.type === "SUMMON_AUTOFILL") {1083 const source = (msg as { type: string; source?: string }).source;1084 const isShortcut = source === "shortcut";1085 const isPasskeyUnlock = source === "passkey-unlock";10861087 const activeInput = getDeepActiveInput();1088 const fields = detectLoginFields();1089 const hasForm = isAnyCredentialForm(fields);10901091 if (hasForm) {1092 1093 1094 const inForm = activeInput && fields.some(f => f.el === activeInput);1095 const target = inForm1096 ? activeInput1097 : (fields.find(f => f.type === "password")?.el1098 ?? fields.find(f => f.type === "username" || f.type === "email")?.el);1099 if (target) {1100 1101 1102 clearFillStateForExplicitSummon(target);1103 dismissLock = false;1104 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1105 lastDismissedField = null;1106 void handleFieldInteraction(target);1107 }1108 } else if (isPasskeyUnlock) {1109 1110 1111 1112 const anchor = (document.activeElement instanceof HTMLElement && document.activeElement !== document.body)1113 ? document.activeElement as unknown as HTMLInputElement1114 : null;1115 if (anchor) {1116 currentDismiss?.();1117 currentAnchorField = null;1118 passkeySummonActive = true;1119 currentDismiss = showAutofillSelector(anchor, [], () => {}, {1120 domain: getPageDomain(),1121 locked: true,1122 onDismiss: () => { passkeySummonActive = false; },1123 });1124 }1125 } else if (isShortcut) {1126 1127 showSearchPalette();1128 }11291130 1131 if (isShortcut) {1132 const domain = getPageDomain();1133 if (domain) {1134 chrome.runtime.sendMessage({ type: "RECORD_MANUAL_SUMMON", payload: { domain } }).catch(() => {});1135 }1136 }11371138 sendResponse({ ok: hasForm || isShortcut || isPasskeyUnlock });1139 return true;1140 }1141 if (msg?.type === "PASSKEY_PRO_GATE") {1142 1143 1144 showPasskeyProGate();1145 sendResponse({ ok: true });1146 return true;1147 }1148 if (msg?.type === "SHOW_PASSKEY_TOAST") {1149 1150 passkeyToastActive = true;1151 if (currentDismiss) {1152 currentDismiss();1153 currentDismiss = null;1154 currentAnchorField = null;1155 }1156 return false; 1157 }1158 if (msg?.type === "DISMISS_PASSKEY_TOAST") {1159 passkeyToastActive = false;1160 return false; 1161 }1162 if (msg?.type === "VAULT_UNLOCKED") {1163 if (passkeySummonActive) {1164 1165 1166 passkeySummonActive = false;1167 if (currentDismiss) {1168 currentDismiss();1169 currentDismiss = null;1170 currentAnchorField = null;1171 }1172 sendResponse({ ok: true });1173 return true;1174 }1175 1176 1177 const savedField = currentAnchorField;1178 if (currentDismiss) {1179 currentDismiss();1180 currentDismiss = null;1181 currentAnchorField = null;1182 }1183 void handleFieldInteraction(savedField ?? undefined);1184 sendResponse({ ok: true });1185 return true;1186 }1187 return true;1188});1189119011911192window.addEventListener("message", (e: MessageEvent) => {1193 1194 1195 if (e.source !== window) return;1196 const d = e.data as { source?: string; type?: string } | undefined;1197 if (d?.source === "peach-passkey-toast" && d?.type === "toast-closed") {1198 passkeyToastActive = false;1199 }1200});1201120212031204120512061207120812091210121112121213if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1214 let lastPasskeyOfferAt = 0;1215 const PASSKEY_OFFER_COOLDOWN_MS = 30_000;12161217 window.addEventListener("message", (event: MessageEvent) => {1218 if (event.source !== window) return;1219 const data = event.data as { source?: string; type?: string; rpId?: string; rpName?: string; userName?: string; userDisplayName?: string } | undefined;1220 if (!data || data.source !== "peach-passkey-firefox") return;1221 if (detectBrowser() !== "firefox") return;1222 if (data.type !== "create") return;12231224 1225 1226 const now = Date.now();1227 if (now - lastPasskeyOfferAt < PASSKEY_OFFER_COOLDOWN_MS) return;1228 lastPasskeyOfferAt = now;12291230 const domain = location.hostname.replace(/^www\./, "");1231 chrome.runtime.sendMessage({1232 type: "GET_PASSKEY_OFFER_DECLINED",1233 payload: { domain },1234 }).then((res: { ok: boolean; data?: boolean } | undefined) => {1235 if (res?.data === true) return;1236 const rpId = data.rpId ?? domain;1237 showPasskeyRegistrationOffer({1238 rpId,1239 rpName: data.rpName,1240 userName: data.userName,1241 userDisplayName: data.userDisplayName,1242 });1243 }).catch(() => {});1244 });1245}124612471248124912501251125212531254125512561257125812591260{1261 function checkPasskeyAvailability() {1262 const domain = location.hostname.replace(/^www\./, "");1263 if (!domain) return;1264 const sessionKey = `peach_state11_shown:${domain}`;1265 chrome.storage.session?.get(sessionKey).then((stored) => {1266 if (stored && (stored as Record<string, unknown>)[sessionKey]) return;1267 chrome.runtime.sendMessage({1268 type: "GET_PASSKEY_FOR_DOMAIN",1269 payload: { domain },1270 }).then((res: { ok: boolean; data?: { rpId: string; userName: string; browser?: "chrome" | "firefox" } | null } | undefined) => {1271 const data = res?.data;1272 if (!data) return;1273 const current = detectBrowser();1274 const isCrossBrowser =1275 data.browser && current !== "unknown" && data.browser !== current;1276 const valuableOnFirefox = current === "firefox";1277 if (!isCrossBrowser && !valuableOnFirefox) return;1278 chrome.storage.session?.set({ [sessionKey]: Date.now() }).catch(() => {});1279 showPasskeyAvailable({1280 rpId: data.rpId,1281 userName: data.userName,1282 fromBrowser: data.browser,1283 currentBrowser: current,1284 });1285 }).catch(() => {});1286 }).catch(() => {});1287 }12881289 1290 1291 1292 if (document.readyState === "loading") {1293 document.addEventListener("DOMContentLoaded", () => setTimeout(checkPasskeyAvailability, 800));1294 } else {1295 setTimeout(checkPasskeyAvailability, 800);1296 }1297}