/** * 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'; 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(' '); return CRED_HINTS.test(attrs); } function findUsernameField(pwField) { // Helper: accept email/tel inputs unconditionally; text inputs only when // they look like a genuine credential field. function isCredentialType(el) { if (el.type === 'email' || el.type === 'tel') return true; if (el.type === 'text') return _isLikelyUsernameField(el); return false; } // 1. Walk backwards through all inputs in DOM order. const all = Array.from(document.querySelectorAll('input')); const idx = all.indexOf(pwField); for (let i = idx - 1; i >= 0; i--) { const el = all[i]; if (!isVisible(el) || el.disabled) continue; if (isCredentialType(el)) return el; } // 2. Fallback: search within the same form / ancestor container. // Prefer email inputs first, then scored text/tel inputs. const container = pwField.closest('form') || pwField.closest('[role="form"]') || pwField.parentElement; if (container) { const emailCandidate = container.querySelector('input[type="email"]:not([disabled])'); if (emailCandidate && isVisible(emailCandidate)) return emailCandidate; const textTelInputs = Array.from( container.querySelectorAll('input[type="text"]:not([disabled]), input[type="tel"]:not([disabled])') ); const scored = textTelInputs.filter(el => isVisible(el) && _isLikelyUsernameField(el)); if (scored.length) return scored[0]; } return null; } // ── Framework-compatible fill ───────────────────────────────────────────────── function fillField(el, value) { const nativeSet = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; if (nativeSet) nativeSet.call(el, value); else el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } function doAutofill(username, password) { const pwFields = visiblePasswordFields(); if (!pwFields.length) return; const pwField = pwFields[0]; const usernameField = findUsernameField(pwField); if (usernameField && username) fillField(usernameField, username); if (password) fillField(pwField, password); [usernameField, pwField].filter(Boolean).forEach(el => { el.style.outline = '2px solid #c0392b'; setTimeout(() => { el.style.outline = ''; }, 1500); }); } // ── Icon button (fixed-position, outside the DOM tree of the field) ─────────── /** * Position the icon button over the right edge of `field` using fixed coords. * This never touches the field's parent, so it can't break any layout. */ function positionBtn(btn, field) { const rect = field.getBoundingClientRect(); if (rect.width === 0) { btn.style.display = 'none'; return; } btn.style.display = 'flex'; btn.style.top = (rect.top + rect.height / 2 - 13) + 'px'; btn.style.left = (rect.right - 30) + 'px'; } // createIconBtn is defined in the Field decoration section below. // ── Suggestion dropdown ─────────────────────────────────────────────────────── function removeDropdown() { const el = document.getElementById(PK_DROPDOWN_ID); if (el) el.remove(); } /** * Build the dropdown anchored below `anchorField`. * Uses _matchingItems which is kept fresh via storage.onChanged listener. * `panel` is either 'credentials' (main list) or 'more' (options menu). */ async function showDropdown(anchorField, pwField, filterText, panel) { removeDropdown(); // Use module-level _matchingItems (kept fresh by storage.onChanged). // If still empty, try a direct storage read as last resort. var freshItems = _matchingItems; if (!freshItems.length) { try { var result = await chrome.storage.session.get('vault_items_cs'); var all = (result && result.vault_items_cs) || []; freshItems = _filterForHost(all); if (freshItems.length) _matchingItems = freshItems; } catch (e) { } } const rect = anchorField.getBoundingClientRect(); const dropWidth = Math.max(260, rect.width); const dropdown = document.createElement('div'); dropdown.id = PK_DROPDOWN_ID; Object.assign(dropdown.style, { position: 'fixed', top: (rect.bottom + 4) + 'px', left: rect.left + 'px', width: dropWidth + 'px', background: '#fff', border: '1px solid #dadce0', borderRadius: '10px', boxShadow: '0 6px 24px rgba(0,0,0,0.18)', zIndex: '2147483647', fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", fontSize: '13px', overflow: 'hidden', }); if (panel === 'more') { buildMorePanel(dropdown, anchorField, pwField, freshItems, filterText); } else { buildCredentialsPanel(dropdown, anchorField, pwField, freshItems, filterText); } document.body.appendChild(dropdown); // Reposition on scroll/resize so it stays under the field. function reposition() { const r = anchorField.getBoundingClientRect(); dropdown.style.top = (r.bottom + 4) + 'px'; dropdown.style.left = r.left + 'px'; } window.addEventListener('scroll', reposition, { passive: true, capture: true }); window.addEventListener('resize', reposition, { passive: true }); // Close on outside mousedown or keyboard navigation. function onOutside(e) { const btn = _fieldBtnMap.get(anchorField); if (dropdown.contains(e.target) || e.target === anchorField || (btn && btn.contains(e.target))) return; removeDropdown(); document.removeEventListener('mousedown', onOutside, true); document.removeEventListener('keydown', onKeydown, true); } // Keyboard navigation: Arrow keys move focus between rows; Enter selects; Escape closes. function onKeydown(e) { if (e.key === 'Escape') { removeDropdown(); document.removeEventListener('mousedown', onOutside, true); document.removeEventListener('keydown', onKeydown, true); return; } if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Enter') return; // Only navigate credential rows (divs with data-pk-row attribute). var rows = Array.from(dropdown.querySelectorAll('[data-pk-row]')); if (!rows.length) return; e.preventDefault(); // prevent the field from scrolling the page if (e.key === 'Enter') { var focused = dropdown.querySelector('[data-pk-row].pk-row-focused'); if (focused && focused._pkFill) focused._pkFill(); return; } var currentIdx = rows.findIndex(function (r) { return r.classList.contains('pk-row-focused'); }); var nextIdx; if (e.key === 'ArrowDown') { nextIdx = currentIdx < rows.length - 1 ? currentIdx + 1 : 0; } else { nextIdx = currentIdx > 0 ? currentIdx - 1 : rows.length - 1; } rows.forEach(function (r) { r.classList.remove('pk-row-focused'); r.style.background = ''; }); rows[nextIdx].classList.add('pk-row-focused'); rows[nextIdx].style.background = '#e8f0fe'; rows[nextIdx].scrollIntoView({ block: 'nearest' }); } setTimeout(function () { document.addEventListener('mousedown', onOutside, true); document.addEventListener('keydown', onKeydown, true); }, 0); } // ── Credentials panel (main list) ──────────────────────────────────────────── function buildCredentialsPanel(dropdown, anchorField, pwField, items, filterText) { const q = (filterText || '').trim().toLowerCase(); const filtered = q ? items.filter(function (item) { return ((item.plain && item.plain.username) || '').toLowerCase().includes(q) || item.name.toLowerCase().includes(q); }) : items; const usernameField = anchorField.type === 'password' ? findUsernameField(anchorField) : anchorField; if (filtered.length === 0) { // No saved passwords — show a minimal "no items" row + More options. const empty = document.createElement('div'); Object.assign(empty.style, { padding: '12px 14px', color: '#5f6368', fontSize: '12px', }); empty.textContent = q ? 'No matches found.' : 'No saved passwords for this site.'; dropdown.appendChild(empty); } else { filtered.forEach(function (item) { const row = document.createElement('div'); row.setAttribute('data-pk-row', '1'); // enables keyboard navigation Object.assign(row.style, { display: 'flex', alignItems: 'center', gap: '10px', padding: '10px 14px', cursor: 'pointer', transition: 'background 0.1s', }); row.onmouseenter = function () { if (!row.classList.contains('pk-row-focused')) row.style.background = '#f1f3f4'; }; row.onmouseleave = function () { if (!row.classList.contains('pk-row-focused')) row.style.background = ''; }; // Derive display hostname. var siteHost = item.name; if (item.plain && item.plain.url) { try { siteHost = new URL(item.plain.url).hostname.replace(/^www\./, ''); } catch (e) { } } var username = escHtml((item.plain && item.plain.username) || ''); var site = escHtml(siteHost); // Lock icon avatar — filled dark circle like the screenshot. var avatar = document.createElement('div'); Object.assign(avatar.style, { width: '34px', height: '34px', borderRadius: '50%', background: '#1a1a2e', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: '0', }); avatar.innerHTML = '' + '' + '' + ''; // Text. var text = document.createElement('div'); text.style.cssText = 'flex:1;min-width:0;'; text.innerHTML = '
' + site + '
' + (username ? '
' + username + '
' : ''); // Edit pencil. var editBtn = document.createElement('button'); Object.assign(editBtn.style, { background: 'none', border: 'none', cursor: 'pointer', padding: '5px', color: '#1a73e8', display: 'flex', alignItems: 'center', flexShrink: '0', borderRadius: '4px', }); editBtn.title = 'Edit in PassKeeper'; editBtn.innerHTML = '' + '' + '' + ''; editBtn.addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); chrome.runtime.sendMessage({ type: 'OPEN_VAULT' }).catch(function () { }); removeDropdown(); }); row.appendChild(avatar); row.appendChild(text); row.appendChild(editBtn); // Shared fill action — used by both mousedown and keyboard Enter. function doFill() { if (usernameField && item.plain && item.plain.username) fillField(usernameField, item.plain.username); if (pwField && item.plain && item.plain.password) fillField(pwField, item.plain.password); // ✓ Filled flash: replace row content briefly before closing. row.innerHTML = '
' + '' + '' + 'Filled
'; row.style.background = '#f0fdf4'; setTimeout(function () { removeDropdown(); if (pwField && anchorField !== pwField) pwField.focus(); }, 600); } row.addEventListener('mousedown', function (e) { if (e.target === editBtn || editBtn.contains(e.target)) return; e.preventDefault(); doFill(); }); // Expose doFill for the keyboard Enter handler via a custom property. row._pkFill = doFill; dropdown.appendChild(row); }); } // Divider + "More options…" footer — always shown. var divider = document.createElement('div'); divider.style.cssText = 'height:1px;background:#e8eaed;'; dropdown.appendChild(divider); var more = document.createElement('div'); Object.assign(more.style, { display: 'flex', alignItems: 'center', gap: '10px', padding: '10px 14px', cursor: 'pointer', color: '#202124', fontSize: '13px', transition: 'background 0.1s', }); more.onmouseenter = function () { more.style.background = '#f1f3f4'; }; more.onmouseleave = function () { more.style.background = ''; }; more.innerHTML = '' + '' + '' + '' + '' + 'More options\u2026'; more.addEventListener('mousedown', function (e) { e.preventDefault(); showDropdown(anchorField, pwField, filterText, 'more'); }); dropdown.appendChild(more); } // ── More options panel ──────────────────────────────────────────────────────── function buildMorePanel(dropdown, anchorField, pwField, items, filterText) { // Back header. var backRow = document.createElement('div'); Object.assign(backRow.style, { display: 'flex', alignItems: 'center', gap: '6px', padding: '10px 14px', cursor: 'pointer', color: '#1a73e8', fontSize: '13px', fontWeight: '600', borderBottom: '1px solid #e8eaed', transition: 'background 0.1s', }); backRow.onmouseenter = function () { backRow.style.background = '#f1f3f4'; }; backRow.onmouseleave = function () { backRow.style.background = ''; }; backRow.innerHTML = '' + '' + ' Back'; backRow.addEventListener('mousedown', function (e) { e.preventDefault(); showDropdown(anchorField, pwField, filterText, 'credentials'); }); dropdown.appendChild(backRow); // Menu items matching the screenshot. var menuItems = [ { icon: '', label: 'Report a problem', action: function () { chrome.runtime.sendMessage({ type: 'OPEN_VAULT' }).catch(function () { }); removeDropdown(); }, }, { icon: '', label: 'Generate a password', chevron: true, action: function () { chrome.runtime.sendMessage({ type: 'OPEN_GENERATOR' }).catch(function () { }); removeDropdown(); }, }, { icon: '', label: 'Open my vault', action: function () { chrome.runtime.sendMessage({ type: 'OPEN_VAULT' }).catch(function () { }); removeDropdown(); }, }, ]; menuItems.forEach(function (item) { var row = document.createElement('div'); Object.assign(row.style, { display: 'flex', alignItems: 'center', gap: '12px', padding: '11px 14px', cursor: 'pointer', color: '#202124', fontSize: '13px', transition: 'background 0.1s', borderBottom: '1px solid #f3f4f6', }); row.onmouseenter = function () { row.style.background = '#f1f3f4'; }; row.onmouseleave = function () { row.style.background = ''; }; var iconWrap = document.createElement('div'); iconWrap.style.cssText = 'width:18px;height:18px;display:flex;align-items:center;justify-content:center;flex-shrink:0;'; iconWrap.innerHTML = '' + item.icon + ''; var label = document.createElement('span'); label.style.cssText = 'flex:1;'; label.textContent = item.label; row.appendChild(iconWrap); row.appendChild(label); if (item.chevron) { var chev = document.createElement('div'); chev.innerHTML = '' + '' + ''; row.appendChild(chev); } else { var extIcon = document.createElement('div'); extIcon.innerHTML = '' + '' + '' + ''; row.appendChild(extIcon); } row.addEventListener('mousedown', function (e) { e.preventDefault(); item.action(); }); dropdown.appendChild(row); }); } // ── Field decoration ────────────────────────────────────────────────────────── // Map from field element → AbortController so we can cancel its listeners on re-decoration. const _fieldAbortMap = new WeakMap(); function decorateField(field, pwField) { if (field.getAttribute(PK_ATTR)) return; field.setAttribute(PK_ATTR, '1'); // Cancel any previous listeners on this field. const prevAC = _fieldAbortMap.get(field); if (prevAC) prevAC.abort(); const ac = new AbortController(); _fieldAbortMap.set(field, ac); const sig = ac.signal; createIconBtn(field, pwField, sig); } function createIconBtn(field, pwField, abortSignal) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = PK_BTN_CLASS; btn.title = 'PassKeeper autofill'; btn.setAttribute('aria-label', 'Autofill with PassKeeper'); btn.style.cssText = [ 'position:fixed', 'width:26px', 'height:26px', 'background:#c0392b', 'border:none', 'border-radius:5px', 'cursor:pointer', 'display:flex', 'align-items:center', 'justify-content:center', 'z-index:2147483646', 'padding:0', 'box-shadow:0 1px 4px rgba(0,0,0,0.3)', 'transition:background 0.15s', ].join(';'); btn.innerHTML = '' + '' + '' + '' + ''; btn.addEventListener('mouseenter', function () { btn.style.background = '#a93226'; }); btn.addEventListener('mouseleave', function () { btn.style.background = '#c0392b'; }); positionBtn(btn, field); document.body.appendChild(btn); _fieldBtnMap.set(field, btn); // Remove the button when the AbortController fires (re-decoration). abortSignal.addEventListener('abort', function () { btn.remove(); _fieldBtnMap.delete(field); }); // Keep button tracked as page scrolls/resizes. function reposition() { if (document.body.contains(btn)) positionBtn(btn, field); } window.addEventListener('scroll', reposition, { passive: true, signal: abortSignal }); window.addEventListener('resize', reposition, { passive: true, signal: abortSignal }); // ── All event handlers read _matchingItems at call time, never from closure ── // Show dropdown on focus — reads vault_items fresh from storage each time. field.addEventListener('focus', function () { showDropdown(field, pwField, field.value, 'credentials'); }, { signal: abortSignal }); // Re-filter as user types — debounced to avoid rebuilding the dropdown // on every single keystroke (noticeable on large vaults or slow machines). var _debouncedShow = _debounce(function () { showDropdown(field, pwField, field.value, 'credentials'); }, 150); field.addEventListener('input', _debouncedShow, { signal: abortSignal }); // Dim button when field loses focus and no dropdown is open. field.addEventListener('blur', function () { setTimeout(function () { if (!document.getElementById(PK_DROPDOWN_ID)) btn.style.opacity = '0.4'; }, 150); }, { signal: abortSignal }); field.addEventListener('focus', function () { btn.style.opacity = '1'; }, { signal: abortSignal }); // Icon click: toggle dropdown. btn.addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); if (document.getElementById(PK_DROPDOWN_ID)) { removeDropdown(); } else { showDropdown(field, pwField, field.value, 'credentials'); } }); return btn; } /** * Decorate all visible password (and paired username) fields with the PassKeeper * icon button. * * @param {Array|null} knownItems When the caller already holds the correct * filtered item list (e.g. from a storage.onChanged newValue or a VAULT_UPDATED * message payload), pass it here to skip the redundant storage read. * Pass null/undefined to let this function read storage itself. */ async function decorateFields(knownItems) { if (knownItems != null) { // Caller supplied items — trust them and skip the storage round-trip. _matchingItems = knownItems; console.log('[PassKeeper] decorateFields (inline): host=' + location.hostname.replace(/^www\./, '') + ', matched=' + _matchingItems.length); } else { // Read from chrome.storage.session — memory-only, cleared on browser close. // Decrypted vault data must never be written to persistent (local) storage. var all = []; try { var result = await chrome.storage.session.get('vault_items_cs'); all = (result && result.vault_items_cs) || []; } catch (e) { } _matchingItems = _filterForHost(all); console.log('[PassKeeper] decorateFields (storage): host=' + location.hostname.replace(/^www\./, '') + ', matched=' + _matchingItems.length + ' of ' + all.length + ' items'); } // Decorate every visible password field and its paired username field. visiblePasswordFields().forEach(function (pwField) { var usernameField = findUsernameField(pwField); if (usernameField) decorateField(usernameField, pwField); decorateField(pwField, pwField); }); } // ── Vault item helpers ──────────────────────────────────────────────────────── /** * Normalise a stored URL string so it is always parseable by `new URL()`. * Handles bare domains ("github.com"), protocol-relative ("//github.com"), * and fully-formed URLs ("https://github.com") identically. */ function _normaliseUrl(raw) { if (!raw) return null; var s = raw.trim(); if (/^https?:\/\//i.test(s)) return s; // already has a scheme if (s.startsWith('//')) return 'https:' + s; // protocol-relative return 'https://' + s; // bare domain or path } function _filterForHost(items) { var host = location.hostname.replace(/^www\./, ''); return (items || []).filter(function (item) { if (item.item_type !== 'password' || !(item.plain && item.plain.url)) return false; try { var normalised = _normaliseUrl(item.plain.url); if (!normalised) return false; var h = new URL(normalised).hostname.replace(/^www\./, ''); // Match exact domain or any subdomain relationship. return h === host || h.endsWith('.' + host) || host.endsWith('.' + h); } catch (e) { console.warn('[PassKeeper] _filterForHost: could not parse URL:', item.plain.url, e.message); return false; } }); } // ── Form detection ──────────────────────────────────────────────────────────── function notifyFormDetected() { if (_hasNotifiedForm) return; if (!visiblePasswordFields().length) return; _hasNotifiedForm = true; chrome.runtime.sendMessage({ type: 'FORMS_DETECTED' }).catch(function () { }); } // ── Duplicate detection ─────────────────────────────────────────────────────── async function classifyCredentials(username, password) { var all = []; try { var result = await chrome.storage.session.get('vault_items_cs'); all = (result && result.vault_items_cs) || []; } catch (e) { return 'new'; } if (!all.length) return 'new'; var siteItems = _filterForHost(all); if (!siteItems.length) return 'new'; var exactMatch = siteItems.some(function (item) { return item.plain && item.plain.username === username && item.plain.password === password; }); return exactMatch ? 'same' : 'updated'; } // ── Save blocklist ──────────────────────────────────────────────────────────── const BLOCKLIST_KEY = 'save_blocklist'; async function isBlocked(hostname) { try { var result = await chrome.storage.local.get(BLOCKLIST_KEY); var list = (result && result[BLOCKLIST_KEY]) || []; return list.indexOf(hostname) !== -1; } catch (e) { return false; } } async function addToBlocklist(hostname) { try { var result = await chrome.storage.local.get(BLOCKLIST_KEY); var list = (result && result[BLOCKLIST_KEY]) || []; if (list.indexOf(hostname) === -1) { list.push(hostname); await chrome.storage.local.set({ [BLOCKLIST_KEY]: list }); console.log('[PassKeeper] Added to save blocklist:', hostname); } } catch (e) { } } // ── Auto-save banner ────────────────────────────────────────────────────────── function showSaveBanner(username, password, credentialState) { if (_bannerEl) _bannerEl.remove(); var banner = document.createElement('div'); banner.id = '__pk_save_banner__'; Object.assign(banner.style, { position: 'fixed', top: '12px', right: '12px', zIndex: '2147483647', background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: '10px', boxShadow: '0 8px 30px rgba(0,0,0,0.15)', padding: '14px 16px 12px', fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", fontSize: '13px', color: '#1a1a2e', maxWidth: '300px', minWidth: '240px', }); var site = escHtml(location.hostname); var user = escHtml(username); // Default site name: prefer page title (trimmed), fall back to hostname. var defaultSiteName = (document.title || '').trim().slice(0, 60) || location.hostname; var title = credentialState === 'updated' ? 'Update in PassKeeper?' : 'Save to PassKeeper?'; banner.innerHTML = '
' + '' + '' + escHtml(title) + '' + '' + '
' + '
' + '' + '' + '
' + '

