Skip to main content

ferrisgrid_input/
lib.rs

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}