// ============================================================ // app.autofill.js — AUTOFILL module (extracted from app.js, §3.1) // ============================================================ // // Global-hotkey autofill: Win32 combo helpers, browser-title → entry // matching/scoring, request handling and the multi-match picker modal. // Pure declarations (no top-level side effects) → loads BEFORE app.js. // Uses state, Bridge, toast, $ — resolved via shared global scope at // call time. Delphi fires Bridge.onAutofillRequest (declared in app.js). // Win32 modifier flags for RegisterHotKey. const WIN32_MOD = { alt: 0x0001, ctrl: 0x0002, shift: 0x0004, win: 0x0008 }; // Format a combo for human display: "Ctrl+Shift+L". function autofillComboLabel(c) { if (!c || !c.key) return '— not set —'; const parts = []; if (c.ctrl) parts.push('Ctrl'); if (c.alt) parts.push('Alt'); if (c.shift) parts.push('Shift'); if (c.win) parts.push('Win'); parts.push(c.key); return parts.join('+'); } // Convert a combo to the Win32 (mods bitmask, virtual-key code) pair that // Delphi's RegisterHotKey takes. key='A'..'Z'/'0'..'9' → ASCII code; // 'F1'..'F12' → 0x70..0x7B. function autofillComboToWin32(c) { let mods = 0; if (c.ctrl) mods |= WIN32_MOD.ctrl; if (c.alt) mods |= WIN32_MOD.alt; if (c.shift) mods |= WIN32_MOD.shift; if (c.win) mods |= WIN32_MOD.win; let vk = 0; const k = (c.key || '').toUpperCase(); if (/^F([1-9]|1[0-2])$/.test(k)) vk = 0x70 + parseInt(k.slice(1)) - 1; else if (k.length === 1 && k >= 'A' && k <= 'Z') vk = k.charCodeAt(0); else if (k.length === 1 && k >= '0' && k <= '9') vk = k.charCodeAt(0); return { mods, vk }; } // Validate a captured combo. Requires at least one modifier (otherwise a // single key would steal that letter globally) and a valid main key. function autofillComboValid(c) { if (!c) return false; if (!(c.ctrl || c.alt || c.win)) return false; // shift-only is unreliable const w = autofillComboToWin32(c); return w.vk !== 0; } // Capture a key combo from a single keydown event. Returns null if the // event is "incomplete" (only modifiers pressed so far) or Escape. function autofillCaptureFromEvent(e) { const k = e.key; if (k === 'Escape') return 'cancel'; // Ignore pure-modifier keydowns (user is still building the combo). if (k === 'Control' || k === 'Shift' || k === 'Alt' || k === 'Meta' || k === 'OS') return null; // Accept letter, digit, F1-F12. let key = null; if (k.length === 1 && /[a-z0-9]/i.test(k)) { key = k.toUpperCase(); } else if (/^F([1-9]|1[0-2])$/i.test(k)) { key = k.toUpperCase(); } else { return 'invalid'; } return { ctrl: !!e.ctrlKey, shift: !!e.shiftKey, alt: !!e.altKey, win: !!e.metaKey, key, }; } // Push current state to Delphi (toggle + both combos) and persist. // Called after any change so Delphi's RegisterHotKey reflects state. function autofillPushHotkeys() { localStorage.setItem('autofillHotkeyFull', JSON.stringify(state.autofillHotkeyFull)); localStorage.setItem('autofillHotkeyPwd', JSON.stringify(state.autofillHotkeyPwd)); localStorage.setItem('quickSearchHotkey', JSON.stringify(state.quickSearchHotkey)); if (Bridge.active) { Bridge.setAutofillHotkeys(state.autofillEnabled, { full: state.autofillHotkeyFull, password: state.autofillHotkeyPwd, quickSearch: state.quickSearchHotkey, }); } } // Extract a bare hostname from a site string for fuzzy matching. // "https://www.github.com/login" → "github.com" // Strip the browser brand suffix that lives at the end of every tab title // ("Some Page - Google Chrome", "Page — Mozilla Firefox", etc.). Without // this, entries whose site is "google" / "mozilla" / "edge" would match // every single page that has Chrome / Firefox / Edge as the browser brand. const BROWSER_SUFFIX_RE = /\s*[-—–|]\s*(google chrome|chromium|mozilla firefox|firefox|microsoft edge|edge|brave|opera|vivaldi|safari|tor browser|tor|arc)\s*$/i; function autofillStripBrowserSuffix(title) { return (title || '').replace(BROWSER_SUFFIX_RE, '').trim(); } function autofillExtractHost(site) { return site.toLowerCase() .replace(/^https?:\/\//i, '') .replace(/^www\./i, '') .split('/')[0] .split(':')[0]; } // Get the second-level domain (brand part) from a hostname. // "github.com" → "github" ; "mail.google.com" → "google" ; "x.com" → "x" function autofillSLD(host) { const parts = host.split('.').filter(p => p.length > 0); if (parts.length <= 1) return host; return parts[parts.length - 2]; } // Escape a string for safe insertion into a RegExp. function autofillEscapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // Score how well a vault entry matches the foreground window title. // Returns 0 (no match) or a positive integer (higher = better). // // Strategy (browser titles rarely contain the full hostname — usually // just the brand name, e.g. "Sign in to GitHub" or "X. C'est… - Google Chrome"): // 1. Full hostname substring → strongest (score 1000 + len) // 2. SLD ≥3 chars as substring → medium (score 500 + len) // 3. SLD <3 chars as word → weak (score 100), requires word // boundaries to avoid matching "x" inside arbitrary words. function autofillScore(entry, titleLower) { // 1. Display name (entry.title) lowercased substring — strongest brand // match. Skips when title is empty or same as site (already tested // via the site path below). const displayTitle = (entry.title || '').trim().toLowerCase(); if (displayTitle.length >= 2 && titleLower.includes(displayTitle)) return 1500 + displayTitle.length; if (!entry.site) return 0; const host = autofillExtractHost(entry.site); if (host.length < 2) return 0; // 2. Full hostname (rare in tab titles, but strongest URL signal) if (titleLower.includes(host)) return 1000 + host.length; // 3/4. Second-level domain const sld = autofillSLD(host); if (sld.length === 0) return 0; if (sld.length >= 3) { if (titleLower.includes(sld)) return 500 + sld.length; return 0; } // Short SLD ("x", "qq", "vk"…) — require word boundaries so we don't // match the letter inside random words. const re = new RegExp('(^|[^a-z0-9])' + autofillEscapeRegex(sld) + '([^a-z0-9]|$)', 'i'); if (re.test(titleLower)) return 100; return 0; } // Success toast deferred until Delphi confirms the keystrokes were sent. // Quick-search sets this label before calling executeAutofill; Delphi fires // Bridge.onAutofillResult(ok) → we toast the label (ok) or an honest // failure (elevated target — UIPI silently drops our SendInput). let autofillPendingToast = ''; function autofillReportResult(ok) { const label = autofillPendingToast; autofillPendingToast = ''; if (ok) { if (label) toast(label); } else { toast('Autofill blocked — the target window runs as administrator. ' + 'Copy the password instead.', 'error'); } } // Called by Bridge.onAutofillRequest when a hotkey fires. // kind: 'full' = Ctrl+Shift+L (user + Tab + pwd) ; 'password' = Ctrl+Shift+P. async function autofillHandleRequest(windowTitle, kind) { if (!state.autofillEnabled) return; if (!state.cryptoKey || state.locked || !state.token) { // Vault is locked — bring the app to the front so the user can // unlock immediately, rather than silently no-op'ing the hotkey. Bridge.cancelAutofill(); Bridge.focusApp(); setTimeout(() => { const pwd = document.getElementById('loginPassword'); const user = document.getElementById('loginUsername'); if (pwd && !document.getElementById('authScreen').classList.contains('is-hidden')) { if (user && !user.value) user.focus(); else pwd.focus(); } }, 80); toast('Vault is locked — unlock to autofill', 'warning'); return; } const titleLower = autofillStripBrowserSuffix(windowTitle).toLowerCase(); const scored = state.entries .map(e => ({ entry: e, score: autofillScore(e, titleLower) })) .filter(x => x.score > 0) .sort((a, b) => b.score - a.score); if (scored.length === 0) { toast('Autofill: no match for "' + windowTitle.slice(0, 40) + '"', 'warning'); Bridge.cancelAutofill(); return; } if (scored.length === 1) { await autofillFillEntry(scored[0].entry, kind); return; } // Multiple candidates — show picker. kind is captured so clicking a // candidate honours password-only mode. openAutofillPicker(scored.map(x => x.entry), windowTitle, kind); } // Decrypt and type an entry. kind = 'full' or 'password'. async function autofillFillEntry(entry, kind) { const password = await decryptPwd(entry.encrypted_password, entry.iv); if (password === '[ERROR]') { toast('Autofill: decryption error', 'error'); Bridge.cancelAutofill(); return; } // password-only kind → empty username → Delphi skips Tab. // full kind with empty entry.username → also no Tab (Delphi handles it). const user = (kind === 'password') ? '' : (entry.username || ''); Bridge.executeAutofill(user, password); toast((kind === 'password' ? 'Password filled: ' : 'Autofilled: ') + entry.site); // Audit (best-effort, ignore failures) fetch('' + '/audit', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ action: kind === 'password' ? 'autofill_pwd' : 'autofill', site: entry.site, }), }).catch(() => {}); } // Picker modal for multi-match case. kind is forwarded to autofillFillEntry // so the user's hotkey intent (full vs password-only) is preserved through // the manual choice. function openAutofillPicker(entries, windowTitle, kind) { // Bring the app to front so the picker is unambiguously visible — // otherwise the modal opens behind / next to the user's original // window (e.g. Notepad) and easy to miss. ExecuteAutofill restores // the original target HWND via ForceForegroundWindow on selection. Bridge.focusApp(); const list = $('#autofillPickerList'); list.innerHTML = ''; entries.forEach(e => { const btn = el('button', { class: 'autofill-pick-btn', on: { click: async () => { closeAutofillPicker(false); await autofillFillEntry(e, kind); }, }, }); btn.appendChild(el('span', { class: 'autofill-pick-site' }, entryDisplayName(e))); if (e.username) { btn.appendChild(el('span', { class: 'autofill-pick-user' }, e.username)); } list.appendChild(btn); }); const head = (kind === 'password' ? 'Pick entry (password only) — ' : 'Pick entry — ') + entries.length + ' match "' + windowTitle.slice(0, 30) + '…"'; $('#autofillPickerTitle').textContent = head; $('#autofillPickerModal').classList.remove('is-hidden'); } function closeAutofillPicker(notifyCancel = true) { $('#autofillPickerModal').classList.add('is-hidden'); if (notifyCancel) Bridge.cancelAutofill(); }