/**
* Content script for login form detection, autofill, and save prompt.
*/
let secretsForDomain = [];
let shownCards = new WeakSet();
let autofillHost = null;
let autofillShadow = null;
let cardStylesPromise = null;
// --- Messaging helper ---
function send(type, payload) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type, payload }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (!response || !response.ok) {
reject(new Error(response?.error || "Unknown error"));
return;
}
resolve(response.data);
});
});
}
// --- Debounce helper ---
function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
// --- Domain matching ---
async function fetchSecretsForDomain() {
try {
const domain = location.hostname;
secretsForDomain = await send("GET_SECRETS_FOR_DOMAIN", { domain });
} catch {
secretsForDomain = [];
}
}
// --- Login-field detection ---
// DOM probing lives here; the scoring itself is pure string logic in
// field-detect.js (loaded before this file — manifest content_scripts order —
// and unit-tested with plain node via tools/test-field-detect.js).
function isVisible(input) {
try {
if (typeof input.checkVisibility === "function") {
return input.checkVisibility();
}
} catch {
// fall through
}
return !!input.getClientRects().length;
}
function makeFieldDescriptor(el, index) {
const labelText = [];
for (const label of el.labels || []) {
labelText.push(label.textContent || "");
}
const labelledBy = el.getAttribute("aria-labelledby");
if (labelledBy) {
for (const id of labelledBy.split(/\s+/)) {
const node = id && document.getElementById(id);
if (node) labelText.push(node.textContent || "");
}
}
return {
el,
index,
type: (el.type || "").toLowerCase(),
name: (el.name || "").toLowerCase(),
id: (el.id || "").toLowerCase(),
placeholder: (el.getAttribute("placeholder") || "").toLowerCase(),
autocomplete: (el.getAttribute("autocomplete") || "").toLowerCase(),
ariaLabel: (el.getAttribute("aria-label") || "").toLowerCase(),
labelText: labelText.join(" ").toLowerCase(),
visible: isVisible(el),
disabled: !!el.disabled,
readonly: !!el.readOnly,
formEl: el.closest("form"),
};
}
function findLoginFields() {
const inputs = document.querySelectorAll(
'input[type="password"], input[type="text"], input[type="email"], input[type="tel"], input[type="search"], input:not([type])'
);
const fields = Array.from(inputs).map(makeFieldDescriptor);
return GnCredsDetect.findLoginTargets(fields);
}
// --- Autofill card ---
// The card lives in a CLOSED shadow root on a bare host element, so the host
// page cannot restyle it (CSS does not cross the shadow boundary), read its
// content (secret titles) via the DOM, or reach its buttons through
// querySelector. Styles are fetched from our own web-accessible content.css
// and injected as a <style> inside the shadow root — never added to the page.
const CARD_CSS_URL = chrome.runtime.getURL("src/content.css");
function getCardStyles() {
if (!cardStylesPromise) {
cardStylesPromise = fetch(CARD_CSS_URL)
.then((r) => (r.ok ? r.text() : ""))
.catch(() => "");
}
return cardStylesPromise;
}
function getAutofillShadow() {
if (autofillShadow) return autofillShadow;
autofillHost = document.createElement("div");
autofillHost.dataset.gnexusCreds = "root";
autofillHost.style.cssText = "position:absolute;width:0;height:0;";
document.body.appendChild(autofillHost);
autofillShadow = autofillHost.attachShadow({ mode: "closed" });
return autofillShadow;
}
function removeAutofillCard(card, key) {
card.classList.remove("gnexus-creds-autofill-card-visible");
card.classList.add("gnexus-creds-autofill-card-exit");
setTimeout(() => {
card.remove();
shownCards.delete(key);
if (autofillShadow && !autofillShadow.querySelector(".gnexus-creds-autofill-card")) {
autofillHost.remove();
autofillHost = null;
autofillShadow = null;
}
}, 280);
}
function getAutofillContainer(shadow) {
let container = shadow.querySelector(".gnexus-creds-autofill-container");
if (!container) {
container = document.createElement("div");
container.className = "gnexus-creds-autofill-container";
shadow.appendChild(container);
}
return container;
}
async function showAutofillCard(form, usernameInput, passwordInput, secrets) {
const key = form || passwordInput;
if (!key || shownCards.has(key)) return;
shownCards.add(key);
const shadow = getAutofillShadow();
const cardCss = await getCardStyles();
if (!shadow.querySelector("style[data-gnexus-creds-css]")) {
const style = document.createElement("style");
style.dataset.gnexusCredsCss = "card";
style.textContent = cardCss;
shadow.appendChild(style);
}
const container = getAutofillContainer(shadow);
const card = document.createElement("div");
card.className = "gnexus-creds-autofill-card";
if (secrets.length === 1) {
const secret = secrets[0];
card.innerHTML = `
<div class="gnexus-creds-autofill-title">${escapeHtml(secret.title)}</div>
<div class="gnexus-creds-autofill-actions">
<button class="gnexus-creds-autofill-btn gnexus-creds-autofill-btn-primary" data-action="use" data-id="${escapeAttr(secret.id)}">Use</button>
<button class="gnexus-creds-autofill-btn gnexus-creds-autofill-btn-secondary" data-action="dismiss">Dismiss</button>
</div>
`;
} else {
const rows = secrets.map((s) => `
<div class="gnexus-creds-autofill-row">
<div class="gnexus-creds-autofill-row-title">${escapeHtml(s.title)}</div>
<button class="gnexus-creds-autofill-btn gnexus-creds-autofill-btn-primary" data-action="use" data-id="${escapeAttr(s.id)}">Use</button>
</div>
`).join("");
card.innerHTML = `
<div class="gnexus-creds-autofill-title">Select account</div>
<div class="gnexus-creds-autofill-list">${rows}</div>
<div class="gnexus-creds-autofill-actions">
<button class="gnexus-creds-autofill-btn gnexus-creds-autofill-btn-secondary" data-action="dismiss">Dismiss</button>
</div>
`;
}
container.appendChild(card);
requestAnimationFrame(() => {
card.classList.add("gnexus-creds-autofill-card-visible");
});
card.addEventListener("click", async (e) => {
const btn = e.target.closest('[data-action="use"]');
if (!btn) return;
const secretId = btn.dataset.id;
try {
const revealed = await send("REVEAL_SECRET", { id: secretId });
const fields = revealed.fields || [];
const userField = fields.find((f) => /user|login|email/i.test(f.name));
const passField = fields.find((f) => /pass|password|пароль/i.test(f.name));
if (userField && usernameInput) {
usernameInput.value = userField.value || "";
triggerInputEvents(usernameInput);
}
if (passField && passwordInput) {
passwordInput.value = passField.value || "";
triggerInputEvents(passwordInput);
}
} catch (err) {
console.error("[gnexus-creds] autofill failed:", err.message);
}
removeAutofillCard(card, key);
});
const dismissBtn = card.querySelector('[data-action="dismiss"]');
if (dismissBtn) {
dismissBtn.addEventListener("click", () => {
removeAutofillCard(card, key);
});
}
}
function triggerInputEvents(input) {
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
}
// --- Save prompt (persistent via background) ---
function isAlreadySaved(domain, username) {
if (!secretsForDomain.length) return false;
if (username) {
return secretsForDomain.some((s) => (s.title || "").includes(username));
}
return secretsForDomain.some((s) => (s.source || "").includes(domain) || (s.title || "").includes(domain));
}
function notifyPendingSave(username, password) {
const domain = location.hostname;
if (isAlreadySaved(domain, username)) return;
const title = `${domain} — ${username || "account"}`;
chrome.runtime.sendMessage(
{
type: "PENDING_SAVE",
payload: {
title,
source: domain,
fields: [
{ name: "username", value: username || "", encrypted: true, masked: false },
{ name: "password", value: password || "", encrypted: true, masked: true },
],
category: "web",
tags: ["autofill", "browser-extension", "credentials"],
allow_ui: true,
allow_rest_api: true,
},
},
() => {
// Ignore port-closed errors when the page navigates away
if (chrome.runtime.lastError) {
/* ignore */
}
}
);
}
// --- Intercept form submit ---
function attachSubmitInterceptor(form, usernameInput, passwordInput) {
if (form) {
form.addEventListener("submit", () => {
const username = usernameInput?.value?.trim() || "";
const password = passwordInput?.value?.trim() || "";
if (!password) return;
notifyPendingSave(username, password);
}, true);
} else {
// No <form> tag: intercept Enter key on password input
// and clicks on nearby submit-like buttons
passwordInput.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
const username = usernameInput?.value?.trim() || "";
const password = passwordInput?.value?.trim() || "";
if (!password) return;
notifyPendingSave(username, password);
});
// Try to find a submit button near the password field
let container = passwordInput.parentElement;
let depth = 0;
while (container && depth < 3) {
const btn = container.querySelector('button[type="submit"], input[type="submit"], button:not([type])');
if (btn) {
btn.addEventListener("click", () => {
const username = usernameInput?.value?.trim() || "";
const password = passwordInput?.value?.trim() || "";
if (!password) return;
notifyPendingSave(username, password);
});
break;
}
container = container.parentElement;
depth++;
}
}
}
// --- Main flow ---
async function scanPage() {
await fetchSecretsForDomain();
// handled passwords are marked here — a password can gain its interceptor
// and card only once per page load
const targets = findLoginFields().filter(
({ password }) => password.el.dataset.gnexusCreds !== "handled"
);
for (const { password, username, context } of targets) {
password.el.dataset.gnexusCreds = "handled";
// Autofill card
if (secretsForDomain.length > 0 && username) {
await showAutofillCard(context, username.el, password.el, secretsForDomain);
}
// Save interceptor
attachSubmitInterceptor(context, username?.el || null, password.el);
}
}
const debouncedScan = debounce(scanPage, 300);
// Observe DOM changes for SPA-like navigation — filter for password inputs
const observer = new MutationObserver((mutations) => {
let hasPassword = false;
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
if (node.matches?.('input[type="password"]') || node.querySelector?.('input[type="password"]')) {
hasPassword = true;
break;
}
}
if (hasPassword) break;
}
if (hasPassword) debouncedScan();
});
observer.observe(document.documentElement, { childList: true, subtree: true });
// Initial scan
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", scanPage);
} else {
scanPage();
}
function escapeHtml(text) {
if (text == null) return "";
const div = document.createElement("div");
div.textContent = String(text);
return div.innerHTML;
}
function escapeAttr(text) {
if (text == null) return "";
return String(text)
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/'/g, "'")
.replace(/</g, "<")
.replace(/>/g, ">");
}