a602d05b84
autofillFillEntry (Ctrl+Shift+L/P path) still toasted "Password filled:" optimistically alongside the honest UIPI failure toast. Route it through autofillPendingToast / Bridge.onAutofillResult like the quick-search path, and label with entryDisplayName (site is often empty -> "filled:" + nothing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
297 lines
12 KiB
JavaScript
297 lines
12 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 || '');
|
|
// 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 === '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();
|
|
}
|
|
|