Capture hotkeys through XInput2, as TeamSpeak does
The bundled TS3 Linux binary dlopens libX11 and libXi and drives XIQueryVersion/XISelectEvents/XGetEventData — XInput2 raw events, with no trace of the RECORD extension anywhere in its tree. That is the better choice for us too: XInput2 is core input, present on any remotely modern server, where RECORD is a debugging extension that is sometimes disabled or left out of the build. Both are passive, so the keystroke still reaches the focused window either way. XRecordInputHook stays as the fallback behind it. Raw events also explain why TS3's hotkeys work under KWin's Wayland session despite it using no Wayland API at all: it runs on Xwayland, and KWin's legacy X11 app support forwards the keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,15 +36,25 @@ public final class DesktopInputHooks {
|
||||
private static List<GlobalInputHook> candidates() {
|
||||
List<GlobalInputHook> 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<GlobalInputHook> 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"));
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
* <p>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.
|
||||
*
|
||||
* <p>Loading is lazy and failure is expected — with no X11 around, {@link #isAvailable()}
|
||||
* returns {@code false} and the caller falls back to another backend.
|
||||
* <p>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);
|
||||
|
||||
Reference in New Issue
Block a user