05/09 Update: make hooks smarter

This commit is contained in:
Nguyen Ngo
2026-05-09 15:41:10 -04:00
parent 267c36af4d
commit 312c27f31a
+80 -23
View File
@@ -18,6 +18,10 @@
const PK_DROPDOWN_ID = "__pk_dropdown__";
const VAULT_URL = "https://pwkeeper.ngodanguyen.tech/vault";
// Never inject on the PassKeeper vault itself — our own inputs would get decorated.
const OWN_ORIGINS = ["pwkeeper.ngodanguyen.tech"];
if (OWN_ORIGINS.includes(location.hostname)) return;
let _bannerEl = null;
let _hasNotifiedForm = false;
let _formObserver = null;
@@ -107,7 +111,41 @@
el.getAttribute("aria-label") || "",
].join(" ");
return CRED_HINTS.test(attrs);
if (!CRED_HINTS.test(attrs)) return false;
// Final gate: require a password field to be nearby (same form, or within
// 5 ancestor levels) — this prevents hooking standalone search / filter
// inputs that happen to carry a name like "user" or "email".
return _hasPasswordSibling(el);
}
/**
* Returns true when `el` shares a form (or close ancestor) with at least one
* visible password input. This is the key signal that we are on a login form,
* not a generic site-search or profile page.
*/
function _hasPasswordSibling(el) {
// 1. Prefer the explicit <form> ancestor.
const form =
el.closest("form") || el.closest('[role="form"]');
if (form) {
return !!form.querySelector(
'input[type="password"]:not([disabled])',
);
}
// 2. No <form>? Walk up to 5 ancestor elements looking for a password input
// in any subtree (covers React/Vue apps that render outside <form>).
let node = el.parentElement;
for (let i = 0; i < 5 && node; i++, node = node.parentElement) {
if (node.querySelector('input[type="password"]:not([disabled])')) {
return true;
}
}
// 3. Last resort: any visible password field on the entire page.
// Only accept this if there is exactly one password field — avoids
// false-positives on complex pages (account settings, checkout, etc.).
const pwFields = visiblePasswordFields();
return pwFields.length === 1;
}
function findUsernameField(pwField) {
@@ -827,6 +865,11 @@
* Pass nothing/undefined to let this function read storage itself.
*/
async function decorateFields(knownItems) {
// Fast-path: if there are no password fields anywhere on the page, there is
// nothing to decorate. This prevents false-positive hooks on pages with
// standalone text inputs (search bars, filter fields, etc.).
if (!visiblePasswordFields().length) return;
if (knownItems != null) {
// Caller supplied pre-filtered items — trust them, skip the storage read.
_matchingItems = knownItems;
@@ -1193,30 +1236,44 @@
}
});
// Debounce + narrow the MutationObserver: only re-scan when something that
// looks like a form input or a whole subtree with inputs was added/removed.
// This prevents constant re-scanning on SPA re-renders (tooltip shows,
// React state updates, etc.) that don't touch login form elements.
var _mutationTimer = null;
_formObserver = new MutationObserver(function (mutations) {
// Ignore mutations caused by the extension's own injected elements
// (dropdown, icon buttons, save banner) to prevent re-decoration loops
// on SPAs that react to every DOM change.
var ownMutation = mutations.every(function (m) {
return Array.from(m.addedNodes)
.concat(Array.from(m.removedNodes))
.every(function (node) {
if (!node || node.nodeType !== 1) return true;
var cls = node.className || "";
var id = node.id || "";
return (
cls.indexOf("__pk") !== -1 ||
id.indexOf("__pk") !== -1 ||
(node.querySelector &&
(node.querySelector("." + PK_BTN_CLASS) ||
node.querySelector("#" + PK_DROPDOWN_ID)))
);
});
// Ignore mutations caused by the extension's own injected elements.
var hasRelevantChange = mutations.some(function (m) {
// addedNodes contains an <input>, <form>, or a subtree with either.
return Array.from(m.addedNodes).some(function (node) {
if (!node || node.nodeType !== 1) return false;
// Skip our own injected nodes.
var cls = node.className || "";
var id = node.id || "";
if (cls.indexOf("__pk") !== -1 || id.indexOf("__pk") !== -1)
return false;
// A new <input> or <form> element, or a container with one inside.
var tag = node.tagName;
if (tag === "INPUT" || tag === "FORM") return true;
if (
node.querySelector &&
(node.querySelector('input[type="password"]') ||
node.querySelector("input") ||
node.querySelector("form"))
)
return true;
return false;
});
});
if (ownMutation) return;
_hasNotifiedForm = false;
notifyFormDetected();
decorateFields();
if (!hasRelevantChange) return;
// Debounce: wait 300 ms after the last relevant mutation before scanning.
clearTimeout(_mutationTimer);
_mutationTimer = setTimeout(function () {
_hasNotifiedForm = false;
notifyFormDetected();
decorateFields();
}, 300);
});
_formObserver.observe(document.body, { childList: true, subtree: true });
}