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 showTOTPSelector,25} from "./inject";26import { detectBrowser } from "../shared/browser";27import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";28import { 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 { SavedIdentity, 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;69function startDismissLock(): void {70 dismissLock = true;71 if (dismissLockTimer) clearTimeout(dismissLockTimer);72 dismissLockTimer = setTimeout(() => { dismissLock = false; }, DISMISS_LOCK_MS);73}74function canOpen(): boolean { return !dismissLock; }7576777879808182838485868788const filledForms = new WeakSet<HTMLFormElement>();89let pageFilledAt: number | null = null;90const PAGE_FILL_GRACE_MS = 3000;9192function recordFillSuccess(...fields: (HTMLInputElement | null | undefined)[]): void {93 for (const f of fields) {94 if (!f) continue;95 const form = f.closest("form");96 if (form) filledForms.add(form);97 }98 pageFilledAt = Date.now();99}100101function wasAlreadyFilled(field: HTMLInputElement): boolean {102 const form = field.closest("form");103 if (form && filledForms.has(form)) return true;104 if (pageFilledAt !== null && Date.now() - pageFilledAt < PAGE_FILL_GRACE_MS) return true;105 return false;106}107108function clearFillStateForExplicitSummon(field: HTMLInputElement): void {109 const form = field.closest("form");110 if (form) filledForms.delete(form);111 pageFilledAt = null;112}113114115116117118function isSubmitButton(el: EventTarget | null): boolean {119 if (!(el instanceof HTMLElement)) return false;120 if (el instanceof HTMLButtonElement && el.type !== "button") return true;121 if (el instanceof HTMLInputElement && el.type === "submit") return true;122 if (el.getAttribute("type") === "submit") return true;123 const text = (el.textContent?.trim() ?? el.getAttribute("value") ?? "").toLowerCase();124 if (!text || text.length > 24) return false;125 return /^(submit|sign[ -]?in|log[ -]?in|continue|next|go)$/i.test(text);126}127128function debounce<T extends (...args: unknown[]) => void>(129 fn: T,130 ms: number131): (...args: Parameters<T>) => void {132 let timer: ReturnType<typeof setTimeout>;133 return (...args: Parameters<T>) => {134 clearTimeout(timer);135 timer = setTimeout(() => fn(...args), ms);136 };137}138139function showToast(message: string): void {140 const toast = document.createElement("div");141 toast.style.cssText = `142 position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);143 background: #131109; color: #F0E8D8; padding: 8px 16px;144 border-radius: 8px; font-family: 'IBM Plex Mono', monospace;145 font-size: 11px; z-index: 2147483647; opacity: 0;146 transition: opacity 200ms ease;147 border: 1px solid rgba(240,232,216,0.07);148 pointer-events: none;149 `;150 toast.textContent = message;151 document.body.appendChild(toast);152 requestAnimationFrame(() => { toast.style.opacity = "1"; });153 setTimeout(() => {154 toast.style.opacity = "0";155 setTimeout(() => toast.remove(), 200);156 }, 2000);157}158159function showSearchPalette(): void {160 const host = document.createElement("div");161 host.style.cssText = "position:fixed;top:80px;left:50%;transform:translateX(-50%);z-index:2147483647;width:340px;";162 document.body.appendChild(host);163 const shadow = host.attachShadow({ mode: "closed" });164165 const style = document.createElement("style");166 style.textContent = `167 .p{background:#131109;border:1px solid rgba(240,232,216,.12);border-radius:12px;168 font-family:'IBM Plex Mono',monospace;font-size:12px;color:#F0E8D8;169 box-shadow:0 8px 24px rgba(0,0,0,.5);overflow:hidden}170 .h{padding:10px 12px 8px;border-bottom:1px solid rgba(240,232,216,.08);171 font-size:11px;color:rgba(240,232,216,.5);display:flex;justify-content:space-between;align-items:center}172 .x{background:none;border:none;color:rgba(240,232,216,.4);cursor:pointer;font-size:14px;padding:0;line-height:1}173 .s{padding:8px 12px}174 .s input{width:100%;box-sizing:border-box;background:rgba(240,232,216,.06);175 border:1px solid rgba(240,232,216,.12);border-radius:6px;color:#F0E8D8;176 padding:6px 8px;font-family:inherit;font-size:12px;outline:none}177 .l{max-height:220px;overflow-y:auto}178 .e{padding:8px 12px;cursor:pointer;display:flex;flex-direction:column;gap:2px}179 .e:hover{background:rgba(240,232,216,.06)}180 .et{font-size:12px}181 .eu{font-size:10px;color:rgba(240,232,216,.45)}182 .em{padding:12px;color:rgba(240,232,216,.4);text-align:center}183 `;184185 let allEntries: SelectorEntry[] = [];186 let query = "";187188 const panel = document.createElement("div");189 panel.className = "p";190191 const hdr = document.createElement("div");192 hdr.className = "h";193 const hdrText = document.createElement("span");194 hdrText.textContent = "No form detected — search your vault";195 const closeBtn = document.createElement("button");196 closeBtn.className = "x";197 closeBtn.textContent = "✕";198 hdr.appendChild(hdrText);199 hdr.appendChild(closeBtn);200201 const searchWrap = document.createElement("div");202 searchWrap.className = "s";203 const searchInput = document.createElement("input");204 searchInput.placeholder = "search...";205 searchWrap.appendChild(searchInput);206207 const list = document.createElement("div");208 list.className = "l";209210 panel.appendChild(hdr);211 panel.appendChild(searchWrap);212 panel.appendChild(list);213 shadow.appendChild(style);214 shadow.appendChild(panel);215216 function close() { host.remove(); }217218 closeBtn.onclick = close;219220 function onKeyDown(e: KeyboardEvent) {221 if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKeyDown, true); }222 }223 document.addEventListener("keydown", onKeyDown, true);224225 function render() {226 const q = query.toLowerCase();227 const filtered = q228 ? allEntries.filter(e => e.title.toLowerCase().includes(q) || (e.username ?? "").toLowerCase().includes(q))229 : allEntries;230 list.innerHTML = "";231 if (filtered.length === 0) {232 const em = document.createElement("div");233 em.className = "em";234 em.textContent = query ? "no matches" : "vault is empty";235 list.appendChild(em);236 return;237 }238 for (const entry of filtered.slice(0, 20)) {239 const row = document.createElement("div");240 row.className = "e";241 const title = document.createElement("div");242 title.className = "et";243 title.textContent = entry.title;244 row.appendChild(title);245 if (entry.username) {246 const user = document.createElement("div");247 user.className = "eu";248 user.textContent = entry.username;249 row.appendChild(user);250 }251 row.onclick = async () => {252 const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } });253 const r = resp as { ok: boolean; data?: { password?: string } };254 if (r.ok && r.data?.password) {255 let alreadyWritten = false;256 try {257 await navigator.clipboard.writeText(r.data.password);258 alreadyWritten = true;259 } catch (err) {260 console.warn("[Peach] clipboard write denied:", err);261 }262 const copyResp = await chrome.runtime.sendMessage({263 type: "COPY",264 payload: { text: r.data.password, label: "Password", alreadyWritten },265 }) as { ok?: boolean } | undefined;266 if (alreadyWritten || copyResp?.ok) {267 showToast("Password copied");268 } else {269 showToast("Could not copy password — clipboard blocked");270 }271 } else {272 showToast("Could not copy password");273 }274 close();275 document.removeEventListener("keydown", onKeyDown, true);276 };277 list.appendChild(row);278 }279 }280281 searchInput.oninput = () => { query = searchInput.value; render(); };282283 fetchAutofillEntries(getPageDomain(), true).then(result => {284 if (result.locked) { showToast("Vault is locked — open Peach to unlock"); close(); return; }285 allEntries = result.entries;286 render();287 searchInput.focus();288 });289}290291function findTOTPField(root: Document | Element = document): HTMLInputElement | null {292 const inputs = root.querySelectorAll<HTMLInputElement>("input");293 for (const input of inputs) {294 if (isTOTPField(input)) return input;295 }296 return null;297}298299function findSameLoginTOTPField(passwordEl: HTMLInputElement): HTMLInputElement | null {300 const form = passwordEl.closest("form");301 return findTOTPField(form ?? document);302}303304305async function handleAutofill(306 _field: HTMLInputElement,307 usernameEl: HTMLInputElement | null,308 passwordEl: HTMLInputElement | null,309 entry: VaultEntry310): Promise<HTMLInputElement | null> {311 if (!entry.username && !entry.password) return null;312 const pageDomain = getPageDomain();313 const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;314 fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain);315 let filledTOTPField: HTMLInputElement | null = null;316317 if (entry.canary) {318 chrome.runtime.sendMessage({319 type: "FIRE_CANARY_PING",320 payload: {321 webhookUrl: entry.canary.webhookUrl,322 entryTitle: entry.title,323 trigger: "autofilled",324 },325 }).catch(() => {});326 chrome.runtime.sendMessage({327 type: "UPDATE_ENTRY",328 payload: {329 id: entry.id,330 patch: {331 canary: {332 ...entry.canary,333 lastPinged: Date.now(),334 pingCount: (entry.canary.pingCount ?? 0) + 1,335 },336 },337 },338 }).catch(() => {});339 }340341 342 343 const totpCode = (entry as { totpCode?: string }).totpCode;344 if (totpCode) {345 const code = totpCode;346 const totpField = passwordEl ? findSameLoginTOTPField(passwordEl) : null;347 if (fillTOTPCode(totpField, code)) {348 filledTOTPField = totpField;349 }350 let alreadyWritten = false;351 try {352 await navigator.clipboard.writeText(code);353 alreadyWritten = true;354 } catch (err) {355 console.warn("[Peach] clipboard write denied:", err);356 }357 const response = await chrome.runtime.sendMessage({358 type: "COPY",359 payload: { text: code, label: "TOTP", alreadyWritten },360 });361 if (filledTOTPField) {362 showToast("2FA code filled");363 } else if (alreadyWritten || (response as { ok?: boolean }).ok) {364 365 366 367 showToast("2FA code copied");368 } else {369 showToast("Could not copy TOTP — clipboard blocked");370 }371 }372373 return filledTOTPField;374}375376async function fetchAutofillEntries(377 domain: string,378 includeAll = false379): Promise<{ entries: SelectorEntry[]; locked: boolean }> {380 const response = await chrome.runtime.sendMessage({381 type: "AUTOFILL_QUERY",382 payload: { domain, includeAll },383 });384 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };385 if (!res.ok && res.error === "vault_locked") {386 return { entries: [], locked: true };387 }388 return { entries: res.data ?? [], locked: false };389}390391async function fetchTOTPEntries(392 domain: string393): Promise<{ entries: SelectorEntry[]; locked: boolean }> {394 const response = await chrome.runtime.sendMessage({395 type: "AUTOFILL_QUERY",396 payload: { domain, kind: "totp" },397 });398 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };399 if (!res.ok && res.error === "vault_locked") {400 return { entries: [], locked: true };401 }402 return { entries: res.data ?? [], locked: false };403}404405406function resolveAutofillFieldType(407 el: HTMLInputElement,408 predictedType?: FieldPrediction["type"] | null409): "password" | "username" | "email" | null {410 if (411 predictedType === "password" ||412 predictedType === "username" ||413 predictedType === "email"414 ) {415 return predictedType;416 }417418 const detected = detectLoginFields().find((field) => field.el === el)?.type;419 if (detected && AUTOFILL_FIELD_TYPES.has(detected)) {420 return detected;421 }422423 const htmlType = el.type.toLowerCase();424 if (htmlType === "password") return "password";425 if (htmlType === "email") return "email";426 return null;427}428429async function openAutofillSelector(430 anchorField: HTMLInputElement,431 usernameEl: HTMLInputElement | null,432 passwordEl: HTMLInputElement | null,433 domainEntries: SelectorEntry[],434 searchableEntries: SelectorEntry[],435 domain: string,436 identityKind: "email" | "username"437): Promise<void> {438 439 440 441 const isSignup = domainEntries.length === 0;442 let savedIdentities: SavedIdentity[] | undefined;443 let confirmEl: HTMLInputElement | null = null;444 if (isSignup) {445 try {446 const idResp = await chrome.runtime.sendMessage({ type: "GET_IDENTITIES" }).catch(() => null) as447 { ok: boolean; data?: SavedIdentity[] } | null;448 if (idResp?.ok && Array.isArray(idResp.data)) savedIdentities = idResp.data;449 } catch { }450451 const form = (passwordEl ?? usernameEl)?.closest("form") ?? document.body;452 const allPwInputs = Array.from(form.querySelectorAll<HTMLInputElement>("input[type='password']"));453 confirmEl = allPwInputs.find(454 (el) => el !== passwordEl && /confirm|repeat|verify/i.test(el.name + el.id + el.placeholder)455 ) ?? (allPwInputs.length >= 2 ? allPwInputs.find((el) => el !== passwordEl) ?? null : null);456 }457458 currentDismiss?.();459 currentAnchorField = anchorField;460 currentDismiss = showAutofillSelector(461 anchorField,462 domainEntries,463 async (id: string) => {464 currentDismiss = null;465 currentAnchorField = null;466 const entryResponse = await chrome.runtime.sendMessage({467 type: "GET_ENTRY_FOR_FILL",468 payload: { id },469 });470 const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;471 if (entry) {472 const filledTOTPField = await handleAutofill(anchorField, usernameEl, passwordEl, entry);473 recordFillSuccess(anchorField, usernameEl, passwordEl, filledTOTPField);474 475 476 477 const pageDomain = location.hostname.replace(/^www\./, "");478 const entryDomains = new Set<string>();479 for (const u of [entry.url, ...(entry.additionalUrls ?? [])]) {480 try {481 if (u) entryDomains.add(new URL(u).hostname.replace(/^www\./, ""));482 } catch { }483 }484 if (pageDomain && entryDomains.size > 0 && !entryDomains.has(pageDomain)) {485 showRememberSiteToast(pageDomain, entry.id);486 }487 }488 },489 {490 domain,491 searchableEntries,492 isSignup,493 identityKind,494 savedIdentities,495 onFillFields: (username, password) => {496 const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;497 fillFields(usernameEl, passwordEl, username, password, form, getPageDomain());498 if (password && confirmEl) setNativeValue(confirmEl, password);499 recordFillSuccess(anchorField, usernameEl, passwordEl);500 },501 onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },502 }503 );504}505506async function openTOTPSelector(anchorField: HTMLInputElement, otpDigitBoxes?: HTMLInputElement[]): Promise<void> {507 if (!canOpen()) return;508 if (currentDismiss && currentAnchorField === anchorField) return;509 if (anchorField !== lastDismissedField) lastDismissedField = null;510 if (anchorField === lastDismissedField) return;511 if (wasAlreadyFilled(anchorField)) return;512513 const domain = getPageDomain();514 const result = await fetchTOTPEntries(domain);515516 if (result.locked) {517 currentDismiss?.();518 currentAnchorField = anchorField;519 currentDismiss = showAutofillSelector(520 anchorField,521 [],522 () => {},523 {524 domain,525 locked: true,526 onUnlocked: () => void openTOTPSelector(anchorField),527 onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },528 }529 );530 return;531 }532533 if (result.entries.length === 0) {534 lastDismissedField = anchorField;535 startDismissLock();536 showToast("No 2FA entries for this site — add a code in Peach first");537 return;538 }539540 currentDismiss?.();541 currentAnchorField = anchorField;542 currentDismiss = showTOTPSelector(543 anchorField,544 result.entries,545 async (id: string) => {546 try {547 const entryResponse = await chrome.runtime.sendMessage({548 type: "GET_ENTRY_FOR_FILL",549 payload: { id },550 }).catch(() => null) as { ok: boolean; data?: VaultEntry } | null;551 const entry = entryResponse?.ok ? entryResponse.data : undefined;552 const code = (entry as { totpCode?: string } | undefined)?.totpCode;553 if (!code) return;554 if (otpDigitBoxes && otpDigitBoxes.length > 1) {555 fillOTPDigitBoxes(otpDigitBoxes, code);556 } else {557 setNativeValue(anchorField, code);558 }559 recordFillSuccess(anchorField);560 showToast("2FA code filled");561 } finally {562 currentDismiss = null;563 currentAnchorField = null;564 }565 },566 {567 domain,568 onDismiss: () => {569 lastDismissedField = anchorField;570 startDismissLock();571 currentDismiss = null;572 currentAnchorField = null;573 },574 }575 );576}577578579async function handleFieldInteraction(580 anchorField?: HTMLInputElement | null,581 explicitSummon = false582): Promise<void> {583 const field = anchorField ?? getDeepActiveInput();584 if (!field) return;585 if (!isVisible(field)) return;586587 588 if (passkeyToastActive) return;589590 591 592 if (!canOpen()) return;593594 595 if (currentDismiss && currentAnchorField === field) return;596597 598 599 if (field !== lastDismissedField) lastDismissedField = null;600 601 602 if (field === lastDismissedField) return;603604 605 606 607 608 if (wasAlreadyFilled(field)) return;609610 611 612 if (pendingInteractions.has(field)) return;613 pendingInteractions.add(field);614615 try {616 617 618 const otpGroup = findOTPDigitBoxGroup(field);619 if (otpGroup) {620 await openTOTPSelector(otpGroup[0]!, otpGroup);621 return;622 }623624 if (isTOTPField(field)) {625 const formFields = detectLoginFields();626 if (!isLoginForm(formFields) && !isSignupForm(formFields)) {627 await openTOTPSelector(field);628 return;629 }630 }631632 const fieldType = field.type.toLowerCase();633634 const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {635 return preds[0] ?? null;636 });637638 const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {639 const timeout = setTimeout(() => resolve(null), 150);640 mlPromise.then((pred) => {641 clearTimeout(timeout);642 resolve(pred);643 });644 });645646 if (passkeyToastActive) return;647648 if (!explicitSummon && fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {649 return;650 }651652 const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);653 if (autofillFieldType) {654 reattachListeners(field, autofillFieldType);655 }656657 let fields = detectLoginFields();658 const targets = resolveAutofillInteractionTargets(field, fields, explicitSummon);659 if (!targets.shouldOpen) {660 return;661 }662663 const domain = getPageDomain();664 const firstResult = await fetchAutofillEntries(domain);665 if (passkeyToastActive) return;666667 668 669 670 if (firstResult.locked) {671 currentDismiss?.();672 currentAnchorField = field;673 currentDismiss = showAutofillSelector(674 field,675 [],676 () => {},677 { domain, locked: true, onDismiss: () => { lastDismissedField = field; startDismissLock(); } }678 );679 return;680 }681682 const entries = firstResult.entries;683 684 685 686 687 const allEntries = (await fetchAutofillEntries(domain, true)).entries;688689 if (passkeyToastActive) return;690691 const pwField = fields.find((f) => f.type === "password");692 693 if (!explicitSummon && !pwField && !isEmailFirstForm(fields)) return;694 const usernameEl = targets.usernameEl;695 const passwordEl = targets.passwordEl;696697 await openAutofillSelector(698 field,699 usernameEl,700 passwordEl,701 entries,702 allEntries,703 domain,704 autofillFieldType === "email" ? "email" : "username",705 );706 } finally {707 pendingInteractions.delete(field);708 }709}710711async function onFocusIn(e: Event): Promise<void> {712 713 714 715 716 717 if (isSubmitButton(e.target)) return;718 await handleFieldInteraction();719}720721function reattachListeners(722 el: HTMLInputElement,723 detectedType?: "password" | "username" | "email",724 contextFields?: ReturnType<typeof detectLoginFields>725): void {726 if (processedInputs.has(el)) return;727 if (!resolveAutofillFieldType(el, detectedType)) return;728 729 const fields = contextFields ?? detectLoginFields();730 if (!isAnyCredentialForm(fields)) return;731732 const cleanup = injectFieldIcon(el, () => {733 currentDismiss?.();734 currentDismiss = null;735 lastDismissedField = null; 736 dismissLock = false; 737 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }738 clearFillStateForExplicitSummon(el);739 el.focus({ preventScroll: true });740 void handleFieldInteraction(el, true);741 });742 processedInputs.set(el, cleanup);743}744745const debouncedReattach = debounce(() => {746 const allFields = detectLoginFields();747 if (!isAnyCredentialForm(allFields)) return;748 const newFields = allFields.filter(749 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)750 );751 for (const f of newFields) {752 reattachListeners(f.el, f.type, allFields);753 }754 755 756 757}, 200);758759function setupMutationObserver(): void {760 const observer = new MutationObserver(debouncedReattach);761 observer.observe(document.body, {762 childList: true,763 subtree: true,764 attributes: true,765 attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],766 });767}768769function initialScan(aggressionLevel = 0, isFinalPass = false) {770 const allFields = detectLoginFields();771 const hasKnownForm = isAnyCredentialForm(allFields);772773 if (aggressionLevel === 0 && !hasKnownForm) return;774775 const newFields = allFields.filter(776 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)777 );778779 for (const f of newFields) {780 reattachListeners(f.el, f.type, allFields);781 }782783 784 785 if (aggressionLevel >= 2 && isFinalPass && newFields.length === 0) {786 const pwInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');787 for (const el of pwInputs) {788 if (isVisible(el) && !processedInputs.has(el)) {789 const cleanup = injectFieldIcon(el, () => {790 currentDismiss?.();791 currentDismiss = null;792 lastDismissedField = null;793 dismissLock = false;794 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }795 clearFillStateForExplicitSummon(el);796 el.focus({ preventScroll: true });797 void handleFieldInteraction(el, true);798 });799 processedInputs.set(el, cleanup);800 }801 }802 }803}804805806807808809const processedTOTPInputs = new WeakMap<HTMLInputElement, () => void>();810811function hasLoginOrSignupForm(): boolean {812 const fields = detectLoginFields();813 return isAnyCredentialForm(fields);814}815816function injectTOTPFieldIcon(field: HTMLInputElement): void {817 if (processedTOTPInputs.has(field)) return;818 const cleanup = injectFieldIcon(field, () => {819 currentDismiss?.();820 currentDismiss = null;821 currentAnchorField = null;822 lastDismissedField = null;823 dismissLock = false;824 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }825 clearFillStateForExplicitSummon(field);826 field.focus({ preventScroll: true });827 void openTOTPSelector(field);828 });829 processedTOTPInputs.set(field, cleanup);830}831832function scanTOTPFields(): void {833 if (hasLoginOrSignupForm()) return;834 const inputs = document.querySelectorAll<HTMLInputElement>("input");835 for (const input of inputs) {836 if (!isTOTPField(input)) continue;837 injectTOTPFieldIcon(input);838 }839}840841function setupTOTPAutofill(): void {842 setTimeout(scanTOTPFields, 0);843 setTimeout(scanTOTPFields, 500);844 setTimeout(scanTOTPFields, 1500);845846 const debouncedScan = debounce(scanTOTPFields, 200);847 new MutationObserver(debouncedScan).observe(document.body, {848 childList: true,849 subtree: true,850 attributes: true,851 attributeFilter: ["autocomplete", "name", "id", "placeholder", "hidden"],852 });853854 document.addEventListener("focusin", (e) => {855 const target = e.target;856 if (!(target instanceof HTMLInputElement)) return;857 if (hasLoginOrSignupForm()) return;858 if (!isTOTPField(target)) return;859 injectTOTPFieldIcon(target);860 }, true);861}862863document.addEventListener("focusin", onFocusIn);864865setupMutationObserver();866setupTOTPAutofill();867setupSaveDetection();868setupCardAutofill();869870871872873874875876877let lastObservedUrl = location.href;878new MutationObserver(() => {879 if (location.href !== lastObservedUrl) {880 lastObservedUrl = location.href;881 pageFilledAt = null;882 }883}).observe(document.body, { childList: true, subtree: false });884885886887(async () => {888 let aggressionLevel = 0;889 try {890 const resp = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });891 const s = (resp as { ok: boolean; data?: { manualSummonDomains?: Record<string, { summonCount: number }> } }).data;892 const domain = getPageDomain();893 const record = domain ? s?.manualSummonDomains?.[domain] : undefined;894 aggressionLevel = !record ? 0 : record.summonCount >= 2 ? 2 : 1;895 } catch { }896897 setTimeout(() => initialScan(aggressionLevel, false), 0);898 setTimeout(() => initialScan(aggressionLevel, false), 500);899 setTimeout(() => initialScan(aggressionLevel, true), 1500);900})();901902903904905906907const processedCardInputs = new WeakMap<HTMLInputElement, () => void>();908let currentCardDismiss: (() => void) | null = null;909let currentCardAnchor: HTMLInputElement | null = null;910let lastDismissedCardField: HTMLInputElement | null = null;911912async function fetchSavedCards(): Promise<CardSelectorEntry[]> {913 const resp = await chrome.runtime.sendMessage({914 type: "AUTOFILL_QUERY",915 payload: { domain: getPageDomain(), kind: "card" },916 }).catch(() => null);917 const r = resp as { ok: boolean; data?: CardSelectorEntry[] } | null;918 return r?.ok ? (r.data ?? []) : [];919}920921async function openCardSelector(anchor: HTMLInputElement): Promise<void> {922 if (!canOpen()) return;923 if (currentCardDismiss && currentCardAnchor === anchor) return;924 if (anchor === lastDismissedCardField) return;925 if (anchor !== lastDismissedCardField) lastDismissedCardField = null;926 if (wasAlreadyFilled(anchor)) return;927928 const cards = await fetchSavedCards();929 if (cards.length === 0) return; 930931 const cardForm = detectCardForm();932 if (!cardForm?.number) return;933934 currentCardDismiss?.();935 currentCardAnchor = anchor;936 currentCardDismiss = showCardSelector(937 anchor,938 cards,939 {940 domain: getPageDomain(),941 onFill: (entry) => {942 if (!cardForm) return;943 fillCardForm(cardForm, {944 cardNumber: entry.cardNumber ?? "",945 expMonth: entry.expMonth ?? "",946 expYear: entry.expYear ?? "",947 cvc: entry.cvc ?? "",948 cardholderName: entry.cardholderName ?? "",949 });950 951 952 953 recordFillSuccess(cardForm.number, cardForm.cvc, cardForm.expiryCombo, cardForm.name);954 currentCardDismiss = null;955 currentCardAnchor = null;956 },957 onDismiss: () => {958 lastDismissedCardField = anchor;959 startDismissLock();960 currentCardDismiss = null;961 currentCardAnchor = null;962 },963 }964 );965}966967function injectCardIcon(field: HTMLInputElement): void {968 if (processedCardInputs.has(field)) return;969 const cleanup = injectFieldIcon(field, () => {970 currentCardDismiss?.();971 currentCardDismiss = null;972 lastDismissedCardField = null;973 dismissLock = false;974 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }975 clearFillStateForExplicitSummon(field);976 field.focus({ preventScroll: true });977 void openCardSelector(field);978 });979 processedCardInputs.set(field, cleanup);980}981982function setupCardAutofill(): void {983 function scan() {984 const cardForm = detectCardForm();985 if (!cardForm?.number) return;986 if (!isVisible(cardForm.number)) return;987 injectCardIcon(cardForm.number);988 }989990 991 992 setTimeout(scan, 0);993 setTimeout(scan, 500);994 setTimeout(scan, 1500);995996 997 998 999 let pending = false;1000 new MutationObserver(() => {1001 if (pending) return;1002 pending = true;1003 setTimeout(() => { pending = false; scan(); }, 200);1004 }).observe(document.body, { childList: true, subtree: true });10051006 1007 1008 1009 document.addEventListener("focusin", (e) => {1010 const target = e.target;1011 if (!(target instanceof HTMLInputElement)) return;1012 const cardForm = detectCardForm();1013 if (cardForm?.number === target) {1014 injectCardIcon(target);1015 }1016 }, true);1017}10181019chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {1020 1021 if (sender.id !== chrome.runtime.id) return;1022 if (msg?.type === "GET_SITE_PASSWORD_POLICY") {1023 const policy = detectPasswordPolicy();1024 sendResponse({ ok: true, data: policy });1025 return true;1026 }1027 if (msg?.type === "PING") {1028 sendResponse({ ok: true });1029 return true;1030 }1031 if (msg?.type === "SUMMON_AUTOFILL") {1032 const source = (msg as { type: string; source?: string }).source;1033 const isShortcut = source === "shortcut";1034 const isPasskeyUnlock = source === "passkey-unlock";10351036 const activeInput = getDeepActiveInput();1037 const fields = detectLoginFields();1038 const hasForm = isAnyCredentialForm(fields);10391040 if (hasForm) {1041 1042 1043 const inForm = activeInput && fields.some(f => f.el === activeInput);1044 const target = inForm1045 ? activeInput1046 : (fields.find(f => f.type === "password")?.el1047 ?? fields.find(f => f.type === "username" || f.type === "email")?.el);1048 if (target) {1049 1050 1051 clearFillStateForExplicitSummon(target);1052 dismissLock = false;1053 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1054 lastDismissedField = null;1055 void handleFieldInteraction(target);1056 }1057 } else if (isPasskeyUnlock) {1058 1059 1060 1061 const anchor = (document.activeElement instanceof HTMLElement && document.activeElement !== document.body)1062 ? document.activeElement as unknown as HTMLInputElement1063 : null;1064 if (anchor) {1065 currentDismiss?.();1066 currentAnchorField = null;1067 passkeySummonActive = true;1068 currentDismiss = showAutofillSelector(anchor, [], () => {}, {1069 domain: getPageDomain(),1070 locked: true,1071 onDismiss: () => { passkeySummonActive = false; },1072 });1073 }1074 } else if (isShortcut) {1075 1076 showSearchPalette();1077 }10781079 1080 if (isShortcut) {1081 const domain = getPageDomain();1082 if (domain) {1083 chrome.runtime.sendMessage({ type: "RECORD_MANUAL_SUMMON", payload: { domain } }).catch(() => {});1084 }1085 }10861087 sendResponse({ ok: hasForm || isShortcut || isPasskeyUnlock });1088 return true;1089 }1090 if (msg?.type === "PASSKEY_PRO_GATE") {1091 1092 1093 showPasskeyProGate();1094 sendResponse({ ok: true });1095 return true;1096 }1097 if (msg?.type === "SHOW_PASSKEY_TOAST") {1098 1099 passkeyToastActive = true;1100 if (currentDismiss) {1101 currentDismiss();1102 currentDismiss = null;1103 currentAnchorField = null;1104 }1105 return false; 1106 }1107 if (msg?.type === "DISMISS_PASSKEY_TOAST") {1108 passkeyToastActive = false;1109 return false; 1110 }1111 if (msg?.type === "VAULT_UNLOCKED") {1112 if (passkeySummonActive) {1113 1114 1115 passkeySummonActive = false;1116 if (currentDismiss) {1117 currentDismiss();1118 currentDismiss = null;1119 currentAnchorField = null;1120 }1121 sendResponse({ ok: true });1122 return true;1123 }1124 1125 1126 const savedField = currentAnchorField;1127 if (currentDismiss) {1128 currentDismiss();1129 currentDismiss = null;1130 currentAnchorField = null;1131 }1132 void handleFieldInteraction(savedField ?? undefined);1133 sendResponse({ ok: true });1134 return true;1135 }1136 return true;1137});1138113911401141window.addEventListener("message", (e: MessageEvent) => {1142 1143 1144 if (e.source !== window) return;1145 const d = e.data as { source?: string; type?: string } | undefined;1146 if (d?.source === "peach-passkey-toast" && d?.type === "toast-closed") {1147 passkeyToastActive = false;1148 }1149});1150115111521153115411551156115711581159116011611162if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1163 let lastPasskeyOfferAt = 0;1164 const PASSKEY_OFFER_COOLDOWN_MS = 30_000;11651166 window.addEventListener("message", (event: MessageEvent) => {1167 if (event.source !== window) return;1168 const data = event.data as { source?: string; type?: string; rpId?: string; rpName?: string; userName?: string; userDisplayName?: string } | undefined;1169 if (!data || data.source !== "peach-passkey-firefox") return;1170 if (detectBrowser() !== "firefox") return;1171 if (data.type !== "create") return;11721173 1174 1175 const now = Date.now();1176 if (now - lastPasskeyOfferAt < PASSKEY_OFFER_COOLDOWN_MS) return;1177 lastPasskeyOfferAt = now;11781179 const domain = location.hostname.replace(/^www\./, "");1180 chrome.runtime.sendMessage({1181 type: "GET_PASSKEY_OFFER_DECLINED",1182 payload: { domain },1183 }).then((res: { ok: boolean; data?: boolean } | undefined) => {1184 if (res?.data === true) return;1185 const rpId = data.rpId ?? domain;1186 showPasskeyRegistrationOffer({1187 rpId,1188 rpName: data.rpName,1189 userName: data.userName,1190 userDisplayName: data.userDisplayName,1191 });1192 }).catch(() => {});1193 });1194}119511961197119811991200120112021203120412051206120712081209{1210 function checkPasskeyAvailability() {1211 const domain = location.hostname.replace(/^www\./, "");1212 if (!domain) return;1213 const sessionKey = `peach_state11_shown:${domain}`;1214 chrome.storage.session?.get(sessionKey).then((stored) => {1215 if (stored && (stored as Record<string, unknown>)[sessionKey]) return;1216 chrome.runtime.sendMessage({1217 type: "GET_PASSKEY_FOR_DOMAIN",1218 payload: { domain },1219 }).then((res: { ok: boolean; data?: { rpId: string; userName: string; browser?: "chrome" | "firefox" } | null } | undefined) => {1220 const data = res?.data;1221 if (!data) return;1222 const current = detectBrowser();1223 const isCrossBrowser =1224 data.browser && current !== "unknown" && data.browser !== current;1225 const valuableOnFirefox = current === "firefox";1226 if (!isCrossBrowser && !valuableOnFirefox) return;1227 chrome.storage.session?.set({ [sessionKey]: Date.now() }).catch(() => {});1228 showPasskeyAvailable({1229 rpId: data.rpId,1230 userName: data.userName,1231 fromBrowser: data.browser,1232 currentBrowser: current,1233 });1234 }).catch(() => {});1235 }).catch(() => {});1236 }12371238 1239 1240 1241 if (document.readyState === "loading") {1242 document.addEventListener("DOMContentLoaded", () => setTimeout(checkPasskeyAvailability, 800));1243 } else {1244 setTimeout(checkPasskeyAvailability, 800);1245 }1246}