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 { fillAllowed, fillCardForm, fillFields, fillOTPDigitBoxes, fillTOTPCode, setNativeValue } 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(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, 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 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 _userGesture = false,393 capability?: string394): Promise<{ entries: SelectorEntry[]; locked: boolean }> {395 const response = await chrome.runtime.sendMessage({396 type: "AUTOFILL_QUERY",397 payload: { domain, includeAll, capability },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 if (!fillFields(usernameEl, passwordEl, username, password, form, getPageDomain(), emailEl, email)) return;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 (!fillAllowed(anchorField.closest("form"), getPageDomain())) return;572 if (otpDigitBoxes && otpDigitBoxes.length > 1) {573 fillOTPDigitBoxes(otpDigitBoxes, code);574 } else {575 setNativeValue(anchorField, code);576 }577 recordFillSuccess(anchorField);578 showToast("2FA code filled");579 } finally {580 currentDismiss = null;581 currentAnchorField = null;582 }583 },584 {585 domain,586 onDismiss: () => {587 lastDismissedField = anchorField;588 startDismissLock();589 currentDismiss = null;590 currentAnchorField = null;591 },592 }593 );594}595596597export async function handleFieldInteraction(598 anchorField?: HTMLInputElement | null,599 explicitSummon = false,600 capability?: string601): Promise<void> {602 const field = anchorField ?? getDeepActiveInput();603 if (!field) return;604 if (!isVisible(field)) return;605606 607 if (passkeyToastActive) return;608609 610 611 if (!canOpen()) return;612613 614 if (currentDismiss && currentAnchorField === field) return;615616 617 618 if (field !== lastDismissedField) lastDismissedField = null;619 620 621 if (field === lastDismissedField) return;622623 624 625 626 627 if (wasAlreadyFilled(field)) return;628629 630 631 if (pendingInteractions.has(field)) return;632 pendingInteractions.add(field);633634 try {635 636 637 638 639 640 const otpGroup = findOTPDigitBoxGroup(field) ?? findOtpSignalingGroup(field);641 if (otpGroup) {642 await openTOTPSelector(otpGroup[0]!, otpGroup);643 return;644 }645646 if (isTOTPField(field)) {647 const formFields = detectLoginFields();648 if (!isLoginForm(formFields) && !isSignupForm(formFields)) {649 await openTOTPSelector(field);650 return;651 }652 }653654 const fieldType = field.type.toLowerCase();655656 const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {657 return preds[0] ?? null;658 });659660 const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {661 const timeout = setTimeout(() => resolve(null), 150);662 mlPromise.then((pred) => {663 clearTimeout(timeout);664 resolve(pred);665 });666 });667668 if (passkeyToastActive) return;669670 if (!explicitSummon && fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {671 return;672 }673674 const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);675 if (autofillFieldType) {676 reattachListeners(field, autofillFieldType);677 }678679 const fields = detectLoginFields();680 const targets = resolveAutofillInteractionTargets(field, fields, explicitSummon);681 if (!targets.shouldOpen) {682 return;683 }684685 const domain = getPageDomain();686 const firstResult = await fetchAutofillEntries(domain);687 if (passkeyToastActive) return;688689 690 691 692 if (firstResult.locked) {693 currentDismiss?.();694 currentAnchorField = field;695 currentDismiss = showAutofillSelector(696 field,697 [],698 () => {},699 { domain, locked: true, onDismiss: () => { lastDismissedField = field; startDismissLock(); } }700 );701 return;702 }703704 const entries = firstResult.entries;705 706 707 708 709 710 711 const loadSearchableEntries = async (): Promise<SelectorEntry[]> =>712 (await fetchAutofillEntries(domain, true, true, capability)).entries;713714 if (passkeyToastActive) return;715716 const pwField = fields.find((f) => f.type === "password");717 718 if (!explicitSummon && !pwField && !isEmailFirstForm(fields)) return;719 const usernameEl = targets.usernameEl;720 const passwordEl = targets.passwordEl;721 const emailEl = targets.emailEl;722723 await openAutofillSelector(724 field,725 usernameEl,726 passwordEl,727 entries,728 loadSearchableEntries,729 domain,730 autofillFieldType === "email" ? "email" : "username",731 emailEl,732 );733 } finally {734 pendingInteractions.delete(field);735 }736}737738async function onFocusIn(e: Event): Promise<void> {739 740 741 742 743 744 if (isSubmitButton(e.target)) return;745 await handleFieldInteraction();746}747748function reattachListeners(749 el: HTMLInputElement,750 detectedType?: "password" | "username" | "email",751 contextFields?: ReturnType<typeof detectLoginFields>752): void {753 if (processedInputs.has(el)) return;754 if (!resolveAutofillFieldType(el, detectedType)) return;755 756 const fields = contextFields ?? detectLoginFields();757 if (!isAnyCredentialForm(fields)) return;758759 const cleanup = injectFieldIcon(el, () => {760 currentDismiss?.();761 currentDismiss = null;762 lastDismissedField = null; 763 dismissLock = false; 764 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }765 clearFillStateForExplicitSummon(el);766 el.focus({ preventScroll: true });767 void handleFieldInteraction(el, true);768 });769 processedInputs.set(el, cleanup);770}771772const debouncedReattach = debounce(() => {773 const allFields = detectLoginFields();774 if (!isAnyCredentialForm(allFields)) return;775 const newFields = allFields.filter(776 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)777 );778 for (const f of newFields) {779 reattachListeners(f.el, f.type, allFields);780 }781 782 783 784}, 200);785786function setupMutationObserver(): void {787 const observer = new MutationObserver(debouncedReattach);788 observer.observe(document.body, {789 childList: true,790 subtree: true,791 attributes: true,792 attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],793 });794}795796export function initialScan(aggressionLevel = 0, isFinalPass = false) {797 const allFields = detectLoginFields();798 const hasKnownForm = isAnyCredentialForm(allFields);799800 if (aggressionLevel === 0 && !hasKnownForm) return;801802 const newFields = allFields.filter(803 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)804 );805806 for (const f of newFields) {807 reattachListeners(f.el, f.type, allFields);808 }809810 811 812 if (aggressionLevel >= 2 && isFinalPass && newFields.length === 0) {813 const pwInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');814 for (const el of pwInputs) {815 if (isVisible(el) && !processedInputs.has(el)) {816 const cleanup = injectFieldIcon(el, () => {817 currentDismiss?.();818 currentDismiss = null;819 lastDismissedField = null;820 dismissLock = false;821 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }822 clearFillStateForExplicitSummon(el);823 el.focus({ preventScroll: true });824 void handleFieldInteraction(el, true);825 });826 processedInputs.set(el, cleanup);827 }828 }829 }830}831832833834export const _testSeam = {835 getLastDismissedField(): HTMLInputElement | null { return lastDismissedField; },836 setLastDismissedField(f: HTMLInputElement | null): void { lastDismissedField = f; },837 isDismissLocked(): boolean { return dismissLock; },838 DISMISS_LOCK_MS,839} as const;840841842843844845const processedTOTPInputs = new WeakMap<HTMLInputElement, () => void>();846847function hasLoginOrSignupForm(): boolean {848 const fields = detectLoginFields();849 return isAnyCredentialForm(fields);850}851852function injectTOTPFieldIcon(853 field: HTMLInputElement,854 otpDigitBoxes?: HTMLInputElement[]855): void {856 if (processedTOTPInputs.has(field)) return;857 const cleanup = injectFieldIcon(field, () => {858 currentDismiss?.();859 currentDismiss = null;860 currentAnchorField = null;861 lastDismissedField = null;862 dismissLock = false;863 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }864 clearFillStateForExplicitSummon(field);865 field.focus({ preventScroll: true });866 void openTOTPSelector(field, otpDigitBoxes);867 });868 processedTOTPInputs.set(field, cleanup);869}870871872873874875function resolveTOTPIconTarget(876 field: HTMLInputElement877): { anchor: HTMLInputElement; group?: HTMLInputElement[] } {878 const group = findOtpSignalingGroup(field);879 if (group) return { anchor: group[0]!, group };880 return { anchor: field };881}882883export function scanTOTPFields(): void {884 if (hasLoginOrSignupForm()) return;885 const inputs = document.querySelectorAll<HTMLInputElement>("input");886 for (const input of inputs) {887 if (!isTOTPField(input)) continue;888 const { anchor, group } = resolveTOTPIconTarget(input);889 injectTOTPFieldIcon(anchor, group);890 }891}892893function setupTOTPAutofill(): void {894 setTimeout(scanTOTPFields, 0);895 setTimeout(scanTOTPFields, 500);896 setTimeout(scanTOTPFields, 1500);897898 const debouncedScan = debounce(scanTOTPFields, 200);899 new MutationObserver(debouncedScan).observe(document.body, {900 childList: true,901 subtree: true,902 attributes: true,903 attributeFilter: ["autocomplete", "name", "id", "placeholder", "hidden"],904 });905906 document.addEventListener("focusin", (e) => {907 const target = e.target;908 if (!(target instanceof HTMLInputElement)) return;909 if (hasLoginOrSignupForm()) return;910 if (!isTOTPField(target)) return;911 const { anchor, group } = resolveTOTPIconTarget(target);912 injectTOTPFieldIcon(anchor, group);913 }, true);914}915916document.addEventListener("focusin", onFocusIn);917918setupMutationObserver();919setupTOTPAutofill();920setupSaveDetection();921setupCardAutofill();922923924925926927928929930let lastObservedUrl = globalThis.location.href;931new MutationObserver(() => {932 const currentUrl = globalThis.location?.href;933 if (!currentUrl || currentUrl === lastObservedUrl) return;934 lastObservedUrl = currentUrl;935 pageFilledAt = null;936}).observe(document.body, { childList: true, subtree: false });937938939940(async () => {941 let aggressionLevel = 0;942 try {943 const resp = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });944 const s = (resp as { ok: boolean; data?: { manualSummonDomains?: Record<string, { summonCount: number }> } }).data;945 const domain = getPageDomain();946 const record = domain ? s?.manualSummonDomains?.[domain] : undefined;947 aggressionLevel = !record ? 0 : record.summonCount >= 2 ? 2 : 1;948 } catch { }949950 setTimeout(() => initialScan(aggressionLevel, false), 0);951 setTimeout(() => initialScan(aggressionLevel, false), 500);952 setTimeout(() => initialScan(aggressionLevel, true), 1500);953})();954955956957958959960const processedCardInputs = new WeakMap<HTMLInputElement, () => void>();961let currentCardDismiss: (() => void) | null = null;962let currentCardAnchor: HTMLInputElement | null = null;963let lastDismissedCardField: HTMLInputElement | null = null;964965async function fetchSavedCards(): Promise<CardSelectorEntry[]> {966 const resp = await chrome.runtime.sendMessage({967 type: "AUTOFILL_QUERY",968 payload: { domain: getPageDomain(), kind: "card" },969 }).catch(() => null);970 const r = resp as { ok: boolean; data?: CardSelectorEntry[] } | null;971 return r?.ok ? (r.data ?? []) : [];972}973974async function openCardSelector(anchor: HTMLInputElement): Promise<void> {975 if (!canOpen()) return;976 if (currentCardDismiss && currentCardAnchor === anchor) return;977 if (anchor === lastDismissedCardField) return;978 if (anchor !== lastDismissedCardField) lastDismissedCardField = null;979 if (wasAlreadyFilled(anchor)) return;980981 const cards = await fetchSavedCards();982 if (cards.length === 0) return; 983984 const cardForm = detectCardForm();985 if (!cardForm?.number) return;986987 currentCardDismiss?.();988 currentCardAnchor = anchor;989 currentCardDismiss = showCardSelector(990 anchor,991 cards,992 {993 domain: getPageDomain(),994 onFill: (entry) => {995 if (!cardForm) return;996 fillCardForm(cardForm, {997 cardNumber: entry.cardNumber ?? "",998 expMonth: entry.expMonth ?? "",999 expYear: entry.expYear ?? "",1000 cvc: entry.cvc ?? "",1001 cardholderName: entry.cardholderName ?? "",1002 });1003 1004 1005 1006 recordFillSuccess(cardForm.number, cardForm.cvc, cardForm.expiryCombo, cardForm.name);1007 currentCardDismiss = null;1008 currentCardAnchor = null;1009 },1010 onDismiss: () => {1011 lastDismissedCardField = anchor;1012 startDismissLock();1013 currentCardDismiss = null;1014 currentCardAnchor = null;1015 },1016 }1017 );1018}10191020function injectCardIcon(field: HTMLInputElement): void {1021 if (processedCardInputs.has(field)) return;1022 const cleanup = injectFieldIcon(field, () => {1023 currentCardDismiss?.();1024 currentCardDismiss = null;1025 lastDismissedCardField = null;1026 dismissLock = false;1027 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1028 clearFillStateForExplicitSummon(field);1029 field.focus({ preventScroll: true });1030 void openCardSelector(field);1031 });1032 processedCardInputs.set(field, cleanup);1033}10341035function setupCardAutofill(): void {1036 function scan() {1037 const cardForm = detectCardForm();1038 if (!cardForm?.number) return;1039 if (!isVisible(cardForm.number)) return;1040 injectCardIcon(cardForm.number);1041 }10421043 1044 1045 setTimeout(scan, 0);1046 setTimeout(scan, 500);1047 setTimeout(scan, 1500);10481049 1050 1051 1052 let pending = false;1053 new MutationObserver(() => {1054 if (pending) return;1055 pending = true;1056 setTimeout(() => { pending = false; scan(); }, 200);1057 }).observe(document.body, { childList: true, subtree: true });10581059 1060 1061 1062 document.addEventListener("focusin", (e) => {1063 const target = e.target;1064 if (!(target instanceof HTMLInputElement)) return;1065 const cardForm = detectCardForm();1066 if (cardForm?.number === target) {1067 injectCardIcon(target);1068 }1069 }, true);1070}10711072chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {1073 1074 if (sender.id !== chrome.runtime.id) return;1075 if (msg?.type === "GET_SITE_PASSWORD_POLICY") {1076 const policy = detectPasswordPolicy();1077 sendResponse({ ok: true, data: policy });1078 return true;1079 }1080 if (msg?.type === "PING") {1081 sendResponse({ ok: true });1082 return true;1083 }1084 if (msg?.type === "SUMMON_AUTOFILL") {1085 const source = (msg as { type: string; source?: string; capability?: string }).source;1086 const capability = (msg as { capability?: string }).capability;1087 const isShortcut = source === "shortcut";1088 const isPasskeyUnlock = source === "passkey-unlock";10891090 const activeInput = getDeepActiveInput();1091 const fields = detectLoginFields();1092 const hasForm = isAnyCredentialForm(fields);10931094 if (hasForm) {1095 1096 1097 const inForm = activeInput && fields.some(f => f.el === activeInput);1098 const target = inForm1099 ? activeInput1100 : (fields.find(f => f.type === "password")?.el1101 ?? fields.find(f => f.type === "username" || f.type === "email")?.el);1102 if (target) {1103 1104 1105 clearFillStateForExplicitSummon(target);1106 dismissLock = false;1107 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1108 lastDismissedField = null;1109 void handleFieldInteraction(target, true, capability);1110 }1111 } else if (isPasskeyUnlock) {1112 1113 1114 1115 const anchor = (document.activeElement instanceof HTMLElement && document.activeElement !== document.body)1116 ? document.activeElement as unknown as HTMLInputElement1117 : null;1118 if (anchor) {1119 currentDismiss?.();1120 currentAnchorField = null;1121 passkeySummonActive = true;1122 currentDismiss = showAutofillSelector(anchor, [], () => {}, {1123 domain: getPageDomain(),1124 locked: true,1125 onDismiss: () => { passkeySummonActive = false; },1126 });1127 }1128 } else if (isShortcut) {1129 1130 showSearchPalette(capability);1131 }11321133 1134 if (isShortcut) {1135 const domain = getPageDomain();1136 if (domain) {1137 chrome.runtime.sendMessage({ type: "RECORD_MANUAL_SUMMON", payload: { domain } }).catch(() => {});1138 }1139 }11401141 sendResponse({ ok: hasForm || isShortcut || isPasskeyUnlock });1142 return true;1143 }1144 if (msg?.type === "PASSKEY_PRO_GATE") {1145 1146 1147 showPasskeyProGate();1148 sendResponse({ ok: true });1149 return true;1150 }1151 if (msg?.type === "SHOW_PASSKEY_TOAST") {1152 1153 passkeyToastActive = true;1154 if (currentDismiss) {1155 currentDismiss();1156 currentDismiss = null;1157 currentAnchorField = null;1158 }1159 return false; 1160 }1161 if (msg?.type === "DISMISS_PASSKEY_TOAST") {1162 passkeyToastActive = false;1163 return false; 1164 }1165 if (msg?.type === "VAULT_UNLOCKED") {1166 if (passkeySummonActive) {1167 1168 1169 passkeySummonActive = false;1170 if (currentDismiss) {1171 currentDismiss();1172 currentDismiss = null;1173 currentAnchorField = null;1174 }1175 sendResponse({ ok: true });1176 return true;1177 }1178 1179 1180 const savedField = currentAnchorField;1181 if (currentDismiss) {1182 currentDismiss();1183 currentDismiss = null;1184 currentAnchorField = null;1185 }1186 void handleFieldInteraction(savedField ?? undefined);1187 sendResponse({ ok: true });1188 return true;1189 }1190 return true;1191});1192119311941195window.addEventListener("message", (e: MessageEvent) => {1196 1197 1198 if (e.source !== window) return;1199 const d = e.data as { source?: string; type?: string } | undefined;1200 if (d?.source === "peach-passkey-toast" && d?.type === "toast-closed") {1201 passkeyToastActive = false;1202 }1203});1204120512061207120812091210121112121213121412151216if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1217 let lastPasskeyOfferAt = 0;1218 const PASSKEY_OFFER_COOLDOWN_MS = 30_000;12191220 window.addEventListener("message", (event: MessageEvent) => {1221 if (event.source !== window) return;1222 const data = event.data as { source?: string; type?: string; rpId?: string; rpName?: string; userName?: string; userDisplayName?: string } | undefined;1223 if (!data || data.source !== "peach-passkey-firefox") return;1224 if (detectBrowser() !== "firefox") return;1225 if (data.type !== "create") return;12261227 1228 1229 const now = Date.now();1230 if (now - lastPasskeyOfferAt < PASSKEY_OFFER_COOLDOWN_MS) return;1231 lastPasskeyOfferAt = now;12321233 const domain = location.hostname.replace(/^www\./, "");1234 chrome.runtime.sendMessage({1235 type: "GET_PASSKEY_OFFER_DECLINED",1236 payload: { domain },1237 }).then((res: { ok: boolean; data?: boolean } | undefined) => {1238 if (res?.data === true) return;1239 const rpId = data.rpId ?? domain;1240 showPasskeyRegistrationOffer({1241 rpId,1242 rpName: data.rpName,1243 userName: data.userName,1244 userDisplayName: data.userDisplayName,1245 });1246 }).catch(() => {});1247 });1248}124912501251125212531254125512561257125812591260126112621263{1264 function checkPasskeyAvailability() {1265 const domain = location.hostname.replace(/^www\./, "");1266 if (!domain) return;1267 const sessionKey = `peach_state11_shown:${domain}`;1268 chrome.storage.session?.get(sessionKey).then((stored) => {1269 if (stored && (stored as Record<string, unknown>)[sessionKey]) return;1270 chrome.runtime.sendMessage({1271 type: "GET_PASSKEY_FOR_DOMAIN",1272 payload: { domain },1273 }).then((res: { ok: boolean; data?: { rpId: string; userName: string; browser?: "chrome" | "firefox" } | null } | undefined) => {1274 const data = res?.data;1275 if (!data) return;1276 const current = detectBrowser();1277 const isCrossBrowser =1278 data.browser && current !== "unknown" && data.browser !== current;1279 const valuableOnFirefox = current === "firefox";1280 if (!isCrossBrowser && !valuableOnFirefox) return;1281 chrome.storage.session?.set({ [sessionKey]: Date.now() }).catch(() => {});1282 showPasskeyAvailable({1283 rpId: data.rpId,1284 userName: data.userName,1285 fromBrowser: data.browser,1286 currentBrowser: current,1287 });1288 }).catch(() => {});1289 }).catch(() => {});1290 }12911292 1293 1294 1295 if (document.readyState === "loading") {1296 document.addEventListener("DOMContentLoaded", () => setTimeout(checkPasskeyAvailability, 800));1297 } else {1298 setTimeout(checkPasskeyAvailability, 800);1299 }1300}