diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fa859e..1609546 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,9 +8,12 @@ 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 + # The integration tests start the real keyboard hook, so every platform runs them. + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 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 07d20ce..ca189ce 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,17 +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) 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 @@ -45,9 +47,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`. @@ -68,20 +70,57 @@ 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. -- **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": +- **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()`. +- **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, 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`. +- **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`. +- **`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. +- **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 +427,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/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 d86ba48..f6a76ef 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,30 @@ 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 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: + +- 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. +- 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, Fn, `F21`-`F24` and the media keys 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 +142,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 +161,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,16 +172,17 @@ 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 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 @@ -307,7 +334,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 +356,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 +373,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..4d4fe7c --- /dev/null +++ b/src/SystemHotkeys/HookAccessNotifications.cs @@ -0,0 +1,85 @@ +using MacroDeck.Localization; +using MacroDeck.Sdk.Notifications; +using SystemHotkeys.Hotkeys; + +namespace SystemHotkeys; + +internal sealed class HookAccessNotifications +{ + private const string InputMonitoringKey = "input-monitoring-required"; + private const string AccessibilityKey = "accessibility-required"; + + // UserNotificationRequest takes plain strings. + 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/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/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/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 new file mode 100644 index 0000000..6f79c7e --- /dev/null +++ b/src/SystemHotkeys/Hotkeys/KeyboardHook.cs @@ -0,0 +1,147 @@ +using System.Runtime.InteropServices; +using MacroDeck.Sdk.Input; +using Serilog; +using SystemHotkeys.Hotkeys.MacOS; +using SystemHotkeys.Hotkeys.Windows; + +namespace SystemHotkeys.Hotkeys; + +internal abstract class KeyboardHook : IDisposable +{ + private readonly Lock _gate = new(); + private readonly HashSet _heldKeys = []; + private readonly HashSet _swallowedKeys = []; + private KeyModifier _heldModifiers; + + 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); + } + + // True when the key must not reach other applications. + protected bool OnKeyDown(KeyCode key) + { + if (KeyNames.TryGetModifier(key, out var modifier)) + { + SetModifierHeld(modifier, true); + return false; + } + + KeyModifier modifiers; + lock (_gate) + { + if (!_heldKeys.Add(key)) + { + return _swallowedKeys.Contains(key); + } + + modifiers = _heldModifiers; + } + + var combo = HotkeyCombo.Create(modifiers, key); + + 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(key); + } + + return true; + } + + protected bool OnKeyUp(KeyCode key) + { + if (KeyNames.TryGetModifier(key, out var modifier)) + { + SetModifierHeld(modifier, false); + return false; + } + + lock (_gate) + { + _heldKeys.Remove(key); + return _swallowedKeys.Remove(key); + } + } + + // For a platform that reports modifier state rather than modifier key presses. + protected void SetModifierHeld(KeyModifier modifier, bool held) + { + lock (_gate) + { + _heldModifiers = held ? _heldModifiers | modifier : _heldModifiers & ~modifier; + } + } + + 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 +{ + // Also a hook that failed for a reason no permission fixes. + Pending, + Denied, + ObserveOnly, + Full, +} diff --git a/src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs b/src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs new file mode 100644 index 0000000..eadbdeb --- /dev/null +++ b/src/SystemHotkeys/Hotkeys/MacOS/MacKeyboardHook.cs @@ -0,0 +1,277 @@ +using System.Runtime.Versioning; +using MacroDeck.Sdk.Input; +using Serilog; + +namespace SystemHotkeys.Hotkeys.MacOS; + +// An active tap needs Accessibility, a listen-only one Input Monitoring. +[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); + + // 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); + + // 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(); + + private NativeMethods.CGEventTapCallBack? _callback; + + private volatile Thread? _thread; + private volatile nint _runLoop; + private nint _tap; + private nint _source; + private bool _tapIsActive; + 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; + } + + RunUntilStoppedOrAccessibilityChanges(); + RemoveTap(); + } + } + finally + { + RemoveTap(); + _runLoop = 0; + } + } + + private KeyboardHookAccess InstallTap() + { + _tap = CreateTap(NativeMethods.kCGEventTapOptionDefault); + _tapIsActive = _tap != 0; + + if (_tap == 0) + { + _tap = CreateTap(NativeMethods.kCGEventTapOptionListenOnly); + } + + if (_tap == 0) + { + if (!_listenAccessRequested) + { + // Lists Macro Deck under Input Monitoring. + _listenAccessRequested = true; + NativeMethods.CGRequestListenEventAccess(); + } + + return KeyboardHookAccess.Denied; + } + + _source = NativeMethods.CFMachPortCreateRunLoopSource(0, _tap, 0); + NativeMethods.CFRunLoopAddSource(_runLoop, _source, NativeMethods.DefaultRunLoopMode); + NativeMethods.CGEventTapEnable(_tap, true); + return _tapIsActive ? KeyboardHookAccess.Full : KeyboardHookAccess.ObserveOnly; + } + + private nint CreateTap(uint options, ulong eventsOfInterest = KeyboardEvents) => + NativeMethods.CGEventTapCreate( + NativeMethods.kCGSessionEventTap, + NativeMethods.kCGHeadInsertEventTap, + options, + eventsOfInterest, + _callback!, + 0 + ); + + // AXIsProcessTrusted stays false in a running process after Accessibility is granted. + 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; + } + + // CFRunLoopStop is lost when it arrives before the loop runs. + private void RunUntilStoppedOrAccessibilityChanges() + { + while (!_stopping.IsSet) + { + NativeMethods.CFRunLoopRunInMode(NativeMethods.DefaultRunLoopMode, RunLoopSliceSeconds, false); + + if (_tapIsActive != CanCreateActiveTap()) + { + 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: + if (_tapIsActive && !CanCreateActiveTap()) + { + // Accessibility was revoked: swap to listen-only. + NativeMethods.CFRunLoopStop(_runLoop); + return cgEvent; + } + + 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; + } + } + + private bool HandleKey(uint type, nint cgEvent) + { + var keyCode = (int) + NativeMethods.CGEventGetIntegerValueField(cgEvent, NativeMethods.kCGKeyboardEventKeycode); + var key = NativeKeys.FromMacOs(keyCode); + if (key == KeyCode.None) + { + return false; + } + + if (type == NativeMethods.kCGEventKeyUp) + { + 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(key); + } + + return OnKeyDown(key); + } + + private void SyncModifiers(nint cgEvent) + { + var flags = NativeMethods.CGEventGetFlags(cgEvent); + foreach (var (modifier, flagMask) in _modifierFlags) + { + SetModifierHeld(modifier, (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..e580de1 --- /dev/null +++ b/src/SystemHotkeys/Hotkeys/MacOS/NativeMethods.cs @@ -0,0 +1,84 @@ +using System.Runtime.InteropServices; + +namespace SystemHotkeys.Hotkeys.MacOS; + +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"; + + 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; + internal const uint kCGEventTapDisabledByTimeout = 0xFFFFFFFE; + internal const uint kCGEventTapDisabledByUserInput = 0xFFFFFFFF; + + 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); + + 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(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/VirtualKeys.cs b/src/SystemHotkeys/Hotkeys/VirtualKeys.cs deleted file mode 100644 index 0ed034e..0000000 --- a/src/SystemHotkeys/Hotkeys/VirtualKeys.cs +++ /dev/null @@ -1,134 +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(); - - public static bool IsModifier(int vkCode) => _modifierCodes.Contains(vkCode); - - public static string? NameOf(int vkCode) => _names.GetValueOrDefault(vkCode); - - 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/NativeMethods.cs b/src/SystemHotkeys/Hotkeys/Windows/NativeMethods.cs similarity index 96% rename from src/SystemHotkeys/Hotkeys/NativeMethods.cs rename to src/SystemHotkeys/Hotkeys/Windows/NativeMethods.cs index 7aba113..32af051 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. @@ -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 new file mode 100644 index 0000000..c5a0f4d --- /dev/null +++ b/src/SystemHotkeys/Hotkeys/Windows/WindowsKeyboardHook.cs @@ -0,0 +1,111 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using MacroDeck.Sdk.Input; +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 && KeyOf(lParam) is var key and not KeyCode.None) + { + var swallow = wParam switch + { + NativeMethods.WM_KEYDOWN or NativeMethods.WM_SYSKEYDOWN => OnKeyDown(key), + NativeMethods.WM_KEYUP or NativeMethods.WM_SYSKEYUP => OnKeyUp(key), + _ => false, + }; + if (swallow) + { + return 1; + } + } + + 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/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/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/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); - } -}