Пожалуй, почти не осталось людей, не знающих, что такое Ctrl+C и Ctrl+V. Более опытные пользователи знают горячие клавиши часто используемых приложений. Есть те, кто использует более сложные комбинации: например, для управления плеером, находящимся в фоне. Для разработчиков реализация подобной функциональности обычно не вызывает больших трудностей, т.к. эта задача широко распространена, а о её решении уже многое написано. Но как быть, если надо в свернутом состоянии слушать пользовательский ввод с джойстика или презентера, да к тому же ещё и разбираться, от какого именно устройства пришло событие? Скажем честно, для нас эта задача оказалась чем-то новым. Под катом мы расскажем, как мы её решили на C# в WPF приложении с помощью "Raw Input API".

Предыстория


Наше приложение Jalinga Studio используют для проведения съемок видео, вебинаров и онлайн трансляций, а управляют им преимущественно с помощью презентера или джойстика (зачем нам это понадобилось, можно почитать в нашей предыдущей статье “Как мы оживляем презентацию”). Большинство презентеров сделано для работы с Power Point, поэтому они генерируют обычный клавиатурный ввод: F5, Page Up, Page Down и т.д. В WPF есть стандартный механизм для работы с клавиатурным вводом. Им мы первое время и пользовались, пока не наткнулись на существенный для нас недостаток. Дело в том, что этот механизм работает только тогда, когда приложение активно (находится на переднем плане), но некоторые наши клиенты хотели бы параллельно иметь доступ, например, к браузеру или другой программе, что непременно лишает нас получения клавиатурного ввода. Сначала мы пытались обойти эту проблему путем создания дополнительного небольшого окна на переднем плане, подобно тому, как это сделано в Skype. На этом окне отображается статус работы программы и несколько кнопок для управления, если пользователю удобнее управлять мышкой. Этот подход оказался не самым удобным — окно управления нужно активировать. Если пользователь забывал переключить фокус, то клавиатурный ввод с презентера уходил текущему активному приложению. Например, F5 или Page Down в браузер. Вдобавок к этому всему, в какой-то момент нам стало не хватать кнопок  на презентере, и мы решили использовать джойстики, которые стандартный механизм WPF не поддерживает.

Поиск решения


Сначала мы сформулировали требования к новому механизму:

  • получение информации о пользовательском вводе, если приложение работает в фоне;
  • возможность работы с презентерами, джойстиками, геймпадами;
  • наличие способа различать устройства ввода.

Первое, что пришло в голову — это хуки, которые можно поставить с помощью функции SetWindowsHookEx. Но тут всё равно остаётся открытым вопрос поддержки джойстиков и геймпадов. Я уж молчу про антивирусы, которые могут принять нас за кейлоггера, влияние хуков на работу чужих программ, создание 32-х и 64-х битных dll-ек, взаимодействие с нашим приложением из другого процесса и общую сложность поддержки.

Рассмотрели вариант использования DirectInput или XInput. DirectInput устарел, Microsoft вместо него рекомендуют использовать XInput. С помощью них можно получать пользовательский ввод с джойстиков даже в фоновом режиме, если с помощью метода SetCooperativeLevel поставить флаг Background. Но XInput не поддерживает клавиатуры и мыши. Еще не понравилась pull-модель использования, из-за которой придется с некоторой периодичностью опрашивать интересующие нас устройства.

Продолжили копать дальше. Один хороший друг из Parallels посоветовал посмотреть в сторону "Raw Input API". Проанализировав возможности этого API, мы поняли, что все, что нам нужно, там есть — и работа с различными устройствами HID класса, и возможность получать ввод, не являясь активным окном, и доступный ID устройства, от которого пришло событие. Ограничения тоже есть — если ввод осуществляется в админский процесс, а наш процесс не является админским, то события ввода не придут. Но нам это и не нужно. В крайнем случае всегда можно запустить наше приложение с правами администратора.

Реализация


Обобщенно процесс получения пользовательского ввода с помощью «Raw Input API» состоит из таких шагов:

  1. Регистрируем типы устройств, от которых будем получать события ввода, с помощью RegisterRawInputDevices.
  2. Слушаем события WM_INPUT в оконной процедуре.
  3. Разбираем пришедшие события с помощью GetRawInputData.
  4. Определяем тип события (RAWMOUSE, RAWKEYBOARD, RAWHID) и разбираем его в соответствии с его типом.

