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: VaultEntry,310 emailEl?: HTMLInputElement | null311): Promise<HTMLInputElement | null> {312 if (!entry.username && !entry.password && !entry.email) return null;313 const pageDomain = getPageDomain();314 const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;315 316 317 318 const emailValue = entry.email ?? (entry.username?.includes("@") ? entry.username : "");319 fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain, emailEl, emailValue);320 let filledTOTPField: HTMLInputElement | null = null;321322 if (entry.canary) {323 chrome.runtime.sendMessage({324 type: "FIRE_CANARY_PING",325 payload: {326 webhookUrl: entry.canary.webhookUrl,327 entryTitle: entry.title,328 trigger: "autofilled",329 },330 }).catch(() => {});331 chrome.runtime.sendMessage({332 type: "UPDATE_ENTRY",333 payload: {334 id: entry.id,335 patch: {336 canary: {337 ...entry.canary,338 lastPinged: Date.now(),339 pingCount: (entry.canary.pingCount ?? 0) + 1,340 },341 },342 },343 }).catch(() => {});344 }345346 347 348 const totpCode = (entry as { totpCode?: string }).totpCode;349 if (totpCode) {350 const code = totpCode;351 const totpField = passwordEl ? findSameLoginTOTPField(passwordEl) : null;352 if (fillTOTPCode(totpField, code)) {353 filledTOTPField = totpField;354 }355 let alreadyWritten = false;356 try {357 await navigator.clipboard.writeText(code);358 alreadyWritten = true;359 } catch (err) {360 console.warn("[Peach] clipboard write denied:", err);361 }362 const response = await chrome.runtime.sendMessage({363 type: "COPY",364 payload: { text: code, label: "TOTP", alreadyWritten },365 });366 if (filledTOTPField) {367 showToast("2FA code filled");368 } else if (alreadyWritten || (response as { ok?: boolean }).ok) {369 370 371 372 showToast("2FA code copied");373 } else {374 showToast("Could not copy TOTP — clipboard blocked");375 }376 }377378 return filledTOTPField;379}380381async function fetchAutofillEntries(382 domain: string,383 includeAll = false384): Promise<{ entries: SelectorEntry[]; locked: boolean }> {385 const response = await chrome.runtime.sendMessage({386 type: "AUTOFILL_QUERY",387 payload: { domain, includeAll },388 });389 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };390 if (!res.ok && res.error === "vault_locked") {391 return { entries: [], locked: true };392 }393 return { entries: res.data ?? [], locked: false };394}395396async function fetchTOTPEntries(397 domain: string398): Promise<{ entries: SelectorEntry[]; locked: boolean }> {399 const response = await chrome.runtime.sendMessage({400 type: "AUTOFILL_QUERY",401 payload: { domain, kind: "totp" },402 });403 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };404 if (!res.ok && res.error === "vault_locked") {405 return { entries: [], locked: true };406 }407 return { entries: res.data ?? [], locked: false };408}409410411function resolveAutofillFieldType(412 el: HTMLInputElement,413 predictedType?: FieldPrediction["type"] | null414): "password" | "username" | "email" | null {415 if (416 predictedType === "password" ||417 predictedType === "username" ||418 predictedType === "email"419 ) {420 return predictedType;421 }422423 const detected = detectLoginFields().find((field) => field.el === el)?.type;424 if (detected && AUTOFILL_FIELD_TYPES.has(detected)) {425 return detected;426 }427428 const htmlType = el.type.toLowerCase();429 if (htmlType === "password") return "password";430 if (htmlType === "email") return "email";431 return null;432}433434async function openAutofillSelector(435 anchorField: HTMLInputElement,436 usernameEl: HTMLInputElement | null,437 passwordEl: HTMLInputElement | null,438 domainEntries: SelectorEntry[],439 searchableEntries: SelectorEntry[],440 domain: string,441 identityKind: "email" | "username",442 emailEl?: HTMLInputElement | null443): Promise<void> {444 445 446 447 const isSignup = domainEntries.length === 0;448 let savedIdentities: SavedIdentity[] | undefined;449 let confirmEl: HTMLInputElement | null = null;450 if (isSignup) {451 try {452 const idResp = await chrome.runtime.sendMessage({ type: "GET_IDENTITIES" }).catch(() => null) as453 { ok: boolean; data?: SavedIdentity[] } | null;454 if (idResp?.ok && Array.isArray(idResp.data)) savedIdentities = idResp.data;455 } catch { }456457 const form = (passwordEl ?? usernameEl)?.closest("form") ?? document.body;458 const allPwInputs = Array.from(form.querySelectorAll<HTMLInputElement>("input[type='password']"));459 confirmEl = allPwInputs.find(460 (el) => el !== passwordEl && /confirm|repeat|verify/i.test(el.name + el.id + el.placeholder)461 ) ?? (allPwInputs.length >= 2 ? allPwInputs.find((el) => el !== passwordEl) ?? null : null);462 }463464 currentDismiss?.();465 currentAnchorField = anchorField;466 currentDismiss = showAutofillSelector(467 anchorField,468 domainEntries,469 async (id: string) => {470 currentDismiss = null;471 currentAnchorField = null;472 const entryResponse = await chrome.runtime.sendMessage({473 type: "GET_ENTRY_FOR_FILL",474 payload: { id },475 });476 const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;477 if (entry) {478 const filledTOTPField = await handleAutofill(anchorField, usernameEl, passwordEl, entry, emailEl);479 recordFillSuccess(anchorField, usernameEl, passwordEl, filledTOTPField);480 481 482 483 const pageDomain = location.hostname.replace(/^www\./, "");484 const entryDomains = new Set<string>();485 for (const u of [entry.url, ...(entry.additionalUrls ?? [])]) {486 try {487 if (u) entryDomains.add(new URL(u).hostname.replace(/^www\./, ""));488 } catch { }489 }490 if (pageDomain && entryDomains.size > 0 && !entryDomains.has(pageDomain)) {491 showRememberSiteToast(pageDomain, entry.id);492 }493 }494 },495 {496 domain,497 searchableEntries,498 isSignup,499 identityKind,500 hasEmailField: !!emailEl,501 savedIdentities,502 onFillFields: (username, password, email) => {503 const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;504 fillFields(usernameEl, passwordEl, username, password, form, getPageDomain(), emailEl, email);505 if (password && confirmEl) setNativeValue(confirmEl, password);506 recordFillSuccess(anchorField, usernameEl, passwordEl);507 },508 onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },509 }510 );511}512513async function openTOTPSelector(anchorField: HTMLInputElement, otpDigitBoxes?: HTMLInputElement[]): Promise<void> {514 if (!canOpen()) return;515 if (currentDismiss && currentAnchorField === anchorField) return;516 if (anchorField !== lastDismissedField) lastDismissedField = null;517 if (anchorField === lastDismissedField) return;518 if (wasAlreadyFilled(anchorField)) return;519520 const domain = getPageDomain();521 const result = await fetchTOTPEntries(domain);522523 if (result.locked) {524 currentDismiss?.();525 currentAnchorField = anchorField;526 currentDismiss = showAutofillSelector(527 anchorField,528 [],529 () => {},530 {531 domain,532 locked: true,533 onUnlocked: () => void openTOTPSelector(anchorField),534 onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },535 }536 );537 return;538 }539540 if (result.entries.length === 0) {541 lastDismissedField = anchorField;542 startDismissLock();543 showToast("No 2FA entries for this site — add a code in Peach first");544 return;545 }546547 currentDismiss?.();548 currentAnchorField = anchorField;549 currentDismiss = showTOTPSelector(550 anchorField,551 result.entries,552 async (id: string) => {553 try {554 const entryResponse = await chrome.runtime.sendMessage({555 type: "GET_ENTRY_FOR_FILL",556 payload: { id },557 }).catch(() => null) as { ok: boolean; data?: VaultEntry } | null;558 const entry = entryResponse?.ok ? entryResponse.data : undefined;559 const code = (entry as { totpCode?: string } | undefined)?.totpCode;560 if (!code) return;561 if (otpDigitBoxes && otpDigitBoxes.length > 1) {562 fillOTPDigitBoxes(otpDigitBoxes, code);563 } else {564 setNativeValue(anchorField, code);565 }566 recordFillSuccess(anchorField);567 showToast("2FA code filled");568 } finally {569 currentDismiss = null;570 currentAnchorField = null;571 }572 },573 {574 domain,575 onDismiss: () => {576 lastDismissedField = anchorField;577 startDismissLock();578 currentDismiss = null;579 currentAnchorField = null;580 },581 }582 );583}584585586async function handleFieldInteraction(587 anchorField?: HTMLInputElement | null,588 explicitSummon = false589): Promise<void> {590 const field = anchorField ?? getDeepActiveInput();591 if (!field) return;592 if (!isVisible(field)) return;593594 595 if (passkeyToastActive) return;596597 598 599 if (!canOpen()) return;600601 602 if (currentDismiss && currentAnchorField === field) return;603604 605 606 if (field !== lastDismissedField) lastDismissedField = null;607 608 609 if (field === lastDismissedField) return;610611 612 613 614 615 if (wasAlreadyFilled(field)) return;616617 618 619 if (pendingInteractions.has(field)) return;620 pendingInteractions.add(field);621622 try {623 624 625 const otpGroup = findOTPDigitBoxGroup(field);626 if (otpGroup) {627 await openTOTPSelector(otpGroup[0]!, otpGroup);628 return;629 }630631 if (isTOTPField(field)) {632 const formFields = detectLoginFields();633 if (!isLoginForm(formFields) && !isSignupForm(formFields)) {634 await openTOTPSelector(field);635 return;636 }637 }638639 const fieldType = field.type.toLowerCase();640641 const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {642 return preds[0] ?? null;643 });644645 const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {646 const timeout = setTimeout(() => resolve(null), 150);647 mlPromise.then((pred) => {648 clearTimeout(timeout);649 resolve(pred);650 });651 });652653 if (passkeyToastActive) return;654655 if (!explicitSummon && fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {656 return;657 }658659 const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);660 if (autofillFieldType) {661 reattachListeners(field, autofillFieldType);662 }663664 let fields = detectLoginFields();665 const targets = resolveAutofillInteractionTargets(field, fields, explicitSummon);666 if (!targets.shouldOpen) {667 return;668 }669670 const domain = getPageDomain();671 const firstResult = await fetchAutofillEntries(domain);672 if (passkeyToastActive) return;673674 675 676 677 if (firstResult.locked) {678 currentDismiss?.();679 currentAnchorField = field;680 currentDismiss = showAutofillSelector(681 field,682 [],683 () => {},684 { domain, locked: true, onDismiss: () => { lastDismissedField = field; startDismissLock(); } }685 );686 return;687 }688689 const entries = firstResult.entries;690 691 692 693 694 const allEntries = (await fetchAutofillEntries(domain, true)).entries;695696 if (passkeyToastActive) return;697698 const pwField = fields.find((f) => f.type === "password");699 700 if (!explicitSummon && !pwField && !isEmailFirstForm(fields)) return;701 const usernameEl = targets.usernameEl;702 const passwordEl = targets.passwordEl;703 const emailEl = targets.emailEl;704705 await openAutofillSelector(706 field,707 usernameEl,708 passwordEl,709 entries,710 allEntries,711 domain,712 autofillFieldType === "email" ? "email" : "username",713 emailEl,714 );715 } finally {716 pendingInteractions.delete(field);717 }718}719720async function onFocusIn(e: Event): Promise<void> {721 722 723 724 725 726 if (isSubmitButton(e.target)) return;727 await handleFieldInteraction();728}729730function reattachListeners(731 el: HTMLInputElement,732 detectedType?: "password" | "username" | "email",733 contextFields?: ReturnType<typeof detectLoginFields>734): void {735 if (processedInputs.has(el)) return;736 if (!resolveAutofillFieldType(el, detectedType)) return;737 738 const fields = contextFields ?? detectLoginFields();739 if (!isAnyCredentialForm(fields)) return;740741 const cleanup = injectFieldIcon(el, () => {742 currentDismiss?.();743 currentDismiss = null;744 lastDismissedField = null; 745 dismissLock = false; 746 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }747 clearFillStateForExplicitSummon(el);748 el.focus({ preventScroll: true });749 void handleFieldInteraction(el, true);750 });751 processedInputs.set(el, cleanup);752}753754const debouncedReattach = debounce(() => {755 const allFields = detectLoginFields();756 if (!isAnyCredentialForm(allFields)) return;757 const newFields = allFields.filter(758 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)759 );760 for (const f of newFields) {761 reattachListeners(f.el, f.type, allFields);762 }763 764 765 766}, 200);767768function setupMutationObserver(): void {769 const observer = new MutationObserver(debouncedReattach);770 observer.observe(document.body, {771 childList: true,772 subtree: true,773 attributes: true,774 attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],775 });776}777778function initialScan(aggressionLevel = 0, isFinalPass = false) {779 const allFields = detectLoginFields();780 const hasKnownForm = isAnyCredentialForm(allFields);781782 if (aggressionLevel === 0 && !hasKnownForm) return;783784 const newFields = allFields.filter(785 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)786 );787788 for (const f of newFields) {789 reattachListeners(f.el, f.type, allFields);790 }791792 793 794 if (aggressionLevel >= 2 && isFinalPass && newFields.length === 0) {795 const pwInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');796 for (const el of pwInputs) {797 if (isVisible(el) && !processedInputs.has(el)) {798 const cleanup = injectFieldIcon(el, () => {799 currentDismiss?.();800 currentDismiss = null;801 lastDismissedField = null;802 dismissLock = false;803 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }804 clearFillStateForExplicitSummon(el);805 el.focus({ preventScroll: true });806 void handleFieldInteraction(el, true);807 });808 processedInputs.set(el, cleanup);809 }810 }811 }812}813814815816817818const processedTOTPInputs = new WeakMap<HTMLInputElement, () => void>();819820function hasLoginOrSignupForm(): boolean {821 const fields = detectLoginFields();822 return isAnyCredentialForm(fields);823}824825function injectTOTPFieldIcon(field: HTMLInputElement): void {826 if (processedTOTPInputs.has(field)) return;827 const cleanup = injectFieldIcon(field, () => {828 currentDismiss?.();829 currentDismiss = null;830 currentAnchorField = null;831 lastDismissedField = null;832 dismissLock = false;833 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }834 clearFillStateForExplicitSummon(field);835 field.focus({ preventScroll: true });836 void openTOTPSelector(field);837 });838 processedTOTPInputs.set(field, cleanup);839}840841function scanTOTPFields(): void {842 if (hasLoginOrSignupForm()) return;843 const inputs = document.querySelectorAll<HTMLInputElement>("input");844 for (const input of inputs) {845 if (!isTOTPField(input)) continue;846 injectTOTPFieldIcon(input);847 }848}849850function setupTOTPAutofill(): void {851 setTimeout(scanTOTPFields, 0);852 setTimeout(scanTOTPFields, 500);853 setTimeout(scanTOTPFields, 1500);854855 const debouncedScan = debounce(scanTOTPFields, 200);856 new MutationObserver(debouncedScan).observe(document.body, {857 childList: true,858 subtree: true,859 attributes: true,860 attributeFilter: ["autocomplete", "name", "id", "placeholder", "hidden"],861 });862863 document.addEventListener("focusin", (e) => {864 const target = e.target;865 if (!(target instanceof HTMLInputElement)) return;866 if (hasLoginOrSignupForm()) return;867 if (!isTOTPField(target)) return;868 injectTOTPFieldIcon(target);869 }, true);870}871872document.addEventListener("focusin", onFocusIn);873874setupMutationObserver();875setupTOTPAutofill();876setupSaveDetection();877setupCardAutofill();878879880881882883884885886let lastObservedUrl = location.href;887new MutationObserver(() => {888 if (location.href !== lastObservedUrl) {889 lastObservedUrl = location.href;890 pageFilledAt = null;891 }892}).observe(document.body, { childList: true, subtree: false });893894895896(async () => {897 let aggressionLevel = 0;898 try {899 const resp = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });900 const s = (resp as { ok: boolean; data?: { manualSummonDomains?: Record<string, { summonCount: number }> } }).data;901 const domain = getPageDomain();902 const record = domain ? s?.manualSummonDomains?.[domain] : undefined;903 aggressionLevel = !record ? 0 : record.summonCount >= 2 ? 2 : 1;904 } catch { }905906 setTimeout(() => initialScan(aggressionLevel, false), 0);907 setTimeout(() => initialScan(aggressionLevel, false), 500);908 setTimeout(() => initialScan(aggressionLevel, true), 1500);909})();910911912913914915916const processedCardInputs = new WeakMap<HTMLInputElement, () => void>();917let currentCardDismiss: (() => void) | null = null;918let currentCardAnchor: HTMLInputElement | null = null;919let lastDismissedCardField: HTMLInputElement | null = null;920921async function fetchSavedCards(): Promise<CardSelectorEntry[]> {922 const resp = await chrome.runtime.sendMessage({923 type: "AUTOFILL_QUERY",924 payload: { domain: getPageDomain(), kind: "card" },925 }).catch(() => null);926 const r = resp as { ok: boolean; data?: CardSelectorEntry[] } | null;927 return r?.ok ? (r.data ?? []) : [];928}929930async function openCardSelector(anchor: HTMLInputElement): Promise<void> {931 if (!canOpen()) return;932 if (currentCardDismiss && currentCardAnchor === anchor) return;933 if (anchor === lastDismissedCardField) return;934 if (anchor !== lastDismissedCardField) lastDismissedCardField = null;935 if (wasAlreadyFilled(anchor)) return;936937 const cards = await fetchSavedCards();938 if (cards.length === 0) return; 939940 const cardForm = detectCardForm();941 if (!cardForm?.number) return;942943 currentCardDismiss?.();944 currentCardAnchor = anchor;945 currentCardDismiss = showCardSelector(946 anchor,947 cards,948 {949 domain: getPageDomain(),950 onFill: (entry) => {951 if (!cardForm) return;952 fillCardForm(cardForm, {953 cardNumber: entry.cardNumber ?? "",954 expMonth: entry.expMonth ?? "",955 expYear: entry.expYear ?? "",956 cvc: entry.cvc ?? "",957 cardholderName: entry.cardholderName ?? "",958 });959 960 961 962 recordFillSuccess(cardForm.number, cardForm.cvc, cardForm.expiryCombo, cardForm.name);963 currentCardDismiss = null;964 currentCardAnchor = null;965 },966 onDismiss: () => {967 lastDismissedCardField = anchor;968 startDismissLock();969 currentCardDismiss = null;970 currentCardAnchor = null;971 },972 }973 );974}975976function injectCardIcon(field: HTMLInputElement): void {977 if (processedCardInputs.has(field)) return;978 const cleanup = injectFieldIcon(field, () => {979 currentCardDismiss?.();980 currentCardDismiss = null;981 lastDismissedCardField = null;982 dismissLock = false;983 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }984 clearFillStateForExplicitSummon(field);985 field.focus({ preventScroll: true });986 void openCardSelector(field);987 });988 processedCardInputs.set(field, cleanup);989}990991function setupCardAutofill(): void {992 function scan() {993 const cardForm = detectCardForm();994 if (!cardForm?.number) return;995 if (!isVisible(cardForm.number)) return;996 injectCardIcon(cardForm.number);997 }998999 1000 1001 setTimeout(scan, 0);1002 setTimeout(scan, 500);1003 setTimeout(scan, 1500);10041005 1006 1007 1008 let pending = false;1009 new MutationObserver(() => {1010 if (pending) return;1011 pending = true;1012 setTimeout(() => { pending = false; scan(); }, 200);1013 }).observe(document.body, { childList: true, subtree: true });10141015 1016 1017 1018 document.addEventListener("focusin", (e) => {1019 const target = e.target;1020 if (!(target instanceof HTMLInputElement)) return;1021 const cardForm = detectCardForm();1022 if (cardForm?.number === target) {1023 injectCardIcon(target);1024 }1025 }, true);1026}10271028chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {1029 1030 if (sender.id !== chrome.runtime.id) return;1031 if (msg?.type === "GET_SITE_PASSWORD_POLICY") {1032 const policy = detectPasswordPolicy();1033 sendResponse({ ok: true, data: policy });1034 return true;1035 }1036 if (msg?.type === "PING") {1037 sendResponse({ ok: true });1038 return true;1039 }1040 if (msg?.type === "SUMMON_AUTOFILL") {1041 const source = (msg as { type: string; source?: string }).source;1042 const isShortcut = source === "shortcut";1043 const isPasskeyUnlock = source === "passkey-unlock";10441045 const activeInput = getDeepActiveInput();1046 const fields = detectLoginFields();1047 const hasForm = isAnyCredentialForm(fields);10481049 if (hasForm) {1050 1051 1052 const inForm = activeInput && fields.some(f => f.el === activeInput);1053 const target = inForm1054 ? activeInput1055 : (fields.find(f => f.type === "password")?.el1056 ?? fields.find(f => f.type === "username" || f.type === "email")?.el);1057 if (target) {1058 1059 1060 clearFillStateForExplicitSummon(target);1061 dismissLock = false;1062 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1063 lastDismissedField = null;1064 void handleFieldInteraction(target);1065 }1066 } else if (isPasskeyUnlock) {1067 1068 1069 1070 const anchor = (document.activeElement instanceof HTMLElement && document.activeElement !== document.body)1071 ? document.activeElement as unknown as HTMLInputElement1072 : null;1073 if (anchor) {1074 currentDismiss?.();1075 currentAnchorField = null;1076 passkeySummonActive = true;1077 currentDismiss = showAutofillSelector(anchor, [], () => {}, {1078 domain: getPageDomain(),1079 locked: true,1080 onDismiss: () => { passkeySummonActive = false; },1081 });1082 }1083 } else if (isShortcut) {1084 1085 showSearchPalette();1086 }10871088 1089 if (isShortcut) {1090 const domain = getPageDomain();1091 if (domain) {1092 chrome.runtime.sendMessage({ type: "RECORD_MANUAL_SUMMON", payload: { domain } }).catch(() => {});1093 }1094 }10951096 sendResponse({ ok: hasForm || isShortcut || isPasskeyUnlock });1097 return true;1098 }1099 if (msg?.type === "PASSKEY_PRO_GATE") {1100 1101 1102 showPasskeyProGate();1103 sendResponse({ ok: true });1104 return true;1105 }1106 if (msg?.type === "SHOW_PASSKEY_TOAST") {1107 1108 passkeyToastActive = true;1109 if (currentDismiss) {1110 currentDismiss();1111 currentDismiss = null;1112 currentAnchorField = null;1113 }1114 return false; 1115 }1116 if (msg?.type === "DISMISS_PASSKEY_TOAST") {1117 passkeyToastActive = false;1118 return false; 1119 }1120 if (msg?.type === "VAULT_UNLOCKED") {1121 if (passkeySummonActive) {1122 1123 1124 passkeySummonActive = false;1125 if (currentDismiss) {1126 currentDismiss();1127 currentDismiss = null;1128 currentAnchorField = null;1129 }1130 sendResponse({ ok: true });1131 return true;1132 }1133 1134 1135 const savedField = currentAnchorField;1136 if (currentDismiss) {1137 currentDismiss();1138 currentDismiss = null;1139 currentAnchorField = null;1140 }1141 void handleFieldInteraction(savedField ?? undefined);1142 sendResponse({ ok: true });1143 return true;1144 }1145 return true;1146});1147114811491150window.addEventListener("message", (e: MessageEvent) => {1151 1152 1153 if (e.source !== window) return;1154 const d = e.data as { source?: string; type?: string } | undefined;1155 if (d?.source === "peach-passkey-toast" && d?.type === "toast-closed") {1156 passkeyToastActive = false;1157 }1158});1159116011611162116311641165116611671168116911701171if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1172 let lastPasskeyOfferAt = 0;1173 const PASSKEY_OFFER_COOLDOWN_MS = 30_000;11741175 window.addEventListener("message", (event: MessageEvent) => {1176 if (event.source !== window) return;1177 const data = event.data as { source?: string; type?: string; rpId?: string; rpName?: string; userName?: string; userDisplayName?: string } | undefined;1178 if (!data || data.source !== "peach-passkey-firefox") return;1179 if (detectBrowser() !== "firefox") return;1180 if (data.type !== "create") return;11811182 1183 1184 const now = Date.now();1185 if (now - lastPasskeyOfferAt < PASSKEY_OFFER_COOLDOWN_MS) return;1186 lastPasskeyOfferAt = now;11871188 const domain = location.hostname.replace(/^www\./, "");1189 chrome.runtime.sendMessage({1190 type: "GET_PASSKEY_OFFER_DECLINED",1191 payload: { domain },1192 }).then((res: { ok: boolean; data?: boolean } | undefined) => {1193 if (res?.data === true) return;1194 const rpId = data.rpId ?? domain;1195 showPasskeyRegistrationOffer({1196 rpId,1197 rpName: data.rpName,1198 userName: data.userName,1199 userDisplayName: data.userDisplayName,1200 });1201 }).catch(() => {});1202 });1203}120412051206120712081209121012111212121312141215121612171218{1219 function checkPasskeyAvailability() {1220 const domain = location.hostname.replace(/^www\./, "");1221 if (!domain) return;1222 const sessionKey = `peach_state11_shown:${domain}`;1223 chrome.storage.session?.get(sessionKey).then((stored) => {1224 if (stored && (stored as Record<string, unknown>)[sessionKey]) return;1225 chrome.runtime.sendMessage({1226 type: "GET_PASSKEY_FOR_DOMAIN",1227 payload: { domain },1228 }).then((res: { ok: boolean; data?: { rpId: string; userName: string; browser?: "chrome" | "firefox" } | null } | undefined) => {1229 const data = res?.data;1230 if (!data) return;1231 const current = detectBrowser();1232 const isCrossBrowser =1233 data.browser && current !== "unknown" && data.browser !== current;1234 const valuableOnFirefox = current === "firefox";1235 if (!isCrossBrowser && !valuableOnFirefox) return;1236 chrome.storage.session?.set({ [sessionKey]: Date.now() }).catch(() => {});1237 showPasskeyAvailable({1238 rpId: data.rpId,1239 userName: data.userName,1240 fromBrowser: data.browser,1241 currentBrowser: current,1242 });1243 }).catch(() => {});1244 }).catch(() => {});1245 }12461247 1248 1249 1250 if (document.readyState === "loading") {1251 document.addEventListener("DOMContentLoaded", () => setTimeout(checkPasskeyAvailability, 800));1252 } else {1253 setTimeout(checkPasskeyAvailability, 800);1254 }1255}