// ============================================================ // 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 }; // The five configurable global hotkeys: slot id → Settings button + label. // Delphi reports registration failures by slot id; this is the single place // mapping them to UI, so a new hotkey only has to be added here. const HOTKEY_SLOTS = { full: { btn: '#settingAutofillFullCombo', label: 'Autofill (full)' }, password: { btn: '#settingAutofillPwdCombo', label: 'Password only' }, totp: { btn: '#settingAutofillTotpCombo', label: '2FA code' }, quickSearch: { btn: '#settingQuickSearchCombo', label: 'Quick search' }, newEntry: { btn: '#settingNewEntryCombo', label: 'New entry' }, }; // Flag the Settings rows whose combo Windows refused. Re-applied whenever the // panel is (re)rendered, so the warning outlives the toast. function renderHotkeyConflicts() { const bad = new Set(state.hotkeyConflicts || []); for (const [slot, def] of Object.entries(HOTKEY_SLOTS)) { const btn = document.querySelector(def.btn); if (!btn) continue; const clash = bad.has(slot); btn.classList.toggle('is-conflict', clash); if (clash) btn.title = 'This combo is already used by another app — ' + 'the hotkey does nothing. Pick another one.'; else btn.removeAttribute('title'); } // Dot on the Settings button: the ⚠ inside the panel only helps someone who // opens it, and the toast may well have been missed. This is the only cue // left in the main UI that a hotkey is silently dead. const gear = document.getElementById('settingsBtn'); if (gear) { gear.classList.toggle('has-alert', bad.size > 0); if (bad.size > 0) gear.title = 'A hotkey combo is already used by another app — see Autofill settings'; else if (gear.title.startsWith('A hotkey combo')) gear.title = 'Settings'; } } // 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. // Set by the startup push so the Delphi callback can stay quiet about // success — "Hotkeys updated" on every unlock is noise. A CLASH is still // reported: it's actionable and the user has no other way to learn about it. let hotkeyPushSilent = false; function autofillPushHotkeys(silent) { hotkeyPushSilent = !!silent; localStorage.setItem('autofillHotkeyFull', JSON.stringify(state.autofillHotkeyFull)); localStorage.setItem('autofillHotkeyPwd', JSON.stringify(state.autofillHotkeyPwd)); localStorage.setItem('autofillHotkeyTotp', JSON.stringify(state.autofillHotkeyTotp)); localStorage.setItem('quickSearchHotkey', JSON.stringify(state.quickSearchHotkey)); localStorage.setItem('newEntryHotkey', JSON.stringify(state.newEntryHotkey)); if (Bridge.active) { Bridge.setAutofillHotkeys(state.autofillEnabled, { full: state.autofillHotkeyFull, password: state.autofillHotkeyPwd, totp: state.autofillHotkeyTotp, quickSearch: state.quickSearchHotkey, newEntry: state.newEntryHotkey, }); } } // 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; // A blocking operation owns the screen (export, sync, rotation). Covers all // three hotkeys and both outcomes: the multi-match picker would open behind // the overlay, unreachable, and a single-match fill would minimise our // window and hand the foreground away mid-operation. cancelAutofill so // Delphi doesn't keep the captured target HWND for a fill that never comes. if (typeof isBusy === 'function' && isBusy()) { // refocus=false: refusing must not move any window. During an export a // native Save dialog holds the foreground, and shoving it behind leaves // the app disabled by a modal the user can no longer find. Bridge.cancelAutofill(false); toast('Busy — try again in a moment', 'warning'); 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. // refocus=false: the default hands the foreground back to the target, // which focusApp would then have to take away again — two window moves // to end where one gets us, and the round trip is visible. Bridge.cancelAutofill(false); 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(); // The 2FA hotkey can only serve entries that actually carry a TOTP secret // — filtering here (not after scoring) also means the picker never lists a // candidate that would fail once chosen. const pool = (kind === 'totp') ? state.entries.filter(e => e.totp_secret && e.totp_iv) : state.entries; const scored = pool .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(kind === 'totp' ? 'No 2FA code stored for "' + windowTitle.slice(0, 40) + '"' : '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' | 'password' | 'totp'. async function autofillFillEntry(entry, kind) { // 2FA: type the current 6-digit code instead of the password. Sent in the // password slot with an empty username, so Delphi's password-only path // types it as-is — no new native code path. if (kind === 'totp') { const secret = await decryptTotpSecret(entry.totp_secret, entry.totp_iv); if (!secret || secret === '[ERROR]') { toast('2FA: cannot decrypt the secret', 'error'); Bridge.cancelAutofill(); return; } let code; try { code = (await generateTOTP(secret)).code; } catch (e) { toast('2FA: invalid secret for ' + entryDisplayName(entry), 'error'); Bridge.cancelAutofill(); return; } autofillPendingToast = '2FA code filled: ' + entryDisplayName(entry); Bridge.executeAutofill('', code); fetch('' + '/audit', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ action: 'autofill_totp', site: entry.site }), }).catch(() => {}); return; } 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 || ''); // Toast deferred to Bridge.onAutofillResult — no success lie when the // target runs elevated (UIPI drops the keystrokes). autofillPendingToast = (kind === 'password' ? 'Password filled: ' : 'Autofilled: ') + entryDisplayName(entry); Bridge.executeAutofill(user, password); // 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 === 'totp' ? 'Pick entry (2FA code) — ' : 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(); }