Всю эту последовательность нужно было реализовать на C# в WPF приложении. Чтобы не писать самостоятельно большого количества оберток Win API-шных функций, было решено использовать SharpDX.RawInput.

Вот так выглядит упрощенный код на C#, если вы используете Windows.Forms:

public class RawInputListener
{
    public void Init(IntPtr hWnd)
    {
        Device.RegisterDevice(UsagePage.Generic, UsageId.GenericGamepad,
            DeviceFlags.InputSink, hWnd);
        Device.RegisterDevice(UsagePage.Generic, UsageId.GenericKeyboard,
            DeviceFlags.InputSink, hWnd);
        Device.RawInput += OnRawInput;
        Device.KeyboardInput += OnKeyboardInput;
    }

    public void Clear()
    {
        Device.RawInput -= OnRawInput;
        Device.KeyboardInput -= OnKeyboardInput;
    }

    private void OnKeyboardInput(object sender, KeyboardInputEventArgs e)
    {
    }

    private void OnRawInput(object sender, RawInputEventArgs e)
    {
    }
}

Флаг DeviceFlags.InputSink нужен для того, чтобы приложение получало сообщение, даже если оно не находится на переднем плане. При использовании этого флага обязательно нужно указать hWnd.

Если вы используете WPF, то в таком виде методы OnRawInput и OnKeyboardInput вызываться не будут, т.к. внутри класса Device реализуется интерфейс IMessageFilter из Windows.Forms. Если заглянуть в исходный код Device, то там можно увидеть, что в  методе PreFilterMessage вызывается HandleMessage.

Упрощенный код на C#, если вы используете WPF:

public class RawInputListener
{
    private const int WM_INPUT = 0x00FF;
    private HwndSource _hwndSource;

    public void Init(IntPtr hWnd)
    {
        if (_hwndSource != null)
        {
            return;
        }

        _hwndSource = HwndSource.FromHwnd(hWnd);
        if (_hwndSource != null)
            _hwndSource.AddHook(WndProc);

        Device.RegisterDevice(UsagePage.Generic, UsageId.GenericGamepad,
            DeviceFlags.InputSink, hWnd);
        Device.RegisterDevice(UsagePage.Generic, UsageId.GenericKeyboard,
            DeviceFlags.InputSink, hWnd);

        Device.RawInput += OnRawInput;
        Device.KeyboardInput += OnKeyboardInput;
    }

    public void Clear()
    {
        Device.RawInput -= OnRawInput;
        Device.KeyboardInput -= OnKeyboardInput;
    }

    private IntPtr WndProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
    {
        if (msg == WM_INPUT)
        {
            Device.HandleMessage(lParam, hWnd);
        }

        return IntPtr.Zero;
    }

    private void OnKeyboardInput(object sender, KeyboardInputEventArgs e)
    {
    }

    private void OnRawInput(object sender, RawInputEventArgs e)
    {
    }
}

Чтобы разобраться, от какого устройства пришло событие, можно использовать свойство Device класса RawInputEventArgs, с помощью которого можно получить DeviceName вида:

"\\?\HID#{00001124-0000-1000-8000-00805f9b34fb}_VID&000205ac_PID&3232&Col02#8&26f2f425&7&0001#{884b96c3-56ef-11d1-bc8c-00a0c91405dd}"

Vendor ID и Product ID можно найти в этом имени или воспользоваться функциями GetRawInputDeviceInfo или HidD_GetAttributes. Подробнее о VID и PID можно почитать тут и тут.

Перейдем теперь к разбору событий. С клавиатурой все оказалось просто: информация уже приходит в разобранном виде, описанном в классе KeyboardInputEventArgs. А вот с геймпадом все оказалось сложнее. В OnRawInput приходит аргумент базового типа RawInputEventArgs. Этот аргумент нужно привести к типу HidInputEventArgs и, если получилось, дальше работать с ним. В HidInputEventArgs есть только массив байт, пришедший от устройства, причем количество байт в этом массиве у разных джойстиков и геймпадов отличается.

К сожалению, удалось найти мало документации, рассказывающей о том, как разбирать эти данные, да и та встречалась обычно в кратком виде (даже в MSDN сплошные недосказанности по этому вопросу). Самым полезным оказался вот этот проект на C. Его пришлось сначала довести до рабочего состояния, но это уже был отличный старт. После оставалось перенести нужные части на C#.

