Skip to main content

xa11y_linux/
input.rs

1//! Linux input-simulation backend using X11 + XTEST.
2//!
3//! Connects to the X server on construction and drives the XTest extension
4//! (`fake_input`) for pointer motion, button events, scroll, and keyboard.
5//!
6//! **Wayland is not supported** at this layer. If `WAYLAND_DISPLAY` is set and
7//! `DISPLAY` is not, [`LinuxInputProvider::new`] returns [`Error::Unsupported`]
8//! — per Tenet 1 we refuse to fall back silently. A future backend based on
9//! `libei` / `org.freedesktop.portal.RemoteDesktop` can be added behind a
10//! feature flag.
11//!
12//! Key mapping goes keysym → keycode via `GetKeyboardMapping`, which we query
13//! once at connect time and re-query when the server notifies us of a layout
14//! change. `Key::Char` for printable ASCII uses the codepoint as its keysym;
15//! [`Keyboard::type_text`](xa11y_core::input::Keyboard::type_text) looks up
16//! each character in the same keymap table and holds Shift when it appears in
17//! the shifted column.
18
19use std::sync::{Mutex, RwLock};
20
21use x11rb::connection::Connection;
22use x11rb::protocol::xproto::{
23    ConnectionExt as _, GetKeyboardMappingReply, Keycode, Screen, Window, BUTTON_PRESS_EVENT,
24    BUTTON_RELEASE_EVENT, KEY_PRESS_EVENT, KEY_RELEASE_EVENT, MOTION_NOTIFY_EVENT,
25};
26use x11rb::protocol::xtest::ConnectionExt as _;
27use x11rb::rust_connection::RustConnection;
28
29use xa11y_core::input::{InputProvider, Key, MouseButton, Point, ScrollDelta};
30use xa11y_core::{Error, Result};
31
32// X11 keysyms — lifted from /usr/include/X11/keysymdef.h. Only the keysyms
33// named by [`Key`] plus the modifiers we need to hold for shifted text.
34const XK_SHIFT_L: u32 = 0xffe1;
35const XK_CONTROL_L: u32 = 0xffe3;
36const XK_ALT_L: u32 = 0xffe9;
37const XK_SUPER_L: u32 = 0xffeb;
38const XK_RETURN: u32 = 0xff0d;
39const XK_ESCAPE: u32 = 0xff1b;
40const XK_BACKSPACE: u32 = 0xff08;
41const XK_TAB: u32 = 0xff09;
42const XK_DELETE: u32 = 0xffff;
43const XK_INSERT: u32 = 0xff63;
44const XK_UP: u32 = 0xff52;
45const XK_DOWN: u32 = 0xff54;
46const XK_LEFT: u32 = 0xff51;
47const XK_RIGHT: u32 = 0xff53;
48const XK_HOME: u32 = 0xff50;
49const XK_END: u32 = 0xff57;
50const XK_PAGE_UP: u32 = 0xff55;
51const XK_PAGE_DOWN: u32 = 0xff56;
52const XK_F1: u32 = 0xffbe;
53// XK_F24 = 0xffd5; 1..=24 is contiguous starting at XK_F1.
54
55/// Keymap snapshot built from `GetKeyboardMapping`. For each keysym we care
56/// about we store the keycode plus whether it lives in the shifted column
57/// (column 1) rather than the unshifted column (column 0).
58struct Keymap {
59    min_keycode: u8,
60    syms_per_code: u8,
61    syms: Vec<u32>,
62}
63
64impl Keymap {
65    fn from_reply(reply: GetKeyboardMappingReply, min_keycode: u8) -> Self {
66        Self {
67            min_keycode,
68            syms_per_code: reply.keysyms_per_keycode,
69            syms: reply.keysyms,
70        }
71    }
72
73    /// Locate a keysym in the map. Returns `(keycode, needs_shift)` — the
74    /// caller must hold Shift iff `needs_shift` is true.
75    fn lookup(&self, keysym: u32) -> Option<(Keycode, bool)> {
76        let per = self.syms_per_code as usize;
77        if per == 0 {
78            return None;
79        }
80        for (code_index, chunk) in self.syms.chunks(per).enumerate() {
81            // Column 0 is the unshifted level, column 1 the shifted level.
82            // Some keys repeat the column-0 keysym into column 1 when there
83            // is no shifted binding; that's fine — either lookup succeeds.
84            if chunk.first() == Some(&keysym) {
85                return Some((self.min_keycode + code_index as u8, false));
86            }
87            if per >= 2 && chunk.get(1) == Some(&keysym) {
88                return Some((self.min_keycode + code_index as u8, true));
89            }
90        }
91        None
92    }
93}
94
95/// XTest-backed [`InputProvider`].
96pub struct LinuxInputProvider {
97    /// Connection is `!Sync`; guard all server traffic with the mutex.
98    conn: Mutex<RustConnection>,
99    root: Window,
100    keymap: RwLock<Keymap>,
101}
102
103impl LinuxInputProvider {
104    /// Connect to `$DISPLAY` and initialise XTest state.
105    ///
106    /// Returns [`Error::Unsupported`] if the session is Wayland-only
107    /// (`WAYLAND_DISPLAY` is set and `DISPLAY` is not). Returns
108    /// [`Error::Platform`] for any X protocol / connection failure.
109    pub fn new() -> Result<Self> {
110        let display_set = std::env::var_os("DISPLAY").is_some();
111        let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
112        if !display_set {
113            let feature = if wayland {
114                "input simulation on Wayland (X11 DISPLAY not set)".to_string()
115            } else {
116                "input simulation (no X11 DISPLAY)".to_string()
117            };
118            return Err(Error::Unsupported { feature });
119        }
120
121        let (conn, screen_num) = RustConnection::connect(None).map_err(platform)?;
122        let setup = conn.setup().clone();
123        let screen: &Screen = setup
124            .roots
125            .get(screen_num)
126            .ok_or_else(|| platform_msg("X server reported no screens"))?;
127        let root = screen.root;
128
129        let min_keycode = setup.min_keycode;
130        let max_keycode = setup.max_keycode;
131        if max_keycode < min_keycode {
132            return Err(platform_msg("X server reported an empty keycode range"));
133        }
134        let count = max_keycode - min_keycode + 1;
135        let reply = conn
136            .get_keyboard_mapping(min_keycode, count)
137            .map_err(platform)?
138            .reply()
139            .map_err(platform)?;
140        let keymap = Keymap::from_reply(reply, min_keycode);
141
142        Ok(Self {
143            conn: Mutex::new(conn),
144            root,
145            keymap: RwLock::new(keymap),
146        })
147    }
148
149    fn with_conn<F, R>(&self, f: F) -> Result<R>
150    where
151        F: FnOnce(&RustConnection) -> Result<R>,
152    {
153        let guard = self.conn.lock().unwrap_or_else(|e| e.into_inner());
154        f(&guard)
155    }
156
157    fn send(&self, type_: u8, detail: u8, x: i16, y: i16) -> Result<()> {
158        self.with_conn(|conn| {
159            conn.xtest_fake_input(type_, detail, 0, self.root, x, y, 0)
160                .map_err(platform)?
161                .check()
162                .map_err(platform)?;
163            conn.flush().map_err(platform)?;
164            Ok(())
165        })
166    }
167
168    fn key_event(&self, keysym: u32, press: bool) -> Result<()> {
169        let (keycode, _shift) = {
170            let map = self.keymap.read().unwrap_or_else(|e| e.into_inner());
171            map.lookup(keysym).ok_or_else(|| Error::Unsupported {
172                feature: format!("keysym 0x{keysym:04x} has no keycode in the current X layout"),
173            })?
174        };
175        let type_ = if press {
176            KEY_PRESS_EVENT
177        } else {
178            KEY_RELEASE_EVENT
179        };
180        self.send(type_, keycode, 0, 0)
181    }
182
183    fn button_event(&self, button: u8, press: bool) -> Result<()> {
184        let type_ = if press {
185            BUTTON_PRESS_EVENT
186        } else {
187            BUTTON_RELEASE_EVENT
188        };
189        self.send(type_, button, 0, 0)
190    }
191
192    /// Resolve a [`Key`] to its X11 keysym, returning
193    /// [`Error::InvalidActionData`] for out-of-range `Key::F(n)`.
194    fn keysym_for(&self, key: &Key) -> Result<u32> {
195        let keysym = match key {
196            Key::Shift => XK_SHIFT_L,
197            Key::Ctrl => XK_CONTROL_L,
198            Key::Alt => XK_ALT_L,
199            Key::Meta => XK_SUPER_L,
200            Key::Enter => XK_RETURN,
201            Key::Escape => XK_ESCAPE,
202            Key::Backspace => XK_BACKSPACE,
203            Key::Tab => XK_TAB,
204            Key::Space => 0x0020,
205            Key::Delete => XK_DELETE,
206            Key::Insert => XK_INSERT,
207            Key::ArrowUp => XK_UP,
208            Key::ArrowDown => XK_DOWN,
209            Key::ArrowLeft => XK_LEFT,
210            Key::ArrowRight => XK_RIGHT,
211            Key::Home => XK_HOME,
212            Key::End => XK_END,
213            Key::PageUp => XK_PAGE_UP,
214            Key::PageDown => XK_PAGE_DOWN,
215            Key::F(n) => {
216                if *n < 1 || *n > 24 {
217                    return Err(Error::InvalidActionData {
218                        message: format!("F{n} is out of range (1..=24)"),
219                    });
220                }
221                XK_F1 + (*n as u32 - 1)
222            }
223            Key::Char(c) => char_keysym(*c),
224        };
225        Ok(keysym)
226    }
227}
228
229/// Convert a character to its X11 keysym. ASCII 0x20..=0x7e maps directly;
230/// everything above uses the Unicode plane (`0x01000000 | codepoint`).
231fn char_keysym(c: char) -> u32 {
232    let cp = c as u32;
233    if (0x20..=0x7e).contains(&cp) {
234        cp
235    } else {
236        0x0100_0000 | cp
237    }
238}
239
240fn platform<E: std::fmt::Display>(e: E) -> Error {
241    Error::Platform {
242        code: -1,
243        message: e.to_string(),
244    }
245}
246
247fn platform_msg(msg: &str) -> Error {
248    Error::Platform {
249        code: -1,
250        message: msg.to_string(),
251    }
252}
253
254/// Clamp an `i32` screen coordinate into the `i16` range that XTest takes.
255/// X11 coordinates are 16-bit; this matches how libX11 itself narrows them.
256fn clamp_coord(v: i32) -> i16 {
257    v.clamp(i16::MIN as i32, i16::MAX as i32) as i16
258}
259
260impl InputProvider for LinuxInputProvider {
261    fn pointer_move(&self, to: Point) -> Result<()> {
262        // detail=0 on MOTION_NOTIFY means absolute coordinates relative to root.
263        self.send(MOTION_NOTIFY_EVENT, 0, clamp_coord(to.x), clamp_coord(to.y))
264    }
265
266    fn pointer_down(&self, button: MouseButton) -> Result<()> {
267        self.button_event(button_number(button), true)
268    }
269
270    fn pointer_up(&self, button: MouseButton) -> Result<()> {
271        self.button_event(button_number(button), false)
272    }
273
274    fn pointer_click(&self, at: Point, button: MouseButton, count: u32) -> Result<()> {
275        if count == 0 {
276            return Ok(());
277        }
278        self.pointer_move(at)?;
279        let btn = button_number(button);
280        for _ in 0..count {
281            self.button_event(btn, true)?;
282            self.button_event(btn, false)?;
283        }
284        Ok(())
285    }
286
287    fn pointer_scroll(&self, at: Point, delta: ScrollDelta) -> Result<()> {
288        self.pointer_move(at)?;
289        // X11 scroll-wheel convention: button 4 = up, 5 = down, 6 = left, 7 = right.
290        // The input-sim contract: positive dy scrolls content down (viewport
291        // up), which is the "wheel rolled toward the user" direction — that's
292        // button 5 on X11. Matches Windows' positive-delta / scroll-down
293        // mapping implicitly the other way around, but "positive dy moves
294        // content down" is the doc'd invariant.
295        for _ in 0..delta.dy.abs() {
296            let btn = if delta.dy > 0 { 5 } else { 4 };
297            self.button_event(btn, true)?;
298            self.button_event(btn, false)?;
299        }
300        for _ in 0..delta.dx.abs() {
301            let btn = if delta.dx > 0 { 7 } else { 6 };
302            self.button_event(btn, true)?;
303            self.button_event(btn, false)?;
304        }
305        Ok(())
306    }
307
308    fn key_down(&self, key: &Key) -> Result<()> {
309        let keysym = self.keysym_for(key)?;
310        self.key_event(keysym, true)
311    }
312
313    fn key_up(&self, key: &Key) -> Result<()> {
314        let keysym = self.keysym_for(key)?;
315        self.key_event(keysym, false)
316    }
317
318    fn type_text(&self, text: &str) -> Result<()> {
319        // For each character, look up its keysym; if the keymap has it in the
320        // shifted column, hold Shift for that press. This is the X11 analogue
321        // of the KEYEVENTF_UNICODE path on Windows — both aim to be robust
322        // against the active keyboard layout.
323        for c in text.chars() {
324            let keysym = char_keysym(c);
325            let (keycode, needs_shift) = {
326                let map = self.keymap.read().unwrap_or_else(|e| e.into_inner());
327                match map.lookup(keysym) {
328                    Some(v) => v,
329                    None => {
330                        return Err(Error::Unsupported {
331                            feature: format!(
332                                "character '{c}' (keysym 0x{keysym:04x}) has no keycode \
333                                 in the current X keyboard layout"
334                            ),
335                        });
336                    }
337                }
338            };
339            if needs_shift {
340                self.key_event(XK_SHIFT_L, true)?;
341            }
342            self.send(KEY_PRESS_EVENT, keycode, 0, 0)?;
343            self.send(KEY_RELEASE_EVENT, keycode, 0, 0)?;
344            if needs_shift {
345                self.key_event(XK_SHIFT_L, false)?;
346            }
347        }
348        Ok(())
349    }
350}
351
352fn button_number(button: MouseButton) -> u8 {
353    match button {
354        MouseButton::Left => 1,
355        MouseButton::Middle => 2,
356        MouseButton::Right => 3,
357    }
358}