1import {2 detectCardForm,3 detectLoginFields,4 getDeepActiveInput,5 getPageDomain,6 isLoginForm,7 isSignupForm,8 isTOTPField,9 isVisible,10} from "./detect";11import {12 CardSelectorEntry,13 injectFieldIcon,14 SelectorEntry,15 showAutofillSelector,16 showCardSelector,17 showPasskeyAvailable,18 showPasskeyProGate,19 showPasskeyRegistrationOffer,20 showRememberSiteToast,21 showTOTPSelector,22} from "./inject";23import { detectBrowser } from "../shared/browser";24import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";25import { fillCardForm, fillFields, setNativeValue } from "./fill";26import { setupSaveDetection } from "./save";27import { contentMLDetector } from "./content-ml-detector";28import { detectPasswordPolicy } from "./policy";29import type { FieldPrediction } from "../shared/ml";30import { generateTOTP } from "../crypto/totp";31import type { SavedIdentity, VaultEntry } from "../shared/types";323334353637const processedInputs = new WeakMap<HTMLInputElement, () => void>();38const AUTOFILL_FIELD_TYPES = new Set(["password", "username", "email"]);3940let currentDismiss: (() => void) | null = null;4142let currentAnchorField: HTMLInputElement | null = null;4344const pendingInteractions = new WeakSet<HTMLInputElement>();45464748let lastDismissedField: HTMLInputElement | null = null;49505152535455const DISMISS_LOCK_MS = 800;56let dismissLock = false;57let dismissLockTimer: ReturnType<typeof setTimeout> | null = null;58596061let passkeySummonActive = false;62636465let passkeyToastActive = false;66function startDismissLock(): void {67 dismissLock = true;68 if (dismissLockTimer) clearTimeout(dismissLockTimer);69 dismissLockTimer = setTimeout(() => { dismissLock = false; }, DISMISS_LOCK_MS);70}71function canOpen(): boolean { return !dismissLock; }7273747576777879808182838485const filledForms = new WeakSet<HTMLFormElement>();86let pageFilledAt: number | null = null;87const PAGE_FILL_GRACE_MS = 3000;8889function recordFillSuccess(...fields: (HTMLInputElement | null | undefined)[]): void {90 for (const f of fields) {91 if (!f) continue;92 const form = f.closest("form");93 if (form) filledForms.add(form);94 }95 pageFilledAt = Date.now();96}9798function wasAlreadyFilled(field: HTMLInputElement): boolean {99 const form = field.closest("form");100 if (form && filledForms.has(form)) return true;101 if (pageFilledAt !== null && Date.now() - pageFilledAt < PAGE_FILL_GRACE_MS) return true;102 return false;103}104105function clearFillStateForExplicitSummon(field: HTMLInputElement): void {106 const form = field.closest("form");107 if (form) filledForms.delete(form);108 pageFilledAt = null;109}110111112113114115function isSubmitButton(el: EventTarget | null): boolean {116 if (!(el instanceof HTMLElement)) return false;117 if (el instanceof HTMLButtonElement && el.type !== "button") return true;118 if (el instanceof HTMLInputElement && el.type === "submit") return true;119 if (el.getAttribute("type") === "submit") return true;120 const text = (el.textContent?.trim() ?? el.getAttribute("value") ?? "").toLowerCase();121 if (!text || text.length > 24) return false;122 return /^(submit|sign[ -]?in|log[ -]?in|continue|next|go)$/i.test(text);123}124125function debounce<T extends (...args: unknown[]) => void>(126 fn: T,127 ms: number128): (...args: Parameters<T>) => void {129 let timer: ReturnType<typeof setTimeout>;130 return (...args: Parameters<T>) => {131 clearTimeout(timer);132 timer = setTimeout(() => fn(...args), ms);133 };134}135136function showToast(message: string): void {137 const toast = document.createElement("div");138 toast.style.cssText = `139 position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);140 background: #131109; color: #F0E8D8; padding: 8px 16px;141 border-radius: 8px; font-family: 'IBM Plex Mono', monospace;142 font-size: 11px; z-index: 2147483647; opacity: 0;143 transition: opacity 200ms ease;144 border: 1px solid rgba(240,232,216,0.07);145 pointer-events: none;146 `;147 toast.textContent = message;148 document.body.appendChild(toast);149 requestAnimationFrame(() => { toast.style.opacity = "1"; });150 setTimeout(() => {151 toast.style.opacity = "0";152 setTimeout(() => toast.remove(), 200);153 }, 2000);154}155156function showSearchPalette(): void {157 const host = document.createElement("div");158 host.style.cssText = "position:fixed;top:80px;left:50%;transform:translateX(-50%);z-index:2147483647;width:340px;";159 document.body.appendChild(host);160 const shadow = host.attachShadow({ mode: "closed" });161162 const style = document.createElement("style");163 style.textContent = `164 .p{background:#131109;border:1px solid rgba(240,232,216,.12);border-radius:12px;165 font-family:'IBM Plex Mono',monospace;font-size:12px;color:#F0E8D8;166 box-shadow:0 8px 24px rgba(0,0,0,.5);overflow:hidden}167 .h{padding:10px 12px 8px;border-bottom:1px solid rgba(240,232,216,.08);168 font-size:11px;color:rgba(240,232,216,.5);display:flex;justify-content:space-between;align-items:center}169 .x{background:none;border:none;color:rgba(240,232,216,.4);cursor:pointer;font-size:14px;padding:0;line-height:1}170 .s{padding:8px 12px}171 .s input{width:100%;box-sizing:border-box;background:rgba(240,232,216,.06);172 border:1px solid rgba(240,232,216,.12);border-radius:6px;color:#F0E8D8;173 padding:6px 8px;font-family:inherit;font-size:12px;outline:none}174 .l{max-height:220px;overflow-y:auto}175 .e{padding:8px 12px;cursor:pointer;display:flex;flex-direction:column;gap:2px}176 .e:hover{background:rgba(240,232,216,.06)}177 .et{font-size:12px}178 .eu{font-size:10px;color:rgba(240,232,216,.45)}179 .em{padding:12px;color:rgba(240,232,216,.4);text-align:center}180 `;181182 let allEntries: SelectorEntry[] = [];183 let query = "";184185 const panel = document.createElement("div");186 panel.className = "p";187188 const hdr = document.createElement("div");189 hdr.className = "h";190 const hdrText = document.createElement("span");191 hdrText.textContent = "No form detected — search your vault";192 const closeBtn = document.createElement("button");193 closeBtn.className = "x";194 closeBtn.textContent = "✕";195 hdr.appendChild(hdrText);196 hdr.appendChild(closeBtn);197198 const searchWrap = document.createElement("div");199 searchWrap.className = "s";200 const searchInput = document.createElement("input");201 searchInput.placeholder = "search...";202 searchWrap.appendChild(searchInput);203204 const list = document.createElement("div");205 list.className = "l";206207 panel.appendChild(hdr);208 panel.appendChild(searchWrap);209 panel.appendChild(list);210 shadow.appendChild(style);211 shadow.appendChild(panel);212213 function close() { host.remove(); }214215 closeBtn.onclick = close;216217 function onKeyDown(e: KeyboardEvent) {218 if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKeyDown, true); }219 }220 document.addEventListener("keydown", onKeyDown, true);221222 function render() {223 const q = query.toLowerCase();224 const filtered = q225 ? allEntries.filter(e => e.title.toLowerCase().includes(q) || (e.username ?? "").toLowerCase().includes(q))226 : allEntries;227 list.innerHTML = "";228 if (filtered.length === 0) {229 const em = document.createElement("div");230 em.className = "em";231 em.textContent = query ? "no matches" : "vault is empty";232 list.appendChild(em);233 return;234 }235 for (const entry of filtered.slice(0, 20)) {236 const row = document.createElement("div");237 row.className = "e";238 const title = document.createElement("div");239 title.className = "et";240 title.textContent = entry.title;241 row.appendChild(title);242 if (entry.username) {243 const user = document.createElement("div");244 user.className = "eu";245 user.textContent = entry.username;246 row.appendChild(user);247 }248 row.onclick = async () => {249 const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } });250 const r = resp as { ok: boolean; data?: { password?: string } };251 if (r.ok && r.data?.password) {252 let alreadyWritten = false;253 try {254 await navigator.clipboard.writeText(r.data.password);255 alreadyWritten = true;256 } catch (err) {257 console.warn("[Peach] clipboard write denied:", err);258 }259 const copyResp = await chrome.runtime.sendMessage({260 type: "COPY",261 payload: { text: r.data.password, label: "Password", alreadyWritten },262 }) as { ok?: boolean } | undefined;263 if (alreadyWritten || copyResp?.ok) {264 showToast("Password copied");265 } else {266 showToast("Could not copy password — clipboard blocked");267 }268 } else {269 showToast("Could not copy password");270 }271 close();272 document.removeEventListener("keydown", onKeyDown, true);273 };274 list.appendChild(row);275 }276 }277278 searchInput.oninput = () => { query = searchInput.value; render(); };279280 fetchAutofillEntries(getPageDomain(), true).then(result => {281 if (result.locked) { showToast("Vault is locked — open Peach to unlock"); close(); return; }282 allEntries = result.entries;283 render();284 searchInput.focus();285 });286}287288function findTOTPField(): HTMLInputElement | null {289 const inputs = document.querySelectorAll<HTMLInputElement>("input");290 for (const input of inputs) {291 if (isTOTPField(input)) return input;292 }293 return null;294}295296async function autofillTOTP(totpField: HTMLInputElement, totpSecret: string): Promise<void> {297 await new Promise((r) => setTimeout(r, 400));298 if (totpField.value === "" || totpField.value.trim() === "") {299 const { code } = generateTOTP(totpSecret);300 setNativeValue(totpField, code);301 showToast("TOTP autofilled");302 }303}304305306async function handleAutofill(307 _field: HTMLInputElement,308 usernameEl: HTMLInputElement | null,309 passwordEl: HTMLInputElement,310 entry: VaultEntry311): Promise<void> {312 if (!entry.username && !entry.password) return;313 const pageDomain = getPageDomain();314 const form = passwordEl.closest("form") as HTMLFormElement | null;315 fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain);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 if (entry.totp) {342 const { code } = generateTOTP(entry.totp);343 let alreadyWritten = false;344 try {345 await navigator.clipboard.writeText(code);346 alreadyWritten = true;347 } catch (err) {348 console.warn("[Peach] clipboard write denied:", err);349 }350 const response = await chrome.runtime.sendMessage({351 type: "COPY",352 payload: { text: code, label: "TOTP", alreadyWritten },353 });354 if (alreadyWritten || (response as { ok?: boolean }).ok) {355 showToast("TOTP copied · " + code);356 } else {357 showToast("Could not copy TOTP — clipboard blocked");358 }359 const totpField = findTOTPField();360 if (totpField) {361 autofillTOTP(totpField, entry.totp);362 }363 }364}365366async function fetchAutofillEntries(367 domain: string,368 includeAll = false369): Promise<{ entries: SelectorEntry[]; locked: boolean }> {370 const response = await chrome.runtime.sendMessage({371 type: "AUTOFILL_QUERY",372 payload: { domain, includeAll },373 });374 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };375 if (!res.ok && res.error === "vault_locked") {376 return { entries: [], locked: true };377 }378 return { entries: res.data ?? [], locked: false };379}380381async function fetchTOTPEntries(382 domain: string383): Promise<{ entries: SelectorEntry[]; locked: boolean }> {384 const response = await chrome.runtime.sendMessage({385 type: "AUTOFILL_QUERY",386 payload: { domain, kind: "totp" },387 });388 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };389 if (!res.ok && res.error === "vault_locked") {390 return { entries: [], locked: true };391 }392 return { entries: res.data ?? [], locked: false };393}394395396function resolveAutofillFieldType(397 el: HTMLInputElement,398 predictedType?: FieldPrediction["type"] | null399): "password" | "username" | "email" | null {400 if (401 predictedType === "password" ||402 predictedType === "username" ||403 predictedType === "email"404 ) {405 return predictedType;406 }407408 const detected = detectLoginFields().find((field) => field.el === el)?.type;409 if (detected && AUTOFILL_FIELD_TYPES.has(detected)) {410 return detected;411 }412413 const htmlType = el.type.toLowerCase();414 if (htmlType === "password") return "password";415 if (htmlType === "email") return "email";416 return null;417}418419async function openAutofillSelector(420 anchorField: HTMLInputElement,421 usernameEl: HTMLInputElement | null,422 passwordEl: HTMLInputElement,423 domainEntries: SelectorEntry[],424 searchableEntries: SelectorEntry[],425 domain: string426): Promise<void> {427 428 429 430 const isSignup = domainEntries.length === 0;431 let savedIdentities: SavedIdentity[] | undefined;432 let confirmEl: HTMLInputElement | null = null;433 if (isSignup) {434 try {435 const idResp = await chrome.runtime.sendMessage({ type: "GET_IDENTITIES" }).catch(() => null) as436 { ok: boolean; data?: SavedIdentity[] } | null;437 if (idResp?.ok && Array.isArray(idResp.data)) savedIdentities = idResp.data;438 } catch { }439440 const form = passwordEl.closest("form") ?? document.body;441 const allPwInputs = Array.from(form.querySelectorAll<HTMLInputElement>("input[type='password']"));442 confirmEl = allPwInputs.find(443 (el) => el !== passwordEl && /confirm|repeat|verify/i.test(el.name + el.id + el.placeholder)444 ) ?? (allPwInputs.length >= 2 ? allPwInputs.find((el) => el !== passwordEl) ?? null : null);445 }446447 currentDismiss?.();448 currentAnchorField = anchorField;449 currentDismiss = showAutofillSelector(450 anchorField,451 domainEntries,452 async (id: string) => {453 currentDismiss = null;454 currentAnchorField = null;455 const entryResponse = await chrome.runtime.sendMessage({456 type: "GET_ENTRY_FOR_FILL",457 payload: { id },458 });459 const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;460 if (entry) {461 await handleAutofill(anchorField, usernameEl, passwordEl, entry);462 recordFillSuccess(anchorField, usernameEl, passwordEl);463 464 465 466 const pageDomain = location.hostname.replace(/^www\./, "");467 const entryDomains = new Set<string>();468 for (const u of [entry.url, ...(entry.additionalUrls ?? [])]) {469 try {470 if (u) entryDomains.add(new URL(u).hostname.replace(/^www\./, ""));471 } catch { }472 }473 if (pageDomain && entryDomains.size > 0 && !entryDomains.has(pageDomain)) {474 showRememberSiteToast(pageDomain, entry.id);475 }476 }477 },478 {479 domain,480 searchableEntries,481 isSignup,482 savedIdentities,483 onFillFields: (username, password) => {484 const form = passwordEl.closest("form") as HTMLFormElement | null;485 fillFields(usernameEl, passwordEl, username, password, form, getPageDomain());486 if (password && confirmEl) setNativeValue(confirmEl, password);487 recordFillSuccess(anchorField, usernameEl, passwordEl);488 },489 onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },490 }491 );492}493494async function openTOTPSelector(anchorField: HTMLInputElement): Promise<void> {495 if (!canOpen()) return;496 if (currentDismiss && currentAnchorField === anchorField) return;497 if (anchorField !== lastDismissedField) lastDismissedField = null;498 if (anchorField === lastDismissedField) return;499 if (wasAlreadyFilled(anchorField)) return;500501 const domain = getPageDomain();502 const result = await fetchTOTPEntries(domain);503504 if (result.locked) {505 currentDismiss?.();506 currentAnchorField = anchorField;507 currentDismiss = showAutofillSelector(508 anchorField,509 [],510 () => {},511 {512 domain,513 locked: true,514 onUnlocked: () => void openTOTPSelector(anchorField),515 onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },516 }517 );518 return;519 }520521 if (result.entries.length === 0) return;522523 currentDismiss?.();524 currentAnchorField = anchorField;525 currentDismiss = showTOTPSelector(526 anchorField,527 result.entries,528 async (id: string) => {529 try {530 const entryResponse = await chrome.runtime.sendMessage({531 type: "GET_ENTRY_FOR_FILL",532 payload: { id },533 }).catch(() => null) as { ok: boolean; data?: VaultEntry } | null;534 const entry = entryResponse?.ok ? entryResponse.data : undefined;535 if (!entry?.totp) return;536 const { code } = generateTOTP(entry.totp);537 setNativeValue(anchorField, code);538 recordFillSuccess(anchorField);539 showToast("2FA code filled");540 } finally {541 currentDismiss = null;542 currentAnchorField = null;543 }544 },545 {546 domain,547 onDismiss: () => {548 lastDismissedField = anchorField;549 startDismissLock();550 currentDismiss = null;551 currentAnchorField = null;552 },553 }554 );555}556557558async function handleFieldInteraction(anchorField?: HTMLInputElement | null): Promise<void> {559 const field = anchorField ?? getDeepActiveInput();560 if (!field) return;561 if (!isVisible(field)) return;562563 564 if (passkeyToastActive) return;565566 567 568 if (!canOpen()) return;569570 571 if (currentDismiss && currentAnchorField === field) return;572573 574 575 if (field !== lastDismissedField) lastDismissedField = null;576 577 578 if (field === lastDismissedField) return;579580 581 582 583 584 if (wasAlreadyFilled(field)) return;585586 587 588 if (pendingInteractions.has(field)) return;589 pendingInteractions.add(field);590591 try {592 if (isTOTPField(field)) {593 const formFields = detectLoginFields();594 if (!isLoginForm(formFields) && !isSignupForm(formFields)) {595 await openTOTPSelector(field);596 return;597 }598 }599600 const fieldType = field.type.toLowerCase();601602 const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {603 return preds[0] ?? null;604 });605606 const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {607 const timeout = setTimeout(() => resolve(null), 150);608 mlPromise.then((pred) => {609 clearTimeout(timeout);610 resolve(pred);611 });612 });613614 if (passkeyToastActive) return;615616 if (fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {617 return;618 }619620 const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);621 if (autofillFieldType) {622 reattachListeners(field, autofillFieldType);623 }624625 let fields = detectLoginFields();626 const loginForm = isLoginForm(fields);627 const signupForm = isSignupForm(fields);628 if (!loginForm && !signupForm) {629 return;630 }631632 const domain = getPageDomain();633 const firstResult = await fetchAutofillEntries(domain);634 if (passkeyToastActive) return;635636 637 638 639 if (firstResult.locked) {640 currentDismiss?.();641 currentAnchorField = field;642 currentDismiss = showAutofillSelector(643 field,644 [],645 () => {},646 { domain, locked: true, onDismiss: () => { lastDismissedField = field; startDismissLock(); } }647 );648 return;649 }650651 const entries = firstResult.entries;652 const allEntries = entries.length === 0653 ? (await fetchAutofillEntries(domain, true)).entries654 : entries;655656 if (passkeyToastActive) return;657658 let usernameEl: HTMLInputElement | null = null;659 const userField = fields.find((f) => f.type === "username" || f.type === "email");660 if (userField) usernameEl = userField.el;661 const pwField = fields.find((f) => f.type === "password");662 if (!pwField) return;663 const passwordEl = pwField.el;664665 await openAutofillSelector(field, usernameEl, passwordEl, entries, allEntries, domain);666 } finally {667 pendingInteractions.delete(field);668 }669}670671async function onFocusIn(e: Event): Promise<void> {672 673 674 675 676 677 if (isSubmitButton(e.target)) return;678 await handleFieldInteraction();679}680681function reattachListeners(682 el: HTMLInputElement,683 detectedType?: "password" | "username" | "email",684 contextFields?: ReturnType<typeof detectLoginFields>685): void {686 if (processedInputs.has(el)) return;687 if (!resolveAutofillFieldType(el, detectedType)) return;688 689 const fields = contextFields ?? detectLoginFields();690 if (!isLoginForm(fields) && !isSignupForm(fields)) return;691692 const cleanup = injectFieldIcon(el, () => {693 currentDismiss?.();694 currentDismiss = null;695 lastDismissedField = null; 696 dismissLock = false; 697 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }698 clearFillStateForExplicitSummon(el);699 el.focus({ preventScroll: true });700 void handleFieldInteraction(el);701 });702 processedInputs.set(el, cleanup);703}704705const debouncedReattach = debounce(() => {706 const allFields = detectLoginFields();707 708 if (!isLoginForm(allFields) && !isSignupForm(allFields)) return;709 const newFields = allFields.filter(710 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)711 );712 for (const f of newFields) {713 reattachListeners(f.el, f.type, allFields);714 }715 716 717 718}, 200);719720function setupMutationObserver(): void {721 const observer = new MutationObserver(debouncedReattach);722 observer.observe(document.body, {723 childList: true,724 subtree: true,725 attributes: true,726 attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],727 });728}729730function initialScan(aggressionLevel = 0, isFinalPass = false) {731 const allFields = detectLoginFields();732 const hasKnownForm = isLoginForm(allFields) || isSignupForm(allFields);733734 if (aggressionLevel === 0 && !hasKnownForm) return;735736 const newFields = allFields.filter(737 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)738 );739740 for (const f of newFields) {741 reattachListeners(f.el, f.type, allFields);742 }743744 745 746 if (aggressionLevel >= 2 && isFinalPass && newFields.length === 0) {747 const pwInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');748 for (const el of pwInputs) {749 if (isVisible(el) && !processedInputs.has(el)) {750 const cleanup = injectFieldIcon(el, () => {751 currentDismiss?.();752 currentDismiss = null;753 lastDismissedField = null;754 dismissLock = false;755 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }756 clearFillStateForExplicitSummon(el);757 el.focus({ preventScroll: true });758 void handleFieldInteraction(el);759 });760 processedInputs.set(el, cleanup);761 }762 }763 }764}765766767768769770const processedTOTPInputs = new WeakMap<HTMLInputElement, () => void>();771772function hasLoginOrSignupForm(): boolean {773 const fields = detectLoginFields();774 return isLoginForm(fields) || isSignupForm(fields);775}776777function injectTOTPFieldIcon(field: HTMLInputElement): void {778 if (processedTOTPInputs.has(field)) return;779 const cleanup = injectFieldIcon(field, () => {780 currentDismiss?.();781 currentDismiss = null;782 currentAnchorField = null;783 lastDismissedField = null;784 dismissLock = false;785 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }786 clearFillStateForExplicitSummon(field);787 field.focus({ preventScroll: true });788 void openTOTPSelector(field);789 });790 processedTOTPInputs.set(field, cleanup);791}792793function scanTOTPFields(): void {794 if (hasLoginOrSignupForm()) return;795 const inputs = document.querySelectorAll<HTMLInputElement>("input");796 for (const input of inputs) {797 if (!isTOTPField(input)) continue;798 injectTOTPFieldIcon(input);799 }800}801802function setupTOTPAutofill(): void {803 setTimeout(scanTOTPFields, 0);804 setTimeout(scanTOTPFields, 500);805 setTimeout(scanTOTPFields, 1500);806807 const debouncedScan = debounce(scanTOTPFields, 200);808 new MutationObserver(debouncedScan).observe(document.body, {809 childList: true,810 subtree: true,811 attributes: true,812 attributeFilter: ["autocomplete", "name", "id", "placeholder", "hidden"],813 });814815 document.addEventListener("focusin", (e) => {816 const target = e.target;817 if (!(target instanceof HTMLInputElement)) return;818 if (hasLoginOrSignupForm()) return;819 if (!isTOTPField(target)) return;820 injectTOTPFieldIcon(target);821 }, true);822}823824document.addEventListener("focusin", onFocusIn);825826setupMutationObserver();827setupTOTPAutofill();828setupSaveDetection();829setupCardAutofill();830831832833834835836837838let lastObservedUrl = location.href;839new MutationObserver(() => {840 if (location.href !== lastObservedUrl) {841 lastObservedUrl = location.href;842 pageFilledAt = null;843 }844}).observe(document.body, { childList: true, subtree: false });845846847848(async () => {849 let aggressionLevel = 0;850 try {851 const resp = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });852 const s = (resp as { ok: boolean; data?: { manualSummonDomains?: Record<string, { summonCount: number }> } }).data;853 const domain = getPageDomain();854 const record = domain ? s?.manualSummonDomains?.[domain] : undefined;855 aggressionLevel = !record ? 0 : record.summonCount >= 2 ? 2 : 1;856 } catch { }857858 setTimeout(() => initialScan(aggressionLevel, false), 0);859 setTimeout(() => initialScan(aggressionLevel, false), 500);860 setTimeout(() => initialScan(aggressionLevel, true), 1500);861})();862863864865866867868const processedCardInputs = new WeakMap<HTMLInputElement, () => void>();869let currentCardDismiss: (() => void) | null = null;870let currentCardAnchor: HTMLInputElement | null = null;871let lastDismissedCardField: HTMLInputElement | null = null;872873async function fetchSavedCards(): Promise<CardSelectorEntry[]> {874 const resp = await chrome.runtime.sendMessage({875 type: "AUTOFILL_QUERY",876 payload: { domain: getPageDomain(), kind: "card" },877 }).catch(() => null);878 const r = resp as { ok: boolean; data?: CardSelectorEntry[] } | null;879 return r?.ok ? (r.data ?? []) : [];880}881882async function openCardSelector(anchor: HTMLInputElement): Promise<void> {883 if (!canOpen()) return;884 if (currentCardDismiss && currentCardAnchor === anchor) return;885 if (anchor === lastDismissedCardField) return;886 if (anchor !== lastDismissedCardField) lastDismissedCardField = null;887 if (wasAlreadyFilled(anchor)) return;888889 const cards = await fetchSavedCards();890 if (cards.length === 0) return; 891892 const cardForm = detectCardForm();893 if (!cardForm?.number) return;894895 currentCardDismiss?.();896 currentCardAnchor = anchor;897 currentCardDismiss = showCardSelector(898 anchor,899 cards,900 {901 domain: getPageDomain(),902 onFill: (entry) => {903 if (!cardForm) return;904 fillCardForm(cardForm, {905 cardNumber: entry.cardNumber ?? "",906 expMonth: entry.expMonth ?? "",907 expYear: entry.expYear ?? "",908 cvc: entry.cvc ?? "",909 cardholderName: entry.cardholderName ?? "",910 });911 912 913 914 recordFillSuccess(cardForm.number, cardForm.cvc, cardForm.expiryCombo, cardForm.name);915 currentCardDismiss = null;916 currentCardAnchor = null;917 },918 onDismiss: () => {919 lastDismissedCardField = anchor;920 startDismissLock();921 currentCardDismiss = null;922 currentCardAnchor = null;923 },924 }925 );926}927928function injectCardIcon(field: HTMLInputElement): void {929 if (processedCardInputs.has(field)) return;930 const cleanup = injectFieldIcon(field, () => {931 currentCardDismiss?.();932 currentCardDismiss = null;933 lastDismissedCardField = null;934 dismissLock = false;935 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }936 clearFillStateForExplicitSummon(field);937 field.focus({ preventScroll: true });938 void openCardSelector(field);939 });940 processedCardInputs.set(field, cleanup);941}942943function setupCardAutofill(): void {944 function scan() {945 const cardForm = detectCardForm();946 if (!cardForm?.number) return;947 if (!isVisible(cardForm.number)) return;948 injectCardIcon(cardForm.number);949 }950951 952 953 setTimeout(scan, 0);954 setTimeout(scan, 500);955 setTimeout(scan, 1500);956957 958 959 960 let pending = false;961 new MutationObserver(() => {962 if (pending) return;963 pending = true;964 setTimeout(() => { pending = false; scan(); }, 200);965 }).observe(document.body, { childList: true, subtree: true });966967 968 969 970 document.addEventListener("focusin", (e) => {971 const target = e.target;972 if (!(target instanceof HTMLInputElement)) return;973 const cardForm = detectCardForm();974 if (cardForm?.number === target) {975 injectCardIcon(target);976 }977 }, true);978}979980chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {981 982 if (sender.id !== chrome.runtime.id) return;983 if (msg?.type === "GET_SITE_PASSWORD_POLICY") {984 const policy = detectPasswordPolicy();985 sendResponse({ ok: true, data: policy });986 return true;987 }988 if (msg?.type === "PING") {989 sendResponse({ ok: true });990 return true;991 }992 if (msg?.type === "SUMMON_AUTOFILL") {993 const source = (msg as { type: string; source?: string }).source;994 const isShortcut = source === "shortcut";995 const isPasskeyUnlock = source === "passkey-unlock";996997 const activeInput = getDeepActiveInput();998 const fields = detectLoginFields();999 const hasForm = isLoginForm(fields) || isSignupForm(fields);10001001 if (hasForm) {1002 1003 1004 const inForm = activeInput && fields.some(f => f.el === activeInput);1005 const target = inForm1006 ? activeInput1007 : (fields.find(f => f.type === "password")?.el1008 ?? fields.find(f => f.type === "username" || f.type === "email")?.el);1009 if (target) {1010 1011 1012 clearFillStateForExplicitSummon(target);1013 dismissLock = false;1014 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1015 lastDismissedField = null;1016 void handleFieldInteraction(target);1017 }1018 } else if (isPasskeyUnlock) {1019 1020 1021 1022 const anchor = (document.activeElement instanceof HTMLElement && document.activeElement !== document.body)1023 ? document.activeElement as unknown as HTMLInputElement1024 : null;1025 if (anchor) {1026 currentDismiss?.();1027 currentAnchorField = null;1028 passkeySummonActive = true;1029 currentDismiss = showAutofillSelector(anchor, [], () => {}, {1030 domain: getPageDomain(),1031 locked: true,1032 onDismiss: () => { passkeySummonActive = false; },1033 });1034 }1035 } else if (isShortcut) {1036 1037 showSearchPalette();1038 }10391040 1041 if (isShortcut) {1042 const domain = getPageDomain();1043 if (domain) {1044 chrome.runtime.sendMessage({ type: "RECORD_MANUAL_SUMMON", payload: { domain } }).catch(() => {});1045 }1046 }10471048 sendResponse({ ok: hasForm || isShortcut || isPasskeyUnlock });1049 return true;1050 }1051 if (msg?.type === "PASSKEY_PRO_GATE") {1052 1053 1054 showPasskeyProGate();1055 sendResponse({ ok: true });1056 return true;1057 }1058 if (msg?.type === "SHOW_PASSKEY_TOAST") {1059 1060 passkeyToastActive = true;1061 if (currentDismiss) {1062 currentDismiss();1063 currentDismiss = null;1064 currentAnchorField = null;1065 }1066 return false; 1067 }1068 if (msg?.type === "DISMISS_PASSKEY_TOAST") {1069 passkeyToastActive = false;1070 return false; 1071 }1072 if (msg?.type === "VAULT_UNLOCKED") {1073 if (passkeySummonActive) {1074 1075 1076 passkeySummonActive = false;1077 if (currentDismiss) {1078 currentDismiss();1079 currentDismiss = null;1080 currentAnchorField = null;1081 }1082 sendResponse({ ok: true });1083 return true;1084 }1085 1086 1087 const savedField = currentAnchorField;1088 if (currentDismiss) {1089 currentDismiss();1090 currentDismiss = null;1091 currentAnchorField = null;1092 }1093 void handleFieldInteraction(savedField ?? undefined);1094 sendResponse({ ok: true });1095 return true;1096 }1097 return true;1098});1099110011011102window.addEventListener("message", (e: MessageEvent) => {1103 1104 1105 if (e.source !== window) return;1106 const d = e.data as { source?: string; type?: string } | undefined;1107 if (d?.source === "peach-passkey-toast" && d?.type === "toast-closed") {1108 passkeyToastActive = false;1109 }1110});1111111211131114111511161117111811191120112111221123if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1124 let lastPasskeyOfferAt = 0;1125 const PASSKEY_OFFER_COOLDOWN_MS = 30_000;11261127 window.addEventListener("message", (event: MessageEvent) => {1128 if (event.source !== window) return;1129 const data = event.data as { source?: string; type?: string; rpId?: string; rpName?: string; userName?: string; userDisplayName?: string } | undefined;1130 if (!data || data.source !== "peach-passkey-firefox") return;1131 if (detectBrowser() !== "firefox") return;1132 if (data.type !== "create") return;11331134 1135 1136 const now = Date.now();1137 if (now - lastPasskeyOfferAt < PASSKEY_OFFER_COOLDOWN_MS) return;1138 lastPasskeyOfferAt = now;11391140 const domain = location.hostname.replace(/^www\./, "");1141 chrome.runtime.sendMessage({1142 type: "GET_PASSKEY_OFFER_DECLINED",1143 payload: { domain },1144 }).then((res: { ok: boolean; data?: boolean } | undefined) => {1145 if (res?.data === true) return;1146 const rpId = data.rpId ?? domain;1147 showPasskeyRegistrationOffer({1148 rpId,1149 rpName: data.rpName,1150 userName: data.userName,1151 userDisplayName: data.userDisplayName,1152 });1153 }).catch(() => {});1154 });1155}115611571158115911601161116211631164116511661167116811691170{1171 function checkPasskeyAvailability() {1172 const domain = location.hostname.replace(/^www\./, "");1173 if (!domain) return;1174 const sessionKey = `peach_state11_shown:${domain}`;1175 chrome.storage.session?.get(sessionKey).then((stored) => {1176 if (stored && (stored as Record<string, unknown>)[sessionKey]) return;1177 chrome.runtime.sendMessage({1178 type: "GET_PASSKEY_FOR_DOMAIN",1179 payload: { domain },1180 }).then((res: { ok: boolean; data?: { rpId: string; userName: string; browser?: "chrome" | "firefox" } | null } | undefined) => {1181 const data = res?.data;1182 if (!data) return;1183 const current = detectBrowser();1184 const isCrossBrowser =1185 data.browser && current !== "unknown" && data.browser !== current;1186 const valuableOnFirefox = current === "firefox";1187 if (!isCrossBrowser && !valuableOnFirefox) return;1188 chrome.storage.session?.set({ [sessionKey]: Date.now() }).catch(() => {});1189 showPasskeyAvailable({1190 rpId: data.rpId,1191 userName: data.userName,1192 fromBrowser: data.browser,1193 currentBrowser: current,1194 });1195 }).catch(() => {});1196 }).catch(() => {});1197 }11981199 1200 1201 1202 if (document.readyState === "loading") {1203 document.addEventListener("DOMContentLoaded", () => setTimeout(checkPasskeyAvailability, 800));1204 } else {1205 setTimeout(checkPasskeyAvailability, 800);1206 }1207}