diff --git a/ts3-client/README.md b/ts3-client/README.md index c89e455..19ed273 100644 --- a/ts3-client/README.md +++ b/ts3-client/README.md @@ -94,9 +94,14 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged. actions. Actions this client does not implement are listed but greyed out. - Push-to-talk is one of those hotkeys; the button in **Options → Voice Activation** edits that binding. -- Capture uses **XInput2 raw events**, as TeamSpeak's own Linux client does: no +- On Linux, capture uses **XInput2 raw events**, as TeamSpeak's own client does: no privileges needed and the keystroke is not swallowed. X11's RECORD extension is kept as a fallback for servers without XInput2. +- On **Windows**, capture uses **raw input** (`RIDEV_INPUTSINK`) through a message-only + window: it observes rather than intercepts, so unlike a low-level keyboard hook it + cannot swallow a keystroke or stall the system input queue, and it reports the side + buttons and both edges of every key. Bindings are stored as scan codes, so they follow + the physical key rather than the layout. - On Wayland the hooks run inside Xwayland, so they see every X11 application, and keys aimed at native Wayland windows only where the compositor forwards them — under KWin that is *System Settings → Window Management → Legacy X11 App Support*, which is why @@ -206,6 +211,9 @@ desktop/ com.ts3client.audio.desktop + com.ts3client.hotkey.desktop ├── XInput2InputHook global key/button capture via XInput2 raw events ├── XRecordInputHook the same via X11's RECORD extension, as a fallback ├── EvdevInputHook /dev/input fallback for Wayland sessions + ├── WindowsInputHook the same on Windows: raw input into a message-only window + ├── RawInput RAWINPUT layout and decoding, split out to be testable + ├── X11KeyNamer/WindowsKeyNamer layout-aware key labels per platform └── DesktopInputHooks picks the backend that suits the session swing/ com.ts3client @@ -244,4 +252,10 @@ swing/ com.ts3client whisper and push-to-whisper, recording, plugins, server groups, talk power, 3D sound, hardware ("local") microphone mute and the channel-traversal variants beyond "Switch to Channel". +- Global hotkeys cover Linux and Windows; **macOS has no backend**, so hotkeys there are + inert (a `CGEventTap`, which needs Accessibility permission, is the way in). The + Windows backend is written but has not been run on Windows — only its decoding is + covered by tests. +- When no backend can start, push-to-talk does not work at all, not even with the window + focused; a focus-scoped fallback hook would restore the pre-hotkey behaviour. - No file transfer, avatars, or server/channel administration UI yet. diff --git a/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyKey.java b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyKey.java index d60fbd2..a849856 100644 --- a/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyKey.java +++ b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyKey.java @@ -1,12 +1,19 @@ package com.ts3client.hotkey; /** - * One physical key or mouse button, identified the way the X server numbers them: - * keyboards by keycode (Linux evdev code + 8) and mice by button number, so a binding - * is independent of the keyboard layout in force when it was recorded. + * One physical key or mouse button. Keys are identified by their position rather than by + * the character they produce, so a binding is independent of the keyboard layout in force + * when it was recorded. + * + *
The keyboard numbering is whatever the platform's own is — X keycodes (Linux evdev + * code + 8) on X11, set-1 scan codes with the {@code E0}/{@code E1} escape in the high + * byte on Windows — because that is what its input API reports and its key-naming call + * expects. Bindings are therefore per-machine, which is also how TeamSpeak stores them. + * Mouse buttons are unified on the X numbering everywhere, side buttons included. * * @param device which input device the code belongs to - * @param code X keycode for {@link Device#KEYBOARD}, X button number for {@link Device#MOUSE} + * @param code the platform's key code for {@link Device#KEYBOARD}, an X-style button + * number for {@link Device#MOUSE} */ public record HotkeyKey(HotkeyKey.Device device, int code) { diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/DesktopInputHooks.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/DesktopInputHooks.java index d3f6821..3a802fb 100644 --- a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/DesktopInputHooks.java +++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/DesktopInputHooks.java @@ -9,10 +9,11 @@ import java.util.List; /** * Picks the global input hook that suits the running session and starts it. * - *
On X11, RECORD is the polite choice: no extra privileges, no devices to open. On - * Wayland the X server sees only what is aimed at X clients, so reading evdev is the - * only way to catch every key — and when that is not permitted either, the returned - * hook is simply not running and says why. + *
Windows has one answer, raw input. On X11 it is XInput2, with RECORD behind it for
+ * servers that lack it: neither needs privileges or devices to open. On Wayland the X
+ * server sees only what the compositor forwards, so reading evdev is the surest way to
+ * catch every key — and when that is not permitted either, the returned hook is simply
+ * not running and says why.
*/
public final class DesktopInputHooks {
@@ -35,6 +36,10 @@ public final class DesktopInputHooks {
private static List Keyboards report a set-1 scan code, which is what a binding stores: it names the
+ * physical key, so it survives a layout change exactly as an X keycode does on Linux.
+ * Mice are renumbered to the X button numbering the rest of the client already speaks,
+ * so {@link HotkeyKey#fallbackName()} and the saved bindings mean the same thing on
+ * both platforms.
+ */
+final class RawInput {
+
+ private RawInput() {
+ }
+
+ /** {@code RAWINPUTHEADER.dwType}. */
+ static final int TYPE_MOUSE = 0;
+ static final int TYPE_KEYBOARD = 1;
+
+ /** Field offsets in {@code RAWINPUT} under x64, past the 24-byte header. */
+ static final long HEADER_TYPE = 0;
+ static final long HEADER_SIZE = 24;
+ static final long KEYBOARD_MAKE_CODE = 24;
+ static final long KEYBOARD_FLAGS = 26;
+ static final long KEYBOARD_VKEY = 30;
+ static final long MOUSE_BUTTON_FLAGS = 28;
+
+ /** Both event kinds fit comfortably; the larger, {@code RAWMOUSE}, needs 48 bytes. */
+ static final long BUFFER_SIZE = 64;
+
+ /** {@code RAWKEYBOARD.Flags}. */
+ static final int RI_KEY_BREAK = 0x01;
+ static final int RI_KEY_E0 = 0x02;
+ static final int RI_KEY_E1 = 0x04;
+
+ /** {@code VK_NO_VKEY}: the filler half of an escaped sequence, carrying no key. */
+ private static final int VKEY_NONE = 0xFF;
+
+ /** {@code RAWMOUSE.usButtonFlags}, in down/up pairs. */
+ private static final int[] BUTTON_FLAGS = {
+ 0x0001, 0x0002, 1, // left
+ 0x0010, 0x0020, 2, // middle
+ 0x0004, 0x0008, 3, // right
+ 0x0040, 0x0080, 8, // X1, "Mouse 4"
+ 0x0100, 0x0200, 9, // X2, "Mouse 5"
+ };
+
+ private static final int RI_MOUSE_WHEEL = 0x0400;
+ private static final int RI_MOUSE_HWHEEL = 0x0800;
+
+ /**
+ * The scan code identifying a key, with the escape prefix folded into the high byte
+ * so that e.g. right Ctrl ({@code E0 1D}) stays distinct from left ({@code 1D}).
+ *
+ * @return the code, or -1 when the event names no key of its own
+ */
+ static int scanCode(int makeCode, int flags, int vkey) {
+ if (vkey == VKEY_NONE) return -1;
+ int code = makeCode & 0xFF;
+ if (code == 0) return -1;
+ if ((flags & RI_KEY_E0) != 0) code |= 0xE000;
+ else if ((flags & RI_KEY_E1) != 0) code |= 0xE100;
+ return code;
+ }
+
+ static boolean isRelease(int flags) {
+ return (flags & RI_KEY_BREAK) != 0;
+ }
+
+ /** One button transition, in the order the flags word packs them. */
+ record ButtonEvent(HotkeyKey key, boolean pressed) {
+ }
+
+ /**
+ * Unpacks a mouse event, which may carry several transitions at once.
+ *
+ * @param buttonFlags {@code usButtonFlags}
+ * @param wheelDelta {@code usButtonData}, read as a signed delta when a wheel bit is set
+ */
+ static List Raw input is the polite way to watch the whole machine on Windows: registering with
+ * {@code RIDEV_INPUTSINK} delivers every key and button even while another application is
+ * in the foreground, and — unlike a {@code WH_KEYBOARD_LL} hook — it observes rather than
+ * intercepts, so nothing can be swallowed and nothing serialises the system input queue.
+ *
+ * Loading is lazy and failure is expected off Windows, where {@link #isAvailable()}
+ * returns {@code false} and the caller falls back to another backend.
+ */
+final class Win32 {
+
+ private Win32() {
+ }
+
+ private static final ValueLayout.OfInt INT = ValueLayout.JAVA_INT;
+ private static final ValueLayout.OfLong LONG = ValueLayout.JAVA_LONG;
+ private static final java.lang.foreign.AddressLayout PTR = ValueLayout.ADDRESS;
+
+ /** Window messages we care about. */
+ static final int WM_DESTROY = 0x0002;
+ static final int WM_CLOSE = 0x0010;
+ static final int WM_INPUT = 0x00FF;
+
+ /** {@code HWND_MESSAGE}: parent for a window that only ever receives messages. */
+ static final long HWND_MESSAGE = -3L;
+
+ /** {@code RIDEV_INPUTSINK}: deliver input even when the window is not in front. */
+ static final int RIDEV_INPUTSINK = 0x00000100;
+
+ /** HID usages for the two devices we register (usage page 1, "generic desktop"). */
+ static final int USAGE_PAGE_GENERIC = 0x01;
+ static final int USAGE_MOUSE = 0x02;
+ static final int USAGE_KEYBOARD = 0x06;
+
+ /** {@code RID_INPUT}: ask {@code GetRawInputData} for the payload, not the header. */
+ static final int RID_INPUT = 0x10000003;
+
+ /** {@code WNDCLASSEXW} size and the field offsets we fill, under x64. */
+ static final long WNDCLASS_SIZE = 80;
+ static final long WNDCLASS_CBSIZE = 0;
+ static final long WNDCLASS_WNDPROC = 8;
+ static final long WNDCLASS_HINSTANCE = 24;
+ static final long WNDCLASS_CLASSNAME = 64;
+
+ /** {@code RAWINPUTDEVICE}: usage page, usage, flags, target window. */
+ static final long RAWINPUTDEVICE_SIZE = 16;
+ static final long RAWINPUTDEVICE_USAGE_PAGE = 0;
+ static final long RAWINPUTDEVICE_USAGE = 2;
+ static final long RAWINPUTDEVICE_FLAGS = 4;
+ static final long RAWINPUTDEVICE_TARGET = 8;
+
+ /** {@code MSG} is 48 bytes under x64. */
+ static final long MSG_SIZE = 48;
+
+ /** The C signature of a {@code WNDPROC}. */
+ static final FunctionDescriptor WND_PROC =
+ FunctionDescriptor.of(LONG, PTR, INT, LONG, LONG);
+
+ private static final Linker LINKER = Linker.nativeLinker();
+
+ private static final class Libs {
+ static final SymbolLookup USER32 = SymbolLookup.libraryLookup("user32.dll", Arena.global());
+ static final SymbolLookup KERNEL32 = SymbolLookup.libraryLookup("kernel32.dll", Arena.global());
+
+ static final MethodHandle GET_MODULE_HANDLE =
+ downcall(KERNEL32, "GetModuleHandleW", FunctionDescriptor.of(PTR, PTR));
+ static final MethodHandle REGISTER_CLASS =
+ downcall(USER32, "RegisterClassExW", FunctionDescriptor.of(INT, PTR));
+ static final MethodHandle UNREGISTER_CLASS =
+ downcall(USER32, "UnregisterClassW", FunctionDescriptor.of(INT, PTR, PTR));
+ static final MethodHandle CREATE_WINDOW =
+ downcall(USER32, "CreateWindowExW", FunctionDescriptor.of(PTR,
+ INT, PTR, PTR, INT, INT, INT, INT, INT, PTR, PTR, PTR, PTR));
+ static final MethodHandle DESTROY_WINDOW =
+ downcall(USER32, "DestroyWindow", FunctionDescriptor.of(INT, PTR));
+ static final MethodHandle DEF_WINDOW_PROC =
+ downcall(USER32, "DefWindowProcW", FunctionDescriptor.of(LONG, PTR, INT, LONG, LONG));
+ static final MethodHandle GET_MESSAGE =
+ downcall(USER32, "GetMessageW", FunctionDescriptor.of(INT, PTR, PTR, INT, INT));
+ static final MethodHandle DISPATCH_MESSAGE =
+ downcall(USER32, "DispatchMessageW", FunctionDescriptor.of(LONG, PTR));
+ static final MethodHandle POST_MESSAGE =
+ downcall(USER32, "PostMessageW", FunctionDescriptor.of(INT, PTR, INT, LONG, LONG));
+ static final MethodHandle POST_QUIT_MESSAGE =
+ downcall(USER32, "PostQuitMessage", FunctionDescriptor.ofVoid(INT));
+ static final MethodHandle REGISTER_RAW_INPUT =
+ downcall(USER32, "RegisterRawInputDevices", FunctionDescriptor.of(INT, PTR, INT, INT));
+ static final MethodHandle GET_RAW_INPUT_DATA =
+ downcall(USER32, "GetRawInputData",
+ FunctionDescriptor.of(INT, PTR, INT, PTR, PTR, INT));
+ static final MethodHandle GET_KEY_NAME_TEXT =
+ downcall(USER32, "GetKeyNameTextW", FunctionDescriptor.of(INT, INT, PTR, INT));
+ }
+
+ private static MethodHandle downcall(SymbolLookup lookup, String symbol,
+ FunctionDescriptor descriptor) {
+ return LINKER.downcallHandle(
+ lookup.find(symbol).orElseThrow(() ->
+ new UnsatisfiedLinkError("unresolved symbol " + symbol)),
+ descriptor);
+ }
+
+ static boolean isWindows() {
+ return System.getProperty("os.name", "").toLowerCase().startsWith("windows");
+ }
+
+ static boolean isAvailable() {
+ if (!isWindows()) return false;
+ try {
+ return Libs.USER32 != null && Libs.KERNEL32 != null;
+ } catch (Throwable t) {
+ return false;
+ }
+ }
+
+ /** Allocates a null-terminated UTF-16 string, as every {@code ...W} entry point wants. */
+ static MemorySegment wide(Arena arena, String text) {
+ return arena.allocateFrom(text, StandardCharsets.UTF_16LE);
+ }
+
+ static MemorySegment moduleHandle() {
+ return (MemorySegment) call(Libs.GET_MODULE_HANDLE, MemorySegment.NULL);
+ }
+
+ /** @return the class atom, or 0 when registration failed */
+ static int registerClass(MemorySegment wndClass) {
+ return (int) call(Libs.REGISTER_CLASS, wndClass);
+ }
+
+ static void unregisterClass(MemorySegment className, MemorySegment instance) {
+ call(Libs.UNREGISTER_CLASS, className, instance);
+ }
+
+ /** Creates a message-only window: no pixels, but it has a queue and can be a target. */
+ static MemorySegment createMessageWindow(MemorySegment className, MemorySegment instance) {
+ return (MemorySegment) call(Libs.CREATE_WINDOW, 0, className, MemorySegment.NULL, 0,
+ 0, 0, 0, 0, MemorySegment.ofAddress(HWND_MESSAGE),
+ MemorySegment.NULL, instance, MemorySegment.NULL);
+ }
+
+ static void destroyWindow(MemorySegment window) {
+ call(Libs.DESTROY_WINDOW, window);
+ }
+
+ static long defWindowProc(MemorySegment window, int message, long wParam, long lParam) {
+ return (long) call(Libs.DEF_WINDOW_PROC, window, message, wParam, lParam);
+ }
+
+ /** @return 1 for a message, 0 for {@code WM_QUIT}, -1 on error */
+ static int getMessage(MemorySegment message) {
+ return (int) call(Libs.GET_MESSAGE, message, MemorySegment.NULL, 0, 0);
+ }
+
+ static void dispatchMessage(MemorySegment message) {
+ call(Libs.DISPATCH_MESSAGE, message);
+ }
+
+ static void postMessage(MemorySegment window, int message, long wParam, long lParam) {
+ call(Libs.POST_MESSAGE, window, message, wParam, lParam);
+ }
+
+ static void postQuitMessage(int exitCode) {
+ call(Libs.POST_QUIT_MESSAGE, exitCode);
+ }
+
+ /** @return whether the devices were registered for background delivery */
+ static boolean registerRawInputDevices(MemorySegment devices, int count) {
+ return (int) call(Libs.REGISTER_RAW_INPUT, devices, count, (int) RAWINPUTDEVICE_SIZE) != 0;
+ }
+
+ /** @return bytes written into {@code buffer}, or -1 on failure */
+ static int getRawInputData(MemorySegment handle, MemorySegment buffer, MemorySegment size) {
+ return (int) call(Libs.GET_RAW_INPUT_DATA, handle, RID_INPUT, buffer, size,
+ (int) RawInput.HEADER_SIZE);
+ }
+
+ /**
+ * The layout's name for a key.
+ *
+ * @param lParam scan code in bits 16..23, the extended flag in bit 24, as the API wants
+ */
+ static String keyName(int lParam, Arena arena) {
+ MemorySegment buffer = arena.allocate(128);
+ int length = (int) call(Libs.GET_KEY_NAME_TEXT, lParam, buffer, 64);
+ if (length <= 0) return null;
+ return buffer.getString(0, StandardCharsets.UTF_16LE);
+ }
+
+ private static Object call(MethodHandle handle, Object... args) {
+ try {
+ return handle.invokeWithArguments(args);
+ } catch (Throwable t) {
+ throw new IllegalStateException("Win32 call failed", t);
+ }
+ }
+}
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/WindowsInputHook.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/WindowsInputHook.java
new file mode 100644
index 0000000..90a75f4
--- /dev/null
+++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/WindowsInputHook.java
@@ -0,0 +1,234 @@
+package com.ts3client.hotkey.desktop;
+
+import com.ts3client.hotkey.GlobalInputHook;
+import com.ts3client.hotkey.HotkeyKey;
+
+import java.lang.foreign.Arena;
+import java.lang.foreign.MemorySegment;
+import java.lang.foreign.ValueLayout;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * System-wide input hook for Windows, built on raw input: a message-only window
+ * registers the keyboard and mouse with {@code RIDEV_INPUTSINK}, so every key and button
+ * on the machine arrives as {@code WM_INPUT} whether or not this application is in front,
+ * and the event still reaches the foreground window untouched.
+ *
+ * Chosen over a {@code WH_KEYBOARD_LL} hook because raw input only observes: it cannot
+ * swallow a keystroke, it does not put this process in the path of the system input queue
+ * — where a slow callback stalls typing everywhere — and it reports the side buttons and
+ * both edges of every key, which push-to-talk needs.
+ *
+ * A window's messages belong to the thread that created it, so the window is created
+ * on the pump thread and {@link #start} waits to hear whether that succeeded.
+ */
+public final class WindowsInputHook implements GlobalInputHook {
+
+ private static final String WINDOW_CLASS = "Ts3jHotkeyInputSink";
+
+ /** How long to wait for the pump thread to stand its window up before giving up. */
+ private static final long STARTUP_TIMEOUT_SECONDS = 5;
+
+ private static final MethodHandle WND_PROC;
+
+ static {
+ try {
+ WND_PROC = MethodHandles.lookup().findStatic(WindowsInputHook.class, "onMessage",
+ MethodType.methodType(long.class, MemorySegment.class, int.class,
+ long.class, long.class));
+ } catch (ReflectiveOperationException e) {
+ throw new ExceptionInInitializerError(e);
+ }
+ }
+
+ /** The running hook, for the static window procedure to find its way back. */
+ private static volatile WindowsInputHook current;
+
+ private volatile MemorySegment window = MemorySegment.NULL;
+ private Thread thread;
+ private volatile Listener listener;
+ private volatile boolean running;
+ private volatile String unavailable = "not started";
+
+ public static boolean isSupported() {
+ return Win32.isAvailable();
+ }
+
+ @Override
+ public void start(Listener listener) {
+ this.listener = listener;
+ CountDownLatch ready = new CountDownLatch(1);
+ thread = new Thread(() -> pump(ready), "ts3j-hotkeys-rawinput");
+ thread.setDaemon(true);
+ thread.start();
+ try {
+ if (!ready.await(STARTUP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
+ unavailable = "the raw input window did not come up";
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ unavailable = "interrupted while starting";
+ }
+ }
+
+ /** Owns the window for its whole life: creates it, pumps it, tears it down. */
+ private void pump(CountDownLatch ready) {
+ boolean signalled = false;
+ try (Arena arena = Arena.ofConfined()) {
+ MemorySegment className = Win32.wide(arena, WINDOW_CLASS);
+ MemorySegment instance = Win32.moduleHandle();
+ MemorySegment stub = java.lang.foreign.Linker.nativeLinker()
+ .upcallStub(WND_PROC, Win32.WND_PROC, arena);
+
+ MemorySegment wndClass = arena.allocate(Win32.WNDCLASS_SIZE);
+ wndClass.set(ValueLayout.JAVA_INT, Win32.WNDCLASS_CBSIZE, (int) Win32.WNDCLASS_SIZE);
+ wndClass.set(ValueLayout.ADDRESS, Win32.WNDCLASS_WNDPROC, stub);
+ wndClass.set(ValueLayout.ADDRESS, Win32.WNDCLASS_HINSTANCE, instance);
+ wndClass.set(ValueLayout.ADDRESS, Win32.WNDCLASS_CLASSNAME, className);
+ // A leftover registration from an earlier run in this process is harmless:
+ // CreateWindowEx only needs the class to exist.
+ Win32.registerClass(wndClass);
+
+ MemorySegment hwnd = Win32.createMessageWindow(className, instance);
+ if (hwnd.equals(MemorySegment.NULL)) {
+ unavailable = "cannot create the raw input window";
+ return;
+ }
+ window = hwnd;
+
+ if (!Win32.registerRawInputDevices(rawInputDevices(arena, hwnd), 2)) {
+ unavailable = "RegisterRawInputDevices failed";
+ Win32.destroyWindow(hwnd);
+ window = MemorySegment.NULL;
+ return;
+ }
+
+ current = this;
+ running = true;
+ unavailable = "";
+ ready.countDown();
+ signalled = true;
+
+ MemorySegment message = arena.allocate(Win32.MSG_SIZE);
+ while (running) {
+ int result = Win32.getMessage(message);
+ // 0 is WM_QUIT, -1 an error; either way the window is finished.
+ if (result <= 0) break;
+ Win32.dispatchMessage(message);
+ }
+ } catch (Throwable t) {
+ unavailable = describe(t);
+ } finally {
+ running = false;
+ window = MemorySegment.NULL;
+ if (current == this) current = null;
+ if (!signalled) ready.countDown();
+ }
+ }
+
+ /** The keyboard and the mouse, both asked for in the background. */
+ private static MemorySegment rawInputDevices(Arena arena, MemorySegment hwnd) {
+ MemorySegment devices = arena.allocate(Win32.RAWINPUTDEVICE_SIZE * 2);
+ int[] usages = {Win32.USAGE_KEYBOARD, Win32.USAGE_MOUSE};
+ for (int i = 0; i < usages.length; i++) {
+ long base = i * Win32.RAWINPUTDEVICE_SIZE;
+ devices.set(ValueLayout.JAVA_SHORT, base + Win32.RAWINPUTDEVICE_USAGE_PAGE,
+ (short) Win32.USAGE_PAGE_GENERIC);
+ devices.set(ValueLayout.JAVA_SHORT, base + Win32.RAWINPUTDEVICE_USAGE,
+ (short) usages[i]);
+ devices.set(ValueLayout.JAVA_INT, base + Win32.RAWINPUTDEVICE_FLAGS,
+ Win32.RIDEV_INPUTSINK);
+ devices.set(ValueLayout.ADDRESS, base + Win32.RAWINPUTDEVICE_TARGET, hwnd);
+ }
+ return devices;
+ }
+
+ /** Upcall target: the window procedure. */
+ @SuppressWarnings("unused")
+ private static long onMessage(MemorySegment hwnd, int message, long wParam, long lParam) {
+ WindowsInputHook hook = current;
+ try {
+ if (message == Win32.WM_INPUT && hook != null) {
+ hook.readInput(lParam);
+ } else if (message == Win32.WM_DESTROY) {
+ Win32.postQuitMessage(0);
+ return 0;
+ }
+ } catch (Throwable ignored) {
+ }
+ // WM_INPUT must reach DefWindowProc too, so the system can release the event.
+ return Win32.defWindowProc(hwnd, message, wParam, lParam);
+ }
+
+ /** Copies one {@code RAWINPUT} out of the system and turns it into hotkey events. */
+ private void readInput(long handle) {
+ Listener l = listener;
+ if (l == null) return;
+ try (Arena arena = Arena.ofConfined()) {
+ MemorySegment buffer = arena.allocate(RawInput.BUFFER_SIZE);
+ MemorySegment size = arena.allocate(ValueLayout.JAVA_INT);
+ size.set(ValueLayout.JAVA_INT, 0, (int) RawInput.BUFFER_SIZE);
+ if (Win32.getRawInputData(MemorySegment.ofAddress(handle), buffer, size) <= 0) return;
+
+ switch (buffer.get(ValueLayout.JAVA_INT, RawInput.HEADER_TYPE)) {
+ case RawInput.TYPE_KEYBOARD -> {
+ int makeCode = buffer.get(ValueLayout.JAVA_SHORT, RawInput.KEYBOARD_MAKE_CODE) & 0xFFFF;
+ int flags = buffer.get(ValueLayout.JAVA_SHORT, RawInput.KEYBOARD_FLAGS) & 0xFFFF;
+ int vkey = buffer.get(ValueLayout.JAVA_SHORT, RawInput.KEYBOARD_VKEY) & 0xFFFF;
+ int code = RawInput.scanCode(makeCode, flags, vkey);
+ if (code >= 0) l.onInput(HotkeyKey.keyboard(code), !RawInput.isRelease(flags));
+ }
+ case RawInput.TYPE_MOUSE -> {
+ int flags = buffer.get(ValueLayout.JAVA_SHORT, RawInput.MOUSE_BUTTON_FLAGS) & 0xFFFF;
+ short data = buffer.get(ValueLayout.JAVA_SHORT, RawInput.MOUSE_BUTTON_FLAGS + 2);
+ for (RawInput.ButtonEvent e : RawInput.buttons(flags, data)) {
+ l.onInput(e.key(), e.pressed());
+ }
+ }
+ default -> {
+ }
+ }
+ } catch (Throwable ignored) {
+ }
+ }
+
+ @Override
+ public boolean isRunning() {
+ return running;
+ }
+
+ @Override
+ public String unavailableReason() {
+ return running ? "" : unavailable;
+ }
+
+ @Override
+ public String keyName(HotkeyKey key) {
+ return WindowsKeyNamer.name(key);
+ }
+
+ @Override
+ public void close() {
+ listener = null;
+ running = false;
+ MemorySegment hwnd = window;
+ try {
+ // Closing has to happen on the pump thread; ask it to, then let it unwind.
+ if (!hwnd.equals(MemorySegment.NULL)) Win32.postMessage(hwnd, Win32.WM_CLOSE, 0, 0);
+ if (thread != null) thread.join(2000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (Throwable ignored) {
+ }
+ thread = null;
+ }
+
+ private static String describe(Throwable t) {
+ String message = t.getMessage();
+ return (message == null || message.isBlank()) ? t.getClass().getSimpleName() : message;
+ }
+}
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/WindowsKeyNamer.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/WindowsKeyNamer.java
new file mode 100644
index 0000000..ad59f66
--- /dev/null
+++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/WindowsKeyNamer.java
@@ -0,0 +1,37 @@
+package com.ts3client.hotkey.desktop;
+
+import com.ts3client.hotkey.HotkeyKey;
+
+import java.lang.foreign.Arena;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Names scan codes through the active Windows layout, so a hotkey button shows "A"
+ * rather than "Key 30" — the counterpart to {@link X11KeyNamer}.
+ *
+ * {@code GetKeyNameTextW} wants the scan code where a {@code WM_KEYDOWN} would carry
+ * it: bits 16..23, with bit 24 marking the {@code E0} escape.
+ */
+final class WindowsKeyNamer {
+
+ private WindowsKeyNamer() {
+ }
+
+ private static final Map