1#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2use ferrisgrid_core::MouseButton;
3use ferrisgrid_core::{
4 ErrorKind, FerrisError, InputBackend, InputCapabilities, InputExecution, NativeAction, Result,
5};
6#[cfg(any(target_os = "linux", target_os = "macos"))]
7use std::process::Command;
8#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
9use std::thread;
10#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
11use std::time::Duration;
12
13pub struct FakeInputBackend;
14
15impl InputBackend for FakeInputBackend {
16 fn name(&self) -> &'static str {
17 "fake"
18 }
19
20 fn capabilities(&self) -> InputCapabilities {
21 InputCapabilities {
22 can_mouse: true,
23 can_keyboard: true,
24 }
25 }
26
27 fn execute(&self, action: &NativeAction) -> Result<InputExecution> {
28 Ok(InputExecution {
29 summary: format!("fake_execute {action:?}"),
30 })
31 }
32}
33
34pub struct MacOsInputBackend;
35
36impl InputBackend for MacOsInputBackend {
37 fn name(&self) -> &'static str {
38 "native-macos"
39 }
40
41 fn capabilities(&self) -> InputCapabilities {
42 InputCapabilities {
43 can_mouse: cfg!(target_os = "macos"),
44 can_keyboard: cfg!(target_os = "macos"),
45 }
46 }
47
48 fn execute(&self, action: &NativeAction) -> Result<InputExecution> {
49 #[cfg(target_os = "macos")]
50 {
51 execute_macos(action)
52 }
53 #[cfg(not(target_os = "macos"))]
54 {
55 let _ = action;
56 Err(FerrisError::new(
57 ErrorKind::Platform,
58 "native input is currently implemented for macOS only; use --backend fake",
59 ))
60 }
61 }
62}
63
64pub struct LinuxInputBackend;
65
66impl InputBackend for LinuxInputBackend {
67 fn name(&self) -> &'static str {
68 "native-linux-x11"
69 }
70
71 fn capabilities(&self) -> InputCapabilities {
72 InputCapabilities {
73 can_mouse: cfg!(target_os = "linux"),
74 can_keyboard: cfg!(target_os = "linux"),
75 }
76 }
77
78 fn execute(&self, action: &NativeAction) -> Result<InputExecution> {
79 #[cfg(target_os = "linux")]
80 {
81 execute_linux(action)
82 }
83 #[cfg(not(target_os = "linux"))]
84 {
85 let _ = action;
86 Err(FerrisError::new(
87 ErrorKind::Platform,
88 "native Linux X11 input is only available on Linux; use --backend native on this OS or --backend fake",
89 ))
90 }
91 }
92}
93
94pub struct WindowsInputBackend;
95
96impl InputBackend for WindowsInputBackend {
97 fn name(&self) -> &'static str {
98 "native-windows"
99 }
100
101 fn capabilities(&self) -> InputCapabilities {
102 InputCapabilities {
103 can_mouse: cfg!(target_os = "windows"),
104 can_keyboard: cfg!(target_os = "windows"),
105 }
106 }
107
108 fn execute(&self, action: &NativeAction) -> Result<InputExecution> {
109 #[cfg(target_os = "windows")]
110 {
111 execute_windows(action)
112 }
113 #[cfg(not(target_os = "windows"))]
114 {
115 let _ = action;
116 Err(FerrisError::new(
117 ErrorKind::Platform,
118 "native Windows input is only available on Windows; use --backend native on this OS or --backend fake",
119 ))
120 }
121 }
122}
123
124pub fn backend_by_name(name: &str) -> Box<dyn InputBackend> {
125 match name {
126 "fake" => Box::new(FakeInputBackend),
127 "native" => native_backend(),
128 "macos" | "native-macos" => Box::new(MacOsInputBackend),
129 "linux" | "x11" | "native-linux" | "native-linux-x11" => Box::new(LinuxInputBackend),
130 "windows" | "win32" | "native-windows" => Box::new(WindowsInputBackend),
131 _ => native_backend(),
132 }
133}
134
135fn native_backend() -> Box<dyn InputBackend> {
136 #[cfg(target_os = "linux")]
137 {
138 Box::new(LinuxInputBackend)
139 }
140 #[cfg(target_os = "macos")]
141 {
142 Box::new(MacOsInputBackend)
143 }
144 #[cfg(target_os = "windows")]
145 {
146 Box::new(WindowsInputBackend)
147 }
148 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
149 {
150 Box::new(MacOsInputBackend)
151 }
152}
153
154#[cfg(target_os = "windows")]
155fn execute_windows(action: &NativeAction) -> Result<InputExecution> {
156 match action {
157 NativeAction::Click { x, y, button } => {
158 windows_click(*x, *y, *button, 1)?;
159 Ok(InputExecution {
160 summary: format!("click x={x} y={y} button={}", button.as_str()),
161 })
162 }
163 NativeAction::DoubleClick { x, y, button } => {
164 windows_click(*x, *y, *button, 2)?;
165 Ok(InputExecution {
166 summary: format!("double_click x={x} y={y} button={}", button.as_str()),
167 })
168 }
169 NativeAction::RightClick { x, y } => {
170 windows_click(*x, *y, MouseButton::Right, 1)?;
171 Ok(InputExecution {
172 summary: format!("right_click x={x} y={y}"),
173 })
174 }
175 NativeAction::MoveMouse { x, y } => {
176 windows_move(*x, *y)?;
177 Ok(InputExecution {
178 summary: format!("move_mouse x={x} y={y}"),
179 })
180 }
181 NativeAction::Wait { duration_ms } => {
182 thread::sleep(Duration::from_millis(*duration_ms));
183 Ok(InputExecution {
184 summary: format!("wait duration_ms={duration_ms}"),
185 })
186 }
187 NativeAction::Type { text } => {
188 windows_type(text)?;
189 Ok(InputExecution {
190 summary: "type text=<redacted>".to_string(),
191 })
192 }
193 NativeAction::PressKey { key } => {
194 windows_press_key(key)?;
195 Ok(InputExecution {
196 summary: format!("press_key key={key}"),
197 })
198 }
199 NativeAction::Hotkey { keys } => {
200 windows_hotkey(keys)?;
201 Ok(InputExecution {
202 summary: format!("hotkey keys={}", keys.join("+")),
203 })
204 }
205 NativeAction::Drag {
206 from_x,
207 from_y,
208 to_x,
209 to_y,
210 duration_ms,
211 button,
212 } => {
213 windows_drag(*from_x, *from_y, *to_x, *to_y, *duration_ms, *button)?;
214 Ok(InputExecution {
215 summary: format!(
216 "drag from_x={from_x} from_y={from_y} to_x={to_x} to_y={to_y} duration_ms={duration_ms} button={}",
217 button.as_str()
218 ),
219 })
220 }
221 NativeAction::Scroll {
222 x,
223 y,
224 delta_x,
225 delta_y,
226 } => {
227 if let (Some(x), Some(y)) = (x, y) {
228 windows_move(*x, *y)?;
229 }
230 if *delta_y != 0 {
231 send_windows_mouse(MOUSEEVENTF_WHEEL, *delta_y as u32)?;
232 }
233 if *delta_x != 0 {
234 send_windows_mouse(MOUSEEVENTF_HWHEEL, *delta_x as u32)?;
235 }
236 Ok(InputExecution {
237 summary: format!("scroll delta_x={delta_x} delta_y={delta_y}"),
238 })
239 }
240 }
241}
242
243#[cfg(target_os = "windows")]
244fn windows_move(x: i32, y: i32) -> Result<()> {
245 if unsafe { SetCursorPos(x, y) } == 0 {
246 return Err(windows_input_error("SetCursorPos failed"));
247 }
248 Ok(())
249}
250
251#[cfg(target_os = "windows")]
252fn windows_click(x: i32, y: i32, button: MouseButton, count: u8) -> Result<()> {
253 windows_move(x, y)?;
254 let (down, up) = windows_button_flags(button);
255 for _ in 0..count {
256 send_windows_mouse(down, 0)?;
257 send_windows_mouse(up, 0)?;
258 }
259 Ok(())
260}
261
262#[cfg(target_os = "windows")]
263fn windows_drag(
264 from_x: i32,
265 from_y: i32,
266 to_x: i32,
267 to_y: i32,
268 duration_ms: u64,
269 button: MouseButton,
270) -> Result<()> {
271 windows_move(from_x, from_y)?;
272 let (down, up) = windows_button_flags(button);
273 send_windows_mouse(down, 0)?;
274 let steps = 10_u64;
275 let sleep_ms = duration_ms.checked_div(steps).unwrap_or(0);
276 for step in 1..=steps {
277 let ratio = step as f64 / steps as f64;
278 let x = from_x + ((to_x - from_x) as f64 * ratio).round() as i32;
279 let y = from_y + ((to_y - from_y) as f64 * ratio).round() as i32;
280 windows_move(x, y)?;
281 if sleep_ms > 0 {
282 thread::sleep(Duration::from_millis(sleep_ms));
283 }
284 }
285 send_windows_mouse(up, 0)?;
286 Ok(())
287}
288
289#[cfg(target_os = "windows")]
290fn windows_button_flags(button: MouseButton) -> (u32, u32) {
291 match button {
292 MouseButton::Left => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
293 MouseButton::Right => (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP),
294 MouseButton::Middle => (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP),
295 }
296}
297
298#[cfg(target_os = "windows")]
299fn send_windows_mouse(flags: u32, data: u32) -> Result<()> {
300 let input = WinInput {
301 input_type: INPUT_MOUSE,
302 value: WinInputValue {
303 mouse: MouseInput {
304 dx: 0,
305 dy: 0,
306 mouse_data: data,
307 flags,
308 time: 0,
309 extra_info: 0,
310 },
311 },
312 };
313 send_windows_inputs(&[input])
314}
315
316#[cfg(target_os = "windows")]
317fn windows_type(text: &str) -> Result<()> {
318 let mut inputs = Vec::with_capacity(text.len() * 2);
319 for code_unit in text.encode_utf16() {
320 inputs.push(windows_keyboard_input(0, code_unit, KEYEVENTF_UNICODE));
321 inputs.push(windows_keyboard_input(
322 0,
323 code_unit,
324 KEYEVENTF_UNICODE | KEYEVENTF_KEYUP,
325 ));
326 }
327 send_windows_inputs(&inputs)
328}
329
330#[cfg(target_os = "windows")]
331fn windows_press_key(key: &str) -> Result<()> {
332 let virtual_key = windows_virtual_key(key)?;
333 send_windows_inputs(&[
334 windows_keyboard_input(virtual_key, 0, 0),
335 windows_keyboard_input(virtual_key, 0, KEYEVENTF_KEYUP),
336 ])
337}
338
339#[cfg(target_os = "windows")]
340fn windows_hotkey(keys: &[String]) -> Result<()> {
341 if keys.is_empty() {
342 return Err(FerrisError::new(
343 ErrorKind::Protocol,
344 "hotkey keys are required",
345 ));
346 }
347 let mapped = keys
348 .iter()
349 .map(|key| windows_virtual_key(key))
350 .collect::<Result<Vec<_>>>()?;
351 let mut inputs = Vec::with_capacity(mapped.len() * 2);
352 for key in &mapped {
353 inputs.push(windows_keyboard_input(*key, 0, 0));
354 }
355 for key in mapped.iter().rev() {
356 inputs.push(windows_keyboard_input(*key, 0, KEYEVENTF_KEYUP));
357 }
358 send_windows_inputs(&inputs)
359}
360
361#[cfg(target_os = "windows")]
362fn windows_virtual_key(key: &str) -> Result<u16> {
363 if let Some(key) = named_windows_virtual_key(key) {
364 return Ok(key);
365 }
366 let mut chars = key.chars();
367 let Some(value) = chars.next() else {
368 return Err(unsupported_windows_key(key));
369 };
370 if chars.next().is_some() || value.len_utf16() != 1 {
371 return Err(unsupported_windows_key(key));
372 }
373 let mapped = unsafe { VkKeyScanW(value as u16) };
374 if mapped == -1 {
375 Err(unsupported_windows_key(key))
376 } else {
377 Ok((mapped as u16) & 0xff)
378 }
379}
380
381#[cfg(any(target_os = "windows", test))]
382fn named_windows_virtual_key(key: &str) -> Option<u16> {
383 match key.to_ascii_lowercase().as_str() {
384 "cmd" | "command" | "meta" | "super" | "win" | "windows" => Some(0x5b),
385 "ctrl" | "control" => Some(0x11),
386 "alt" | "option" => Some(0x12),
387 "shift" => Some(0x10),
388 "enter" | "return" => Some(0x0d),
389 "tab" => Some(0x09),
390 "escape" | "esc" => Some(0x1b),
391 "space" => Some(0x20),
392 "delete" | "del" => Some(0x2e),
393 "backspace" => Some(0x08),
394 "up" | "arrowup" => Some(0x26),
395 "down" | "arrowdown" => Some(0x28),
396 "left" | "arrowleft" => Some(0x25),
397 "right" | "arrowright" => Some(0x27),
398 _ => None,
399 }
400}
401
402#[cfg(target_os = "windows")]
403fn unsupported_windows_key(key: &str) -> FerrisError {
404 FerrisError::new(
405 ErrorKind::Protocol,
406 format!("unsupported key for native Windows backend: {key}"),
407 )
408}
409
410#[cfg(target_os = "windows")]
411fn windows_keyboard_input(virtual_key: u16, scan: u16, flags: u32) -> WinInput {
412 WinInput {
413 input_type: INPUT_KEYBOARD,
414 value: WinInputValue {
415 keyboard: KeyboardInput {
416 virtual_key,
417 scan,
418 flags,
419 time: 0,
420 extra_info: 0,
421 },
422 },
423 }
424}
425
426#[cfg(target_os = "windows")]
427fn send_windows_inputs(inputs: &[WinInput]) -> Result<()> {
428 if inputs.is_empty() {
429 return Ok(());
430 }
431 let sent = unsafe {
432 SendInput(
433 inputs.len() as u32,
434 inputs.as_ptr(),
435 std::mem::size_of::<WinInput>() as i32,
436 )
437 };
438 if sent != inputs.len() as u32 {
439 return Err(windows_input_error(
440 "SendInput failed; Windows can block input to an elevated application or secure desktop",
441 ));
442 }
443 Ok(())
444}
445
446#[cfg(target_os = "windows")]
447fn windows_input_error(context: &str) -> FerrisError {
448 FerrisError::new(
449 ErrorKind::Execution,
450 format!("{context}: {}", std::io::Error::last_os_error()),
451 )
452}
453
454#[cfg(target_os = "linux")]
455fn execute_linux(action: &NativeAction) -> Result<InputExecution> {
456 match action {
457 NativeAction::Click { x, y, button } => {
458 run_xdotool(&["mousemove", &x.to_string(), &y.to_string()])?;
459 run_xdotool(&["click", xdotool_button(*button)])?;
460 Ok(InputExecution {
461 summary: format!("click x={x} y={y} button={}", button.as_str()),
462 })
463 }
464 NativeAction::DoubleClick { x, y, button } => {
465 run_xdotool(&["mousemove", &x.to_string(), &y.to_string()])?;
466 run_xdotool(&["click", "--repeat", "2", xdotool_button(*button)])?;
467 Ok(InputExecution {
468 summary: format!("double_click x={x} y={y} button={}", button.as_str()),
469 })
470 }
471 NativeAction::RightClick { x, y } => {
472 run_xdotool(&["mousemove", &x.to_string(), &y.to_string()])?;
473 run_xdotool(&["click", "3"])?;
474 Ok(InputExecution {
475 summary: format!("right_click x={x} y={y}"),
476 })
477 }
478 NativeAction::MoveMouse { x, y } => {
479 run_xdotool(&["mousemove", &x.to_string(), &y.to_string()])?;
480 Ok(InputExecution {
481 summary: format!("move_mouse x={x} y={y}"),
482 })
483 }
484 NativeAction::Wait { duration_ms } => {
485 thread::sleep(Duration::from_millis(*duration_ms));
486 Ok(InputExecution {
487 summary: format!("wait duration_ms={duration_ms}"),
488 })
489 }
490 NativeAction::Type { text } => {
491 run_xdotool(&["type", "--clearmodifiers", "--", text])?;
492 Ok(InputExecution {
493 summary: "type text=<redacted>".to_string(),
494 })
495 }
496 NativeAction::PressKey { key } => {
497 let mapped = linux_key(key)?;
498 run_xdotool(&["key", "--clearmodifiers", &mapped])?;
499 Ok(InputExecution {
500 summary: format!("press_key key={key}"),
501 })
502 }
503 NativeAction::Hotkey { keys } => {
504 let mapped = keys
505 .iter()
506 .map(|key| linux_key(key))
507 .collect::<Result<Vec<_>>>()?;
508 let sequence = mapped.join("+");
509 run_xdotool(&["key", "--clearmodifiers", &sequence])?;
510 Ok(InputExecution {
511 summary: format!("hotkey keys={}", keys.join("+")),
512 })
513 }
514 NativeAction::Drag {
515 from_x,
516 from_y,
517 to_x,
518 to_y,
519 duration_ms,
520 button,
521 } => {
522 let button = xdotool_button(*button);
523 run_xdotool(&["mousemove", &from_x.to_string(), &from_y.to_string()])?;
524 run_xdotool(&["mousedown", button])?;
525 let steps = 10_u64;
526 let sleep_ms = duration_ms.checked_div(steps).unwrap_or(0);
527 for step in 1..=steps {
528 let ratio = step as f64 / steps as f64;
529 let x = from_x + ((*to_x - *from_x) as f64 * ratio).round() as i32;
530 let y = from_y + ((*to_y - *from_y) as f64 * ratio).round() as i32;
531 run_xdotool(&["mousemove", &x.to_string(), &y.to_string()])?;
532 if sleep_ms > 0 {
533 thread::sleep(Duration::from_millis(sleep_ms));
534 }
535 }
536 run_xdotool(&["mouseup", button])?;
537 Ok(InputExecution {
538 summary: format!(
539 "drag from_x={from_x} from_y={from_y} to_x={to_x} to_y={to_y} duration_ms={duration_ms} button={}",
540 button
541 ),
542 })
543 }
544 NativeAction::Scroll {
545 x,
546 y,
547 delta_x,
548 delta_y,
549 } => {
550 if let (Some(x), Some(y)) = (x, y) {
551 run_xdotool(&["mousemove", &x.to_string(), &y.to_string()])?;
552 }
553 click_scroll(*delta_y, "4", "5")?;
554 click_scroll(*delta_x, "6", "7")?;
555 Ok(InputExecution {
556 summary: format!("scroll delta_x={delta_x} delta_y={delta_y}"),
557 })
558 }
559 }
560}
561
562#[cfg(target_os = "linux")]
563fn run_xdotool(args: &[&str]) -> Result<()> {
564 if std::env::var("DISPLAY").unwrap_or_default().is_empty() {
565 return Err(FerrisError::new(
566 ErrorKind::Execution,
567 "DISPLAY is not set; run FerrisGrid inside an X11 session such as Xvfb/noVNC",
568 ));
569 }
570 let status = Command::new("xdotool")
571 .args(args)
572 .status()
573 .map_err(|error| {
574 FerrisError::new(
575 ErrorKind::Execution,
576 format!("failed to run xdotool: {error}"),
577 )
578 })?;
579 if status.success() {
580 Ok(())
581 } else {
582 Err(FerrisError::new(
583 ErrorKind::Execution,
584 "xdotool failed while sending input to the X11 display",
585 ))
586 }
587}
588
589#[cfg(target_os = "linux")]
590fn xdotool_button(button: MouseButton) -> &'static str {
591 match button {
592 MouseButton::Left => "1",
593 MouseButton::Middle => "2",
594 MouseButton::Right => "3",
595 }
596}
597
598#[cfg(target_os = "linux")]
599fn click_scroll(
600 delta: i32,
601 positive_button: &'static str,
602 negative_button: &'static str,
603) -> Result<()> {
604 let button = if delta > 0 {
605 positive_button
606 } else if delta < 0 {
607 negative_button
608 } else {
609 return Ok(());
610 };
611 let clicks = ((delta.unsigned_abs() + 119) / 120).clamp(1, 30);
612 for _ in 0..clicks {
613 run_xdotool(&["click", button])?;
614 }
615 Ok(())
616}
617
618#[cfg(target_os = "linux")]
619fn linux_key(key: &str) -> Result<String> {
620 let mapped = match key.to_ascii_lowercase().as_str() {
621 "cmd" | "command" | "meta" | "super" => "Super".to_string(),
622 "ctrl" | "control" => "ctrl".to_string(),
623 "alt" | "option" => "alt".to_string(),
624 "shift" => "shift".to_string(),
625 "enter" | "return" => "Return".to_string(),
626 "tab" => "Tab".to_string(),
627 "escape" | "esc" => "Escape".to_string(),
628 "space" => "space".to_string(),
629 "delete" | "del" => "Delete".to_string(),
630 "backspace" => "BackSpace".to_string(),
631 "up" | "arrowup" => "Up".to_string(),
632 "down" | "arrowdown" => "Down".to_string(),
633 "left" | "arrowleft" => "Left".to_string(),
634 "right" | "arrowright" => "Right".to_string(),
635 value if value.len() == 1 => value.to_string(),
636 other => {
637 return Err(FerrisError::new(
638 ErrorKind::Protocol,
639 format!("unsupported key for native Linux X11 backend: {other}"),
640 ));
641 }
642 };
643 Ok(mapped)
644}
645
646#[cfg(target_os = "macos")]
647fn execute_macos(action: &NativeAction) -> Result<InputExecution> {
648 match action {
649 NativeAction::Click { x, y, button } => {
650 mouse_click(*x, *y, *button, 1)?;
651 Ok(InputExecution {
652 summary: format!("click x={x} y={y} button={}", button.as_str()),
653 })
654 }
655 NativeAction::DoubleClick { x, y, button } => {
656 mouse_click(*x, *y, *button, 2)?;
657 Ok(InputExecution {
658 summary: format!("double_click x={x} y={y} button={}", button.as_str()),
659 })
660 }
661 NativeAction::RightClick { x, y } => {
662 mouse_click(*x, *y, MouseButton::Right, 1)?;
663 Ok(InputExecution {
664 summary: format!("right_click x={x} y={y}"),
665 })
666 }
667 NativeAction::MoveMouse { x, y } => {
668 mouse_move(*x, *y)?;
669 Ok(InputExecution {
670 summary: format!("move_mouse x={x} y={y}"),
671 })
672 }
673 NativeAction::Wait { duration_ms } => {
674 thread::sleep(Duration::from_millis(*duration_ms));
675 Ok(InputExecution {
676 summary: format!("wait duration_ms={duration_ms}"),
677 })
678 }
679 NativeAction::Type { text } => {
680 run_osascript(&format!(
681 "tell application \"System Events\" to keystroke \"{}\"",
682 escape_applescript(text)
683 ))?;
684 Ok(InputExecution {
685 summary: "type text=<redacted>".to_string(),
686 })
687 }
688 NativeAction::PressKey { key } => {
689 run_osascript(&format!(
690 "tell application \"System Events\" to key code {}",
691 key_code(key)?
692 ))?;
693 Ok(InputExecution {
694 summary: format!("press_key key={key}"),
695 })
696 }
697 NativeAction::Hotkey { keys } => {
698 run_hotkey(keys)?;
699 Ok(InputExecution {
700 summary: format!("hotkey keys={}", keys.join("+")),
701 })
702 }
703 NativeAction::Drag {
704 from_x,
705 from_y,
706 to_x,
707 to_y,
708 duration_ms,
709 button,
710 } => {
711 mouse_drag(*from_x, *from_y, *to_x, *to_y, *duration_ms, *button)?;
712 Ok(InputExecution {
713 summary: format!(
714 "drag from_x={from_x} from_y={from_y} to_x={to_x} to_y={to_y} duration_ms={duration_ms} button={}",
715 button.as_str()
716 ),
717 })
718 }
719 NativeAction::Scroll {
720 delta_x, delta_y, ..
721 } => {
722 scroll(*delta_x, *delta_y)?;
723 Ok(InputExecution {
724 summary: format!("scroll delta_x={delta_x} delta_y={delta_y}"),
725 })
726 }
727 }
728}
729
730#[cfg(target_os = "macos")]
731#[repr(C)]
732#[derive(Clone, Copy)]
733struct CGPoint {
734 x: f64,
735 y: f64,
736}
737
738#[cfg(target_os = "macos")]
739#[link(name = "ApplicationServices", kind = "framework")]
740unsafe extern "C" {
741 fn CGEventCreateMouseEvent(
742 source: *const std::ffi::c_void,
743 mouse_type: u32,
744 mouse_cursor_position: CGPoint,
745 mouse_button: u32,
746 ) -> *mut std::ffi::c_void;
747 fn CGEventPost(tap: u32, event: *mut std::ffi::c_void);
748 fn CGEventCreateScrollWheelEvent(
749 source: *const std::ffi::c_void,
750 units: u32,
751 wheel_count: u32,
752 wheel1: i32,
753 ...
754 ) -> *mut std::ffi::c_void;
755 fn CFRelease(cf: *mut std::ffi::c_void);
756}
757
758#[cfg(target_os = "macos")]
759fn mouse_move(x: i32, y: i32) -> Result<()> {
760 post_mouse(5, x, y, 0)
761}
762
763#[cfg(target_os = "macos")]
764fn mouse_click(x: i32, y: i32, button: MouseButton, count: u8) -> Result<()> {
765 let (down, up, button_code) = match button {
766 MouseButton::Left => (1, 2, 0),
767 MouseButton::Right => (3, 4, 1),
768 MouseButton::Middle => (25, 26, 2),
769 };
770 for _ in 0..count {
771 post_mouse(down, x, y, button_code)?;
772 post_mouse(up, x, y, button_code)?;
773 }
774 Ok(())
775}
776
777#[cfg(target_os = "macos")]
778fn mouse_drag(
779 from_x: i32,
780 from_y: i32,
781 to_x: i32,
782 to_y: i32,
783 duration_ms: u64,
784 button: MouseButton,
785) -> Result<()> {
786 let (down, up, dragged, button_code) = match button {
787 MouseButton::Left => (1, 2, 6, 0),
788 MouseButton::Right => (3, 4, 7, 1),
789 MouseButton::Middle => (25, 26, 27, 2),
790 };
791 post_mouse(down, from_x, from_y, button_code)?;
792 let steps = 10_u64;
793 let sleep_ms = duration_ms.checked_div(steps).unwrap_or(0);
794 for step in 1..=steps {
795 let ratio = step as f64 / steps as f64;
796 let x = from_x + ((to_x - from_x) as f64 * ratio).round() as i32;
797 let y = from_y + ((to_y - from_y) as f64 * ratio).round() as i32;
798 post_mouse(dragged, x, y, button_code)?;
799 if sleep_ms > 0 {
800 thread::sleep(Duration::from_millis(sleep_ms));
801 }
802 }
803 post_mouse(up, to_x, to_y, button_code)?;
804 Ok(())
805}
806
807#[cfg(target_os = "macos")]
808fn scroll(delta_x: i32, delta_y: i32) -> Result<()> {
809 unsafe {
810 let event = CGEventCreateScrollWheelEvent(std::ptr::null(), 0, 2, delta_y, delta_x);
811 if event.is_null() {
812 return Err(FerrisError::new(
813 ErrorKind::Execution,
814 "failed to create macOS scroll event; check Accessibility permission",
815 ));
816 }
817 CGEventPost(0, event);
818 CFRelease(event);
819 }
820 Ok(())
821}
822
823#[cfg(target_os = "macos")]
824fn post_mouse(event_type: u32, x: i32, y: i32, button: u32) -> Result<()> {
825 unsafe {
826 let event = CGEventCreateMouseEvent(
827 std::ptr::null(),
828 event_type,
829 CGPoint {
830 x: x as f64,
831 y: y as f64,
832 },
833 button,
834 );
835 if event.is_null() {
836 return Err(FerrisError::new(
837 ErrorKind::Execution,
838 "failed to create macOS mouse event; check Accessibility permission",
839 ));
840 }
841 CGEventPost(0, event);
842 CFRelease(event);
843 }
844 Ok(())
845}
846
847#[cfg(target_os = "macos")]
848fn run_osascript(script: &str) -> Result<()> {
849 let status = Command::new("osascript")
850 .arg("-e")
851 .arg(script)
852 .status()
853 .map_err(|error| FerrisError::new(ErrorKind::Execution, error.to_string()))?;
854 if status.success() {
855 Ok(())
856 } else {
857 Err(FerrisError::new(
858 ErrorKind::Execution,
859 "osascript failed; check Accessibility permission",
860 ))
861 }
862}
863
864#[cfg(target_os = "macos")]
865fn run_hotkey(keys: &[String]) -> Result<()> {
866 let Some(last) = keys.last() else {
867 return Err(FerrisError::new(
868 ErrorKind::Protocol,
869 "hotkey keys are required",
870 ));
871 };
872 let modifiers: Vec<&str> = keys[..keys.len().saturating_sub(1)]
873 .iter()
874 .filter_map(|key| match key.to_ascii_lowercase().as_str() {
875 "cmd" | "command" | "meta" => Some("command down"),
876 "ctrl" | "control" => Some("control down"),
877 "alt" | "option" => Some("option down"),
878 "shift" => Some("shift down"),
879 _ => None,
880 })
881 .collect();
882 let script = if modifiers.is_empty() {
883 format!(
884 "tell application \"System Events\" to keystroke \"{}\"",
885 escape_applescript(last)
886 )
887 } else {
888 format!(
889 "tell application \"System Events\" to keystroke \"{}\" using {{{}}}",
890 escape_applescript(last),
891 modifiers.join(", ")
892 )
893 };
894 run_osascript(&script)
895}
896
897#[cfg(target_os = "macos")]
898fn key_code(key: &str) -> Result<u16> {
899 match key.to_ascii_lowercase().as_str() {
900 "enter" | "return" => Ok(36),
901 "tab" => Ok(48),
902 "escape" | "esc" => Ok(53),
903 "space" => Ok(49),
904 "delete" | "backspace" => Ok(51),
905 other => Err(FerrisError::new(
906 ErrorKind::Protocol,
907 format!("unsupported key for native macOS backend: {other}"),
908 )),
909 }
910}
911
912#[cfg(target_os = "macos")]
913fn escape_applescript(value: &str) -> String {
914 value.replace('\\', "\\\\").replace('"', "\\\"")
915}
916
917#[cfg(target_os = "windows")]
918const INPUT_MOUSE: u32 = 0;
919#[cfg(target_os = "windows")]
920const INPUT_KEYBOARD: u32 = 1;
921#[cfg(target_os = "windows")]
922const MOUSEEVENTF_LEFTDOWN: u32 = 0x0002;
923#[cfg(target_os = "windows")]
924const MOUSEEVENTF_LEFTUP: u32 = 0x0004;
925#[cfg(target_os = "windows")]
926const MOUSEEVENTF_RIGHTDOWN: u32 = 0x0008;
927#[cfg(target_os = "windows")]
928const MOUSEEVENTF_RIGHTUP: u32 = 0x0010;
929#[cfg(target_os = "windows")]
930const MOUSEEVENTF_MIDDLEDOWN: u32 = 0x0020;
931#[cfg(target_os = "windows")]
932const MOUSEEVENTF_MIDDLEUP: u32 = 0x0040;
933#[cfg(target_os = "windows")]
934const MOUSEEVENTF_WHEEL: u32 = 0x0800;
935#[cfg(target_os = "windows")]
936const MOUSEEVENTF_HWHEEL: u32 = 0x1000;
937#[cfg(target_os = "windows")]
938const KEYEVENTF_KEYUP: u32 = 0x0002;
939#[cfg(target_os = "windows")]
940const KEYEVENTF_UNICODE: u32 = 0x0004;
941
942#[cfg(target_os = "windows")]
943#[repr(C)]
944#[derive(Clone, Copy)]
945struct MouseInput {
946 dx: i32,
947 dy: i32,
948 mouse_data: u32,
949 flags: u32,
950 time: u32,
951 extra_info: usize,
952}
953
954#[cfg(target_os = "windows")]
955#[repr(C)]
956#[derive(Clone, Copy)]
957struct KeyboardInput {
958 virtual_key: u16,
959 scan: u16,
960 flags: u32,
961 time: u32,
962 extra_info: usize,
963}
964
965#[cfg(target_os = "windows")]
966#[repr(C)]
967#[derive(Clone, Copy)]
968union WinInputValue {
969 mouse: MouseInput,
970 keyboard: KeyboardInput,
971}
972
973#[cfg(target_os = "windows")]
974#[repr(C)]
975#[derive(Clone, Copy)]
976struct WinInput {
977 input_type: u32,
978 value: WinInputValue,
979}
980
981#[cfg(target_os = "windows")]
982#[link(name = "user32")]
983unsafe extern "system" {
984 fn SetCursorPos(x: i32, y: i32) -> i32;
985 fn SendInput(count: u32, inputs: *const WinInput, input_size: i32) -> u32;
986 fn VkKeyScanW(character: u16) -> i16;
987}
988
989#[cfg(test)]
990mod tests {
991 use super::*;
992
993 #[test]
994 fn windows_key_aliases_cover_cross_platform_action_names() {
995 assert_eq!(named_windows_virtual_key("cmd"), Some(0x5b));
996 assert_eq!(named_windows_virtual_key("control"), Some(0x11));
997 assert_eq!(named_windows_virtual_key("escape"), Some(0x1b));
998 assert_eq!(named_windows_virtual_key("arrowleft"), Some(0x25));
999 assert_eq!(named_windows_virtual_key("backspace"), Some(0x08));
1000 }
1001
1002 #[test]
1003 fn windows_backend_aliases_are_explicit() {
1004 assert_eq!(backend_by_name("windows").name(), "native-windows");
1005 assert_eq!(backend_by_name("win32").name(), "native-windows");
1006 assert_eq!(backend_by_name("native-windows").name(), "native-windows");
1007 }
1008}