feat(hotkeys): persistent conflict marker in Settings
A toast is gone by the time the user opens Settings, so a hotkey Windows refused looked perfectly configured while doing nothing. The offending row now carries a ⚠ marker (amber border + tooltip explaining the combo is taken) until a re-registration clears it. Delphi reports slot ids instead of labels; HOTKEY_SLOTS maps id → button + label in one place, so JS owns the wording and can flag the exact row. The markers are re-applied by openSettings, surviving panel re-renders. Also stop toasting success on the startup push (it fires on every unlock) — autofillPushHotkeys(true). A clash still warns: that one is actionable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+33
-1
@@ -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));
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user