From 752676e863ceb527ee8b9a52d3a5a0f4bd90596c Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sun, 16 Aug 2026 18:40:50 +0000 Subject: [PATCH] Capture global hotkeys on Windows through raw input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A message-only window registers the keyboard and mouse with RIDEV_INPUTSINK, so every key and button arrives as WM_INPUT whether or not the client is in front. Raw input only observes, where a WH_KEYBOARD_LL hook sits in the path of the system input queue and can swallow a keystroke; it also reports the side buttons and both edges of every key, which push-to-talk needs. Keyboard codes stay in each platform's own numbering — X keycodes on X11, set-1 scan codes with the E0/E1 escape folded into the high byte on Windows — since that is what the input API reports and the key-naming call expects. Bindings are per-machine either way, as TeamSpeak's own per-OS keydefs are. Mouse buttons are unified on the X numbering, so "Mouse 4" means the same thing on both. The RAWINPUT decoding lives in its own class so it can be tested off Windows; the window and its pump have not been run on Windows yet. Co-Authored-By: Claude Opus 5 --- ts3-client/README.md | 16 +- .../java/com/ts3client/hotkey/HotkeyKey.java | 15 +- .../hotkey/desktop/DesktopInputHooks.java | 17 +- .../ts3client/hotkey/desktop/RawInput.java | 109 ++++++++ .../com/ts3client/hotkey/desktop/Win32.java | 210 ++++++++++++++++ .../hotkey/desktop/WindowsInputHook.java | 234 ++++++++++++++++++ .../hotkey/desktop/WindowsKeyNamer.java | 37 +++ .../hotkey/desktop/RawInputTest.java | 92 +++++++ 8 files changed, 720 insertions(+), 10 deletions(-) create mode 100644 ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/RawInput.java create mode 100644 ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/Win32.java create mode 100644 ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/WindowsInputHook.java create mode 100644 ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/WindowsKeyNamer.java create mode 100644 ts3-client/desktop/src/test/java/com/ts3client/hotkey/desktop/RawInputTest.java 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 candidates() { List hooks = new ArrayList<>(); + if (Win32.isWindows()) { + if (WindowsInputHook.isSupported()) hooks.add(new WindowsInputHook()); + return hooks; + } if (isWayland()) { // Evdev first: it is the only backend guaranteed to see keys aimed at native // Wayland windows. Failing that, Xwayland still covers every X11 app, and on @@ -79,7 +84,9 @@ public final class DesktopInputHooks { @Override public String keyName(HotkeyKey key) { - return X11KeyNamer.name(key); + // Bindings recorded earlier still deserve their real names in the dialog, + // even with no hook running to have produced them. + return Win32.isWindows() ? WindowsKeyNamer.name(key) : X11KeyNamer.name(key); } @Override diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/RawInput.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/RawInput.java new file mode 100644 index 0000000..69ca9a8 --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/RawInput.java @@ -0,0 +1,109 @@ +package com.ts3client.hotkey.desktop; + +import com.ts3client.hotkey.HotkeyKey; + +import java.util.ArrayList; +import java.util.List; + +/** + * Layout of a {@code RAWINPUT} structure and the reading of the two kinds we ask for, + * split out from {@link WindowsInputHook} so the decoding can be tested anywhere. + * + *

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 buttons(int buttonFlags, short wheelDelta) { + List events = new ArrayList<>(); + for (int i = 0; i < BUTTON_FLAGS.length; i += 3) { + HotkeyKey key = HotkeyKey.mouse(BUTTON_FLAGS[i + 2]); + if ((buttonFlags & BUTTON_FLAGS[i]) != 0) events.add(new ButtonEvent(key, true)); + if ((buttonFlags & BUTTON_FLAGS[i + 1]) != 0) events.add(new ButtonEvent(key, false)); + } + // A wheel notch has no release of its own; X reports it as a button tap, and the + // hotkey engine expects the same shape, so synthesise both edges. + if ((buttonFlags & RI_MOUSE_WHEEL) != 0 && wheelDelta != 0) { + tap(events, wheelDelta > 0 ? 4 : 5); + } + if ((buttonFlags & RI_MOUSE_HWHEEL) != 0 && wheelDelta != 0) { + tap(events, wheelDelta > 0 ? 7 : 6); + } + return events; + } + + private static void tap(List events, int button) { + events.add(new ButtonEvent(HotkeyKey.mouse(button), true)); + events.add(new ButtonEvent(HotkeyKey.mouse(button), false)); + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/Win32.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/Win32.java new file mode 100644 index 0000000..03d2c87 --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/Win32.java @@ -0,0 +1,210 @@ +package com.ts3client.hotkey.desktop; + +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.foreign.ValueLayout; +import java.lang.invoke.MethodHandle; +import java.nio.charset.StandardCharsets; + +/** + * Raw binding to {@code user32} and {@code kernel32}, with just the calls + * {@link WindowsInputHook} needs to own a message-only window and read raw input from it. + * + *

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 CACHE = new HashMap<>(); + + static synchronized String name(HotkeyKey key) { + if (key.device() != HotkeyKey.Device.KEYBOARD) return null; + if (!Win32.isAvailable()) return null; + return CACHE.computeIfAbsent(key.code(), code -> { + try (Arena arena = Arena.ofConfined()) { + int lParam = (code & 0xFF) << 16; + if ((code & 0xE000) == 0xE000) lParam |= 1 << 24; + String name = Win32.keyName(lParam, arena); + return (name == null || name.isBlank()) ? null : name; + } catch (Throwable t) { + return null; + } + }); + } +} diff --git a/ts3-client/desktop/src/test/java/com/ts3client/hotkey/desktop/RawInputTest.java b/ts3-client/desktop/src/test/java/com/ts3client/hotkey/desktop/RawInputTest.java new file mode 100644 index 0000000..0ed57e4 --- /dev/null +++ b/ts3-client/desktop/src/test/java/com/ts3client/hotkey/desktop/RawInputTest.java @@ -0,0 +1,92 @@ +package com.ts3client.hotkey.desktop; + +import com.ts3client.hotkey.HotkeyKey; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Decoding of Windows raw input, which needs no Windows to check. */ +class RawInputTest { + + @Test + void plainKeyKeepsItsScanCode() { + // 'A' is scan code 0x1E, no escape. + assertEquals(0x1E, RawInput.scanCode(0x1E, 0, 0x41)); + } + + @Test + void escapedKeysStayDistinctFromTheirUnescapedTwins() { + // Left Ctrl is 1D; right Ctrl is E0 1D, and the two must not collide. + int left = RawInput.scanCode(0x1D, 0, 0xA2); + int right = RawInput.scanCode(0x1D, RawInput.RI_KEY_E0, 0xA3); + assertEquals(0x1D, left); + assertEquals(0xE01D, right); + } + + @Test + void pauseUsesTheOtherEscape() { + assertEquals(0xE11D, RawInput.scanCode(0x1D, RawInput.RI_KEY_E1, 0x13)); + } + + @Test + void fillerHalfOfAnEscapedSequenceIsIgnored() { + assertEquals(-1, RawInput.scanCode(0x2A, RawInput.RI_KEY_E0, 0xFF)); + assertEquals(-1, RawInput.scanCode(0, 0, 0x41)); + } + + @Test + void breakFlagMarksTheRelease() { + assertFalse(RawInput.isRelease(0)); + assertTrue(RawInput.isRelease(RawInput.RI_KEY_BREAK)); + assertTrue(RawInput.isRelease(RawInput.RI_KEY_BREAK | RawInput.RI_KEY_E0)); + } + + @Test + void sideButtonsMapToTheNumbersUsersKnow() { + // X1 down: "Mouse 4" everywhere else in the client, X button 8. + List down = RawInput.buttons(0x0040, (short) 0); + assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(8), true)), down); + assertEquals("Mouse 4", HotkeyKey.mouse(8).fallbackName()); + + List up = RawInput.buttons(0x0200, (short) 0); + assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(9), false)), up); + assertEquals("Mouse 5", HotkeyKey.mouse(9).fallbackName()); + } + + @Test + void primaryButtonsUseTheXNumbering() { + assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(1), true)), + RawInput.buttons(0x0001, (short) 0)); + assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(2), true)), + RawInput.buttons(0x0010, (short) 0)); + assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(3), true)), + RawInput.buttons(0x0004, (short) 0)); + } + + @Test + void oneEventCanCarrySeveralTransitions() { + // Left up and right down in the same report. + assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(1), false), + new RawInput.ButtonEvent(HotkeyKey.mouse(3), true)), + RawInput.buttons(0x0002 | 0x0004, (short) 0)); + } + + @Test + void wheelBecomesATapSoTheEngineSeesBothEdges() { + assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(4), true), + new RawInput.ButtonEvent(HotkeyKey.mouse(4), false)), + RawInput.buttons(0x0400, (short) 120)); + assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(5), true), + new RawInput.ButtonEvent(HotkeyKey.mouse(5), false)), + RawInput.buttons(0x0400, (short) -120)); + } + + @Test + void mouseMovementAloneReportsNothing() { + assertEquals(List.of(), RawInput.buttons(0, (short) 0)); + } +}