104 lines
3.3 KiB
TypeScript
104 lines
3.3 KiB
TypeScript
/// Content script entry point.
|
|
///
|
|
/// Detects login forms on the page by finding password fields and their
|
|
/// associated username inputs. Injects small icons into detected fields
|
|
/// and sets up a fill listener to receive credentials from the service worker.
|
|
|
|
import { setupFillListener } from './fill';
|
|
import { injectFieldIcons } from './icon';
|
|
import { hookForms } from './capture';
|
|
|
|
/// Find password fields on the page and detect their associated username inputs.
|
|
function detectLoginForms(): Array<{ password: HTMLInputElement; username: HTMLInputElement | null }> {
|
|
const passwordFields = document.querySelectorAll<HTMLInputElement>('input[type="password"]');
|
|
const forms: Array<{ password: HTMLInputElement; username: HTMLInputElement | null }> = [];
|
|
|
|
for (const pwField of passwordFields) {
|
|
// Skip hidden or very small fields (likely honeypots).
|
|
if (pwField.offsetWidth < 20 || pwField.offsetHeight < 10) continue;
|
|
|
|
const username = findUsernameField(pwField);
|
|
forms.push({ password: pwField, username });
|
|
}
|
|
|
|
return forms;
|
|
}
|
|
|
|
/// Find the most likely username field associated with a password field.
|
|
///
|
|
/// Priority:
|
|
/// 1. autocomplete="username" in the same form
|
|
/// 2. autocomplete="email" in the same form
|
|
/// 3. type="email" in the same form
|
|
/// 4. name/id matching /user|email|login|account/i in the same form
|
|
/// 5. Nearest preceding visible text input (sibling or DOM-adjacent)
|
|
function findUsernameField(pwField: HTMLInputElement): HTMLInputElement | null {
|
|
const form = pwField.closest('form');
|
|
const scope = form ?? document;
|
|
const inputs = scope.querySelectorAll<HTMLInputElement>('input');
|
|
|
|
// 1. autocomplete="username"
|
|
for (const input of inputs) {
|
|
if (input === pwField) continue;
|
|
if (input.autocomplete === 'username') return input;
|
|
}
|
|
|
|
// 2. autocomplete="email"
|
|
for (const input of inputs) {
|
|
if (input === pwField) continue;
|
|
if (input.autocomplete === 'email') return input;
|
|
}
|
|
|
|
// 3. type="email"
|
|
for (const input of inputs) {
|
|
if (input === pwField) continue;
|
|
if (input.type === 'email') return input;
|
|
}
|
|
|
|
// 4. name/id matching common patterns
|
|
const pattern = /user|email|login|account/i;
|
|
for (const input of inputs) {
|
|
if (input === pwField) continue;
|
|
if (input.type === 'hidden' || input.type === 'password') continue;
|
|
if (pattern.test(input.name) || pattern.test(input.id)) return input;
|
|
}
|
|
|
|
// 5. Nearest preceding visible text input
|
|
const allInputs = Array.from(inputs);
|
|
const pwIndex = allInputs.indexOf(pwField);
|
|
for (let i = pwIndex - 1; i >= 0; i--) {
|
|
const input = allInputs[i];
|
|
if (input.type === 'hidden' || input.type === 'password' || input.type === 'submit') continue;
|
|
if (input.offsetWidth > 0 && input.offsetHeight > 0) return input;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// Scan the page for login forms and inject icons.
|
|
function scan(): void {
|
|
const forms = detectLoginForms();
|
|
for (const { password, username } of forms) {
|
|
injectFieldIcons(password, username);
|
|
}
|
|
hookForms();
|
|
}
|
|
|
|
// --- Initialization ---
|
|
|
|
// Set up the fill listener (receives credentials from service worker).
|
|
setupFillListener();
|
|
|
|
// Initial scan.
|
|
scan();
|
|
|
|
// Watch for DOM changes (SPA navigation, dynamically loaded forms).
|
|
const observer = new MutationObserver(() => {
|
|
scan();
|
|
});
|
|
|
|
observer.observe(document.body, {
|
|
childList: true,
|
|
subtree: true,
|
|
});
|