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";353637383940const processedInputs = new WeakMap<HTMLInputElement, () => void>();41const AUTOFILL_FIELD_TYPES = new Set(["password", "username", "email"]);4243let currentDismiss: (() => void) | null = null;4445let currentAnchorField: HTMLInputElement | null = null;4647const pendingInteractions = new WeakSet<HTMLInputElement>();48495051let lastDismissedField: HTMLInputElement | null = null;52535455565758const DISMISS_LOCK_MS = 800;59let dismissLock = false;60let dismissLockTimer: ReturnType<typeof setTimeout> | null = null;61626364let passkeySummonActive = false;65666768let passkeyToastActive = false;69let passkeyToastToken: string | null = null;70function startDismissLock(): void {71 dismissLock = true;72 if (dismissLockTimer) clearTimeout(dismissLockTimer);73 dismissLockTimer = setTimeout(() => { dismissLock = false; }, DISMISS_LOCK_MS);74}75function canOpen(): boolean { return !dismissLock; }7677787980818283848586878889const filledForms = new WeakSet<HTMLFormElement>();90let pageFilledAt: number | null = null;91const PAGE_FILL_GRACE_MS = 3000;9293function recordFillSuccess(...fields: (HTMLInputElement | null | undefined)[]): void {94 for (const f of fields) {95 if (!f) continue;96 const form = f.closest("form");97 if (form) filledForms.add(form);98 }99 pageFilledAt = Date.now();100}101102export function wasAlreadyFilled(field: HTMLInputElement): boolean {103 const form = field.closest("form");104 if (form && filledForms.has(form)) return true;105 if (pageFilledAt !== null && Date.now() - pageFilledAt < PAGE_FILL_GRACE_MS) return true;106 return false;107}108109function clearFillStateForExplicitSummon(field: HTMLInputElement): void {110 const form = field.closest("form");111 if (form) filledForms.delete(form);112 pageFilledAt = null;113}114115116117118119function isSubmitButton(el: EventTarget | null): boolean {120 if (!(el instanceof HTMLElement)) return false;121 if (el instanceof HTMLButtonElement && el.type !== "button") return true;122 if (el instanceof HTMLInputElement && el.type === "submit") return true;123 if (el.getAttribute("type") === "submit") return true;124 const text = (el.textContent?.trim() ?? el.getAttribute("value") ?? "").toLowerCase();125 if (!text || text.length > 24) return false;126 return /^(submit|sign[ -]?in|log[ -]?in|continue|next|go)$/i.test(text);127}128129function debounce<T extends (...args: unknown[]) => void>(130 fn: T,131 ms: number132): (...args: Parameters<T>) => void {133 let timer: ReturnType<typeof setTimeout>;134 return (...args: Parameters<T>) => {135 clearTimeout(timer);136 timer = setTimeout(() => fn(...args), ms);137 };138}139140function showToast(message: string): void {141 const toast = document.createElement("div");142 toast.style.cssText = `143 position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);144 background: #131109; color: #F0E8D8; padding: 8px 16px;145 border-radius: 8px; font-family: 'IBM Plex Mono', monospace;146 font-size: 11px; z-index: 2147483647; opacity: 0;147 transition: opacity 200ms ease;148 border: 1px solid rgba(240,232,216,0.07);149 pointer-events: none;150 `;151 toast.textContent = message;152 document.body.appendChild(toast);153 requestAnimationFrame(() => { toast.style.opacity = "1"; });154 setTimeout(() => {155 toast.style.opacity = "0";156 setTimeout(() => toast.remove(), 200);157 }, 2000);158}159160function showSearchPalette(capability?: string): void {161 const host = document.createElement("div");162 host.style.cssText = "position:fixed;top:80px;left:50%;transform:translateX(-50%);z-index:2147483647;width:340px;";163 document.body.appendChild(host);164 const shadow = host.attachShadow({ mode: "closed" });165166 const style = document.createElement("style");167 style.textContent = `168 .p{background:#131109;border:1px solid rgba(240,232,216,.12);border-radius:12px;169 font-family:'IBM Plex Mono',monospace;font-size:12px;color:#F0E8D8;170 box-shadow:0 8px 24px rgba(0,0,0,.5);overflow:hidden}171 .h{padding:10px 12px 8px;border-bottom:1px solid rgba(240,232,216,.08);172 font-size:11px;color:rgba(240,232,216,.5);display:flex;justify-content:space-between;align-items:center}173 .x{background:none;border:none;color:rgba(240,232,216,.4);cursor:pointer;font-size:14px;padding:0;line-height:1}174 .s{padding:8px 12px}175 .s input{width:100%;box-sizing:border-box;background:rgba(240,232,216,.06);176 border:1px solid rgba(240,232,216,.12);border-radius:6px;color:#F0E8D8;177 padding:6px 8px;font-family:inherit;font-size:12px;outline:none}178 .l{max-height:220px;overflow-y:auto}179 .e{padding:8px 12px;cursor:pointer;display:flex;flex-direction:column;gap:2px}180 .e:hover{background:rgba(240,232,216,.06)}181 .et{font-size:12px}182 .eu{font-size:10px;color:rgba(240,232,216,.45)}183 .em{padding:12px;color:rgba(240,232,216,.4);text-align:center}184 `;185186 let allEntries: SelectorEntry[] = [];187 let query = "";188189 const panel = document.createElement("div");190 panel.className = "p";191192 const hdr = document.createElement("div");193 hdr.className = "h";194 const hdrText = document.createElement("span");195 hdrText.textContent = "No form detected — search your vault";196 const closeBtn = document.createElement("button");197 closeBtn.className = "x";198 closeBtn.textContent = "✕";199 hdr.appendChild(hdrText);200 hdr.appendChild(closeBtn);201202 const searchWrap = document.createElement("div");203 searchWrap.className = "s";204 const searchInput = document.createElement("input");205 searchInput.placeholder = "search...";206 searchWrap.appendChild(searchInput);207208 const list = document.createElement("div");209 list.className = "l";210211 panel.appendChild(hdr);212 panel.appendChild(searchWrap);213 panel.appendChild(list);214 shadow.appendChild(style);215 shadow.appendChild(panel);216217 function close() { host.remove(); }218219 closeBtn.onclick = close;220221 function onKeyDown(e: KeyboardEvent) {222 if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKeyDown, true); }223 }224 document.addEventListener("keydown", onKeyDown, true);225226 function render() {227 const q = query.toLowerCase();228 const filtered = q229 ? allEntries.filter(e => e.title.toLowerCase().includes(q) || (e.username ?? "").toLowerCase().includes(q))230 : allEntries;231 list.innerHTML = "";232 if (filtered.length === 0) {233 const em = document.createElement("div");234 em.className = "em";235 em.textContent = query ? "no matches" : "vault is empty";236 list.appendChild(em);237 return;238 }239 for (const entry of filtered.slice(0, 20)) {240 const row = document.createElement("div");241 row.className = "e";242 const title = document.createElement("div");243 title.className = "et";244 title.textContent = entry.title;245 row.appendChild(title);246 if (entry.username) {247 const user = document.createElement("div");248 user.className = "eu";249 user.textContent = entry.username;250 row.appendChild(user);251 }252 row.onclick = async () => {253 254 255 const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability } });256 const r = resp as { ok: boolean; data?: { password?: string } };257 if (r.ok && r.data?.password) {258 let alreadyWritten = false;259 try {260 await navigator.clipboard.writeText(r.data.password);261 alreadyWritten = true;262 } catch (err) {263 console.warn("[Peach] clipboard write denied:", err);264 }265 const copyResp = await chrome.runtime.sendMessage({266 type: "COPY",267 payload: { text: r.data.password, label: "Password", alreadyWritten },268 }) as { ok?: boolean } | undefined;269 if (alreadyWritten || copyResp?.ok) {270 showToast("Password copied");271 } else {272 showToast("Could not copy password — clipboard blocked");273 }274 } else {275 showToast("Could not copy password");276 }277 close();278 document.removeEventListener("keydown", onKeyDown, true);279 };280 list.appendChild(row);281 }282 }283284 searchInput.oninput = () => { query = searchInput.value; render(); };285286 287 288 fetchAutofillEntries(getPageDomain(), true, capability).then(result => {289 if (result.locked) { showToast("Vault is locked — open Peach to unlock"); close(); return; }290 allEntries = result.entries;291 render();292 searchInput.focus();293 });294}295296function findTOTPField(root: Document | Element = document): HTMLInputElement | null {297 const inputs = root.querySelectorAll<HTMLInputElement>("input");298 for (const input of inputs) {299 if (isTOTPField(input)) return input;300 }301 return null;302}303304function findSameLoginTOTPField(passwordEl: HTMLInputElement): HTMLInputElement | null {305 const form = passwordEl.closest("form");306 return findTOTPField(form ?? document);307}308309310async function handleAutofill(311 _field: HTMLInputElement,312 usernameEl: HTMLInputElement | null,313 passwordEl: HTMLInputElement | null,314 entry: VaultEntry,315 emailEl?: HTMLInputElement | null316): Promise<{ filledTOTPField: HTMLInputElement | null; gatePassed: boolean }> {317 if (!entry.username && !entry.password && !entry.email) return { filledTOTPField: null, gatePassed: false };318 const pageDomain = getPageDomain();319 const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;320 321 322 323 const emailValue = entry.email ?? (entry.username?.includes("@") ? entry.username : "");324 const gatePassed = fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain, emailEl, emailValue);325 let filledTOTPField: HTMLInputElement | null = null;326327 if (entry.canary && gatePassed) {328 chrome.runtime.sendMessage({329 type: "FIRE_CANARY_PING",330 payload: {331 webhookUrl: entry.canary.webhookUrl,332 entryTitle: entry.title,333 trigger: "autofilled",334 },335 }).catch(() => {});336 chrome.runtime.sendMessage({337 type: "UPDATE_ENTRY",338 payload: {339 id: entry.id,340 patch: {341 canary: {342 ...entry.canary,343 lastPinged: Date.now(),344 pingCount: (entry.canary.pingCount ?? 0) + 1,345 },346 },347 },348 }).catch(() => {});349 }350351 352 353 const totpCode = (entry as { totpCode?: string }).totpCode;354 if (totpCode && gatePassed) {355 const code = totpCode;356 const totpField = passwordEl ? findSameLoginTOTPField(passwordEl) : null;357 if (fillTOTPCode(totpField, code, form, pageDomain)) {358 filledTOTPField = totpField;359 }360 let alreadyWritten = false;361 try {362 await navigator.clipboard.writeText(code);363 alreadyWritten = true;364 } catch (err) {365 console.warn("[Peach] clipboard write denied:", err);366 }367 const response = await chrome.runtime.sendMessage({368 type: "COPY",369 payload: { text: code, label: "TOTP", alreadyWritten },370 });371 const res = response as { ok?: boolean; clearSeconds?: number };372 if (filledTOTPField) {373 showToast("2FA code filled");374 } else if (alreadyWritten || res.ok) {375 376 377 378 379 showTotpCopyToast(res.clearSeconds ?? 30);380 } else {381 showToast("Could not copy TOTP — clipboard blocked");382 }383 }384385 return { filledTOTPField, gatePassed };386}387388async function fetchAutofillEntries(389 domain: string,390 includeAll = false,391 capability?: string392): Promise<{ entries: SelectorEntry[]; locked: boolean }> {393 const response = await chrome.runtime.sendMessage({394 type: "AUTOFILL_QUERY",395 payload: { domain, includeAll, capability },396 });397 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };398 if (!res.ok && res.error === "vault_locked") {399 return { entries: [], locked: true };400 }401 return { entries: res.data ?? [], locked: false };402}403404async function fetchTOTPEntries(405 domain: string,406 capability?: string,407): Promise<{ entries: SelectorEntry[]; locked: boolean }> {408 const response = await chrome.runtime.sendMessage({409 type: "AUTOFILL_QUERY",410 payload: { domain, kind: "totp", capability },411 });412 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };413 if (!res.ok && res.error === "vault_locked") {414 return { entries: [], locked: true };415 }416 return { entries: res.data ?? [], locked: false };417}418419420function resolveAutofillFieldType(421 el: HTMLInputElement,422 predictedType?: FieldPrediction["type"] | null423): "password" | "username" | "email" | null {424 if (425 predictedType === "password" ||426 predictedType === "username" ||427 predictedType === "email"428 ) {429 return predictedType;430 }431432 const detected = detectLoginFields().find((field) => field.el === el)?.type;433 if (detected && AUTOFILL_FIELD_TYPES.has(detected)) {434 return detected;435 }436437 const htmlType = el.type.toLowerCase();438 if (htmlType === "password") return "password";439 if (htmlType === "email") return "email";440 return null;441}442443async function openAutofillSelector(444 anchorField: HTMLInputElement,445 usernameEl: HTMLInputElement | null,446 passwordEl: HTMLInputElement | null,447 domainEntries: SelectorEntry[],448 loadSearchableEntries: () => Promise<SelectorEntry[]>,449 domain: string,450 identityKind: "email" | "username",451 emailEl?: HTMLInputElement | null,452 capability?: string,453): Promise<void> {454 455 456 const isSignup = domainEntries.length === 0;457 let confirmEl: HTMLInputElement | null = null;458 if (isSignup) {459 const form = (passwordEl ?? usernameEl)?.closest("form") ?? document.body;460 const allPwInputs = Array.from(form.querySelectorAll<HTMLInputElement>("input[type='password']"));461 confirmEl = allPwInputs.find(462 (el) => el !== passwordEl && /confirm|repeat|verify/i.test(el.name + el.id + el.placeholder)463 ) ?? (allPwInputs.length >= 2 ? allPwInputs.find((el) => el !== passwordEl) ?? null : null);464 }465466 currentDismiss?.();467 currentAnchorField = anchorField;468 currentDismiss = showAutofillSelector(469 anchorField,470 domainEntries,471 async (id: string) => {472 currentDismiss = null;473 currentAnchorField = null;474 const entryResponse = await chrome.runtime.sendMessage({475 type: "GET_ENTRY_FOR_FILL",476 payload: { id, capability },477 });478 const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;479 if (entry) {480 const { filledTOTPField, gatePassed } = await handleAutofill(anchorField, usernameEl, passwordEl, entry, emailEl);481 if (gatePassed) recordFillSuccess(anchorField, usernameEl, passwordEl, filledTOTPField);482 483 484 485 const pageDomain = location.hostname.replace(/^www\./, "");486 const entryDomains = new Set<string>();487 for (const u of [entry.url, ...(entry.additionalUrls ?? [])]) {488 try {489 if (u) entryDomains.add(new URL(u).hostname.replace(/^www\./, ""));490 } catch { }491 }492 if (pageDomain && entryDomains.size > 0 && !entryDomains.has(pageDomain)) {493 showRememberSiteToast(pageDomain, entry.id);494 }495 }496 },497 {498 domain,499 capability,500 loadSearchableEntries,501 isSignup,502 identityKind,503 hasEmailField: !!emailEl,504 onFillFields: (username, password, email) => {505 const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;506 if (!fillFields(usernameEl, passwordEl, username, password, form, getPageDomain(), emailEl, email)) return;507 if (password && confirmEl) setNativeValue(confirmEl, password);508 recordFillSuccess(anchorField, usernameEl, passwordEl);509 },510 onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },511 }512 );513}514515async function openTOTPSelector(516 anchorField: HTMLInputElement,517 otpDigitBoxes?: HTMLInputElement[],518 capability?: string,519): Promise<void> {520 if (!canOpen()) return;521 if (currentDismiss && currentAnchorField === anchorField) return;522 if (anchorField !== lastDismissedField) lastDismissedField = null;523 if (anchorField === lastDismissedField) return;524 if (wasAlreadyFilled(anchorField)) return;525526 const domain = getPageDomain();527 const result = await fetchTOTPEntries(domain, capability);528529 if (result.locked) {530 currentDismiss?.();531 currentAnchorField = anchorField;532 currentDismiss = showAutofillSelector(533 anchorField,534 [],535 () => {},536 {537 domain,538 locked: true,539 onUnlocked: () => void openTOTPSelector(anchorField),540 onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },541 }542 );543 return;544 }545546 if (result.entries.length === 0) {547 lastDismissedField = anchorField;548 startDismissLock();549 showToast("No 2FA entries for this site — add a code in Peach first");550 return;551 }552553 currentDismiss?.();554 currentAnchorField = anchorField;555 currentDismiss = showTOTPSelector(556 anchorField,557 result.entries,558 async (id: string) => {559 try {560 const entryResponse = await chrome.runtime.sendMessage({561 type: "GET_ENTRY_FOR_FILL",562 payload: { id, capability },563 }).catch(() => null) as { ok: boolean; data?: VaultEntry } | null;564 const entry = entryResponse?.ok ? entryResponse.data : undefined;565 const code = (entry as { totpCode?: string } | undefined)?.totpCode;566 if (!code) return;567 if (!fillAllowed(anchorField.closest("form"), getPageDomain())) return;568 if (otpDigitBoxes && otpDigitBoxes.length > 1) {569 fillOTPDigitBoxes(otpDigitBoxes, code);570 } else {571 setNativeValue(anchorField, code);572 }573 recordFillSuccess(anchorField);574 showToast("2FA code filled");575 } finally {576 currentDismiss = null;577 currentAnchorField = null;578 }579 },580 {581 domain,582 onDismiss: () => {583 lastDismissedField = anchorField;584 startDismissLock();585 currentDismiss = null;586 currentAnchorField = null;587 },588 }589 );590}591592593export async function handleFieldInteraction(594 anchorField?: HTMLInputElement | null,595 explicitSummon = false,596 capability?: string597): Promise<void> {598 const field = anchorField ?? getDeepActiveInput();599 if (!field) return;600 if (!isVisible(field)) return;601602 603 if (passkeyToastActive) return;604605 606 607 if (!canOpen()) return;608609 610 if (currentDismiss && currentAnchorField === field) return;611612 613 614 if (field !== lastDismissedField) lastDismissedField = null;615 616 617 if (field === lastDismissedField) return;618619 620 621 622 623 if (wasAlreadyFilled(field)) return;624625 626 627 if (pendingInteractions.has(field)) return;628 pendingInteractions.add(field);629630 try {631 632 633 634 635 636 const otpGroup = findOTPDigitBoxGroup(field) ?? findOtpSignalingGroup(field);637 if (otpGroup) {638 await openTOTPSelector(otpGroup[0]!, otpGroup, capability);639 return;640 }641642 if (isTOTPField(field)) {643 const formFields = detectLoginFields();644 if (!isLoginForm(formFields) && !isSignupForm(formFields)) {645 await openTOTPSelector(field, undefined, capability);646 return;647 }648 }649650 const fieldType = field.type.toLowerCase();651652 const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {653 return preds[0] ?? null;654 });655656 const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {657 const timeout = setTimeout(() => resolve(null), 150);658 mlPromise.then((pred) => {659 clearTimeout(timeout);660 resolve(pred);661 });662 });663664 if (passkeyToastActive) return;665666 if (!explicitSummon && fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {667 return;668 }669670 const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);671 if (autofillFieldType) {672 reattachListeners(field, autofillFieldType);673 }674675 const fields = detectLoginFields();676 const targets = resolveAutofillInteractionTargets(field, fields, explicitSummon);677 if (!targets.shouldOpen) {678 return;679 }680681 const domain = getPageDomain();682 const commandAuthorized = explicitSummon && capability !== undefined;683 const firstResult = await fetchAutofillEntries(684 domain,685 commandAuthorized,686 commandAuthorized ? capability : undefined,687 );688 if (passkeyToastActive) return;689690 691 692 693 if (firstResult.locked) {694 currentDismiss?.();695 currentAnchorField = field;696 currentDismiss = showAutofillSelector(697 field,698 [],699 () => {},700 { domain, locked: true, onDismiss: () => { lastDismissedField = field; startDismissLock(); } }701 );702 return;703 }704705 const entries = firstResult.entries;706 707 708 709 710 711 712 const loadSearchableEntries = async (): Promise<SelectorEntry[]> =>713 commandAuthorized714 ? entries715 : (await fetchAutofillEntries(domain)).entries;716717 if (passkeyToastActive) return;718719 const pwField = fields.find((f) => f.type === "password");720 721 if (!explicitSummon && !pwField && !isEmailFirstForm(fields)) return;722 const usernameEl = targets.usernameEl;723 const passwordEl = targets.passwordEl;724 const emailEl = targets.emailEl;725726 await openAutofillSelector(727 field,728 usernameEl,729 passwordEl,730 entries,731 loadSearchableEntries,732 domain,733 autofillFieldType === "email" ? "email" : "username",734 emailEl,735 commandAuthorized ? capability : undefined,736 );737 } finally {738 pendingInteractions.delete(field);739 }740}741742async function onFocusIn(e: Event): Promise<void> {743 744 745 746 747 748 if (isSubmitButton(e.target)) return;749 await handleFieldInteraction();750}751752function reattachListeners(753 el: HTMLInputElement,754 detectedType?: "password" | "username" | "email",755 contextFields?: ReturnType<typeof detectLoginFields>756): void {757 if (processedInputs.has(el)) return;758 if (!resolveAutofillFieldType(el, detectedType)) return;759 760 const fields = contextFields ?? detectLoginFields();761 if (!isAnyCredentialForm(fields)) return;762763 const cleanup = injectFieldIcon(el, () => {764 currentDismiss?.();765 currentDismiss = null;766 lastDismissedField = null; 767 dismissLock = false; 768 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }769 clearFillStateForExplicitSummon(el);770 el.focus({ preventScroll: true });771 void handleFieldInteraction(el, true);772 });773 processedInputs.set(el, cleanup);774}775776const debouncedReattach = debounce(() => {777 const allFields = detectLoginFields();778 if (!isAnyCredentialForm(allFields)) return;779 const newFields = allFields.filter(780 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)781 );782 for (const f of newFields) {783 reattachListeners(f.el, f.type, allFields);784 }785 786 787 788}, 200);789790function setupMutationObserver(): void {791 const observer = new MutationObserver(debouncedReattach);792 observer.observe(document.body, {793 childList: true,794 subtree: true,795 attributes: true,796 attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],797 });798}799800export function initialScan(aggressionLevel = 0, isFinalPass = false) {801 const allFields = detectLoginFields();802 const hasKnownForm = isAnyCredentialForm(allFields);803804 if (aggressionLevel === 0 && !hasKnownForm) return;805806 const newFields = allFields.filter(807 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)808 );809810 for (const f of newFields) {811 reattachListeners(f.el, f.type, allFields);812 }813814 815 816 if (aggressionLevel >= 2 && isFinalPass && newFields.length === 0) {817 const pwInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');818 for (const el of pwInputs) {819 if (isVisible(el) && !processedInputs.has(el)) {820 const cleanup = injectFieldIcon(el, () => {821 currentDismiss?.();822 currentDismiss = null;823 lastDismissedField = null;824 dismissLock = false;825 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }826 clearFillStateForExplicitSummon(el);827 el.focus({ preventScroll: true });828 void handleFieldInteraction(el, true);829 });830 processedInputs.set(el, cleanup);831 }832 }833 }834}835836837838export const _testSeam = {839 getLastDismissedField(): HTMLInputElement | null { return lastDismissedField; },840 setLastDismissedField(f: HTMLInputElement | null): void { lastDismissedField = f; },841 isDismissLocked(): boolean { return dismissLock; },842 DISMISS_LOCK_MS,843} as const;844845846847848849const processedTOTPInputs = new WeakMap<HTMLInputElement, () => void>();850851function hasLoginOrSignupForm(): boolean {852 const fields = detectLoginFields();853 return isAnyCredentialForm(fields);854}855856function injectTOTPFieldIcon(857 field: HTMLInputElement,858 otpDigitBoxes?: HTMLInputElement[]859): void {860 if (processedTOTPInputs.has(field)) return;861 const cleanup = injectFieldIcon(field, () => {862 currentDismiss?.();863 currentDismiss = null;864 currentAnchorField = null;865 lastDismissedField = null;866 dismissLock = false;867 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }868 clearFillStateForExplicitSummon(field);869 field.focus({ preventScroll: true });870 void openTOTPSelector(field, otpDigitBoxes);871 });872 processedTOTPInputs.set(field, cleanup);873}874875876877878879function resolveTOTPIconTarget(880 field: HTMLInputElement881): { anchor: HTMLInputElement; group?: HTMLInputElement[] } {882 const group = findOtpSignalingGroup(field);883 if (group) return { anchor: group[0]!, group };884 return { anchor: field };885}886887export function scanTOTPFields(): void {888 if (hasLoginOrSignupForm()) return;889 const inputs = document.querySelectorAll<HTMLInputElement>("input");890 for (const input of inputs) {891 if (!isTOTPField(input)) continue;892 const { anchor, group } = resolveTOTPIconTarget(input);893 injectTOTPFieldIcon(anchor, group);894 }895}896897function setupTOTPAutofill(): void {898 setTimeout(scanTOTPFields, 0);899 setTimeout(scanTOTPFields, 500);900 setTimeout(scanTOTPFields, 1500);901902 const debouncedScan = debounce(scanTOTPFields, 200);903 new MutationObserver(debouncedScan).observe(document.body, {904 childList: true,905 subtree: true,906 attributes: true,907 attributeFilter: ["autocomplete", "name", "id", "placeholder", "hidden"],908 });909910 document.addEventListener("focusin", (e) => {911 const target = e.target;912 if (!(target instanceof HTMLInputElement)) return;913 if (hasLoginOrSignupForm()) return;914 if (!isTOTPField(target)) return;915 const { anchor, group } = resolveTOTPIconTarget(target);916 injectTOTPFieldIcon(anchor, group);917 }, true);918}919920document.addEventListener("focusin", onFocusIn);921922setupMutationObserver();923setupTOTPAutofill();924setupSaveDetection();925setupCardAutofill();926927928929930931932933934let lastObservedUrl = globalThis.location.href;935new MutationObserver(() => {936 const currentUrl = globalThis.location?.href;937 if (!currentUrl || currentUrl === lastObservedUrl) return;938 lastObservedUrl = currentUrl;939 pageFilledAt = null;940}).observe(document.body, { childList: true, subtree: false });941942943944(async () => {945 let aggressionLevel = 0;946 try {947 const resp = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });948 const s = (resp as { ok: boolean; data?: { manualSummonDomains?: Record<string, { summonCount: number }> } }).data;949 const domain = getPageDomain();950 const record = domain ? s?.manualSummonDomains?.[domain] : undefined;951 aggressionLevel = !record ? 0 : record.summonCount >= 2 ? 2 : 1;952 } catch { }953954 setTimeout(() => initialScan(aggressionLevel, false), 0);955 setTimeout(() => initialScan(aggressionLevel, false), 500);956 setTimeout(() => initialScan(aggressionLevel, true), 1500);957})();958959960961962963964const processedCardInputs = new WeakMap<HTMLInputElement, () => void>();965let currentCardDismiss: (() => void) | null = null;966let currentCardAnchor: HTMLInputElement | null = null;967let lastDismissedCardField: HTMLInputElement | null = null;968969async function fetchSavedCards(capability?: string): Promise<CardSelectorEntry[]> {970 const resp = await chrome.runtime.sendMessage({971 type: "AUTOFILL_QUERY",972 payload: { domain: getPageDomain(), kind: "card", capability },973 }).catch(() => null);974 const r = resp as { ok: boolean; data?: CardSelectorEntry[] } | null;975 return r?.ok ? (r.data ?? []) : [];976}977978async function openCardSelector(anchor: HTMLInputElement, capability?: string): Promise<void> {979 if (!canOpen()) return;980 if (currentCardDismiss && currentCardAnchor === anchor) return;981 if (anchor === lastDismissedCardField) return;982 if (anchor !== lastDismissedCardField) lastDismissedCardField = null;983 if (wasAlreadyFilled(anchor)) return;984985 const cards = await fetchSavedCards(capability);986 if (cards.length === 0) return; 987988 const cardForm = detectCardForm();989 if (!cardForm?.number) return;990991 currentCardDismiss?.();992 currentCardAnchor = anchor;993 currentCardDismiss = showCardSelector(994 anchor,995 cards,996 {997 domain: getPageDomain(),998 capability,999 onFill: (entry) => {1000 if (!cardForm) return;1001 fillCardForm(cardForm, {1002 cardNumber: entry.cardNumber ?? "",1003 expMonth: entry.expMonth ?? "",1004 expYear: entry.expYear ?? "",1005 cvc: entry.cvc ?? "",1006 cardholderName: entry.cardholderName ?? "",1007 });1008 1009 1010 1011 recordFillSuccess(cardForm.number, cardForm.cvc, cardForm.expiryCombo, cardForm.name);1012 currentCardDismiss = null;1013 currentCardAnchor = null;1014 },1015 onDismiss: () => {1016 lastDismissedCardField = anchor;1017 startDismissLock();1018 currentCardDismiss = null;1019 currentCardAnchor = null;1020 },1021 }1022 );1023}10241025function injectCardIcon(field: HTMLInputElement): void {1026 if (processedCardInputs.has(field)) return;1027 const cleanup = injectFieldIcon(field, () => {1028 currentCardDismiss?.();1029 currentCardDismiss = null;1030 lastDismissedCardField = null;1031 dismissLock = false;1032 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1033 clearFillStateForExplicitSummon(field);1034 field.focus({ preventScroll: true });1035 void openCardSelector(field);1036 });1037 processedCardInputs.set(field, cleanup);1038}10391040function setupCardAutofill(): void {1041 function scan() {1042 const cardForm = detectCardForm();1043 if (!cardForm?.number) return;1044 if (!isVisible(cardForm.number)) return;1045 injectCardIcon(cardForm.number);1046 }10471048 1049 1050 setTimeout(scan, 0);1051 setTimeout(scan, 500);1052 setTimeout(scan, 1500);10531054 1055 1056 1057 let pending = false;1058 new MutationObserver(() => {1059 if (pending) return;1060 pending = true;1061 setTimeout(() => { pending = false; scan(); }, 200);1062 }).observe(document.body, { childList: true, subtree: true });10631064 1065 1066 1067 document.addEventListener("focusin", (e) => {1068 const target = e.target;1069 if (!(target instanceof HTMLInputElement)) return;1070 const cardForm = detectCardForm();1071 if (cardForm?.number === target) {1072 injectCardIcon(target);1073 }1074 }, true);1075}10761077chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {1078 1079 if (sender.id !== chrome.runtime.id) return;1080 if (msg?.type === "GET_SITE_PASSWORD_POLICY") {1081 const policy = detectPasswordPolicy();1082 sendResponse({ ok: true, data: policy });1083 return true;1084 }1085 if (msg?.type === "PING") {1086 sendResponse({ ok: true });1087 return true;1088 }1089 if (msg?.type === "SUMMON_AUTOFILL") {1090 const source = (msg as { type: string; source?: string; capability?: string }).source;1091 const capability = (msg as { capability?: string }).capability;1092 const isShortcut = source === "shortcut";1093 const isPasskeyUnlock = source === "passkey-unlock";10941095 const activeInput = getDeepActiveInput();1096 const fields = detectLoginFields();1097 const hasForm = isAnyCredentialForm(fields);1098 const cardForm = detectCardForm();10991100 if (hasForm) {1101 1102 1103 const inForm = activeInput && fields.some(f => f.el === activeInput);1104 const target = inForm1105 ? activeInput1106 : (fields.find(f => f.type === "password")?.el1107 ?? fields.find(f => f.type === "username" || f.type === "email")?.el);1108 if (target) {1109 1110 1111 clearFillStateForExplicitSummon(target);1112 dismissLock = false;1113 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1114 lastDismissedField = null;1115 void handleFieldInteraction(target, true, capability);1116 }1117 } else if (isShortcut && cardForm?.number) {1118 clearFillStateForExplicitSummon(cardForm.number);1119 dismissLock = false;1120 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1121 lastDismissedCardField = null;1122 void openCardSelector(cardForm.number, capability);1123 } else if (isPasskeyUnlock) {1124 1125 1126 1127 const anchor = (document.activeElement instanceof HTMLElement && document.activeElement !== document.body)1128 ? document.activeElement as unknown as HTMLInputElement1129 : null;1130 if (anchor) {1131 currentDismiss?.();1132 currentAnchorField = null;1133 passkeySummonActive = true;1134 currentDismiss = showAutofillSelector(anchor, [], () => {}, {1135 domain: getPageDomain(),1136 locked: true,1137 onDismiss: () => { passkeySummonActive = false; },1138 });1139 }1140 } else if (isShortcut) {1141 1142 showSearchPalette(capability);1143 }11441145 1146 if (isShortcut) {1147 const domain = getPageDomain();1148 if (domain) {1149 chrome.runtime.sendMessage({ type: "RECORD_MANUAL_SUMMON", payload: { domain } }).catch(() => {});1150 }1151 }11521153 sendResponse({ ok: hasForm || Boolean(cardForm?.number) || isShortcut || isPasskeyUnlock });1154 return true;1155 }1156 if (msg?.type === "PASSKEY_PRO_GATE") {1157 1158 1159 showPasskeyProGate();1160 sendResponse({ ok: true });1161 return true;1162 }1163 if (msg?.type === "SHOW_PASSKEY_TOAST") {1164 1165 passkeyToastActive = true;1166 passkeyToastToken = typeof (msg.payload as { toastToken?: unknown } | undefined)?.toastToken === "string"1167 ? (msg.payload as { toastToken: string }).toastToken1168 : null;1169 if (currentDismiss) {1170 currentDismiss();1171 currentDismiss = null;1172 currentAnchorField = null;1173 }1174 return false; 1175 }1176 if (msg?.type === "DISMISS_PASSKEY_TOAST") {1177 passkeyToastActive = false;1178 passkeyToastToken = null;1179 return false; 1180 }1181 if (msg?.type === "VAULT_UNLOCKED") {1182 if (passkeySummonActive) {1183 1184 1185 passkeySummonActive = false;1186 if (currentDismiss) {1187 currentDismiss();1188 currentDismiss = null;1189 currentAnchorField = null;1190 }1191 sendResponse({ ok: true });1192 return true;1193 }1194 1195 1196 const savedField = currentAnchorField;1197 if (currentDismiss) {1198 currentDismiss();1199 currentDismiss = null;1200 currentAnchorField = null;1201 }1202 void handleFieldInteraction(savedField ?? undefined);1203 sendResponse({ ok: true });1204 return true;1205 }1206 return true;1207});1208120912101211window.addEventListener("message", (e: MessageEvent) => {1212 1213 1214 if (e.source !== window) return;1215 const d = e.data as { source?: string; type?: string; token?: unknown } | undefined;1216 if (1217 d?.source === "peach-passkey-toast" &&1218 d?.type === "toast-closed" &&1219 d.token === passkeyToastToken1220 ) {1221 passkeyToastActive = false;1222 passkeyToastToken = null;1223 }1224});1225122612271228122912301231123212331234123512361237123812391240{1241 function checkPasskeyAvailability() {1242 const domain = location.hostname.replace(/^www\./, "");1243 if (!domain) return;1244 chrome.runtime.sendMessage({1245 type: "GET_PASSKEY_FOR_DOMAIN",1246 payload: { domain },1247 }).then((res: { ok: boolean; data?: { rpId: string; userName: string; browser?: "chrome" | "firefox"; bannerShown?: boolean } | null } | undefined) => {1248 const data = res?.data;1249 if (!data || data.bannerShown) return;1250 const current = detectBrowser();1251 const isCrossBrowser =1252 data.browser && current !== "unknown" && data.browser !== current;1253 const valuableOnFirefox = current === "firefox";1254 if (!isCrossBrowser && !valuableOnFirefox) return;1255 chrome.runtime.sendMessage({1256 type: "MARK_PASSKEY_BANNER_SHOWN",1257 payload: { domain },1258 }).catch(() => {});1259 showPasskeyAvailable({1260 rpId: data.rpId,1261 userName: data.userName,1262 fromBrowser: data.browser,1263 currentBrowser: current,1264 });1265 }).catch(() => {});1266 }12671268 1269 1270 1271 if (document.readyState === "loading") {1272 document.addEventListener("DOMContentLoaded", () => setTimeout(checkPasskeyAvailability, 800));1273 } else {1274 setTimeout(checkPasskeyAvailability, 800);1275 }1276}