/**
* Content script for login form detection, autofill, and save prompt.
*/
let secretsForDomain = [];
let shownCards = new WeakSet();
let autofillContainer = 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 = [];
}
}
// --- Form detection ---
function findLoginForms() {
const forms = [];
const inputs = document.querySelectorAll('input[type="password"]');
for (const passwordInput of inputs) {
if (passwordInput.dataset.gnexusCreds === "handled") continue;
passwordInput.dataset.gnexusCreds = "handled";
const { form, usernameInput } = findFormContext(passwordInput);
forms.push({ form, usernameInput, passwordInput });
}
return forms;
}
function findFormContext(passwordInput) {
const form = passwordInput.closest("form");
if (form) {
return { form, usernameInput: findUsernameInput(form, passwordInput) };
}
// Fallback for SPA inputs without a <form> tag:
// look within a parent container (section/article/div) up to 3 levels up
let container = passwordInput.parentElement;
let depth = 0;
while (container && depth < 3) {
const user = findUsernameInput(container, passwordInput);
if (user) return { form: container, usernameInput: user };
container = container.parentElement;
depth++;
}
return { form: null, usernameInput: null };
}
function findUsernameInput(scope, passwordInput) {
const candidates = scope.querySelectorAll('input[type="text"], input[type="email"], input:not([type])');
for (const input of candidates) {
if (input === passwordInput) continue;
const name = (input.name || "").toLowerCase();
const id = (input.id || "").toLowerCase();
const placeholder = (input.placeholder || "").toLowerCase();
const autocomp = (input.autocomplete || "").toLowerCase();
if (
autocomp.includes("username") ||
autocomp.includes("email") ||
name.includes("user") ||
name.includes("login") ||
name.includes("email") ||
id.includes("user") ||
id.includes("login") ||
id.includes("email") ||
placeholder.includes("user") ||
placeholder.includes("login") ||
placeholder.includes("email")
) {
return input;
}
}
// Fallback: first text/email input before the password
const all = Array.from(scope.querySelectorAll('input[type="text"], input[type="email"], input:not([type])'));
const idx = all.indexOf(passwordInput);
if (idx > 0) return all[idx - 1];
return all[0] || null;
}
// --- Autofill card ---
function getAutofillContainer() {
if (autofillContainer) return autofillContainer;
autofillContainer = document.createElement("div");
autofillContainer.className = "gnexus-creds-autofill-container";
document.body.appendChild(autofillContainer);
return autofillContainer;
}
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 (autofillContainer && !autofillContainer.children.length) {
autofillContainer.remove();
autofillContainer = null;
}
}, 280);
}
function showAutofillCard(form, usernameInput, passwordInput, secret) {
const key = form || passwordInput;
if (!key || shownCards.has(key)) return;
shownCards.add(key);
const container = getAutofillContainer();
const card = document.createElement("div");
card.className = "gnexus-creds-autofill-card";
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">Use</button>
<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.querySelector('[data-action="use"]').addEventListener("click", async () => {
try {
const revealed = await send("REVEAL_SECRET", { id: secret.id });
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);
});
card.querySelector('[data-action="dismiss"]').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();
const forms = findLoginForms();
for (const { form, usernameInput, passwordInput } of forms) {
// Autofill card
if (secretsForDomain.length > 0 && usernameInput) {
const bestMatch = secretsForDomain[0];
showAutofillCard(form, usernameInput, passwordInput, bestMatch);
}
// Save interceptor
attachSubmitInterceptor(form, usernameInput, passwordInput);
}
}
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;
}