feat: add content script with form detection and autofill

Login form detector using password field + username heuristics,
native value setter fill for React/Vue compatibility, inline "id" icon
injection with autofill candidate picker, and MutationObserver for SPA support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-12 09:42:27 -04:00
parent b4febbbe45
commit 78ffeb4b8d
3 changed files with 350 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
/// 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';
/// 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);
}
}
// --- 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,
});

View File

@@ -0,0 +1,88 @@
/// Fill listener — receives credentials from the service worker and fills form fields.
///
/// Uses the native value setter trick to work with React/Vue controlled inputs
/// that override the value property.
/// Set up a listener for fill_credentials messages from the service worker.
export function setupFillListener(): void {
chrome.runtime.onMessage.addListener(
(message: { type: string; username: string; password: string }, _sender: chrome.runtime.MessageSender, sendResponse: (response: { ok: boolean }) => void) => {
if (message.type !== 'fill_credentials') return false;
fillFields(message.username, message.password);
sendResponse({ ok: true });
return false;
},
);
}
/// Fill username and password fields on the page.
///
/// Finds the first visible password field and its associated username field,
/// then sets their values using the native setter trick for React/Vue compat.
export function fillFields(username: string, password: string): void {
const pwField = document.querySelector<HTMLInputElement>('input[type="password"]');
if (!pwField) return;
// Set the password.
setNativeValue(pwField, password);
// Find the username field (same logic as detector).
if (username) {
const usernameField = findUsernameForFill(pwField);
if (usernameField) {
setNativeValue(usernameField, username);
}
}
}
/// Use the native HTMLInputElement.value setter to bypass React/Vue wrappers.
/// Then dispatch input and change events so the framework picks up the change.
function setNativeValue(input: HTMLInputElement, value: string): void {
const nativeSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)?.set;
if (nativeSetter) {
nativeSetter.call(input, value);
} else {
input.value = value;
}
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
/// Find the username field associated with a password field (simplified version for fill).
function findUsernameForFill(pwField: HTMLInputElement): HTMLInputElement | null {
const form = pwField.closest('form');
const scope = form ?? document;
const inputs = scope.querySelectorAll<HTMLInputElement>('input');
// Priority: autocomplete > type=email > name pattern > preceding text input.
for (const input of inputs) {
if (input === pwField) continue;
if (input.autocomplete === 'username' || input.autocomplete === 'email') return input;
}
for (const input of inputs) {
if (input === pwField) continue;
if (input.type === 'email') return input;
}
const pattern = /user|email|login|account/i;
for (const input of inputs) {
if (input === pwField || input.type === 'hidden' || input.type === 'password') continue;
if (pattern.test(input.name) || pattern.test(input.id)) return 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;
}

View File

@@ -0,0 +1,161 @@
/// Inject a small "id" icon into password fields for quick autofill access.
///
/// Uses a WeakSet to avoid double-injection on re-scans (MutationObserver).
import type { ManifestEntry } from '../shared/types';
/// Track which fields already have an injected icon.
const injected = new WeakSet<HTMLInputElement>();
/// Inject a small blue "id" icon at the right edge of a password field.
/// Clicking it queries for autofill candidates and either fills immediately
/// (single match) or shows an inline picker (multiple matches).
export function injectFieldIcons(
passwordField: HTMLInputElement,
_usernameField: HTMLInputElement | null,
): void {
if (injected.has(passwordField)) return;
injected.add(passwordField);
// Create the icon element.
const icon = document.createElement('div');
icon.textContent = 'id';
icon.setAttribute('role', 'button');
icon.setAttribute('aria-label', 'idfoto autofill');
Object.assign(icon.style, {
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
width: '20px',
height: '20px',
lineHeight: '20px',
textAlign: 'center',
fontSize: '10px',
fontWeight: '700',
fontFamily: 'monospace',
color: '#fff',
background: '#1f6feb',
borderRadius: '3px',
cursor: 'pointer',
zIndex: '999999',
userSelect: 'none',
});
// Ensure the password field's parent is positioned so the icon can be absolute.
const parent = passwordField.parentElement;
if (parent) {
const parentPosition = getComputedStyle(parent).position;
if (parentPosition === 'static') {
parent.style.position = 'relative';
}
}
// Insert the icon after the password field.
passwordField.insertAdjacentElement('afterend', icon);
// Click handler: query for autofill candidates.
icon.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const url = window.location.href;
const resp = await chrome.runtime.sendMessage({
type: 'get_autofill_candidates',
url,
});
if (!resp || !resp.ok) return;
const candidates = resp.data.candidates as Array<[string, ManifestEntry]>;
if (candidates.length === 0) return;
if (candidates.length === 1) {
// Single match — fill immediately.
const [id] = candidates[0];
const credResp = await chrome.runtime.sendMessage({
type: 'get_credentials',
id,
});
if (credResp?.ok) {
chrome.runtime.sendMessage({
type: 'fill_credentials',
username: credResp.data.username,
password: credResp.data.password,
});
}
} else {
// Multiple matches — show inline picker.
showPicker(icon, candidates);
}
});
}
/// Show a small dropdown picker below the icon for selecting among multiple candidates.
function showPicker(
anchor: HTMLElement,
candidates: Array<[string, ManifestEntry]>,
): void {
// Remove any existing picker.
document.querySelectorAll('.idfoto-picker').forEach(el => el.remove());
const picker = document.createElement('div');
picker.className = 'idfoto-picker';
Object.assign(picker.style, {
position: 'absolute',
right: '0',
top: '100%',
marginTop: '4px',
background: '#161b22',
border: '1px solid #30363d',
borderRadius: '6px',
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
zIndex: '9999999',
minWidth: '180px',
maxHeight: '200px',
overflowY: 'auto',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '12px',
});
for (const [id, entry] of candidates) {
const row = document.createElement('div');
row.textContent = `${entry.name}${entry.username ? ` (${entry.username})` : ''}`;
Object.assign(row.style, {
padding: '8px 12px',
cursor: 'pointer',
color: '#c9d1d9',
borderBottom: '1px solid #21262d',
});
row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; });
row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; });
row.addEventListener('click', async (e) => {
e.stopPropagation();
picker.remove();
const credResp = await chrome.runtime.sendMessage({
type: 'get_credentials',
id,
});
if (credResp?.ok) {
chrome.runtime.sendMessage({
type: 'fill_credentials',
username: credResp.data.username,
password: credResp.data.password,
});
}
});
picker.appendChild(row);
}
anchor.parentElement?.appendChild(picker);
// Close picker on outside click.
const closeHandler = (e: MouseEvent) => {
if (!picker.contains(e.target as Node) && e.target !== anchor) {
picker.remove();
document.removeEventListener('click', closeHandler);
}
};
setTimeout(() => document.addEventListener('click', closeHandler), 0);
}