Skip to main content

ferrisgrid_input/
lib.rs

1use ferrisgrid_core::{
2    ErrorKind, FerrisError, InputBackend, InputCapabilities, InputExecution, MouseButton,
3    NativeAction, Result,
4};
5use std::process::Command;
6use std::thread;
7use std::time::Duration;
8
9pub struct FakeInputBackend;
10
11impl InputBackend for FakeInputBackend {
12    fn name(&self) -> &'static str {
13        "fake"
14    }
15
16    fn capabilities(&self) -> InputCapabilities {
17        InputCapabilities {
18            can_mouse: true,
19            can_keyboard: true,
20        }
21    }
22
23    fn execute(&self, action: &NativeAction) -> Result<InputExecution> {
24        Ok(InputExecution {
25            summary: format!("fake_execute {action:?}"),
26        })
27    }
28}
29
30pub struct MacOsInputBackend;
31
32impl InputBackend for MacOsInputBackend {
33    fn name(&self) -> &'static str {
34        "native-macos"
35    }
36
37    fn capabilities(&self) -> InputCapabilities {
38        InputCapabilities {
39            can_mouse: cfg!(target_os = "macos"),
40            can_keyboard: cfg!(target_os = "macos"),
41        }
42    }
43
44    fn execute(&self, action: &NativeAction) -> Result<InputExecution> {
45        #[cfg(target_os = "macos")]
46        {
47            execute_macos(action)
48        }
49        #[cfg(not(target_os = "macos"))]
50        {
51            let _ = action;
52            Err(FerrisError::new(
53                ErrorKind::Platform,
54                "native input is currently implemented for macOS only; use --backend fake",
55            ))
56        }
57    }
58}
59
60pub struct LinuxInputBackend;
61
62impl InputBackend for LinuxInputBackend {
63    fn name(&self) -> &'static str {
64        "native-linux-x11"
65    }
66
67    fn capabilities(&self) -> InputCapabilities {
68        InputCapabilities {
69            can_mouse: cfg!(target_os = "linux"),
70            can_keyboard: cfg!(target_os = "linux"),
71        }
72    }
73
74    fn execute(&self, action: &NativeAction) -> Result<InputExecution> {
75        #[cfg(target_os = "linux")]
76        {
77            execute_linux(action)
78        }
79        #[cfg(not(target_os = "linux"))]
80        {
81            let _ = action;
82            Err(FerrisError::new(
83                ErrorKind::Platform,
84                "native Linux X11 input is only available on Linux; use --backend native on this OS or --backend fake",
85            ))
86        }
87    }
88}
89
90pub fn backend_by_name(name: &str) -> Box<dyn InputBackend> {
91    match name {
92        "fake" => Box::new(FakeInputBackend),
93        "native" => native_backend(),
94        "macos" | "native-macos" => Box::new(MacOsInputBackend),
95        "linux" | "x11" | "native-linux" | "native-linux-x11" => Box::new(LinuxInputBackend),
96        _ => native_backend(),
97    }
98}
99
100fn native_backend() -> Box<dyn InputBackend> {
101    #[cfg(target_os = "linux")]
102    {
103        Box::new(LinuxInputBackend)
104    }
105    #[cfg(target_os = "macos")]
106    {
107        Box::new(MacOsInputBackend)
108    }
109    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
110    {
111        Box::new(MacOsInputBackend)
112    }
113}
114
115#[cfg(target_os = "linux")]
116fn execute_linux(action: &NativeAction) -> Result<InputExecution> {
117    match action {
118        NativeAction::Click { x, y, button } => {
119            run_xdotool(&["mousemove", &x.to_string(), &y.to_string()])?;
120            run_xdotool(&["click", xdotool_button(*button)])?;
121            Ok(InputExecution {
122                summary: format!("click x={x} y={y} button={}", button.as_str()),
123            })
124        }
125        NativeAction::DoubleClick { x, y, button } => {
126            run_xdotool(&["mousemove", &x.to_string(), &y.to_string()])?;
127            run_xdotool(&["click", "--repeat", "2", xdotool_button(*button)])?;
128            Ok(InputExecution {
129                summary: format!("double_click x={x} y={y} button={}", button.as_str()),
130            })
131        }
132        NativeAction::RightClick { x, y } => {
133            run_xdotool(&["mousemove", &x.to_string(), &y.to_string()])?;
134            run_xdotool(&["click", "3"])?;
135            Ok(InputExecution {
136                summary: format!("right_click x={x} y={y}"),
137            })
138        }
139        NativeAction::MoveMouse { x, y } => {
140            run_xdotool(&["mousemove", &x.to_string(), &y.to_string()])?;
141            Ok(InputExecution {
142                summary: format!("move_mouse x={x} y={y}"),
143            })
144        }
145        NativeAction::Wait { duration_ms } => {
146            thread::sleep(Duration::from_millis(*duration_ms));
147            Ok(InputExecution {
148                summary: format!("wait duration_ms={duration_ms}"),
149            })
150        }
151        NativeAction::Type { text } => {
152            run_xdotool(&["type", "--clearmodifiers", "--", text])?;
153            Ok(InputExecution {
154                summary: "type text=<redacted>".to_string(),
155            })
156        }
157        NativeAction::PressKey { key } => {
158            let mapped = linux_key(key)?;
159            run_xdotool(&["key", "--clearmodifiers", &mapped])?;
160            Ok(InputExecution {
161                summary: format!("press_key key={key}"),
162            })
163        }
164        NativeAction::Hotkey { keys } => {
165            let mapped = keys
166                .iter()
167                .map(|key| linux_key(key))
168                .collect::<Result<Vec<_>>>()?;
169            let sequence = mapped.join("+");
170            run_xdotool(&["key", "--clearmodifiers", &sequence])?;
171            Ok(InputExecution {
172                summary: format!("hotkey keys={}", keys.join("+")),
173            })
174        }
175        NativeAction::Drag {
176            from_x,
177            from_y,
178            to_x,
179            to_y,
180            duration_ms,
181            button,
182        } => {
183            let button = xdotool_button(*button);
184            run_xdotool(&["mousemove", &from_x.to_string(), &from_y.to_string()])?;
185            run_xdotool(&["mousedown", button])?;
186            let steps = 10_u64;
187            let sleep_ms = duration_ms.checked_div(steps).unwrap_or(0);
188            for step in 1..=steps {
189                let ratio = step as f64 / steps as f64;
190                let x = from_x + ((*to_x - *from_x) as f64 * ratio).round() as i32;
191                let y = from_y + ((*to_y - *from_y) as f64 * ratio).round() as i32;
192                run_xdotool(&["mousemove", &x.to_string(), &y.to_string()])?;
193                if sleep_ms > 0 {
194                    thread::sleep(Duration::from_millis(sleep_ms));
195                }
196            }
197            run_xdotool(&["mouseup", button])?;
198            Ok(InputExecution {
199                summary: format!(
200                    "drag from_x={from_x} from_y={from_y} to_x={to_x} to_y={to_y} duration_ms={duration_ms} button={}",
201                    button
202                ),
203            })
204        }
205        NativeAction::Scroll {
206            x,
207            y,
208            delta_x,
209            delta_y,
210        } => {
211            if let (Some(x), Some(y)) = (x, y) {
212                run_xdotool(&["mousemove", &x.to_string(), &y.to_string()])?;
213            }
214            click_scroll(*delta_y, "4", "5")?;
215            click_scroll(*delta_x, "6", "7")?;
216            Ok(InputExecution {
217                summary: format!("scroll delta_x={delta_x} delta_y={delta_y}"),
218            })
219        }
220    }
221}
222
223#[cfg(target_os = "linux")]
224fn run_xdotool(args: &[&str]) -> Result<()> {
225    if std::env::var("DISPLAY").unwrap_or_default().is_empty() {
226        return Err(FerrisError::new(
227            ErrorKind::Execution,
228            "DISPLAY is not set; run FerrisGrid inside an X11 session such as Xvfb/noVNC",
229        ));
230    }
231    let status = Command::new("xdotool")
232        .args(args)
233        .status()
234        .map_err(|error| {
235            FerrisError::new(
236                ErrorKind::Execution,
237                format!("failed to run xdotool: {error}"),
238            )
239        })?;
240    if status.success() {
241        Ok(())
242    } else {
243        Err(FerrisError::new(
244            ErrorKind::Execution,
245            "xdotool failed while sending input to the X11 display",
246        ))
247    }
248}
249
250#[cfg(target_os = "linux")]
251fn xdotool_button(button: MouseButton) -> &'static str {
252    match button {
253        MouseButton::Left => "1",
254        MouseButton::Middle => "2",
255        MouseButton::Right => "3",
256    }
257}
258
259#[cfg(target_os = "linux")]
260fn click_scroll(
261    delta: i32,
262    positive_button: &'static str,
263    negative_button: &'static str,
264) -> Result<()> {
265    let button = if delta > 0 {
266        positive_button
267    } else if delta < 0 {
268        negative_button
269    } else {
270        return Ok(());
271    };
272    let clicks = ((delta.unsigned_abs() + 119) / 120).clamp(1, 30);
273    for _ in 0..clicks {
274        run_xdotool(&["click", button])?;
275    }
276    Ok(())
277}
278
279#[cfg(target_os = "linux")]
280fn linux_key(key: &str) -> Result<String> {
281    let mapped = match key.to_ascii_lowercase().as_str() {
282        "cmd" | "command" | "meta" | "super" => "Super".to_string(),
283        "ctrl" | "control" => "ctrl".to_string(),
284        "alt" | "option" => "alt".to_string(),
285        "shift" => "shift".to_string(),
286        "enter" | "return" => "Return".to_string(),
287        "tab" => "Tab".to_string(),
288        "escape" | "esc" => "Escape".to_string(),
289        "space" => "space".to_string(),
290        "delete" | "del" => "Delete".to_string(),
291        "backspace" => "BackSpace".to_string(),
292        "up" | "arrowup" => "Up".to_string(),
293        "down" | "arrowdown" => "Down".to_string(),
294        "left" | "arrowleft" => "Left".to_string(),
295        "right" | "arrowright" => "Right".to_string(),
296        value if value.len() == 1 => value.to_string(),
297        other => {
298            return Err(FerrisError::new(
299                ErrorKind::Protocol,
300                format!("unsupported key for native Linux X11 backend: {other}"),
301            ));
302        }
303    };
304    Ok(mapped)
305}
306
307#[cfg(target_os = "macos")]
308fn execute_macos(action: &NativeAction) -> Result<InputExecution> {
309    match action {
310        NativeAction::Click { x, y, button } => {
311            mouse_click(*x, *y, *button, 1)?;
312            Ok(InputExecution {
313                summary: format!("click x={x} y={y} button={}", button.as_str()),
314            })
315        }
316        NativeAction::DoubleClick { x, y, button } => {
317            mouse_click(*x, *y, *button, 2)?;
318            Ok(InputExecution {
319                summary: format!("double_click x={x} y={y} button={}", button.as_str()),
320            })
321        }
322        NativeAction::RightClick { x, y } => {
323            mouse_click(*x, *y, MouseButton::Right, 1)?;
324            Ok(InputExecution {
325                summary: format!("right_click x={x} y={y}"),
326            })
327        }
328        NativeAction::MoveMouse { x, y } => {
329            mouse_move(*x, *y)?;
330            Ok(InputExecution {
331                summary: format!("move_mouse x={x} y={y}"),
332            })
333        }
334        NativeAction::Wait { duration_ms } => {
335            thread::sleep(Duration::from_millis(*duration_ms));
336            Ok(InputExecution {
337                summary: format!("wait duration_ms={duration_ms}"),
338            })
339        }
340        NativeAction::Type { text } => {
341            run_osascript(&format!(
342                "tell application \"System Events\" to keystroke \"{}\"",
343                escape_applescript(text)
344            ))?;
345            Ok(InputExecution {
346                summary: "type text=<redacted>".to_string(),
347            })
348        }
349        NativeAction::PressKey { key } => {
350            run_osascript(&format!(
351                "tell application \"System Events\" to key code {}",
352                key_code(key)?
353            ))?;
354            Ok(InputExecution {
355                summary: format!("press_key key={key}"),
356            })
357        }
358        NativeAction::Hotkey { keys } => {
359            run_hotkey(keys)?;
360            Ok(InputExecution {
361                summary: format!("hotkey keys={}", keys.join("+")),
362            })
363        }
364        NativeAction::Drag {
365            from_x,
366            from_y,
367            to_x,
368            to_y,
369            duration_ms,
370            button,
371        } => {
372            mouse_drag(*from_x, *from_y, *to_x, *to_y, *duration_ms, *button)?;
373            Ok(InputExecution {
374                summary: format!(
375                    "drag from_x={from_x} from_y={from_y} to_x={to_x} to_y={to_y} duration_ms={duration_ms} button={}",
376                    button.as_str()
377                ),
378            })
379        }
380        NativeAction::Scroll {
381            delta_x, delta_y, ..
382        } => {
383            scroll(*delta_x, *delta_y)?;
384            Ok(InputExecution {
385                summary: format!("scroll delta_x={delta_x} delta_y={delta_y}"),
386            })
387        }
388    }
389}
390
391#[cfg(target_os = "macos")]
392#[repr(C)]
393#[derive(Clone, Copy)]
394struct CGPoint {
395    x: f64,
396    y: f64,
397}
398
399#[cfg(target_os = "macos")]
400#[link(name = "ApplicationServices", kind = "framework")]
401unsafe extern "C" {
402    fn CGEventCreateMouseEvent(
403        source: *const std::ffi::c_void,
404        mouse_type: u32,
405        mouse_cursor_position: CGPoint,
406        mouse_button: u32,
407    ) -> *mut std::ffi::c_void;
408    fn CGEventPost(tap: u32, event: *mut std::ffi::c_void);
409    fn CGEventCreateScrollWheelEvent(
410        source: *const std::ffi::c_void,
411        units: u32,
412        wheel_count: u32,
413        wheel1: i32,
414        ...
415    ) -> *mut std::ffi::c_void;
416    fn CFRelease(cf: *mut std::ffi::c_void);
417}
418
419#[cfg(target_os = "macos")]
420fn mouse_move(x: i32, y: i32) -> Result<()> {
421    post_mouse(5, x, y, 0)
422}
423
424#[cfg(target_os = "macos")]
425fn mouse_click(x: i32, y: i32, button: MouseButton, count: u8) -> Result<()> {
426    let (down, up, button_code) = match button {
427        MouseButton::Left => (1, 2, 0),
428        MouseButton::Right => (3, 4, 1),
429        MouseButton::Middle => (25, 26, 2),
430    };
431    for _ in 0..count {
432        post_mouse(down, x, y, button_code)?;
433        post_mouse(up, x, y, button_code)?;
434    }
435    Ok(())
436}
437
438#[cfg(target_os = "macos")]
439fn mouse_drag(
440    from_x: i32,
441    from_y: i32,
442    to_x: i32,
443    to_y: i32,
444    duration_ms: u64,
445    button: MouseButton,
446) -> Result<()> {
447    let (down, up, dragged, button_code) = match button {
448        MouseButton::Left => (1, 2, 6, 0),
449        MouseButton::Right => (3, 4, 7, 1),
450        MouseButton::Middle => (25, 26, 27, 2),
451    };
452    post_mouse(down, from_x, from_y, button_code)?;
453    let steps = 10_u64;
454    let sleep_ms = duration_ms.checked_div(steps).unwrap_or(0);
455    for step in 1..=steps {
456        let ratio = step as f64 / steps as f64;
457        let x = from_x + ((to_x - from_x) as f64 * ratio).round() as i32;
458        let y = from_y + ((to_y - from_y) as f64 * ratio).round() as i32;
459        post_mouse(dragged, x, y, button_code)?;
460        if sleep_ms > 0 {
461            thread::sleep(Duration::from_millis(sleep_ms));
462        }
463    }
464    post_mouse(up, to_x, to_y, button_code)?;
465    Ok(())
466}
467
468#[cfg(target_os = "macos")]
469fn scroll(delta_x: i32, delta_y: i32) -> Result<()> {
470    unsafe {
471        let event = CGEventCreateScrollWheelEvent(std::ptr::null(), 0, 2, delta_y, delta_x);
472        if event.is_null() {
473            return Err(FerrisError::new(
474                ErrorKind::Execution,
475                "failed to create macOS scroll event; check Accessibility permission",
476            ));
477        }
478        CGEventPost(0, event);
479        CFRelease(event);
480    }
481    Ok(())
482}
483
484#[cfg(target_os = "macos")]
485fn post_mouse(event_type: u32, x: i32, y: i32, button: u32) -> Result<()> {
486    unsafe {
487        let event = CGEventCreateMouseEvent(
488            std::ptr::null(),
489            event_type,
490            CGPoint {
491                x: x as f64,
492                y: y as f64,
493            },
494            button,
495        );
496        if event.is_null() {
497            return Err(FerrisError::new(
498                ErrorKind::Execution,
499                "failed to create macOS mouse event; check Accessibility permission",
500            ));
501        }
502        CGEventPost(0, event);
503        CFRelease(event);
504    }
505    Ok(())
506}
507
508#[cfg(target_os = "macos")]
509fn run_osascript(script: &str) -> Result<()> {
510    let status = Command::new("osascript")
511        .arg("-e")
512        .arg(script)
513        .status()
514        .map_err(|error| FerrisError::new(ErrorKind::Execution, error.to_string()))?;
515    if status.success() {
516        Ok(())
517    } else {
518        Err(FerrisError::new(
519            ErrorKind::Execution,
520            "osascript failed; check Accessibility permission",
521        ))
522    }
523}
524
525#[cfg(target_os = "macos")]
526fn run_hotkey(keys: &[String]) -> Result<()> {
527    let Some(last) = keys.last() else {
528        return Err(FerrisError::new(
529            ErrorKind::Protocol,
530            "hotkey keys are required",
531        ));
532    };
533    let modifiers: Vec<&str> = keys[..keys.len().saturating_sub(1)]
534        .iter()
535        .filter_map(|key| match key.to_ascii_lowercase().as_str() {
536            "cmd" | "command" | "meta" => Some("command down"),
537            "ctrl" | "control" => Some("control down"),
538            "alt" | "option" => Some("option down"),
539            "shift" => Some("shift down"),
540            _ => None,
541        })
542        .collect();
543    let script = if modifiers.is_empty() {
544        format!(
545            "tell application \"System Events\" to keystroke \"{}\"",
546            escape_applescript(last)
547        )
548    } else {
549        format!(
550            "tell application \"System Events\" to keystroke \"{}\" using {{{}}}",
551            escape_applescript(last),
552            modifiers.join(", ")
553        )
554    };
555    run_osascript(&script)
556}
557
558#[cfg(target_os = "macos")]
559fn key_code(key: &str) -> Result<u16> {
560    match key.to_ascii_lowercase().as_str() {
561        "enter" | "return" => Ok(36),
562        "tab" => Ok(48),
563        "escape" | "esc" => Ok(53),
564        "space" => Ok(49),
565        "delete" | "backspace" => Ok(51),
566        other => Err(FerrisError::new(
567            ErrorKind::Protocol,
568            format!("unsupported key for native macOS backend: {other}"),
569        )),
570    }
571}
572
573#[cfg(target_os = "macos")]
574fn escape_applescript(value: &str) -> String {
575    value.replace('\\', "\\\\").replace('"', "\\\"")
576}