diff --git a/css/style.css b/css/style.css index 2b5dfc0..973ac0d 100644 --- a/css/style.css +++ b/css/style.css @@ -2617,6 +2617,16 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before { min-width: 140px; text-align: center; } +/* Combo refused by Windows (another app holds it) — the hotkey is dead, so the + row has to say so long after the toast is gone. ⚠ via ::after so no markup + change is needed when the state flips. */ +.hotkey-capture-btn.is-conflict { + border-color: var(--warning, #e0a800); + color: var(--warning, #e0a800); +} +.hotkey-capture-btn.is-conflict::after { + content: " ⚠"; +} .hotkey-capture-btn.is-capturing { background: var(--accent); color: var(--accent-fg, #fff); diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index 21ce2fa..77292da 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -863,10 +863,9 @@ begin // we register both with the supplied combos (replacing any prior). else if ACmd = 'autofill/hotkeys' then begin - // Names of the combos Windows refused (already held by another app), so - // the toast can say WHICH one is dead instead of leaving the user to - // wonder why a hotkey silently does nothing. A combo the user cleared - // (vk=0) is skipped on purpose and never listed here. + // Slot ids of the combos Windows refused (already held by another app). + // Ids, not labels: JS owns the wording AND uses them to flag the exact + // row in Settings. A combo the user cleared (vk=0) is never listed. var LFailed := ''; // Quick-search combo is set unconditionally (independent of the // autofill enabled flag) so the picker stays armed even when the @@ -876,7 +875,7 @@ begin var LQsMods := Word(StrToIntDef(GetParam('qs_mods'), 6)); var LQsVk := Word(StrToIntDef(GetParam('qs_vk'), Ord('Q'))); var LQsOk := FBridge.SetQuickSearchHotkey(LQsMods, LQsVk); - if (not LQsOk) and (LQsVk <> 0) then LFailed := LFailed + 'Quick search, '; + if (not LQsOk) and (LQsVk <> 0) then LFailed := LFailed + 'quickSearch,'; LogLine(Format('Quick-search hotkey set — mods:%d vk:%d (ok=%s)', [LQsMods, LQsVk, BoolToStr(LQsOk, True)])); end; @@ -887,7 +886,7 @@ begin var LNeMods := Word(StrToIntDef(GetParam('ne_mods'), 6)); var LNeVk := Word(StrToIntDef(GetParam('ne_vk'), Ord('A'))); var LNeOk := FBridge.SetNewEntryHotkey(LNeMods, LNeVk); - if (not LNeOk) and (LNeVk <> 0) then LFailed := LFailed + 'New entry, '; + if (not LNeOk) and (LNeVk <> 0) then LFailed := LFailed + 'newEntry,'; LogLine(Format('New-entry hotkey set — mods:%d vk:%d (ok=%s)', [LNeMods, LNeVk, BoolToStr(LNeOk, True)])); end; @@ -911,11 +910,11 @@ begin LogLine(Format('Autofill hotkeys set — full=mods:%d vk:%d pwd=mods:%d vk:%d totp=mods:%d vk:%d (all_ok=%s)', [LFullMods, LFullVk, LPwdMods, LPwdVk, LTotpMods, LTotpVk, BoolToStr(LAllOk, True)])); if (not FBridge.AutofillFullActive) and (LFullVk <> 0) then - LFailed := LFailed + 'Autofill (full), '; + LFailed := LFailed + 'full,'; if (not FBridge.AutofillPwdActive) and (LPwdVk <> 0) then - LFailed := LFailed + 'Password only, '; + LFailed := LFailed + 'password,'; if (not FBridge.AutofillTotpActive) and (LTotpVk <> 0) then - LFailed := LFailed + '2FA code, '; + LFailed := LFailed + 'totp,'; end; // One notification for the whole batch, naming the combos that failed — // empty string = everything registered. diff --git a/js/app.autofill.js b/js/app.autofill.js index 9d25ca6..691bbfd 100644 --- a/js/app.autofill.js +++ b/js/app.autofill.js @@ -12,6 +12,32 @@ // 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'); + } +} + // Format a combo for human display: "Ctrl+Shift+L". function autofillComboLabel(c) { if (!c || !c.key) return '— not set —'; @@ -78,7 +104,13 @@ function autofillCaptureFromEvent(e) { // Push current state to Delphi (toggle + both combos) and persist. // Called after any change so Delphi's RegisterHotKey reflects state. -function autofillPushHotkeys() { +// 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)); diff --git a/js/app.js b/js/app.js index e783021..d79f51a 100644 --- a/js/app.js +++ b/js/app.js @@ -218,15 +218,23 @@ const Bridge = (() => { // Called by Delphi after a setAutofillHotkeys request, with true if // BOTH combos registered successfully, false otherwise (clash with // another app holding a global hotkey). Surfaces a toast. - // `failed` = comma-separated names of the combos Windows refused - // (another app already owns them); empty string = all registered. - // Naming them matters: a clashing hotkey is otherwise indistinguishable - // from a broken feature — it just silently does nothing. + // `failed` = comma-separated SLOT IDS Windows refused (another app + // already owns them); empty string = all registered. A clashing hotkey + // is otherwise indistinguishable from a broken feature — it silently + // does nothing — so it gets both a toast AND a persistent marker on the + // Settings row (the toast is gone by the time the user looks). onAutofillHotkeysResult(failed) { - const list = String(failed || '').trim(); - if (!list) { toast('Hotkeys updated'); return; } - toast('Already used by another app: ' + list + - ' — pick another combo in Settings', 'warning'); + state.hotkeyConflicts = String(failed || '') + .split(',').map(s => s.trim()).filter(Boolean); + renderHotkeyConflicts(); + if (state.hotkeyConflicts.length === 0) { + if (!hotkeyPushSilent) toast('Hotkeys updated'); + return; + } + toast('Already used by another app: ' + + state.hotkeyConflicts.map(k => HOTKEY_SLOTS[k] ? HOTKEY_SLOTS[k].label : k) + .join(', ') + + ' — pick another combo', 'warning'); }, // ---- Device-bound prefs (DPAPI-backed) ---------------------------- @@ -683,6 +691,9 @@ const state = { '{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"P"}'), autofillHotkeyTotp: JSON.parse(localStorage.getItem('autofillHotkeyTotp') || '{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"T"}'), + // Slot ids whose combo Windows refused (another app owns it). Runtime-only: + // re-evaluated on every registration, never persisted. + hotkeyConflicts: [], quickSearchHotkey: JSON.parse(localStorage.getItem('quickSearchHotkey') || '{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"Q"}'), newEntryHotkey: JSON.parse(localStorage.getItem('newEntryHotkey') || @@ -6313,6 +6324,7 @@ function openSettings() { $('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull); $('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd); $('#settingAutofillTotpCombo').textContent = autofillComboLabel(state.autofillHotkeyTotp); + renderHotkeyConflicts(); // keep the clash markers after a panel re-render $('#settingQuickSearchCombo').textContent = autofillComboLabel(state.quickSearchHotkey); $('#settingNewEntryCombo').textContent = autofillComboLabel(state.newEntryHotkey); $('#settingAutofillHotkeysRow').style.display = Bridge.active ? '' : 'none'; @@ -6639,7 +6651,8 @@ async function enterApp() { if (state.hibpEnabled) hibpCheckAllEntries(); // Push the user-configured hotkeys (combos + enabled state) to Delphi. // Replaces the historical "always Ctrl+Shift+L on startup" path. - autofillPushHotkeys(); + // silent: no success toast on every unlock — a clash still warns. + autofillPushHotkeys(true); // Sync the tray notifications preference to Delphi (default ON; // settings_json may have flipped it). if (Bridge.active && typeof Bridge.setTrayNotifications === 'function')