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}