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:
r-zakarya
2026-07-25 17:52:05 +01:00
parent 3d3ab3b02b
commit 4965ff2518
4 changed files with 73 additions and 19 deletions
+22 -9
View File
@@ -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')