From 047c89404c7a34c0af721e5bae919c342a20650c Mon Sep 17 00:00:00 2001 From: ericek111 Date: Mon, 17 Aug 2026 10:34:19 +0000 Subject: [PATCH] Prefer RECORD over XInput2 on X11: raw button releases can go missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On at least some driver stacks, XI_RawButtonRelease is never delivered to a client that hasn't grabbed the pointer, even though the matching XI_RawButtonPress arrives fine — a bound mouse button then looks stuck down forever, both when recording a hotkey and when using one. Taking an active XIGrabDevice grab does fix delivery, but even with owner_events set it blocks clicks from reaching every other window, so it's not usable. XRecordInputHook taps the same core events xev sees and isn't affected, so it now goes first on X11; XInput2 stays as the fallback for servers where RECORD is disabled or missing, and gets a correctness cleanup (real per-device event selection instead of the XIAllMasterDevices pseudo-device) along the way. Co-Authored-By: Claude Sonnet 5 --- .../hotkey/desktop/DesktopInputHooks.java | 10 +++- .../hotkey/desktop/XInput2InputHook.java | 37 ++++++++----- .../com/ts3client/hotkey/desktop/Xlib.java | 55 +++++++++++++++++-- 3 files changed, 80 insertions(+), 22 deletions(-) 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 3a802fb..9a42a88 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 @@ -54,10 +54,16 @@ public final class DesktopInputHooks { return hooks; } - /** XInput2 before RECORD: core input beats a debugging extension that may be absent. */ + /** + * RECORD before XInput2: on at least some driver stacks, XInput2's raw + * {@code XI_RawButtonRelease} never reaches a client that hasn't grabbed the pointer — + * see {@link XInput2InputHook} — which makes a bound mouse button look stuck down. + * RECORD taps the same core events {@code xev} sees and is not affected. XInput2 stays + * as the fallback for servers where RECORD is disabled or missing. + */ private static void addX11Hooks(List hooks) { - if (XInput2InputHook.isSupported()) hooks.add(new XInput2InputHook()); if (XRecordInputHook.isSupported()) hooks.add(new XRecordInputHook()); + if (XInput2InputHook.isSupported()) hooks.add(new XInput2InputHook()); } private static boolean isWayland() { diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/XInput2InputHook.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/XInput2InputHook.java index caa255d..6b5ae8c 100644 --- a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/XInput2InputHook.java +++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/XInput2InputHook.java @@ -13,14 +13,19 @@ import java.lang.foreign.ValueLayout; * every key and button on the machine arrives here regardless of which window has focus, * and the event still reaches that window untouched. * - *

Preferred over {@link XRecordInputHook}: XInput2 is core input rather than a - * debugging extension, so it is present wherever the X server is remotely modern, while - * RECORD is sometimes disabled or left out of the build. - * *

Raw events report the device's own view of the key, before focus routing and before * any layout mapping, which is precisely what a hotkey wants — the detail field is the * keycode, matching how {@link HotkeyKey} identifies keys. * + *

On at least some driver stacks, {@code XI_RawButtonRelease} is never delivered here + * at all: the X server hands the initiating {@code XI_RawButtonPress} to every raw + * listener, but the matching release only reaches whichever client holds the pointer's + * implicit per-click grab, which is never us. An active {@code XIGrabDevice} grab makes + * this hook that owner and does fix delivery, but even with {@code owner_events} set it + * stopped clicks from reaching other windows at all — worse than the bug it fixes — so it + * is not used here. {@link XRecordInputHook} is unaffected and takes over first on X11 as + * a result; this hook stays as its fallback for servers without RECORD. + * *

Under Wayland this hook lives inside Xwayland and therefore sees what Xwayland is * given: every X11 client, plus — on compositors that allow it, KWin's "legacy X11 app * support" among them — keys aimed at native Wayland windows too. Where the compositor @@ -66,7 +71,11 @@ public final class XInput2InputHook implements GlobalInputHook { throw new IllegalStateException("the X server does not speak XInput 2"); } - Xlib.selectInputEvents(display, Xlib.defaultRootWindow(display), rawEventMask()); + long root = Xlib.defaultRootWindow(display); + int maskLen = maskLen(); + MemorySegment bits = rawMaskBits(maskLen); + int[] masters = Xlib.queryMasterDeviceIds(display, arena); + Xlib.selectInputEvents(display, root, masters, bits, maskLen, arena); Xlib.sync(display); running = true; @@ -76,10 +85,13 @@ public final class XInput2InputHook implements GlobalInputHook { thread.start(); } - /** An {@code XIEventMask} asking every master device for the four raw event types. */ - private MemorySegment rawEventMask() { - int highest = Xlib.XI_RAW_BUTTON_RELEASE; - int maskLen = highest / 8 + 1; + private static int maskLen() { + // XIMaskLen(event): the byte length of a mask covering event types up to this one. + return (Xlib.XI_RAW_BUTTON_RELEASE >> 3) + 1; + } + + /** The four raw event types, as a byte mask in {@code XISetMask} order. */ + private MemorySegment rawMaskBits(int maskLen) { MemorySegment bits = arena.allocate(maskLen); for (int type : new int[]{Xlib.XI_RAW_KEY_PRESS, Xlib.XI_RAW_KEY_RELEASE, Xlib.XI_RAW_BUTTON_PRESS, Xlib.XI_RAW_BUTTON_RELEASE}) { @@ -88,12 +100,7 @@ public final class XInput2InputHook implements GlobalInputHook { bits.set(ValueLayout.JAVA_BYTE, index, (byte) (bits.get(ValueLayout.JAVA_BYTE, index) | bit)); } - - MemorySegment mask = arena.allocate(Xlib.EVENT_MASK_SIZE); - mask.set(ValueLayout.JAVA_INT, Xlib.EVENT_MASK_DEVICEID, Xlib.XI_ALL_MASTER_DEVICES); - mask.set(ValueLayout.JAVA_INT, Xlib.EVENT_MASK_LEN, maskLen); - mask.set(ValueLayout.ADDRESS, Xlib.EVENT_MASK_MASK, bits); - return mask; + return bits; } private void pump() { diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/Xlib.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/Xlib.java index 065557f..5137a13 100644 --- a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/Xlib.java +++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/Xlib.java @@ -15,8 +15,8 @@ import java.nio.charset.StandardCharsets; * *

