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
+10
View File
@@ -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);
+8 -9
View File
@@ -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.
+33 -1
View File
@@ -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));
+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')