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,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);
}