Files
Password-Manager/js/app.autofill.js
T
r-zakarya 4a47caad55 fix(autofill): honest result reporting + restore maximized from tray
Bug 1: a maximized window trayed via the quick-search fill flow came back
"normal" on the next restore. ExecuteAutofill minimizes the window BEFORE
MinimizeToTray snapshots the placement, so the snapshot said SHOWMINIMIZED
and the never-restore-minimized guard forced SHOWNORMAL. Now honours
WPF_RESTORETOMAXIMIZED (Windows keeps the pre-minimize state in flags).

Bug 2: filling into an elevated app (admin Notepad) showed "password sent"
while UIPI silently discarded the keystrokes (SendInput even reports
success). ExecuteAutofill is now a function: it checks the target process
elevation up front (can't-open counts as elevated) and returns False without
typing. UMainForm feeds the result to JS via Bridge.onAutofillResult; the
quick-search success toast is deferred until Delphi confirms, and a failure
shows "Autofill blocked - the target window runs as administrator".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:19:34 +01:00

294 lines
11 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 };
// 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();
}