KeyboardHook: кроссплатформенный глобальный перехват клавиатуры и мыши для .NET

от автора

Проблема: ни один готовый пакет не подошёл

Первые несколько пакетов, которые я попробовал, работали исключительно на 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/