diff --git a/src/lib/components/HotkeyRecorder.svelte b/src/lib/components/HotkeyRecorder.svelte index b8d7d49..69f19e1 100644 --- a/src/lib/components/HotkeyRecorder.svelte +++ b/src/lib/components/HotkeyRecorder.svelte @@ -1,8 +1,21 @@ - + {#if rejectionReason} + + Couldn't bind that combo — {rejectionReason}. + {/if} - + diff --git a/src/lib/utils/hotkeyValidity.ts b/src/lib/utils/hotkeyValidity.ts new file mode 100644 index 0000000..9ae26be --- /dev/null +++ b/src/lib/utils/hotkeyValidity.ts @@ -0,0 +1,149 @@ +/** + * Per-OS hotkey capability matrix. + * + * Different global-hotkey backends refuse different combos. Rather + * than let the user save a combo the OS will silently ignore, we + * validate before save and surface a clear "why" string. + * + * Covered failures (brief item #5): + * - X11: single-key combos (no modifier) get swallowed by the user's + * editor / IRC client / whatever; they are useless as globals. + * Handy #917 / #1019. + * - Windows: right-hand modifiers (RCtrl, RAlt) don't always register + * through RegisterHotKey; the user gets a dead shortcut. Handy + * #966. We reject a combo whose only modifier is a right-hand one + * — if the user binds Ctrl+R we don't care whether left or right + * Ctrl fired it. + * - macOS: Fn-only combos require an NSEvent private API that + * RegisterHotKey-equivalents don't expose (Whispering #549 area). + * - Wayland (Linux): single-key combos are rejected even more + * aggressively than X11 because compositors vary on whether they + * forward a bare key to a global listener at all. + * + * All rules are pure — the UI calls `validateHotkey(combo, os)` and + * gates save on `result.valid`. No backend round-trip required. + */ + +export type HotkeyOs = "windows" | "macos" | "linux" | "unknown"; + +export interface HotkeyValidity { + /** Whether this combo is safe to save. */ + valid: boolean; + /** + * User-visible "why not" string when `valid` is false. Null when + * the combo is OK. Copy is written for inline display — no leading + * capitalisation assumption, no trailing period needed. + */ + reason: string | null; +} + +export interface HotkeyParts { + modifiers: string[]; // e.g. ["Ctrl", "Shift"] + trigger: string; // e.g. "R" — may be empty if we're mid-record +} + +/** Tokenise a `+`-joined hotkey the way the kon-hotkey parser does, + * but without demanding a fully-valid combo (we validate next). The + * last part is always the trigger key; everything before is a + * modifier. An empty/whitespace combo returns empty parts. */ +export function parseHotkey(combo: string): HotkeyParts { + const trimmed = combo?.trim?.() ?? ""; + if (!trimmed) return { modifiers: [], trigger: "" }; + const tokens = trimmed.split("+").map((t) => t.trim()).filter(Boolean); + if (tokens.length === 0) return { modifiers: [], trigger: "" }; + const trigger = tokens[tokens.length - 1]; + const modifiers = tokens.slice(0, -1); + return { modifiers, trigger }; +} + +// Fn and function-key helpers. +const FUNCTION_KEYS = new Set([ + "F1", "F2", "F3", "F4", "F5", "F6", + "F7", "F8", "F9", "F10", "F11", "F12", + "F13", "F14", "F15", "F16", "F17", "F18", "F19", "F20", + "F21", "F22", "F23", "F24", +]); + +function normaliseModifier(mod: string): string { + const m = mod.trim().toLowerCase(); + if (m === "cmd" || m === "meta" || m === "super" || m === "win") return "super"; + if (m === "option") return "alt"; + return m; +} + +function isRightHandModifier(mod: string): boolean { + const m = mod.trim().toLowerCase(); + return m === "rctrl" || m === "ralt" || m === "rshift" || m === "rsuper" || m === "rmeta"; +} + +function isFnModifier(mod: string): boolean { + return mod.trim().toLowerCase() === "fn"; +} + +/** Validate a combo for the given OS. `unknown` OS passes unchanged + * — we'd rather ship a flawed combo than reject one we can't check. */ +export function validateHotkey(combo: string, os: HotkeyOs): HotkeyValidity { + const parts = parseHotkey(combo); + if (!parts.trigger) { + return { + valid: false, + reason: "pick a key — hotkeys need at least one non-modifier", + }; + } + + const hasModifier = parts.modifiers.length > 0; + const normalisedMods = parts.modifiers.map(normaliseModifier); + + if (os === "linux" || os === "unknown") { + // X11 AND Wayland: single-key globals are a footgun everywhere on + // Linux. Function keys F13+ are the conventional escape hatch + // (they're not on anyone's keyboard, so they don't collide) — + // allow them single-key. + if (!hasModifier && !FUNCTION_KEYS.has(parts.trigger)) { + return { + valid: false, + reason: + "add Ctrl, Shift, Alt, or Super — single keys get eaten by whichever app is focused", + }; + } + } + + if (os === "windows") { + // Right-hand-only modifiers break RegisterHotKey in some layouts. + const onlyRightHand = + parts.modifiers.length > 0 + && parts.modifiers.every(isRightHandModifier); + if (onlyRightHand) { + return { + valid: false, + reason: + "right-hand-only modifiers (RCtrl / RAlt) don't always register as global shortcuts on Windows — use the left-hand equivalent", + }; + } + if (!hasModifier) { + return { + valid: false, + reason: "Windows requires at least one modifier on a global hotkey", + }; + } + } + + if (os === "macos") { + if (normalisedMods.some(isFnModifier) && parts.modifiers.length === 1) { + return { + valid: false, + reason: + "Fn-only combos aren't available to Tauri global shortcuts on macOS — pair Fn with Cmd/Ctrl/Option, or pick a different trigger", + }; + } + if (!hasModifier && !FUNCTION_KEYS.has(parts.trigger)) { + return { + valid: false, + reason: + "add Cmd, Ctrl, Option, or Shift — macOS requires a modifier on bare-key global shortcuts", + }; + } + } + + return { valid: true, reason: null }; +}