Первым делом пришлось обернуть нативные функции для вызова их из C# кода. Тут, как всегда, помог pinvoke.net, кое-что понадобилось описать самому. Обзорно про Marshal, PInvoke и небезопасный код в C# можно почитать тут.

Следующий шаг — перенести алгоритм разбора сообщения, который сводится к следующему:

  1. получить PreparsedData устройства (GetRawInputDeviceInfo или HidD_GetPreparsedData);
  2. узнать о возможностях устройства (HidP_GetCaps);
  3. узнать о кнопках устройства (HidP_GetButtonCaps);
  4. получить список нажатых кнопок (HidP_GetUsages).

Ниже представлена часть кода разбора данных от геймпада, который на выходе выдает список нажатых кнопок:

public static class RawInputParser
{
    public static bool Parse(HidInputEventArgs hidInput, out List<ushort> pressedButtons)
    {
        var preparsedData = IntPtr.Zero;
        pressedButtons = new List<ushort>();

        try
        {
            preparsedData = GetPreparsedData(hidInput.Device);
            if (preparsedData == IntPtr.Zero)
                return false;

            HIDP_CAPS hidCaps;
            CheckError(HidP_GetCaps(preparsedData, out hidCaps));

            pressedButtons = GetPressedButtons(hidCaps, preparsedData, hidInput.RawData);
        }
        catch (Win32Exception e)
        {
            return false;
        }
        finally
        {
            if (preparsedData != IntPtr.Zero)
            {
                Marshal.FreeHGlobal(preparsedData);
            }
        }

        return true;
    }
}

PreparsedData легче получать с помощью GetRawInputDeviceInfo, т.к. нужный хэндл устройства уже есть в RawInputEventArgs. Функция HidD_GetPreparsedData этот хэндл не принимает, для него требуется хэндл, который можно получить с помощью CreateFile.

Для получения значений дискретных кнопок процедура аналогичная, только вместо HidP_GetButtonCaps нужно сначала вызвать HidP_GetValueCaps, а потом HidP_GetUsageValue, чтобы получить дискретное значение кнопки.

Теперь, когда набор байт из HidInputEventArgs преобразован в данные о том, какие кнопки нажаты, можно сделать механизм, аналогичный тому, что есть в WPF для работы с клавиатурой и мышкой.

Полный код приложения, разбирающего пользовательский ввод от геймпадов и клавиатуры, можно посмотреть в проекте RawInputWPF на GitHub.

Итог


Вот таким образом с помощью «Raw Input API» можно получить пользовательский ввод с клавиатуры, мышки, джойстика, геймпада или другого устройства пользовательского ввода, даже если ваше приложение находится в фоне.

А что делать с данными о нажатых кнопках, решать вам.
Поделиться с друзьями
-->

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


  1. Laserson
    16.01.2017 15:07
    +1

    А можно полностью перехватить ввод с устройства? Например, есть RFID reader (распознается как стандартное устройство ввода), который в конце прочитанной с носителя последовательности всегда отправляет Enter — хотелось бы полностью перехватить ввод не пропуская Enter дальше в систему и никто не должен получать ввод с этого устройства кроме моего приложения.


    1. AShil
      16.01.2017 16:01
      +1

      Есть такие способы. Но я бы их использовал только в случае острой необходимости.

      1. Можно попробовать комбинацию хуков и raw input API. Из приложения нужно поставить хуки на WH_CALLWNDPROC с помощью SetWindowsHookEx (HINSTANCE должно указывать на соответствующую разрядности dll-ку). Эти dll-ки грузятся во все процессы. В них при загрузке делается всё то, что я описывал в статье про работу с raw input. Там же выполняется разбор пользовательского ввода. Если это нужное сообщение, обрабатываем его сами. Вот тут можно глянуть про запрет ввода.
      2. Фильтр-драйвер.


  1. 3aicheg
    17.01.2017 04:02

    А если подключить к одному компьютеру две или более мышей по USB, можно перехватить ввод от них всех и разделить, какие данные пришли от какой мыши?


    1. AShil
      17.01.2017 12:07

      Да, можно. Добавил обработку мышиных событий в тестовый проект на GitHub. Там можно посмотреть, как это сделать.