Skip to main content

xa11y_linux/
input.rs

1//! Linux input-simulation backend.
2//!
3//! Two implementations live behind a single [`LinuxInputProvider`] facade,
4//! chosen at construction time based on the session environment:
5//!
6//! - **X11** (`DISPLAY` is set) — drives the XTest extension over `x11rb`.
7//!   Zero setup required; matches what existing X11 users already have.
8//! - **Wayland / uinput** (anything else) — opens the kernel's
9//!   `/dev/uinput` device and registers a virtual evdev keyboard+pointer.
10//!   Compositor-agnostic — works on every Linux desktop (GNOME, KDE,
11//!   sway, Hyprland, Cosmic, weston) plus headless setups. Same mechanism
12//!   `xdotool --using-uinput`, `ydotool`, `wtype`, Steam, and Wine use.
13//!   Requires `input` group membership; surfaces an actionable
14//!   `Error::PermissionDenied` if the user isn't in the group. See
15//!   [`crate::wayland_input`].
16//!
17//! Routing prefers X11 when `DISPLAY` is set and falls back to uinput
18//! otherwise. There is no compile-time feature flag — both backends are
19//! always built on Linux.
20//!
21//! Key mapping on X11 goes keysym → keycode via `GetKeyboardMapping`, queried
22//! once at connect time. `Key::Char` for printable ASCII uses the codepoint
23//! as its keysym; [`Keyboard::type_text`](xa11y_core::input::Keyboard::type_text)
24//! looks up each character in the same keymap table and holds Shift when it
25//! appears in the shifted column. The Wayland backend does the same dance
26//! against an xkb keymap built from system defaults.
27
28use std::sync::{Mutex, RwLock};
29
30use x11rb::connection::Connection;
31use x11rb::protocol::xproto::{
32    ConnectionExt as _, GetKeyboardMappingReply, Keycode, Screen, Window, BUTTON_PRESS_EVENT,
33    BUTTON_RELEASE_EVENT, KEY_PRESS_EVENT, KEY_RELEASE_EVENT, MOTION_NOTIFY_EVENT,
34};
35use x11rb::protocol::xtest::ConnectionExt as _;
36use x11rb::rust_connection::RustConnection;
37
38use xa11y_core::input::{InputProvider, Key, MouseButton, Point, ScrollDelta};
39use xa11y_core::{Error, Result};
40
41use crate::wayland_input::WaylandInputBackend;
42
43// X11 keysyms — lifted from /usr/include/X11/keysymdef.h. Only the keysyms
44// named by [`Key`] plus the modifiers we need to hold for shifted text.
45// Shared with the Wayland backend via `key_to_keysym`.
46pub(crate) const XK_SHIFT_L: u32 = 0xffe1;
47pub(crate) const XK_CONTROL_L: u32 = 0xffe3;
48pub(crate) const XK_ALT_L: u32 = 0xffe9;
49pub(crate) const XK_SUPER_L: u32 = 0xffeb;
50const XK_RETURN: u32 = 0xff0d;
51const XK_ESCAPE: u32 = 0xff1b;
52const XK_BACKSPACE: u32 = 0xff08;
53const XK_TAB: u32 = 0xff09;
54const XK_DELETE: u32 = 0xffff;
55const XK_INSERT: u32 = 0xff63;
56const XK_UP: u32 = 0xff52;
57const XK_DOWN: u32 = 0xff54;
58const XK_LEFT: u32 = 0xff51;
59const XK_RIGHT: u32 = 0xff53;
60const XK_HOME: u32 = 0xff50;
61const XK_END: u32 = 0xff57;
62const XK_PAGE_UP: u32 = 0xff55;
63const XK_PAGE_DOWN: u32 = 0xff56;
64const XK_F1: u32 = 0xffbe;
65// XK_F24 = 0xffd5; 1..=24 is contiguous starting at XK_F1.
66
67/// Resolve a [`Key`] to its X11 keysym.
68///
69/// Both the X11 and Wayland backends translate `Key` → keysym via this
70/// function. The Wayland path then looks the keysym up in an xkb keymap to
71/// get an evdev keycode; the X11 path looks it up in `GetKeyboardMapping`.
72pub(crate) fn key_to_keysym(key: &Key) -> Result<u32> {
73    let keysym = match key {
74        Key::Shift => XK_SHIFT_L,
75        Key::Ctrl => XK_CONTROL_L,
76        Key::Alt => XK_ALT_L,
77        Key::Meta => XK_SUPER_L,
78        Key::Enter => XK_RETURN,
79        Key::Escape => XK_ESCAPE,
80        Key::Backspace => XK_BACKSPACE,
81        Key::Tab => XK_TAB,
82        Key::Space => 0x0020,
83        Key::Delete => XK_DELETE,
84        Key::Insert => XK_INSERT,
85        Key::ArrowUp => XK_UP,
86        Key::ArrowDown => XK_DOWN,
87        Key::ArrowLeft => XK_LEFT,
88        Key::ArrowRight => XK_RIGHT,
89        Key::Home => XK_HOME,
90        Key::End => XK_END,
91        Key::PageUp => XK_PAGE_UP,
92        Key::PageDown => XK_PAGE_DOWN,
93        Key::F(n) => {
94            if *n < 1 || *n > 24 {
95                return Err(Error::InvalidActionData {
96                    message: format!("F{n} is out of range (1..=24)"),
97                });
98            }
99            XK_F1 + (*n as u32 - 1)
100        }
101        Key::Char(c) => char_keysym(*c),
102    };
103    Ok(keysym)
104}
105
106/// Convert a character to its X11 keysym. ASCII 0x20..=0x7e maps directly;
107/// everything above uses the Unicode plane (`0x01000000 | codepoint`).
108pub(crate) fn char_keysym(c: char) -> u32 {
109    let cp = c as u32;
110    if (0x20..=0x7e).contains(&cp) {
111        cp
112    } else {
113        0x0100_0000 | cp
114    }
115}
116
117/// Keymap snapshot built from `GetKeyboardMapping`. For each keysym we care
118/// about we store the keycode plus whether it lives in the shifted column
119/// (column 1) rather than the unshifted column (column 0).
120struct Keymap {
121    min_keycode: u8,
122    syms_per_code: u8,
123    syms: Vec<u32>,
124}
125
126impl Keymap {
127    fn from_reply(reply: GetKeyboardMappingReply, min_keycode: u8) -> Self {
128        Self {
129            min_keycode,
130            syms_per_code: reply.keysyms_per_keycode,
131            syms: reply.keysyms,
132        }
133    }
134
135    /// Locate a keysym in the map. Returns `(keycode, needs_shift)` — the
136    /// caller must hold Shift iff `needs_shift` is true.
137    fn lookup(&self, keysym: u32) -> Option<(Keycode, bool)> {
138        let per = self.syms_per_code as usize;
139        if per == 0 {
140            return None;
141        }
142        for (code_index, chunk) in self.syms.chunks(per).enumerate() {
143            // Column 0 is the unshifted level, column 1 the shifted level.
144            // Some keys repeat the column-0 keysym into column 1 when there
145            // is no shifted binding; that's fine — either lookup succeeds.
146            if chunk.first() == Some(&keysym) {
147                return Some((self.min_keycode + code_index as u8, false));
148            }
149            if per >= 2 && chunk.get(1) == Some(&keysym) {
150                return Some((self.min_keycode + code_index as u8, true));
151            }
152        }
153        None
154    }
155}
156
157/// XTest-backed backend. Holds the X server connection and a keymap snapshot.
158pub(crate) struct X11InputBackend {
159    /// Connection is `!Sync`; guard all server traffic with the mutex.
160    conn: Mutex<RustConnection>,
161    root: Window,
162    keymap: RwLock<Keymap>,
163}
164
165impl X11InputBackend {
166    fn new() -> Result<Self> {
167        let (conn, screen_num) = RustConnection::connect(None).map_err(platform)?;
168        let setup = conn.setup().clone();
169        let screen: &Screen = setup
170            .roots
171            .get(screen_num)
172            .ok_or_else(|| platform_msg("X server reported no screens"))?;
173        let root = screen.root;
174
175        let min_keycode = setup.min_keycode;
176        let max_keycode = setup.max_keycode;
177        if max_keycode < min_keycode {
178            return Err(platform_msg("X server reported an empty keycode range"));
179        }
180        let count = max_keycode - min_keycode + 1;
181        let reply = conn
182            .get_keyboard_mapping(min_keycode, count)
183            .map_err(platform)?
184            .reply()
185            .map_err(platform)?;
186        let keymap = Keymap::from_reply(reply, min_keycode);
187
188        Ok(Self {
189            conn: Mutex::new(conn),
190            root,
191            keymap: RwLock::new(keymap),
192        })
193    }
194
195    fn with_conn<F, R>(&self, f: F) -> Result<R>
196    where
197        F: FnOnce(&RustConnection) -> Result<R>,
198    {
199        let guard = self.conn.lock().unwrap_or_else(|e| e.into_inner());
200        f(&guard)
201    }
202
203    fn send(&self, type_: u8, detail: u8, x: i16, y: i16) -> Result<()> {
204        self.with_conn(|conn| {
205            conn.xtest_fake_input(type_, detail, 0, self.root, x, y, 0)
206                .map_err(platform)?
207                .check()
208                .map_err(platform)?;
209            conn.flush().map_err(platform)?;
210            Ok(())
211        })
212    }
213
214    fn key_event(&self, keysym: u32, press: bool) -> Result<()> {
215        let (keycode, _shift) = {
216            let map = self.keymap.read().unwrap_or_else(|e| e.into_inner());
217            map.lookup(keysym).ok_or_else(|| Error::Unsupported {
218                feature: format!("keysym 0x{keysym:04x} has no keycode in the current X layout"),
219            })?
220        };
221        let type_ = if press {
222            KEY_PRESS_EVENT
223        } else {
224            KEY_RELEASE_EVENT
225        };
226        self.send(type_, keycode, 0, 0)
227    }
228
229    fn button_event(&self, button: u8, press: bool) -> Result<()> {
230        let type_ = if press {
231            BUTTON_PRESS_EVENT
232        } else {
233            BUTTON_RELEASE_EVENT
234        };
235        self.send(type_, button, 0, 0)
236    }
237
238    fn pointer_move(&self, to: Point) -> Result<()> {
239        // detail=0 on MOTION_NOTIFY means absolute coordinates relative to root.
240        self.send(MOTION_NOTIFY_EVENT, 0, clamp_coord(to.x), clamp_coord(to.y))
241    }
242
243    fn pointer_down(&self, button: MouseButton) -> Result<()> {
244        self.button_event(button_number(button), true)
245    }
246
247    fn pointer_up(&self, button: MouseButton) -> Result<()> {
248        self.button_event(button_number(button), false)
249    }
250
251    fn pointer_click(&self, at: Point, button: MouseButton, count: u32) -> Result<()> {
252        if count == 0 {
253            return Ok(());
254        }
255        self.pointer_move(at)?;
256        let btn = button_number(button);
257        for _ in 0..count {
258            self.button_event(btn, true)?;
259            self.button_event(btn, false)?;
260        }
261        Ok(())
262    }
263
264    fn pointer_scroll(&self, at: Point, delta: ScrollDelta) -> Result<()> {
265        self.pointer_move(at)?;
266        // X11 scroll-wheel convention: button 4 = up, 5 = down, 6 = left, 7 = right.
267        // The input-sim contract: positive dy scrolls content down (viewport
268        // up), which is the "wheel rolled toward the user" direction — that's
269        // button 5 on X11.
270        for _ in 0..delta.dy.abs() {
271            let btn = if delta.dy > 0 { 5 } else { 4 };
272            self.button_event(btn, true)?;
273            self.button_event(btn, false)?;
274        }
275        for _ in 0..delta.dx.abs() {
276            let btn = if delta.dx > 0 { 7 } else { 6 };
277            self.button_event(btn, true)?;
278            self.button_event(btn, false)?;
279        }
280        Ok(())
281    }
282
283    fn key_down(&self, key: &Key) -> Result<()> {
284        let keysym = key_to_keysym(key)?;
285        self.key_event(keysym, true)
286    }
287
288    fn key_up(&self, key: &Key) -> Result<()> {
289        let keysym = key_to_keysym(key)?;
290        self.key_event(keysym, false)
291    }
292
293    fn type_text(&self, text: &str) -> Result<()> {
294        // For each character, look up its keysym; if the keymap has it in the
295        // shifted column, hold Shift for that press. This is the X11 analogue
296        // of the KEYEVENTF_UNICODE path on Windows — both aim to be robust
297        // against the active keyboard layout.
298        for c in text.chars() {
299            let keysym = char_keysym(c);
300            let (keycode, needs_shift) = {
301                let map = self.keymap.read().unwrap_or_else(|e| e.into_inner());
302                match map.lookup(keysym) {
303                    Some(v) => v,
304                    None => {
305                        return Err(Error::Unsupported {
306                            feature: format!(
307                                "character '{c}' (keysym 0x{keysym:04x}) has no keycode \
308                                 in the current X keyboard layout"
309                            ),
310                        });
311                    }
312                }
313            };
314            if needs_shift {
315                self.key_event(XK_SHIFT_L, true)?;
316            }
317            self.send(KEY_PRESS_EVENT, keycode, 0, 0)?;
318            self.send(KEY_RELEASE_EVENT, keycode, 0, 0)?;
319            if needs_shift {
320                self.key_event(XK_SHIFT_L, false)?;
321            }
322        }
323        Ok(())
324    }
325}
326
327/// Public input provider for Linux. Dispatches to the X11 or Wayland backend
328/// chosen at construction.
329pub struct LinuxInputProvider {
330    backend: InputBackend,
331}
332
333enum InputBackend {
334    X11(Box<X11InputBackend>),
335    Wayland(Box<WaylandInputBackend>),
336}
337
338impl LinuxInputProvider {
339    /// Choose a backend based on the session environment.
340    ///
341    /// - `DISPLAY` set → X11 (XTest).
342    /// - otherwise → uinput (works on Wayland and headless sessions).
343    ///   May return [`Error::PermissionDenied`] if the user isn't in the
344    ///   `input` group, or [`Error::Unsupported`] if the kernel `uinput`
345    ///   module isn't loaded.
346    pub fn new() -> Result<Self> {
347        let display_set = std::env::var_os("DISPLAY").is_some();
348
349        if display_set {
350            let x11 = X11InputBackend::new()?;
351            Ok(Self {
352                backend: InputBackend::X11(Box::new(x11)),
353            })
354        } else {
355            let wl = WaylandInputBackend::new()?;
356            Ok(Self {
357                backend: InputBackend::Wayland(Box::new(wl)),
358            })
359        }
360    }
361}
362
363impl LinuxInputProvider {
364    /// Convert a logical target point (the cross-platform contract, same space
365    /// as `Element::bounds`) to the physical device pixels both backends
366    /// expect: XTest warps to physical root coordinates, and the uinput
367    /// virtual range is mapped against the physical screen size. No-op at
368    /// scale 1.0. See `crate::scale`.
369    fn to_physical(&self, p: Point) -> Point {
370        p.to_physical(crate::scale::coordinate_scale())
371    }
372}
373
374impl InputProvider for LinuxInputProvider {
375    fn pointer_move(&self, to: Point) -> Result<()> {
376        let to = self.to_physical(to);
377        match &self.backend {
378            InputBackend::X11(x) => x.pointer_move(to),
379            InputBackend::Wayland(w) => w.pointer_move(to),
380        }
381    }
382
383    fn pointer_down(&self, button: MouseButton) -> Result<()> {
384        match &self.backend {
385            InputBackend::X11(x) => x.pointer_down(button),
386            InputBackend::Wayland(w) => w.pointer_down(button),
387        }
388    }
389
390    fn pointer_up(&self, button: MouseButton) -> Result<()> {
391        match &self.backend {
392            InputBackend::X11(x) => x.pointer_up(button),
393            InputBackend::Wayland(w) => w.pointer_up(button),
394        }
395    }
396
397    fn pointer_click(&self, at: Point, button: MouseButton, count: u32) -> Result<()> {
398        let at = self.to_physical(at);
399        match &self.backend {
400            InputBackend::X11(x) => x.pointer_click(at, button, count),
401            InputBackend::Wayland(w) => w.pointer_click(at, button, count),
402        }
403    }
404
405    fn pointer_scroll(&self, at: Point, delta: ScrollDelta) -> Result<()> {
406        let at = self.to_physical(at);
407        match &self.backend {
408            InputBackend::X11(x) => x.pointer_scroll(at, delta),
409            InputBackend::Wayland(w) => w.pointer_scroll(at, delta),
410        }
411    }
412
413    fn key_down(&self, key: &Key) -> Result<()> {
414        match &self.backend {
415            InputBackend::X11(x) => x.key_down(key),
416            InputBackend::Wayland(w) => w.key_down(key),
417        }
418    }
419
420    fn key_up(&self, key: &Key) -> Result<()> {
421        match &self.backend {
422            InputBackend::X11(x) => x.key_up(key),
423            InputBackend::Wayland(w) => w.key_up(key),
424        }
425    }
426
427    fn type_text(&self, text: &str) -> Result<()> {
428        match &self.backend {
429            InputBackend::X11(x) => x.type_text(text),
430            InputBackend::Wayland(w) => w.type_text(text),
431        }
432    }
433}
434
435pub(crate) fn platform<E: std::fmt::Display>(e: E) -> Error {
436    Error::Platform {
437        code: -1,
438        message: e.to_string(),
439    }
440}
441
442pub(crate) fn platform_msg(msg: &str) -> Error {
443    Error::Platform {
444        code: -1,
445        message: msg.to_string(),
446    }
447}
448
449/// Clamp an `i32` screen coordinate into the `i16` range that XTest takes.
450/// X11 coordinates are 16-bit; this matches how libX11 itself narrows them.
451fn clamp_coord(v: i32) -> i16 {
452    v.clamp(i16::MIN as i32, i16::MAX as i32) as i16
453}
454
455fn button_number(button: MouseButton) -> u8 {
456    match button {
457        MouseButton::Left => 1,
458        MouseButton::Middle => 2,
459        MouseButton::Right => 3,
460    }
461}