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

@@ -9,10 +9,11 @@ import java.util.List;
/**
* 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
* 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.
* <p>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<GlobalInputHook> candidates() {
List<GlobalInputHook> 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

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));
}
}