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