Both extensions let a client watch input the server delivers to everyone else * without intercepting it, which is exactly what a global hotkey needs: the keystroke - * still reaches the focused application. XInput2 is the one to prefer — it is core input - * rather than a debugging extension, and it is what TeamSpeak itself uses. + * still reaches the focused application. See {@link DesktopInputHooks#addX11Hooks} for + * which one is preferred and why. * *

Each library loads lazily and failure is expected — with no X11 around, the * {@code is*Available()} probes return {@code false} and the caller falls back to @@ -89,6 +89,10 @@ final class Xlib { static final long EVENT_MASK_LEN = 4; static final long EVENT_MASK_MASK = 8; + /** {@code XIDeviceInfo.deviceid} — the only field {@link #queryMasterDeviceIds} needs. */ + static final long DEVICE_INFO_SIZE = 40; + static final long DEVICE_INFO_DEVICEID = 0; + private static final Linker LINKER = Linker.nativeLinker(); private static final String[] X11_NAMES = {"libX11.so.6", "libX11.so"}; private static final String[] XI_NAMES = {"libXi.so.6", "libXi.so"}; @@ -134,6 +138,10 @@ final class Xlib { downcall(LIB, "XIQueryVersion", FunctionDescriptor.of(INT, PTR, PTR, PTR)); static final MethodHandle XI_SELECT_EVENTS = downcall(LIB, "XISelectEvents", FunctionDescriptor.of(INT, PTR, LONG, PTR, INT)); + static final MethodHandle XI_QUERY_DEVICE = + downcall(LIB, "XIQueryDevice", FunctionDescriptor.of(PTR, PTR, INT, PTR)); + static final MethodHandle XI_FREE_DEVICE_INFO = + downcall(LIB, "XIFreeDeviceInfo", FunctionDescriptor.ofVoid(PTR)); } /** {@code poll(2)}, so the event loop can wait on the X socket and still be woken. */ @@ -292,9 +300,46 @@ final class Xlib { return (long) call(Core.X_DEFAULT_ROOT_WINDOW, display); } - /** Asks for the events in {@code mask} — an {@code XIEventMask} — on a window. */ - static void selectInputEvents(MemorySegment display, long window, MemorySegment mask) { - call(Input.XI_SELECT_EVENTS, display, window, mask, 1); + /** + * Asks for the events set in {@code bits} on {@code window}, once per device in + * {@code deviceIds}: selecting against {@code XIAllMasterDevices} itself does not + * reliably deliver every raw event type, so callers resolve it to real device ids first + * with {@link #queryMasterDeviceIds}. + */ + static void selectInputEvents(MemorySegment display, long window, int[] deviceIds, + MemorySegment bits, int maskLen, Arena arena) { + MemorySegment masks = arena.allocate(EVENT_MASK_SIZE * deviceIds.length); + for (int i = 0; i < deviceIds.length; i++) { + long base = i * EVENT_MASK_SIZE; + masks.set(INT, base + EVENT_MASK_DEVICEID, deviceIds[i]); + masks.set(INT, base + EVENT_MASK_LEN, maskLen); + masks.set(PTR, base + EVENT_MASK_MASK, bits); + } + call(Input.XI_SELECT_EVENTS, display, window, masks, deviceIds.length); + } + + /** + * The real device ids behind the {@code XIAllMasterDevices} pseudo-device — selecting + * raw events against each one individually, rather than the pseudo-device itself, is + * the more correct form (some drivers reportedly need it), even though it alone does + * not fix the {@code XI_RawButtonRelease} gap {@link XInput2InputHook} documents. + */ + static int[] queryMasterDeviceIds(MemorySegment display, Arena arena) { + MemorySegment count = arena.allocate(INT); + MemorySegment infos = (MemorySegment) call(Input.XI_QUERY_DEVICE, display, + XI_ALL_MASTER_DEVICES, count); + int n = count.get(INT, 0); + if (infos.equals(MemorySegment.NULL) || n <= 0) return new int[0]; + try { + MemorySegment array = infos.reinterpret(DEVICE_INFO_SIZE * n); + int[] ids = new int[n]; + for (int i = 0; i < n; i++) { + ids[i] = array.get(INT, i * DEVICE_INFO_SIZE + DEVICE_INFO_DEVICEID); + } + return ids; + } finally { + call(Input.XI_FREE_DEVICE_INFO, infos); + } } static int pending(MemorySegment display) {