' + '' + user + ' on ' + site + '' + '

' + '
' + '' + '' + '
' + ''; document.body.appendChild(banner); _bannerEl = banner; var dismiss = function () { if (_bannerEl === banner) { banner.remove(); _bannerEl = null; } }; banner.querySelector('#__pk_close__').addEventListener('click', dismiss); banner.querySelector('#__pk_skip__').addEventListener('click', dismiss); banner.querySelector('#__pk_save__').addEventListener('click', function () { var siteName = (banner.querySelector('#__pk_site_name__').value || '').trim() || location.hostname; console.log('[PassKeeper] User chose to save credentials for', location.hostname, '— site name:', siteName); chrome.runtime.sendMessage({ type: 'SAVE_CREDENTIALS', data: { url: location.href, siteName: siteName, username: username, password: password }, }).catch(function () { }); dismiss(); }); banner.querySelector('#__pk_never__').addEventListener('click', function () { addToBlocklist(location.hostname); dismiss(); }); } // ── Form submission watch ───────────────────────────────────────────────────── function watchSubmissions() { document.addEventListener('submit', async function (e) { var form = e.target; var pwField = form.querySelector('input[type="password"]:not([disabled])'); if (!pwField || !pwField.value) return; var userField = findUsernameField(pwField) || form.querySelector('input[type="email"]:not([disabled])') || form.querySelector('input[type="text"]:not([disabled])'); var username = (userField && userField.value && userField.value.trim()) || ''; var password = pwField.value; if (!username || !password) return; removeDropdown(); // Check blocklist before doing anything else. if (await isBlocked(location.hostname)) { console.log('[PassKeeper] Site is blocklisted, skipping save banner:', location.hostname); return; } var credentialState = await classifyCredentials(username, password); console.log('[PassKeeper] Credential state for', location.hostname, '\u2192', credentialState); if (credentialState === 'same') return; setTimeout(function () { showSaveBanner(username, password, credentialState); }, 500); }, true); } // ── Message listener ────────────────────────────────────────────────────────── chrome.runtime.onMessage.addListener(function (msg, _sender, sendResponse) { if (msg.type === 'DO_AUTOFILL') { doAutofill(msg.username, msg.password); sendResponse({ ok: true }); } if (msg.type === 'VAULT_UPDATED') { // Items may arrive in the message payload (best-effort), but the source // of truth is now chrome.storage.session which was already written by the popup. var allItems = msg.vault_items || []; var matched = allItems.length ? _filterForHost(allItems) : null; if (matched !== null) { _matchingItems = matched; console.log('[PassKeeper] VAULT_UPDATED (message): matched=' + _matchingItems.length + ' of ' + allItems.length); } // Re-decorate. Pass matched items so decorateFields skips the storage read // when the message payload was non-empty; fall back to storage otherwise. document.querySelectorAll('[' + PK_ATTR + ']').forEach(function (el) { var ac = _fieldAbortMap.get(el); if (ac) ac.abort(); el.removeAttribute(PK_ATTR); }); document.querySelectorAll('.' + PK_BTN_CLASS).forEach(function (el) { el.remove(); }); removeDropdown(); decorateFields(matched); } return false; }); // ── Init ────────────────────────────────────────────────────────────────────── function init() { notifyFormDetected(); decorateFields(); watchSubmissions(); // React instantly when the popup writes fresh vault data to local storage. // This fires in the same tick as the write — no message delivery required. chrome.storage.onChanged.addListener(function (changes, area) { if (area === 'session' && changes.vault_items_cs) { var allItems = (changes.vault_items_cs.newValue) || []; var matched = _filterForHost(allItems); _matchingItems = matched; console.log('[PassKeeper] storage.onChanged: matched=' + matched.length + ' of ' + allItems.length + ' items'); // Re-decorate, passing the already-filtered list to avoid a redundant storage read. document.querySelectorAll('[' + PK_ATTR + ']').forEach(function (el) { var ac = _fieldAbortMap.get(el); if (ac) ac.abort(); el.removeAttribute(PK_ATTR); }); document.querySelectorAll('.' + PK_BTN_CLASS).forEach(function (el) { el.remove(); }); removeDropdown(); decorateFields(matched); } }); _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) ); }); }); if (ownMutation) return; _hasNotifiedForm = false; notifyFormDetected(); decorateFields(); }); _formObserver.observe(document.body, { childList: true, subtree: true }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();