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