fix(hotkey): resolve physical-key codes; Super-as-modifier; surface backend registration errors
Three correctness fixes to the hotkey recorder based on dogfood logs: 1. Modifier set now includes Super / OS / Hyper (webkit2gtk on Linux fires e.key === "Super" for the Windows key — previously that key got captured as the final trigger, producing invalid 'Ctrl+Shift+Super' strings the evdev parser rejected). 2. resolveTriggerKey() uses e.code (physical, shift-independent key) to resolve shifted punctuation back to the unshifted name the evdev parser understands: '+' -> '=', '|' -> '\\', etc. Letters and digits also use e.code (KeyA -> A, Digit1 -> 1) to avoid layout quirks. 3. Numpad keys intentionally not mapped to main-keyboard equivalents — they are distinct evdev codes. Leaves the parser to reject them so the user gets a toast instead of a silently-wrong binding. Registration failures now surface as a toast and revert settings.globalHotkey to the last successfully-registered value (if any), so the UI cannot lie about what is actually bound. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,49 @@
|
|||||||
let recording = $state(false);
|
let recording = $state(false);
|
||||||
let captured = $state(false);
|
let captured = $state(false);
|
||||||
|
|
||||||
const modifierKeys = new Set(["Control", "Shift", "Alt", "Meta"]);
|
// Linux webkit2gtk fires e.key === "Super" for the Windows/Super key;
|
||||||
|
// Firefox/older Chrome may fire "OS" or "Hyper". All of these should be
|
||||||
|
// treated as modifiers, not as the trigger key.
|
||||||
|
const modifierKeys = new Set([
|
||||||
|
"Control", "Shift", "Alt", "Meta",
|
||||||
|
"Super", "OS", "Hyper",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Map KeyboardEvent.code (physical key, layout-independent, shift-independent)
|
||||||
|
// to the unshifted name the kon-hotkey evdev parser understands. Browser
|
||||||
|
// e.key returns "+" for Shift+Equal — parser wants "=". Same for | → \, etc.
|
||||||
|
const codeToParserKey: Record<string, string> = {
|
||||||
|
Equal: "=", Minus: "-",
|
||||||
|
BracketLeft: "[", BracketRight: "]",
|
||||||
|
Backslash: "\\", IntlBackslash: "\\",
|
||||||
|
Semicolon: ";", Quote: "'",
|
||||||
|
Comma: ",", Period: ".", Slash: "/",
|
||||||
|
Backquote: "`",
|
||||||
|
Space: "Space",
|
||||||
|
// Numpad keys intentionally NOT mapped here. They are physically distinct
|
||||||
|
// evdev codes from their main-keyboard counterparts; mapping them would
|
||||||
|
// silently bind the wrong key. Let resolveTriggerKey fall through so the
|
||||||
|
// backend parser rejects and the toast surfaces the failure.
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveTriggerKey(e: KeyboardEvent): string {
|
||||||
|
// Letters and digits: e.key is reliable (always A-Z / 0-9 for those codes).
|
||||||
|
if (/^Key[A-Z]$/.test(e.code)) return e.code.slice(3); // KeyA → A
|
||||||
|
if (/^Digit[0-9]$/.test(e.code)) return e.code.slice(5); // Digit1 → 1
|
||||||
|
if (/^Numpad[0-9]$/.test(e.code)) return e.code.slice(6); // Numpad1 → 1
|
||||||
|
if (/^F\d{1,2}$/.test(e.code)) return e.code; // F1..F12
|
||||||
|
|
||||||
|
// Shifted punctuation / numpad operators: use the physical-key mapping.
|
||||||
|
if (codeToParserKey[e.code]) return codeToParserKey[e.code];
|
||||||
|
|
||||||
|
// Named keys (Escape, Enter, Tab, arrows, etc.): e.key is the right choice
|
||||||
|
// — but only if it's multi-char (single chars here are usually dead-keys
|
||||||
|
// or IME output we shouldn't save).
|
||||||
|
if (e.key.length > 1) return e.key;
|
||||||
|
|
||||||
|
// Fallback: uppercase single char. Better than nothing.
|
||||||
|
return e.key.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
// Capture-phase listener; guard still belts-and-braces.
|
// Capture-phase listener; guard still belts-and-braces.
|
||||||
@@ -32,7 +74,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key);
|
parts.push(resolveTriggerKey(e));
|
||||||
settings.globalHotkey = parts.join("+");
|
settings.globalHotkey = parts.join("+");
|
||||||
saveSettings();
|
saveSettings();
|
||||||
recording = false;
|
recording = false;
|
||||||
@@ -72,7 +114,7 @@
|
|||||||
aria-label="Record hotkey"
|
aria-label="Record hotkey"
|
||||||
>
|
>
|
||||||
{#if recording}
|
{#if recording}
|
||||||
<span class="text-[11px] text-text-tertiary italic">Press new shortcut...</span>
|
<span class="text-[11px] text-text-tertiary italic">Hold Ctrl/Alt/Super + key...</span>
|
||||||
{:else}
|
{:else}
|
||||||
{#each chips as chip, i}
|
{#each chips as chip, i}
|
||||||
{#if i > 0}
|
{#if i > 0}
|
||||||
|
|||||||
@@ -117,6 +117,17 @@
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Hotkey registration failed:", err);
|
console.error("Hotkey registration failed:", err);
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
toasts.error(
|
||||||
|
"Hotkey not registered",
|
||||||
|
`${hotkey} — ${msg}. Reverted to ${registeredHotkey ?? "previous value"}.`,
|
||||||
|
);
|
||||||
|
// Revert the in-memory setting to the last successfully-registered hotkey
|
||||||
|
// so the UI does not lie about what is actually bound.
|
||||||
|
if (registeredHotkey && registeredHotkey !== hotkey) {
|
||||||
|
settings.globalHotkey = registeredHotkey;
|
||||||
|
saveSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user