From 7921ec3d65c0657346ca70d046b350edd6fa99f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Fri, 9 Oct 2026 15:33:00 +0200 Subject: [PATCH 1/4] feat: add macOS support --- .github/workflows/ci.yml | 10 +- AGENTS.md | 68 +++-- README.md | 59 ++-- src/SystemHotkeys/HookAccessNotifications.cs | 88 ++++++ .../Hotkeys/GlobalKeyboardHook.cs | 167 ------------ src/SystemHotkeys/Hotkeys/KeyboardHook.cs | 158 +++++++++++ .../Hotkeys/MacOS/MacKeyCodes.cs | 144 ++++++++++ .../Hotkeys/MacOS/MacKeyboardHook.cs | 254 ++++++++++++++++++ .../Hotkeys/MacOS/NativeMethods.cs | 92 +++++++ .../Hotkeys/{ => Windows}/NativeMethods.cs | 2 +- .../Hotkeys/Windows/WindowsKeyboardHook.cs | 103 +++++++ src/SystemHotkeys/Localization/Strings.resx | 12 + src/SystemHotkeys/PluginIntegration.cs | 20 +- src/SystemHotkeys/macrodeck-build.json | 17 ++ src/SystemHotkeys/manifest.json | 10 +- .../Hotkeys/MacKeyCodesTests.cs | 95 +++++++ 16 files changed, 1092 insertions(+), 207 deletions(-) create mode 100644 src/SystemHotkeys/HookAccessNotifications.cs delete mode 100644 src/SystemHotkeys/Hotkeys/GlobalKeyboardHook.cs create mode 100644 src/SystemHotkeys/Hotkeys/KeyboardHook.cs create mode 100644 src/SystemHotkeys/Hotkeys/MacOS/MacKeyCodes.cs create mode 100644 src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs create mode 100644 src/SystemHotkeys/Hotkeys/MacOS/NativeMethods.cs rename src/SystemHotkeys/Hotkeys/{ => Windows}/NativeMethods.cs (98%) create mode 100644 src/SystemHotkeys/Hotkeys/Windows/WindowsKeyboardHook.cs create mode 100644 tests/SystemHotkeys.Tests/Hotkeys/MacKeyCodesTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fa859e..489182a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,9 +8,13 @@ on: jobs: build-and-test: - # The keyboard hook is a Win32 SetWindowsHookExW P/Invoke, so the plugin only builds and runs its - # tests on Windows. - runs-on: windows-latest + # One runner per platform the manifest declares: the integration tests start the real keyboard hook, + # so this is where a backend that crashes on its own platform shows up. + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 diff --git a/AGENTS.md b/AGENTS.md index 07d20ce..1a3793b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,8 @@ This file must be kept up to date. When a rule here stops matching reality, or a work in this repository, update this file as part of that change rather than leaving it to drift. This folder is **System Hotkeys** (`manifest.json` `name`), a Macro Deck 3 out-of-process plugin -(Windows only) that installs a global `WH_KEYBOARD_LL` keyboard hook and publishes a `hotkey-pressed` +(Windows and macOS) that installs a global keyboard hook (`WH_KEYBOARD_LL` on Windows, a Quartz event tap +on macOS) and publishes a `hotkey-pressed` event whenever a modifier+key combination is pressed anywhere on the machine, so any Automation or widget flow can bind to a system-wide hotkey. The hook is passive by default; a trigger can opt in to also swallowing its combo's trailing key, and can scope itself to one device, profile and/or folder. @@ -19,15 +20,18 @@ package itself is covered by ``` src/SystemHotkeys/ Program.cs builder chain - a few lines and a RunAsync - manifest.json identity, icon, win-x64 entrypoint - macrodeck-build.json the win-x64 publish target (framework-dependent) + manifest.json identity, icon, win-x64 and osx-arm64 entrypoints + macrodeck-build.json one publish target per entrypoint (framework-dependent) PluginIntegration.cs IPluginIntegration + IEventProvider + IDynamicEventOptionsProvider: the hotkey-pressed event, its combo/suppress/scope parameters, and reading bound triggers back through IEventPublisher.GetBindings + HookAccessNotifications.cs shows or clears a notification for each OS permission the hook lacks Hotkeys/ the keyboard hook itself - NativeMethods.cs the WH_KEYBOARD_LL P/Invoke surface - GlobalKeyboardHook.cs installs the hook, pumps its message loop, detects a completed combo, - swallows a suppressed combo's trailing key + KeyboardHook.cs the platform-neutral half: held keys, combo detection, which key events a + suppressed combo swallows, the access level, and the per-OS factory + Windows/ WindowsKeyboardHook (WH_KEYBOARD_LL plus its message loop) and its P/Invoke + MacOS/ MacKeyboardHook (Quartz event tap on a CFRunLoop thread), its P/Invoke, and + MacKeyCodes (kVK key code and modifier flag to virtual-key code) HotkeyCombo.cs held keys into the { modifiers, key } shape the combo editor stores VirtualKeys.cs virtual-key code to editor key-token mapping BoundHotkeyCombos.cs turns GetBindings() into the set of combos currently opted into @@ -45,9 +49,9 @@ tests/SystemHotkeys.Tests/ Design knowledge that is not obvious from the code alone: -- **The hook only ever observes unless a bound trigger opted into suppression.** Every path in - `GlobalKeyboardHook.HookCallback` falls through to `CallNextHookEx` except a key-down/repeat/key-up - that matches a combo currently in `BoundHotkeyCombos`. Only the combo's trailing key is swallowed - +- **The hook only ever observes unless a bound trigger opted into suppression.** `KeyboardHook.OnKeyDown` + and `OnKeyUp` return `true` only for a key-down/repeat/key-up that matches a combo currently in + `BoundHotkeyCombos`; every backend passes everything else on untouched. Only the combo's trailing key is swallowed - never its modifiers - so a bind that uses a bare Ctrl or Shift never also eats whatever else that modifier does elsewhere. `BoundHotkeyCombos.FromBindings` is the single place that decides "currently bound and suppressed", rebuilt from scratch on every `Events.BindingsChanged`. @@ -76,12 +80,40 @@ Design knowledge that is not obvious from the code alone: and sorts its modifiers before comparing, so `Shift+Ctrl+F3` and `ctrl+shift+f3` are the same combo. - **No config flow.** Every setting lives on the `hotkey-pressed` trigger itself (Device/Profile/Folder scope, the suppress toggle), so the integration starts enabled immediately rather than waiting on setup. -- **Windows only, framework-dependent.** The keyboard hook is a `user32.dll`/`kernel32.dll` P/Invoke - surface, so only `win-x64` is declared in `manifest.json`/`macrodeck-build.json`. The entrypoint names - `runtimes/win-x64/SystemHotkeys.dll` with `"runtime": { "kind": "FrameworkDependent", "dotnetVersion": +- **Every backend speaks Windows virtual-key codes.** A backend translates its native key codes into VK + codes before calling `KeyboardHook`, so `HotkeyCombo`, `VirtualKeys` and `BoundHotkeyCombos` stay + platform-neutral and a new platform is one key table plus an event source. A backend file is marked + `[SupportedOSPlatform]` and only `KeyboardHook.Create` picks one, behind an `OperatingSystem.Is*` guard. + An OS with no backend gets a hook that only logs a warning; it must never throw, because the + integration is constructed during `Build()`. +- **A failed hook never takes the plugin down.** The conformance suite runs each declared platform on a CI + runner with no keyboard access at all (no macOS permission granted), and it still has to pass. A + backend logs and reports its `KeyboardHookAccess`; it never throws out of `Start`. +- **macOS permissions decide what the tap can do.** `MacKeyboardHook` tries an active tap first (needs + Accessibility, can swallow), falls back to listen-only (needs Input Monitoring, cannot swallow), and with + neither reports `Denied` and retries every 5 seconds. TCC attributes both permissions to Macro Deck, the + responsible process, not to the plugin's `dotnet`. `PluginIntegration` turns the access level into a + notification: `Denied` always, `ObserveOnly` only while some binding asks for suppression. + `UserNotificationRequest` takes plain strings, so `HookAccessNotifications` resolves the `Strings` keys + itself through its own `LocalizationResolver`. +- **macOS modifiers come from the event flags, not from key presses.** Every key event resyncs the held + modifiers from the device-dependent `CGEventFlags` bits (the only part that tells left from right), so a + modifier released while the tap was blind (Secure Event Input) cannot stick. A non-repeat key-down of a + key still marked held clears that stale state first, for the same reason. Command is `Meta`, Option is + `Alt`. +- **`MacKeyCodes` is positional.** A `kVK_*` code is a key position on a US ANSI keyboard, not the + character the active layout puts there, so on a non-US layout some letters and punctuation do not match + what the combo recorder stores. A layout-aware lookup (`UCKeyTranslate`) would fix that; until then the + README documents it. Caps Lock and Fn are deliberately unmapped. +- **Linux is not declared yet.** Wayland offers no global key observation, so a Linux backend would read + evdev (`/dev/input`, user in the `input` group). Swallowing a key there means grabbing the whole + keyboard and re-emitting every other key through uinput, or the XDG GlobalShortcuts portal. +- **Framework-dependent on every platform.** Each entrypoint names `runtimes//SystemHotkeys.dll` + with `"runtime": { "kind": "FrameworkDependent", "dotnetVersion": "10.0" }`, and `macrodeck-build.json` publishes with `--self-contained false -p:UseAppHost=false` to match - Macro Deck ships the .NET 10 runtime with the host, so the artifact carries only this plugin's - own assemblies. Keep the manifest and `macrodeck-build.json` on the same pairing; mixing them fails + own assemblies. The DLL is plain IL, so one machine builds every platform. Keep the manifest and + `macrodeck-build.json` on the same pairing, with one target per entrypoint; mixing them fails validation. Authoritative upstream documentation, in the @@ -388,18 +420,18 @@ macrodeck-plugin build --source src/SystemHotkeys --output ./artifacts macrodeck-plugin inspect --artifact ./artifacts/-.macroDeckPlugin ``` -`build` reads `macrodeck-build.json`, publishes the win-x64 runtime identifier into its `runtimes/win-x64/` -slot and packs the result. +`build` reads `macrodeck-build.json`, publishes each runtime identifier (win-x64, osx-arm64) into its +`runtimes//` slot and packs the result. -A `dotnet build -c Release` output is *not* packable: the manifest points at `runtimes/win-x64/`, which +A `dotnet build -c Release` output is *not* packable: the manifest points at `runtimes//`, which only `build` assembles, so `validate`/`pack` against `bin/Release/net10.0` fails on a missing entrypoint. -This plugin is **framework-dependent**: the entrypoint names `runtimes/win-x64/SystemHotkeys.dll` with +This plugin is **framework-dependent**: each entrypoint names `runtimes//SystemHotkeys.dll` with `"runtime": { "kind": "FrameworkDependent", "dotnetVersion": "10.0" }`, and `macrodeck-build.json` publishes with `--self-contained false -p:UseAppHost=false`. Macro Deck ships a .NET 10 runtime (ASP.NET Core included) with the host and runs the plugin on it, which is also why it appears as `dotnet` in process lists. Going self-contained would mean removing the `runtime` block, pointing `executable` at -the apphost (`SystemHotkeys.exe`, no `.dll`) and publishing with `--self-contained true` - keep the +the apphost (`SystemHotkeys.exe` on Windows, `SystemHotkeys` on macOS) and publishing with `--self-contained true` - keep the manifest and `macrodeck-build.json` on the same pairing, since mixing them fails validation. Packing validates first, recomputes every `files[]` digest from disk and fills in `languages` from diff --git a/README.md b/README.md index d86ba48..db2a557 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # System Hotkeys -A [Macro Deck 3](https://macro-deck.app/) plugin (Windows only) that installs a global keyboard hook and +A [Macro Deck 3](https://macro-deck.app/) plugin for Windows and macOS that installs a global keyboard hook and publishes a `hotkey-pressed` event whenever a modifier+key combination is pressed anywhere on the machine - press Alt+F1 while the browser has focus, and Macro Deck still sees it. This is what Macro Deck 2's per-button hotkey assignment turns into in Macro Deck 3: bind any Automation or widget flow to @@ -30,7 +30,7 @@ A key with no modifier is allowed. A bare non-typing key (a function or media ke hotkey; a bare letter or digit fires the trigger on **every** press of it anywhere on the machine, so reach for one only when that is genuinely what you want. A key your keyboard's own software rewrites (an `Fn` layer that turns `Fn+F4` into numpad `+`, for instance) reaches the plugin only as whatever it -was rewritten to - `Fn` itself is never visible to Windows. The hook only observes: it never blocks or +was rewritten to - `Fn` itself is never visible to the operating system. The hook only observes: it never blocks or alters a keystroke for any other application, unless you turn that on for a specific trigger - next. ## Keeping a hotkey from reaching other apps @@ -54,8 +54,8 @@ combo(s).`. Check the log viewer first if suppression seems to have no effect - many combos the plugin currently thinks it should intercept. A suppressed combo still publishes `hotkey-pressed` as usual - the toggle only decides what the rest of -the system sees. Only the trailing key is intercepted (its key-down, Windows' own repeats of it while -held, and its key-up); a modifier held as part of the combo, such as Ctrl or Shift, is never touched and +the system sees. Only the trailing key is intercepted (its key-down, the operating system's own repeats +of it while held, and its key-up); a modifier held as part of the combo, such as Ctrl or Shift, is never touched and keeps doing whatever it normally does elsewhere - which is why `Ctrl+Shift+F3` above only needs `F3` swallowed to stop leaking into the game. Swallowing a combo's own modifiers too (so a bind that uses a bare Ctrl or Shift, e.g., does not also trigger a game's crouch or sprint) is a further step this plugin @@ -63,6 +63,8 @@ does not take: doing it without adding input lag to every ordinary use of that m key-up correction after the fact, which is real added complexity worth reaching for only once an actual bind needs it. +On macOS this needs the Accessibility permission - see [macOS](#macos) below. + This works for the overwhelming majority of applications, games included - but a `WH_KEYBOARD_LL` hook is not guaranteed to reach every input path (a title using exclusive DirectInput or kernel-level anti-cheat capture may still see the raw keystroke). There is no way to confirm that without testing the specific @@ -97,6 +99,31 @@ A press only publishes once per connected client when it has to - when at least `hotkey-pressed` actually sets Device, Profile or Folder. A plain global hotkey, with nothing scoped anywhere, always publishes exactly once, no matter how many clients are connected. +## macOS + +macOS only lets an app see keys pressed in other apps once you allow it. The plugin runs inside Macro +Deck, so the permission goes to **Macro Deck**, under **System Settings > Privacy & Security**: + +- **Input Monitoring** is enough for hotkeys to fire. Without it nothing fires; the plugin shows a + notification saying so, and asks macOS to list Macro Deck there. +- **Accessibility** is needed as well for [Also stop this key from reaching other apps](#keeping-a-hotkey-from-reaching-other-apps). + Without it hotkeys still fire, but the focused app sees the key too. The plugin shows a notification + only once a trigger actually asks for suppression. + +The plugin retries every few seconds, so a newly granted permission takes effect without a restart. If +it does not, restart Macro Deck. + +Differences from Windows: + +- Command is `Meta` and Option is `Alt` in the combo editor, so record a combo rather than picking its + modifiers by hand if in doubt. +- Keys are matched by their position on a US keyboard. On another layout a letter or punctuation key + may need to be picked by the US key in that position (on a German keyboard, `Z` and `Y` are swapped). +- While a password field or another secure text field has focus, macOS hides every key from every app + that watches the keyboard, so no hotkey fires there. +- Caps Lock and Fn can never be a combo's key. +- Only Apple silicon Macs are supported. + ## Privacy A global keyboard hook sees every key pressed on the machine, so here is exactly what this plugin does @@ -116,8 +143,8 @@ published) and install it from Macro Deck's plugin manager. - .NET SDK 10.0 - A running Macro Deck desktop app for [interactive debugging](#run-and-debug-against-macro-deck) -- Windows - the keyboard hook is a `user32.dll` P/Invoke surface, so this plugin ships win-x64 only. - Contributions adding macOS or Linux support are very welcome. +- Windows (win-x64) or macOS on Apple silicon (osx-arm64). Linux is not supported yet: Wayland gives an + app no way to watch global keys, so it needs a separate evdev backend. ## Quick start @@ -135,7 +162,7 @@ Build and tests need no Macro Deck installation. For an interactive session, use The [Makefile](Makefile) wraps the everyday commands (`make` lists them): `make run` / `make watch` launch the plugin against the running Macro Deck through `macrodeck-plugin run` (pairing once, the credential kept in `src/SystemHotkeys/.macrodeck-dev-state/`), `make stub` against a stub host, -`make cli` keeps the CLI at the SDK's version, `make pack` builds and inspects the win-x64 artifact, and +`make cli` keeps the CLI at the SDK's version, `make pack` builds and inspects the artifact, and `make release` tests and packs, then tags the version in `manifest.json` and pushes - the tag starts the release workflow. `make release VERSION=x.y.z` does the same for another version, bumping and committing `manifest.json` first. Either refuses a version that is not newer than the latest release tag. On @@ -146,14 +173,16 @@ Windows it needs GNU make and Git Bash's `sh` on `PATH`. ``` src/SystemHotkeys/ Program.cs the host builder - a few lines and a RunAsync - manifest.json identity, icon and the win-x64-only entrypoint + manifest.json identity, icon and the win-x64 and osx-arm64 entrypoints macrodeck-build.json how `macrodeck-plugin build` publishes it PluginIntegration.cs the integration: the hotkey-pressed event, its combo and suppress-toggle parameters, and reading them back through IEventPublisher.GetBindings + HookAccessNotifications.cs tells the user which OS permission the hook is missing Hotkeys/ the keyboard hook itself - NativeMethods.cs the WH_KEYBOARD_LL P/Invoke surface - GlobalKeyboardHook.cs installs the hook, pumps its message loop, detects a completed combo, - swallows a suppressed combo's trailing key + KeyboardHook.cs the platform-neutral half: detects a completed combo, decides what a + suppressed combo swallows, picks the backend for this OS + Windows/ the WH_KEYBOARD_LL hook and its message loop + MacOS/ the Quartz event tap, its run loop, and kVK key code to virtual-key mapping HotkeyCombo.cs held keys into the { modifiers, key } shape the combo editor stores VirtualKeys.cs virtual-key code to editor key-token mapping BoundHotkeyCombos.cs turns GetBindings() into the set of combos currently opted into suppression @@ -307,7 +336,7 @@ real Kestrel server. | Command | What it does | | --- | --- | -| `build` | Publishes the win-x64 runtime identifier using `macrodeck-build.json`, then packs the result. | +| `build` | Publishes every runtime identifier in `macrodeck-build.json` (win-x64, osx-arm64), then packs the result. | | `validate` | Checks a manifest, version directory or artifact against the real manifest reader, the JSON Schema, the permission vocabulary and declared file digests. | | `inspect` | Reports what installing an artifact would find - entrypoints, permissions, dependencies, conflicts, compatibility, signature shape, size. | | `pack` | Builds a `.macroDeckPlugin` artifact, validating the manifest first and recomputing `files[]` digests. | @@ -329,8 +358,8 @@ app instead; for debugging with breakpoints, use the launch profile above rather ### Packing a release -`build` is the whole path: it reads `macrodeck-build.json`, publishes win-x64 into its `runtimes/win-x64/` -slot and packs the artifact. +`build` is the whole path: it reads `macrodeck-build.json`, publishes each runtime identifier into its +`runtimes//` slot and packs the artifact. Both are framework-dependent IL, so either OS builds both. ```bash macrodeck-plugin build --source src/SystemHotkeys --output ./artifacts @@ -346,7 +375,7 @@ from `Localization/`. It cannot sign anything: sign *after* packing, against the digest will not match. A plain `dotnet build -c Release` does not produce a packable layout - the manifest points at -`runtimes/win-x64/`, which only `build` assembles. Use `validate` against a built artifact or a version +`runtimes//`, which only `build` assembles. Use `validate` against a built artifact or a version directory rather than against `bin/Release/net10.0`. ### Conformance diff --git a/src/SystemHotkeys/HookAccessNotifications.cs b/src/SystemHotkeys/HookAccessNotifications.cs new file mode 100644 index 0000000..8c838d7 --- /dev/null +++ b/src/SystemHotkeys/HookAccessNotifications.cs @@ -0,0 +1,88 @@ +using MacroDeck.Localization; +using MacroDeck.Sdk.Notifications; +using SystemHotkeys.Hotkeys; + +namespace SystemHotkeys; + +/// +/// Tells the user which OS permission the keyboard hook is missing, and clears that again once granted. +/// +internal sealed class HookAccessNotifications +{ + private const string InputMonitoringKey = "input-monitoring-required"; + private const string AccessibilityKey = "accessibility-required"; + + // UserNotificationRequest takes plain strings, so the plugin resolves its own text. + private static readonly LocalizationResolver _resolver = CreateResolver(); + + private readonly Lock _gate = new(); + private readonly HashSet _shown = []; + + internal void Update(IUserNotifier notifier, KeyboardHookAccess access, bool suppressionRequested) + { + lock (_gate) + { + Toggle( + notifier, + InputMonitoringKey, + access == KeyboardHookAccess.Denied, + Strings.Notifications.InputMonitoringRequired.Title(), + Strings.Notifications.InputMonitoringRequired.Message() + ); + Toggle( + notifier, + AccessibilityKey, + access == KeyboardHookAccess.ObserveOnly && suppressionRequested, + Strings.Notifications.AccessibilityRequired.Title(), + Strings.Notifications.AccessibilityRequired.Message() + ); + } + } + + internal void Reset() + { + lock (_gate) + { + _shown.Clear(); + } + } + + private void Toggle( + IUserNotifier notifier, + string key, + bool visible, + LocalizedText title, + LocalizedText message + ) + { + if (!visible) + { + if (_shown.Remove(key)) + { + notifier.Dismiss(key); + } + + return; + } + + if (_shown.Add(key)) + { + notifier.Notify( + new UserNotificationRequest + { + Key = key, + Level = UserNotificationLevel.Warning, + Title = _resolver.Resolve(title, null) ?? string.Empty, + Message = _resolver.Resolve(message, null), + } + ); + } + } + + private static LocalizationResolver CreateResolver() + { + var catalogs = new LocalizationCatalogRegistry(); + catalogs.Register(Strings.LocalizationCatalog); + return new LocalizationResolver(catalogs); + } +} diff --git a/src/SystemHotkeys/Hotkeys/GlobalKeyboardHook.cs b/src/SystemHotkeys/Hotkeys/GlobalKeyboardHook.cs deleted file mode 100644 index 9cbcee3..0000000 --- a/src/SystemHotkeys/Hotkeys/GlobalKeyboardHook.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System.Runtime.InteropServices; -using Serilog; - -namespace SystemHotkeys.Hotkeys; - -internal sealed class GlobalKeyboardHook : IDisposable -{ - private readonly ILogger _logger; - private readonly Lock _gate = new(); - private readonly HashSet _heldKeys = []; - - private readonly HashSet _swallowedKeys = []; - - private volatile BoundHotkeyCombos _suppressed = BoundHotkeyCombos.None; - - private NativeMethods.LowLevelKeyboardProc? _proc; - - private volatile Thread? _thread; - private volatile uint _threadId; - private nint _hook; - - internal GlobalKeyboardHook(ILogger logger) => - _logger = logger.ForContext(); - - internal event Action? HotkeyPressed; - - internal void SetSuppressed(BoundHotkeyCombos suppressed) => _suppressed = suppressed; - - internal void Start() - { - if (_thread is not null) - { - return; - } - - _thread = new Thread(RunMessageLoop) - { - IsBackground = true, - Name = "SystemHotkeys.KeyboardHook", - }; - _thread.Start(); - } - - public void Dispose() - { - if (_threadId != 0) - { - NativeMethods.PostThreadMessageW(_threadId, NativeMethods.WM_QUIT, 0, 0); - } - - _thread?.Join(TimeSpan.FromSeconds(2)); - - _thread = null; - _threadId = 0; - } - - private void RunMessageLoop() - { - _threadId = NativeMethods.GetCurrentThreadId(); - - _proc = HookCallback; - _hook = NativeMethods.SetWindowsHookExW( - NativeMethods.WH_KEYBOARD_LL, - _proc, - NativeMethods.GetModuleHandleW(null), - 0 - ); - if (_hook == 0) - { - _logger.Warning( - "Could not install the global keyboard hook (Win32 error {Error}).", - Marshal.GetLastPInvokeError() - ); - return; - } - - _logger.Information("Global keyboard hook installed."); - - try - { - while (NativeMethods.GetMessageW(out var message, 0, 0, 0)) - { - NativeMethods.TranslateMessage(message); - NativeMethods.DispatchMessageW(message); - } - } - finally - { - NativeMethods.UnhookWindowsHookEx(_hook); - _hook = 0; - } - } - - private nint HookCallback(int nCode, nint wParam, nint lParam) - { - if (nCode >= 0) - { - var data = Marshal.PtrToStructure(lParam); - var vkCode = (int)data.VkCode; - - if (wParam is NativeMethods.WM_KEYDOWN or NativeMethods.WM_SYSKEYDOWN) - { - if (HandleKeyDown(vkCode)) - { - return 1; - } - } - else if (wParam is NativeMethods.WM_KEYUP or NativeMethods.WM_SYSKEYUP) - { - bool swallow; - lock (_gate) - { - _heldKeys.Remove(vkCode); - swallow = _swallowedKeys.Remove(vkCode); - } - - if (swallow) - { - return 1; - } - } - } - - return NativeMethods.CallNextHookEx(0, nCode, wParam, lParam); - } - - private bool HandleKeyDown(int vkCode) - { - bool isNewPress; - HashSet snapshot; - lock (_gate) - { - isNewPress = _heldKeys.Add(vkCode); - snapshot = [.. _heldKeys]; - - if (!isNewPress) - { - return _swallowedKeys.Contains(vkCode); - } - } - - if (HotkeyCombo.TryCreate(snapshot, vkCode) is not { } combo) - { - return false; - } - - _logger.Debug("Detected hotkey {Combo}.", combo.Text); - HotkeyPressed?.Invoke(combo); - - if (!_suppressed.Contains(combo)) - { - return false; - } - - _logger.Information( - "Suppressing hotkey {Combo}: keeping it from reaching other applications.", - combo.Text - ); - - lock (_gate) - { - _swallowedKeys.Add(vkCode); - } - - return true; - } -} diff --git a/src/SystemHotkeys/Hotkeys/KeyboardHook.cs b/src/SystemHotkeys/Hotkeys/KeyboardHook.cs new file mode 100644 index 0000000..2d0455d --- /dev/null +++ b/src/SystemHotkeys/Hotkeys/KeyboardHook.cs @@ -0,0 +1,158 @@ +using System.Runtime.InteropServices; +using Serilog; +using SystemHotkeys.Hotkeys.MacOS; +using SystemHotkeys.Hotkeys.Windows; + +namespace SystemHotkeys.Hotkeys; + +/// +/// The platform-neutral half of the global keyboard hook: tracks held keys, detects a completed combo and +/// decides which key events a suppressed combo swallows. A platform backend feeds it virtual-key codes. +/// +internal abstract class KeyboardHook : IDisposable +{ + private readonly Lock _gate = new(); + private readonly HashSet _heldKeys = []; + private readonly HashSet _swallowedKeys = []; + + private volatile BoundHotkeyCombos _suppressed = BoundHotkeyCombos.None; + private volatile KeyboardHookAccess _access = KeyboardHookAccess.Pending; + + protected KeyboardHook(ILogger logger) => Logger = logger.ForContext(GetType()); + + internal event Action? HotkeyPressed; + + internal event Action? AccessChanged; + + internal KeyboardHookAccess Access => _access; + + protected ILogger Logger { get; } + + internal static KeyboardHook Create(ILogger logger) + { + if (OperatingSystem.IsWindows()) + { + return new WindowsKeyboardHook(logger); + } + + if (OperatingSystem.IsMacOS()) + { + return new MacKeyboardHook(logger); + } + + return new UnsupportedKeyboardHook(logger); + } + + internal void SetSuppressed(BoundHotkeyCombos suppressed) => _suppressed = suppressed; + + internal abstract void Start(); + + public abstract void Dispose(); + + protected void ReportAccess(KeyboardHookAccess access) + { + if (_access == access) + { + return; + } + + _access = access; + AccessChanged?.Invoke(access); + } + + /// when the key-down must be kept from other applications. + protected bool OnKeyDown(int vkCode) + { + bool isNewPress; + HashSet snapshot; + lock (_gate) + { + isNewPress = _heldKeys.Add(vkCode); + snapshot = [.. _heldKeys]; + + if (!isNewPress) + { + return _swallowedKeys.Contains(vkCode); + } + } + + if (HotkeyCombo.TryCreate(snapshot, vkCode) is not { } combo) + { + return false; + } + + Logger.Debug("Detected hotkey {Combo}.", combo.Text); + HotkeyPressed?.Invoke(combo); + + if (!_suppressed.Contains(combo)) + { + return false; + } + + Logger.Information( + "Suppressing hotkey {Combo}: keeping it from reaching other applications.", + combo.Text + ); + + lock (_gate) + { + _swallowedKeys.Add(vkCode); + } + + return true; + } + + /// when the key-up must be kept from other applications. + protected bool OnKeyUp(int vkCode) + { + lock (_gate) + { + _heldKeys.Remove(vkCode); + return _swallowedKeys.Remove(vkCode); + } + } + + /// + /// Records a modifier's state without running combo detection, for a platform that reports modifier + /// state rather than modifier key presses. A modifier never completes a combo and is never swallowed. + /// + protected void SetModifierHeld(int vkCode, bool held) + { + lock (_gate) + { + if (held) + { + _heldKeys.Add(vkCode); + } + else + { + _heldKeys.Remove(vkCode); + } + } + } + + private sealed class UnsupportedKeyboardHook(ILogger logger) : KeyboardHook(logger) + { + internal override void Start() => + Logger.Warning( + "Global hotkeys are not supported on {Platform}; no hotkey will fire.", + RuntimeInformation.OSDescription + ); + + public override void Dispose() { } + } +} + +internal enum KeyboardHookAccess +{ + /// Not started yet, or failed for a reason the user cannot fix with a permission. + Pending, + + /// The OS refused the hook until the user grants a permission. + Denied, + + /// Hotkeys are detected, but a suppressed combo still reaches other applications. + ObserveOnly, + + Full, +} diff --git a/src/SystemHotkeys/Hotkeys/MacOS/MacKeyCodes.cs b/src/SystemHotkeys/Hotkeys/MacOS/MacKeyCodes.cs new file mode 100644 index 0000000..0555e76 --- /dev/null +++ b/src/SystemHotkeys/Hotkeys/MacOS/MacKeyCodes.cs @@ -0,0 +1,144 @@ +namespace SystemHotkeys.Hotkeys.MacOS; + +/// +/// macOS kVK_* key code to Windows virtual-key code mapping, so a macOS key event runs through the +/// same naming and detection as a Windows one. +/// +/// +/// A kVK_* code names a physical key position on a US ANSI keyboard, not the character the current +/// layout puts there. Not exhaustive, like : an unmapped code is never recognized +/// as a combo's trailing key. +/// +public static class MacKeyCodes +{ + private static readonly Dictionary _virtualKeys = new() + { + [0x00] = 'A', + [0x01] = 'S', + [0x02] = 'D', + [0x03] = 'F', + [0x04] = 'H', + [0x05] = 'G', + [0x06] = 'Z', + [0x07] = 'X', + [0x08] = 'C', + [0x09] = 'V', + [0x0B] = 'B', + [0x0C] = 'Q', + [0x0D] = 'W', + [0x0E] = 'E', + [0x0F] = 'R', + [0x10] = 'Y', + [0x11] = 'T', + [0x1F] = 'O', + [0x20] = 'U', + [0x22] = 'I', + [0x23] = 'P', + [0x25] = 'L', + [0x26] = 'J', + [0x28] = 'K', + [0x2D] = 'N', + [0x2E] = 'M', + + [0x1D] = '0', + [0x12] = '1', + [0x13] = '2', + [0x14] = '3', + [0x15] = '4', + [0x17] = '5', + [0x16] = '6', + [0x1A] = '7', + [0x1C] = '8', + [0x19] = '9', + + [0x7A] = 0x70, // F1 + [0x78] = 0x71, + [0x63] = 0x72, + [0x76] = 0x73, + [0x60] = 0x74, + [0x61] = 0x75, + [0x62] = 0x76, + [0x64] = 0x77, + [0x65] = 0x78, + [0x6D] = 0x79, + [0x67] = 0x7A, + [0x6F] = 0x7B, + [0x69] = 0x7C, + [0x6B] = 0x7D, + [0x71] = 0x7E, + [0x6A] = 0x7F, + [0x40] = 0x80, + [0x4F] = 0x81, + [0x50] = 0x82, + [0x5A] = 0x83, // F20 + + [0x24] = 0x0D, // Return + [0x4C] = 0x0D, // keypad Enter, which Windows also reports as Enter + [0x30] = 0x09, // Tab + [0x31] = 0x20, // Space + [0x33] = 0x08, // Delete, the key Windows calls Backspace + [0x35] = 0x1B, // Escape + [0x72] = 0x2D, // Help, in the Insert position on a PC keyboard + [0x75] = 0x2E, // Forward Delete + [0x73] = 0x24, // Home + [0x77] = 0x23, // End + [0x74] = 0x21, // Page Up + [0x79] = 0x22, // Page Down + [0x7B] = 0x25, // Left + [0x7E] = 0x26, // Up + [0x7C] = 0x27, // Right + [0x7D] = 0x28, // Down + [0x6E] = 0x5D, // Context menu + + [0x52] = 0x60, // keypad 0 + [0x53] = 0x61, + [0x54] = 0x62, + [0x55] = 0x63, + [0x56] = 0x64, + [0x57] = 0x65, + [0x58] = 0x66, + [0x59] = 0x67, + [0x5B] = 0x68, + [0x5C] = 0x69, // keypad 9 + [0x43] = 0x6A, // keypad * + [0x45] = 0x6B, // keypad + + [0x4E] = 0x6D, // keypad - + [0x41] = 0x6E, // keypad . + [0x4B] = 0x6F, // keypad / + + [0x4A] = 0xAD, // Mute + [0x49] = 0xAE, // Volume Down + [0x48] = 0xAF, // Volume Up + + [0x29] = 0xBA, // ; + [0x18] = 0xBB, // = + [0x2B] = 0xBC, // , + [0x1B] = 0xBD, // - + [0x2F] = 0xBE, // . + [0x2C] = 0xBF, // / + [0x32] = 0xC0, // ` + [0x21] = 0xDB, // [ + [0x2A] = 0xDC, // \ + [0x1E] = 0xDD, // ] + [0x27] = 0xDE, // ' + }; + + /// + /// Each modifier's device-dependent bit in CGEventFlags, the only part of the flags that tells + /// the left key from the right one. + /// + public static IReadOnlyList<(int VirtualKey, ulong FlagMask)> ModifierFlags { get; } = + [ + (VirtualKeys.LControl, 0x00000001), + (VirtualKeys.LShift, 0x00000002), + (VirtualKeys.RShift, 0x00000004), + (VirtualKeys.LWin, 0x00000008), + (VirtualKeys.RWin, 0x00000010), + (VirtualKeys.LMenu, 0x00000020), + (VirtualKeys.RMenu, 0x00000040), + (VirtualKeys.RControl, 0x00002000), + ]; + + public static int? ToVirtualKey(int keyCode) => + _virtualKeys.TryGetValue(keyCode, out var vkCode) ? vkCode : null; +} diff --git a/src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs b/src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs new file mode 100644 index 0000000..c2a34b7 --- /dev/null +++ b/src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs @@ -0,0 +1,254 @@ +using System.Runtime.Versioning; +using Serilog; + +namespace SystemHotkeys.Hotkeys.MacOS; + +/// +/// A Quartz event tap on its own CFRunLoop thread. An active tap (able to swallow a key) needs the +/// Accessibility permission; without it the hook falls back to a listen-only tap, which needs Input +/// Monitoring. With neither it keeps retrying, so granting a permission takes effect without a restart. +/// +[SupportedOSPlatform("macos")] +internal sealed class MacKeyboardHook(ILogger logger) : KeyboardHook(logger) +{ + private const ulong KeyboardEvents = + (1UL << (int)NativeMethods.kCGEventKeyDown) + | (1UL << (int)NativeMethods.kCGEventKeyUp) + | (1UL << (int)NativeMethods.kCGEventFlagsChanged); + + private const double RunLoopSliceSeconds = 1; + private static readonly TimeSpan _retryInterval = TimeSpan.FromSeconds(5); + + // Never disposed: it is reused across Start/Dispose cycles and never allocates a kernel wait handle. + private readonly ManualResetEventSlim _stopping = new(); + + private NativeMethods.CGEventTapCallBack? _callback; + + private volatile Thread? _thread; + private volatile nint _runLoop; + private nint _tap; + private nint _source; + private bool _upgradeWhenTrusted; + private bool _listenAccessRequested; + + internal override void Start() + { + if (_thread is not null) + { + return; + } + + _stopping.Reset(); + _thread = new Thread(RunEventTaps) + { + IsBackground = true, + Name = "SystemHotkeys.KeyboardHook", + }; + _thread.Start(); + } + + public override void Dispose() + { + _stopping.Set(); + if (_runLoop != 0) + { + NativeMethods.CFRunLoopStop(_runLoop); + } + + _thread?.Join(TimeSpan.FromSeconds(2)); + _thread = null; + } + + private void RunEventTaps() + { + _runLoop = NativeMethods.CFRunLoopGetCurrent(); + _callback = OnEvent; + + try + { + while (!_stopping.IsSet) + { + var access = InstallTap(); + if (access != Access) + { + LogAccess(access); + } + + ReportAccess(access); + + if (access == KeyboardHookAccess.Denied) + { + _stopping.Wait(_retryInterval); + continue; + } + + RunUntilStoppedOrUpgradable(); + RemoveTap(); + } + } + finally + { + RemoveTap(); + _runLoop = 0; + } + } + + private KeyboardHookAccess InstallTap() + { + _tap = CreateTap(NativeMethods.kCGEventTapOptionDefault); + var access = KeyboardHookAccess.Full; + + if (_tap == 0) + { + _tap = CreateTap(NativeMethods.kCGEventTapOptionListenOnly); + access = KeyboardHookAccess.ObserveOnly; + } + + if (_tap == 0) + { + if (!_listenAccessRequested) + { + // Shows the system prompt once and lists Macro Deck under Input Monitoring. + _listenAccessRequested = true; + NativeMethods.CGRequestListenEventAccess(); + } + + return KeyboardHookAccess.Denied; + } + + // Granting Accessibility later should upgrade the tap, but if it is already granted and the active + // tap still failed, re-checking would only rebuild the same listen-only tap every slice. + _upgradeWhenTrusted = + access == KeyboardHookAccess.ObserveOnly && !NativeMethods.AXIsProcessTrusted(); + + _source = NativeMethods.CFMachPortCreateRunLoopSource(0, _tap, 0); + NativeMethods.CFRunLoopAddSource(_runLoop, _source, NativeMethods.DefaultRunLoopMode); + NativeMethods.CGEventTapEnable(_tap, true); + return access; + } + + private nint CreateTap(uint options) => + NativeMethods.CGEventTapCreate( + NativeMethods.kCGSessionEventTap, + NativeMethods.kCGHeadInsertEventTap, + options, + KeyboardEvents, + _callback!, + 0 + ); + + // Runs in short slices because CFRunLoopStop is lost when it arrives before the loop is running. + private void RunUntilStoppedOrUpgradable() + { + while (!_stopping.IsSet) + { + NativeMethods.CFRunLoopRunInMode(NativeMethods.DefaultRunLoopMode, RunLoopSliceSeconds, false); + + if (_upgradeWhenTrusted && NativeMethods.AXIsProcessTrusted()) + { + return; + } + } + } + + private void RemoveTap() + { + if (_source != 0) + { + NativeMethods.CFRunLoopRemoveSource(_runLoop, _source, NativeMethods.DefaultRunLoopMode); + NativeMethods.CFRelease(_source); + _source = 0; + } + + if (_tap != 0) + { + NativeMethods.CGEventTapEnable(_tap, false); + NativeMethods.CFMachPortInvalidate(_tap); + NativeMethods.CFRelease(_tap); + _tap = 0; + } + } + + private void LogAccess(KeyboardHookAccess access) + { + switch (access) + { + case KeyboardHookAccess.Full: + Logger.Information("Global keyboard event tap installed."); + break; + case KeyboardHookAccess.ObserveOnly: + Logger.Warning( + "Global keyboard event tap installed listen-only: Macro Deck has no Accessibility " + + "permission, so a suppressed hotkey still reaches other applications." + ); + break; + case KeyboardHookAccess.Denied: + Logger.Warning( + "Could not install the global keyboard event tap: Macro Deck has neither the Input " + + "Monitoring nor the Accessibility permission. Retrying every {Seconds} seconds.", + _retryInterval.TotalSeconds + ); + break; + } + } + + private nint OnEvent(nint proxy, uint type, nint cgEvent, nint userInfo) + { + switch (type) + { + case NativeMethods.kCGEventTapDisabledByTimeout: + case NativeMethods.kCGEventTapDisabledByUserInput: + // macOS turns off a tap whose callback was too slow; turn it back on rather than go deaf. + NativeMethods.CGEventTapEnable(_tap, true); + return cgEvent; + + case NativeMethods.kCGEventFlagsChanged: + SyncModifiers(cgEvent); + return cgEvent; + + case NativeMethods.kCGEventKeyDown: + case NativeMethods.kCGEventKeyUp: + SyncModifiers(cgEvent); + return HandleKey(type, cgEvent) ? 0 : cgEvent; + + default: + return cgEvent; + } + } + + /// when the event must be swallowed. + private bool HandleKey(uint type, nint cgEvent) + { + var keyCode = (int) + NativeMethods.CGEventGetIntegerValueField(cgEvent, NativeMethods.kCGKeyboardEventKeycode); + if (MacKeyCodes.ToVirtualKey(keyCode) is not { } vkCode) + { + return false; + } + + if (type == NativeMethods.kCGEventKeyUp) + { + return OnKeyUp(vkCode); + } + + // A fresh press of a key still marked held means its key-up was never seen (Secure Event Input + // hides keys from every tap), so drop the stale state or this press would count as a repeat. + if (NativeMethods.CGEventGetIntegerValueField(cgEvent, NativeMethods.kCGKeyboardEventAutorepeat) == 0) + { + OnKeyUp(vkCode); + } + + return OnKeyDown(vkCode); + } + + // Modifier state is read from every event's flags rather than tracked from key presses, so a modifier + // released while the tap could not see it never sticks. + private void SyncModifiers(nint cgEvent) + { + var flags = NativeMethods.CGEventGetFlags(cgEvent); + foreach (var (vkCode, flagMask) in MacKeyCodes.ModifierFlags) + { + SetModifierHeld(vkCode, (flags & flagMask) != 0); + } + } +} diff --git a/src/SystemHotkeys/Hotkeys/MacOS/NativeMethods.cs b/src/SystemHotkeys/Hotkeys/MacOS/NativeMethods.cs new file mode 100644 index 0000000..3708d43 --- /dev/null +++ b/src/SystemHotkeys/Hotkeys/MacOS/NativeMethods.cs @@ -0,0 +1,92 @@ +using System.Runtime.InteropServices; + +namespace SystemHotkeys.Hotkeys.MacOS; + +/// +/// Quartz event tap and CoreFoundation run loop P/Invoke surface. +/// +internal static partial class NativeMethods +{ + private const string CoreFoundation = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"; + private const string CoreGraphics = "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics"; + private const string ApplicationServices = + "/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices"; + + internal const uint kCGSessionEventTap = 1; + internal const uint kCGHeadInsertEventTap = 0; + internal const uint kCGEventTapOptionDefault = 0; + internal const uint kCGEventTapOptionListenOnly = 1; + + internal const uint kCGEventKeyDown = 10; + internal const uint kCGEventKeyUp = 11; + internal const uint kCGEventFlagsChanged = 12; + internal const uint kCGEventTapDisabledByTimeout = 0xFFFFFFFE; + internal const uint kCGEventTapDisabledByUserInput = 0xFFFFFFFF; + + internal const uint kCGKeyboardEventAutorepeat = 8; + internal const uint kCGKeyboardEventKeycode = 9; + + private static readonly Lazy _defaultRunLoopMode = new(() => + Marshal.ReadIntPtr(NativeLibrary.GetExport(NativeLibrary.Load(CoreFoundation), "kCFRunLoopDefaultMode")) + ); + + internal delegate nint CGEventTapCallBack(nint proxy, uint type, nint cgEvent, nint userInfo); + + /// The kCFRunLoopDefaultMode CFStringRef, a data export rather than a function. + internal static nint DefaultRunLoopMode => _defaultRunLoopMode.Value; + + [LibraryImport(CoreGraphics)] + internal static partial nint CGEventTapCreate( + uint tap, + uint place, + uint options, + ulong eventsOfInterest, + CGEventTapCallBack callback, + nint userInfo + ); + + [LibraryImport(CoreGraphics)] + internal static partial void CGEventTapEnable(nint tap, [MarshalAs(UnmanagedType.U1)] bool enable); + + [LibraryImport(CoreGraphics)] + internal static partial long CGEventGetIntegerValueField(nint cgEvent, uint field); + + [LibraryImport(CoreGraphics)] + internal static partial ulong CGEventGetFlags(nint cgEvent); + + [LibraryImport(CoreGraphics)] + [return: MarshalAs(UnmanagedType.U1)] + internal static partial bool CGRequestListenEventAccess(); + + [LibraryImport(ApplicationServices)] + [return: MarshalAs(UnmanagedType.U1)] + internal static partial bool AXIsProcessTrusted(); + + [LibraryImport(CoreFoundation)] + internal static partial nint CFMachPortCreateRunLoopSource(nint allocator, nint port, nint order); + + [LibraryImport(CoreFoundation)] + internal static partial void CFMachPortInvalidate(nint port); + + [LibraryImport(CoreFoundation)] + internal static partial nint CFRunLoopGetCurrent(); + + [LibraryImport(CoreFoundation)] + internal static partial void CFRunLoopAddSource(nint runLoop, nint source, nint mode); + + [LibraryImport(CoreFoundation)] + internal static partial void CFRunLoopRemoveSource(nint runLoop, nint source, nint mode); + + [LibraryImport(CoreFoundation)] + internal static partial int CFRunLoopRunInMode( + nint mode, + double seconds, + [MarshalAs(UnmanagedType.U1)] bool returnAfterSourceHandled + ); + + [LibraryImport(CoreFoundation)] + internal static partial void CFRunLoopStop(nint runLoop); + + [LibraryImport(CoreFoundation)] + internal static partial void CFRelease(nint cf); +} diff --git a/src/SystemHotkeys/Hotkeys/NativeMethods.cs b/src/SystemHotkeys/Hotkeys/Windows/NativeMethods.cs similarity index 98% rename from src/SystemHotkeys/Hotkeys/NativeMethods.cs rename to src/SystemHotkeys/Hotkeys/Windows/NativeMethods.cs index 7aba113..6d76efc 100644 --- a/src/SystemHotkeys/Hotkeys/NativeMethods.cs +++ b/src/SystemHotkeys/Hotkeys/Windows/NativeMethods.cs @@ -1,6 +1,6 @@ using System.Runtime.InteropServices; -namespace SystemHotkeys.Hotkeys; +namespace SystemHotkeys.Hotkeys.Windows; /// /// Win32 P/Invoke surface for the global, passive WH_KEYBOARD_LL hook. diff --git a/src/SystemHotkeys/Hotkeys/Windows/WindowsKeyboardHook.cs b/src/SystemHotkeys/Hotkeys/Windows/WindowsKeyboardHook.cs new file mode 100644 index 0000000..f996b63 --- /dev/null +++ b/src/SystemHotkeys/Hotkeys/Windows/WindowsKeyboardHook.cs @@ -0,0 +1,103 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Serilog; + +namespace SystemHotkeys.Hotkeys.Windows; + +[SupportedOSPlatform("windows")] +internal sealed class WindowsKeyboardHook(ILogger logger) : KeyboardHook(logger) +{ + private NativeMethods.LowLevelKeyboardProc? _proc; + + private volatile Thread? _thread; + private volatile uint _threadId; + private nint _hook; + + internal override void Start() + { + if (_thread is not null) + { + return; + } + + _thread = new Thread(RunMessageLoop) + { + IsBackground = true, + Name = "SystemHotkeys.KeyboardHook", + }; + _thread.Start(); + } + + public override void Dispose() + { + if (_threadId != 0) + { + NativeMethods.PostThreadMessageW(_threadId, NativeMethods.WM_QUIT, 0, 0); + } + + _thread?.Join(TimeSpan.FromSeconds(2)); + + _thread = null; + _threadId = 0; + } + + private void RunMessageLoop() + { + _threadId = NativeMethods.GetCurrentThreadId(); + + _proc = HookCallback; + _hook = NativeMethods.SetWindowsHookExW( + NativeMethods.WH_KEYBOARD_LL, + _proc, + NativeMethods.GetModuleHandleW(null), + 0 + ); + if (_hook == 0) + { + Logger.Warning( + "Could not install the global keyboard hook (Win32 error {Error}).", + Marshal.GetLastPInvokeError() + ); + return; + } + + Logger.Information("Global keyboard hook installed."); + ReportAccess(KeyboardHookAccess.Full); + + try + { + while (NativeMethods.GetMessageW(out var message, 0, 0, 0)) + { + NativeMethods.TranslateMessage(message); + NativeMethods.DispatchMessageW(message); + } + } + finally + { + NativeMethods.UnhookWindowsHookEx(_hook); + _hook = 0; + } + } + + private nint HookCallback(int nCode, nint wParam, nint lParam) + { + if (nCode >= 0) + { + var data = Marshal.PtrToStructure(lParam); + var vkCode = (int)data.VkCode; + + var swallow = wParam switch + { + NativeMethods.WM_KEYDOWN or NativeMethods.WM_SYSKEYDOWN => OnKeyDown(vkCode), + NativeMethods.WM_KEYUP or NativeMethods.WM_SYSKEYUP => OnKeyUp(vkCode), + _ => false, + }; + if (swallow) + { + return 1; + } + } + + return NativeMethods.CallNextHookEx(0, nCode, wParam, lParam); + } +} diff --git a/src/SystemHotkeys/Localization/Strings.resx b/src/SystemHotkeys/Localization/Strings.resx index f594dbc..17ad3a5 100644 --- a/src/SystemHotkeys/Localization/Strings.resx +++ b/src/SystemHotkeys/Localization/Strings.resx @@ -61,4 +61,16 @@ Any folder + + System Hotkeys cannot see the keyboard + + + macOS has not allowed Macro Deck to read keyboard input, so no hotkey fires. Open System Settings, Privacy & Security, Input Monitoring and turn on Macro Deck. Hotkeys start working a few seconds later. If they do not, restart Macro Deck. + + + Hotkeys still reach other apps + + + A hotkey trigger is set to stop its key from reaching other apps, which on macOS needs Accessibility access. Open System Settings, Privacy & Security, Accessibility and turn on Macro Deck. Until then, hotkeys still fire but the focused app sees the key too. + diff --git a/src/SystemHotkeys/PluginIntegration.cs b/src/SystemHotkeys/PluginIntegration.cs index 65d163f..51a635d 100644 --- a/src/SystemHotkeys/PluginIntegration.cs +++ b/src/SystemHotkeys/PluginIntegration.cs @@ -23,16 +23,19 @@ public sealed class PluginIntegration internal const string FolderParameterName = "folderId"; private readonly ILogger _logger; - private readonly GlobalKeyboardHook _hook; + private readonly KeyboardHook _hook; + private readonly HookAccessNotifications _accessNotifications = new(); private IIntegrationContext? _context; private volatile bool _anyBindingIsScoped; + private volatile bool _suppressionRequested; public PluginIntegration(ILogger logger) { _logger = logger.ForContext(); - _hook = new GlobalKeyboardHook(logger); + _hook = KeyboardHook.Create(logger); _hook.HotkeyPressed += OnHotkeyPressed; + _hook.AccessChanged += OnHookAccessChanged; } public IReadOnlyList Actions { get; } = []; @@ -60,6 +63,7 @@ public Task ShutdownAsync() } _hook.Dispose(); + _accessNotifications.Reset(); _context = null; return Task.CompletedTask; } @@ -159,6 +163,18 @@ private void ApplyBoundCombos() _logger ); _hook.SetSuppressed(bound); + _suppressionRequested = bound.Count > 0; + UpdateAccessNotifications(); + } + + private void OnHookAccessChanged(KeyboardHookAccess access) => UpdateAccessNotifications(); + + private void UpdateAccessNotifications() + { + if (_context is { } context) + { + _accessNotifications.Update(context.Notifications, _hook.Access, _suppressionRequested); + } } // Moving folders never raises BindingsChanged, so watch it separately diff --git a/src/SystemHotkeys/macrodeck-build.json b/src/SystemHotkeys/macrodeck-build.json index 6c535ef..037ba19 100644 --- a/src/SystemHotkeys/macrodeck-build.json +++ b/src/SystemHotkeys/macrodeck-build.json @@ -17,6 +17,23 @@ "bin/publish/win-x64" ], "output": "bin/publish/win-x64" + }, + "osx-arm64": { + "executable": "dotnet", + "arguments": [ + "publish", + "SystemHotkeys.csproj", + "-c", + "Release", + "-r", + "osx-arm64", + "--self-contained", + "false", + "-p:UseAppHost=false", + "-o", + "bin/publish/osx-arm64" + ], + "output": "bin/publish/osx-arm64" } } } \ No newline at end of file diff --git a/src/SystemHotkeys/manifest.json b/src/SystemHotkeys/manifest.json index 49e4006..1e88af0 100644 --- a/src/SystemHotkeys/manifest.json +++ b/src/SystemHotkeys/manifest.json @@ -13,6 +13,13 @@ "kind": "FrameworkDependent", "dotnetVersion": "10.0" } + }, + "osx-arm64": { + "executable": "runtimes/osx-arm64/SystemHotkeys.dll", + "runtime": { + "kind": "FrameworkDependent", + "dotnetVersion": "10.0" + } } }, "publisher": { @@ -28,7 +35,8 @@ "permissions": [ "events:publish", "host:event-bindings", - "host:deck" + "host:deck", + "host:notifications" ], "compatibility": { "macroDeck": ">=3.0.0-beta.7" diff --git a/tests/SystemHotkeys.Tests/Hotkeys/MacKeyCodesTests.cs b/tests/SystemHotkeys.Tests/Hotkeys/MacKeyCodesTests.cs new file mode 100644 index 0000000..05c30da --- /dev/null +++ b/tests/SystemHotkeys.Tests/Hotkeys/MacKeyCodesTests.cs @@ -0,0 +1,95 @@ +using NUnit.Framework; +using SystemHotkeys.Hotkeys; +using SystemHotkeys.Hotkeys.MacOS; + +namespace SystemHotkeys.Tests.Hotkeys; + +[TestFixture] +public sealed class MacKeyCodesTests +{ + [TestCase(0x00, "A")] + [TestCase(0x06, "Z")] + [TestCase(0x10, "Y")] + [TestCase(0x2E, "M")] + [TestCase(0x1D, "0")] + [TestCase(0x12, "1")] + [TestCase(0x16, "6")] + [TestCase(0x17, "5")] + [TestCase(0x7A, "F1")] + [TestCase(0x63, "F3")] + [TestCase(0x6F, "F12")] + [TestCase(0x5A, "F20")] + [TestCase(0x24, "Enter")] + [TestCase(0x4C, "Enter")] + [TestCase(0x33, "Backspace")] + [TestCase(0x75, "Delete")] + [TestCase(0x35, "Escape")] + [TestCase(0x31, "Space")] + [TestCase(0x7E, "ArrowUp")] + [TestCase(0x7B, "ArrowLeft")] + [TestCase(0x52, "Numpad0")] + [TestCase(0x5C, "Numpad9")] + [TestCase(0x45, "NumpadAdd")] + [TestCase(0x4E, "NumpadSubtract")] + [TestCase(0x43, "NumpadMultiply")] + [TestCase(0x4B, "NumpadDivide")] + [TestCase(0x41, "NumpadDecimal")] + [TestCase(0x1B, "-")] + [TestCase(0x2A, "\\")] + [TestCase(0x32, "`")] + [TestCase(0x48, "AudioVolumeUp")] + public void Mac_key_codes_name_the_same_key_as_on_windows(int keyCode, string expected) + { + var vkCode = MacKeyCodes.ToVirtualKey(keyCode); + + Assert.That(vkCode, Is.Not.Null); + Assert.That(VirtualKeys.NameOf(vkCode!.Value), Is.EqualTo(expected)); + } + + [Test] + public void Every_mapped_mac_key_code_has_an_editor_key_name() + { + for (var keyCode = 0; keyCode <= 0x7F; keyCode++) + { + if (MacKeyCodes.ToVirtualKey(keyCode) is { } vkCode) + { + Assert.That(VirtualKeys.NameOf(vkCode), Is.Not.Null, $"kVK 0x{keyCode:X2}"); + } + } + } + + [TestCase(0x37)] + [TestCase(0x38)] + [TestCase(0x3A)] + [TestCase(0x3B)] + [TestCase(0x39)] + [TestCase(0x3F)] + public void Modifier_caps_lock_and_fn_key_codes_are_never_a_trailing_key(int keyCode) + { + Assert.That(MacKeyCodes.ToVirtualKey(keyCode), Is.Null); + } + + [Test] + public void Every_modifier_flag_names_a_distinct_modifier_key() + { + Assert.Multiple(() => + { + var virtualKeys = MacKeyCodes.ModifierFlags.Select(m => m.VirtualKey).ToList(); + + Assert.That(virtualKeys, Is.All.Matches(VirtualKeys.IsModifier)); + Assert.That(virtualKeys, Is.Unique); + Assert.That(MacKeyCodes.ModifierFlags.Select(m => m.FlagMask), Is.Unique); + }); + } + + [Test] + public void Command_and_option_read_back_as_meta_and_alt() + { + var command = MacKeyCodes.ModifierFlags.Single(m => m.FlagMask == 0x08).VirtualKey; + var option = MacKeyCodes.ModifierFlags.Single(m => m.FlagMask == 0x20).VirtualKey; + + var combo = HotkeyCombo.TryCreate(new HashSet { command, option, 'K' }, 'K'); + + Assert.That(combo?.Text, Is.EqualTo("Alt+Meta+K")); + } +} From 37e22d168e17ef8977f9310254b2c0b1cb0d3f06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Fri, 9 Oct 2026 16:16:12 +0200 Subject: [PATCH 2/4] fix: handle Accessibility being granted or revoked while the macOS hook runs --- AGENTS.md | 15 ++++-- README.md | 3 +- .../Hotkeys/MacOS/MacKeyboardHook.cs | 51 +++++++++++++------ .../Hotkeys/MacOS/NativeMethods.cs | 7 +-- 4 files changed, 49 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1a3793b..10990b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,10 +89,17 @@ Design knowledge that is not obvious from the code alone: - **A failed hook never takes the plugin down.** The conformance suite runs each declared platform on a CI runner with no keyboard access at all (no macOS permission granted), and it still has to pass. A backend logs and reports its `KeyboardHookAccess`; it never throws out of `Start`. -- **macOS permissions decide what the tap can do.** `MacKeyboardHook` tries an active tap first (needs - Accessibility, can swallow), falls back to listen-only (needs Input Monitoring, cannot swallow), and with - neither reports `Denied` and retries every 5 seconds. TCC attributes both permissions to Macro Deck, the - responsible process, not to the plugin's `dotnet`. `PluginIntegration` turns the access level into a +- **macOS permissions decide what the tap can do, and can change under a running tap.** + `MacKeyboardHook` tries an active tap first (needs Accessibility, can swallow), falls back to listen-only + (needs Input Monitoring, cannot swallow), and with neither reports `Denied` and retries every 5 seconds. + Every half-second slice it probes whether an active tap can still be created and reinstalls the tap when + that answer changes: a grant upgrades without a restart, and a revocation must be noticed because an + active tap whose Accessibility was taken away freezes all keyboard and mouse input. The probe is a + throwaway active tap listening only to `kCGEventNull`, because `AXIsProcessTrusted` kept reporting + false in a running process after a grant. +- **TCC attributes macOS permissions to the responsible process.** That is Macro Deck for an installed + plugin, not the plugin's `dotnet`, but the terminal app (VS Code, Terminal) for one started with + `make run`/`make watch`, so test a permission change by toggling that app. `PluginIntegration` turns the access level into a notification: `Denied` always, `ObserveOnly` only while some binding asks for suppression. `UserNotificationRequest` takes plain strings, so `HookAccessNotifications` resolves the `Strings` keys itself through its own `LocalizationResolver`. diff --git a/README.md b/README.md index db2a557..8b38ca1 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,8 @@ Deck, so the permission goes to **Macro Deck**, under **System Settings > Privac Without it hotkeys still fire, but the focused app sees the key too. The plugin shows a notification only once a trigger actually asks for suppression. -The plugin retries every few seconds, so a newly granted permission takes effect without a restart. If +The plugin checks again every few seconds, so a newly granted permission takes effect without a +restart, and taking Accessibility away drops it back to watching only. If it does not, restart Macro Deck. Differences from Windows: diff --git a/src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs b/src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs index c2a34b7..48af8b1 100644 --- a/src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs +++ b/src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs @@ -6,7 +6,8 @@ namespace SystemHotkeys.Hotkeys.MacOS; /// /// A Quartz event tap on its own CFRunLoop thread. An active tap (able to swallow a key) needs the /// Accessibility permission; without it the hook falls back to a listen-only tap, which needs Input -/// Monitoring. With neither it keeps retrying, so granting a permission takes effect without a restart. +/// Monitoring. With neither it keeps retrying, and it swaps taps whenever Accessibility is granted or +/// revoked, so a permission change takes effect without a restart. /// [SupportedOSPlatform("macos")] internal sealed class MacKeyboardHook(ILogger logger) : KeyboardHook(logger) @@ -16,7 +17,8 @@ internal sealed class MacKeyboardHook(ILogger logger) : KeyboardHook(logger) | (1UL << (int)NativeMethods.kCGEventKeyUp) | (1UL << (int)NativeMethods.kCGEventFlagsChanged); - private const double RunLoopSliceSeconds = 1; + // Short, because an active tap whose Accessibility was revoked freezes all input until it is removed. + private const double RunLoopSliceSeconds = 0.5; private static readonly TimeSpan _retryInterval = TimeSpan.FromSeconds(5); // Never disposed: it is reused across Start/Dispose cycles and never allocates a kernel wait handle. @@ -28,7 +30,7 @@ internal sealed class MacKeyboardHook(ILogger logger) : KeyboardHook(logger) private volatile nint _runLoop; private nint _tap; private nint _source; - private bool _upgradeWhenTrusted; + private bool _tapIsActive; private bool _listenAccessRequested; internal override void Start() @@ -82,7 +84,7 @@ private void RunEventTaps() continue; } - RunUntilStoppedOrUpgradable(); + RunUntilStoppedOrAccessibilityChanges(); RemoveTap(); } } @@ -96,12 +98,11 @@ private void RunEventTaps() private KeyboardHookAccess InstallTap() { _tap = CreateTap(NativeMethods.kCGEventTapOptionDefault); - var access = KeyboardHookAccess.Full; + _tapIsActive = _tap != 0; if (_tap == 0) { _tap = CreateTap(NativeMethods.kCGEventTapOptionListenOnly); - access = KeyboardHookAccess.ObserveOnly; } if (_tap == 0) @@ -116,35 +117,46 @@ private KeyboardHookAccess InstallTap() return KeyboardHookAccess.Denied; } - // Granting Accessibility later should upgrade the tap, but if it is already granted and the active - // tap still failed, re-checking would only rebuild the same listen-only tap every slice. - _upgradeWhenTrusted = - access == KeyboardHookAccess.ObserveOnly && !NativeMethods.AXIsProcessTrusted(); - _source = NativeMethods.CFMachPortCreateRunLoopSource(0, _tap, 0); NativeMethods.CFRunLoopAddSource(_runLoop, _source, NativeMethods.DefaultRunLoopMode); NativeMethods.CGEventTapEnable(_tap, true); - return access; + return _tapIsActive ? KeyboardHookAccess.Full : KeyboardHookAccess.ObserveOnly; } - private nint CreateTap(uint options) => + private nint CreateTap(uint options, ulong eventsOfInterest = KeyboardEvents) => NativeMethods.CGEventTapCreate( NativeMethods.kCGSessionEventTap, NativeMethods.kCGHeadInsertEventTap, options, - KeyboardEvents, + eventsOfInterest, _callback!, 0 ); + // AXIsProcessTrusted kept answering false in a running process after Accessibility was granted, so the + // grant is tested by creating an active tap instead. Listening to no real event, it holds back no input. + private bool CanCreateActiveTap() + { + var probe = CreateTap(NativeMethods.kCGEventTapOptionDefault, 1UL << (int)NativeMethods.kCGEventNull); + if (probe == 0) + { + return false; + } + + NativeMethods.CGEventTapEnable(probe, false); + NativeMethods.CFMachPortInvalidate(probe); + NativeMethods.CFRelease(probe); + return true; + } + // Runs in short slices because CFRunLoopStop is lost when it arrives before the loop is running. - private void RunUntilStoppedOrUpgradable() + private void RunUntilStoppedOrAccessibilityChanges() { while (!_stopping.IsSet) { NativeMethods.CFRunLoopRunInMode(NativeMethods.DefaultRunLoopMode, RunLoopSliceSeconds, false); - if (_upgradeWhenTrusted && NativeMethods.AXIsProcessTrusted()) + if (_tapIsActive != CanCreateActiveTap()) { return; } @@ -198,6 +210,13 @@ private nint OnEvent(nint proxy, uint type, nint cgEvent, nint userInfo) { case NativeMethods.kCGEventTapDisabledByTimeout: case NativeMethods.kCGEventTapDisabledByUserInput: + if (_tapIsActive && !CanCreateActiveTap()) + { + // Accessibility was revoked; re-enabling would freeze input again, so swap to listen-only. + NativeMethods.CFRunLoopStop(_runLoop); + return cgEvent; + } + // macOS turns off a tap whose callback was too slow; turn it back on rather than go deaf. NativeMethods.CGEventTapEnable(_tap, true); return cgEvent; diff --git a/src/SystemHotkeys/Hotkeys/MacOS/NativeMethods.cs b/src/SystemHotkeys/Hotkeys/MacOS/NativeMethods.cs index 3708d43..f4b1e83 100644 --- a/src/SystemHotkeys/Hotkeys/MacOS/NativeMethods.cs +++ b/src/SystemHotkeys/Hotkeys/MacOS/NativeMethods.cs @@ -9,14 +9,13 @@ internal static partial class NativeMethods { private const string CoreFoundation = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"; private const string CoreGraphics = "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics"; - private const string ApplicationServices = - "/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices"; internal const uint kCGSessionEventTap = 1; internal const uint kCGHeadInsertEventTap = 0; internal const uint kCGEventTapOptionDefault = 0; internal const uint kCGEventTapOptionListenOnly = 1; + internal const uint kCGEventNull = 0; internal const uint kCGEventKeyDown = 10; internal const uint kCGEventKeyUp = 11; internal const uint kCGEventFlagsChanged = 12; @@ -58,10 +57,6 @@ nint userInfo [return: MarshalAs(UnmanagedType.U1)] internal static partial bool CGRequestListenEventAccess(); - [LibraryImport(ApplicationServices)] - [return: MarshalAs(UnmanagedType.U1)] - internal static partial bool AXIsProcessTrusted(); - [LibraryImport(CoreFoundation)] internal static partial nint CFMachPortCreateRunLoopSource(nint allocator, nint port, nint order); From b277712f3017d4bcd46dae6df6b04e8f0d65fdd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Fri, 9 Oct 2026 16:25:36 +0200 Subject: [PATCH 3/4] chore: trim comments --- .github/workflows/ci.yml | 3 +- src/SystemHotkeys/HookAccessNotifications.cs | 5 +- src/SystemHotkeys/Hotkeys/KeyboardHook.cs | 20 +- .../Hotkeys/MacOS/MacKeyCodes.cs | 223 +++++++++--------- .../Hotkeys/MacOS/MacKeyboardHook.cs | 27 +-- .../Hotkeys/MacOS/NativeMethods.cs | 5 +- src/SystemHotkeys/Hotkeys/VirtualKeys.cs | 6 + .../Hotkeys/MacKeyCodesTests.cs | 18 +- 8 files changed, 133 insertions(+), 174 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 489182a..1609546 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,8 +8,7 @@ on: jobs: build-and-test: - # One runner per platform the manifest declares: the integration tests start the real keyboard hook, - # so this is where a backend that crashes on its own platform shows up. + # The integration tests start the real keyboard hook, so every platform runs them. strategy: fail-fast: false matrix: diff --git a/src/SystemHotkeys/HookAccessNotifications.cs b/src/SystemHotkeys/HookAccessNotifications.cs index 8c838d7..4d4fe7c 100644 --- a/src/SystemHotkeys/HookAccessNotifications.cs +++ b/src/SystemHotkeys/HookAccessNotifications.cs @@ -4,15 +4,12 @@ namespace SystemHotkeys; -/// -/// Tells the user which OS permission the keyboard hook is missing, and clears that again once granted. -/// internal sealed class HookAccessNotifications { private const string InputMonitoringKey = "input-monitoring-required"; private const string AccessibilityKey = "accessibility-required"; - // UserNotificationRequest takes plain strings, so the plugin resolves its own text. + // UserNotificationRequest takes plain strings. private static readonly LocalizationResolver _resolver = CreateResolver(); private readonly Lock _gate = new(); diff --git a/src/SystemHotkeys/Hotkeys/KeyboardHook.cs b/src/SystemHotkeys/Hotkeys/KeyboardHook.cs index 2d0455d..3aaa245 100644 --- a/src/SystemHotkeys/Hotkeys/KeyboardHook.cs +++ b/src/SystemHotkeys/Hotkeys/KeyboardHook.cs @@ -5,10 +5,7 @@ namespace SystemHotkeys.Hotkeys; -/// -/// The platform-neutral half of the global keyboard hook: tracks held keys, detects a completed combo and -/// decides which key events a suppressed combo swallows. A platform backend feeds it virtual-key codes. -/// +// Platform backends feed this Windows virtual-key codes. internal abstract class KeyboardHook : IDisposable { private readonly Lock _gate = new(); @@ -60,7 +57,7 @@ protected void ReportAccess(KeyboardHookAccess access) AccessChanged?.Invoke(access); } - /// when the key-down must be kept from other applications. + // True when the key must not reach other applications. protected bool OnKeyDown(int vkCode) { bool isNewPress; @@ -102,7 +99,6 @@ protected bool OnKeyDown(int vkCode) return true; } - /// when the key-up must be kept from other applications. protected bool OnKeyUp(int vkCode) { lock (_gate) @@ -112,10 +108,7 @@ protected bool OnKeyUp(int vkCode) } } - /// - /// Records a modifier's state without running combo detection, for a platform that reports modifier - /// state rather than modifier key presses. A modifier never completes a combo and is never swallowed. - /// + // For a platform that reports modifier state rather than modifier key presses. protected void SetModifierHeld(int vkCode, bool held) { lock (_gate) @@ -145,14 +138,9 @@ public override void Dispose() { } internal enum KeyboardHookAccess { - /// Not started yet, or failed for a reason the user cannot fix with a permission. + // Also a hook that failed for a reason no permission fixes. Pending, - - /// The OS refused the hook until the user grants a permission. Denied, - - /// Hotkeys are detected, but a suppressed combo still reaches other applications. ObserveOnly, - Full, } diff --git a/src/SystemHotkeys/Hotkeys/MacOS/MacKeyCodes.cs b/src/SystemHotkeys/Hotkeys/MacOS/MacKeyCodes.cs index 0555e76..33d9ad8 100644 --- a/src/SystemHotkeys/Hotkeys/MacOS/MacKeyCodes.cs +++ b/src/SystemHotkeys/Hotkeys/MacOS/MacKeyCodes.cs @@ -1,132 +1,121 @@ namespace SystemHotkeys.Hotkeys.MacOS; -/// -/// macOS kVK_* key code to Windows virtual-key code mapping, so a macOS key event runs through the -/// same naming and detection as a Windows one. -/// -/// -/// A kVK_* code names a physical key position on a US ANSI keyboard, not the character the current -/// layout puts there. Not exhaustive, like : an unmapped code is never recognized -/// as a combo's trailing key. -/// +// kVK codes are key positions on a US keyboard, not what the active layout types. public static class MacKeyCodes { - private static readonly Dictionary _virtualKeys = new() + private static readonly Dictionary _names = new() { - [0x00] = 'A', - [0x01] = 'S', - [0x02] = 'D', - [0x03] = 'F', - [0x04] = 'H', - [0x05] = 'G', - [0x06] = 'Z', - [0x07] = 'X', - [0x08] = 'C', - [0x09] = 'V', - [0x0B] = 'B', - [0x0C] = 'Q', - [0x0D] = 'W', - [0x0E] = 'E', - [0x0F] = 'R', - [0x10] = 'Y', - [0x11] = 'T', - [0x1F] = 'O', - [0x20] = 'U', - [0x22] = 'I', - [0x23] = 'P', - [0x25] = 'L', - [0x26] = 'J', - [0x28] = 'K', - [0x2D] = 'N', - [0x2E] = 'M', + [0x00] = "A", + [0x01] = "S", + [0x02] = "D", + [0x03] = "F", + [0x04] = "H", + [0x05] = "G", + [0x06] = "Z", + [0x07] = "X", + [0x08] = "C", + [0x09] = "V", + [0x0B] = "B", + [0x0C] = "Q", + [0x0D] = "W", + [0x0E] = "E", + [0x0F] = "R", + [0x10] = "Y", + [0x11] = "T", + [0x1F] = "O", + [0x20] = "U", + [0x22] = "I", + [0x23] = "P", + [0x25] = "L", + [0x26] = "J", + [0x28] = "K", + [0x2D] = "N", + [0x2E] = "M", - [0x1D] = '0', - [0x12] = '1', - [0x13] = '2', - [0x14] = '3', - [0x15] = '4', - [0x17] = '5', - [0x16] = '6', - [0x1A] = '7', - [0x1C] = '8', - [0x19] = '9', + [0x1D] = "0", + [0x12] = "1", + [0x13] = "2", + [0x14] = "3", + [0x15] = "4", + [0x17] = "5", + [0x16] = "6", + [0x1A] = "7", + [0x1C] = "8", + [0x19] = "9", - [0x7A] = 0x70, // F1 - [0x78] = 0x71, - [0x63] = 0x72, - [0x76] = 0x73, - [0x60] = 0x74, - [0x61] = 0x75, - [0x62] = 0x76, - [0x64] = 0x77, - [0x65] = 0x78, - [0x6D] = 0x79, - [0x67] = 0x7A, - [0x6F] = 0x7B, - [0x69] = 0x7C, - [0x6B] = 0x7D, - [0x71] = 0x7E, - [0x6A] = 0x7F, - [0x40] = 0x80, - [0x4F] = 0x81, - [0x50] = 0x82, - [0x5A] = 0x83, // F20 + [0x7A] = "F1", + [0x78] = "F2", + [0x63] = "F3", + [0x76] = "F4", + [0x60] = "F5", + [0x61] = "F6", + [0x62] = "F7", + [0x64] = "F8", + [0x65] = "F9", + [0x6D] = "F10", + [0x67] = "F11", + [0x6F] = "F12", + [0x69] = "F13", + [0x6B] = "F14", + [0x71] = "F15", + [0x6A] = "F16", + [0x40] = "F17", + [0x4F] = "F18", + [0x50] = "F19", + [0x5A] = "F20", - [0x24] = 0x0D, // Return - [0x4C] = 0x0D, // keypad Enter, which Windows also reports as Enter - [0x30] = 0x09, // Tab - [0x31] = 0x20, // Space - [0x33] = 0x08, // Delete, the key Windows calls Backspace - [0x35] = 0x1B, // Escape - [0x72] = 0x2D, // Help, in the Insert position on a PC keyboard - [0x75] = 0x2E, // Forward Delete - [0x73] = 0x24, // Home - [0x77] = 0x23, // End - [0x74] = 0x21, // Page Up - [0x79] = 0x22, // Page Down - [0x7B] = 0x25, // Left - [0x7E] = 0x26, // Up - [0x7C] = 0x27, // Right - [0x7D] = 0x28, // Down - [0x6E] = 0x5D, // Context menu + [0x24] = "Enter", + [0x4C] = "Enter", + [0x30] = "Tab", + [0x31] = "Space", + [0x33] = "Backspace", + [0x35] = "Escape", + [0x72] = "Insert", + [0x75] = "Delete", + [0x73] = "Home", + [0x77] = "End", + [0x74] = "PageUp", + [0x79] = "PageDown", + [0x7B] = "ArrowLeft", + [0x7E] = "ArrowUp", + [0x7C] = "ArrowRight", + [0x7D] = "ArrowDown", + [0x6E] = "ContextMenu", - [0x52] = 0x60, // keypad 0 - [0x53] = 0x61, - [0x54] = 0x62, - [0x55] = 0x63, - [0x56] = 0x64, - [0x57] = 0x65, - [0x58] = 0x66, - [0x59] = 0x67, - [0x5B] = 0x68, - [0x5C] = 0x69, // keypad 9 - [0x43] = 0x6A, // keypad * - [0x45] = 0x6B, // keypad + - [0x4E] = 0x6D, // keypad - - [0x41] = 0x6E, // keypad . - [0x4B] = 0x6F, // keypad / + [0x52] = "Numpad0", + [0x53] = "Numpad1", + [0x54] = "Numpad2", + [0x55] = "Numpad3", + [0x56] = "Numpad4", + [0x57] = "Numpad5", + [0x58] = "Numpad6", + [0x59] = "Numpad7", + [0x5B] = "Numpad8", + [0x5C] = "Numpad9", + [0x43] = "NumpadMultiply", + [0x45] = "NumpadAdd", + [0x4E] = "NumpadSubtract", + [0x41] = "NumpadDecimal", + [0x4B] = "NumpadDivide", - [0x4A] = 0xAD, // Mute - [0x49] = 0xAE, // Volume Down - [0x48] = 0xAF, // Volume Up + [0x4A] = "AudioVolumeMute", + [0x49] = "AudioVolumeDown", + [0x48] = "AudioVolumeUp", - [0x29] = 0xBA, // ; - [0x18] = 0xBB, // = - [0x2B] = 0xBC, // , - [0x1B] = 0xBD, // - - [0x2F] = 0xBE, // . - [0x2C] = 0xBF, // / - [0x32] = 0xC0, // ` - [0x21] = 0xDB, // [ - [0x2A] = 0xDC, // \ - [0x1E] = 0xDD, // ] - [0x27] = 0xDE, // ' + [0x29] = ";", + [0x18] = "=", + [0x2B] = ",", + [0x1B] = "-", + [0x2F] = ".", + [0x2C] = "/", + [0x32] = "`", + [0x21] = "[", + [0x2A] = "\\", + [0x1E] = "]", + [0x27] = "'", }; - /// - /// Each modifier's device-dependent bit in CGEventFlags, the only part of the flags that tells - /// the left key from the right one. - /// + // The device-dependent CGEventFlags bits, the only ones that tell left from right. public static IReadOnlyList<(int VirtualKey, ulong FlagMask)> ModifierFlags { get; } = [ (VirtualKeys.LControl, 0x00000001), @@ -140,5 +129,5 @@ public static class MacKeyCodes ]; public static int? ToVirtualKey(int keyCode) => - _virtualKeys.TryGetValue(keyCode, out var vkCode) ? vkCode : null; + _names.TryGetValue(keyCode, out var name) ? VirtualKeys.CodeOf(name) : null; } diff --git a/src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs b/src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs index 48af8b1..7024836 100644 --- a/src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs +++ b/src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs @@ -3,12 +3,7 @@ namespace SystemHotkeys.Hotkeys.MacOS; -/// -/// A Quartz event tap on its own CFRunLoop thread. An active tap (able to swallow a key) needs the -/// Accessibility permission; without it the hook falls back to a listen-only tap, which needs Input -/// Monitoring. With neither it keeps retrying, and it swaps taps whenever Accessibility is granted or -/// revoked, so a permission change takes effect without a restart. -/// +// An active tap needs Accessibility, a listen-only one Input Monitoring. [SupportedOSPlatform("macos")] internal sealed class MacKeyboardHook(ILogger logger) : KeyboardHook(logger) { @@ -17,11 +12,11 @@ internal sealed class MacKeyboardHook(ILogger logger) : KeyboardHook(logger) | (1UL << (int)NativeMethods.kCGEventKeyUp) | (1UL << (int)NativeMethods.kCGEventFlagsChanged); - // Short, because an active tap whose Accessibility was revoked freezes all input until it is removed. + // An active tap whose Accessibility was revoked freezes all input until it is removed. private const double RunLoopSliceSeconds = 0.5; private static readonly TimeSpan _retryInterval = TimeSpan.FromSeconds(5); - // Never disposed: it is reused across Start/Dispose cycles and never allocates a kernel wait handle. + // Reused across Start/Dispose cycles. private readonly ManualResetEventSlim _stopping = new(); private NativeMethods.CGEventTapCallBack? _callback; @@ -109,7 +104,7 @@ private KeyboardHookAccess InstallTap() { if (!_listenAccessRequested) { - // Shows the system prompt once and lists Macro Deck under Input Monitoring. + // Lists Macro Deck under Input Monitoring. _listenAccessRequested = true; NativeMethods.CGRequestListenEventAccess(); } @@ -133,8 +128,7 @@ private nint CreateTap(uint options, ulong eventsOfInterest = KeyboardEvents) => 0 ); - // AXIsProcessTrusted kept answering false in a running process after Accessibility was granted, so the - // grant is tested by creating an active tap instead. Listening to no real event, it holds back no input. + // AXIsProcessTrusted stays false in a running process after Accessibility is granted. private bool CanCreateActiveTap() { var probe = CreateTap(NativeMethods.kCGEventTapOptionDefault, 1UL << (int)NativeMethods.kCGEventNull); @@ -149,7 +143,7 @@ private bool CanCreateActiveTap() return true; } - // Runs in short slices because CFRunLoopStop is lost when it arrives before the loop is running. + // CFRunLoopStop is lost when it arrives before the loop runs. private void RunUntilStoppedOrAccessibilityChanges() { while (!_stopping.IsSet) @@ -212,12 +206,11 @@ private nint OnEvent(nint proxy, uint type, nint cgEvent, nint userInfo) case NativeMethods.kCGEventTapDisabledByUserInput: if (_tapIsActive && !CanCreateActiveTap()) { - // Accessibility was revoked; re-enabling would freeze input again, so swap to listen-only. + // Accessibility was revoked: swap to listen-only. NativeMethods.CFRunLoopStop(_runLoop); return cgEvent; } - // macOS turns off a tap whose callback was too slow; turn it back on rather than go deaf. NativeMethods.CGEventTapEnable(_tap, true); return cgEvent; @@ -235,7 +228,6 @@ private nint OnEvent(nint proxy, uint type, nint cgEvent, nint userInfo) } } - /// when the event must be swallowed. private bool HandleKey(uint type, nint cgEvent) { var keyCode = (int) @@ -250,8 +242,7 @@ private bool HandleKey(uint type, nint cgEvent) return OnKeyUp(vkCode); } - // A fresh press of a key still marked held means its key-up was never seen (Secure Event Input - // hides keys from every tap), so drop the stale state or this press would count as a repeat. + // Secure Event Input hides key-ups, so a fresh press may find the key still held. if (NativeMethods.CGEventGetIntegerValueField(cgEvent, NativeMethods.kCGKeyboardEventAutorepeat) == 0) { OnKeyUp(vkCode); @@ -260,8 +251,6 @@ private bool HandleKey(uint type, nint cgEvent) return OnKeyDown(vkCode); } - // Modifier state is read from every event's flags rather than tracked from key presses, so a modifier - // released while the tap could not see it never sticks. private void SyncModifiers(nint cgEvent) { var flags = NativeMethods.CGEventGetFlags(cgEvent); diff --git a/src/SystemHotkeys/Hotkeys/MacOS/NativeMethods.cs b/src/SystemHotkeys/Hotkeys/MacOS/NativeMethods.cs index f4b1e83..e580de1 100644 --- a/src/SystemHotkeys/Hotkeys/MacOS/NativeMethods.cs +++ b/src/SystemHotkeys/Hotkeys/MacOS/NativeMethods.cs @@ -2,9 +2,6 @@ namespace SystemHotkeys.Hotkeys.MacOS; -/// -/// Quartz event tap and CoreFoundation run loop P/Invoke surface. -/// internal static partial class NativeMethods { private const string CoreFoundation = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"; @@ -25,13 +22,13 @@ internal static partial class NativeMethods internal const uint kCGKeyboardEventAutorepeat = 8; internal const uint kCGKeyboardEventKeycode = 9; + // A CFStringRef exported as data, not a function. private static readonly Lazy _defaultRunLoopMode = new(() => Marshal.ReadIntPtr(NativeLibrary.GetExport(NativeLibrary.Load(CoreFoundation), "kCFRunLoopDefaultMode")) ); internal delegate nint CGEventTapCallBack(nint proxy, uint type, nint cgEvent, nint userInfo); - /// The kCFRunLoopDefaultMode CFStringRef, a data export rather than a function. internal static nint DefaultRunLoopMode => _defaultRunLoopMode.Value; [LibraryImport(CoreGraphics)] diff --git a/src/SystemHotkeys/Hotkeys/VirtualKeys.cs b/src/SystemHotkeys/Hotkeys/VirtualKeys.cs index 0ed034e..ccde55f 100644 --- a/src/SystemHotkeys/Hotkeys/VirtualKeys.cs +++ b/src/SystemHotkeys/Hotkeys/VirtualKeys.cs @@ -38,11 +38,17 @@ public static class VirtualKeys ]; private static readonly IReadOnlyDictionary _names = BuildNames(); + private static readonly Dictionary _codes = _names.ToDictionary( + entry => entry.Value, + entry => entry.Key + ); public static bool IsModifier(int vkCode) => _modifierCodes.Contains(vkCode); public static string? NameOf(int vkCode) => _names.GetValueOrDefault(vkCode); + public static int? CodeOf(string name) => _codes.TryGetValue(name, out var vkCode) ? vkCode : null; + private static Dictionary BuildNames() { var names = new Dictionary(); diff --git a/tests/SystemHotkeys.Tests/Hotkeys/MacKeyCodesTests.cs b/tests/SystemHotkeys.Tests/Hotkeys/MacKeyCodesTests.cs index 05c30da..091280e 100644 --- a/tests/SystemHotkeys.Tests/Hotkeys/MacKeyCodesTests.cs +++ b/tests/SystemHotkeys.Tests/Hotkeys/MacKeyCodesTests.cs @@ -38,6 +38,12 @@ public sealed class MacKeyCodesTests [TestCase(0x2A, "\\")] [TestCase(0x32, "`")] [TestCase(0x48, "AudioVolumeUp")] + [TestCase(0x4A, "AudioVolumeMute")] + [TestCase(0x72, "Insert")] + [TestCase(0x79, "PageDown")] + [TestCase(0x6E, "ContextMenu")] + [TestCase(0x27, "'")] + [TestCase(0x21, "[")] public void Mac_key_codes_name_the_same_key_as_on_windows(int keyCode, string expected) { var vkCode = MacKeyCodes.ToVirtualKey(keyCode); @@ -46,18 +52,6 @@ public void Mac_key_codes_name_the_same_key_as_on_windows(int keyCode, string ex Assert.That(VirtualKeys.NameOf(vkCode!.Value), Is.EqualTo(expected)); } - [Test] - public void Every_mapped_mac_key_code_has_an_editor_key_name() - { - for (var keyCode = 0; keyCode <= 0x7F; keyCode++) - { - if (MacKeyCodes.ToVirtualKey(keyCode) is { } vkCode) - { - Assert.That(VirtualKeys.NameOf(vkCode), Is.Not.Null, $"kVK 0x{keyCode:X2}"); - } - } - } - [TestCase(0x37)] [TestCase(0x38)] [TestCase(0x3A)] From 893ca1c1c7fd6cce3b56412377f2c8b70d600ee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Wed, 14 Oct 2026 23:13:25 +0200 Subject: [PATCH 4/4] refactor: use the SDK key names and native key lookup --- .github/workflows/release.yml | 2 +- AGENTS.md | 32 ++-- Directory.Packages.props | 12 +- README.md | 7 +- .../Hotkeys/BoundHotkeyCombos.cs | 29 +++- src/SystemHotkeys/Hotkeys/HotkeyCombo.cs | 37 ++--- src/SystemHotkeys/Hotkeys/KeyboardHook.cs | 57 +++---- .../Hotkeys/MacOS/MacKeyCodes.cs | 133 ----------------- .../Hotkeys/MacOS/MacKeyboardHook.cs | 27 +++- src/SystemHotkeys/Hotkeys/VirtualKeys.cs | 140 ------------------ .../Hotkeys/Windows/NativeMethods.cs | 1 + .../Hotkeys/Windows/WindowsKeyboardHook.cs | 20 ++- .../Hotkeys/BoundHotkeyCombosTests.cs | 18 +++ .../Hotkeys/HotkeyComboTests.cs | 117 ++++----------- .../Hotkeys/MacKeyCodesTests.cs | 89 ----------- .../Hotkeys/VirtualKeysTests.cs | 61 -------- 16 files changed, 174 insertions(+), 608 deletions(-) delete mode 100644 src/SystemHotkeys/Hotkeys/MacOS/MacKeyCodes.cs delete mode 100644 src/SystemHotkeys/Hotkeys/VirtualKeys.cs delete mode 100644 tests/SystemHotkeys.Tests/Hotkeys/MacKeyCodesTests.cs delete mode 100644 tests/SystemHotkeys.Tests/Hotkeys/VirtualKeysTests.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7af36e1..9eb9c0f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,7 +74,7 @@ jobs: with: version: ${{ needs.prepare.outputs.tag }} source: src/SystemHotkeys - cli-version: 3.0.0-beta.15 + cli-version: 3.0.0-beta.17 changelog: ${{ needs.prepare.outputs.changelog }} # Puts the tests on windows-latest: no Linux entrypoint is declared and the tests never ran on Linux build-per-platform: true diff --git a/AGENTS.md b/AGENTS.md index 10990b7..ca189ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,10 +30,8 @@ src/SystemHotkeys/ KeyboardHook.cs the platform-neutral half: held keys, combo detection, which key events a suppressed combo swallows, the access level, and the per-OS factory Windows/ WindowsKeyboardHook (WH_KEYBOARD_LL plus its message loop) and its P/Invoke - MacOS/ MacKeyboardHook (Quartz event tap on a CFRunLoop thread), its P/Invoke, and - MacKeyCodes (kVK key code and modifier flag to virtual-key code) + MacOS/ MacKeyboardHook (Quartz event tap on a CFRunLoop thread) and its P/Invoke HotkeyCombo.cs held keys into the { modifiers, key } shape the combo editor stores - VirtualKeys.cs virtual-key code to editor key-token mapping BoundHotkeyCombos.cs turns GetBindings() into the set of combos currently opted into suppression HotkeyPressedPayloads.cs builds the payload(s) a single press publishes @@ -72,17 +70,20 @@ Design knowledge that is not obvious from the code alone: than an id nobody recognises. Undocumented and unverified against a real host from here, so it could stop resolving without notice on a future host; check the picker still offers real device names after any host update. -- **`VirtualKeys` is deliberately not exhaustive.** A virtual-key code with no entry in `_names` is simply - never recognised as a combo's trailing key - the safe default for a key nobody has asked to bind yet, - not a bug to "complete". Every numpad key is named `Numpad*` (digits, Add, Subtract, Multiply, - Divide, Decimal), which is what Macro Deck's combo recorder stores for it, so Record and Select agree. -- **Combo matching is case- and order-insensitive.** `BoundHotkeyCombos.NormalizedKey` upper-cases the key - and sorts its modifiers before comparing, so `Shift+Ctrl+F3` and `ctrl+shift+f3` are the same combo. +- **Key names come from the SDK, never from a table of our own.** A backend translates its native code with + `MacroDeck.Sdk.Input.NativeKeys` (`FromWindows(vk, scanCode, isExtended)`, `FromMacOs`) and hands + `KeyboardHook` a `KeyCode`; `KeyNames.ToName` and `ToModifierNames` produce exactly what the combo editor + stores (Macro Deck ADR 0104: letters by layout label, digits and punctuation by position, `Minus`, + `IntlBackslash`, `NumpadEnter`). A key the SDK returns `KeyCode.None` for is ignored. +- **Right-hand modifiers publish the generic name.** The editor only stores `RightCtrl` and friends for the + host's own keyboard actions, never for event triggers, so `HotkeyCombo.Create` folds every right-hand + modifier into `Ctrl`/`Shift`/`Alt`/`Meta` before naming it. +- **Combo matching follows the host's trigger matching.** `BoundHotkeyCombos.NormalizedKey` resolves key and + modifier names through `KeyNames.TryParse`/`TryParseModifier`, because `GetBindings()` returns whatever + spelling the editor stored (`-`, `Esc`, `Cmd`, ...). Unknown names compare case-insensitively as text. - **No config flow.** Every setting lives on the `hotkey-pressed` trigger itself (Device/Profile/Folder scope, the suppress toggle), so the integration starts enabled immediately rather than waiting on setup. -- **Every backend speaks Windows virtual-key codes.** A backend translates its native key codes into VK - codes before calling `KeyboardHook`, so `HotkeyCombo`, `VirtualKeys` and `BoundHotkeyCombos` stay - platform-neutral and a new platform is one key table plus an event source. A backend file is marked +- **A new platform is an event source plus an SDK lookup.** A backend file is marked `[SupportedOSPlatform]` and only `KeyboardHook.Create` picks one, behind an `OperatingSystem.Is*` guard. An OS with no backend gets a hook that only logs a warning; it must never throw, because the integration is constructed during `Build()`. @@ -108,10 +109,9 @@ Design knowledge that is not obvious from the code alone: modifier released while the tap was blind (Secure Event Input) cannot stick. A non-repeat key-down of a key still marked held clears that stale state first, for the same reason. Command is `Meta`, Option is `Alt`. -- **`MacKeyCodes` is positional.** A `kVK_*` code is a key position on a US ANSI keyboard, not the - character the active layout puts there, so on a non-US layout some letters and punctuation do not match - what the combo recorder stores. A layout-aware lookup (`UCKeyTranslate`) would fix that; until then the - README documents it. Caps Lock and Fn are deliberately unmapped. +- **`NativeKeys.FromMacOs` reads the active input source from the event tap thread.** Apple documents the + Text Input Sources API for the main thread and the SDK only tested it from a thread-pool thread, so check + letters on a non-US layout (German `Z`/`Y`) after any change to the tap or an SDK update. - **Linux is not declared yet.** Wayland offers no global key observation, so a Linux backend would read evdev (`/dev/input`, user in the `input` group). Swallowing a key there means grabbing the whole keyboard and re-emitting every other key through uinput, or the XDG GlobalShortcuts portal. diff --git a/Directory.Packages.props b/Directory.Packages.props index 82daa4f..8be5d3a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,12 +7,12 @@ - - - - - - + + + + + + diff --git a/README.md b/README.md index 8b38ca1..f6a76ef 100644 --- a/README.md +++ b/README.md @@ -118,11 +118,9 @@ Differences from Windows: - Command is `Meta` and Option is `Alt` in the combo editor, so record a combo rather than picking its modifiers by hand if in doubt. -- Keys are matched by their position on a US keyboard. On another layout a letter or punctuation key - may need to be picked by the US key in that position (on a German keyboard, `Z` and `Y` are swapped). - While a password field or another secure text field has focus, macOS hides every key from every app that watches the keyboard, so no hotkey fires there. -- Caps Lock and Fn can never be a combo's key. +- Caps Lock, Fn, `F21`-`F24` and the media keys can never be a combo's key. - Only Apple silicon Macs are supported. ## Privacy @@ -183,9 +181,8 @@ src/SystemHotkeys/ KeyboardHook.cs the platform-neutral half: detects a completed combo, decides what a suppressed combo swallows, picks the backend for this OS Windows/ the WH_KEYBOARD_LL hook and its message loop - MacOS/ the Quartz event tap, its run loop, and kVK key code to virtual-key mapping + MacOS/ the Quartz event tap and its run loop HotkeyCombo.cs held keys into the { modifiers, key } shape the combo editor stores - VirtualKeys.cs virtual-key code to editor key-token mapping BoundHotkeyCombos.cs turns GetBindings() into the set of combos currently opted into suppression Localization/Strings.resx the default-culture strings, one file per language Assets/icon.svg the icon the manifest declares diff --git a/src/SystemHotkeys/Hotkeys/BoundHotkeyCombos.cs b/src/SystemHotkeys/Hotkeys/BoundHotkeyCombos.cs index b24e8ae..5fb2842 100644 --- a/src/SystemHotkeys/Hotkeys/BoundHotkeyCombos.cs +++ b/src/SystemHotkeys/Hotkeys/BoundHotkeyCombos.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using MacroDeck.Sdk.Input; using MacroDeck.Sdk.Decks; using MacroDeck.Sdk.Events; using Serilog; @@ -185,15 +186,27 @@ out IReadOnlyList modifiers return true; } + // Same rule as the host's trigger matching: known spellings resolve, unknown ones compare as text. private static (string Key, string Modifiers) NormalizedKey( IEnumerable modifiers, string key - ) => - ( - key.ToUpperInvariant(), - string.Join( - ',', - modifiers.Select(m => m.ToUpperInvariant()).OrderBy(m => m, StringComparer.Ordinal) - ) - ); + ) + { + var known = KeyModifier.None; + var unknown = new SortedSet(StringComparer.Ordinal); + foreach (var modifier in modifiers) + { + if (KeyNames.TryParseModifier(modifier, out var resolved)) + { + known |= resolved; + } + else + { + unknown.Add(modifier.ToUpperInvariant()); + } + } + + var keyName = KeyNames.TryParse(key, out var code) ? code.ToString() : key.ToUpperInvariant(); + return (keyName, $"{known}+{string.Join(',', unknown)}"); + } } diff --git a/src/SystemHotkeys/Hotkeys/HotkeyCombo.cs b/src/SystemHotkeys/Hotkeys/HotkeyCombo.cs index 595490b..4fb7784 100644 --- a/src/SystemHotkeys/Hotkeys/HotkeyCombo.cs +++ b/src/SystemHotkeys/Hotkeys/HotkeyCombo.cs @@ -1,36 +1,31 @@ +using MacroDeck.Sdk.Input; + namespace SystemHotkeys.Hotkeys; public sealed record HotkeyCombo(IReadOnlyList Modifiers, string Key) { - private static readonly (int Left, int Right, string Name)[] _modifiers = + private static readonly (KeyModifier Right, KeyModifier Either)[] _sides = [ - (VirtualKeys.LControl, VirtualKeys.RControl, "Ctrl"), - (VirtualKeys.LShift, VirtualKeys.RShift, "Shift"), - (VirtualKeys.LMenu, VirtualKeys.RMenu, "Alt"), - (VirtualKeys.LWin, VirtualKeys.RWin, "Meta"), + (KeyModifier.RightControl, KeyModifier.Control), + (KeyModifier.RightShift, KeyModifier.Shift), + (KeyModifier.RightAlt, KeyModifier.Alt), + (KeyModifier.RightMeta, KeyModifier.Meta), ]; public string Text => string.Join('+', [.. Modifiers, Key]); - public static HotkeyCombo? TryCreate(IReadOnlySet heldKeys, int vkCode) + // Event trigger fields never store right-hand modifier names, so either side counts as the generic one. + public static HotkeyCombo Create(KeyModifier heldModifiers, KeyCode key) { - if (VirtualKeys.IsModifier(vkCode)) + var generic = heldModifiers; + foreach (var (right, either) in _sides) { - return null; + if (generic.HasFlag(right)) + { + generic = (generic & ~right) | either; + } } - if (VirtualKeys.NameOf(vkCode) is not { } keyName) - { - return null; - } - - var modifiers = _modifiers - .Where(modifier => - heldKeys.Contains(modifier.Left) || heldKeys.Contains(modifier.Right) - ) - .Select(modifier => modifier.Name) - .ToArray(); - - return new HotkeyCombo(modifiers, keyName); + return new HotkeyCombo(KeyNames.ToModifierNames(generic), KeyNames.ToName(key)); } } diff --git a/src/SystemHotkeys/Hotkeys/KeyboardHook.cs b/src/SystemHotkeys/Hotkeys/KeyboardHook.cs index 3aaa245..6f79c7e 100644 --- a/src/SystemHotkeys/Hotkeys/KeyboardHook.cs +++ b/src/SystemHotkeys/Hotkeys/KeyboardHook.cs @@ -1,16 +1,17 @@ using System.Runtime.InteropServices; +using MacroDeck.Sdk.Input; using Serilog; using SystemHotkeys.Hotkeys.MacOS; using SystemHotkeys.Hotkeys.Windows; namespace SystemHotkeys.Hotkeys; -// Platform backends feed this Windows virtual-key codes. internal abstract class KeyboardHook : IDisposable { private readonly Lock _gate = new(); - private readonly HashSet _heldKeys = []; - private readonly HashSet _swallowedKeys = []; + private readonly HashSet _heldKeys = []; + private readonly HashSet _swallowedKeys = []; + private KeyModifier _heldModifiers; private volatile BoundHotkeyCombos _suppressed = BoundHotkeyCombos.None; private volatile KeyboardHookAccess _access = KeyboardHookAccess.Pending; @@ -58,26 +59,27 @@ protected void ReportAccess(KeyboardHookAccess access) } // True when the key must not reach other applications. - protected bool OnKeyDown(int vkCode) + protected bool OnKeyDown(KeyCode key) { - bool isNewPress; - HashSet snapshot; - lock (_gate) + if (KeyNames.TryGetModifier(key, out var modifier)) { - isNewPress = _heldKeys.Add(vkCode); - snapshot = [.. _heldKeys]; + SetModifierHeld(modifier, true); + return false; + } - if (!isNewPress) + KeyModifier modifiers; + lock (_gate) + { + if (!_heldKeys.Add(key)) { - return _swallowedKeys.Contains(vkCode); + return _swallowedKeys.Contains(key); } - } - if (HotkeyCombo.TryCreate(snapshot, vkCode) is not { } combo) - { - return false; + modifiers = _heldModifiers; } + var combo = HotkeyCombo.Create(modifiers, key); + Logger.Debug("Detected hotkey {Combo}.", combo.Text); HotkeyPressed?.Invoke(combo); @@ -93,34 +95,33 @@ protected bool OnKeyDown(int vkCode) lock (_gate) { - _swallowedKeys.Add(vkCode); + _swallowedKeys.Add(key); } return true; } - protected bool OnKeyUp(int vkCode) + protected bool OnKeyUp(KeyCode key) { + if (KeyNames.TryGetModifier(key, out var modifier)) + { + SetModifierHeld(modifier, false); + return false; + } + lock (_gate) { - _heldKeys.Remove(vkCode); - return _swallowedKeys.Remove(vkCode); + _heldKeys.Remove(key); + return _swallowedKeys.Remove(key); } } // For a platform that reports modifier state rather than modifier key presses. - protected void SetModifierHeld(int vkCode, bool held) + protected void SetModifierHeld(KeyModifier modifier, bool held) { lock (_gate) { - if (held) - { - _heldKeys.Add(vkCode); - } - else - { - _heldKeys.Remove(vkCode); - } + _heldModifiers = held ? _heldModifiers | modifier : _heldModifiers & ~modifier; } } diff --git a/src/SystemHotkeys/Hotkeys/MacOS/MacKeyCodes.cs b/src/SystemHotkeys/Hotkeys/MacOS/MacKeyCodes.cs deleted file mode 100644 index 33d9ad8..0000000 --- a/src/SystemHotkeys/Hotkeys/MacOS/MacKeyCodes.cs +++ /dev/null @@ -1,133 +0,0 @@ -namespace SystemHotkeys.Hotkeys.MacOS; - -// kVK codes are key positions on a US keyboard, not what the active layout types. -public static class MacKeyCodes -{ - private static readonly Dictionary _names = new() - { - [0x00] = "A", - [0x01] = "S", - [0x02] = "D", - [0x03] = "F", - [0x04] = "H", - [0x05] = "G", - [0x06] = "Z", - [0x07] = "X", - [0x08] = "C", - [0x09] = "V", - [0x0B] = "B", - [0x0C] = "Q", - [0x0D] = "W", - [0x0E] = "E", - [0x0F] = "R", - [0x10] = "Y", - [0x11] = "T", - [0x1F] = "O", - [0x20] = "U", - [0x22] = "I", - [0x23] = "P", - [0x25] = "L", - [0x26] = "J", - [0x28] = "K", - [0x2D] = "N", - [0x2E] = "M", - - [0x1D] = "0", - [0x12] = "1", - [0x13] = "2", - [0x14] = "3", - [0x15] = "4", - [0x17] = "5", - [0x16] = "6", - [0x1A] = "7", - [0x1C] = "8", - [0x19] = "9", - - [0x7A] = "F1", - [0x78] = "F2", - [0x63] = "F3", - [0x76] = "F4", - [0x60] = "F5", - [0x61] = "F6", - [0x62] = "F7", - [0x64] = "F8", - [0x65] = "F9", - [0x6D] = "F10", - [0x67] = "F11", - [0x6F] = "F12", - [0x69] = "F13", - [0x6B] = "F14", - [0x71] = "F15", - [0x6A] = "F16", - [0x40] = "F17", - [0x4F] = "F18", - [0x50] = "F19", - [0x5A] = "F20", - - [0x24] = "Enter", - [0x4C] = "Enter", - [0x30] = "Tab", - [0x31] = "Space", - [0x33] = "Backspace", - [0x35] = "Escape", - [0x72] = "Insert", - [0x75] = "Delete", - [0x73] = "Home", - [0x77] = "End", - [0x74] = "PageUp", - [0x79] = "PageDown", - [0x7B] = "ArrowLeft", - [0x7E] = "ArrowUp", - [0x7C] = "ArrowRight", - [0x7D] = "ArrowDown", - [0x6E] = "ContextMenu", - - [0x52] = "Numpad0", - [0x53] = "Numpad1", - [0x54] = "Numpad2", - [0x55] = "Numpad3", - [0x56] = "Numpad4", - [0x57] = "Numpad5", - [0x58] = "Numpad6", - [0x59] = "Numpad7", - [0x5B] = "Numpad8", - [0x5C] = "Numpad9", - [0x43] = "NumpadMultiply", - [0x45] = "NumpadAdd", - [0x4E] = "NumpadSubtract", - [0x41] = "NumpadDecimal", - [0x4B] = "NumpadDivide", - - [0x4A] = "AudioVolumeMute", - [0x49] = "AudioVolumeDown", - [0x48] = "AudioVolumeUp", - - [0x29] = ";", - [0x18] = "=", - [0x2B] = ",", - [0x1B] = "-", - [0x2F] = ".", - [0x2C] = "/", - [0x32] = "`", - [0x21] = "[", - [0x2A] = "\\", - [0x1E] = "]", - [0x27] = "'", - }; - - // The device-dependent CGEventFlags bits, the only ones that tell left from right. - public static IReadOnlyList<(int VirtualKey, ulong FlagMask)> ModifierFlags { get; } = - [ - (VirtualKeys.LControl, 0x00000001), - (VirtualKeys.LShift, 0x00000002), - (VirtualKeys.RShift, 0x00000004), - (VirtualKeys.LWin, 0x00000008), - (VirtualKeys.RWin, 0x00000010), - (VirtualKeys.LMenu, 0x00000020), - (VirtualKeys.RMenu, 0x00000040), - (VirtualKeys.RControl, 0x00002000), - ]; - - public static int? ToVirtualKey(int keyCode) => - _names.TryGetValue(keyCode, out var name) ? VirtualKeys.CodeOf(name) : null; -} diff --git a/src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs b/src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs index 7024836..eadbdeb 100644 --- a/src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs +++ b/src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs @@ -1,4 +1,5 @@ using System.Runtime.Versioning; +using MacroDeck.Sdk.Input; using Serilog; namespace SystemHotkeys.Hotkeys.MacOS; @@ -16,6 +17,19 @@ internal sealed class MacKeyboardHook(ILogger logger) : KeyboardHook(logger) private const double RunLoopSliceSeconds = 0.5; private static readonly TimeSpan _retryInterval = TimeSpan.FromSeconds(5); + // The device-dependent CGEventFlags bits, the only ones that tell left from right. + private static readonly (KeyModifier Modifier, ulong FlagMask)[] _modifierFlags = + [ + (KeyModifier.Control, 0x00000001), + (KeyModifier.Shift, 0x00000002), + (KeyModifier.RightShift, 0x00000004), + (KeyModifier.Meta, 0x00000008), + (KeyModifier.RightMeta, 0x00000010), + (KeyModifier.Alt, 0x00000020), + (KeyModifier.RightAlt, 0x00000040), + (KeyModifier.RightControl, 0x00002000), + ]; + // Reused across Start/Dispose cycles. private readonly ManualResetEventSlim _stopping = new(); @@ -232,31 +246,32 @@ private bool HandleKey(uint type, nint cgEvent) { var keyCode = (int) NativeMethods.CGEventGetIntegerValueField(cgEvent, NativeMethods.kCGKeyboardEventKeycode); - if (MacKeyCodes.ToVirtualKey(keyCode) is not { } vkCode) + var key = NativeKeys.FromMacOs(keyCode); + if (key == KeyCode.None) { return false; } if (type == NativeMethods.kCGEventKeyUp) { - return OnKeyUp(vkCode); + return OnKeyUp(key); } // Secure Event Input hides key-ups, so a fresh press may find the key still held. if (NativeMethods.CGEventGetIntegerValueField(cgEvent, NativeMethods.kCGKeyboardEventAutorepeat) == 0) { - OnKeyUp(vkCode); + OnKeyUp(key); } - return OnKeyDown(vkCode); + return OnKeyDown(key); } private void SyncModifiers(nint cgEvent) { var flags = NativeMethods.CGEventGetFlags(cgEvent); - foreach (var (vkCode, flagMask) in MacKeyCodes.ModifierFlags) + foreach (var (modifier, flagMask) in _modifierFlags) { - SetModifierHeld(vkCode, (flags & flagMask) != 0); + SetModifierHeld(modifier, (flags & flagMask) != 0); } } } diff --git a/src/SystemHotkeys/Hotkeys/VirtualKeys.cs b/src/SystemHotkeys/Hotkeys/VirtualKeys.cs deleted file mode 100644 index ccde55f..0000000 --- a/src/SystemHotkeys/Hotkeys/VirtualKeys.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System.Globalization; - -namespace SystemHotkeys.Hotkeys; - -/// -/// Virtual-key code to combo-editor key-token mapping. Not exhaustive: an unmapped code is simply never -/// recognized as a combo's trailing key. -/// -public static class VirtualKeys -{ - public const int LShift = 0xA0; - public const int RShift = 0xA1; - public const int LControl = 0xA2; - public const int RControl = 0xA3; - public const int LMenu = 0xA4; - public const int RMenu = 0xA5; - public const int LWin = 0x5B; - public const int RWin = 0x5C; - - // Generic (non-side-specific) modifier codes some callers still report. - private const int Shift = 0x10; - private const int Control = 0x11; - private const int Menu = 0x12; - - private static readonly HashSet _modifierCodes = - [ - LShift, - RShift, - LControl, - RControl, - LMenu, - RMenu, - LWin, - RWin, - Shift, - Control, - Menu, - ]; - - private static readonly IReadOnlyDictionary _names = BuildNames(); - private static readonly Dictionary _codes = _names.ToDictionary( - entry => entry.Value, - entry => entry.Key - ); - - public static bool IsModifier(int vkCode) => _modifierCodes.Contains(vkCode); - - public static string? NameOf(int vkCode) => _names.GetValueOrDefault(vkCode); - - public static int? CodeOf(string name) => _codes.TryGetValue(name, out var vkCode) ? vkCode : null; - - private static Dictionary BuildNames() - { - var names = new Dictionary(); - - for (var letter = 0; letter < 26; letter++) - { - names[0x41 + letter] = ((char)('A' + letter)).ToString(); - } - - for (var digit = 0; digit < 10; digit++) - { - names[0x30 + digit] = digit.ToString(CultureInfo.InvariantCulture); - } - - for (var f = 0; f < 24; f++) - { - names[0x70 + f] = $"F{f + 1}"; - } - - names[0x08] = "Backspace"; - names[0x09] = "Tab"; - names[0x0D] = "Enter"; - names[0x13] = "Pause"; - names[0x14] = "CapsLock"; - names[0x1B] = "Escape"; - names[0x20] = "Space"; - names[0x21] = "PageUp"; - names[0x22] = "PageDown"; - names[0x23] = "End"; - names[0x24] = "Home"; - names[0x25] = "ArrowLeft"; - names[0x26] = "ArrowUp"; - names[0x27] = "ArrowRight"; - names[0x28] = "ArrowDown"; - names[0x2C] = "PrintScreen"; - names[0x2D] = "Insert"; - names[0x2E] = "Delete"; - names[0x5D] = "ContextMenu"; - - for (var digit = 0; digit < 10; digit++) - { - names[0x60 + digit] = $"Numpad{digit}"; - } - - names[0x6A] = "NumpadMultiply"; - names[0x6B] = "NumpadAdd"; - names[0x6D] = "NumpadSubtract"; - names[0x6E] = "NumpadDecimal"; - names[0x6F] = "NumpadDivide"; - - names[0x91] = "ScrollLock"; - - names[0xA6] = "BrowserBack"; - names[0xA7] = "BrowserForward"; - names[0xA8] = "BrowserRefresh"; - names[0xA9] = "BrowserStop"; - names[0xAA] = "BrowserSearch"; - names[0xAB] = "BrowserFavorites"; - names[0xAC] = "BrowserHome"; - names[0xAD] = "AudioVolumeMute"; - names[0xAE] = "AudioVolumeDown"; - names[0xAF] = "AudioVolumeUp"; - names[0xB0] = "MediaTrackNext"; - names[0xB1] = "MediaTrackPrevious"; - names[0xB2] = "MediaStop"; - names[0xB3] = "MediaPlayPause"; - names[0xB4] = "LaunchMail"; - names[0xB5] = "LaunchMediaPlayer"; - names[0xB6] = "LaunchApplication1"; - names[0xB7] = "LaunchApplication2"; - - names[0xBA] = ";"; - names[0xBB] = "="; - names[0xBC] = ","; - names[0xBD] = "-"; - names[0xBE] = "."; - names[0xBF] = "/"; - names[0xC0] = "`"; - names[0xDB] = "["; - names[0xDC] = "\\"; - names[0xDD] = "]"; - names[0xDE] = "'"; - - // layout-dependent ISO key next to left Shift on most non-US keyboards - names[0xE2] = "<"; - - return names; - } -} diff --git a/src/SystemHotkeys/Hotkeys/Windows/NativeMethods.cs b/src/SystemHotkeys/Hotkeys/Windows/NativeMethods.cs index 6d76efc..32af051 100644 --- a/src/SystemHotkeys/Hotkeys/Windows/NativeMethods.cs +++ b/src/SystemHotkeys/Hotkeys/Windows/NativeMethods.cs @@ -13,6 +13,7 @@ internal static partial class NativeMethods internal const int WM_SYSKEYDOWN = 0x0104; internal const int WM_SYSKEYUP = 0x0105; internal const int WM_QUIT = 0x0012; + internal const uint LLKHF_EXTENDED = 0x01; internal delegate nint LowLevelKeyboardProc(int nCode, nint wParam, nint lParam); diff --git a/src/SystemHotkeys/Hotkeys/Windows/WindowsKeyboardHook.cs b/src/SystemHotkeys/Hotkeys/Windows/WindowsKeyboardHook.cs index f996b63..c5a0f4d 100644 --- a/src/SystemHotkeys/Hotkeys/Windows/WindowsKeyboardHook.cs +++ b/src/SystemHotkeys/Hotkeys/Windows/WindowsKeyboardHook.cs @@ -1,5 +1,6 @@ using System.Runtime.InteropServices; using System.Runtime.Versioning; +using MacroDeck.Sdk.Input; using Serilog; namespace SystemHotkeys.Hotkeys.Windows; @@ -81,15 +82,12 @@ private void RunMessageLoop() private nint HookCallback(int nCode, nint wParam, nint lParam) { - if (nCode >= 0) + if (nCode >= 0 && KeyOf(lParam) is var key and not KeyCode.None) { - var data = Marshal.PtrToStructure(lParam); - var vkCode = (int)data.VkCode; - var swallow = wParam switch { - NativeMethods.WM_KEYDOWN or NativeMethods.WM_SYSKEYDOWN => OnKeyDown(vkCode), - NativeMethods.WM_KEYUP or NativeMethods.WM_SYSKEYUP => OnKeyUp(vkCode), + NativeMethods.WM_KEYDOWN or NativeMethods.WM_SYSKEYDOWN => OnKeyDown(key), + NativeMethods.WM_KEYUP or NativeMethods.WM_SYSKEYUP => OnKeyUp(key), _ => false, }; if (swallow) @@ -100,4 +98,14 @@ private nint HookCallback(int nCode, nint wParam, nint lParam) return NativeMethods.CallNextHookEx(0, nCode, wParam, lParam); } + + private static KeyCode KeyOf(nint lParam) + { + var data = Marshal.PtrToStructure(lParam); + return NativeKeys.FromWindows( + (int)data.VkCode, + (int)data.ScanCode, + (data.Flags & NativeMethods.LLKHF_EXTENDED) != 0 + ); + } } diff --git a/tests/SystemHotkeys.Tests/Hotkeys/BoundHotkeyCombosTests.cs b/tests/SystemHotkeys.Tests/Hotkeys/BoundHotkeyCombosTests.cs index f8930bb..f6f26ae 100644 --- a/tests/SystemHotkeys.Tests/Hotkeys/BoundHotkeyCombosTests.cs +++ b/tests/SystemHotkeys.Tests/Hotkeys/BoundHotkeyCombosTests.cs @@ -242,6 +242,24 @@ public void Matching_is_case_and_modifier_order_insensitive() Assert.That(suppressed.Contains(new HotkeyCombo(["Ctrl", "Shift"], "F3")), Is.True); } + [TestCase("-", "Minus")] + [TestCase("Esc", "Escape")] + [TestCase("D5", "5")] + public void An_older_key_spelling_matches_the_published_name(string stored, string published) + { + var suppressed = FromBindings(Binding(EventId, Combo(["Ctrl"], stored), Bool(true))); + + Assert.That(suppressed.Contains(new HotkeyCombo(["Ctrl"], published)), Is.True); + } + + [Test] + public void An_older_modifier_spelling_matches_the_published_name() + { + var suppressed = FromBindings(Binding(EventId, Combo(["Cmd", "Option"], "K"), Bool(true))); + + Assert.That(suppressed.Contains(new HotkeyCombo(["Alt", "Meta"], "K")), Is.True); + } + [Test] public void A_combo_with_extra_or_missing_modifiers_is_not_contained() { diff --git a/tests/SystemHotkeys.Tests/Hotkeys/HotkeyComboTests.cs b/tests/SystemHotkeys.Tests/Hotkeys/HotkeyComboTests.cs index 574938a..e2e3d0a 100644 --- a/tests/SystemHotkeys.Tests/Hotkeys/HotkeyComboTests.cs +++ b/tests/SystemHotkeys.Tests/Hotkeys/HotkeyComboTests.cs @@ -1,5 +1,6 @@ -using SystemHotkeys.Hotkeys; +using MacroDeck.Sdk.Input; using NUnit.Framework; +using SystemHotkeys.Hotkeys; namespace SystemHotkeys.Tests.Hotkeys; @@ -7,123 +8,63 @@ namespace SystemHotkeys.Tests.Hotkeys; public sealed class HotkeyComboTests { private static readonly string[] _ctrl = ["Ctrl"]; - private static readonly string[] _ctrlShift = ["Ctrl", "Shift"]; private static readonly string[] _allFourModifiersInOrder = ["Ctrl", "Shift", "Alt", "Meta"]; [Test] - public void A_modifier_and_key_produce_the_expected_parts() - { - var combo = HotkeyCombo.TryCreate( - Held(VirtualKeys.LControl), - 0x70 /* F1 */ - ); - - Assert.That(combo?.Modifiers, Is.EqualTo(_ctrl)); - Assert.That(combo?.Key, Is.EqualTo("F1")); - } - - [Test] - public void Modifiers_always_appear_in_the_same_order_regardless_of_press_order() + public void A_modifier_and_key_produce_the_editors_names() { - var shiftThenCtrl = HotkeyCombo.TryCreate( - Held(VirtualKeys.LShift, VirtualKeys.LControl), - 0x41 /* A */ - ); - var ctrlThenShift = HotkeyCombo.TryCreate( - Held(VirtualKeys.LControl, VirtualKeys.LShift), - 0x41 /* A */ - ); + var combo = HotkeyCombo.Create(KeyModifier.Control, KeyCode.F1); - Assert.That(shiftThenCtrl?.Modifiers, Is.EqualTo(_ctrlShift)); - Assert.That(ctrlThenShift?.Modifiers, Is.EqualTo(shiftThenCtrl?.Modifiers)); + Assert.That(combo.Modifiers, Is.EqualTo(_ctrl)); + Assert.That(combo.Key, Is.EqualTo("F1")); } [Test] public void All_four_modifiers_keep_the_editors_record_order() { - var combo = HotkeyCombo.TryCreate( - Held(VirtualKeys.LWin, VirtualKeys.LMenu, VirtualKeys.LShift, VirtualKeys.LControl), - 0x41 + var combo = HotkeyCombo.Create( + KeyModifier.Meta | KeyModifier.Alt | KeyModifier.Shift | KeyModifier.Control, + KeyCode.A ); - Assert.That(combo?.Modifiers, Is.EqualTo(_allFourModifiersInOrder)); + Assert.That(combo.Modifiers, Is.EqualTo(_allFourModifiersInOrder)); } [Test] - public void The_left_and_right_variant_of_a_modifier_produce_the_same_name() + public void A_right_hand_modifier_is_named_like_the_left_one() { - var left = HotkeyCombo.TryCreate(Held(VirtualKeys.LControl), 0x41); - var right = HotkeyCombo.TryCreate(Held(VirtualKeys.RControl), 0x41); + var right = HotkeyCombo.Create(KeyModifier.RightControl | KeyModifier.RightMeta, KeyCode.A); + var left = HotkeyCombo.Create(KeyModifier.Control | KeyModifier.Meta, KeyCode.A); - Assert.That(left?.Modifiers, Is.EqualTo(right?.Modifiers)); + Assert.That(right.Text, Is.EqualTo("Ctrl+Meta+A")); + Assert.That(right.Text, Is.EqualTo(left.Text)); } [Test] public void A_key_with_no_modifier_held_still_forms_a_combo() { - var combo = HotkeyCombo.TryCreate( - Held(), - 0x41 /* A */ - ); + var combo = HotkeyCombo.Create(KeyModifier.None, KeyCode.A); - Assert.That(combo?.Modifiers, Is.Empty); - Assert.That(combo?.Key, Is.EqualTo("A")); + Assert.That(combo.Modifiers, Is.Empty); + Assert.That(combo.Text, Is.EqualTo("A")); } - [Test] - public void A_modifier_pressed_as_the_trailing_key_is_not_a_combo() + [TestCase(KeyCode.D3, "3")] + [TestCase(KeyCode.Minus, "Minus")] + [TestCase(KeyCode.IntlBackslash, "IntlBackslash")] + [TestCase(KeyCode.NumpadSubtract, "NumpadSubtract")] + [TestCase(KeyCode.NumpadEnter, "NumpadEnter")] + public void Keys_use_the_name_the_editor_stores(KeyCode key, string expected) { - var combo = HotkeyCombo.TryCreate( - Held(VirtualKeys.LControl, VirtualKeys.LShift), - VirtualKeys.LShift - ); - - Assert.That(combo, Is.Null); + Assert.That(HotkeyCombo.Create(KeyModifier.None, key).Key, Is.EqualTo(expected)); } [Test] - public void An_unmapped_trailing_key_is_not_a_combo() + public void A_windows_punctuation_key_is_named_by_its_scan_code_position() { - var combo = HotkeyCombo.TryCreate( - Held(VirtualKeys.LControl), - 0x07 /* unmapped */ - ); + // VK_OEM_4 with the scan code of the key right of 0, the German ß key. + var key = NativeKeys.FromWindows(0xDB, 0x0C, isExtended: false); - Assert.That(combo, Is.Null); + Assert.That(HotkeyCombo.Create(KeyModifier.Control, key).Text, Is.EqualTo("Ctrl+Minus")); } - - [Test] - public void A_numpad_key_uses_its_own_token_distinct_from_the_identically_printed_main_row_key() - { - // Numpad and main-row minus print the same glyph but the editor names them differently. - Assert.That( - HotkeyCombo - .TryCreate( - Held(VirtualKeys.LControl), - 0x6D /* numpad - */ - ) - ?.Key, - Is.EqualTo("NumpadSubtract") - ); - Assert.That( - HotkeyCombo - .TryCreate( - Held(VirtualKeys.LControl), - 0xBD /* main-row - */ - ) - ?.Key, - Is.EqualTo("-") - ); - Assert.That( - HotkeyCombo - .TryCreate( - Held(VirtualKeys.LControl), - 0x6B /* numpad + */ - ) - ?.Key, - Is.EqualTo("NumpadAdd") - ); - } - - private static HashSet Held(params int[] vkCodes) => [.. vkCodes]; } diff --git a/tests/SystemHotkeys.Tests/Hotkeys/MacKeyCodesTests.cs b/tests/SystemHotkeys.Tests/Hotkeys/MacKeyCodesTests.cs deleted file mode 100644 index 091280e..0000000 --- a/tests/SystemHotkeys.Tests/Hotkeys/MacKeyCodesTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -using NUnit.Framework; -using SystemHotkeys.Hotkeys; -using SystemHotkeys.Hotkeys.MacOS; - -namespace SystemHotkeys.Tests.Hotkeys; - -[TestFixture] -public sealed class MacKeyCodesTests -{ - [TestCase(0x00, "A")] - [TestCase(0x06, "Z")] - [TestCase(0x10, "Y")] - [TestCase(0x2E, "M")] - [TestCase(0x1D, "0")] - [TestCase(0x12, "1")] - [TestCase(0x16, "6")] - [TestCase(0x17, "5")] - [TestCase(0x7A, "F1")] - [TestCase(0x63, "F3")] - [TestCase(0x6F, "F12")] - [TestCase(0x5A, "F20")] - [TestCase(0x24, "Enter")] - [TestCase(0x4C, "Enter")] - [TestCase(0x33, "Backspace")] - [TestCase(0x75, "Delete")] - [TestCase(0x35, "Escape")] - [TestCase(0x31, "Space")] - [TestCase(0x7E, "ArrowUp")] - [TestCase(0x7B, "ArrowLeft")] - [TestCase(0x52, "Numpad0")] - [TestCase(0x5C, "Numpad9")] - [TestCase(0x45, "NumpadAdd")] - [TestCase(0x4E, "NumpadSubtract")] - [TestCase(0x43, "NumpadMultiply")] - [TestCase(0x4B, "NumpadDivide")] - [TestCase(0x41, "NumpadDecimal")] - [TestCase(0x1B, "-")] - [TestCase(0x2A, "\\")] - [TestCase(0x32, "`")] - [TestCase(0x48, "AudioVolumeUp")] - [TestCase(0x4A, "AudioVolumeMute")] - [TestCase(0x72, "Insert")] - [TestCase(0x79, "PageDown")] - [TestCase(0x6E, "ContextMenu")] - [TestCase(0x27, "'")] - [TestCase(0x21, "[")] - public void Mac_key_codes_name_the_same_key_as_on_windows(int keyCode, string expected) - { - var vkCode = MacKeyCodes.ToVirtualKey(keyCode); - - Assert.That(vkCode, Is.Not.Null); - Assert.That(VirtualKeys.NameOf(vkCode!.Value), Is.EqualTo(expected)); - } - - [TestCase(0x37)] - [TestCase(0x38)] - [TestCase(0x3A)] - [TestCase(0x3B)] - [TestCase(0x39)] - [TestCase(0x3F)] - public void Modifier_caps_lock_and_fn_key_codes_are_never_a_trailing_key(int keyCode) - { - Assert.That(MacKeyCodes.ToVirtualKey(keyCode), Is.Null); - } - - [Test] - public void Every_modifier_flag_names_a_distinct_modifier_key() - { - Assert.Multiple(() => - { - var virtualKeys = MacKeyCodes.ModifierFlags.Select(m => m.VirtualKey).ToList(); - - Assert.That(virtualKeys, Is.All.Matches(VirtualKeys.IsModifier)); - Assert.That(virtualKeys, Is.Unique); - Assert.That(MacKeyCodes.ModifierFlags.Select(m => m.FlagMask), Is.Unique); - }); - } - - [Test] - public void Command_and_option_read_back_as_meta_and_alt() - { - var command = MacKeyCodes.ModifierFlags.Single(m => m.FlagMask == 0x08).VirtualKey; - var option = MacKeyCodes.ModifierFlags.Single(m => m.FlagMask == 0x20).VirtualKey; - - var combo = HotkeyCombo.TryCreate(new HashSet { command, option, 'K' }, 'K'); - - Assert.That(combo?.Text, Is.EqualTo("Alt+Meta+K")); - } -} diff --git a/tests/SystemHotkeys.Tests/Hotkeys/VirtualKeysTests.cs b/tests/SystemHotkeys.Tests/Hotkeys/VirtualKeysTests.cs deleted file mode 100644 index 029defc..0000000 --- a/tests/SystemHotkeys.Tests/Hotkeys/VirtualKeysTests.cs +++ /dev/null @@ -1,61 +0,0 @@ -using SystemHotkeys.Hotkeys; -using NUnit.Framework; - -namespace SystemHotkeys.Tests.Hotkeys; - -[TestFixture] -public sealed class VirtualKeysTests -{ - [TestCase(0x41, "A")] - [TestCase(0x5A, "Z")] - [TestCase(0x30, "0")] - [TestCase(0x39, "9")] - [TestCase(0x70, "F1")] - [TestCase(0x87, "F24")] - [TestCase(0x20, "Space")] - [TestCase(0x1B, "Escape")] - [TestCase(0x26, "ArrowUp")] - [TestCase(0x60, "Numpad0")] - [TestCase(0x69, "Numpad9")] - [TestCase(0x6A, "NumpadMultiply")] - [TestCase(0x6B, "NumpadAdd")] - [TestCase(0x6D, "NumpadSubtract")] - [TestCase(0x6E, "NumpadDecimal")] - [TestCase(0x6F, "NumpadDivide")] - [TestCase(0xBD, "-")] - [TestCase(0xB3, "MediaPlayPause")] - [TestCase(0x14, "CapsLock")] - [TestCase(0x5D, "ContextMenu")] - [TestCase(0xA6, "BrowserBack")] - [TestCase(0xB6, "LaunchApplication1")] - [TestCase(0xE2, "<")] - public void Known_virtual_keys_map_to_the_editors_key_token(int vkCode, string expected) - { - Assert.That(VirtualKeys.NameOf(vkCode), Is.EqualTo(expected)); - } - - [Test] - public void An_unmapped_virtual_key_has_no_name() - { - Assert.That(VirtualKeys.NameOf(0x07), Is.Null); - } - - [TestCase(0xA0)] // left shift - [TestCase(0xA1)] // right shift - [TestCase(0xA2)] // left control - [TestCase(0xA3)] // right control - [TestCase(0xA4)] // left alt - [TestCase(0xA5)] // right alt - [TestCase(0x5B)] // left win - [TestCase(0x5C)] // right win - public void Every_modifier_key_reports_itself_as_a_modifier(int vkCode) - { - Assert.That(VirtualKeys.IsModifier(vkCode), Is.True); - } - - [Test] - public void An_ordinary_key_does_not_report_itself_as_a_modifier() - { - Assert.That(VirtualKeys.IsModifier(0x41), Is.False); - } -}