Capture global hotkeys on Windows through raw input

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 18:40:50 +00:00
parent edc8a0ca3a
commit 752676e863
8 changed files with 720 additions and 10 deletions

View File

@@ -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. 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** - Push-to-talk is one of those hotkeys; the button in **Options → Voice Activation**
edits that binding. 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 privileges needed and the keystroke is not swallowed. X11's RECORD extension is kept
as a fallback for servers without XInput2. 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 - 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 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 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 ├── XInput2InputHook global key/button capture via XInput2 raw events
├── XRecordInputHook the same via X11's RECORD extension, as a fallback ├── XRecordInputHook the same via X11's RECORD extension, as a fallback
├── EvdevInputHook /dev/input fallback for Wayland sessions ├── 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 └── DesktopInputHooks picks the backend that suits the session
swing/ com.ts3client swing/ com.ts3client
@@ -244,4 +252,10 @@ swing/ com.ts3client
whisper and push-to-whisper, recording, plugins, server groups, talk power, 3D whisper and push-to-whisper, recording, plugins, server groups, talk power, 3D
sound, hardware ("local") microphone mute and the channel-traversal variants sound, hardware ("local") microphone mute and the channel-traversal variants
beyond "Switch to Channel". 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. - No file transfer, avatars, or server/channel administration UI yet.

View File

@@ -1,12 +1,19 @@
package com.ts3client.hotkey; package com.ts3client.hotkey;
/** /**
* One physical key or mouse button, identified the way the X server numbers them: * One physical key or mouse button. Keys are identified by their position rather than by
* keyboards by keycode (Linux evdev code + 8) and mice by button number, so a binding * the character they produce, so a binding is independent of the keyboard layout in force
* is independent of the keyboard layout in force when it was recorded. * when it was recorded.
*
* <p>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 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) { public record HotkeyKey(HotkeyKey.Device device, int code) {

View File

@@ -9,10 +9,11 @@ import java.util.List;
/** /**
* Picks the global input hook that suits the running session and starts it. * Picks the global input hook that suits the running session and starts it.
* *
* <p>On X11, RECORD is the polite choice: no extra privileges, no devices to open. On * <p>Windows has one answer, raw input. On X11 it is XInput2, with RECORD behind it for
* Wayland the X server sees only what is aimed at X clients, so reading evdev is the * servers that lack it: neither needs privileges or devices to open. On Wayland the X
* only way to catch every key — and when that is not permitted either, the returned * server sees only what the compositor forwards, so reading evdev is the surest way to
* hook is simply not running and says why. * catch every key — and when that is not permitted either, the returned hook is simply
* not running and says why.
*/ */
public final class DesktopInputHooks { public final class DesktopInputHooks {
@@ -35,6 +36,10 @@ public final class DesktopInputHooks {
private static List<GlobalInputHook> candidates() { private static List<GlobalInputHook> candidates() {
List<GlobalInputHook> hooks = new ArrayList<>(); List<GlobalInputHook> hooks = new ArrayList<>();
if (Win32.isWindows()) {
if (WindowsInputHook.isSupported()) hooks.add(new WindowsInputHook());
return hooks;
}
if (isWayland()) { if (isWayland()) {
// Evdev first: it is the only backend guaranteed to see keys aimed at native // 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 // Wayland windows. Failing that, Xwayland still covers every X11 app, and on
@@ -79,7 +84,9 @@ public final class DesktopInputHooks {
@Override @Override
public String keyName(HotkeyKey key) { 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 @Override

View File

@@ -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.
*
* <p>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<ButtonEvent> buttons(int buttonFlags, short wheelDelta) {
List<ButtonEvent> 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<ButtonEvent> events, int button) {
events.add(new ButtonEvent(HotkeyKey.mouse(button), true));
events.add(new ButtonEvent(HotkeyKey.mouse(button), false));
}
}

View File

@@ -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.
*
* <p>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.
*
* <p>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);
}
}
}

View File

@@ -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.
*
* <p>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.
*
* <p>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;
}
}

View File

@@ -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}.
*
* <p>{@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<Integer, String> 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;
}
});
}
}

View File

@@ -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<RawInput.ButtonEvent> 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<RawInput.ButtonEvent> 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));
}
}