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+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.

Источник: https://habr.com/ru/articles/1040932/


Интересные статьи

Интересные статьи

Привет! Это Илья, руководитель проектов в Webest. Домен нужен каждому проекту, но подобрать доменное имя — тот ещё квест. Мы решили эту задачу, разработав ИИ-генератор доменных имён. В его основе Yand...
Российский учёный предложил решение информационного парадокса чёрных дыр, показав, куда должна деваться исчезающая информация. Работа была опубликована в журнале Modern Physics Letters A. В исследова...
Запустив последний тест на проверку, ты откидываешься на спинку стула, чувствуя удовлетворение от проделанной работы. Твои руки создали шедевральный код, но за пределами твоего рабочего пространства м...
Привет! Меня зовут Саша Шутай, я тимлид в AGIMA. В прошлой статье я рассказывал, что делать, если на проекте Bitrix сожительствует с Vue.js и поисковые боты не видят контента сайта. А в этой помогу ра...
Однажды, в понедельник, мне пришла в голову мысль — "а покопаюсь ка я в новом ядре" (новым относительно, но об этом позже). Мысль не появилась на ровном месте, а предпосылками для нее стали: ...