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:
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user