fix(hotkeys): release our combos while capturing a new one

A RegisterHotKey combo is delivered to the owning window, never to the
WebView. So a combo one of OUR slots holds could not be captured at all —
pressing it fired that hotkey instead (Ctrl+Shift+L ran an autofill in the
middle of the capture), and the "already used by another hotkey" collision
check was unreachable dead code. Swapping two combos was impossible.

The capture now suspends all five hotkeys (enabled=0 + vk=0 for quick-search
and new-entry, which unregisters without re-registering and is excluded from
the failure report) and restores them on finish — silently when cancelled or
timed out, with the usual toast on a successful change.

Combos held by ANOTHER app still can't be captured — that one is out of our
hands, and the 10 s timeout explains it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-25 18:22:36 +01:00
parent dd69747cb8
commit c818f12c24
+23 -4
View File
@@ -207,6 +207,7 @@ const Bridge = (() => {
} }
let tp = ''; let tp = '';
if (combos.totp) { if (combos.totp) {
// (see suspendHotkeys below for why vk=0 matters)
const t = autofillComboToWin32(combos.totp); const t = autofillComboToWin32(combos.totp);
tp = '&totp_mods=' + t.mods + '&totp_vk=' + t.vk; tp = '&totp_mods=' + t.mods + '&totp_vk=' + t.vk;
} }
@@ -215,9 +216,18 @@ const Bridge = (() => {
'&pwd_mods=' + p.mods + '&pwd_vk=' + p.vk + qs + ne + tp); '&pwd_mods=' + p.mods + '&pwd_vk=' + p.vk + qs + ne + tp);
}, },
// Called by Delphi after a setAutofillHotkeys request, with true if // Release ALL five global hotkeys while the Settings capture is armed.
// BOTH combos registered successfully, false otherwise (clash with // A RegisterHotKey combo never reaches the WebView — Windows delivers
// another app holding a global hotkey). Surfaces a toast. // it to the owning window — so a combo we hold can't be captured, and
// pressing it would FIRE that hotkey mid-capture. vk=0 unregisters a
// slot without re-registering it (and is excluded from the failure
// report). autofillPushHotkeys() restores everything afterwards.
suspendHotkeys() {
if (!active) return;
cmd('cmd://autofill/hotkeys?enabled=0' +
'&qs_mods=0&qs_vk=0&ne_mods=0&ne_vk=0');
},
// `failed` = comma-separated SLOT IDS Windows refused (another app // `failed` = comma-separated SLOT IDS Windows refused (another app
// already owns them); empty string = all registered. A clashing hotkey // already owns them); empty string = all registered. A clashing hotkey
// is otherwise indistinguishable from a broken feature — it silently // is otherwise indistinguishable from a broken feature — it silently
@@ -7707,6 +7717,10 @@ async function init() {
btn.classList.add('is-capturing'); btn.classList.add('is-capturing');
const original = btn.textContent; const original = btn.textContent;
btn.textContent = 'Press combo… (Esc to cancel)'; btn.textContent = 'Press combo… (Esc to cancel)';
// Free our own combos so they reach this listener instead of
// firing their hotkey. Restored by finish() / the success path.
hotkeyPushSilent = true;
Bridge.suspendHotkeys();
// A combo already owned by another app via RegisterHotKey is // A combo already owned by another app via RegisterHotKey is
// swallowed by Windows — it is delivered to that app, never to us, // swallowed by Windows — it is delivered to that app, never to us,
@@ -7725,7 +7739,12 @@ async function init() {
btn.dataset.capturing = ''; btn.dataset.capturing = '';
btn.classList.remove('is-capturing'); btn.classList.remove('is-capturing');
document.removeEventListener('keydown', onKey, true); document.removeEventListener('keydown', onKey, true);
if (restore) btn.textContent = original; if (restore) {
btn.textContent = original;
// Cancelled / timed out: nothing changed, so re-register the
// combos we suspended — quietly, it's not a user action.
autofillPushHotkeys(true);
}
} }
function onKey(e) { function onKey(e) {