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