diff --git a/ts3-client/README.md b/ts3-client/README.md index 98cf320..c89e455 100644 --- a/ts3-client/README.md +++ b/ts3-client/README.md @@ -94,10 +94,15 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged. 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** edits that binding. -- Capture uses X11's RECORD extension, which needs no privileges and does not swallow - the keystroke. On Wayland the X server never sees keys aimed at other applications, - so the client falls back to reading `/dev/input/event*` — for which your user has to - be in the `input` group. The Hotkeys tab says which backend is in use, or why none is. +- Capture uses **XInput2 raw events**, as TeamSpeak's own Linux client does: no + privileges needed and the keystroke is not swallowed. X11's RECORD extension is kept + as a fallback for servers without XInput2. +- 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 + that is *System Settings → Window Management → Legacy X11 App Support*, which is why + TeamSpeak's hotkeys work there. Where it forwards nothing, the client reads + `/dev/input/event*` instead, for which your user has to be in the `input` group. + The Hotkeys tab says which backend is in use, or why none is. ### Notification sounds - **Sound packs** in TeamSpeak's own format: a folder of waves plus a `settings.ini` @@ -198,7 +203,8 @@ desktop/ com.ts3client.audio.desktop + com.ts3client.hotkey.desktop ├── WavSoundPlayer sound-pack playback: decode, resample and mix on one line ├── JavaSoundAudioBackend wires the above into the core AudioBackend └── hotkey.desktop - ├── XRecordInputHook global key/button capture via X11's RECORD extension + ├── XInput2InputHook global key/button capture via XInput2 raw events + ├── XRecordInputHook the same via X11's RECORD extension, as a fallback ├── EvdevInputHook /dev/input fallback for Wayland sessions └── DesktopInputHooks picks the backend that suits the session 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 14a87cc..d3f6821 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 @@ -36,15 +36,25 @@ public final class DesktopInputHooks { private static List candidates() { List hooks = new ArrayList<>(); 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 + // compositors that forward the rest — KWin, with legacy X11 app support on — + // it covers those too. if (EvdevInputHook.isSupported()) hooks.add(new EvdevInputHook()); - if (XRecordInputHook.isSupported()) hooks.add(new XRecordInputHook()); + addX11Hooks(hooks); } else { - if (XRecordInputHook.isSupported()) hooks.add(new XRecordInputHook()); + addX11Hooks(hooks); if (EvdevInputHook.isSupported()) hooks.add(new EvdevInputHook()); } return hooks; } + /** XInput2 before RECORD: core input beats a debugging extension that may be absent. */ + private static void addX11Hooks(List hooks) { + if (XInput2InputHook.isSupported()) hooks.add(new XInput2InputHook()); + if (XRecordInputHook.isSupported()) hooks.add(new XRecordInputHook()); + } + private static boolean isWayland() { return System.getenv("WAYLAND_DISPLAY") != null || "wayland".equalsIgnoreCase(System.getenv("XDG_SESSION_TYPE")); 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 new file mode 100644 index 0000000..caa255d --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/XInput2InputHook.java @@ -0,0 +1,199 @@ +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; + +/** + * System-wide input hook built on XInput2 raw events, the way TeamSpeak's own Linux + * client does it: {@code XI_RawKeyPress} and friends are selected on the root window, so + * 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. + * + *

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 + * forwards nothing, {@link EvdevInputHook} is the way out. + */ +public final class XInput2InputHook implements GlobalInputHook { + + /** How long the event loop sleeps on the socket before rechecking {@link #running}. */ + private static final int POLL_TIMEOUT_MILLIS = 200; + + private Arena arena; + private MemorySegment display = MemorySegment.NULL; + private int opcode = -1; + private Thread thread; + private volatile Listener listener; + private volatile boolean running; + private volatile String unavailable = "not started"; + + public static boolean isSupported() { + return Xlib.isInputAvailable() && System.getenv("DISPLAY") != null; + } + + @Override + public void start(Listener listener) { + this.listener = listener; + try { + open(); + } catch (Throwable t) { + unavailable = describe(t); + closeQuietly(); + } + } + + private void open() { + arena = Arena.ofShared(); + display = Xlib.openDisplay(); + if (display.equals(MemorySegment.NULL)) { + throw new IllegalStateException("cannot open the X display"); + } + opcode = Xlib.queryExtension(display, "XInputExtension", arena); + if (opcode < 0) throw new IllegalStateException("the X server has no XInput extension"); + if (!Xlib.queryInputVersion(display, arena)) { + throw new IllegalStateException("the X server does not speak XInput 2"); + } + + Xlib.selectInputEvents(display, Xlib.defaultRootWindow(display), rawEventMask()); + Xlib.sync(display); + + running = true; + unavailable = ""; + thread = new Thread(this::pump, "ts3j-hotkeys-xinput2"); + thread.setDaemon(true); + 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; + 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}) { + long index = type / 8; + byte bit = (byte) (1 << (type % 8)); + 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; + } + + private void pump() { + try (Arena loop = Arena.ofConfined()) { + MemorySegment event = loop.allocate(Xlib.EVENT_SIZE); + MemorySegment pollFd = Xlib.pollFd(display, loop); + while (running) { + if (Xlib.pending(display) <= 0) { + Xlib.awaitEvent(pollFd, POLL_TIMEOUT_MILLIS); + continue; + } + Xlib.nextEvent(display, event); + handle(event); + } + } catch (Throwable t) { + unavailable = describe(t); + } finally { + running = false; + } + } + + /** Unpacks one event, which is ours only when it is a generic event from XInput2. */ + private void handle(MemorySegment event) { + if (event.get(ValueLayout.JAVA_INT, Xlib.COOKIE_TYPE) != Xlib.GENERIC_EVENT) return; + if (event.get(ValueLayout.JAVA_INT, Xlib.COOKIE_EXTENSION) != opcode) return; + if (!Xlib.getEventData(display, event)) return; + try { + int evtype = event.get(ValueLayout.JAVA_INT, Xlib.COOKIE_EVTYPE); + MemorySegment raw = event.get(ValueLayout.ADDRESS, Xlib.COOKIE_DATA); + if (raw.equals(MemorySegment.NULL)) return; + int detail = raw.reinterpret(Xlib.RAW_EVENT_SIZE) + .get(ValueLayout.JAVA_INT, Xlib.RAW_EVENT_DETAIL); + dispatch(evtype, detail); + } finally { + Xlib.freeEventData(display, event); + } + } + + private void dispatch(int evtype, int detail) { + Listener l = listener; + if (l == null) return; + switch (evtype) { + case Xlib.XI_RAW_KEY_PRESS -> l.onInput(HotkeyKey.keyboard(detail), true); + case Xlib.XI_RAW_KEY_RELEASE -> l.onInput(HotkeyKey.keyboard(detail), false); + case Xlib.XI_RAW_BUTTON_PRESS -> l.onInput(HotkeyKey.mouse(detail), true); + case Xlib.XI_RAW_BUTTON_RELEASE -> l.onInput(HotkeyKey.mouse(detail), false); + default -> { + } + } + } + + @Override + public boolean isRunning() { + return running; + } + + @Override + public String unavailableReason() { + return running ? "" : unavailable; + } + + @Override + public String keyName(HotkeyKey key) { + return X11KeyNamer.name(key); + } + + @Override + public void close() { + listener = null; + running = false; + try { + if (thread != null) thread.join(POLL_TIMEOUT_MILLIS * 5L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + closeQuietly(); + } + + private synchronized void closeQuietly() { + // The pump owns the display connection; closing it underneath would crash Xlib. + boolean pumpDone = thread == null || !thread.isAlive(); + if (pumpDone) { + try { + if (!display.equals(MemorySegment.NULL)) Xlib.closeDisplay(display); + } catch (Throwable ignored) { + } + display = MemorySegment.NULL; + if (arena != null) { + try { + arena.close(); + } catch (Throwable ignored) { + } + arena = null; + } + thread = null; + } + } + + private static String describe(Throwable t) { + String message = t.getMessage(); + return (message == null || message.isBlank()) ? t.getClass().getSimpleName() : message; + } +} 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 550c764..065557f 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 @@ -10,15 +10,17 @@ import java.lang.invoke.MethodHandle; import java.nio.charset.StandardCharsets; /** - * Raw binding to {@code libX11} and the RECORD extension in {@code libXtst}, with just - * the calls {@link XRecordInputHook} needs. + * Raw binding to {@code libX11}, the XInput2 extension in {@code libXi} and the RECORD + * extension in {@code libXtst}, with just the calls the X11 hooks need. * - *

RECORD lets a client watch the core input events the server delivers to everyone - * else without intercepting them, which is exactly what a global hotkey needs: the - * keystroke still reaches the focused application. + *

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. * - *

Loading is lazy and failure is expected — with no X11 around, {@link #isAvailable()} - * returns {@code false} and the caller falls back to another backend. + *

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 + * another backend. */ final class Xlib { @@ -53,8 +55,43 @@ final class Xlib { static final long INTERCEPT_DATA = 32; static final long INTERCEPT_DATA_LEN = 40; + /** {@code GenericEvent}, the envelope every extension event arrives in. */ + static final int GENERIC_EVENT = 35; + + /** XInput2 raw event types (XI2.h): the device's own view, before any focus routing. */ + static final int XI_RAW_KEY_PRESS = 13; + static final int XI_RAW_KEY_RELEASE = 14; + static final int XI_RAW_BUTTON_PRESS = 15; + static final int XI_RAW_BUTTON_RELEASE = 16; + + /** {@code XIAllMasterDevices}: raw events reach us through the master devices. */ + static final int XI_ALL_MASTER_DEVICES = 1; + + /** An {@code XEvent} is a union of 24 longs; a cookie is the widest member we read. */ + static final long EVENT_SIZE = 192; + + /** {@code XGenericEventCookie} field offsets under LP64. */ + static final long COOKIE_TYPE = 0; + static final long COOKIE_EXTENSION = 32; + static final long COOKIE_EVTYPE = 36; + static final long COOKIE_DATA = 48; + + /** + * {@code XIRawEvent.detail} — the keycode or button number — past the generic header, + * {@code time}, {@code deviceid} and {@code sourceid}. + */ + static final long RAW_EVENT_DETAIL = 56; + static final long RAW_EVENT_SIZE = 152; + + /** {@code XIEventMask}: {@code deviceid}, {@code mask_len}, then the mask pointer. */ + static final long EVENT_MASK_SIZE = 16; + static final long EVENT_MASK_DEVICEID = 0; + static final long EVENT_MASK_LEN = 4; + static final long EVENT_MASK_MASK = 8; + 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"}; private static final String[] XTST_NAMES = {"libXtst.so.6", "libXtst.so"}; /** libX11 alone: enough to open a display and name keys. */ @@ -73,8 +110,45 @@ final class Xlib { downcall(LIB, "XkbKeycodeToKeysym", FunctionDescriptor.of(LONG, PTR, INT, INT, INT)); static final MethodHandle X_KEYSYM_TO_STRING = downcall(LIB, "XKeysymToString", FunctionDescriptor.of(PTR, LONG)); + static final MethodHandle X_QUERY_EXTENSION = + downcall(LIB, "XQueryExtension", FunctionDescriptor.of(INT, PTR, PTR, PTR, PTR, PTR)); + static final MethodHandle X_DEFAULT_ROOT_WINDOW = + downcall(LIB, "XDefaultRootWindow", FunctionDescriptor.of(LONG, PTR)); + static final MethodHandle X_CONNECTION_NUMBER = + downcall(LIB, "XConnectionNumber", FunctionDescriptor.of(INT, PTR)); + static final MethodHandle X_PENDING = + downcall(LIB, "XPending", FunctionDescriptor.of(INT, PTR)); + static final MethodHandle X_NEXT_EVENT = + downcall(LIB, "XNextEvent", FunctionDescriptor.of(INT, PTR, PTR)); + static final MethodHandle X_GET_EVENT_DATA = + downcall(LIB, "XGetEventData", FunctionDescriptor.of(INT, PTR, PTR)); + static final MethodHandle X_FREE_EVENT_DATA = + downcall(LIB, "XFreeEventData", FunctionDescriptor.ofVoid(PTR, PTR)); } + /** XInput2, which ships separately in libXi. */ + private static final class Input { + static final SymbolLookup LIB = load(XI_NAMES, "libXi"); + + static final MethodHandle XI_QUERY_VERSION = + 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)); + } + + /** {@code poll(2)}, so the event loop can wait on the X socket and still be woken. */ + private static final class Poll { + static final MethodHandle POLL = LINKER.downcallHandle( + LINKER.defaultLookup().find("poll").orElseThrow(() -> + new UnsatisfiedLinkError("unresolved symbol poll")), + FunctionDescriptor.of(INT, PTR, LONG, INT)); + } + + /** {@code struct pollfd} is an int and two shorts, and {@code POLLIN} is bit 0. */ + private static final long POLLFD_SIZE = 8; + private static final long POLLFD_EVENTS = 4; + private static final short POLLIN = 1; + /** The RECORD extension, which ships separately in libXtst. */ private static final class Record { static final SymbolLookup LIB = load(XTST_NAMES, "libXtst"); @@ -136,6 +210,15 @@ final class Xlib { } } + /** Whether libX11 and libXi both loaded, which is what the XInput2 hook needs. */ + static boolean isInputAvailable() { + try { + return Core.LIB != null && Input.LIB != null && Poll.POLL != null; + } catch (Throwable t) { + return false; + } + } + static MemorySegment openDisplay() { return (MemorySegment) call(Core.X_OPEN_DISPLAY, MemorySegment.NULL); } @@ -185,6 +268,71 @@ final class Xlib { call(Record.FREE_DATA, data); } + /** @return the extension's major opcode, or -1 when the server does not have it */ + static int queryExtension(MemorySegment display, String name, Arena arena) { + MemorySegment opcode = arena.allocate(INT); + MemorySegment event = arena.allocate(INT); + MemorySegment error = arena.allocate(INT); + int found = (int) call(Core.X_QUERY_EXTENSION, display, arena.allocateFrom(name), + opcode, event, error); + return found != 0 ? opcode.get(INT, 0) : -1; + } + + /** @return whether the server speaks at least XInput 2.0 */ + static boolean queryInputVersion(MemorySegment display, Arena arena) { + MemorySegment major = arena.allocate(INT); + MemorySegment minor = arena.allocate(INT); + major.set(INT, 0, 2); + minor.set(INT, 0, 0); + // Success is 0; anything else means the server would not agree on 2.x. + return (int) call(Input.XI_QUERY_VERSION, display, major, minor) == 0; + } + + static long defaultRootWindow(MemorySegment display) { + 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); + } + + static int pending(MemorySegment display) { + return (int) call(Core.X_PENDING, display); + } + + static void nextEvent(MemorySegment display, MemorySegment event) { + call(Core.X_NEXT_EVENT, display, event); + } + + /** Fetches a generic event's payload; the caller must free it when this succeeds. */ + static boolean getEventData(MemorySegment display, MemorySegment cookie) { + return (int) call(Core.X_GET_EVENT_DATA, display, cookie) != 0; + } + + static void freeEventData(MemorySegment display, MemorySegment cookie) { + call(Core.X_FREE_EVENT_DATA, display, cookie); + } + + /** + * A {@code struct pollfd} watching the display connection, to be allocated once and + * handed to {@link #awaitEvent} for the life of an event loop. + */ + static MemorySegment pollFd(MemorySegment display, Arena arena) { + MemorySegment fds = arena.allocate(POLLFD_SIZE); + fds.set(INT, 0, (int) call(Core.X_CONNECTION_NUMBER, display)); + fds.set(ValueLayout.JAVA_SHORT, POLLFD_EVENTS, POLLIN); + return fds; + } + + /** + * Waits until the connection has something to read or the timeout runs out, so an + * event loop can stay asleep yet still notice it has been asked to stop. + */ + static void awaitEvent(MemorySegment pollFd, int timeoutMillis) { + call(Poll.POLL, pollFd, 1L, timeoutMillis); + } + /** The unshifted keysym of a keycode in the first group, or 0 when unbound. */ static long keysym(MemorySegment display, int keycode) { return (long) call(Core.XKB_KEYCODE_TO_KEYSYM, display, keycode, 0, 0);