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}100101export function 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 253 254 const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, explicitSummon: true } });255 const r = resp as { ok: boolean; data?: { password?: string } };256 if (r.ok && r.data?.password) {257 let alreadyWritten = false;258 try {259 await navigator.clipboard.writeText(r.data.password);260 alreadyWritten = true;261 } catch (err) {262 console.warn("[Peach] clipboard write denied:", err);263 }264 const copyResp = await chrome.runtime.sendMessage({265 type: "COPY",266 payload: { text: r.data.password, label: "Password", alreadyWritten },267 }) as { ok?: boolean } | undefined;268 if (alreadyWritten || copyResp?.ok) {269 showToast("Password copied");270 } else {271 showToast("Could not copy password — clipboard blocked");272 }273 } else {274 showToast("Could not copy password");275 }276 close();277 document.removeEventListener("keydown", onKeyDown, true);278 };279 list.appendChild(row);280 }281 }282283 searchInput.oninput = () => { query = searchInput.value; render(); };284285 286 287 fetchAutofillEntries(getPageDomain(), true, true).then(result => {288 if (result.locked) { showToast("Vault is locked — open Peach to unlock"); close(); return; }289 allEntries = result.entries;290 render();291 searchInput.focus();292 });293}294295function findTOTPField(root: Document | Element = document): HTMLInputElement | null {296 const inputs = root.querySelectorAll<HTMLInputElement>("input");297 for (const input of inputs) {298 if (isTOTPField(input)) return input;299 }300 return null;301}302303function findSameLoginTOTPField(passwordEl: HTMLInputElement): HTMLInputElement | null {304 const form = passwordEl.closest("form");305 return findTOTPField(form ?? document);306}307308309async function handleAutofill(310 _field: HTMLInputElement,311 usernameEl: HTMLInputElement | null,312 passwordEl: HTMLInputElement | null,313 entry: VaultEntry,314 emailEl?: HTMLInputElement | null315): Promise<HTMLInputElement | null> {316 if (!entry.username && !entry.password && !entry.email) return null;317 const pageDomain = getPageDomain();318 const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;319 320 321 322 const emailValue = entry.email ?? (entry.username?.includes("@") ? entry.username : "");323 fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain, emailEl, emailValue);324 let filledTOTPField: HTMLInputElement | null = null;325326 if (entry.canary) {327 chrome.runtime.sendMessage({328 type: "FIRE_CANARY_PING",329 payload: {330 webhookUrl: entry.canary.webhookUrl,331 entryTitle: entry.title,332 trigger: "autofilled",333 },334 }).catch(() => {});335 chrome.runtime.sendMessage({336 type: "UPDATE_ENTRY",337 payload: {338 id: entry.id,339 patch: {340 canary: {341 ...entry.canary,342 lastPinged: Date.now(),343 pingCount: (entry.canary.pingCount ?? 0) + 1,344 },345 },346 },347 }).catch(() => {});348 }349350 351 352 const totpCode = (entry as { totpCode?: string }).totpCode;353 if (totpCode) {354 const code = totpCode;355 const totpField = passwordEl ? findSameLoginTOTPField(passwordEl) : null;356 if (fillTOTPCode(totpField, code)) {357 filledTOTPField = totpField;358 }359 let alreadyWritten = false;360 try {361 await navigator.clipboard.writeText(code);362 alreadyWritten = true;363 } catch (err) {364 console.warn("[Peach] clipboard write denied:", err);365 }366 const response = await chrome.runtime.sendMessage({367 type: "COPY",368 payload: { text: code, label: "TOTP", alreadyWritten },369 });370 if (filledTOTPField) {371 showToast("2FA code filled");372 } else if (alreadyWritten || (response as { ok?: boolean }).ok) {373 374 375 376 showToast("2FA code copied");377 } else {378 showToast("Could not copy TOTP — clipboard blocked");379 }380 }381382 return filledTOTPField;383}384385async function fetchAutofillEntries(386 domain: string,387 includeAll = false,388 userGesture = false389): Promise<{ entries: SelectorEntry[]; locked: boolean }> {390 const response = await chrome.runtime.sendMessage({391 type: "AUTOFILL_QUERY",392 payload: { domain, includeAll, userGesture },393 });394 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };395 if (!res.ok && res.error === "vault_locked") {396 return { entries: [], locked: true };397 }398 return { entries: res.data ?? [], locked: false };399}400401async function fetchTOTPEntries(402 domain: string403): Promise<{ entries: SelectorEntry[]; locked: boolean }> {404 const response = await chrome.runtime.sendMessage({405 type: "AUTOFILL_QUERY",406 payload: { domain, kind: "totp" },407 });408 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };409 if (!res.ok && res.error === "vault_locked") {410 return { entries: [], locked: true };411 }412 return { entries: res.data ?? [], locked: false };413}414415416function resolveAutofillFieldType(417 el: HTMLInputElement,418 predictedType?: FieldPrediction["type"] | null419): "password" | "username" | "email" | null {420 if (421 predictedType === "password" ||422 predictedType === "username" ||423 predictedType === "email"424 ) {425 return predictedType;426 }427428 const detected = detectLoginFields().find((field) => field.el === el)?.type;429 if (detected && AUTOFILL_FIELD_TYPES.has(detected)) {430 return detected;431 }432433 const htmlType = el.type.toLowerCase();434 if (htmlType === "password") return "password";435 if (htmlType === "email") return "email";436 return null;437}438439async function openAutofillSelector(440 anchorField: HTMLInputElement,441 usernameEl: HTMLInputElement | null,442 passwordEl: HTMLInputElement | null,443 domainEntries: SelectorEntry[],444 loadSearchableEntries: () => Promise<SelectorEntry[]>,445 domain: string,446 identityKind: "email" | "username",447 emailEl?: HTMLInputElement | null448): Promise<void> {449 450 451 452 const isSignup = domainEntries.length === 0;453 let savedIdentities: SavedIdentity[] | undefined;454 let confirmEl: HTMLInputElement | null = null;455 if (isSignup) {456 try {457 const idResp = await chrome.runtime.sendMessage({ type: "GET_IDENTITIES" }).catch(() => null) as458 { ok: boolean; data?: SavedIdentity[] } | null;459 if (idResp?.ok && Array.isArray(idResp.data)) savedIdentities = idResp.data;460 } catch { }461462 const form = (passwordEl ?? usernameEl)?.closest("form") ?? document.body;463 const allPwInputs = Array.from(form.querySelectorAll<HTMLInputElement>("input[type='password']"));464 confirmEl = allPwInputs.find(465 (el) => el !== passwordEl && /confirm|repeat|verify/i.test(el.name + el.id + el.placeholder)466 ) ?? (allPwInputs.length >= 2 ? allPwInputs.find((el) => el !== passwordEl) ?? null : null);467 }468469 currentDismiss?.();470 currentAnchorField = anchorField;471 currentDismiss = showAutofillSelector(472 anchorField,473 domainEntries,474 async (id: string) => {475 currentDismiss = null;476 currentAnchorField = null;477 const entryResponse = await chrome.runtime.sendMessage({478 type: "GET_ENTRY_FOR_FILL",479 payload: { id },480 });481 const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;482 if (entry) {483 const filledTOTPField = await handleAutofill(anchorField, usernameEl, passwordEl, entry, emailEl);484 recordFillSuccess(anchorField, usernameEl, passwordEl, filledTOTPField);485 486 487 488 const pageDomain = location.hostname.replace(/^www\./, "");489 const entryDomains = new Set<string>();490 for (const u of [entry.url, ...(entry.additionalUrls ?? [])]) {491 try {492 if (u) entryDomains.add(new URL(u).hostname.replace(/^www\./, ""));493 } catch { }494 }495 if (pageDomain && entryDomains.size > 0 && !entryDomains.has(pageDomain)) {496 showRememberSiteToast(pageDomain, entry.id);497 }498 }499 },500 {501 domain,502 loadSearchableEntries,503 isSignup,504 identityKind,505 hasEmailField: !!emailEl,506 savedIdentities,507 onFillFields: (username, password, email) => {508 const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;509 fillFields(usernameEl, passwordEl, username, password, form, getPageDomain(), emailEl, email);510 if (password && confirmEl) setNativeValue(confirmEl, password);511 recordFillSuccess(anchorField, usernameEl, passwordEl);512 },513 onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },514 }515 );516}517518async function openTOTPSelector(anchorField: HTMLInputElement, otpDigitBoxes?: HTMLInputElement[]): Promise<void> {519 if (!canOpen()) return;520 if (currentDismiss && currentAnchorField === anchorField) return;521 if (anchorField !== lastDismissedField) lastDismissedField = null;522 if (anchorField === lastDismissedField) return;523 if (wasAlreadyFilled(anchorField)) return;524525 const domain = getPageDomain();526 const result = await fetchTOTPEntries(domain);527528 if (result.locked) {529 currentDismiss?.();530 currentAnchorField = anchorField;531 currentDismiss = showAutofillSelector(532 anchorField,533 [],534 () => {},535 {536 domain,537 locked: true,538 onUnlocked: () => void openTOTPSelector(anchorField),539 onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },540 }541 );542 return;543 }544545 if (result.entries.length === 0) {546 lastDismissedField = anchorField;547 startDismissLock();548 showToast("No 2FA entries for this site — add a code in Peach first");549 return;550 }551552 currentDismiss?.();553 currentAnchorField = anchorField;554 currentDismiss = showTOTPSelector(555 anchorField,556 result.entries,557 async (id: string) => {558 try {559 const entryResponse = await chrome.runtime.sendMessage({560 type: "GET_ENTRY_FOR_FILL",561 payload: { id },562 }).catch(() => null) as { ok: boolean; data?: VaultEntry } | null;563 const entry = entryResponse?.ok ? entryResponse.data : undefined;564 const code = (entry as { totpCode?: string } | undefined)?.totpCode;565 if (!code) return;566 if (otpDigitBoxes && otpDigitBoxes.length > 1) {567 fillOTPDigitBoxes(otpDigitBoxes, code);568 } else {569 setNativeValue(anchorField, code);570 }571 recordFillSuccess(anchorField);572 showToast("2FA code filled");573 } finally {574 currentDismiss = null;575 currentAnchorField = null;576 }577 },578 {579 domain,580 onDismiss: () => {581 lastDismissedField = anchorField;582 startDismissLock();583 currentDismiss = null;584 currentAnchorField = null;585 },586 }587 );588}589590591export async function handleFieldInteraction(592 anchorField?: HTMLInputElement | null,593 explicitSummon = false594): Promise<void> {595 const field = anchorField ?? getDeepActiveInput();596 if (!field) return;597 if (!isVisible(field)) return;598599 600 if (passkeyToastActive) return;601602 603 604 if (!canOpen()) return;605606 607 if (currentDismiss && currentAnchorField === field) return;608609 610 611 if (field !== lastDismissedField) lastDismissedField = null;612 613 614 if (field === lastDismissedField) return;615616 617 618 619 620 if (wasAlreadyFilled(field)) return;621622 623 624 if (pendingInteractions.has(field)) return;625 pendingInteractions.add(field);626627 try {628 629 630 const otpGroup = findOTPDigitBoxGroup(field);631 if (otpGroup) {632 await openTOTPSelector(otpGroup[0]!, otpGroup);633 return;634 }635636 if (isTOTPField(field)) {637 const formFields = detectLoginFields();638 if (!isLoginForm(formFields) && !isSignupForm(formFields)) {639 await openTOTPSelector(field);640 return;641 }642 }643644 const fieldType = field.type.toLowerCase();645646 const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {647 return preds[0] ?? null;648 });649650 const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {651 const timeout = setTimeout(() => resolve(null), 150);652 mlPromise.then((pred) => {653 clearTimeout(timeout);654 resolve(pred);655 });656 });657658 if (passkeyToastActive) return;659660 if (!explicitSummon && fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {661 return;662 }663664 const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);665 if (autofillFieldType) {666 reattachListeners(field, autofillFieldType);667 }668669 let fields = detectLoginFields();670 const targets = resolveAutofillInteractionTargets(field, fields, explicitSummon);671 if (!targets.shouldOpen) {672 return;673 }674675 const domain = getPageDomain();676 const firstResult = await fetchAutofillEntries(domain);677 if (passkeyToastActive) return;678679 680 681 682 if (firstResult.locked) {683 currentDismiss?.();684 currentAnchorField = field;685 currentDismiss = showAutofillSelector(686 field,687 [],688 () => {},689 { domain, locked: true, onDismiss: () => { lastDismissedField = field; startDismissLock(); } }690 );691 return;692 }693694 const entries = firstResult.entries;695 696 697 698 699 700 701 const loadSearchableEntries = async (): Promise<SelectorEntry[]> =>702 (await fetchAutofillEntries(domain, true, true)).entries;703704 if (passkeyToastActive) return;705706 const pwField = fields.find((f) => f.type === "password");707 708 if (!explicitSummon && !pwField && !isEmailFirstForm(fields)) return;709 const usernameEl = targets.usernameEl;710 const passwordEl = targets.passwordEl;711 const emailEl = targets.emailEl;712713 await openAutofillSelector(714 field,715 usernameEl,716 passwordEl,717 entries,718 loadSearchableEntries,719 domain,720 autofillFieldType === "email" ? "email" : "username",721 emailEl,722 );723 } finally {724 pendingInteractions.delete(field);725 }726}727728async function onFocusIn(e: Event): Promise<void> {729 730 731 732 733 734 if (isSubmitButton(e.target)) return;735 await handleFieldInteraction();736}737738function reattachListeners(739 el: HTMLInputElement,740 detectedType?: "password" | "username" | "email",741 contextFields?: ReturnType<typeof detectLoginFields>742): void {743 if (processedInputs.has(el)) return;744 if (!resolveAutofillFieldType(el, detectedType)) return;745 746 const fields = contextFields ?? detectLoginFields();747 if (!isAnyCredentialForm(fields)) return;748749 const cleanup = injectFieldIcon(el, () => {750 currentDismiss?.();751 currentDismiss = null;752 lastDismissedField = null; 753 dismissLock = false; 754 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }755 clearFillStateForExplicitSummon(el);756 el.focus({ preventScroll: true });757 void handleFieldInteraction(el, true);758 });759 processedInputs.set(el, cleanup);760}761762const debouncedReattach = debounce(() => {763 const allFields = detectLoginFields();764 if (!isAnyCredentialForm(allFields)) return;765 const newFields = allFields.filter(766 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)767 );768 for (const f of newFields) {769 reattachListeners(f.el, f.type, allFields);770 }771 772 773 774}, 200);775776function setupMutationObserver(): void {777 const observer = new MutationObserver(debouncedReattach);778 observer.observe(document.body, {779 childList: true,780 subtree: true,781 attributes: true,782 attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],783 });784}785786export function initialScan(aggressionLevel = 0, isFinalPass = false) {787 const allFields = detectLoginFields();788 const hasKnownForm = isAnyCredentialForm(allFields);789790 if (aggressionLevel === 0 && !hasKnownForm) return;791792 const newFields = allFields.filter(793 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)794 );795796 for (const f of newFields) {797 reattachListeners(f.el, f.type, allFields);798 }799800 801 802 if (aggressionLevel >= 2 && isFinalPass && newFields.length === 0) {803 const pwInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');804 for (const el of pwInputs) {805 if (isVisible(el) && !processedInputs.has(el)) {806 const cleanup = injectFieldIcon(el, () => {807 currentDismiss?.();808 currentDismiss = null;809 lastDismissedField = null;810 dismissLock = false;811 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }812 clearFillStateForExplicitSummon(el);813 el.focus({ preventScroll: true });814 void handleFieldInteraction(el, true);815 });816 processedInputs.set(el, cleanup);817 }818 }819 }820}821822823824export const _testSeam = {825 getLastDismissedField(): HTMLInputElement | null { return lastDismissedField; },826 setLastDismissedField(f: HTMLInputElement | null): void { lastDismissedField = f; },827 isDismissLocked(): boolean { return dismissLock; },828 DISMISS_LOCK_MS,829} as const;830831832833834835const processedTOTPInputs = new WeakMap<HTMLInputElement, () => void>();836837function hasLoginOrSignupForm(): boolean {838 const fields = detectLoginFields();839 return isAnyCredentialForm(fields);840}841842function injectTOTPFieldIcon(field: HTMLInputElement): void {843 if (processedTOTPInputs.has(field)) return;844 const cleanup = injectFieldIcon(field, () => {845 currentDismiss?.();846 currentDismiss = null;847 currentAnchorField = null;848 lastDismissedField = null;849 dismissLock = false;850 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }851 clearFillStateForExplicitSummon(field);852 field.focus({ preventScroll: true });853 void openTOTPSelector(field);854 });855 processedTOTPInputs.set(field, cleanup);856}857858function scanTOTPFields(): void {859 if (hasLoginOrSignupForm()) return;860 const inputs = document.querySelectorAll<HTMLInputElement>("input");861 for (const input of inputs) {862 if (!isTOTPField(input)) continue;863 injectTOTPFieldIcon(input);864 }865}866867function setupTOTPAutofill(): void {868 setTimeout(scanTOTPFields, 0);869 setTimeout(scanTOTPFields, 500);870 setTimeout(scanTOTPFields, 1500);871872 const debouncedScan = debounce(scanTOTPFields, 200);873 new MutationObserver(debouncedScan).observe(document.body, {874 childList: true,875 subtree: true,876 attributes: true,877 attributeFilter: ["autocomplete", "name", "id", "placeholder", "hidden"],878 });879880 document.addEventListener("focusin", (e) => {881 const target = e.target;882 if (!(target instanceof HTMLInputElement)) return;883 if (hasLoginOrSignupForm()) return;884 if (!isTOTPField(target)) return;885 injectTOTPFieldIcon(target);886 }, true);887}888889document.addEventListener("focusin", onFocusIn);890891setupMutationObserver();892setupTOTPAutofill();893setupSaveDetection();894setupCardAutofill();895896897898899900901902903let lastObservedUrl = location.href;904new MutationObserver(() => {905 if (location.href !== lastObservedUrl) {906 lastObservedUrl = location.href;907 pageFilledAt = null;908 }909}).observe(document.body, { childList: true, subtree: false });910911912913(async () => {914 let aggressionLevel = 0;915 try {916 const resp = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });917 const s = (resp as { ok: boolean; data?: { manualSummonDomains?: Record<string, { summonCount: number }> } }).data;918 const domain = getPageDomain();919 const record = domain ? s?.manualSummonDomains?.[domain] : undefined;920 aggressionLevel = !record ? 0 : record.summonCount >= 2 ? 2 : 1;921 } catch { }922923 setTimeout(() => initialScan(aggressionLevel, false), 0);924 setTimeout(() => initialScan(aggressionLevel, false), 500);925 setTimeout(() => initialScan(aggressionLevel, true), 1500);926})();927928929930931932933const processedCardInputs = new WeakMap<HTMLInputElement, () => void>();934let currentCardDismiss: (() => void) | null = null;935let currentCardAnchor: HTMLInputElement | null = null;936let lastDismissedCardField: HTMLInputElement | null = null;937938async function fetchSavedCards(): Promise<CardSelectorEntry[]> {939 const resp = await chrome.runtime.sendMessage({940 type: "AUTOFILL_QUERY",941 payload: { domain: getPageDomain(), kind: "card" },942 }).catch(() => null);943 const r = resp as { ok: boolean; data?: CardSelectorEntry[] } | null;944 return r?.ok ? (r.data ?? []) : [];945}946947async function openCardSelector(anchor: HTMLInputElement): Promise<void> {948 if (!canOpen()) return;949 if (currentCardDismiss && currentCardAnchor === anchor) return;950 if (anchor === lastDismissedCardField) return;951 if (anchor !== lastDismissedCardField) lastDismissedCardField = null;952 if (wasAlreadyFilled(anchor)) return;953954 const cards = await fetchSavedCards();955 if (cards.length === 0) return; 956957 const cardForm = detectCardForm();958 if (!cardForm?.number) return;959960 currentCardDismiss?.();961 currentCardAnchor = anchor;962 currentCardDismiss = showCardSelector(963 anchor,964 cards,965 {966 domain: getPageDomain(),967 onFill: (entry) => {968 if (!cardForm) return;969 fillCardForm(cardForm, {970 cardNumber: entry.cardNumber ?? "",971 expMonth: entry.expMonth ?? "",972 expYear: entry.expYear ?? "",973 cvc: entry.cvc ?? "",974 cardholderName: entry.cardholderName ?? "",975 });976 977 978 979 recordFillSuccess(cardForm.number, cardForm.cvc, cardForm.expiryCombo, cardForm.name);980 currentCardDismiss = null;981 currentCardAnchor = null;982 },983 onDismiss: () => {984 lastDismissedCardField = anchor;985 startDismissLock();986 currentCardDismiss = null;987 currentCardAnchor = null;988 },989 }990 );991}992993function injectCardIcon(field: HTMLInputElement): void {994 if (processedCardInputs.has(field)) return;995 const cleanup = injectFieldIcon(field, () => {996 currentCardDismiss?.();997 currentCardDismiss = null;998 lastDismissedCardField = null;999 dismissLock = false;1000 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1001 clearFillStateForExplicitSummon(field);1002 field.focus({ preventScroll: true });1003 void openCardSelector(field);1004 });1005 processedCardInputs.set(field, cleanup);1006}10071008function setupCardAutofill(): void {1009 function scan() {1010 const cardForm = detectCardForm();1011 if (!cardForm?.number) return;1012 if (!isVisible(cardForm.number)) return;1013 injectCardIcon(cardForm.number);1014 }10151016 1017 1018 setTimeout(scan, 0);1019 setTimeout(scan, 500);1020 setTimeout(scan, 1500);10211022 1023 1024 1025 let pending = false;1026 new MutationObserver(() => {1027 if (pending) return;1028 pending = true;1029 setTimeout(() => { pending = false; scan(); }, 200);1030 }).observe(document.body, { childList: true, subtree: true });10311032 1033 1034 1035 document.addEventListener("focusin", (e) => {1036 const target = e.target;1037 if (!(target instanceof HTMLInputElement)) return;1038 const cardForm = detectCardForm();1039 if (cardForm?.number === target) {1040 injectCardIcon(target);1041 }1042 }, true);1043}10441045chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {1046 1047 if (sender.id !== chrome.runtime.id) return;1048 if (msg?.type === "GET_SITE_PASSWORD_POLICY") {1049 const policy = detectPasswordPolicy();1050 sendResponse({ ok: true, data: policy });1051 return true;1052 }1053 if (msg?.type === "PING") {1054 sendResponse({ ok: true });1055 return true;1056 }1057 if (msg?.type === "SUMMON_AUTOFILL") {1058 const source = (msg as { type: string; source?: string }).source;1059 const isShortcut = source === "shortcut";1060 const isPasskeyUnlock = source === "passkey-unlock";10611062 const activeInput = getDeepActiveInput();1063 const fields = detectLoginFields();1064 const hasForm = isAnyCredentialForm(fields);10651066 if (hasForm) {1067 1068 1069 const inForm = activeInput && fields.some(f => f.el === activeInput);1070 const target = inForm1071 ? activeInput1072 : (fields.find(f => f.type === "password")?.el1073 ?? fields.find(f => f.type === "username" || f.type === "email")?.el);1074 if (target) {1075 1076 1077 clearFillStateForExplicitSummon(target);1078 dismissLock = false;1079 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1080 lastDismissedField = null;1081 void handleFieldInteraction(target);1082 }1083 } else if (isPasskeyUnlock) {1084 1085 1086 1087 const anchor = (document.activeElement instanceof HTMLElement && document.activeElement !== document.body)1088 ? document.activeElement as unknown as HTMLInputElement1089 : null;1090 if (anchor) {1091 currentDismiss?.();1092 currentAnchorField = null;1093 passkeySummonActive = true;1094 currentDismiss = showAutofillSelector(anchor, [], () => {}, {1095 domain: getPageDomain(),1096 locked: true,1097 onDismiss: () => { passkeySummonActive = false; },1098 });1099 }1100 } else if (isShortcut) {1101 1102 showSearchPalette();1103 }11041105 1106 if (isShortcut) {1107 const domain = getPageDomain();1108 if (domain) {1109 chrome.runtime.sendMessage({ type: "RECORD_MANUAL_SUMMON", payload: { domain } }).catch(() => {});1110 }1111 }11121113 sendResponse({ ok: hasForm || isShortcut || isPasskeyUnlock });1114 return true;1115 }1116 if (msg?.type === "PASSKEY_PRO_GATE") {1117 1118 1119 showPasskeyProGate();1120 sendResponse({ ok: true });1121 return true;1122 }1123 if (msg?.type === "SHOW_PASSKEY_TOAST") {1124 1125 passkeyToastActive = true;1126 if (currentDismiss) {1127 currentDismiss();1128 currentDismiss = null;1129 currentAnchorField = null;1130 }1131 return false; 1132 }1133 if (msg?.type === "DISMISS_PASSKEY_TOAST") {1134 passkeyToastActive = false;1135 return false; 1136 }1137 if (msg?.type === "VAULT_UNLOCKED") {1138 if (passkeySummonActive) {1139 1140 1141 passkeySummonActive = false;1142 if (currentDismiss) {1143 currentDismiss();1144 currentDismiss = null;1145 currentAnchorField = null;1146 }1147 sendResponse({ ok: true });1148 return true;1149 }1150 1151 1152 const savedField = currentAnchorField;1153 if (currentDismiss) {1154 currentDismiss();1155 currentDismiss = null;1156 currentAnchorField = null;1157 }1158 void handleFieldInteraction(savedField ?? undefined);1159 sendResponse({ ok: true });1160 return true;1161 }1162 return true;1163});1164116511661167window.addEventListener("message", (e: MessageEvent) => {1168 1169 1170 if (e.source !== window) return;1171 const d = e.data as { source?: string; type?: string } | undefined;1172 if (d?.source === "peach-passkey-toast" && d?.type === "toast-closed") {1173 passkeyToastActive = false;1174 }1175});1176117711781179118011811182118311841185118611871188if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1189 let lastPasskeyOfferAt = 0;1190 const PASSKEY_OFFER_COOLDOWN_MS = 30_000;11911192 window.addEventListener("message", (event: MessageEvent) => {1193 if (event.source !== window) return;1194 const data = event.data as { source?: string; type?: string; rpId?: string; rpName?: string; userName?: string; userDisplayName?: string } | undefined;1195 if (!data || data.source !== "peach-passkey-firefox") return;1196 if (detectBrowser() !== "firefox") return;1197 if (data.type !== "create") return;11981199 1200 1201 const now = Date.now();1202 if (now - lastPasskeyOfferAt < PASSKEY_OFFER_COOLDOWN_MS) return;1203 lastPasskeyOfferAt = now;12041205 const domain = location.hostname.replace(/^www\./, "");1206 chrome.runtime.sendMessage({1207 type: "GET_PASSKEY_OFFER_DECLINED",1208 payload: { domain },1209 }).then((res: { ok: boolean; data?: boolean } | undefined) => {1210 if (res?.data === true) return;1211 const rpId = data.rpId ?? domain;1212 showPasskeyRegistrationOffer({1213 rpId,1214 rpName: data.rpName,1215 userName: data.userName,1216 userDisplayName: data.userDisplayName,1217 });1218 }).catch(() => {});1219 });1220}122112221223122412251226122712281229123012311232123312341235{1236 function checkPasskeyAvailability() {1237 const domain = location.hostname.replace(/^www\./, "");1238 if (!domain) return;1239 const sessionKey = `peach_state11_shown:${domain}`;1240 chrome.storage.session?.get(sessionKey).then((stored) => {1241 if (stored && (stored as Record<string, unknown>)[sessionKey]) return;1242 chrome.runtime.sendMessage({1243 type: "GET_PASSKEY_FOR_DOMAIN",1244 payload: { domain },1245 }).then((res: { ok: boolean; data?: { rpId: string; userName: string; browser?: "chrome" | "firefox" } | null } | undefined) => {1246 const data = res?.data;1247 if (!data) return;1248 const current = detectBrowser();1249 const isCrossBrowser =1250 data.browser && current !== "unknown" && data.browser !== current;1251 const valuableOnFirefox = current === "firefox";1252 if (!isCrossBrowser && !valuableOnFirefox) return;1253 chrome.storage.session?.set({ [sessionKey]: Date.now() }).catch(() => {});1254 showPasskeyAvailable({1255 rpId: data.rpId,1256 userName: data.userName,1257 fromBrowser: data.browser,1258 currentBrowser: current,1259 });1260 }).catch(() => {});1261 }).catch(() => {});1262 }12631264 1265 1266 1267 if (document.readyState === "loading") {1268 document.addEventListener("DOMContentLoaded", () => setTimeout(checkPasskeyAvailability, 800));1269 } else {1270 setTimeout(checkPasskeyAvailability, 800);1271 }1272}