1import { extractDomain } from "../shared/domain";23export interface DetectedField {4 type: "username" | "password" | "email";5 el: HTMLInputElement;6}789const USERNAME_PATTERNS = /\b(user(name)?|e?mail(box)?|login|account|identifier|signin|sign.?in|credential)\b/i;10const PASSWORD_PATTERNS = /pass|passwd|pwd/i;111213const SKIP_INPUT_TYPES = new Set([14 "submit", "button", "reset", "image",15 "checkbox", "radio", "hidden", "file",16 "range", "color", "date", "time",17 "datetime-local", "month", "week",18]);19202122function looksLikeSubmitButton(input: HTMLInputElement): boolean {23 if (input.type === "password") return false;24 const val = (input.value || input.getAttribute("value") || "").toLowerCase().trim();25 if (!val) return false;26 return ["submit", "login", "sign in", "log in", "continue", "next", "go"].some(w => val === w || val.startsWith(w + " "));27}2829function findLabelText(field: HTMLInputElement): string {30 const ariaLabel = field.getAttribute("aria-label") ?? "";31 if (ariaLabel) return ariaLabel.toLowerCase();32 const id = field.id;33 if (id) {34 const label = document.querySelector<HTMLLabelElement>(`label[for="${id}"]`);35 if (label) return label.textContent?.toLowerCase() ?? "";36 }37 const wrappingLabel = field.closest("label");38 return wrappingLabel?.textContent?.toLowerCase() ?? "";39}4041export function querySelectorAllDeep<T extends Element>(42 root: Document | ShadowRoot | Element,43 selector: string44): T[] {45 const results: T[] = [];46 const walk = (node: Document | ShadowRoot | Element) => {47 if (node instanceof Element) {48 results.push(...(node.querySelectorAll<T>(selector)));49 const shadowRoot = node.shadowRoot;50 if (shadowRoot) {51 shadowRoot.querySelectorAll<T>("*").forEach(walk);52 }53 }54 };55 root.querySelectorAll<T>("*").forEach(walk);56 return results;57}5859export function getDeepActiveInput(): HTMLInputElement | null {60 let current: Document | Element | null = document.activeElement;61 while (current) {62 if (current instanceof HTMLInputElement) return current;63 if (current instanceof Element && current.shadowRoot) {64 current = current.shadowRoot.activeElement;65 } else {66 break;67 }68 }69 return null;70}7172export function isLoginForm(fields: DetectedField[]): boolean {73 const hasPassword = fields.some((f) => f.type === "password");74 const hasUsernameOrEmail = fields.some((f) => f.type === "username" || f.type === "email");75 if (hasPassword && hasUsernameOrEmail) return true;76 const singlePassword = fields.filter((f) => f.type === "password");77 if (singlePassword.length === 1 && singlePassword[0].el.autocomplete === "current-password") {78 return true;79 }80 return false;81}8283export function isSignupForm(fields: DetectedField[]): boolean {84 const passwords = fields.filter((f) => f.type === "password");85 if (passwords.length >= 2) return true;86 if (passwords.some((f) => f.el.autocomplete === "new-password")) return true;87 if (88 passwords.some((f) => {89 const id = (f.el.id + f.el.name).toLowerCase();90 return /new|confirm/i.test(id);91 })92 ) {93 return true;94 }95 return false;96}9798export function detectLoginFields(root: Document | ShadowRoot = document): DetectedField[] {99 const inputs = Array.from(querySelectorAllDeep<HTMLInputElement>(root, "input"));100 const fields: DetectedField[] = [];101102 for (const el of inputs) {103 if (!isVisible(el)) continue;104 const type = el.type.toLowerCase();105 106 if (SKIP_INPUT_TYPES.has(type)) continue;107 108 if (looksLikeSubmitButton(el)) continue;109 const ac = el.autocomplete;110 const name = (el.name + el.id + el.placeholder).toLowerCase();111112 if (ac === "current-password" || ac === "new-password" || type === "password") {113 fields.push({ type: "password", el });114 continue;115 }116117 if (ac === "username" || ac === "email" || type === "email") {118 fields.push({ type: "username", el });119 continue;120 }121122 if (USERNAME_PATTERNS.test(name)) {123 fields.push({ type: "username", el });124 continue;125 }126127 const labelText = findLabelText(el);128 if (!fields.some((f) => f.el === el) && USERNAME_PATTERNS.test(labelText)) {129 fields.push({ type: "username", el });130 }131 }132133 134 135 136 const hasUsername = fields.some((f) => f.type === "username" || f.type === "email");137 const pwField = fields.find((f) => f.type === "password");138 if (!hasUsername && pwField) {139 const allInputs = Array.from(inputs);140 const pwIndex = allInputs.indexOf(pwField.el);141 for (let i = 0; i < pwIndex; i++) {142 const candidate = allInputs[i];143 const candidateType = candidate.type.toLowerCase();144 if (145 (candidateType === "text" || candidateType === "email" || candidateType === "") &&146 isVisible(candidate) &&147 !SKIP_INPUT_TYPES.has(candidateType) &&148 !fields.some((f) => f.el === candidate)149 ) {150 fields.push({ type: "username", el: candidate });151 break; 152 }153 }154 }155156 return fields;157}158159export function isVisible(el: HTMLElement): boolean {160 const s = getComputedStyle(el);161 const r = el.getBoundingClientRect();162 return (163 s.display !== "none" &&164 s.visibility !== "hidden" &&165 parseFloat(s.opacity) > 0 &&166 r.width > 0 &&167 r.height > 0168 );169}170171export function getPageDomain(): string {172 return extractDomain(location.hostname) ?? location.hostname;173}