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 das 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 zukönnen ist es nötig entweder zur Laufzeit die Dynamic Library zu laden oder während des Linken den Linker anzuweisen die User32.lib mit der eigenen Anwendung zu linken. Die WinUser.h enthält die nötigen Definitionen.
Eingaben Speichern
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 einen Message Loop einer anderen Anwendung zuhä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 beinhaltenden Wert wurde mittels einer Makro Definition eine Bezeichnung gegeben, die dadurch verständlicher wird. 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 ein oder andere wird sich nun vielleicht Fragen warum ich dort ein KeyboardEvent Objekt erzeuge obwohl ich die methode emplace_back()
des vector
s 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.
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 Speicher Adresse sowieso kopieren. Dazu dereferenziere ich sie und übergebe sie an den jeweiligen Konstruktur 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 Harwareeingabe.
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.
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
den 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. Bei einer niedrigen Geschwindigkeit kann das auch zu instabilität Führen, da die Anwendung nicht mehr auf die Nachrichten von Windows reagieren kann.
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 mit unter die leichteste Möglichkeit fehlerfrei den Typ im Feld zu bestimmen und auf das Objekt zu zugreifen. 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.
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 4 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 zwichen einen 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.
Nach dem ich die Parameter aus den vorhanden Daten erzeugt habe versende ich sie mit den 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.