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:
101
extension/src/content/detector.ts
Normal file
101
extension/src/content/detector.ts
Normal 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,
|
||||||
|
});
|
||||||
88
extension/src/content/fill.ts
Normal file
88
extension/src/content/fill.ts
Normal 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;
|
||||||
|
}
|
||||||
161
extension/src/content/icon.ts
Normal file
161
extension/src/content/icon.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user