Управляем подсветкой клавиатуры при смене языка ввода

Недавно я купил себе клавиатуру от Corsair модели K55 RGB Pro. У нее есть модная нынче цветная подсветка, а чтобы ее настраивать, производитель предлагает скачать программу iCUE. На сайте написано, что некоторые игры могут управлять подсветкой совместимых устройств. Гугл обнаружил официальный SDK с примерами, а также документацию. Я решил сделать что-то полезное для себя, а заодно посмотреть, как создаются приложения под Windows.

Мой код (для Visual Studio) можно найти здесь.


Выглядит это вот так

Для того, чтобы начать работать с периферией, достаточно подключить библиотеку, заинклюдить заголовки и положить dll рядом с программой. В комплекте идет несколько версий, я взял последнюю, CUESDK_2019 для 32 бит.

Начинается работа с вызова CorsairPerformProtocolHandshake(). Если что-то пошло не так, CorsairGetLastError() вернет код последней ошибки.

CorsairPerformProtocolHandshake(); if (const auto error = CorsairGetLastError()) { std::cout << "Handshake failed: " << toString(error) << std::endl; return 2; }

Метод toString — обычный switch-case, возвращающий строку по коду. Ошибок всего 6, я не буду их здесь перечислять, можно посмотреть, как это сделано в примере.

