Skip to main content

a7n_platform/
input.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3//! Channels shared by the daemon and future platform input threads.
4
5use std::time::{Duration, Instant};
6
7use a7n_proto::v1::InputEvent;
8use tokio::sync::mpsc;
9
10#[cfg(all(unix, not(target_os = "macos")))]
11mod accumulate;
12#[cfg(all(unix, not(target_os = "macos")))]
13mod evdev_keymap;
14#[cfg(target_os = "macos")]
15pub(crate) mod macos;
16#[cfg(all(test, not(target_os = "macos")))]
17#[path = "input/macos/keymap.rs"]
18mod macos_keymap;
19mod scroll;
20#[cfg(all(unix, not(target_os = "macos")))]
21mod wayland;
22#[cfg(windows)]
23pub(crate) mod windows;
24#[cfg(all(unix, not(target_os = "macos")))]
25mod x11;
26
27pub const KEEPALIVE_INTERVAL: Duration = Duration::from_millis(500);
28pub const DEADMAN_TIMEOUT: Duration = Duration::from_millis(1500);
29
30/// Raw pointer and key events queued for the daemon.
31#[cfg(all(unix, not(target_os = "macos")))]
32const CAPTURE_EVENT_CAPACITY: usize = 1024;
33#[cfg(all(unix, not(target_os = "macos")))]
34const CAPTURE_CONTROL_CAPACITY: usize = 16;
35#[cfg(all(unix, not(target_os = "macos")))]
36const INJECT_COMMAND_CAPACITY: usize = 1024;
37/// Position reports are superseded by the next one, so a short queue is enough.
38#[cfg(all(unix, not(target_os = "macos")))]
39const INJECT_POINTER_CAPACITY: usize = 64;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct Bounds {
43    pub x: i32,
44    pub y: i32,
45    pub width: u32,
46    pub height: u32,
47}
48
49#[derive(Debug)]
50pub enum Captured {
51    Pointer { x: i32, y: i32, dx: i32, dy: i32 },
52    Input(InputEvent),
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum CaptureControl {
57    Grab(bool),
58    Keepalive,
59}
60
61/// While grabbed, all events still arrive here but do not reach the local OS.
62#[derive(Debug)]
63pub struct Capture {
64    pub bounds: Bounds,
65    pub events: mpsc::Receiver<Captured>,
66    pub control: mpsc::Sender<CaptureControl>,
67}
68
69#[derive(Debug)]
70pub enum Inject {
71    Event(InputEvent),
72    Motion { dx: i32, dy: i32 },
73    Warp { x: i32, y: i32 },
74}
75
76#[derive(Debug)]
77pub struct Injector {
78    pub bounds: Bounds,
79    pub commands: mpsc::Sender<Inject>,
80    /// Absolute pointer position and raw delta after each motion or warp.
81    pub pointer: mpsc::Receiver<Captured>,
82}
83
84#[derive(Debug, thiserror::Error)]
85pub enum InputError {
86    #[error("input capture and injection are not supported on this platform")]
87    Unsupported,
88    #[error("{0} permission missing")]
89    MissingPermission(&'static str),
90    #[cfg(all(unix, not(target_os = "macos")))]
91    #[error("Wayland portal failed: {0}")]
92    Portal(String),
93    #[cfg(target_os = "macos")]
94    #[error("macOS input failed: {0}")]
95    Macos(#[from] std::io::Error),
96    #[cfg(all(unix, not(target_os = "macos")))]
97    #[error("cannot connect to the X server: {0}")]
98    X11Connect(#[from] x11rb::errors::ConnectError),
99    #[cfg(all(unix, not(target_os = "macos")))]
100    #[error("X connection failed: {0}")]
101    X11Connection(#[from] x11rb::errors::ConnectionError),
102    #[cfg(all(unix, not(target_os = "macos")))]
103    #[error("X request failed: {0}")]
104    X11Reply(#[from] x11rb::errors::ReplyError),
105    #[cfg(all(unix, not(target_os = "macos")))]
106    #[error("the X server lacks the {0} extension")]
107    MissingX11Extension(&'static str),
108    #[error("cannot start an input thread: {0}")]
109    Thread(std::io::Error),
110    #[cfg(windows)]
111    #[error("Windows input failed: {0}")]
112    Windows(#[from] std::io::Error),
113}
114
115/// # Errors
116/// Returns [`InputError::Unsupported`] when no backend fits the session
117/// (no display, other platforms), or the backend's own error when it
118/// cannot start.
119pub fn start() -> Result<(Capture, Injector), InputError> {
120    start_platform()
121}
122
123#[cfg(all(unix, not(target_os = "macos")))]
124fn start_platform() -> Result<(Capture, Injector), InputError> {
125    match detect_backend() {
126        Backend::Wayland => wayland::start(),
127        Backend::X11 => x11::start(),
128        Backend::None => Err(InputError::Unsupported),
129    }
130}
131
132/// The backend the current session environment calls for.
133#[cfg(all(unix, not(target_os = "macos")))]
134pub(crate) fn detect_backend() -> Backend {
135    let session_type = std::env::var("XDG_SESSION_TYPE").ok();
136    select_backend(
137        session_type.as_deref(),
138        env_set("DISPLAY"),
139        env_set("WAYLAND_DISPLAY"),
140    )
141}
142
143#[cfg(all(unix, not(target_os = "macos")))]
144fn env_set(name: &str) -> bool {
145    std::env::var_os(name).is_some_and(|value| !value.is_empty())
146}
147
148#[cfg(windows)]
149fn start_platform() -> Result<(Capture, Injector), InputError> {
150    windows::start()
151}
152
153#[cfg(target_os = "macos")]
154fn start_platform() -> Result<(Capture, Injector), InputError> {
155    macos::start()
156}
157
158#[cfg(not(any(windows, unix)))]
159fn start_platform() -> Result<(Capture, Injector), InputError> {
160    Err(InputError::Unsupported)
161}
162
163#[cfg(all(unix, not(target_os = "macos")))]
164#[derive(Debug, PartialEq, Eq)]
165pub(crate) enum Backend {
166    Wayland,
167    X11,
168    None,
169}
170
171/// `XWayland` cannot capture global input, so a Wayland session never gets the
172/// X11 backend even when `DISPLAY` is set.
173#[cfg(all(unix, not(target_os = "macos")))]
174pub(crate) fn select_backend(
175    session_type: Option<&str>,
176    display_set: bool,
177    wayland_display_set: bool,
178) -> Backend {
179    if session_type == Some("wayland") {
180        return if wayland_display_set {
181            Backend::Wayland
182        } else {
183            Backend::None
184        };
185    }
186    if display_set {
187        Backend::X11
188    } else {
189        Backend::None
190    }
191}
192
193/// Backends check this independently so a stalled daemon cannot keep input grabbed.
194#[derive(Debug)]
195pub struct DeadMan {
196    last: Instant,
197}
198
199impl DeadMan {
200    #[must_use]
201    pub fn new(now: Instant) -> Self {
202        Self { last: now }
203    }
204
205    pub fn keepalive(&mut self, now: Instant) {
206        self.last = now;
207    }
208
209    #[must_use]
210    pub fn expired(&self, now: Instant) -> bool {
211        now.saturating_duration_since(self.last) >= DEADMAN_TIMEOUT
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[cfg(all(unix, not(target_os = "macos")))]
220    #[test]
221    fn select_backend_session_type_and_displays_pick_backend() {
222        // Arrange
223        let cases = [
224            (Some("wayland"), true, true, Backend::Wayland),
225            (Some("wayland"), false, true, Backend::Wayland),
226            (Some("wayland"), true, false, Backend::None),
227            (Some("x11"), false, false, Backend::None),
228            (Some("x11"), true, false, Backend::X11),
229            (Some("x11"), true, true, Backend::X11),
230            (None, true, false, Backend::X11),
231            (None, false, true, Backend::None),
232        ];
233
234        for (session_type, display_set, wayland_display_set, expected) in cases {
235            // Act
236            let backend = select_backend(session_type, display_set, wayland_display_set);
237
238            // Assert
239            assert_eq!(
240                backend, expected,
241                "{session_type:?} display={display_set} wayland={wayland_display_set}"
242            );
243        }
244    }
245
246    #[test]
247    fn expired_keepalive_missing_releases_at_timeout() {
248        // Arrange
249        let now = Instant::now();
250        let mut deadman = DeadMan::new(now);
251        deadman.keepalive(now + KEEPALIVE_INTERVAL);
252
253        // Act
254        let expired = [
255            DEADMAN_TIMEOUT.saturating_sub(Duration::from_nanos(1)),
256            DEADMAN_TIMEOUT,
257        ]
258        .map(|elapsed| deadman.expired(now + KEEPALIVE_INTERVAL + elapsed));
259
260        // Assert
261        assert_eq!(expired, [false, true]);
262    }
263}