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