К компьютеру может быть подключено несколько устройств, для каждого устройства может быть доступно несколько светодиодов. Каждый из них имеет свой ID, координаты в пространстве относительно устройства, цвет, и может управляться независимо. Вот структура, описывающая светодиод:

	struct CorsairLedPosition { CorsairLedId ledId; // identifier of led. double top; double left; double height; double width; // values in mm. };

Я хочу сделать все максимально просто и менять цвет сразу всем диодам, поэтому мне надо получить список их идентификаторов. Меня не очень интересует их расположение, но, в принципе, зная его, можно попробовать высвечивать флаг страны или что-то более интересное.

Сначала вызовем CorsairGetDeviceCount(), чтобы узнать, сколько у нас вообще подключено совместимой периферии, и, если есть хотя бы одно устройство, вызовем CorsairGetLedPositionsByDeviceIndex(i) для каждого. В моем случае устройство всего одно, и я передаю i=0. В примерах из документации можно посмотреть, как управлять разными устройствами. Сразу же, как только мы получили идентификаторы светодиодов, можно создать массивы с нужными нам цветами (CorsairLedColor)

void getAllLeds()
{ if (CorsairGetDeviceCount() > 0) { if (const auto ledPositions = CorsairGetLedPositionsByDeviceIndex(0)) { for (auto i = 0; i < ledPositions->numberOfLed; i++) { const auto ledId = ledPositions->pLedPosition[i].ledId; leds1.push_back(CorsairLedColor{ ledId, en_r, en_g, en_b }); leds2.push_back(CorsairLedColor{ ledId, ru_r, ru_g, ru_b }); } } }
}

Я использую два языка ввода, для них я создал два пресета: голубой для английского и оранжевый для русского.

Теперь, чтобы изменить цвет, мы должны вызвать CorsairSetLedsColorsBufferByDeviceIndex и передать туда индекс устройства (в моем случае 0 — у меня оно всего одно) и массив из CorsairLedColor-ов.

CorsairSetLedsColorsBufferByDeviceIndex(0, static_cast<int>(leds1.size()), leds2.data());

Изменения вступят в силу, как только мы вызовем CorsairSetLedsColorsFlushBuffer().

В принципе, на этом месте можно уже скомпилировать и запустить код и посмотреть, как это все выглядит. Но я хочу, чтобы цвет менялся в зависимости от языка.

Как узнать язык ввода?

О, а вот тут начинается интересная часть. Из нескольких способов, описанных в документации к winapi, у меня заработал только один — использовать SetWindowsHookEx на событие WH_SHELL. Описание можно найти в документации по ссылке, если кратко, это работает так:

  1. Создаем специальную функцию ShellProc, которая должна вызываться на различные события, связанные с оболочкой windows.

  2. Нас интересует параметр nCode, который может принимать значение HSHELL_LANGUAGE, означающее, что пользователь сменил язык ввода.

  3. Handle языка ввода передается в lParam. Я не смог найти полное описание этого параметра, отчасти потому что в разных местах эта штука называется по-разному (handle to a keyboard layout, input language handle). Однако, эксперименты показали, что каждому языку (методу ввода) соответствует одно числовое значение, которое, к тому же, не меняется от запуска к запуску и даже от перезагрузок, что позволяет вынести нужные нам значения в константы (или в конфиг).

Здесь нужно немного поговорить о структуре нашего приложения. Дело в том, что для установки глобального хука его обработчик должен быть в DLL. Эта DLL затем подключается ко всем запущенным процессам. Однако мы живем в век 64-битных систем, и это накладывает дополнительные ограничения. Так, в 64-битные процессы можно загружать только 64-битные DLL, и наоборот. Чтобы наше приложение работало везде, нам понадобятся две DLL разной разрядности и два приложения, которые будут их подгружать/выгружать. В моем случае DLL собирается из проекта ShellHook. Его код очень простой. Функции установки и удаления хука выглядят вот так:

extern "C" SHELLHOOK_API void install()
{ hook = SetWindowsHookEx(WH_SHELL, hookproc, module, 0);
} extern "C" SHELLHOOK_API void uninstall()
{ UnhookWindowsHookEx(hook);
}

SHELLHOOK_API описан рядом в заголовочном файле, который инклюдится в проект приложения, использующего эту DLL. Это стандартная практика для библиотек, как утверждает туториал: так мы можем описать импорт или экспорт в зависимости от того, какая сторона инклюдит заголовки.

При срабатывании хука вызывается функция hookproc из этой же библиотеки. Чтобы не тащить логику в DLL, мы просто сообщаем родительскому приложению через отправку сообщения окну о событии смены языка, а оно там уже само разберется, что с этим делать.

extern "C" LRESULT CALLBACK hookproc(int nCode, WPARAM wParam, LPARAM lParam)
{ if (nCode < 0) // do not process message return CallNextHookEx(hook, nCode, wParam, lParam); switch (nCode) { case HSHELL_LANGUAGE: { HWND wnd = FindWindow(L"CueLangApp", L"CueLangApp"); // we're hard-coding the strings here for simplicity if (wnd != NULL) PostMessage(wnd, WM_USER + 1, wParam, lParam); } default: break; } return CallNextHookEx(hook, nCode, wParam, lParam);
}

Как говорилось выше, ShellHook.dll надо собрать для двух архитектур, x86 и x64. Кроме того, чтобы хуки правильно работали, эти библиотеки должны иметь разные названия. Используем суффикс .x64 для 64-битной версии — установим в настройках проекта Target name в $(ProjectName).x64 для платформы x64.

Для загрузки этой DLL нам потребуется приложение, разрядность которого совпадает с разрядностью библиотеки. Его задача проста: вызвать install, ждать сигнала о завершении, вызвать uninstall. Библиотеку можно либо включить в проект через LIB-файл, либо подгрузить в рантайме с помощью LoadLibrary. Используем второй вариант.

HMODULE dll = LoadLibrary(HOOKLIBNAME);
if (dll == NULL) return 2; install_ = (InstallProc)GetProcAddress(dll, "install");
uninstall_ = (UninstallProc)GetProcAddress(dll, "uninstall"); install_();

Так как имя библиотеки зависит от разрядности, можно воспользоваться макросом Visual Studio _WIN64:

#if _WIN64
#define HOOKLIBNAME L"ShellHook.x64.dll"
#else
#define HOOKLIBNAME L"ShellHook.dll"
#endif

Это вспомогательное приложение, и ему ни к чему иметь окно и вообще как-то отсвечивать, поэтому мы не будем его создавать. Очередь сообщений в windows привязана к треду, и мы можем сделать цикл обработки сообщений прямо в WinMain:

while (GetMessage(&msg, NULL, 0, 0) > 0) { if (msg.message == WM_CLOSE) { break; }
}

Windows не отправляет никакого WM_CLOSE потокам без окна, это сообщение выбрано произвольно, чтобы можно было остановить выполнение из родительского приложения.

В конце снимем хук и освободим ресурсы:

uninstall_();
FreeLibrary(dll);

Как этим всем управлять?

Для того, чтобы это все заработало, нам надо:

  1. Запустить обе версии HookSupportApp

  2. Реагировать на сообщения WM_USER+1 из коллбэка хука

  3. Остановить все и снять хуки, когда пользователь закрыл программу

Для этого сделаем третье, основное приложение. Оно представляет из себя консольное приложение (мне так было удобнее выводить дебажные сообщения и ошибки), но создает невидимое окно, чтобы принимать события о переключении языка. В нем же определены языки, цвета и реализована работа с CUE SDK, описанная в начале статьи. Код, по сравнению с предыдущими двумя проектами, достаточно объемный, поэтому я предлагаю интересующимся ознакомиться с ним по ссылке, а ниже я опишу то, что, на мой взгляд, достойно внимания.

Точкой входа для консольного приложения является main. Внутри подключаемся к CUE и инициализируем цвета для светодиодов. Затем создаем невидимое окно, которое будет получать сообщения:

WNDCLASS wc = {};
wc.lpfnWndProc = WndProc;
wc.hInstance = hInstance;
wc.lpszClassName = CLASS_NAME; RegisterClass(&wc); hwnd = CreateWindowEx( 0, CLASS_NAME, L"CueLangApp", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, NULL, NULL, hInstance, NULL
);
if (hwnd == NULL) { return 0;
}
ShowWindow(hwnd, SW_HIDE);

Здесь нужно использовать те же имя класса окна и его заголовок, которые использованы в FindWindow внутри DLL, иначе окно не найдется. WndProc это обработчик событий окна, на все неизвестные события вызывается DefWindowProc, кроме двух, интересных нам: WM_CLOSE на закрытие окна и WM_USER+1 на изменение языка.

LRESULT CALLBACK WndProc( _In_ HWND hWnd, _In_ UINT message, _In_ WPARAM wParam, _In_ LPARAM lParam
)
{ switch (message) { case WM_USER + 1: changeLang(wParam, lParam); break; case WM_CLOSE: PostQuitMessage(0); default: return DefWindowProc(hWnd, message, wParam, lParam); } return 0;
}

Для запуска и контроля дочерних процессов, ответственных за хуки, используем CreateProcess:

STARTUPINFO si;
PROCESS_INFORMATION pi;
ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);
ZeroMemory(&pi, sizeof(pi)); if (!CreateProcess(NULL, // No module name (use command line) &childexe[0], // Command line NULL, // Process handle not inheritable NULL, // Thread handle not inheritable FALSE, // Set handle inheritance to FALSE 0, // No creation flags NULL, // Use parent's environment block NULL, // Use parent's starting directory &si, // Pointer to STARTUPINFO structure &pi) // Pointer to PROCESS_INFORMATION structure )
{ printf("CreateProcess 32 failed (%d).\n", GetLastError()); return 1;
}
//...
childThread32 = GetThreadId(pi.hThread);

childThread32 затем используется для того, чтобы отправить ему сообщение об остановке:

PostThreadMessage(childThread32, WM_CLOSE, 0, 0);

Еще одна деталь: так как у нас консольное приложение, а дополнительное окно невидимо, мы должны как-то реагировать на остановку приложения пользователем (CTRL-C, закрытие окна консоли, и т.д.). Для этого у нас есть функция-обработчик таких событий CtrlHandler, которая устанавливается с помощью SetConsoleCtrlHandler(CtrlHandler, TRUE).

После того, как окно и консоль созданы, дочерние процессы запущены, и подключение к CUE SDK прошло успешно, запускается цикл обработки сообщений. Здесь он выглядит немного не так, как в HookSupportApp, мы используем DispatchMessage(), потому что на этот раз у нас есть окно с указанной для него WndProc.

while (GetMessage(&msg, NULL, 0, 0) > 0) { TranslateMessage(&msg); DispatchMessage(&msg);
}

Вот, собственно, и все. Здесь еще большой простор для улучшений: можно добавить полноценный GUI для удобного взаимодействия с пользователем, список устройств и языков, конфиг, более сложные цветовые комбинации — все, что может прийти вам в голову!


Список того, что мне помогло в процессе работы над проектом.

Читайте так же:

  • Google опубликовал последний выпуск поисковых новостей в 2021 годуGoogle опубликовал последний выпуск поисковых новостей в 2021 году Google опубликовал подборку главных новостей по поиску за декабрь 2021 года. Это последний выпуск Google Search News в уходящем году. [embedded content] В этом выпуске сотрудник Google Джон Мюллер перечислил те новости, о которых важно знать владельцам сайтов и SEO-специалистам. В их […]
  • Google подтвердил, что обрабатывает 308 редирект как 301Google подтвердил, что обрабатывает 308 редирект как 301 Google обновил официальную документацию, где официально подтвердил, что обрабатывает 308 редирект как 301. Предположения о том, что Google обрабатывает эти коды ответа сервера одинаково, появились еще три года назад, но этому не было официальных подтверждений. Теперь в обновленной […]
  • Как оптимизировать рекламные кампании в Яндекс.ДиректеКак оптимизировать рекламные кампании в Яндекс.Директе Описание 20 мая с 13:00 до 15:00 по московскому времени пройдет бесплатный вебинар «Как оптимизировать рекламные кампании в Яндекс.Директе». Вебинар проведет Никита Кравченко, ведущий специалист по работе с платным трафиком, eLama. Программа вебинара:  Теория оптимизации кампаний […]
  • «Челка» в новых MacBook Pro автоматически маскируется при переходе приложений в полноэкранный режим«Челка» в новых MacBook Pro автоматически маскируется при переходе приложений в полноэкранный режим Обои для MacBook Pro с «челкой» теоретически должны выглядеть так, но Apple будет ее маскировать черной полоской в верхней части дисплея.По информации MacRumors, «челка» в новых MacBook Pro будет автоматически маскироваться при переходе приложений в полноэкранный режим. Это будет […]