diff --git a/extension/src/content/detector.ts b/extension/src/content/detector.ts new file mode 100644 index 0000000..1790953 --- /dev/null +++ b/extension/src/content/detector.ts @@ -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('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('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, +}); diff --git a/extension/src/content/fill.ts b/extension/src/content/fill.ts new file mode 100644 index 0000000..88e00c8 --- /dev/null +++ b/extension/src/content/fill.ts @@ -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('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('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; +} diff --git a/extension/src/content/icon.ts b/extension/src/content/icon.ts new file mode 100644 index 0000000..156f9d3 --- /dev/null +++ b/extension/src/content/icon.ts @@ -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(); + +/// 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); +}