42d2d6fda5
The busy guard cancelled through the path that hands the foreground back to the captured target. During an export that pushed the native Save dialog behind the other application, leaving the app disabled by a modal the user could no longer find. refocus=0 separates refusing a request from ending a user interaction.
395 lines
16 KiB
JavaScript
395 lines
16 KiB
JavaScript
// ============================================================
|
|
// 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.
|
|
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();
|
|
// 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();
|
|
}
|
|
|