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

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

Комментарии (8)


  1. fedorro
    29.05.2026 08:12

    Спасибо что сделали и поделились полезной разработкой! И сразу вопрос\предложение: в конструкторе в статье не увидел можно ли это привязать к определённому контроллеру. Суть в том что некоторые контроллеры, например Ulanzi D100H - просто видны как HID Клавиатура\Мышь, и хотелось бы сделать приложение, которое именно события с неё перехватывает.


    1. c3n9 Автор
      29.05.2026 08:12

      да, hid тоже может обрабатывать, но это зависит от того какие клавиши в прошивке этого устройства, могут какие-то другие приходить неопределенные у меня в Enum, вы можете потестировать, сделать fork и добавить их в enum


      1. fedorro
        29.05.2026 08:12

        Там обычные коды, по большей части медиа, по умолчанию. Вопрос был в другом - можно ли настроить эту библиотеку, чтобы определенные коды она перехватывала только с конкретных клавиатур. Например колесо громкости с клавиатуры пусть и дальше работает, как работало, а тот-же код с контроллера обработать самому.


  1. Sabirman
    29.05.2026 08:12

    А можете реализовать перехват нажатия клавишь до того как они уйдут в RDP (и т.п.) сессию. Это нужно для перехвата мультимедийных клавиш - музыку при удаленном подключении запускаешь локально.


    1. c3n9 Автор
      29.05.2026 08:12

      Хорошая идея, можете, пожалуйста создать issue на git и максимально подробно описать, в свободное время займусь этим


    1. c3n9 Автор
      29.05.2026 08:12

      Я до конца не понимаю момент с мультимедийными клавишами


      1. fedorro
        29.05.2026 08:12

        Если включить RDP на полный экран, то он пересылает все нажатия на удалённую машину, при этом переключение треков и изменение громкости хотелось бы иметь возможность обрабатывать локально.


        1. c3n9 Автор
          29.05.2026 08:12

          Интересно, почитаю об этом, напишите issue, забуду ;)