Files
relicario/extension/src/content/icon.ts
adlee-was-taken bbafe7fb7e feat(ext): sweep inline blue/red colors to gold/theca-red
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 22:20:16 -04:00

232 lines
8.1 KiB
TypeScript

/// 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<HTMLInputElement>();
/// 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: #7c5719',
'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<void> {
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: #d2ab43;';
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);
}