|
|
|
|
@@ -1,16 +1,40 @@
|
|
|
|
|
// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires)
|
|
|
|
|
/// Inject a small "id" icon into password fields for quick autofill access.
|
|
|
|
|
///
|
|
|
|
|
/// Uses a WeakSet to avoid double-injection on re-scans (MutationObserver).
|
|
|
|
|
/// 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 { ManifestEntry } from '../shared/types';
|
|
|
|
|
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.
|
|
|
|
|
/// 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,
|
|
|
|
|
@@ -18,145 +42,190 @@ export function injectFieldIcons(
|
|
|
|
|
if (injected.has(passwordField)) return;
|
|
|
|
|
injected.add(passwordField);
|
|
|
|
|
|
|
|
|
|
// Create the icon element.
|
|
|
|
|
// 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);
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
// Note: no `url` on message — router derives from sender.tab.url.
|
|
|
|
|
const resp = await chrome.runtime.sendMessage({
|
|
|
|
|
type: 'get_autofill_candidates',
|
|
|
|
|
url,
|
|
|
|
|
});
|
|
|
|
|
}) as Response;
|
|
|
|
|
|
|
|
|
|
if (!resp || !resp.ok) return;
|
|
|
|
|
const candidates = resp.data.candidates as Array<[string, ManifestEntry]>;
|
|
|
|
|
|
|
|
|
|
const candidates = (resp as AutofillCandidatesResponse).data.candidates;
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
await handleSingleCandidate(candidates[0][0]);
|
|
|
|
|
} else {
|
|
|
|
|
// Multiple matches — show inline picker.
|
|
|
|
|
showPicker(icon, candidates);
|
|
|
|
|
showPicker(passwordField, candidates);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Show a small dropdown picker below the icon for selecting among multiple 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: HTMLElement,
|
|
|
|
|
candidates: Array<[string, ManifestEntry]>,
|
|
|
|
|
anchor: HTMLInputElement,
|
|
|
|
|
candidates: Array<[ItemId, ManifestEntry]>,
|
|
|
|
|
): void {
|
|
|
|
|
// Remove any existing picker.
|
|
|
|
|
document.querySelectorAll('.relicario-picker').forEach(el => el.remove());
|
|
|
|
|
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.className = 'relicario-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',
|
|
|
|
|
});
|
|
|
|
|
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');
|
|
|
|
|
row.textContent = `${entry.name}${entry.username ? ` (${entry.username})` : ''}`;
|
|
|
|
|
Object.assign(row.style, {
|
|
|
|
|
padding: '8px 12px',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
color: '#c9d1d9',
|
|
|
|
|
borderBottom: '1px solid #21262d',
|
|
|
|
|
});
|
|
|
|
|
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();
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
closeOverlay();
|
|
|
|
|
await handleSingleCandidate(id);
|
|
|
|
|
});
|
|
|
|
|
picker.appendChild(row);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
anchor.parentElement?.appendChild(picker);
|
|
|
|
|
root.appendChild(picker);
|
|
|
|
|
|
|
|
|
|
// Close picker on outside click.
|
|
|
|
|
const closeHandler = (e: MouseEvent) => {
|
|
|
|
|
if (!picker.contains(e.target as Node) && e.target !== anchor) {
|
|
|
|
|
picker.remove();
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|