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