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) {