Global hotkeys, in TeamSpeak's own shape
Bindings are captured system-wide rather than only while the window has focus: X11's RECORD extension where the X server sees every key, and a /dev/input reader as the Wayland fallback. Any key can act as a modifier, mouse buttons included, as TS3 allows. The action catalogue, its three categories and the "advanced actions" split are reverse-engineered from the original client; actions this client cannot perform are listed but greyed out. Push-to-talk becomes one of these hotkeys, so the old focus-bound pushToTalkKey setting is gone and the Voice Activation button edits that binding instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package com.ts3client.hotkey.desktop;
|
||||
|
||||
import com.ts3client.hotkey.GlobalInputHook;
|
||||
import com.ts3client.hotkey.HotkeyKey;
|
||||
|
||||
import java.util.ArrayList;
|
||||
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.
|
||||
*/
|
||||
public final class DesktopInputHooks {
|
||||
|
||||
private DesktopInputHooks() {
|
||||
}
|
||||
|
||||
/** @return a hook, started when it could be; check {@link GlobalInputHook#isRunning()} */
|
||||
public static GlobalInputHook start(GlobalInputHook.Listener listener) {
|
||||
List<String> reasons = new ArrayList<>();
|
||||
for (GlobalInputHook hook : candidates()) {
|
||||
hook.start(listener);
|
||||
if (hook.isRunning()) return hook;
|
||||
reasons.add(hook.getClass().getSimpleName() + ": " + hook.unavailableReason());
|
||||
hook.close();
|
||||
}
|
||||
return new Unavailable(reasons.isEmpty()
|
||||
? "no global input backend for this session"
|
||||
: String.join("; ", reasons));
|
||||
}
|
||||
|
||||
private static List<GlobalInputHook> candidates() {
|
||||
List<GlobalInputHook> hooks = new ArrayList<>();
|
||||
if (isWayland()) {
|
||||
if (EvdevInputHook.isSupported()) hooks.add(new EvdevInputHook());
|
||||
if (XRecordInputHook.isSupported()) hooks.add(new XRecordInputHook());
|
||||
} else {
|
||||
if (XRecordInputHook.isSupported()) hooks.add(new XRecordInputHook());
|
||||
if (EvdevInputHook.isSupported()) hooks.add(new EvdevInputHook());
|
||||
}
|
||||
return hooks;
|
||||
}
|
||||
|
||||
private static boolean isWayland() {
|
||||
return System.getenv("WAYLAND_DISPLAY") != null
|
||||
|| "wayland".equalsIgnoreCase(System.getenv("XDG_SESSION_TYPE"));
|
||||
}
|
||||
|
||||
/** Stands in when no backend could start, so callers need no null checks. */
|
||||
private record Unavailable(String reason) implements GlobalInputHook {
|
||||
|
||||
@Override
|
||||
public void start(Listener listener) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String unavailableReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String keyName(HotkeyKey key) {
|
||||
return X11KeyNamer.name(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.ts3client.hotkey.desktop;
|
||||
|
||||
import com.ts3client.hotkey.GlobalInputHook;
|
||||
import com.ts3client.hotkey.HotkeyKey;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* System-wide input hook that reads the kernel's evdev devices directly, for sessions
|
||||
* where {@link XRecordInputHook} cannot see everything — Wayland above all, where the X
|
||||
* server is only told about events aimed at X clients.
|
||||
*
|
||||
* <p>It needs read access to {@code /dev/input/event*}, which normally means membership
|
||||
* of the {@code input} group; without it the hook reports itself unavailable and the
|
||||
* client carries on without global hotkeys.
|
||||
*
|
||||
* <p>Key codes are reported as X keycodes (the kernel code plus 8) and buttons as X
|
||||
* button numbers, so bindings mean the same thing whichever backend recorded them.
|
||||
*/
|
||||
public final class EvdevInputHook implements GlobalInputHook {
|
||||
|
||||
private static final File INPUT_DIR = new File("/dev/input");
|
||||
/** {@code struct input_event} on 64-bit Linux: two 8-byte time fields, then type/code/value. */
|
||||
private static final int EVENT_SIZE = 24;
|
||||
private static final int EV_KEY = 1;
|
||||
/** Kernel key codes below this are keyboard keys; from here up they are BTN_* buttons. */
|
||||
private static final int BTN_MISC = 0x100;
|
||||
private static final int BTN_LEFT = 0x110;
|
||||
|
||||
private final List<InputStream> streams = new ArrayList<>();
|
||||
private final List<Thread> threads = new ArrayList<>();
|
||||
private volatile Listener listener;
|
||||
private volatile boolean running;
|
||||
private volatile String unavailable = "not started";
|
||||
|
||||
public static boolean isSupported() {
|
||||
File[] devices = INPUT_DIR.listFiles((dir, name) -> name.startsWith("event"));
|
||||
if (devices == null) return false;
|
||||
for (File device : devices) {
|
||||
if (device.canRead()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start(Listener listener) {
|
||||
this.listener = listener;
|
||||
File[] devices = INPUT_DIR.listFiles((dir, name) -> name.startsWith("event"));
|
||||
if (devices == null || devices.length == 0) {
|
||||
unavailable = "no /dev/input devices";
|
||||
return;
|
||||
}
|
||||
for (File device : devices) {
|
||||
try {
|
||||
InputStream in = new FileInputStream(device);
|
||||
streams.add(in);
|
||||
Thread t = new Thread(() -> read(in), "ts3j-hotkeys-evdev-" + device.getName());
|
||||
t.setDaemon(true);
|
||||
threads.add(t);
|
||||
} catch (Exception ignored) {
|
||||
// Devices we may not read are simply skipped.
|
||||
}
|
||||
}
|
||||
if (streams.isEmpty()) {
|
||||
unavailable = "no readable /dev/input device — add your user to the \"input\" group";
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
unavailable = "";
|
||||
for (Thread t : threads) t.start();
|
||||
}
|
||||
|
||||
private void read(InputStream in) {
|
||||
byte[] buffer = new byte[EVENT_SIZE * 16];
|
||||
ByteBuffer view = ByteBuffer.wrap(buffer).order(ByteOrder.nativeOrder());
|
||||
while (running) {
|
||||
try {
|
||||
int read = in.read(buffer);
|
||||
if (read < 0) return;
|
||||
for (int offset = 0; offset + EVENT_SIZE <= read; offset += EVENT_SIZE) {
|
||||
int type = view.getShort(offset + 16) & 0xffff;
|
||||
int code = view.getShort(offset + 18) & 0xffff;
|
||||
int value = view.getInt(offset + 20);
|
||||
// 2 is auto-repeat, which must not look like a fresh press.
|
||||
if (type == EV_KEY && value != 2) dispatch(code, value == 1);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatch(int code, boolean pressed) {
|
||||
Listener l = listener;
|
||||
if (l == null) return;
|
||||
HotkeyKey key = code < BTN_MISC ? HotkeyKey.keyboard(code + 8) : mouse(code);
|
||||
if (key != null) l.onInput(key, pressed);
|
||||
}
|
||||
|
||||
/** Maps the kernel's BTN_* codes onto the X button numbering. */
|
||||
private static HotkeyKey mouse(int code) {
|
||||
return switch (code) {
|
||||
case BTN_LEFT -> HotkeyKey.mouse(1);
|
||||
case BTN_LEFT + 1 -> HotkeyKey.mouse(3); // BTN_RIGHT
|
||||
case BTN_LEFT + 2 -> HotkeyKey.mouse(2); // BTN_MIDDLE
|
||||
case BTN_LEFT + 3 -> HotkeyKey.mouse(8); // BTN_SIDE, "mouse 4"
|
||||
case BTN_LEFT + 4 -> HotkeyKey.mouse(9); // BTN_EXTRA, "mouse 5"
|
||||
case BTN_LEFT + 5 -> HotkeyKey.mouse(10); // BTN_FORWARD
|
||||
case BTN_LEFT + 6 -> HotkeyKey.mouse(11); // BTN_BACK
|
||||
case BTN_LEFT + 7 -> HotkeyKey.mouse(12); // BTN_TASK
|
||||
default -> null; // joysticks, lid switches, …
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String unavailableReason() {
|
||||
return running ? "" : unavailable;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String keyName(HotkeyKey key) {
|
||||
// The kernel knows scancodes, not layouts; let X name the key when it can.
|
||||
return X11KeyNamer.name(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
running = false;
|
||||
listener = null;
|
||||
// Closing the descriptor is what breaks the readers out of their blocking read.
|
||||
for (InputStream in : streams) {
|
||||
try {
|
||||
in.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
streams.clear();
|
||||
threads.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ts3client.hotkey.desktop;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** Turns X keysym names into the labels users expect on a hotkey button. */
|
||||
final class KeyNames {
|
||||
|
||||
private KeyNames() {
|
||||
}
|
||||
|
||||
private static final Map<String, String> SPECIAL = Map.ofEntries(
|
||||
Map.entry("Control_L", "Left Ctrl"),
|
||||
Map.entry("Control_R", "Right Ctrl"),
|
||||
Map.entry("Shift_L", "Left Shift"),
|
||||
Map.entry("Shift_R", "Right Shift"),
|
||||
Map.entry("Alt_L", "Left Alt"),
|
||||
Map.entry("Alt_R", "Right Alt"),
|
||||
Map.entry("ISO_Level3_Shift", "AltGr"),
|
||||
Map.entry("Super_L", "Left Super"),
|
||||
Map.entry("Super_R", "Right Super"),
|
||||
Map.entry("Meta_L", "Left Meta"),
|
||||
Map.entry("Meta_R", "Right Meta"),
|
||||
Map.entry("Prior", "Page Up"),
|
||||
Map.entry("Next", "Page Down"),
|
||||
Map.entry("Return", "Enter"),
|
||||
Map.entry("space", "Space"),
|
||||
Map.entry("BackSpace", "Backspace"),
|
||||
Map.entry("Escape", "Esc"),
|
||||
Map.entry("Caps_Lock", "Caps Lock"),
|
||||
Map.entry("Num_Lock", "Num Lock"),
|
||||
Map.entry("Scroll_Lock", "Scroll Lock"),
|
||||
Map.entry("Menu", "Menu"),
|
||||
Map.entry("Print", "Print Screen"));
|
||||
|
||||
/**
|
||||
* @param keysym the X name of the keysym, e.g. {@code a}, {@code Control_L}, {@code KP_Add}
|
||||
* @return a display name, or {@code null} when there is nothing sensible to show
|
||||
*/
|
||||
static String pretty(String keysym) {
|
||||
if (keysym == null || keysym.isBlank()) return null;
|
||||
String special = SPECIAL.get(keysym);
|
||||
if (special != null) return special;
|
||||
if (keysym.startsWith("KP_")) return "Numpad " + pretty(keysym.substring(3));
|
||||
if (keysym.length() == 1) return keysym.toUpperCase();
|
||||
return keysym.replace('_', ' ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ts3client.hotkey.desktop;
|
||||
|
||||
import com.ts3client.hotkey.HotkeyKey;
|
||||
|
||||
import java.lang.foreign.MemorySegment;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Names keycodes using the X keyboard layout, so a hotkey button shows "A" rather than
|
||||
* "Key 38". Bindings themselves stay layout-independent; only the label comes from here.
|
||||
*
|
||||
* <p>Keeps one long-lived display connection of its own, opened on first use, so it can
|
||||
* be asked from any thread without racing the input hooks' own connections.
|
||||
*/
|
||||
final class X11KeyNamer {
|
||||
|
||||
private X11KeyNamer() {
|
||||
}
|
||||
|
||||
private static final Map<Integer, String> CACHE = new HashMap<>();
|
||||
private static MemorySegment display;
|
||||
private static boolean tried;
|
||||
|
||||
static synchronized String name(HotkeyKey key) {
|
||||
if (key.device() != HotkeyKey.Device.KEYBOARD) return null;
|
||||
if (!tried) {
|
||||
tried = true;
|
||||
try {
|
||||
if (Xlib.isCoreAvailable() && System.getenv("DISPLAY") != null) {
|
||||
display = Xlib.openDisplay();
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
display = null;
|
||||
}
|
||||
}
|
||||
if (display == null || display.equals(MemorySegment.NULL)) return null;
|
||||
return CACHE.computeIfAbsent(key.code(), code -> {
|
||||
try {
|
||||
long sym = Xlib.keysym(display, code);
|
||||
return sym == 0 ? null : KeyNames.pretty(Xlib.keysymName(sym));
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* System-wide input hook built on X11's RECORD extension: it taps the core key and
|
||||
* button events the server delivers to every client, so hotkeys work no matter which
|
||||
* window has focus, and the keystroke still reaches that window untouched.
|
||||
*
|
||||
* <p>Two connections are needed, as RECORD demands: a control one that creates and
|
||||
* later tears the context down, and a data one that blocks inside
|
||||
* {@code XRecordEnableContext} handing us events.
|
||||
*
|
||||
* <p>Under Wayland the X server only ever sees events aimed at X clients, so this hook
|
||||
* is not truly global there; {@link EvdevInputHook} is the way out.
|
||||
*/
|
||||
public final class XRecordInputHook implements GlobalInputHook {
|
||||
|
||||
private static final MethodHandle CALLBACK;
|
||||
|
||||
static {
|
||||
try {
|
||||
CALLBACK = MethodHandles.lookup().findStatic(XRecordInputHook.class, "onRecorded",
|
||||
MethodType.methodType(void.class, MemorySegment.class, MemorySegment.class));
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new ExceptionInInitializerError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** The running hook, for the static upcall to find its way back. */
|
||||
private static volatile XRecordInputHook current;
|
||||
|
||||
private Arena arena;
|
||||
private MemorySegment control = MemorySegment.NULL;
|
||||
private MemorySegment data = MemorySegment.NULL;
|
||||
private long context;
|
||||
private Thread thread;
|
||||
private volatile Listener listener;
|
||||
private volatile boolean running;
|
||||
private volatile String unavailable = "not started";
|
||||
|
||||
public static boolean isSupported() {
|
||||
return Xlib.isAvailable() && 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();
|
||||
control = Xlib.openDisplay();
|
||||
if (control.equals(MemorySegment.NULL)) {
|
||||
throw new IllegalStateException("cannot open the X display");
|
||||
}
|
||||
if (!Xlib.queryRecordVersion(control, arena)) {
|
||||
throw new IllegalStateException("the X server has no RECORD extension");
|
||||
}
|
||||
|
||||
MemorySegment range = Xlib.allocRange();
|
||||
if (range.equals(MemorySegment.NULL)) throw new IllegalStateException("XRecordAllocRange failed");
|
||||
MemorySegment r = range.reinterpret(64);
|
||||
r.set(ValueLayout.JAVA_BYTE, Xlib.RANGE_DEVICE_EVENTS_FIRST, (byte) Xlib.KEY_PRESS);
|
||||
r.set(ValueLayout.JAVA_BYTE, Xlib.RANGE_DEVICE_EVENTS_LAST, (byte) Xlib.BUTTON_RELEASE);
|
||||
|
||||
MemorySegment clients = arena.allocate(ValueLayout.JAVA_LONG);
|
||||
clients.set(ValueLayout.JAVA_LONG, 0, Xlib.ALL_CLIENTS);
|
||||
MemorySegment ranges = arena.allocate(ValueLayout.ADDRESS);
|
||||
ranges.set(ValueLayout.ADDRESS, 0, range);
|
||||
|
||||
context = Xlib.createContext(control, clients, 1, ranges, 1);
|
||||
if (context == 0) throw new IllegalStateException("XRecordCreateContext failed");
|
||||
// The context id is allocated client-side and the request is not a round trip:
|
||||
// without this the second connection can enable a context the server has yet to
|
||||
// create, which it answers with BadContext.
|
||||
Xlib.sync(control);
|
||||
|
||||
data = Xlib.openDisplay();
|
||||
if (data.equals(MemorySegment.NULL)) {
|
||||
throw new IllegalStateException("cannot open the second X display connection");
|
||||
}
|
||||
|
||||
MemorySegment stub = java.lang.foreign.Linker.nativeLinker()
|
||||
.upcallStub(CALLBACK, Xlib.INTERCEPT_PROC, arena);
|
||||
|
||||
current = this;
|
||||
running = true;
|
||||
unavailable = "";
|
||||
thread = new Thread(() -> {
|
||||
try {
|
||||
// Blocks until close() disables the context from the control connection.
|
||||
Xlib.enableContext(data, context, stub);
|
||||
} catch (Throwable ignored) {
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}, "ts3j-hotkeys-xrecord");
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
/** Upcall target: one recorded protocol datum. */
|
||||
@SuppressWarnings("unused")
|
||||
private static void onRecorded(MemorySegment closure, MemorySegment recorded) {
|
||||
XRecordInputHook hook = current;
|
||||
MemorySegment d = recorded.reinterpret(48);
|
||||
try {
|
||||
if (hook == null || d.get(ValueLayout.JAVA_INT, Xlib.INTERCEPT_CATEGORY) != Xlib.FROM_SERVER) {
|
||||
return;
|
||||
}
|
||||
long length = d.get(ValueLayout.JAVA_LONG, Xlib.INTERCEPT_DATA_LEN);
|
||||
MemorySegment event = d.get(ValueLayout.ADDRESS, Xlib.INTERCEPT_DATA);
|
||||
// data_len counts 4-byte units; a core event is always 32 bytes.
|
||||
if (event.equals(MemorySegment.NULL) || length < 2) return;
|
||||
event = event.reinterpret(32);
|
||||
hook.dispatch(event.get(ValueLayout.JAVA_BYTE, 0) & 0x7f,
|
||||
event.get(ValueLayout.JAVA_BYTE, 1) & 0xff);
|
||||
} catch (Throwable ignored) {
|
||||
} finally {
|
||||
Xlib.freeData(recorded);
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatch(int type, int detail) {
|
||||
Listener l = listener;
|
||||
if (l == null) return;
|
||||
switch (type) {
|
||||
case Xlib.KEY_PRESS -> l.onInput(HotkeyKey.keyboard(detail), true);
|
||||
case Xlib.KEY_RELEASE -> l.onInput(HotkeyKey.keyboard(detail), false);
|
||||
case Xlib.BUTTON_PRESS -> l.onInput(HotkeyKey.mouse(detail), true);
|
||||
case Xlib.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 (!control.equals(MemorySegment.NULL) && context != 0) {
|
||||
Xlib.disableContext(control, context);
|
||||
Xlib.flush(control);
|
||||
}
|
||||
if (thread != null) thread.join(1000);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
closeQuietly();
|
||||
}
|
||||
|
||||
private synchronized void closeQuietly() {
|
||||
if (current == this) current = null;
|
||||
try {
|
||||
if (!control.equals(MemorySegment.NULL) && context != 0) Xlib.freeContext(control, context);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
context = 0;
|
||||
for (MemorySegment display : new MemorySegment[]{data, control}) {
|
||||
try {
|
||||
if (!display.equals(MemorySegment.NULL)) Xlib.closeDisplay(display);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
data = control = MemorySegment.NULL;
|
||||
// The arena owns the upcall stub: freeing it while the recording thread could
|
||||
// still call into it would take the JVM down, so a stuck thread keeps it alive.
|
||||
if (arena != null && (thread == null || !thread.isAlive())) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
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 libX11} and the RECORD extension in {@code libXtst}, with just
|
||||
* the calls {@link XRecordInputHook} needs.
|
||||
*
|
||||
* <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>Loading is lazy and failure is expected — with no X11 around, {@link #isAvailable()}
|
||||
* returns {@code false} and the caller falls back to another backend.
|
||||
*/
|
||||
final class Xlib {
|
||||
|
||||
private Xlib() {
|
||||
}
|
||||
|
||||
static final ValueLayout.OfInt INT = ValueLayout.JAVA_INT;
|
||||
static final ValueLayout.OfLong LONG = ValueLayout.JAVA_LONG;
|
||||
private static final java.lang.foreign.AddressLayout PTR = ValueLayout.ADDRESS;
|
||||
|
||||
/** Core event types (X.h) as they appear in the recorded protocol data. */
|
||||
static final int KEY_PRESS = 2;
|
||||
static final int KEY_RELEASE = 3;
|
||||
static final int BUTTON_PRESS = 4;
|
||||
static final int BUTTON_RELEASE = 5;
|
||||
|
||||
/** {@code XRecordAllClients}: record every client, present and future. */
|
||||
static final long ALL_CLIENTS = 3L;
|
||||
|
||||
/** {@code XRecordInterceptData.category} values we care about. */
|
||||
static final int FROM_SERVER = 0;
|
||||
|
||||
/**
|
||||
* {@code XRecordRange}: only {@code device_events} is filled in. Its two bytes sit
|
||||
* after the request/reply and delivered-event ranges, at a fixed LP64 offset.
|
||||
*/
|
||||
static final long RANGE_DEVICE_EVENTS_FIRST = 18;
|
||||
static final long RANGE_DEVICE_EVENTS_LAST = 19;
|
||||
|
||||
/** {@code XRecordInterceptData}: the protocol bytes and the reason we were called. */
|
||||
static final long INTERCEPT_CATEGORY = 24;
|
||||
static final long INTERCEPT_DATA = 32;
|
||||
static final long INTERCEPT_DATA_LEN = 40;
|
||||
|
||||
private static final Linker LINKER = Linker.nativeLinker();
|
||||
private static final String[] X11_NAMES = {"libX11.so.6", "libX11.so"};
|
||||
private static final String[] XTST_NAMES = {"libXtst.so.6", "libXtst.so"};
|
||||
|
||||
/** libX11 alone: enough to open a display and name keys. */
|
||||
private static final class Core {
|
||||
static final SymbolLookup LIB = load(X11_NAMES, "libX11");
|
||||
|
||||
static final MethodHandle X_OPEN_DISPLAY =
|
||||
downcall(LIB, "XOpenDisplay", FunctionDescriptor.of(PTR, PTR));
|
||||
static final MethodHandle X_CLOSE_DISPLAY =
|
||||
downcall(LIB, "XCloseDisplay", FunctionDescriptor.of(INT, PTR));
|
||||
static final MethodHandle X_FLUSH =
|
||||
downcall(LIB, "XFlush", FunctionDescriptor.of(INT, PTR));
|
||||
static final MethodHandle X_SYNC =
|
||||
downcall(LIB, "XSync", FunctionDescriptor.of(INT, PTR, INT));
|
||||
static final MethodHandle XKB_KEYCODE_TO_KEYSYM =
|
||||
downcall(LIB, "XkbKeycodeToKeysym", FunctionDescriptor.of(LONG, PTR, INT, INT, INT));
|
||||
static final MethodHandle X_KEYSYM_TO_STRING =
|
||||
downcall(LIB, "XKeysymToString", FunctionDescriptor.of(PTR, LONG));
|
||||
}
|
||||
|
||||
/** The RECORD extension, which ships separately in libXtst. */
|
||||
private static final class Record {
|
||||
static final SymbolLookup LIB = load(XTST_NAMES, "libXtst");
|
||||
|
||||
static final MethodHandle QUERY_VERSION =
|
||||
downcall(LIB, "XRecordQueryVersion", FunctionDescriptor.of(INT, PTR, PTR, PTR));
|
||||
static final MethodHandle ALLOC_RANGE =
|
||||
downcall(LIB, "XRecordAllocRange", FunctionDescriptor.of(PTR));
|
||||
static final MethodHandle CREATE_CONTEXT =
|
||||
downcall(LIB, "XRecordCreateContext",
|
||||
FunctionDescriptor.of(LONG, PTR, INT, PTR, INT, PTR, INT));
|
||||
static final MethodHandle ENABLE_CONTEXT =
|
||||
downcall(LIB, "XRecordEnableContext", FunctionDescriptor.of(INT, PTR, LONG, PTR, PTR));
|
||||
static final MethodHandle DISABLE_CONTEXT =
|
||||
downcall(LIB, "XRecordDisableContext", FunctionDescriptor.of(INT, PTR, LONG));
|
||||
static final MethodHandle FREE_CONTEXT =
|
||||
downcall(LIB, "XRecordFreeContext", FunctionDescriptor.of(INT, PTR, LONG));
|
||||
static final MethodHandle FREE_DATA =
|
||||
downcall(LIB, "XRecordFreeData", FunctionDescriptor.ofVoid(PTR));
|
||||
}
|
||||
|
||||
private static SymbolLookup load(String[] names, String what) {
|
||||
IllegalArgumentException last = null;
|
||||
for (String name : names) {
|
||||
try {
|
||||
return SymbolLookup.libraryLookup(name, Arena.global());
|
||||
} catch (IllegalArgumentException e) {
|
||||
last = e;
|
||||
}
|
||||
}
|
||||
throw (last != null) ? last : new IllegalArgumentException(what + " not found");
|
||||
}
|
||||
|
||||
private static MethodHandle downcall(SymbolLookup lookup, String symbol,
|
||||
FunctionDescriptor descriptor) {
|
||||
return LINKER.downcallHandle(
|
||||
lookup.find(symbol).orElseThrow(() ->
|
||||
new UnsatisfiedLinkError("unresolved symbol " + symbol)),
|
||||
descriptor);
|
||||
}
|
||||
|
||||
/** The C signature of {@code XRecordInterceptProc}. */
|
||||
static final FunctionDescriptor INTERCEPT_PROC = FunctionDescriptor.ofVoid(PTR, PTR);
|
||||
|
||||
/** Whether libX11 loaded, which is all that naming keys needs. */
|
||||
static boolean isCoreAvailable() {
|
||||
try {
|
||||
return Core.LIB != null;
|
||||
} catch (Throwable t) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isAvailable() {
|
||||
try {
|
||||
return Core.LIB != null && Record.LIB != null;
|
||||
} catch (Throwable t) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static MemorySegment openDisplay() {
|
||||
return (MemorySegment) call(Core.X_OPEN_DISPLAY, MemorySegment.NULL);
|
||||
}
|
||||
|
||||
static void closeDisplay(MemorySegment display) {
|
||||
call(Core.X_CLOSE_DISPLAY, display);
|
||||
}
|
||||
|
||||
static void flush(MemorySegment display) {
|
||||
call(Core.X_FLUSH, display);
|
||||
}
|
||||
|
||||
/** Flushes and waits for the server to have processed everything sent so far. */
|
||||
static void sync(MemorySegment display) {
|
||||
call(Core.X_SYNC, display, 0);
|
||||
}
|
||||
|
||||
/** @return whether the server has the RECORD extension */
|
||||
static boolean queryRecordVersion(MemorySegment display, Arena arena) {
|
||||
MemorySegment major = arena.allocate(INT);
|
||||
MemorySegment minor = arena.allocate(INT);
|
||||
return (int) call(Record.QUERY_VERSION, display, major, minor) != 0;
|
||||
}
|
||||
|
||||
static MemorySegment allocRange() {
|
||||
return (MemorySegment) call(Record.ALLOC_RANGE);
|
||||
}
|
||||
|
||||
static long createContext(MemorySegment display, MemorySegment clients, int clientCount,
|
||||
MemorySegment ranges, int rangeCount) {
|
||||
return (long) call(Record.CREATE_CONTEXT, display, 0, clients, clientCount, ranges, rangeCount);
|
||||
}
|
||||
|
||||
static int enableContext(MemorySegment display, long context, MemorySegment callback) {
|
||||
return (int) call(Record.ENABLE_CONTEXT, display, context, callback, MemorySegment.NULL);
|
||||
}
|
||||
|
||||
static void disableContext(MemorySegment display, long context) {
|
||||
call(Record.DISABLE_CONTEXT, display, context);
|
||||
}
|
||||
|
||||
static void freeContext(MemorySegment display, long context) {
|
||||
call(Record.FREE_CONTEXT, display, context);
|
||||
}
|
||||
|
||||
static void freeData(MemorySegment data) {
|
||||
call(Record.FREE_DATA, data);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** The keysym's X name ("a", "Control_L", "F5"), or {@code null}. */
|
||||
static String keysymName(long keysym) {
|
||||
MemorySegment name = (MemorySegment) call(Core.X_KEYSYM_TO_STRING, keysym);
|
||||
if (name == null || name.equals(MemorySegment.NULL)) return null;
|
||||
return name.reinterpret(Long.MAX_VALUE).getString(0, StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private static Object call(MethodHandle handle, Object... args) {
|
||||
try {
|
||||
return handle.invokeWithArguments(args);
|
||||
} catch (Throwable t) {
|
||||
throw new IllegalStateException("X11 call failed", t);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user