Проблема: ни один готовый пакет не подошёл
Первые несколько пакетов, которые я попробовал, работали исключительно на Windows и молчали на macOS. Другие просто вешали приложение при первом нажатии клавиши. Я искал кроссплатформенное решение, «только Windows» меня не устраивало.
Если чего-то нет, что делать? Конечно, писать самому. По началу всё было написано даже без вынесения в отдельную библиотеку, но чуть позже, когда данный код понадобился и в других моих проектах, подумал написать библиотеку с нуля. Самое главное чего я хотел добиться: без лишних зависимостей, кроссплатформенность, возможность кочевать между pet-проектами. По ходу решил сделать ее open source, чтобы неминуемо улучшать и совершенствовать данное решение.
KeyboardHook — кроссплатформенный глобальный хук клавиатуры и мыши для .NET Standard 2.0.
Что умеет библиотека
-
Глобальный перехват нажатий и отпусканий клавиш — работает даже когда ваше приложение не в фокусе
-
Глобальный перехват событий мыши (Left, Right, Middle)
-
Программная эмуляция нажатий клавиш и кнопок мыши
-
Поддержка комбинаций клавиш (Ctrl+C, Alt+F4 и любых других)
-
Три платформы: Windows x86/x64, macOS Arm64, Linux x64 (X11)
-
Нет внешних зависимостей — только стандартный .NET и P/Invoke к системным библиотекам
Установка одной строкой:
dotnet add package KeyboardHook
Архитектура: один интерфейс, три реализации
Ключевое решение — полное разделение публичного API и платформенного кода. Снаружи пользователь видит только два интерфейса:
public interface IKeyboardHook{ event Action<KeyboardKey> KeyDown; event Action<KeyboardKey> KeyUp; void SendKey(KeyboardKey key); void SendKeyCombo(params KeyboardKey[] keys);}
public interface IMouseHook : IDisposable{ event Action<MouseButton> ButtonDown; event Action<MouseButton> ButtonUp; void SendButton(MouseButton button); void SendButtonCombo(params MouseButton[] buttons);}
Получить нужную реализацию — одна строка, платформа определяется в рантайме:
IKeyboardHook keyboard = KeyboardHookFactory.Create();IMouseHook mouse = MouseHookFactory.Create();
Фабрика внутри выглядит так:
public static class KeyboardHookFactory{ public static IKeyboardHook Create() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return new WindowsKeyboardHook(); if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return new LinuxKeyboardHook(); if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return new MacKeyboardHook(); throw new PlatformNotSupportedException("Unsupported platform"); }}
Маппинг клавиш: атрибуты вместо switch-портянок
Каждая платформа использует свои коды клавиш. На Windows это Virtual Key Codes, на Linux — X11 keycodes, на macOS — Virtual Key коды CoreGraphics. Вместо трёх гигантских switch-выражений я решил хранить коды прямо в enum через кастомные атрибуты:
public enum KeyboardKey{ [WindowsCode(0x41)] [LinuxCode(38)] [MacosCode(0)] A, [WindowsCode(0x1B)] [LinuxCode(9)] [MacosCode(53)] Escape, [WindowsCode(0x70)] [LinuxCode(67)] [MacosCode(122)] F1, // ... 150+ клавиш}
Атрибуты минималистичны:
[AttributeUsage(AttributeTargets.Field)]public class WindowsCodeAttribute : Attribute{ public int Code { get; } public WindowsCodeAttribute(int code) => Code = code;}
Конвертация в обе стороны через рефлексию:
internal static int ToPlatformCode(this KeyboardKey key){ var field = typeof(KeyboardKey).GetField(key.ToString()); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return field?.GetCustomAttribute<WindowsCodeAttribute>()?.Code ?? 0; if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return field?.GetCustomAttribute<LinuxCodeAttribute>()?.Code ?? 0; if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return field?.GetCustomAttribute<MacosCodeAttribute>()?.Code ?? 0; return 0;}internal static KeyboardKey FromPlatformCode(int platformCode){ foreach (var field in typeof(KeyboardKey).GetFields()) { if (field.FieldType != typeof(KeyboardKey)) continue; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { var attr = field.GetCustomAttribute<WindowsCodeAttribute>(); if (attr?.Code == platformCode) return (KeyboardKey)field.GetValue(null); } // ... аналогично для Linux и macOS } return KeyboardKey.None;}
Добавить новую клавишу теперь значит добавить одну запись в enum с тремя атрибутами — и больше ничего трогать не нужно.
Windows: WH_KEYBOARD_LL
На Windows реализация опирается на низкоуровневый хук WH_KEYBOARD_LL — это стандартный механизм Win32 для перехвата клавиш на уровне системы:
internal class WindowsKeyboardHook : IKeyboardHook, IDisposable{ private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam); private const int WH_KEYBOARD_LL = 13; private const int WM_KEYDOWN = 0x0100; private const int WM_KEYUP = 0x0101; private const int WM_SYSKEYDOWN = 0x0104; private const int WM_SYSKEYUP = 0x0105; private readonly HashSet<KeyboardKey> _pressedKeys = new HashSet<KeyboardKey>(); public event Action<KeyboardKey> KeyDown; public event Action<KeyboardKey> KeyUp; public WindowsKeyboardHook() { _proc = HookCallback; _hookId = SetHook(_proc); } private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) { if (nCode >= 0) { var kb = Marshal.PtrToStructure<KBDLLHOOKSTRUCT>(lParam); var key = KeyboardKeyExtensions.FromPlatformCode(kb.vkCode); if (wParam == (IntPtr)WM_KEYDOWN || wParam == (IntPtr)WM_SYSKEYDOWN) { _pressedKeys.Add(key); KeyDown?.Invoke(key); } else if (wParam == (IntPtr)WM_KEYUP || wParam == (IntPtr)WM_SYSKEYUP) { _pressedKeys.Remove(key); KeyUp?.Invoke(key); } } return CallNextHookEx(_hookId, nCode, wParam, lParam); } public void SendKey(KeyboardKey key) { var code = (byte)KeyboardKeyExtensions.ToPlatformCode(key); keybd_event(code, 0, 0, UIntPtr.Zero); keybd_event(code, 0, KEYEVENTF_KEYUP, UIntPtr.Zero); } public void SendKeyCombo(params KeyboardKey[] keys) { foreach (var k in keys) keybd_event((byte)KeyboardKeyExtensions.ToPlatformCode(k), 0, 0, UIntPtr.Zero); for (int i = keys.Length - 1; i >= 0; i--) keybd_event((byte)KeyboardKeyExtensions.ToPlatformCode(keys[i]), 0, KEYEVENTF_KEYUP, UIntPtr.Zero); } [DllImport("user32.dll")] private static extern IntPtr SetWindowsHookEx(...); [DllImport("user32.dll")] private static extern IntPtr CallNextHookEx(...); [DllImport("user32.dll")] private static extern void keybd_event(...); // ...}
Важная деталь: SYSKEYDOWN/SYSKEYUP — это отдельные сообщения для клавиш, нажатых вместе с Alt (или если окно не в фокусе). Без них пропустите половину системных сочетаний.
macOS: CGEventTap + CoreFoundation RunLoop
На macOS всё интереснее. Системный механизм — CGEventTap из фреймворка CoreGraphics. Он создаёт «точку перехвата» в потоке событий сессии и требует разрешений Accessibility (иначе CGEventTapCreate вернёт NULL):
internal class MacKeyboardHook : IKeyboardHook, IDisposable{ private const int kCGEventKeyDown = 10; private const int kCGEventKeyUp = 11; private const int kCGKeyboardEventKeycode = 9; private const int kCGSessionEventTap = 1; private CGEventTapCallBack _callbackKeepAlive; // удерживаем делегат от GC! private IntPtr _eventTap; private IntPtr _runLoop; private void InitializeEventTap() { _callbackKeepAlive = EventCallback; var eventMask = (1UL << kCGEventKeyDown) | (1UL << kCGEventKeyUp); _eventTap = CGEventTapCreate( kCGSessionEventTap, place: 1, // HeadInsert options: 1, // Default eventsOfInterest: eventMask, callback: _callbackKeepAlive, userInfo: IntPtr.Zero ); if (_eventTap == IntPtr.Zero) throw new UnauthorizedAccessException( "Accessibility permissions are required to intercept keys."); _runLoopSource = CFMachPortCreateRunLoopSource(IntPtr.Zero, _eventTap, 0); CGEventTapEnable(_eventTap, true); StartRunLoop(); } private void StartRunLoop() { var thread = new Thread(() => { _runLoop = CFRunLoopGetCurrent(); IntPtr modes = GetCFRunLoopCommonModes(); // через dlopen/dlsym CFRunLoopAddSource(_runLoop, _runLoopSource, modes); CFRunLoopRun(); // блокирует поток, обрабатывая события }) { IsBackground = true, Name = "MacKeyboardHook Loop" }; thread.Start(); } private IntPtr EventCallback(IntPtr proxy, int type, IntPtr eventRef, IntPtr userInfo) { if (type == kCGEventKeyDown || type == kCGEventKeyUp) { long macKeyCode = CGEventGetIntegerValueField(eventRef, kCGKeyboardEventKeycode); var key = KeyboardKeyExtensions.FromPlatformCode((int)macKeyCode); if (type == kCGEventKeyDown) KeyDown?.Invoke(key); else KeyUp?.Invoke(key); } return eventRef; }}
Одна нетривиальная вещь: kCFRunLoopCommonModes — это не константа, а указатель на глобальную переменную во фреймворке. Получить его значение можно только через dlopen/dlsym:
private static IntPtr GetCFRunLoopCommonModes(){ IntPtr handle = dlopen( "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", 2); IntPtr symbol = dlsym(handle, "kCFRunLoopCommonModes"); return Marshal.ReadIntPtr(symbol);}
Без этого хук перестанет работать в некоторых режимах runloop.
Linux: опрос XQueryKeymap на 60 FPS
На Linux с X11 нет глобального хука в классическом понимании. Зато есть XQueryKeymap — функция, возвращающая битовую карту из 256 бит (32 байта), где каждый бит соответствует состоянию одной клавиши. Опрашиваем её в фоновом потоке ~60 раз в секунду и сравниваем с предыдущим состоянием:
internal class LinuxKeyboardHook : IKeyboardHook, IDisposable{ private byte[] _previousKeys = new byte[32]; [DllImport("libX11.so.6")] private static extern bool XQueryKeymap(IntPtr display, byte[] keys); [DllImport("libXtst.so.6")] private static extern int XTestFakeKeyEvent(IntPtr display, uint keycode, bool press, uint delay); private void KeymapPollingLoop() { while (_running) { byte[] currentKeys = new byte[32]; if (XQueryKeymap(_display, currentKeys)) ProcessKeymapChanges(currentKeys); Thread.Sleep(16); // ~60 FPS } } private void ProcessKeymapChanges(byte[] currentKeys) { for (int i = 0; i < 32; i++) { byte current = currentKeys[i]; byte previous = _previousKeys[i]; if (current == previous) continue; for (int bit = 0; bit < 8; bit++) { bool wasPressed = (previous & (1 << bit)) != 0; bool isPressed = (current & (1 << bit)) != 0; int keyCode = i * 8 + bit; if (isPressed && !wasPressed) KeyDown?.Invoke(KeyboardKeyExtensions.FromPlatformCode(keyCode)); else if (!isPressed && wasPressed) KeyUp?.Invoke(KeyboardKeyExtensions.FromPlatformCode(keyCode)); } } Array.Copy(currentKeys, _previousKeys, 32); } public void SendKey(KeyboardKey key) { XTestFakeKeyEvent(_display, (uint)KeyboardKeyExtensions.ToPlatformCode(key), true, 0); XTestFakeKeyEvent(_display, (uint)KeyboardKeyExtensions.ToPlatformCode(key), false, 0); XFlush(_display); }}
Подход с опросом означает теоретическую задержку до 16 мс, зато он работает без дополнительных прав и поддерживается во всех дистрибутивах с X11.
Важно: Wayland не поддерживается — там нет эквивалента
XQueryKeymap, доступного из пользовательского процесса без привилегий.
Использование: всё укладывается в несколько строк
Подписка на глобальные события:
var hook = KeyboardHookFactory.Create();hook.KeyDown += key => Console.WriteLine($"Нажата: {key}");hook.KeyUp += key => Console.WriteLine($"Отпущена: {key}");
Эмуляция одиночного нажатия:
hook.SendKey(KeyboardKey.A);
Отправка комбинации (клавиши нажимаются по порядку, отпускаются в обратном):
hook.SendKeyCombo(KeyboardKey.LControl, KeyboardKey.C); // Ctrl+Chook.SendKeyCombo(KeyboardKey.LControl, KeyboardKey.LShift, KeyboardKey.Escape); // Диспетчер задач
Мышь:
var mouse = MouseHookFactory.Create();mouse.ButtonDown += btn => Console.WriteLine($"Кнопка мыши: {btn}");mouse.SendButton(MouseButton.Left); // клик левой
Освобождение ресурсов:
if (hook is IDisposable d) d.Dispose();
Интеграция с Avalonia
public class MainWindowViewModel : ViewModelBase{ private readonly IKeyboardHook _hook; public MainWindowViewModel() { _hook = KeyboardHookFactory.Create(); _hook.KeyDown += OnGlobalKeyDown; } private void OnGlobalKeyDown(KeyboardKey key) { // Выполняем в UI-потоке, если нужно обновить биндинги Dispatcher.UIThread.Post(() => { LastKey = key.ToString(); }); } public string LastKey { get; private set; }}
Хук работает даже когда окно свёрнуто.
Что дальше
Библиотека живёт на GitHub под лицензией MIT. Пакет опубликован на NuGet.
Если вам нужен лёгкий, прозрачный перехватчик ввода без тяжёлых зависимостей — попробуйте, и не стесняйтесь открывать issues и делать форки.
Буду рад комментариям — особенно от тех, кто решал похожую задачу на Wayland.
ссылка на оригинал статьи https://habr.com/ru/articles/1040932/