/// Inject a small "id" icon into password fields for quick autofill access. /// /// Each injected icon and picker renders inside a closed Shadow DOM so /// the host page cannot read or manipulate our UI. /// /// Flow: /// 1. Icon click → chrome.runtime.sendMessage({ type: 'get_autofill_candidates' }) /// (router derives origin from sender.tab.url; no url on message). /// 2. Single candidate → get_credentials; if response is a /// requires_ack variant, show an in-page TOFU hint instructing the /// user to open the popup for ack. Otherwise, call fillFields() /// directly — the content script IS the page origin, so no SW /// round-trip for the fill itself. /// 3. Multiple candidates → show the picker inside a shadow root. /// /// Note: fill_credentials is popup-only in the router. The icon click path /// cannot and MUST NOT issue fill_credentials from content. import type { AutofillCandidatesResponse, CredentialsResponse, Response } from '../shared/messages'; import type { ManifestEntry, ItemId } from '../shared/types'; import { createShadowHost, type ShadowSurface } from './shadow'; import { fillFields } from './fill'; /// Track which fields already have an injected icon. const injected = new WeakSet(); /// The currently-open picker / TOFU hint, if any. let currentOverlay: ShadowSurface | null = null; function closeOverlay(): void { if (currentOverlay) { currentOverlay.destroy(); currentOverlay = null; } } /// Inject a small blue "id" icon at the right edge of a password field. export function injectFieldIcons( passwordField: HTMLInputElement, _usernameField: HTMLInputElement | null, ): void { if (injected.has(passwordField)) return; injected.add(passwordField); // Each icon gets its own shadow host so page CSS cannot reach it. const surface = createShadowHost(); const { host, root } = surface; // Compute initial position from the password field's bounding rect and // reposition on scroll/resize. We keep things lightweight — exact // pixel-perfect tracking during layout churn is not required. function positionHost(): void { const rect = passwordField.getBoundingClientRect(); host.style.cssText = [ 'position: fixed', `top: ${rect.top + rect.height / 2 - 10}px`, `left: ${rect.right - 28}px`, 'z-index: 2147483646', 'pointer-events: auto', ].join('; '); } positionHost(); window.addEventListener('scroll', positionHost, true); window.addEventListener('resize', positionHost); const icon = document.createElement('div'); icon.textContent = 'id'; icon.setAttribute('role', 'button'); icon.setAttribute('aria-label', 'relicario autofill'); icon.style.cssText = [ 'width: 20px', 'height: 20px', 'line-height: 20px', 'text-align: center', 'font-size: 10px', 'font-weight: 700', 'font-family: monospace', 'color: #fff', 'background: #1f6feb', 'border-radius: 3px', 'cursor: pointer', 'user-select: none', 'box-sizing: border-box', ].join('; '); root.appendChild(icon); icon.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); // Note: no `url` on message — router derives from sender.tab.url. const resp = await chrome.runtime.sendMessage({ type: 'get_autofill_candidates', }) as Response; if (!resp || !resp.ok) return; const candidates = (resp as AutofillCandidatesResponse).data.candidates; if (candidates.length === 0) return; if (candidates.length === 1) { await handleSingleCandidate(candidates[0][0]); } else { showPicker(passwordField, candidates); } }); } /// Fetch credentials for a single item and either fill immediately or /// display the TOFU ack hint. async function handleSingleCandidate(id: ItemId): Promise { const credResp = await chrome.runtime.sendMessage({ type: 'get_credentials', id, }) as Response; if (!credResp?.ok) return; const data = (credResp as CredentialsResponse).data; if ('requires_ack' in data && data.requires_ack) { showAckHint(data.hostname); return; } // Discriminated union: must be the {username, password} variant here. if ('username' in data && 'password' in data) { fillFields(data.username, data.password); } } /// Render a dropdown picker below the password field for selecting among /// multiple candidates. The picker lives in its own closed Shadow DOM. function showPicker( anchor: HTMLInputElement, candidates: Array<[ItemId, ManifestEntry]>, ): void { closeOverlay(); const surface = createShadowHost(); currentOverlay = surface; const { host, root } = surface; const rect = anchor.getBoundingClientRect(); host.style.cssText = [ 'position: fixed', `top: ${rect.bottom + 4}px`, `left: ${rect.right - 180}px`, 'z-index: 2147483647', ].join('; '); const picker = document.createElement('div'); picker.style.cssText = [ 'background: #161b22', 'border: 1px solid #30363d', 'border-radius: 6px', 'box-shadow: 0 4px 12px rgba(0,0,0,0.4)', 'min-width: 180px', 'max-height: 200px', 'overflow-y: auto', "font-family: 'JetBrains Mono', monospace", 'font-size: 12px', ].join('; '); for (const [id, entry] of candidates) { const row = document.createElement('div'); const label = entry.title + (/* user hint */ ''); row.textContent = label; row.style.cssText = [ 'padding: 8px 12px', 'cursor: pointer', 'color: #c9d1d9', 'border-bottom: 1px solid #21262d', ].join('; '); row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; }); row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; }); row.addEventListener('click', async (e) => { e.stopPropagation(); closeOverlay(); await handleSingleCandidate(id); }); picker.appendChild(row); } root.appendChild(picker); // Close picker on outside click (scoped to document; shadow root blocks // composedPath for closed mode but the host element still shows up). const closeHandler = (e: MouseEvent): void => { if (e.target !== host) { closeOverlay(); document.removeEventListener('click', closeHandler); } }; setTimeout(() => document.addEventListener('click', closeHandler), 0); } /// TOFU origin-ack hint: credentials exist for this host but the user has /// never explicitly acknowledged autofill here. Instruct them to open /// relicario to confirm — we do not (and cannot) fill until ack-autofill /// has been called from the popup. function showAckHint(hostname: string): void { closeOverlay(); const surface = createShadowHost(); currentOverlay = surface; const { host, root } = surface; host.style.cssText = [ 'position: fixed', 'top: 16px', 'right: 16px', 'z-index: 2147483647', ].join('; '); const hint = document.createElement('div'); hint.style.cssText = [ 'font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace', 'font-size: 12px', 'color: #c9d1d9', 'background: #161b22', 'border: 1px solid #30363d', 'border-radius: 6px', 'padding: 10px 14px', 'box-shadow: 0 4px 12px rgba(0,0,0,0.4)', 'max-width: 320px', 'line-height: 1.5', ].join('; '); const title = document.createElement('div'); title.style.cssText = 'font-weight: 700; margin-bottom: 4px; color: #58a6ff;'; title.textContent = 'relicario'; hint.appendChild(title); const body = document.createElement('div'); body.appendChild(document.createTextNode('First autofill on ')); const hostSpan = document.createElement('strong'); hostSpan.textContent = hostname; body.appendChild(hostSpan); body.appendChild(document.createTextNode(' — open relicario to confirm.')); hint.appendChild(body); const close = document.createElement('div'); close.textContent = '✕'; close.style.cssText = [ 'position: absolute', 'top: 6px', 'right: 8px', 'cursor: pointer', 'color: #8b949e', 'font-size: 14px', ].join('; '); close.addEventListener('click', closeOverlay); hint.style.position = 'relative'; hint.appendChild(close); root.appendChild(hint); // Auto-dismiss after 8 seconds setTimeout(() => { if (currentOverlay === surface) closeOverlay(); }, 8000); }