/** * extension/content/content.js — PassKeeper content script. * * 1. Detects login forms → notifies background (badge count). * 2. Injects a PassKeeper icon button OUTSIDE the DOM (position:fixed, tracked * to the field via scroll/resize) into username AND password fields. * This avoids breaking site layouts (flex/grid parents, React-controlled inputs). * 3. Clicking the icon OR focusing a decorated field shows a suggestion dropdown. * 4. "More options…" shows a second panel with vault/generator actions. * 5. Listens for DO_AUTOFILL from the popup → fills fields. * 6. Watches form submissions → shows save-credentials banner. */ (() => { "use strict"; const PK_ATTR = "data-pk-decorated"; const PK_BTN_CLASS = "__pk_btn__"; 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; let _matchingItems = []; // Map from field element → its fixed-position icon button element const _fieldBtnMap = new WeakMap(); // ── Helpers ────────────────────────────────────────────────────────────────── function escHtml(str) { return String(str ?? "") .replace(/&/g, "&") .replace(//g, ">"); } /** * More robust visibility check than offsetParent (which fails for * position:fixed elements and some modern layouts). */ function isVisible(el) { if (!el || !el.getBoundingClientRect) return false; if (el.disabled) return false; const rect = el.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) return false; const style = window.getComputedStyle(el); if ( style.display === "none" || style.visibility === "hidden" || style.opacity === "0" ) return false; return true; } /** * Returns a debounced version of `fn` that waits `ms` milliseconds after * the last call before firing. Used to avoid re-rendering the dropdown on * every keystroke. */ function _debounce(fn, ms) { var timer; return function () { var args = arguments; var ctx = this; clearTimeout(timer); timer = setTimeout(function () { fn.apply(ctx, args); }, ms); }; } function visiblePasswordFields() { return Array.from( document.querySelectorAll('input[type="password"]'), ).filter((el) => isVisible(el) && !el.disabled); } /** * Returns true only if the input field carries signals suggesting it * collects a credential (username / email / phone) — not a generic * text field such as a search box, full-name field, or address field. * * Scoring precedence: * 1. autocomplete="username"|"email"|"tel" → definite YES * 2. Non-credential autocomplete value → definite NO * 3. name / id / placeholder / aria-label contain a credential keyword → YES * 4. Otherwise → NO (do not decorate) */ function _isLikelyUsernameField(el) { const CRED_HINTS = /user|email|mail|login|phone|tel|mobile|account/i; const ac = (el.getAttribute("autocomplete") || "").toLowerCase().trim(); // Strongest positive signal. if (["username", "email", "tel"].includes(ac)) return true; // Definite negative signals (Chrome's autocomplete token set). const NON_CRED_AC = /^(name|given-name|family-name|additional-name|honorific-prefix|honorific-suffix|organization|street-address|address-line[123]|address-level[1234]|country|country-name|postal-code|cc-|transaction-|language|bday|sex|url|photo|search|new-password|current-password|one-time-code|off)$/i; if (ac && NON_CRED_AC.test(ac)) return false; // Check name, id, placeholder, and aria-label for credential keywords. const attrs = [ el.getAttribute("name") || "", el.getAttribute("id") || "", el.getAttribute("placeholder") || "", el.getAttribute("aria-label") || "", ].join(" "); 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