Capture IO
Wie man mit Low Level Hooks Tastatur - und Mauseingaben abfangen, speichern und wieder abspielen kann. Ein Deep Dive in modernes C++ und sehr alter Win API. Zusätzlich gibt es wissenswertes zu Event Loops. Again.
Einfaches kleines Hilfsprogramm, das Low-Level-Win-API-Hooks nutzt, um Tastatur- und Mauseingaben zu erfassen und die erfassten Eingaben abzuspielen. Es unterstützt eine variable Wiedergabegeschwindigkeit, wenn ein möglichst natürlicher Ablauf benötigt wird oder die Events so dicht beieinander liegen, dass Windows sie nicht mehr alle registrieren kann.

Das Abspielen eines Captures kann über einen konfigurierbaren Hotkey aufgerufen werden. Der Anwendungsdialog muss also nicht den Fokus haben. Es funktioniert wie die Aufzeichnung eines Makros in MS Word oder anderen Office-Produkten, nur eben auf dem gesamten System.

Wie funktioniert es?
Wie schon erwähnt nutze ich zum Abfangen der Eingaben Low Level Hooks. Dabei registriert man eine oder mehrere Callback Funktionen, die dann von Windows in regelmäßigen Intervallen ausgeführt werden. Dabei werden Parameter übergeben, die dann entsprechende Informationen enthalten oder auch nicht. Der Message Loop ist ein Mechanismus mit dem Windows Nachrichten von der Hardware oder anderen Anwendungen an eine bestimmte Anwendung weiterleitet. Diese Nachrichten enthalten Informationen über Ereignisse die die Anwendung betreffen, wie z.B. Tastendrücke, Mausbewegungen, Klicks, Fenstergrößenänderungen usw. Die Anwendung muss diese Nachrichten abrufen und verarbeiten, um auf die Ereignisse zu reagieren. In der Regel werden nur Nachrichten, die die Anwendung verarbeiten muss, ihr gesendet.
Um den Windows Message Loop zu verwenden, um Low Level Keyboard und Mouse Inputs abzufangen, muss die Anwendung eine Funktion registrieren, die als Hook bezeichnet wird. Ein Hook ist eine Funktion, die vor oder nach der Verarbeitung einer bestimmten Art von Nachricht aufgerufen wird. Es gibt verschiedene Arten von Hooks für verschiedene Arten von Nachrichten, aber die relevanten für Keyboard und Mouse Inputs sind WH_KEYBOARD_LL und WH_MOUSE_LL. Diese Hooks ermöglichen es der Anwendung, die Tastatur- und Mausnachrichten zu überwachen oder zu ändern, bevor sie an die Zielanwendung gesendet werden.
Ich kann wirklich nichts dafür, dass ich so viel über Event Loops schreibe. Die sind halt einfach überall.
Auf Betriebssystemebene dient der Message Loop dazu, die Kommunikation zwischen den verschiedenen Anwendungen und dem Betriebssystem zu koordinieren. Er stellt sicher, dass jede Anwendung die richtigen Nachrichten erhält und dass keine Nachrichten verloren gehen oder blockiert werden. Windows managt den Message Loop mit Hilfe eines Moduls namens User32.dll, das für die Erstellung und Zerstörung von Fenstern, das Senden und Empfangen von Nachrichten, das Verwalten von Timern und anderen Aufgaben zuständig ist.
Um mit der Programmierschnittstelle kommunizieren zu können ist es nötig entweder zur Laufzeit die Dynamic Library zu laden oder während des Linkens die User32.lib mit der eigenen Anwendung zu verlinken. Die WinUser.h enthält die nötigen Definitionen.
Eingaben Speichern
LRESULT MainWindow::kb_hook_proc(int nCode, WPARAM wParam, LPARAM lParam) noexcept {
if (nCode < 0) {
return CallNextHookEx(nullptr, nCode, wParam, lParam);
}
KBDLLHOOKSTRUCT *keyboard = reinterpret_cast<KBDLLHOOKSTRUCT *>(lParam);
auto now = duration_cast<microseconds>(system_clock().now().time_since_epoch());
auto &hk_play = GlobalSettings::instance().get_play_hotkey();
auto seq = vk_to_keyseq(keyboard->vkCode);
if (hk_play.matches(seq) == 2 && instance().get_last_toggle().count() + MainWindow::DELAY_MS < now.count()) {
instance().set_last_toggle(now);
instance().on_tbTogglePlay_clicked(!instance().is_playing());
return CallNextHookEx(nullptr, nCode, wParam, lParam);
}
if (instance().is_recording()) {
instance().get_records().emplace_back(KeyboardEvent {*keyboard, wParam});
}
return CallNextHookEx(nullptr, nCode, wParam, lParam);
}Callback für Keyboard Inputs
Zu aller erst prüfe ich ob die Daten überhaupt interessante Informationen enthalten. Wenn nicht reiche ich die Daten weiter. Windows wird sie zur nächsten Anwendung, die sich registriert hat, leiten. Es ist auch möglich in einem Message Loop einer anderen Anwendung zu hängen. Der Parameter lParam enthält einen Pointer zu einer KBDLLHOOKSTRUCT Struktur und der Parameter wParam ist ein Integer Wert vom Typ long long. Den enthaltenen Werten wurden mittels Makrodefinitionen Bezeichnungen gegeben, die dadurch verständlicher werden. WPARAM ist ein Flag und diesem Fall kann es nur einer von vier Werten sein:
WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP
Das Prüfen ob eine definierte Zeit vergangen ist, wird benötigt damit der Hotkey nicht direkt wieder toggled. Er ist kurze Zeit nach Betätigung also ohne Funktion.
Interessant ist folgendes.
if (instance().is_recording()) {
instance().get_records().emplace_back(KeyboardEvent {*keyboard, wParam});
}
Der eine oder andere wird sich nun vielleicht Fragen warum ich dort ein KeyboardEvent Objekt erzeuge obwohl ich die methode emplace_back() des vectors nutze. Die sollte doch eigentlich das Objekt erzeugen.
if (instance().is_recording()) {
instance().get_records().emplace_back(*keyboard, wParam);
}
So funktioniert es nur leider nicht, weil der vector nicht das ist was er scheint. Die Obere Variante ist korrekt und auch emplace_back() tut genau das was es soll.
LRESULT MainWindow::m_hook_proc(int nCode, WPARAM wParam, LPARAM lParam) noexcept {
if (nCode < 0) {
return CallNextHookEx(nullptr, nCode, wParam, lParam);
}
MSLLHOOKSTRUCT *mouse = reinterpret_cast<MSLLHOOKSTRUCT *>(lParam);
instance().get_records().emplace_back(MouseEvent {*mouse, wParam});
return CallNextHookEx(nullptr, nCode, wParam, lParam);
}Callback Funktion für die Mouse Events
Das ist die Callback Funktion für die Mouse Events. Dort packe ich in exakt den gleichen vector nun ein MouseEvent Objekt. Mit einem Blick auf die Typdefinition zeigt sich dann auch wie das möglich sein kann.
using CaptureList = std::vector<std::variant<KeyboardEvent, MouseEvent>>;
Es ist eine Liste von Objekten des Typs variant das sowohl KeyboardEvent Objekte als auch MouseEvent Objekte akzeptiert. Es wird also von der emplace_back Methode ein variant Objekt erzeugt und dessen Konstruktor benötigt entweder Ein KeyboardEvent oder ein MouseEvent Objekt. Da nicht sicher ist wie lange die Pointer zu den Daten leben nach dem sie den Kontext verlassen haben muss ich die Daten an der angegebenen Speicheradresse sowieso kopieren. Dazu dereferenziere ich sie und übergebe sie an den jeweiligen Konstruktor wo dann die Kopie für später gelagert wird. Sie existieren so lange wie der vector die Daten hält. Was bis zum nächsten Aufruf der Record - Funktionalität ist.
Ich kopiere also die übergebenen Daten und lege sie in einer Liste ab.
Synthetische Eingaben erzeugen
Die Win API Funktion SendInput ermöglicht es, synthetische Tastatur- und Mausereignisse an die aktive Anwendung zu senden. Diese Funktion kann nützlich sein, um Automatisierungsaufgaben zu erledigen, Benutzereingaben zu simulieren oder Tests durchzuführen.
Die SendInput Funktion hat die folgende Signatur:
UINT SendInput(
UINT cInputs,
LPINPUT pInputs,
int cbSize
);
Der erste Parameter, cInputs, gibt die Anzahl der Eingabestrukturen an, die im zweiten Parameter, pInputs, übergeben werden. Eine Eingabestruktur ist vom Typ INPUT und enthält Informationen über die Art und die Daten des Eingabeereignisses. Der dritte Parameter, cbSize, gibt die Größe einer Eingabestruktur in Bytes an.
Die INPUT Struktur hat die folgende Definition:
typedef struct tagINPUT {
DWORD type;
union {
MOUSEINPUT mi;
KEYBDINPUT ki;
HARDWAREINPUT hi;
} DUMMYUNIONNAME;
} INPUT, *PINPUT, *LPINPUT;
Der erste Member, type, gibt die Art des Eingabeereignisses an. Es kann einen der folgenden Werte haben:
INPUT_MOUSE: Mausereignis.INPUT_KEYBOARD: Tastatureingabe.INPUT_HARDWARE: Hardwareeingabe.
Der zweite Member ist eine Union, die je nach dem Wert vom type einen der folgenden Typen enthält:
MOUSEINPUT: Struct mit Informationen zum Mausereignis.KEYBDINPUT: Struct mit Informationen zur Tastatureingabe.HARDWAREINPUT: Struct mit Informationen zur Hardwareeingabe.
Um die SendInput Funktion zu verwenden, muss man also zunächst eine oder mehrere INPUT Strukturen erstellen und mit den entsprechenden Daten füllen. Dann muss man diese Strukturen als Array an die SendInput Funktion übergeben. Die Funktion gibt dann die Anzahl der erfolgreich gesendeten Eingaben zurück oder 0 im Fehlerfall.
void MainWindow::playback() noexcept {
static RECT desktop;
static const HWND dhandle = GetDesktopWindow();
static constexpr double MAX { 0xFFFF };
GetWindowRect(dhandle, &desktop);
static const double WIDTH = desktop.right;
static const double HEIGHT = desktop.bottom;
milliseconds last_tick {};
for (auto ev = _records.begin(); ev < _records.end(); ev = std::next(ev)) {
auto now = std::chrono::duration_cast<milliseconds>(
std::chrono::system_clock::now().time_since_epoch());
if (last_tick.count() + GlobalSettings::instance().get_real_playback_speed() > now.count()) {
ev = std::prev(ev);
continue;
}
last_tick = now;
INPUT inputs[1] = {};
ZeroMemory(inputs, sizeof(inputs));
if(auto *kb = std::get_if<KeyboardEvent>(&(*ev))) {
inputs[0].type = INPUT_KEYBOARD;
inputs[0].ki.wVk = kb->get_vkey();
inputs[0].ki.dwFlags = kb->get_msg() == WM_KEYUP ? KEYEVENTF_KEYUP : 0x0000;
} else if (auto *mo = std::get_if<MouseEvent>(&(*ev))) {
double x = mo->get_point().x;
double y = mo->get_point().y;
auto factorX = x / WIDTH;
auto factorY = y / HEIGHT;
x = std::round(MAX * factorX);
y = std::round(MAX * factorY);
inputs[0].type = INPUT_MOUSE;
inputs[0].mi.dx = x;
inputs[0].mi.dy = y;
switch (mo->get_msg()) {
case WM_LBUTTONDOWN:
inputs[0].mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
break;
case WM_LBUTTONUP:
inputs[0].mi.dwFlags = MOUSEEVENTF_LEFTUP;
break;
case WM_RBUTTONDOWN:
inputs[0].mi.dwFlags = MOUSEEVENTF_RIGHTDOWN;
break;
case WM_RBUTTONUP:
inputs[0].mi.dwFlags = MOUSEEVENTF_RIGHTUP;
break;
case WM_MBUTTONDOWN:
inputs[0].mi.dwFlags = MOUSEEVENTF_MIDDLEDOWN;
break;
case WM_MBUTTONUP:
inputs[0].mi.dwFlags = MOUSEEVENTF_MIDDLEUP;
break;
case WM_MOUSEWHEEL:
inputs[0].mi.dwFlags = MOUSEEVENTF_WHEEL;
break;
case WM_MOUSEMOVE:
inputs[0].mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE;
break;
}
}
SendInput(ARRAYSIZE(inputs), inputs, sizeof(inputs));
}
}Die playback Methode zum Senden von synthetischen Keyboard - sowie Mouse Events
Limitierung von Win-API Calls
Eigentlich bin ich ein großer Freund von For-Each-Schleifen. Die sind so schörkellos und einfach in der Handhabung. Hier brauchte ich jedoch die Möglichkeit, die aktuelle Iteration neu beginnen zu können.
milliseconds last_tick {};
for (auto ev = _records.begin(); ev < _records.end(); ev = std::next(ev)) {
auto now = std::chrono::duration_cast<milliseconds>(
std::chrono::system_clock::now().time_since_epoch());
if (last_tick.count() + GlobalSettings::instance().get_real_playback_speed() > now.count()) {
ev = std::prev(ev);
continue;
}
last_tick = now;
/* ... */
}
Ich lege mir eine Variable für die Zeit in MS der letzten erfolgreichen Iteration vor der Schleife an. Diese ist beim ersten Durchlauf mit 0 initialisiert. Dann lasse ich mir die aktuelle Zeit in MS geben und addiere die Zeit der letzten Iteration mit der invertierten Geschwindigkeit (weil weniger und nicht mehr schneller ist) und gleiche das Resultat mit der aktuellen Zeit ab. Sollte das Ergebnis in der Zukunft liegen, wird der Iterator zurück-iteriert und zum Schleifenkopf gesprungen. Das geht nun so lange bis der Zeitwert in der Vergangenheit oder, wenn auch unwahrscheinlich, in der Gegenwart sich befindet. Als nächstes wird last_tick der Wert von now zugewiesen. Die erste Iteration läuft demnach in jedem Fall durch. Andernfalls würde der Versuch, den Iterator von Position 0 rückwärts zu zählen, in einen Absturz der Anwendung münden.
Dadurch ist die Ausführung der Befehle spürbar langsamer, ohne dabei den Thread zu blockieren, wie es bei der Nutzung von std::chrono::this_thread::sleep_for der Fall wäre. Um ganz genau zu sein: Dies ist ein Busy-Wait und der blockiert natürlich den Thread aber solange die Geschwindigkeit gering genug bleibt, fällt es nicht auf. Im Gegensatz zu ::sleep_for findet hier jedoch kein freiwilliger Kontextwechsel statt. Bei einer niedrigen Geschwindigkeit kann das auch zu Instabilität führen, da die Anwendung nicht mehr auf die Nachrichten von Windows reagieren kann. Um das wirklich korrekt zu implementieren müsste man diesen For-Loop eigentlich in einen gesonderten Thread auslagern. In diesem Use-Case ist es vertretbar darauf zu verzichten.
Typdifferenzielle Branches
Mit der If-Else-If Anweisung und std::get_if<T> wird aus dem variant des vectors dann bedingt das jeweilige Objekt geholt. Bei einer geringen Anzahl an Typen im Variant ist das mitunter die leichteste Möglichkeit fehlerfrei den Typ im Feld zu bestimmen und auf das Objekt zuzugreifen. Ab einer gewissen Anzahl wird es praktischer, wenn man mit einem Visitor alle möglichen Typen durchläuft. Ab C++20 kann man dabei auf die Verwendung von Deduction Guides gänzlich verzichten. Ein sehr spannendes Thema. Ich kann hierzu diesen Artikel empfehlen.
Auflösungsunabhängige absolute Koordinaten
Am Anfang der Funktion habe ich einige Variablen definiert und initialisiert. Vor diesen steht das Schlüsselwort static
static RECT desktop;
static const HWND dhandle = GetDesktopWindow();
static constexpr double MAX { 0xFFFF };
static const double WIDTH = desktop.right;
static const double HEIGHT = desktop.bottom;
Auch wenn diese im Funktionsrumpf definiert sind werden sie im Anwendungskontext nur ein einziges Mal erzeugt. Egal wie oft die Funktion auch aufgerufen wird.
Im Übrigen brauche ich die Auflösung, weil der Wert den ich für die x und y Koordinaten des Mouse Cursors habe natürlich auch in diesem Wertebereich zu finden sind.
double x = mo->get_point().x;
double y = mo->get_point().y;
auto factorX = x / WIDTH;
auto factorY = y / HEIGHT;
x = std::round(MAX * factorX);
y = std::round(MAX * factorY);
inputs[0].type = INPUT_MOUSE;
inputs[0].mi.dx = x;
inputs[0].mi.dy = y;
Die Struktur für die Mausereignisse erwartet allerdings einen Wert der im 2 Byte Wertebereich liegt. Also 0 - 65535. Um in die scheinbar nicht passenden Bereiche zu konvertieren, benötigt man einen relativen Wert. Ich lasse einen Faktor errechnen. Dazu benötige ich die Auflösung des Displays. Die ist immer noch der gemeinsame Nenner. Ich teile also x / Pixel Spalten und y / Pixel Zeilen. Würde nur noch die Multiplikation mit 100 für einen prozentualen Wert fehlen. Das brauchen wir aber nicht. Die Variablen x und y liegen nun zwischen einem Wert von 0 und 1. Danach muss ich nur noch 65535 mit x und 65535 mit y multiplizieren um die korrekten Werte für Windows zu haben.
Nachdem ich die Parameter aus den vorhandenen Daten erzeugt habe, versende ich sie mit dem Aufruf von
SendInput(ARRAYSIZE(inputs), inputs, sizeof(inputs));
An Windows und das führt dann die gewünschte Aktion aus.
Wie üblich ist der komplette Source Code Open Source.

Repository
