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;749750 const field = e.target instanceof HTMLInputElement ? e.target : null;751 if (field) {752 const fields = detectLoginFields();753 if (isAnyCredentialForm(fields)) {754 const detected = fields.find((candidate) => candidate.el === field)?.type;755 reattachListeners(field, detected, fields);756 }757 }758759 await handleFieldInteraction();760}761762function reattachListeners(763 el: HTMLInputElement,764 detectedType?: "password" | "username" | "email",765 contextFields?: ReturnType<typeof detectLoginFields>766): void {767 if (processedInputs.has(el)) return;768 if (!resolveAutofillFieldType(el, detectedType)) return;769 770 const fields = contextFields ?? detectLoginFields();771 if (!isAnyCredentialForm(fields)) return;772773 const cleanup = injectFieldIcon(el, () => {774 currentDismiss?.();775 currentDismiss = null;776 lastDismissedField = null; 777 dismissLock = false; 778 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }779 clearFillStateForExplicitSummon(el);780 el.focus({ preventScroll: true });781 void handleFieldInteraction(el, true);782 });783 processedInputs.set(el, cleanup);784}785786const debouncedReattach = debounce(() => {787 const allFields = detectLoginFields();788 if (!isAnyCredentialForm(allFields)) return;789 const newFields = allFields.filter(790 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)791 );792 for (const f of newFields) {793 reattachListeners(f.el, f.type, allFields);794 }795 796 797 798}, 200);799800function setupMutationObserver(): void {801 const observer = new MutationObserver(debouncedReattach);802 observer.observe(document.body, {803 childList: true,804 subtree: true,805 attributes: true,806 attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],807 });808}809810export function initialScan(aggressionLevel = 0, isFinalPass = false) {811 const allFields = detectLoginFields();812 const hasKnownForm = isAnyCredentialForm(allFields);813814 if (aggressionLevel === 0 && !hasKnownForm) return;815816 const newFields = allFields.filter(817 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)818 );819820 for (const f of newFields) {821 reattachListeners(f.el, f.type, allFields);822 }823824 825 826 if (aggressionLevel >= 2 && isFinalPass && newFields.length === 0) {827 const pwInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');828 for (const el of pwInputs) {829 if (isVisible(el) && !processedInputs.has(el)) {830 const cleanup = injectFieldIcon(el, () => {831 currentDismiss?.();832 currentDismiss = null;833 lastDismissedField = null;834 dismissLock = false;835 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }836 clearFillStateForExplicitSummon(el);837 el.focus({ preventScroll: true });838 void handleFieldInteraction(el, true);839 });840 processedInputs.set(el, cleanup);841 }842 }843 }844}845846847848export const _testSeam = {849 getLastDismissedField(): HTMLInputElement | null { return lastDismissedField; },850 setLastDismissedField(f: HTMLInputElement | null): void { lastDismissedField = f; },851 isDismissLocked(): boolean { return dismissLock; },852 DISMISS_LOCK_MS,853} as const;854855856857858859const processedTOTPInputs = new WeakMap<HTMLInputElement, () => void>();860861function hasLoginOrSignupForm(): boolean {862 const fields = detectLoginFields();863 return isAnyCredentialForm(fields);864}865866function injectTOTPFieldIcon(867 field: HTMLInputElement,868 otpDigitBoxes?: HTMLInputElement[]869): void {870 if (processedTOTPInputs.has(field)) return;871 const cleanup = injectFieldIcon(field, () => {872 currentDismiss?.();873 currentDismiss = null;874 currentAnchorField = null;875 lastDismissedField = null;876 dismissLock = false;877 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }878 clearFillStateForExplicitSummon(field);879 field.focus({ preventScroll: true });880 void openTOTPSelector(field, otpDigitBoxes);881 });882 processedTOTPInputs.set(field, cleanup);883}884885886887888889function resolveTOTPIconTarget(890 field: HTMLInputElement891): { anchor: HTMLInputElement; group?: HTMLInputElement[] } {892 const group = findOtpSignalingGroup(field);893 if (group) return { anchor: group[0]!, group };894 return { anchor: field };895}896897export function scanTOTPFields(): void {898 if (hasLoginOrSignupForm()) return;899 const inputs = document.querySelectorAll<HTMLInputElement>("input");900 for (const input of inputs) {901 if (!isTOTPField(input)) continue;902 const { anchor, group } = resolveTOTPIconTarget(input);903 injectTOTPFieldIcon(anchor, group);904 }905}906907function setupTOTPAutofill(): void {908 setTimeout(scanTOTPFields, 0);909 setTimeout(scanTOTPFields, 500);910 setTimeout(scanTOTPFields, 1500);911912 const debouncedScan = debounce(scanTOTPFields, 200);913 new MutationObserver(debouncedScan).observe(document.body, {914 childList: true,915 subtree: true,916 attributes: true,917 attributeFilter: ["autocomplete", "name", "id", "placeholder", "hidden"],918 });919920 document.addEventListener("focusin", (e) => {921 const target = e.target;922 if (!(target instanceof HTMLInputElement)) return;923 if (hasLoginOrSignupForm()) return;924 if (!isTOTPField(target)) return;925 const { anchor, group } = resolveTOTPIconTarget(target);926 injectTOTPFieldIcon(anchor, group);927 }, true);928}929930document.addEventListener("focusin", onFocusIn);931932setupMutationObserver();933setupTOTPAutofill();934setupSaveDetection();935setupCardAutofill();936937938939940941942943944let lastObservedUrl = globalThis.location.href;945new MutationObserver(() => {946 const currentUrl = globalThis.location?.href;947 if (!currentUrl || currentUrl === lastObservedUrl) return;948 lastObservedUrl = currentUrl;949 pageFilledAt = null;950}).observe(document.body, { childList: true, subtree: false });951952953954(async () => {955 let aggressionLevel = 0;956 try {957 const resp = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });958 const s = (resp as { ok: boolean; data?: { manualSummonDomains?: Record<string, { summonCount: number }> } }).data;959 const domain = getPageDomain();960 const record = domain ? s?.manualSummonDomains?.[domain] : undefined;961 aggressionLevel = !record ? 0 : record.summonCount >= 2 ? 2 : 1;962 } catch { }963964 setTimeout(() => initialScan(aggressionLevel, false), 0);965 setTimeout(() => initialScan(aggressionLevel, false), 500);966 setTimeout(() => initialScan(aggressionLevel, true), 1500);967})();968969970971972973974const processedCardInputs = new WeakMap<HTMLInputElement, () => void>();975let currentCardDismiss: (() => void) | null = null;976let currentCardAnchor: HTMLInputElement | null = null;977let lastDismissedCardField: HTMLInputElement | null = null;978979async function fetchSavedCards(capability?: string): Promise<CardSelectorEntry[]> {980 const resp = await chrome.runtime.sendMessage({981 type: "AUTOFILL_QUERY",982 payload: { domain: getPageDomain(), kind: "card", capability },983 }).catch(() => null);984 const r = resp as { ok: boolean; data?: CardSelectorEntry[] } | null;985 return r?.ok ? (r.data ?? []) : [];986}987988async function openCardSelector(anchor: HTMLInputElement, capability?: string): Promise<void> {989 if (!canOpen()) return;990 if (currentCardDismiss && currentCardAnchor === anchor) return;991 if (anchor === lastDismissedCardField) return;992 if (anchor !== lastDismissedCardField) lastDismissedCardField = null;993 if (wasAlreadyFilled(anchor)) return;994995 const cards = await fetchSavedCards(capability);996 if (cards.length === 0) return; 997998 const cardForm = detectCardForm();999 if (!cardForm?.number) return;10001001 currentCardDismiss?.();1002 currentCardAnchor = anchor;1003 currentCardDismiss = showCardSelector(1004 anchor,1005 cards,1006 {1007 domain: getPageDomain(),1008 capability,1009 onFill: (entry) => {1010 if (!cardForm) return;1011 fillCardForm(cardForm, {1012 cardNumber: entry.cardNumber ?? "",1013 expMonth: entry.expMonth ?? "",1014 expYear: entry.expYear ?? "",1015 cvc: entry.cvc ?? "",1016 cardholderName: entry.cardholderName ?? "",1017 });1018 1019 1020 1021 recordFillSuccess(cardForm.number, cardForm.cvc, cardForm.expiryCombo, cardForm.name);1022 currentCardDismiss = null;1023 currentCardAnchor = null;1024 },1025 onDismiss: () => {1026 lastDismissedCardField = anchor;1027 startDismissLock();1028 currentCardDismiss = null;1029 currentCardAnchor = null;1030 },1031 }1032 );1033}10341035function injectCardIcon(field: HTMLInputElement): void {1036 if (processedCardInputs.has(field)) return;1037 const cleanup = injectFieldIcon(field, () => {1038 currentCardDismiss?.();1039 currentCardDismiss = null;1040 lastDismissedCardField = null;1041 dismissLock = false;1042 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1043 clearFillStateForExplicitSummon(field);1044 field.focus({ preventScroll: true });1045 void openCardSelector(field);1046 });1047 processedCardInputs.set(field, cleanup);1048}10491050function setupCardAutofill(): void {1051 function scan() {1052 const cardForm = detectCardForm();1053 if (!cardForm?.number) return;1054 if (!isVisible(cardForm.number)) return;1055 injectCardIcon(cardForm.number);1056 }10571058 1059 1060 setTimeout(scan, 0);1061 setTimeout(scan, 500);1062 setTimeout(scan, 1500);10631064 1065 1066 1067 let pending = false;1068 new MutationObserver(() => {1069 if (pending) return;1070 pending = true;1071 setTimeout(() => { pending = false; scan(); }, 200);1072 }).observe(document.body, { childList: true, subtree: true });10731074 1075 1076 1077 document.addEventListener("focusin", (e) => {1078 const target = e.target;1079 if (!(target instanceof HTMLInputElement)) return;1080 const cardForm = detectCardForm();1081 if (cardForm?.number === target) {1082 injectCardIcon(target);1083 }1084 }, true);1085}10861087chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {1088 1089 if (sender.id !== chrome.runtime.id) return;1090 if (msg?.type === "GET_SITE_PASSWORD_POLICY") {1091 const policy = detectPasswordPolicy();1092 sendResponse({ ok: true, data: policy });1093 return true;1094 }1095 if (msg?.type === "PING") {1096 sendResponse({ ok: true });1097 return true;1098 }1099 if (msg?.type === "SUMMON_AUTOFILL") {1100 const source = (msg as { type: string; source?: string; capability?: string }).source;1101 const capability = (msg as { capability?: string }).capability;1102 const isShortcut = source === "shortcut";1103 const isPasskeyUnlock = source === "passkey-unlock";11041105 const activeInput = getDeepActiveInput();1106 const fields = detectLoginFields();1107 const hasForm = isAnyCredentialForm(fields);1108 const cardForm = detectCardForm();11091110 if (hasForm) {1111 1112 1113 const inForm = activeInput && fields.some(f => f.el === activeInput);1114 const target = inForm1115 ? activeInput1116 : (fields.find(f => f.type === "password")?.el1117 ?? fields.find(f => f.type === "username" || f.type === "email")?.el);1118 if (target) {1119 1120 1121 clearFillStateForExplicitSummon(target);1122 dismissLock = false;1123 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1124 lastDismissedField = null;1125 void handleFieldInteraction(target, true, capability);1126 }1127 } else if (isShortcut && cardForm?.number) {1128 clearFillStateForExplicitSummon(cardForm.number);1129 dismissLock = false;1130 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1131 lastDismissedCardField = null;1132 void openCardSelector(cardForm.number, capability);1133 } else if (isPasskeyUnlock) {1134 1135 1136 1137 const anchor = (document.activeElement instanceof HTMLElement && document.activeElement !== document.body)1138 ? document.activeElement as unknown as HTMLInputElement1139 : null;1140 if (anchor) {1141 currentDismiss?.();1142 currentAnchorField = null;1143 passkeySummonActive = true;1144 currentDismiss = showAutofillSelector(anchor, [], () => {}, {1145 domain: getPageDomain(),1146 locked: true,1147 onDismiss: () => { passkeySummonActive = false; },1148 });1149 }1150 } else if (isShortcut) {1151 1152 showSearchPalette(capability);1153 }11541155 1156 if (isShortcut) {1157 const domain = getPageDomain();1158 if (domain) {1159 chrome.runtime.sendMessage({ type: "RECORD_MANUAL_SUMMON", payload: { domain } }).catch(() => {});1160 }1161 }11621163 sendResponse({ ok: hasForm || Boolean(cardForm?.number) || isShortcut || isPasskeyUnlock });1164 return true;1165 }1166 if (msg?.type === "PASSKEY_PRO_GATE") {1167 1168 1169 showPasskeyProGate();1170 sendResponse({ ok: true });1171 return true;1172 }1173 if (msg?.type === "SHOW_PASSKEY_TOAST") {1174 1175 passkeyToastActive = true;1176 passkeyToastToken = typeof (msg.payload as { toastToken?: unknown } | undefined)?.toastToken === "string"1177 ? (msg.payload as { toastToken: string }).toastToken1178 : null;1179 if (currentDismiss) {1180 currentDismiss();1181 currentDismiss = null;1182 currentAnchorField = null;1183 }1184 return false; 1185 }1186 if (msg?.type === "DISMISS_PASSKEY_TOAST") {1187 passkeyToastActive = false;1188 passkeyToastToken = null;1189 return false; 1190 }1191 if (msg?.type === "VAULT_UNLOCKED") {1192 if (passkeySummonActive) {1193 1194 1195 passkeySummonActive = false;1196 if (currentDismiss) {1197 currentDismiss();1198 currentDismiss = null;1199 currentAnchorField = null;1200 }1201 sendResponse({ ok: true });1202 return true;1203 }1204 1205 1206 const savedField = currentAnchorField;1207 if (currentDismiss) {1208 currentDismiss();1209 currentDismiss = null;1210 currentAnchorField = null;1211 }1212 void handleFieldInteraction(savedField ?? undefined);1213 sendResponse({ ok: true });1214 return true;1215 }1216 return true;1217});1218121912201221window.addEventListener("message", (e: MessageEvent) => {1222 1223 1224 if (e.source !== window) return;1225 const d = e.data as { source?: string; type?: string; token?: unknown } | undefined;1226 if (1227 d?.source === "peach-passkey-toast" &&1228 d?.type === "toast-closed" &&1229 d.token === passkeyToastToken1230 ) {1231 passkeyToastActive = false;1232 passkeyToastToken = null;1233 }1234});1235123612371238123912401241124212431244124512461247124812491250{1251 function checkPasskeyAvailability() {1252 const domain = location.hostname.replace(/^www\./, "");1253 if (!domain) return;1254 chrome.runtime.sendMessage({1255 type: "GET_PASSKEY_FOR_DOMAIN",1256 payload: { domain },1257 }).then((res: { ok: boolean; data?: { rpId: string; userName: string; browser?: "chrome" | "firefox"; bannerShown?: boolean } | null } | undefined) => {1258 const data = res?.data;1259 if (!data || data.bannerShown) return;1260 const current = detectBrowser();1261 const isCrossBrowser =1262 data.browser && current !== "unknown" && data.browser !== current;1263 const valuableOnFirefox = current === "firefox";1264 if (!isCrossBrowser && !valuableOnFirefox) return;1265 chrome.runtime.sendMessage({1266 type: "MARK_PASSKEY_BANNER_SHOWN",1267 payload: { domain },1268 }).catch(() => {});1269 showPasskeyAvailable({1270 rpId: data.rpId,1271 userName: data.userName,1272 fromBrowser: data.browser,1273 currentBrowser: current,1274 });1275 }).catch(() => {});1276 }12771278 1279 1280 1281 if (document.readyState === "loading") {1282 document.addEventListener("DOMContentLoaded", () => setTimeout(checkPasskeyAvailability, 800));1283 } else {1284 setTimeout(checkPasskeyAvailability, 800);1285 }1286}