1import {2 detectCardForm,3 detectLoginFields,4 findOTPDigitBoxGroup,5 getDeepActiveInput,6 getPageDomain,7 isAnyCredentialForm,8 isEmailFirstForm,9 isLoginForm,10 isSignupForm,11 isTOTPField,12 isVisible,13} from "./detect";14import {15 CardSelectorEntry,16 injectFieldIcon,17 SelectorEntry,18 showAutofillSelector,19 showCardSelector,20 showPasskeyAvailable,21 showPasskeyProGate,22 showPasskeyRegistrationOffer,23 showRememberSiteToast,24 showTotpCopyToast,25 showTOTPSelector,26} from "./inject";27import { detectBrowser } from "../shared/browser";28import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";29import { fillCardForm, fillFields, fillOTPDigitBoxes, fillTOTPCode, setNativeValue } from "./fill";30import { resolveAutofillInteractionTargets } from "./interaction";31import { setupSaveDetection } from "./save";32import { contentMLDetector } from "./content-ml-detector";33import { detectPasswordPolicy } from "./policy";34import type { FieldPrediction } from "../shared/ml";35import type { SavedIdentity, VaultEntry } from "../shared/types";363738394041const processedInputs = new WeakMap<HTMLInputElement, () => void>();42const AUTOFILL_FIELD_TYPES = new Set(["password", "username", "email"]);4344let currentDismiss: (() => void) | null = null;4546let currentAnchorField: HTMLInputElement | null = null;4748const pendingInteractions = new WeakSet<HTMLInputElement>();49505152let lastDismissedField: HTMLInputElement | null = null;53545556575859const DISMISS_LOCK_MS = 800;60let dismissLock = false;61let dismissLockTimer: ReturnType<typeof setTimeout> | null = null;62636465let passkeySummonActive = false;66676869let passkeyToastActive = false;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(): 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, explicitSummon: true } });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, true).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<HTMLInputElement | null> {317 if (!entry.username && !entry.password && !entry.email) return null;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 fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain, emailEl, emailValue);325 let filledTOTPField: HTMLInputElement | null = null;326327 if (entry.canary) {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) {355 const code = totpCode;356 const totpField = passwordEl ? findSameLoginTOTPField(passwordEl) : null;357 if (fillTOTPCode(totpField, code)) {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;386}387388async function fetchAutofillEntries(389 domain: string,390 includeAll = false,391 userGesture = false392): Promise<{ entries: SelectorEntry[]; locked: boolean }> {393 const response = await chrome.runtime.sendMessage({394 type: "AUTOFILL_QUERY",395 payload: { domain, includeAll, userGesture },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: string406): Promise<{ entries: SelectorEntry[]; locked: boolean }> {407 const response = await chrome.runtime.sendMessage({408 type: "AUTOFILL_QUERY",409 payload: { domain, kind: "totp" },410 });411 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };412 if (!res.ok && res.error === "vault_locked") {413 return { entries: [], locked: true };414 }415 return { entries: res.data ?? [], locked: false };416}417418419function resolveAutofillFieldType(420 el: HTMLInputElement,421 predictedType?: FieldPrediction["type"] | null422): "password" | "username" | "email" | null {423 if (424 predictedType === "password" ||425 predictedType === "username" ||426 predictedType === "email"427 ) {428 return predictedType;429 }430431 const detected = detectLoginFields().find((field) => field.el === el)?.type;432 if (detected && AUTOFILL_FIELD_TYPES.has(detected)) {433 return detected;434 }435436 const htmlType = el.type.toLowerCase();437 if (htmlType === "password") return "password";438 if (htmlType === "email") return "email";439 return null;440}441442async function openAutofillSelector(443 anchorField: HTMLInputElement,444 usernameEl: HTMLInputElement | null,445 passwordEl: HTMLInputElement | null,446 domainEntries: SelectorEntry[],447 loadSearchableEntries: () => Promise<SelectorEntry[]>,448 domain: string,449 identityKind: "email" | "username",450 emailEl?: HTMLInputElement | null451): Promise<void> {452 453 454 455 const isSignup = domainEntries.length === 0;456 let savedIdentities: SavedIdentity[] | undefined;457 let confirmEl: HTMLInputElement | null = null;458 if (isSignup) {459 try {460 const idResp = await chrome.runtime.sendMessage({ type: "GET_IDENTITIES" }).catch(() => null) as461 { ok: boolean; data?: SavedIdentity[] } | null;462 if (idResp?.ok && Array.isArray(idResp.data)) savedIdentities = idResp.data;463 } catch { }464465 const form = (passwordEl ?? usernameEl)?.closest("form") ?? document.body;466 const allPwInputs = Array.from(form.querySelectorAll<HTMLInputElement>("input[type='password']"));467 confirmEl = allPwInputs.find(468 (el) => el !== passwordEl && /confirm|repeat|verify/i.test(el.name + el.id + el.placeholder)469 ) ?? (allPwInputs.length >= 2 ? allPwInputs.find((el) => el !== passwordEl) ?? null : null);470 }471472 currentDismiss?.();473 currentAnchorField = anchorField;474 currentDismiss = showAutofillSelector(475 anchorField,476 domainEntries,477 async (id: string) => {478 currentDismiss = null;479 currentAnchorField = null;480 const entryResponse = await chrome.runtime.sendMessage({481 type: "GET_ENTRY_FOR_FILL",482 payload: { id },483 });484 const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;485 if (entry) {486 const filledTOTPField = await handleAutofill(anchorField, usernameEl, passwordEl, entry, emailEl);487 recordFillSuccess(anchorField, usernameEl, passwordEl, filledTOTPField);488 489 490 491 const pageDomain = location.hostname.replace(/^www\./, "");492 const entryDomains = new Set<string>();493 for (const u of [entry.url, ...(entry.additionalUrls ?? [])]) {494 try {495 if (u) entryDomains.add(new URL(u).hostname.replace(/^www\./, ""));496 } catch { }497 }498 if (pageDomain && entryDomains.size > 0 && !entryDomains.has(pageDomain)) {499 showRememberSiteToast(pageDomain, entry.id);500 }501 }502 },503 {504 domain,505 loadSearchableEntries,506 isSignup,507 identityKind,508 hasEmailField: !!emailEl,509 savedIdentities,510 onFillFields: (username, password, email) => {511 const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;512 fillFields(usernameEl, passwordEl, username, password, form, getPageDomain(), emailEl, email);513 if (password && confirmEl) setNativeValue(confirmEl, password);514 recordFillSuccess(anchorField, usernameEl, passwordEl);515 },516 onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },517 }518 );519}520521async function openTOTPSelector(anchorField: HTMLInputElement, otpDigitBoxes?: HTMLInputElement[]): Promise<void> {522 if (!canOpen()) return;523 if (currentDismiss && currentAnchorField === anchorField) return;524 if (anchorField !== lastDismissedField) lastDismissedField = null;525 if (anchorField === lastDismissedField) return;526 if (wasAlreadyFilled(anchorField)) return;527528 const domain = getPageDomain();529 const result = await fetchTOTPEntries(domain);530531 if (result.locked) {532 currentDismiss?.();533 currentAnchorField = anchorField;534 currentDismiss = showAutofillSelector(535 anchorField,536 [],537 () => {},538 {539 domain,540 locked: true,541 onUnlocked: () => void openTOTPSelector(anchorField),542 onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },543 }544 );545 return;546 }547548 if (result.entries.length === 0) {549 lastDismissedField = anchorField;550 startDismissLock();551 showToast("No 2FA entries for this site — add a code in Peach first");552 return;553 }554555 currentDismiss?.();556 currentAnchorField = anchorField;557 currentDismiss = showTOTPSelector(558 anchorField,559 result.entries,560 async (id: string) => {561 try {562 const entryResponse = await chrome.runtime.sendMessage({563 type: "GET_ENTRY_FOR_FILL",564 payload: { id },565 }).catch(() => null) as { ok: boolean; data?: VaultEntry } | null;566 const entry = entryResponse?.ok ? entryResponse.data : undefined;567 const code = (entry as { totpCode?: string } | undefined)?.totpCode;568 if (!code) return;569 if (otpDigitBoxes && otpDigitBoxes.length > 1) {570 fillOTPDigitBoxes(otpDigitBoxes, code);571 } else {572 setNativeValue(anchorField, code);573 }574 recordFillSuccess(anchorField);575 showToast("2FA code filled");576 } finally {577 currentDismiss = null;578 currentAnchorField = null;579 }580 },581 {582 domain,583 onDismiss: () => {584 lastDismissedField = anchorField;585 startDismissLock();586 currentDismiss = null;587 currentAnchorField = null;588 },589 }590 );591}592593594export async function handleFieldInteraction(595 anchorField?: HTMLInputElement | null,596 explicitSummon = false597): 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 const otpGroup = findOTPDigitBoxGroup(field);634 if (otpGroup) {635 await openTOTPSelector(otpGroup[0]!, otpGroup);636 return;637 }638639 if (isTOTPField(field)) {640 const formFields = detectLoginFields();641 if (!isLoginForm(formFields) && !isSignupForm(formFields)) {642 await openTOTPSelector(field);643 return;644 }645 }646647 const fieldType = field.type.toLowerCase();648649 const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {650 return preds[0] ?? null;651 });652653 const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {654 const timeout = setTimeout(() => resolve(null), 150);655 mlPromise.then((pred) => {656 clearTimeout(timeout);657 resolve(pred);658 });659 });660661 if (passkeyToastActive) return;662663 if (!explicitSummon && fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {664 return;665 }666667 const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);668 if (autofillFieldType) {669 reattachListeners(field, autofillFieldType);670 }671672 let fields = detectLoginFields();673 const targets = resolveAutofillInteractionTargets(field, fields, explicitSummon);674 if (!targets.shouldOpen) {675 return;676 }677678 const domain = getPageDomain();679 const firstResult = await fetchAutofillEntries(domain);680 if (passkeyToastActive) return;681682 683 684 685 if (firstResult.locked) {686 currentDismiss?.();687 currentAnchorField = field;688 currentDismiss = showAutofillSelector(689 field,690 [],691 () => {},692 { domain, locked: true, onDismiss: () => { lastDismissedField = field; startDismissLock(); } }693 );694 return;695 }696697 const entries = firstResult.entries;698 699 700 701 702 703 704 const loadSearchableEntries = async (): Promise<SelectorEntry[]> =>705 (await fetchAutofillEntries(domain, true, true)).entries;706707 if (passkeyToastActive) return;708709 const pwField = fields.find((f) => f.type === "password");710 711 if (!explicitSummon && !pwField && !isEmailFirstForm(fields)) return;712 const usernameEl = targets.usernameEl;713 const passwordEl = targets.passwordEl;714 const emailEl = targets.emailEl;715716 await openAutofillSelector(717 field,718 usernameEl,719 passwordEl,720 entries,721 loadSearchableEntries,722 domain,723 autofillFieldType === "email" ? "email" : "username",724 emailEl,725 );726 } finally {727 pendingInteractions.delete(field);728 }729}730731async function onFocusIn(e: Event): Promise<void> {732 733 734 735 736 737 if (isSubmitButton(e.target)) return;738 await handleFieldInteraction();739}740741function reattachListeners(742 el: HTMLInputElement,743 detectedType?: "password" | "username" | "email",744 contextFields?: ReturnType<typeof detectLoginFields>745): void {746 if (processedInputs.has(el)) return;747 if (!resolveAutofillFieldType(el, detectedType)) return;748 749 const fields = contextFields ?? detectLoginFields();750 if (!isAnyCredentialForm(fields)) return;751752 const cleanup = injectFieldIcon(el, () => {753 currentDismiss?.();754 currentDismiss = null;755 lastDismissedField = null; 756 dismissLock = false; 757 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }758 clearFillStateForExplicitSummon(el);759 el.focus({ preventScroll: true });760 void handleFieldInteraction(el, true);761 });762 processedInputs.set(el, cleanup);763}764765const debouncedReattach = debounce(() => {766 const allFields = detectLoginFields();767 if (!isAnyCredentialForm(allFields)) return;768 const newFields = allFields.filter(769 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)770 );771 for (const f of newFields) {772 reattachListeners(f.el, f.type, allFields);773 }774 775 776 777}, 200);778779function setupMutationObserver(): void {780 const observer = new MutationObserver(debouncedReattach);781 observer.observe(document.body, {782 childList: true,783 subtree: true,784 attributes: true,785 attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],786 });787}788789export function initialScan(aggressionLevel = 0, isFinalPass = false) {790 const allFields = detectLoginFields();791 const hasKnownForm = isAnyCredentialForm(allFields);792793 if (aggressionLevel === 0 && !hasKnownForm) return;794795 const newFields = allFields.filter(796 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)797 );798799 for (const f of newFields) {800 reattachListeners(f.el, f.type, allFields);801 }802803 804 805 if (aggressionLevel >= 2 && isFinalPass && newFields.length === 0) {806 const pwInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');807 for (const el of pwInputs) {808 if (isVisible(el) && !processedInputs.has(el)) {809 const cleanup = injectFieldIcon(el, () => {810 currentDismiss?.();811 currentDismiss = null;812 lastDismissedField = null;813 dismissLock = false;814 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }815 clearFillStateForExplicitSummon(el);816 el.focus({ preventScroll: true });817 void handleFieldInteraction(el, true);818 });819 processedInputs.set(el, cleanup);820 }821 }822 }823}824825826827export const _testSeam = {828 getLastDismissedField(): HTMLInputElement | null { return lastDismissedField; },829 setLastDismissedField(f: HTMLInputElement | null): void { lastDismissedField = f; },830 isDismissLocked(): boolean { return dismissLock; },831 DISMISS_LOCK_MS,832} as const;833834835836837838const processedTOTPInputs = new WeakMap<HTMLInputElement, () => void>();839840function hasLoginOrSignupForm(): boolean {841 const fields = detectLoginFields();842 return isAnyCredentialForm(fields);843}844845function injectTOTPFieldIcon(field: HTMLInputElement): void {846 if (processedTOTPInputs.has(field)) return;847 const cleanup = injectFieldIcon(field, () => {848 currentDismiss?.();849 currentDismiss = null;850 currentAnchorField = null;851 lastDismissedField = null;852 dismissLock = false;853 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }854 clearFillStateForExplicitSummon(field);855 field.focus({ preventScroll: true });856 void openTOTPSelector(field);857 });858 processedTOTPInputs.set(field, cleanup);859}860861function scanTOTPFields(): void {862 if (hasLoginOrSignupForm()) return;863 const inputs = document.querySelectorAll<HTMLInputElement>("input");864 for (const input of inputs) {865 if (!isTOTPField(input)) continue;866 injectTOTPFieldIcon(input);867 }868}869870function setupTOTPAutofill(): void {871 setTimeout(scanTOTPFields, 0);872 setTimeout(scanTOTPFields, 500);873 setTimeout(scanTOTPFields, 1500);874875 const debouncedScan = debounce(scanTOTPFields, 200);876 new MutationObserver(debouncedScan).observe(document.body, {877 childList: true,878 subtree: true,879 attributes: true,880 attributeFilter: ["autocomplete", "name", "id", "placeholder", "hidden"],881 });882883 document.addEventListener("focusin", (e) => {884 const target = e.target;885 if (!(target instanceof HTMLInputElement)) return;886 if (hasLoginOrSignupForm()) return;887 if (!isTOTPField(target)) return;888 injectTOTPFieldIcon(target);889 }, true);890}891892document.addEventListener("focusin", onFocusIn);893894setupMutationObserver();895setupTOTPAutofill();896setupSaveDetection();897setupCardAutofill();898899900901902903904905906let lastObservedUrl = location.href;907new MutationObserver(() => {908 if (location.href !== lastObservedUrl) {909 lastObservedUrl = location.href;910 pageFilledAt = null;911 }912}).observe(document.body, { childList: true, subtree: false });913914915916(async () => {917 let aggressionLevel = 0;918 try {919 const resp = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });920 const s = (resp as { ok: boolean; data?: { manualSummonDomains?: Record<string, { summonCount: number }> } }).data;921 const domain = getPageDomain();922 const record = domain ? s?.manualSummonDomains?.[domain] : undefined;923 aggressionLevel = !record ? 0 : record.summonCount >= 2 ? 2 : 1;924 } catch { }925926 setTimeout(() => initialScan(aggressionLevel, false), 0);927 setTimeout(() => initialScan(aggressionLevel, false), 500);928 setTimeout(() => initialScan(aggressionLevel, true), 1500);929})();930931932933934935936const processedCardInputs = new WeakMap<HTMLInputElement, () => void>();937let currentCardDismiss: (() => void) | null = null;938let currentCardAnchor: HTMLInputElement | null = null;939let lastDismissedCardField: HTMLInputElement | null = null;940941async function fetchSavedCards(): Promise<CardSelectorEntry[]> {942 const resp = await chrome.runtime.sendMessage({943 type: "AUTOFILL_QUERY",944 payload: { domain: getPageDomain(), kind: "card" },945 }).catch(() => null);946 const r = resp as { ok: boolean; data?: CardSelectorEntry[] } | null;947 return r?.ok ? (r.data ?? []) : [];948}949950async function openCardSelector(anchor: HTMLInputElement): Promise<void> {951 if (!canOpen()) return;952 if (currentCardDismiss && currentCardAnchor === anchor) return;953 if (anchor === lastDismissedCardField) return;954 if (anchor !== lastDismissedCardField) lastDismissedCardField = null;955 if (wasAlreadyFilled(anchor)) return;956957 const cards = await fetchSavedCards();958 if (cards.length === 0) return; 959960 const cardForm = detectCardForm();961 if (!cardForm?.number) return;962963 currentCardDismiss?.();964 currentCardAnchor = anchor;965 currentCardDismiss = showCardSelector(966 anchor,967 cards,968 {969 domain: getPageDomain(),970 onFill: (entry) => {971 if (!cardForm) return;972 fillCardForm(cardForm, {973 cardNumber: entry.cardNumber ?? "",974 expMonth: entry.expMonth ?? "",975 expYear: entry.expYear ?? "",976 cvc: entry.cvc ?? "",977 cardholderName: entry.cardholderName ?? "",978 });979 980 981 982 recordFillSuccess(cardForm.number, cardForm.cvc, cardForm.expiryCombo, cardForm.name);983 currentCardDismiss = null;984 currentCardAnchor = null;985 },986 onDismiss: () => {987 lastDismissedCardField = anchor;988 startDismissLock();989 currentCardDismiss = null;990 currentCardAnchor = null;991 },992 }993 );994}995996function injectCardIcon(field: HTMLInputElement): void {997 if (processedCardInputs.has(field)) return;998 const cleanup = injectFieldIcon(field, () => {999 currentCardDismiss?.();1000 currentCardDismiss = null;1001 lastDismissedCardField = null;1002 dismissLock = false;1003 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1004 clearFillStateForExplicitSummon(field);1005 field.focus({ preventScroll: true });1006 void openCardSelector(field);1007 });1008 processedCardInputs.set(field, cleanup);1009}10101011function setupCardAutofill(): void {1012 function scan() {1013 const cardForm = detectCardForm();1014 if (!cardForm?.number) return;1015 if (!isVisible(cardForm.number)) return;1016 injectCardIcon(cardForm.number);1017 }10181019 1020 1021 setTimeout(scan, 0);1022 setTimeout(scan, 500);1023 setTimeout(scan, 1500);10241025 1026 1027 1028 let pending = false;1029 new MutationObserver(() => {1030 if (pending) return;1031 pending = true;1032 setTimeout(() => { pending = false; scan(); }, 200);1033 }).observe(document.body, { childList: true, subtree: true });10341035 1036 1037 1038 document.addEventListener("focusin", (e) => {1039 const target = e.target;1040 if (!(target instanceof HTMLInputElement)) return;1041 const cardForm = detectCardForm();1042 if (cardForm?.number === target) {1043 injectCardIcon(target);1044 }1045 }, true);1046}10471048chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {1049 1050 if (sender.id !== chrome.runtime.id) return;1051 if (msg?.type === "GET_SITE_PASSWORD_POLICY") {1052 const policy = detectPasswordPolicy();1053 sendResponse({ ok: true, data: policy });1054 return true;1055 }1056 if (msg?.type === "PING") {1057 sendResponse({ ok: true });1058 return true;1059 }1060 if (msg?.type === "SUMMON_AUTOFILL") {1061 const source = (msg as { type: string; source?: string }).source;1062 const isShortcut = source === "shortcut";1063 const isPasskeyUnlock = source === "passkey-unlock";10641065 const activeInput = getDeepActiveInput();1066 const fields = detectLoginFields();1067 const hasForm = isAnyCredentialForm(fields);10681069 if (hasForm) {1070 1071 1072 const inForm = activeInput && fields.some(f => f.el === activeInput);1073 const target = inForm1074 ? activeInput1075 : (fields.find(f => f.type === "password")?.el1076 ?? fields.find(f => f.type === "username" || f.type === "email")?.el);1077 if (target) {1078 1079 1080 clearFillStateForExplicitSummon(target);1081 dismissLock = false;1082 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1083 lastDismissedField = null;1084 void handleFieldInteraction(target);1085 }1086 } else if (isPasskeyUnlock) {1087 1088 1089 1090 const anchor = (document.activeElement instanceof HTMLElement && document.activeElement !== document.body)1091 ? document.activeElement as unknown as HTMLInputElement1092 : null;1093 if (anchor) {1094 currentDismiss?.();1095 currentAnchorField = null;1096 passkeySummonActive = true;1097 currentDismiss = showAutofillSelector(anchor, [], () => {}, {1098 domain: getPageDomain(),1099 locked: true,1100 onDismiss: () => { passkeySummonActive = false; },1101 });1102 }1103 } else if (isShortcut) {1104 1105 showSearchPalette();1106 }11071108 1109 if (isShortcut) {1110 const domain = getPageDomain();1111 if (domain) {1112 chrome.runtime.sendMessage({ type: "RECORD_MANUAL_SUMMON", payload: { domain } }).catch(() => {});1113 }1114 }11151116 sendResponse({ ok: hasForm || isShortcut || isPasskeyUnlock });1117 return true;1118 }1119 if (msg?.type === "PASSKEY_PRO_GATE") {1120 1121 1122 showPasskeyProGate();1123 sendResponse({ ok: true });1124 return true;1125 }1126 if (msg?.type === "SHOW_PASSKEY_TOAST") {1127 1128 passkeyToastActive = true;1129 if (currentDismiss) {1130 currentDismiss();1131 currentDismiss = null;1132 currentAnchorField = null;1133 }1134 return false; 1135 }1136 if (msg?.type === "DISMISS_PASSKEY_TOAST") {1137 passkeyToastActive = false;1138 return false; 1139 }1140 if (msg?.type === "VAULT_UNLOCKED") {1141 if (passkeySummonActive) {1142 1143 1144 passkeySummonActive = false;1145 if (currentDismiss) {1146 currentDismiss();1147 currentDismiss = null;1148 currentAnchorField = null;1149 }1150 sendResponse({ ok: true });1151 return true;1152 }1153 1154 1155 const savedField = currentAnchorField;1156 if (currentDismiss) {1157 currentDismiss();1158 currentDismiss = null;1159 currentAnchorField = null;1160 }1161 void handleFieldInteraction(savedField ?? undefined);1162 sendResponse({ ok: true });1163 return true;1164 }1165 return true;1166});1167116811691170window.addEventListener("message", (e: MessageEvent) => {1171 1172 1173 if (e.source !== window) return;1174 const d = e.data as { source?: string; type?: string } | undefined;1175 if (d?.source === "peach-passkey-toast" && d?.type === "toast-closed") {1176 passkeyToastActive = false;1177 }1178});1179118011811182118311841185118611871188118911901191if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1192 let lastPasskeyOfferAt = 0;1193 const PASSKEY_OFFER_COOLDOWN_MS = 30_000;11941195 window.addEventListener("message", (event: MessageEvent) => {1196 if (event.source !== window) return;1197 const data = event.data as { source?: string; type?: string; rpId?: string; rpName?: string; userName?: string; userDisplayName?: string } | undefined;1198 if (!data || data.source !== "peach-passkey-firefox") return;1199 if (detectBrowser() !== "firefox") return;1200 if (data.type !== "create") return;12011202 1203 1204 const now = Date.now();1205 if (now - lastPasskeyOfferAt < PASSKEY_OFFER_COOLDOWN_MS) return;1206 lastPasskeyOfferAt = now;12071208 const domain = location.hostname.replace(/^www\./, "");1209 chrome.runtime.sendMessage({1210 type: "GET_PASSKEY_OFFER_DECLINED",1211 payload: { domain },1212 }).then((res: { ok: boolean; data?: boolean } | undefined) => {1213 if (res?.data === true) return;1214 const rpId = data.rpId ?? domain;1215 showPasskeyRegistrationOffer({1216 rpId,1217 rpName: data.rpName,1218 userName: data.userName,1219 userDisplayName: data.userDisplayName,1220 });1221 }).catch(() => {});1222 });1223}122412251226122712281229123012311232123312341235123612371238{1239 function checkPasskeyAvailability() {1240 const domain = location.hostname.replace(/^www\./, "");1241 if (!domain) return;1242 const sessionKey = `peach_state11_shown:${domain}`;1243 chrome.storage.session?.get(sessionKey).then((stored) => {1244 if (stored && (stored as Record<string, unknown>)[sessionKey]) return;1245 chrome.runtime.sendMessage({1246 type: "GET_PASSKEY_FOR_DOMAIN",1247 payload: { domain },1248 }).then((res: { ok: boolean; data?: { rpId: string; userName: string; browser?: "chrome" | "firefox" } | null } | undefined) => {1249 const data = res?.data;1250 if (!data) return;1251 const current = detectBrowser();1252 const isCrossBrowser =1253 data.browser && current !== "unknown" && data.browser !== current;1254 const valuableOnFirefox = current === "firefox";1255 if (!isCrossBrowser && !valuableOnFirefox) return;1256 chrome.storage.session?.set({ [sessionKey]: Date.now() }).catch(() => {});1257 showPasskeyAvailable({1258 rpId: data.rpId,1259 userName: data.userName,1260 fromBrowser: data.browser,1261 currentBrowser: current,1262 });1263 }).catch(() => {});1264 }).catch(() => {});1265 }12661267 1268 1269 1270 if (document.readyState === "loading") {1271 document.addEventListener("DOMContentLoaded", () => setTimeout(checkPasskeyAvailability, 800));1272 } else {1273 setTimeout(checkPasskeyAvailability, 800);1274 }1275}