diff --git a/Cargo.lock b/Cargo.lock index b76a599..287771d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -260,6 +260,46 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags", + "core-foundation", + "libc", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -475,6 +515,33 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "futures-core" version = "0.3.32" @@ -1781,6 +1848,21 @@ dependencies = [ "vcli-core", ] +[[package]] +name = "vcli-input" +version = "0.0.1" +dependencies = [ + "core-foundation", + "core-graphics", + "libc", + "proptest", + "serde", + "serde_json", + "tempfile", + "thiserror", + "vcli-core", +] + [[package]] name = "vcli-ipc" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 97750f1..ff522c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/vcli-core", "crates/vcli-capture", "crates/vcli-dsl", + "crates/vcli-input", "crates/vcli-ipc", "crates/vcli-perception", "crates/vcli-store", @@ -37,8 +38,10 @@ hex = "0.4" tracing = "0.1" -# Platform-specific, referenced from vcli-capture only. Declaring here so -# downstream crates that later depend on vcli-capture don't re-select versions. +# Platform-specific (used by vcli-input + vcli-capture) +core-graphics = { version = "0.24", features = ["highsierra"] } +core-foundation = "0.10" +libc = "0.2" screencapturekit = "1" # Added by vcli-perception lane diff --git a/crates/vcli-input/Cargo.toml b/crates/vcli-input/Cargo.toml new file mode 100644 index 0000000..2b942f5 --- /dev/null +++ b/crates/vcli-input/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "vcli-input" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "Input synthesis trait, macOS CGEvent backend, mock, and HIL kill switch for vcli." + +[dependencies] +vcli-core = { path = "../vcli-core" } +thiserror = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +core-graphics = { workspace = true } +core-foundation = { workspace = true } +libc = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +proptest = { workspace = true } diff --git a/crates/vcli-input/README.md b/crates/vcli-input/README.md new file mode 100644 index 0000000..788711a --- /dev/null +++ b/crates/vcli-input/README.md @@ -0,0 +1,5 @@ +# vcli-input + +Input synthesis. Real macOS CGEvent backend, a mock, and a human-in-the-loop kill switch. + +See the design spec at `docs/superpowers/specs/2026-04-16-vcli-design.md` §Input synthesis. diff --git a/crates/vcli-input/src/error.rs b/crates/vcli-input/src/error.rs new file mode 100644 index 0000000..2657684 --- /dev/null +++ b/crates/vcli-input/src/error.rs @@ -0,0 +1,86 @@ +//! Errors surfaced by [`crate::sink::InputSink`] implementations. + +use thiserror::Error; +use vcli_core::ErrorCode; + +/// Error returned by every `InputSink` method. `Halted` wins over all others +/// because the kill switch short-circuits before the backend is even called. +#[derive(Debug, Error)] +pub enum InputError { + /// The kill switch was set; no OS event was posted. + #[error("input halted: kill switch is engaged")] + Halted, + + /// macOS TCC (Accessibility / Input Monitoring) has not granted this process + /// permission to synthesize input. + #[error("input permission denied: {detail}")] + PermissionDenied { + /// Short human-readable reason — which TCC bucket is missing. + detail: String, + }, + + /// Event creation / posting failed at the OS layer. + #[error("backend failure: {detail}")] + Backend { + /// Error message from the OS / FFI call. + detail: String, + }, + + /// Unknown key name in a key-combo action. + #[error("unknown key: {0}")] + UnknownKey(String), + + /// Invalid argument (e.g. empty drag path, `hold_ms > 60_000`). + #[error("invalid input argument: {0}")] + InvalidArgument(String), + + /// Platform does not support this method (Windows stub in v0). + #[error("not implemented on this platform")] + Unimplemented, +} + +impl InputError { + /// Map to the IPC wire-level [`ErrorCode`]. `Halted` maps to `internal` + /// because it is a local-only condition with no external counterpart. + #[must_use] + pub fn to_error_code(&self) -> ErrorCode { + match self { + Self::Halted | Self::Backend { .. } | Self::Unimplemented => ErrorCode::Internal, + Self::PermissionDenied { .. } => ErrorCode::PermissionDenied, + Self::UnknownKey(_) | Self::InvalidArgument(_) => ErrorCode::InvalidProgram, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn halted_display_is_stable() { + assert_eq!( + InputError::Halted.to_string(), + "input halted: kill switch is engaged" + ); + } + + #[test] + fn permission_maps_to_permission_denied() { + let e = InputError::PermissionDenied { + detail: "Accessibility".into(), + }; + assert_eq!(e.to_error_code(), ErrorCode::PermissionDenied); + } + + #[test] + fn unknown_key_maps_to_invalid_program() { + let e = InputError::UnknownKey("blargh".into()); + assert_eq!(e.to_error_code(), ErrorCode::InvalidProgram); + assert!(e.to_string().contains("blargh")); + } + + #[test] + fn halted_maps_to_internal() { + assert_eq!(InputError::Halted.to_error_code(), ErrorCode::Internal); + } +} diff --git a/crates/vcli-input/src/keymap.rs b/crates/vcli-input/src/keymap.rs new file mode 100644 index 0000000..d297b35 --- /dev/null +++ b/crates/vcli-input/src/keymap.rs @@ -0,0 +1,263 @@ +//! Mapping from the vcli canonical key-name vocabulary to platform virtual +//! keycodes. Kept cross-platform so the Windows backend has the same parsing. +//! +//! Canonical names are lowercase ASCII, e.g. `"a"`, `"return"`, `"space"`, +//! `"tab"`, `"escape"`, `"left"`, `"right"`, `"up"`, `"down"`, `"f1"`..`"f12"`, +//! `"backspace"`, `"delete"`, `"home"`, `"end"`, `"page_up"`, `"page_down"`. + +use crate::error::InputError; + +/// Parsed canonical key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CanonicalKey { + /// Single printable ASCII character (after normalization). + Char(char), + /// Named special key. + Named(NamedKey), +} + +/// Named special keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum NamedKey { + /// Return / Enter. + Return, + /// Tab. + Tab, + /// Spacebar. + Space, + /// Escape. + Escape, + /// Backspace. + Backspace, + /// Forward delete. + Delete, + /// Arrow left. + Left, + /// Arrow right. + Right, + /// Arrow up. + Up, + /// Arrow down. + Down, + /// Home. + Home, + /// End. + End, + /// Page Up. + PageUp, + /// Page Down. + PageDown, + /// Function keys 1..12. + Function(u8), +} + +/// Parse a canonical name. +/// +/// # Errors +/// +/// Returns `InputError::UnknownKey` on anything not in the canonical vocabulary. +/// +/// # Panics +/// +/// Does not panic in practice: the single-char branch only calls `unwrap()` +/// after confirming `.chars().count() == 1`. +pub fn parse(name: &str) -> Result { + let n = name.trim().to_ascii_lowercase(); + if n.is_empty() { + return Err(InputError::UnknownKey(name.to_owned())); + } + + if let Some(rest) = n.strip_prefix('f') { + if let Ok(num) = rest.parse::() { + if (1..=12).contains(&num) { + return Ok(CanonicalKey::Named(NamedKey::Function(num))); + } + } + } + + let named = match n.as_str() { + "return" | "enter" => NamedKey::Return, + "tab" => NamedKey::Tab, + "space" => NamedKey::Space, + "escape" | "esc" => NamedKey::Escape, + "backspace" => NamedKey::Backspace, + "delete" | "forward_delete" => NamedKey::Delete, + "left" => NamedKey::Left, + "right" => NamedKey::Right, + "up" => NamedKey::Up, + "down" => NamedKey::Down, + "home" => NamedKey::Home, + "end" => NamedKey::End, + "page_up" | "pageup" => NamedKey::PageUp, + "page_down" | "pagedown" => NamedKey::PageDown, + _ => { + if n.chars().count() == 1 { + return Ok(CanonicalKey::Char(n.chars().next().unwrap())); + } + return Err(InputError::UnknownKey(name.to_owned())); + } + }; + Ok(CanonicalKey::Named(named)) +} + +/// Translate a parsed key into a macOS virtual keycode (from +/// `HIToolbox/Events.h` / `kVK_*`). Returns `None` for code-points that can't +/// be typed via a virtual key (fallback path: `type_text` via Unicode events). +#[cfg(target_os = "macos")] +#[must_use] +pub fn macos_keycode(key: CanonicalKey) -> Option { + use CanonicalKey::{Char, Named}; + use NamedKey::{ + Backspace, Delete, Down, End, Escape, Function, Home, Left, PageDown, PageUp, Return, + Right, Space, Tab, Up, + }; + Some(match key { + Named(Return) => 0x24, + Named(Tab) => 0x30, + Named(Space) => 0x31, + Named(Backspace) => 0x33, + Named(Escape) => 0x35, + Named(Delete) => 0x75, + Named(Home) => 0x73, + Named(End) => 0x77, + Named(PageUp) => 0x74, + Named(PageDown) => 0x79, + Named(Left) => 0x7B, + Named(Right) => 0x7C, + Named(Down) => 0x7D, + Named(Up) => 0x7E, + Named(Function(1)) => 0x7A, + Named(Function(2)) => 0x78, + Named(Function(3)) => 0x63, + Named(Function(4)) => 0x76, + Named(Function(5)) => 0x60, + Named(Function(6)) => 0x61, + Named(Function(7)) => 0x62, + Named(Function(8)) => 0x64, + Named(Function(9)) => 0x65, + Named(Function(10)) => 0x6D, + Named(Function(11)) => 0x67, + Named(Function(12)) => 0x6F, + Named(Function(_)) => return None, + Char(c) => return ascii_to_macos_keycode(c), + }) +} + +#[cfg(target_os = "macos")] +fn ascii_to_macos_keycode(c: char) -> Option { + // kVK_ANSI_* keycodes for US layout (HIToolbox/Events.h). + Some(match c { + 'a' => 0x00, + 's' => 0x01, + 'd' => 0x02, + 'f' => 0x03, + 'h' => 0x04, + 'g' => 0x05, + 'z' => 0x06, + 'x' => 0x07, + 'c' => 0x08, + 'v' => 0x09, + 'b' => 0x0B, + 'q' => 0x0C, + 'w' => 0x0D, + 'e' => 0x0E, + 'r' => 0x0F, + 'y' => 0x10, + 't' => 0x11, + '1' => 0x12, + '2' => 0x13, + '3' => 0x14, + '4' => 0x15, + '6' => 0x16, + '5' => 0x17, + '=' => 0x18, + '9' => 0x19, + '7' => 0x1A, + '-' => 0x1B, + '8' => 0x1C, + '0' => 0x1D, + ']' => 0x1E, + 'o' => 0x1F, + 'u' => 0x20, + '[' => 0x21, + 'i' => 0x22, + 'p' => 0x23, + 'l' => 0x25, + 'j' => 0x26, + '\'' => 0x27, + 'k' => 0x28, + ';' => 0x29, + '\\' => 0x2A, + ',' => 0x2B, + '/' => 0x2C, + 'n' => 0x2D, + 'm' => 0x2E, + '.' => 0x2F, + '`' => 0x32, + _ => return None, + }) +} + +/// Non-macOS stub so the symbol exists on every platform but always returns None. +#[cfg(not(target_os = "macos"))] +#[must_use] +pub fn macos_keycode(_key: CanonicalKey) -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn named_keys_parse() { + assert_eq!( + parse("return").unwrap(), + CanonicalKey::Named(NamedKey::Return) + ); + assert_eq!( + parse("Enter").unwrap(), + CanonicalKey::Named(NamedKey::Return) + ); + assert_eq!(parse("esc").unwrap(), CanonicalKey::Named(NamedKey::Escape)); + assert_eq!( + parse("page_up").unwrap(), + CanonicalKey::Named(NamedKey::PageUp) + ); + } + + #[test] + fn function_keys_parse_in_range() { + assert_eq!( + parse("f1").unwrap(), + CanonicalKey::Named(NamedKey::Function(1)) + ); + assert_eq!( + parse("F12").unwrap(), + CanonicalKey::Named(NamedKey::Function(12)) + ); + assert!(matches!(parse("f0"), Err(InputError::UnknownKey(_)))); + assert!(matches!(parse("f13"), Err(InputError::UnknownKey(_)))); + } + + #[test] + fn single_chars_parse() { + assert_eq!(parse("s").unwrap(), CanonicalKey::Char('s')); + assert_eq!(parse("1").unwrap(), CanonicalKey::Char('1')); + } + + #[test] + fn empty_and_garbage_reject() { + assert!(matches!(parse(""), Err(InputError::UnknownKey(_)))); + assert!(matches!(parse("blargh"), Err(InputError::UnknownKey(_)))); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_keycode_known_keys() { + assert_eq!(macos_keycode(parse("return").unwrap()), Some(0x24)); + assert_eq!(macos_keycode(parse("a").unwrap()), Some(0x00)); + assert_eq!(macos_keycode(parse("f1").unwrap()), Some(0x7A)); + } +} diff --git a/crates/vcli-input/src/kill_switch.rs b/crates/vcli-input/src/kill_switch.rs new file mode 100644 index 0000000..6270993 --- /dev/null +++ b/crates/vcli-input/src/kill_switch.rs @@ -0,0 +1,151 @@ +//! Process-global HIL "stop everything" flag (Codex Decision B). +//! +//! Cheap to clone (internal `Arc`). Every `InputSink` method must +//! check `is_engaged()` before calling into the OS and return `InputError::Halted` +//! if set. The flag is observable (`subscribe`) so higher-level runtimes can +//! cancel waits as soon as a human panics. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +/// Thread-safe kill switch. Cloning shares the underlying flag. +#[derive(Debug, Clone, Default)] +pub struct KillSwitch { + engaged: Arc, + engaged_at_ns: Arc, +} + +impl KillSwitch { + /// Fresh switch, not engaged. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Set the switch. Idempotent. Stamps the engagement time (monotonic ns since + /// an unspecified epoch) for observability. + pub fn engage(&self) { + let now_ns = duration_since_boot_ns(); + // CAS so we don't overwrite the original engagement timestamp on re-engage. + let _ = self + .engaged_at_ns + .compare_exchange(0, now_ns, Ordering::SeqCst, Ordering::SeqCst); + self.engaged.store(true, Ordering::SeqCst); + } + + /// Clear the switch. Used only by tests and admin paths; production never clears. + pub fn disengage(&self) { + self.engaged.store(false, Ordering::SeqCst); + self.engaged_at_ns.store(0, Ordering::SeqCst); + } + + /// Non-blocking check. + #[must_use] + pub fn is_engaged(&self) -> bool { + self.engaged.load(Ordering::SeqCst) + } + + /// Observer handle for other crates (e.g. `vcli-runtime`) that want to wake + /// their waits when the switch flips. Busy-wait implementation is acceptable + /// because engagement is a rare, terminal event. + #[must_use] + pub fn subscribe(&self) -> KillSwitchObserver { + KillSwitchObserver { + switch: self.clone(), + } + } +} + +/// Observer returned by [`KillSwitch::subscribe`]. +#[derive(Debug, Clone)] +pub struct KillSwitchObserver { + switch: KillSwitch, +} + +impl KillSwitchObserver { + /// Return immediately if engaged; otherwise poll every `poll` until + /// `timeout` elapses. Returns whether the switch became engaged. + #[must_use] + pub fn wait_until_engaged(&self, timeout: Duration, poll: Duration) -> bool { + let deadline = Instant::now() + timeout; + loop { + if self.switch.is_engaged() { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(poll.min(Duration::from_millis(50))); + } + } +} + +fn duration_since_boot_ns() -> u64 { + // Monotonic-ish "now" — does not need to be synced across machines. + use std::time::SystemTime; + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| { + u64::try_from(d.as_nanos() & u128::from(u64::MAX)).unwrap_or(0) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::Ordering; + + #[test] + fn fresh_switch_is_not_engaged() { + let k = KillSwitch::new(); + assert!(!k.is_engaged()); + } + + #[test] + fn engage_is_observable_from_clone() { + let k = KillSwitch::new(); + let clone = k.clone(); + assert!(!clone.is_engaged()); + k.engage(); + assert!(clone.is_engaged()); + } + + #[test] + fn engage_is_idempotent() { + let k = KillSwitch::new(); + k.engage(); + let first = k.engaged_at_ns.load(Ordering::SeqCst); + k.engage(); + let second = k.engaged_at_ns.load(Ordering::SeqCst); + assert_eq!(first, second, "timestamp must not change on re-engage"); + } + + #[test] + fn disengage_resets_flag() { + let k = KillSwitch::new(); + k.engage(); + k.disengage(); + assert!(!k.is_engaged()); + } + + #[test] + fn observer_returns_true_on_engage_before_timeout() { + let k = KillSwitch::new(); + let obs = k.subscribe(); + let k2 = k.clone(); + let handle = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(10)); + k2.engage(); + }); + assert!(obs.wait_until_engaged(Duration::from_millis(500), Duration::from_millis(5))); + handle.join().unwrap(); + } + + #[test] + fn observer_returns_false_on_timeout() { + let k = KillSwitch::new(); + let obs = k.subscribe(); + assert!(!obs.wait_until_engaged(Duration::from_millis(20), Duration::from_millis(5))); + } +} diff --git a/crates/vcli-input/src/lib.rs b/crates/vcli-input/src/lib.rs new file mode 100644 index 0000000..afe68c5 --- /dev/null +++ b/crates/vcli-input/src/lib.rs @@ -0,0 +1,45 @@ +//! vcli-input — input synthesis for vcli. +//! +//! Exposes a synchronous [`InputSink`] trait with a macOS `CGEvent` backend, a +//! recording [`MockInputSink`] used by downstream crates in tests, and a +//! process-global [`KillSwitch`] (Codex Decision B) that short-circuits every +//! method when a human has signalled STOP via the OS-level chord. +//! +//! Windows is a stub (`unimplemented!()`). See the v0 spec §Input synthesis +//! and §Action confirmation for the contract this crate implements. + +#![deny(unsafe_code)] // relaxed inside macos/ submodules via module-level #![allow(unsafe_code)] +#![deny(rust_2018_idioms)] +#![warn(missing_docs)] +#![warn(clippy::pedantic)] +#![allow(clippy::module_name_repetitions)] + +pub mod error; + +pub use error::InputError; + +pub mod kill_switch; + +pub use kill_switch::{KillSwitch, KillSwitchObserver}; + +pub mod sink; + +pub use sink::{DragSegment, InputSink}; + +pub mod mock; + +pub use mock::MockInputSink; + +pub mod keymap; + +pub use keymap::{macos_keycode, CanonicalKey}; + +pub mod permissions; + +pub use permissions::{PermissionReport, PermissionStatus}; + +#[cfg(target_os = "macos")] +pub mod macos; + +#[cfg(target_os = "windows")] +pub mod windows; diff --git a/crates/vcli-input/src/macos/cg_events.rs b/crates/vcli-input/src/macos/cg_events.rs new file mode 100644 index 0000000..e228461 --- /dev/null +++ b/crates/vcli-input/src/macos/cg_events.rs @@ -0,0 +1,203 @@ +//! Raw `CGEvent` builders. Each function creates an event, posts it to the HID +//! tap, and returns. `CGEventPost` is synchronous — by the time it returns, +//! the OS has enqueued the event on the global event stream (spec §Action +//! confirmation). + +#![allow(unsafe_code)] + +use std::time::Duration; + +use core_graphics::event::{ + CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGKeyCode, CGMouseButton, EventField, + ScrollEventUnit, +}; +use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; +use core_graphics::geometry::CGPoint; + +use vcli_core::action::{Button, Modifier}; +use vcli_core::geom::Point; + +use crate::error::InputError; + +/// Make a new `CGEventSource` for `HIDSystemState`. +/// +/// # Errors +/// +/// Returns `InputError::Backend` if the `CGEventSource` FFI call fails. +fn event_source() -> Result { + CGEventSource::new(CGEventSourceStateID::HIDSystemState).map_err(|()| InputError::Backend { + detail: "CGEventSource::new failed".into(), + }) +} + +/// Convert a `Point` to `CGPoint` in logical display coordinates. +#[must_use] +pub fn to_cg(p: Point) -> CGPoint { + CGPoint::new(f64::from(p.x), f64::from(p.y)) +} + +/// Translate modifiers to a `CGEventFlags` bitmask. +#[must_use] +pub fn flags_from_modifiers(modifiers: &[Modifier]) -> CGEventFlags { + let mut f = CGEventFlags::empty(); + for m in modifiers { + f |= match m { + Modifier::Cmd => CGEventFlags::CGEventFlagCommand, + Modifier::Shift => CGEventFlags::CGEventFlagShift, + Modifier::Alt => CGEventFlags::CGEventFlagAlternate, + Modifier::Ctrl => CGEventFlags::CGEventFlagControl, + }; + } + f +} + +/// Translate our `Button` to `CGMouseButton` + the (`type_down`, `type_up`) pair we +/// need to post for that button. +#[must_use] +pub fn button_types(b: Button) -> (CGMouseButton, CGEventType, CGEventType) { + match b { + Button::Left => ( + CGMouseButton::Left, + CGEventType::LeftMouseDown, + CGEventType::LeftMouseUp, + ), + Button::Right => ( + CGMouseButton::Right, + CGEventType::RightMouseDown, + CGEventType::RightMouseUp, + ), + Button::Middle => ( + CGMouseButton::Center, + CGEventType::OtherMouseDown, + CGEventType::OtherMouseUp, + ), + } +} + +/// Post a `MouseMoved` event to the HID tap. +/// +/// # Errors +/// +/// Returns `InputError::Backend` if event creation or the OS call fails. +pub fn post_move(to: Point) -> Result<(), InputError> { + let src = event_source()?; + let event = CGEvent::new_mouse_event( + src, + CGEventType::MouseMoved, + to_cg(to), + CGMouseButton::Left, // ignored for MouseMoved + ) + .map_err(|()| InputError::Backend { + detail: "CGEvent::new_mouse_event MouseMoved failed".into(), + })?; + event.post(CGEventTapLocation::HID); + Ok(()) +} + +/// Post a down-then-up click, setting `click_state` (`MouseEventClickState` +/// field, integer 1 for single-click, 2 for double-click, etc.) and modifier +/// flags. +/// +/// # Errors +/// +/// Returns `InputError::Backend` if event creation or the OS call fails. +pub fn post_click( + at: Point, + button: Button, + modifiers: &[Modifier], + hold: Duration, + click_state: i64, +) -> Result<(), InputError> { + let src = event_source()?; + let (cg_btn, down_ty, up_ty) = button_types(button); + let flags = flags_from_modifiers(modifiers); + + // Down. + let down = CGEvent::new_mouse_event(src.clone(), down_ty, to_cg(at), cg_btn).map_err(|()| { + InputError::Backend { + detail: "mouse_event down failed".into(), + } + })?; + down.set_flags(flags); + down.set_integer_value_field(EventField::MOUSE_EVENT_CLICK_STATE, click_state); + down.post(CGEventTapLocation::HID); + + if !hold.is_zero() { + std::thread::sleep(hold); + } + + // Up. + let up = CGEvent::new_mouse_event(src, up_ty, to_cg(at), cg_btn).map_err(|()| { + InputError::Backend { + detail: "mouse_event up failed".into(), + } + })?; + up.set_flags(flags); + up.set_integer_value_field(EventField::MOUSE_EVENT_CLICK_STATE, click_state); + up.post(CGEventTapLocation::HID); + Ok(()) +} + +/// Post a dragged mouse motion (button already down) to `to`. +/// +/// # Errors +/// +/// Returns `InputError::Backend` if event creation or the OS call fails. +pub fn post_drag_move(to: Point, button: Button) -> Result<(), InputError> { + let src = event_source()?; + let drag_ty = match button { + Button::Left => CGEventType::LeftMouseDragged, + Button::Right => CGEventType::RightMouseDragged, + Button::Middle => CGEventType::OtherMouseDragged, + }; + let cg_btn = button_types(button).0; + let ev = CGEvent::new_mouse_event(src, drag_ty, to_cg(to), cg_btn).map_err(|()| { + InputError::Backend { + detail: "mouse_event drag failed".into(), + } + })?; + ev.post(CGEventTapLocation::HID); + Ok(()) +} + +/// Post a key down or up event for a virtual keycode. +/// +/// # Errors +/// +/// Returns `InputError::Backend` if event creation or the OS call fails. +pub fn post_key( + keycode: CGKeyCode, + key_down: bool, + modifiers: &[Modifier], +) -> Result<(), InputError> { + let src = event_source()?; + let ev = + CGEvent::new_keyboard_event(src, keycode, key_down).map_err(|()| InputError::Backend { + detail: "CGEvent::new_keyboard_event failed".into(), + })?; + ev.set_flags(flags_from_modifiers(modifiers)); + ev.post(CGEventTapLocation::HID); + Ok(()) +} + +/// Post a scroll-wheel event with pixel units. +/// +/// # Errors +/// +/// Returns `InputError::Backend` if event creation or the OS call fails. +pub fn post_scroll(_at: Point, dx: i32, dy: i32) -> Result<(), InputError> { + // Scroll events aren't positional on macOS (they go to the focused window), + // but we keep the same signature as `InputSink` for symmetry. + let src = event_source()?; + let ev = + CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, dy, dx, 0).map_err(|()| { + InputError::Backend { + detail: "CGEvent::new_scroll_event failed".into(), + } + })?; + ev.post(CGEventTapLocation::HID); + Ok(()) +} + +// No tests here: every function talks to the OS. Exercised by +// `tests/macos_real.rs` behind `#[ignore]`. diff --git a/crates/vcli-input/src/macos/cg_sink.rs b/crates/vcli-input/src/macos/cg_sink.rs new file mode 100644 index 0000000..c7b38ec --- /dev/null +++ b/crates/vcli-input/src/macos/cg_sink.rs @@ -0,0 +1,206 @@ +//! Real macOS `InputSink`. Enforces the [`KillSwitch`] on every entry point, +//! then delegates to the low-level `CGEvent` helpers. + +#![cfg(target_os = "macos")] + +use std::time::Duration; + +use vcli_core::action::{Button, Modifier}; +use vcli_core::geom::Point; + +use super::{cg_events, cg_typing}; +use crate::error::InputError; +use crate::keymap::{macos_keycode, parse}; +use crate::kill_switch::KillSwitch; +use crate::permissions::{probe, PermissionStatus}; +use crate::sink::{DragSegment, InputSink}; + +/// Maximum step interpolation time for a single drag segment. Guards against +/// runaway durations from ill-formed programs. +const MAX_DRAG_SEGMENT_MS: u64 = 5_000; +/// Pixel interval between interpolated drag-move events. +const DRAG_STEP_PX: i32 = 8; + +/// macOS `CGEvent`-backed `InputSink`. +#[derive(Debug)] +pub struct CGEventInputSink { + kill: KillSwitch, +} + +impl CGEventInputSink { + /// Construct with a caller-provided kill switch. Callers should also spawn + /// the hotkey listener via [`super::spawn_kill_switch_listener`] so the + /// `Cmd+Shift+Esc` chord engages this switch. + #[must_use] + pub fn new(kill: KillSwitch) -> Self { + Self { kill } + } + + /// Fail-fast if Accessibility isn't granted. Called on first `InputSink` + /// method invocation only when we know we'd hit the OS. Cheap enough + /// (single `AXIsProcessTrustedWithOptions` call) to gate every call. + fn guard(&self) -> Result<(), InputError> { + if self.kill.is_engaged() { + return Err(InputError::Halted); + } + let report = probe(); + if !matches!( + report.accessibility, + PermissionStatus::Granted | PermissionStatus::NotDetermined + ) { + return Err(InputError::PermissionDenied { + detail: "Accessibility (TCC) not granted".into(), + }); + } + Ok(()) + } +} + +impl InputSink for CGEventInputSink { + fn mouse_move(&self, to: Point) -> Result<(), InputError> { + self.guard()?; + cg_events::post_move(to) + } + + fn click( + &self, + at: Point, + button: Button, + modifiers: &[Modifier], + hold_ms: u32, + ) -> Result<(), InputError> { + self.guard()?; + cg_events::post_move(at)?; + cg_events::post_click( + at, + button, + modifiers, + Duration::from_millis(hold_ms.into()), + 1, + ) + } + + fn double_click(&self, at: Point, button: Button) -> Result<(), InputError> { + self.guard()?; + cg_events::post_move(at)?; + cg_events::post_click(at, button, &[], Duration::ZERO, 1)?; + cg_events::post_click(at, button, &[], Duration::ZERO, 2) + } + + fn drag( + &self, + from: Point, + segments: &[DragSegment], + button: Button, + ) -> Result<(), InputError> { + self.guard()?; + if segments.is_empty() { + return Err(InputError::InvalidArgument( + "drag segments must be non-empty".into(), + )); + } + + // Move to start, press down. + cg_events::post_move(from)?; + let (_cg_btn, down_ty, up_ty) = cg_events::button_types(button); + let src = core_graphics::event_source::CGEventSource::new( + core_graphics::event_source::CGEventSourceStateID::HIDSystemState, + ) + .map_err(|()| InputError::Backend { + detail: "CGEventSource::new failed".into(), + })?; + let down = core_graphics::event::CGEvent::new_mouse_event( + src, + down_ty, + cg_events::to_cg(from), + cg_events::button_types(button).0, + ) + .map_err(|()| InputError::Backend { + detail: "mouse_event down failed".into(), + })?; + down.post(core_graphics::event::CGEventTapLocation::HID); + + // Interpolate through each segment. + let mut current = from; + for seg in segments { + if seg.duration.as_millis() > u128::from(MAX_DRAG_SEGMENT_MS) { + return Err(InputError::InvalidArgument( + "drag segment longer than 5s".into(), + )); + } + let dx = seg.to.x - current.x; + let dy = seg.to.y - current.y; + #[allow(clippy::cast_possible_truncation)] + let dist = f64::from(dx * dx + dy * dy).sqrt() as i32; + let steps = (dist / DRAG_STEP_PX).max(1); + let sleep_each = seg.duration / u32::try_from(steps).unwrap_or(1); + for i in 1..=steps { + if self.kill.is_engaged() { + // Release button before bailing. + let _ = cg_events::post_drag_move(current, button); + let src2 = core_graphics::event_source::CGEventSource::new( + core_graphics::event_source::CGEventSourceStateID::HIDSystemState, + ) + .ok(); + if let Some(src) = src2 { + if let Ok(up) = core_graphics::event::CGEvent::new_mouse_event( + src, + up_ty, + cg_events::to_cg(current), + cg_events::button_types(button).0, + ) { + up.post(core_graphics::event::CGEventTapLocation::HID); + } + } + return Err(InputError::Halted); + } + let nx = current.x + dx * i / steps; + let ny = current.y + dy * i / steps; + cg_events::post_drag_move(Point { x: nx, y: ny }, button)?; + if !sleep_each.is_zero() { + std::thread::sleep(sleep_each); + } + } + current = seg.to; + } + + // Release button at final position. + let src = core_graphics::event_source::CGEventSource::new( + core_graphics::event_source::CGEventSourceStateID::HIDSystemState, + ) + .map_err(|()| InputError::Backend { + detail: "CGEventSource::new failed".into(), + })?; + let up = core_graphics::event::CGEvent::new_mouse_event( + src, + up_ty, + cg_events::to_cg(current), + cg_events::button_types(button).0, + ) + .map_err(|()| InputError::Backend { + detail: "mouse_event up failed".into(), + })?; + up.post(core_graphics::event::CGEventTapLocation::HID); + Ok(()) + } + + fn type_text(&self, text: &str) -> Result<(), InputError> { + self.guard()?; + cg_typing::type_text(text) + } + + fn key_combo(&self, modifiers: &[Modifier], key: &str) -> Result<(), InputError> { + self.guard()?; + let parsed = parse(key)?; + let Some(keycode) = macos_keycode(parsed) else { + return Err(InputError::UnknownKey(key.to_owned())); + }; + // Press modifiers down-in-order, press key, release key, release modifiers + // in reverse order. Using CGEventFlags on the key event alone is usually + // enough, but explicit down/up events are more reliable for apps that + // read flagChanged events. + cg_events::post_key(keycode, true, modifiers)?; + cg_events::post_key(keycode, false, modifiers)?; + Ok(()) + } +} diff --git a/crates/vcli-input/src/macos/cg_typing.rs b/crates/vcli-input/src/macos/cg_typing.rs new file mode 100644 index 0000000..36c943f --- /dev/null +++ b/crates/vcli-input/src/macos/cg_typing.rs @@ -0,0 +1,58 @@ +//! `type_text` for macOS. Uses `CGEventKeyboardSetUnicodeString` so the active +//! keyboard layout is respected and every Unicode code point is typeable. + +#![cfg(target_os = "macos")] +#![allow(unsafe_code)] + +use core_graphics::event::{CGEvent, CGEventTapLocation}; +use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; + +use crate::error::InputError; + +/// Post one keyboard event carrying `chunk` as its Unicode payload (key down) +/// followed by a matching key up. Called once per grapheme cluster so layouts +/// that render combining marks still produce the expected glyph. +fn post_unicode_chunk(chunk: &str) -> Result<(), InputError> { + let src = CGEventSource::new(CGEventSourceStateID::HIDSystemState).map_err(|()| { + InputError::Backend { + detail: "CGEventSource::new failed".into(), + } + })?; + + let utf16: Vec = chunk.encode_utf16().collect(); + + let down = + CGEvent::new_keyboard_event(src.clone(), 0, true).map_err(|()| InputError::Backend { + detail: "keyboard_event down failed".into(), + })?; + down.set_string_from_utf16_unchecked(&utf16); + down.post(CGEventTapLocation::HID); + + let up = CGEvent::new_keyboard_event(src, 0, false).map_err(|()| InputError::Backend { + detail: "keyboard_event up failed".into(), + })?; + up.set_string_from_utf16_unchecked(&utf16); + up.post(CGEventTapLocation::HID); + Ok(()) +} + +/// Type a UTF-8 string one grapheme cluster at a time. If the `core-graphics` +/// crate lacks a helper with that exact name, the implementer should use +/// `set_string`, `set_string_from_utf16`, or the FFI `CGEventKeyboardSetUnicodeString` +/// directly. +/// +/// # Errors +/// +/// Returns `InputError::Backend` if any key-event creation or posting fails. +pub fn type_text(text: &str) -> Result<(), InputError> { + if text.is_empty() { + return Ok(()); + } + // One event per grapheme cluster. v0 approximation: one per character. + for ch in text.chars() { + let mut buf = [0u8; 4]; + let chunk = ch.encode_utf8(&mut buf); + post_unicode_chunk(chunk)?; + } + Ok(()) +} diff --git a/crates/vcli-input/src/macos/hotkey_tap.rs b/crates/vcli-input/src/macos/hotkey_tap.rs new file mode 100644 index 0000000..76b933a --- /dev/null +++ b/crates/vcli-input/src/macos/hotkey_tap.rs @@ -0,0 +1,146 @@ +//! CGEventTap-based global hotkey listener. +//! +//! Spawns a dedicated thread with its own `CFRunLoop`. Installs an event tap at +//! `kCGHIDEventTap` in `kCGEventTapOptionListenOnly` mode so it observes — but +//! never consumes — keystrokes. When the `Cmd+Shift+Esc` chord is detected +//! (keycode `0x35` with Cmd + Shift flags on `KeyDown`), it calls +//! `KillSwitch::engage()`. Dropping the returned `KillSwitchListenerHandle` +//! stops the run loop and joins the thread. +//! +//! Why `Cmd+Shift+Esc`? macOS reserves `Cmd+Option+Esc` for Force Quit; +//! `Cmd+Shift+Esc` is otherwise unused system-wide, easy to chord one-handed, +//! and semantically "escape from the automation." + +#![cfg(target_os = "macos")] +#![allow(unsafe_code)] + +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +use core_foundation::runloop::{kCFRunLoopCommonModes, CFRunLoop}; +use core_graphics::event::{ + CGEvent, CGEventFlags, CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, + CGEventTapProxy, CGEventType, +}; + +use crate::error::InputError; +use crate::kill_switch::KillSwitch; + +/// `kVK_Escape` from HIToolbox/Events.h — numeric discriminant in the `CGEventType` repr. +const KVK_ESCAPE_KEYCODE: i64 = 0x35; +/// `CGEventType::KeyDown` discriminant value. +const CG_EVENT_KEY_DOWN: u32 = CGEventType::KeyDown as u32; + +/// Handle returned by [`spawn_kill_switch_listener`]. Dropping stops the tap. +pub struct KillSwitchListenerHandle { + stop: Arc>>, + join: Option>, +} + +impl Drop for KillSwitchListenerHandle { + fn drop(&mut self) { + if let Some(rl) = self.stop.lock().unwrap().take() { + rl.stop(); + } + if let Some(h) = self.join.take() { + let _ = h.join(); + } + } +} + +/// Start the listener. Returns a handle — dropping the handle stops the thread. +/// +/// Fails if the current process doesn't hold Input Monitoring permission. +/// +/// # Errors +/// +/// Returns `InputError::Backend` if the background thread cannot be spawned. +/// +/// # Panics +/// +/// Panics if `CFMachPortCreateRunLoopSource` returns `None` after a successful +/// `CGEventTap::new` — this would indicate a system-level invariant violation +/// and is not recoverable. +pub fn spawn_kill_switch_listener( + kill: KillSwitch, +) -> Result { + let stop: Arc>> = Arc::new(Mutex::new(None)); + let stop_for_thread = stop.clone(); + + let join = std::thread::Builder::new() + .name("vcli-input-killswitch-tap".into()) + .spawn(move || { + // CGEventTap::new takes a closure that may borrow local data; we + // move `kill` into the closure so it lives for the tap's lifetime. + let tap_result = CGEventTap::new( + CGEventTapLocation::HID, + CGEventTapPlacement::HeadInsertEventTap, + CGEventTapOptions::ListenOnly, + vec![CGEventType::KeyDown], + { + let kill = kill.clone(); + move |_proxy: CGEventTapProxy, + event_type: CGEventType, + event: &CGEvent| + -> Option { + // CGEventType doesn't impl PartialEq — compare discriminants. + if event_type as u32 == CG_EVENT_KEY_DOWN { + let keycode = event.get_integer_value_field( + core_graphics::event::EventField::KEYBOARD_EVENT_KEYCODE, + ); + let flags = event.get_flags(); + let want = + CGEventFlags::CGEventFlagCommand | CGEventFlags::CGEventFlagShift; + if keycode == KVK_ESCAPE_KEYCODE && flags.contains(want) { + kill.engage(); + } + } + // Listen-only: return None to pass through unchanged. + None + } + }, + ); + + let Ok(tap) = tap_result else { + // Input Monitoring not granted — exit thread cleanly. + return; + }; + + let runloop = CFRunLoop::get_current(); + let source = tap + .mach_port + .create_runloop_source(0) + .expect("create_runloop_source failed"); + // Safety: kCFRunLoopCommonModes is a valid CFStringRef constant; + // add_source uses it as a mode identifier for the run loop. + runloop.add_source(&source, unsafe { kCFRunLoopCommonModes }); + tap.enable(); + + *stop_for_thread.lock().unwrap() = Some(runloop); + + // Run until stop() is called from Drop. + CFRunLoop::run_current(); + }) + .map_err(|e| InputError::Backend { + detail: format!("spawn tap thread: {e}"), + })?; + + Ok(KillSwitchListenerHandle { + stop, + join: Some(join), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Spawn + drop should not panic; in CI the tap creation will fail cleanly + /// because the process lacks Input Monitoring, and the thread exits early. + #[test] + fn spawn_and_drop_does_not_panic() { + let kill = KillSwitch::new(); + let handle = spawn_kill_switch_listener(kill.clone()).unwrap(); + drop(handle); + } +} diff --git a/crates/vcli-input/src/macos/mod.rs b/crates/vcli-input/src/macos/mod.rs new file mode 100644 index 0000000..f1854dd --- /dev/null +++ b/crates/vcli-input/src/macos/mod.rs @@ -0,0 +1,11 @@ +//! macOS-only backend modules. Gated on `cfg(target_os = "macos")` at the +//! crate root. + +pub mod cg_events; +pub mod cg_sink; +pub mod cg_typing; +pub mod hotkey_tap; +pub mod tcc; + +pub use cg_sink::CGEventInputSink; +pub use hotkey_tap::{spawn_kill_switch_listener, KillSwitchListenerHandle}; diff --git a/crates/vcli-input/src/macos/tcc.rs b/crates/vcli-input/src/macos/tcc.rs new file mode 100644 index 0000000..eb97f7d --- /dev/null +++ b/crates/vcli-input/src/macos/tcc.rs @@ -0,0 +1,75 @@ +//! macOS TCC probes. Uses `AXIsProcessTrustedWithOptions` for Accessibility +//! and `IOHIDCheckAccess` for Input Monitoring. No prompts are triggered — +//! the options dictionary passes `kAXTrustedCheckOptionPrompt: false`. + +#![allow(unsafe_code)] + +use core_foundation::base::TCFType; +use core_foundation::boolean::CFBoolean; +use core_foundation::dictionary::CFDictionary; +use core_foundation::string::CFString; + +use crate::permissions::{PermissionReport, PermissionStatus}; + +#[link(name = "ApplicationServices", kind = "framework")] +extern "C" { + fn AXIsProcessTrustedWithOptions(options: core_foundation::dictionary::CFDictionaryRef) + -> bool; +} + +#[link(name = "IOKit", kind = "framework")] +extern "C" { + fn IOHIDCheckAccess(request: u32) -> u32; // IOHIDRequestType: 0=PostEvent, 1=ListenEvent +} + +// Values from IOKit IOHIDLib.h: kIOHIDAccessType{Granted=0,Denied=1,Unknown=2}. +const IOHID_ACCESS_GRANTED: u32 = 0; +const IOHID_ACCESS_DENIED: u32 = 1; +const IOHID_REQUEST_TYPE_LISTEN_EVENT: u32 = 1; + +/// Full report (Accessibility + Input Monitoring). +#[must_use] +pub fn probe_report() -> PermissionReport { + PermissionReport { + accessibility: accessibility_status(), + input_monitoring: input_monitoring_status(), + } +} + +fn accessibility_status() -> PermissionStatus { + // Build {"AXTrustedCheckOptionPrompt": false} dictionary without prompting. + let key = CFString::new("AXTrustedCheckOptionPrompt"); + let value = CFBoolean::false_value(); + let dict = CFDictionary::from_CFType_pairs(&[(key.as_CFType(), value.as_CFType())]); + let trusted = unsafe { AXIsProcessTrustedWithOptions(dict.as_concrete_TypeRef()) }; + if trusted { + PermissionStatus::Granted + } else { + // AX API doesn't distinguish Denied vs NotDetermined. Report + // NotDetermined so the caller prompts the user the first time. + PermissionStatus::NotDetermined + } +} + +fn input_monitoring_status() -> PermissionStatus { + let status = unsafe { IOHIDCheckAccess(IOHID_REQUEST_TYPE_LISTEN_EVENT) }; + match status { + IOHID_ACCESS_GRANTED => PermissionStatus::Granted, + IOHID_ACCESS_DENIED => PermissionStatus::Denied, + _ => PermissionStatus::NotDetermined, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn probe_does_not_panic() { + // In CI Input Monitoring will typically be NotDetermined and + // Accessibility will typically be NotDetermined — we only assert we + // got SOME PermissionReport value. + let r = probe_report(); + let _ = serde_json::to_string(&r).unwrap(); + } +} diff --git a/crates/vcli-input/src/mock.rs b/crates/vcli-input/src/mock.rs new file mode 100644 index 0000000..a4509cf --- /dev/null +++ b/crates/vcli-input/src/mock.rs @@ -0,0 +1,290 @@ +//! `MockInputSink` — records every call as an [`InputAction`] for assertions +//! in downstream crates' scenario tests. Also honors the [`KillSwitch`] so +//! kill-switch semantics are testable without an OS backend. + +use std::sync::Mutex; +use std::time::Duration; + +use vcli_core::action::{Button, InputAction, Modifier}; +use vcli_core::geom::Point; + +use crate::error::InputError; +use crate::kill_switch::KillSwitch; +use crate::sink::{DragSegment, InputSink}; + +/// An entry in the mock call log. Mostly 1-to-1 with [`InputAction`], but adds +/// variants the DSL-level action enum doesn't carry (`DoubleClick`, `Drag`). +#[derive(Debug, Clone, PartialEq)] +pub enum MockCall { + /// Low-level action the runtime would emit. + Action(InputAction), + /// Double-click at a point with a button. + DoubleClick { + /// Point. + at: Point, + /// Button. + button: Button, + }, + /// Drag from `from` through segment endpoints with `button` held. + Drag { + /// Start point. + from: Point, + /// Endpoints (duration omitted — mock does not sleep). + to: Vec, + /// Held button. + button: Button, + }, + /// A click variant that carries modifiers + hold (`InputAction::Click` drops them). + ClickDetailed { + /// Point. + at: Point, + /// Button. + button: Button, + /// Modifiers. + modifiers: Vec, + /// Hold-down time in ms. + hold_ms: u32, + }, +} + +/// Recording `InputSink`. Thread-safe. +#[derive(Debug, Default)] +pub struct MockInputSink { + log: Mutex>, + kill: KillSwitch, + /// Optional artificial error; when set, all methods return this. Useful to + /// exercise the runtime's error-handling paths. + forced_error: Mutex>, +} + +impl MockInputSink { + /// Fresh mock with an unengaged kill switch. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Build with a caller-provided kill switch so tests can trigger `Halted`. + #[must_use] + pub fn with_kill_switch(kill: KillSwitch) -> Self { + Self { + log: Mutex::new(Vec::new()), + kill, + forced_error: Mutex::new(None), + } + } + + /// Read the current call log. + /// + /// # Panics + /// + /// Panics if the internal mutex is poisoned (should not happen in normal use). + #[must_use] + pub fn calls(&self) -> Vec { + self.log.lock().unwrap().clone() + } + + /// Drain and return calls (clears the log). + /// + /// # Panics + /// + /// Panics if the internal mutex is poisoned (should not happen in normal use). + pub fn drain(&self) -> Vec { + std::mem::take(&mut *self.log.lock().unwrap()) + } + + /// Reference to the underlying kill switch (clone to share). + #[must_use] + pub fn kill_switch(&self) -> KillSwitch { + self.kill.clone() + } + + /// Force every subsequent call to fail with `InputError::Backend { detail }`. + /// + /// # Panics + /// + /// Panics if the internal mutex is poisoned (should not happen in normal use). + pub fn fail_with(&self, detail: impl Into) { + *self.forced_error.lock().unwrap() = Some(detail.into()); + } + + fn check(&self) -> Result<(), InputError> { + if self.kill.is_engaged() { + return Err(InputError::Halted); + } + if let Some(d) = self.forced_error.lock().unwrap().clone() { + return Err(InputError::Backend { detail: d }); + } + Ok(()) + } + + fn push(&self, c: MockCall) { + self.log.lock().unwrap().push(c); + } +} + +impl InputSink for MockInputSink { + fn mouse_move(&self, to: Point) -> Result<(), InputError> { + self.check()?; + self.push(MockCall::Action(InputAction::Move { at: to })); + Ok(()) + } + + fn click( + &self, + at: Point, + button: Button, + modifiers: &[Modifier], + hold_ms: u32, + ) -> Result<(), InputError> { + self.check()?; + self.push(MockCall::ClickDetailed { + at, + button, + modifiers: modifiers.to_vec(), + hold_ms, + }); + Ok(()) + } + + fn double_click(&self, at: Point, button: Button) -> Result<(), InputError> { + self.check()?; + self.push(MockCall::DoubleClick { at, button }); + Ok(()) + } + + fn drag( + &self, + from: Point, + segments: &[DragSegment], + button: Button, + ) -> Result<(), InputError> { + self.check()?; + if segments.is_empty() { + return Err(InputError::InvalidArgument( + "drag segments must be non-empty".into(), + )); + } + self.push(MockCall::Drag { + from, + to: segments.iter().map(|s| s.to).collect(), + button, + }); + Ok(()) + } + + fn type_text(&self, text: &str) -> Result<(), InputError> { + self.check()?; + self.push(MockCall::Action(InputAction::Type { + text: text.to_owned(), + })); + Ok(()) + } + + fn key_combo(&self, modifiers: &[Modifier], key: &str) -> Result<(), InputError> { + self.check()?; + self.push(MockCall::Action(InputAction::Key { + key: key.to_owned(), + modifiers: modifiers.to_vec(), + })); + Ok(()) + } +} + +// Silence unused-import warning when Duration isn't used after refactor. +#[allow(dead_code)] +fn _duration_in_scope(_: Duration) {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mock_records_move() { + let m = MockInputSink::new(); + m.mouse_move(Point { x: 3, y: 4 }).unwrap(); + assert_eq!( + m.calls(), + vec![MockCall::Action(InputAction::Move { + at: Point { x: 3, y: 4 } + })] + ); + } + + #[test] + fn mock_records_click_with_modifiers() { + let m = MockInputSink::new(); + m.click(Point { x: 1, y: 1 }, Button::Left, &[Modifier::Cmd], 120) + .unwrap(); + assert_eq!( + m.calls(), + vec![MockCall::ClickDetailed { + at: Point { x: 1, y: 1 }, + button: Button::Left, + modifiers: vec![Modifier::Cmd], + hold_ms: 120, + }] + ); + } + + #[test] + fn mock_records_double_click_and_drag() { + let m = MockInputSink::new(); + m.double_click(Point { x: 0, y: 0 }, Button::Left).unwrap(); + m.drag( + Point { x: 0, y: 0 }, + &[DragSegment { + to: Point { x: 10, y: 10 }, + duration: Duration::from_millis(50), + }], + Button::Left, + ) + .unwrap(); + let calls = m.calls(); + assert_eq!(calls.len(), 2); + assert!(matches!(calls[0], MockCall::DoubleClick { .. })); + assert!(matches!(calls[1], MockCall::Drag { .. })); + } + + #[test] + fn mock_records_type_and_key() { + let m = MockInputSink::new(); + m.type_text("hi").unwrap(); + m.key_combo(&[Modifier::Cmd, Modifier::Shift], "s").unwrap(); + assert_eq!( + m.calls(), + vec![ + MockCall::Action(InputAction::Type { text: "hi".into() }), + MockCall::Action(InputAction::Key { + key: "s".into(), + modifiers: vec![Modifier::Cmd, Modifier::Shift], + }), + ] + ); + } + + #[test] + fn mock_rejects_empty_drag() { + let m = MockInputSink::new(); + let e = m.drag(Point { x: 0, y: 0 }, &[], Button::Left).unwrap_err(); + matches!(e, InputError::InvalidArgument(_)); + } + + #[test] + fn drain_empties_the_log() { + let m = MockInputSink::new(); + m.mouse_move(Point { x: 0, y: 0 }).unwrap(); + let first = m.drain(); + assert_eq!(first.len(), 1); + assert!(m.calls().is_empty()); + } + + #[test] + fn forced_error_produces_backend_failure() { + let m = MockInputSink::new(); + m.fail_with("flaky"); + let e = m.mouse_move(Point { x: 0, y: 0 }).unwrap_err(); + assert!(matches!(e, InputError::Backend { .. })); + assert!(m.calls().is_empty(), "failed call must not be recorded"); + } +} diff --git a/crates/vcli-input/src/permissions.rs b/crates/vcli-input/src/permissions.rs new file mode 100644 index 0000000..a309117 --- /dev/null +++ b/crates/vcli-input/src/permissions.rs @@ -0,0 +1,97 @@ +//! Permission diagnostics. On macOS, input synthesis needs Accessibility AND +//! (for some event types) Input Monitoring TCC buckets granted. Reports a +//! status per bucket without prompting (diagnostic only). + +use serde::{Deserialize, Serialize}; + +/// Status of a single TCC (or equivalent) permission. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PermissionStatus { + /// Granted — input synthesis works. + Granted, + /// Denied — user explicitly refused. + Denied, + /// Not determined — never asked. Typically means granted in practice for + /// Accessibility if the user has not yet opened the toggle, so dispatch + /// will prompt / fail the first time. + NotDetermined, + /// Platform does not use this concept (always returned on non-macOS). + NotApplicable, +} + +/// Aggregate report printed by `vcli health` and the daemon readiness check. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PermissionReport { + /// macOS Accessibility bucket (required for `CGEventPost`). + pub accessibility: PermissionStatus, + /// macOS Input Monitoring bucket (required for the `CGEventTap` listener + /// used by the kill-switch hotkey). + pub input_monitoring: PermissionStatus, +} + +impl PermissionReport { + /// True iff both buckets are `Granted`. + #[must_use] + pub fn fully_granted(&self) -> bool { + matches!(self.accessibility, PermissionStatus::Granted) + && matches!(self.input_monitoring, PermissionStatus::Granted) + } +} + +/// Probe permissions. On macOS this calls into `macos::tcc`; on any other OS +/// both buckets report `NotApplicable`. +#[must_use] +pub fn probe() -> PermissionReport { + #[cfg(target_os = "macos")] + { + crate::macos::tcc::probe_report() + } + #[cfg(not(target_os = "macos"))] + { + PermissionReport { + accessibility: PermissionStatus::NotApplicable, + input_monitoring: PermissionStatus::NotApplicable, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn report_roundtrip() { + let r = PermissionReport { + accessibility: PermissionStatus::Granted, + input_monitoring: PermissionStatus::Denied, + }; + let j = serde_json::to_string(&r).unwrap(); + assert!(j.contains(r#""accessibility":"granted""#)); + assert!(j.contains(r#""input_monitoring":"denied""#)); + let back: PermissionReport = serde_json::from_str(&j).unwrap(); + assert_eq!(back, r); + } + + #[test] + fn fully_granted_requires_both() { + assert!(PermissionReport { + accessibility: PermissionStatus::Granted, + input_monitoring: PermissionStatus::Granted, + } + .fully_granted()); + assert!(!PermissionReport { + accessibility: PermissionStatus::Granted, + input_monitoring: PermissionStatus::Denied, + } + .fully_granted()); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn non_macos_reports_not_applicable() { + let r = probe(); + assert_eq!(r.accessibility, PermissionStatus::NotApplicable); + assert_eq!(r.input_monitoring, PermissionStatus::NotApplicable); + } +} diff --git a/crates/vcli-input/src/sink.rs b/crates/vcli-input/src/sink.rs new file mode 100644 index 0000000..b93c6d9 --- /dev/null +++ b/crates/vcli-input/src/sink.rs @@ -0,0 +1,141 @@ +//! `InputSink` — the synchronous, OS-confirmed input interface. +//! +//! Every method must return only after the OS has dispatched the event +//! (microseconds, not visual confirmation — the runtime layers postcondition +//! checks on top per spec §Action confirmation). `Result<(), InputError>` +//! means "the OS accepted the event" or "we failed before posting". A +//! `KillSwitch` engaged at entry produces `InputError::Halted` before any +//! OS call. + +use std::time::Duration; + +use vcli_core::action::{Button, Modifier}; +use vcli_core::geom::Point; + +use crate::error::InputError; + +/// One hop of a multi-point drag. The first segment begins at `from`; later +/// segments interpolate from the prior segment's `to`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DragSegment { + /// End point of this segment in logical pixels. + pub to: Point, + /// Duration of this segment. Backends emit interpolated move events over + /// this span to mimic a human-speed drag. + pub duration: Duration, +} + +/// Synchronous input trait. Implementors must be `Send + Sync` so the runtime +/// can hold one behind an `Arc`. +pub trait InputSink: Send + Sync { + /// Move the cursor to `to` immediately (no interpolation). + /// + /// # Errors + /// + /// Returns `InputError::Halted` if the kill switch is engaged, or a + /// backend-specific error if the OS rejects the event. + fn mouse_move(&self, to: Point) -> Result<(), InputError>; + + /// Click `button` at `at` with `modifiers` held. `hold_ms` is the down→up + /// gap (0 = fire down+up back-to-back). Backends that cannot honor hold + /// duration still must return `Ok(())` only after the up event posts. + /// + /// # Errors + /// + /// Returns `InputError::Halted` if the kill switch is engaged, or a + /// backend-specific error if the OS rejects the event. + fn click( + &self, + at: Point, + button: Button, + modifiers: &[Modifier], + hold_ms: u32, + ) -> Result<(), InputError>; + + /// Double-click `button` at `at`. Implementations set the + /// `MouseEventClickState` field to 2 on the second press so the OS treats + /// it as a double-click (not two single clicks). + /// + /// # Errors + /// + /// Returns `InputError::Halted` if the kill switch is engaged, or a + /// backend-specific error if the OS rejects the event. + fn double_click(&self, at: Point, button: Button) -> Result<(), InputError>; + + /// Drag from `from` through each `DragSegment.to` in order. `button` is + /// held down across all segments and released at the final `to`. Returns + /// only after the final mouse-up is posted. + /// + /// # Errors + /// + /// Returns `InputError::InvalidArgument` if `segments` is empty, + /// `InputError::Halted` if the kill switch fires mid-drag, or a backend + /// error if the OS rejects any event. + fn drag(&self, from: Point, segments: &[DragSegment], button: Button) + -> Result<(), InputError>; + + /// Type literal UTF-8 text. Backends use Unicode key events (macOS: + /// `CGEventKeyboardSetUnicodeString`) so the active keyboard layout is + /// respected and arbitrary code-points (including non-ASCII) type correctly. + /// + /// # Errors + /// + /// Returns `InputError::Halted` if the kill switch is engaged, or a + /// backend-specific error if the OS rejects any key event. + fn type_text(&self, text: &str) -> Result<(), InputError>; + + /// Press a key combo. `key` uses the vcli canonical key-name set + /// (e.g. `"s"`, `"return"`, `"space"`, `"f1"`); see [`crate::keymap`]. + /// Down/up pairs are emitted for each modifier around the primary key. + /// + /// # Errors + /// + /// Returns `InputError::UnknownKey` if `key` is not in the canonical set, + /// `InputError::Halted` if the kill switch is engaged, or a backend error + /// if the OS rejects the event. + fn key_combo(&self, modifiers: &[Modifier], key: &str) -> Result<(), InputError>; +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal stand-in impl to prove the trait is object-safe and compiles. + struct Nop; + impl InputSink for Nop { + fn mouse_move(&self, _: Point) -> Result<(), InputError> { + Ok(()) + } + fn click(&self, _: Point, _: Button, _: &[Modifier], _: u32) -> Result<(), InputError> { + Ok(()) + } + fn double_click(&self, _: Point, _: Button) -> Result<(), InputError> { + Ok(()) + } + fn drag(&self, _: Point, _: &[DragSegment], _: Button) -> Result<(), InputError> { + Ok(()) + } + fn type_text(&self, _: &str) -> Result<(), InputError> { + Ok(()) + } + fn key_combo(&self, _: &[Modifier], _: &str) -> Result<(), InputError> { + Ok(()) + } + } + + #[test] + fn trait_is_object_safe() { + let s: Box = Box::new(Nop); + s.mouse_move(Point { x: 1, y: 2 }).unwrap(); + } + + #[test] + fn drag_segment_is_copy() { + let s = DragSegment { + to: Point { x: 5, y: 5 }, + duration: Duration::from_millis(100), + }; + let s2 = s; + assert_eq!(s.to, s2.to); + } +} diff --git a/crates/vcli-input/src/windows/mod.rs b/crates/vcli-input/src/windows/mod.rs new file mode 100644 index 0000000..ca61c7d --- /dev/null +++ b/crates/vcli-input/src/windows/mod.rs @@ -0,0 +1,41 @@ +//! Windows stub backend. v0.4 replaces this with a real implementation (see +//! spec §Roadmap). Currently all methods return `InputError::Unimplemented`. + +use vcli_core::action::{Button, Modifier}; +use vcli_core::geom::Point; + +use crate::error::InputError; +use crate::sink::{DragSegment, InputSink}; + +/// Windows stub sink. Constructible but every method returns `Unimplemented`. +#[derive(Debug, Default)] +pub struct WindowsInputSink; + +impl WindowsInputSink { + /// Constructor. + #[must_use] + pub fn new() -> Self { + Self + } +} + +impl InputSink for WindowsInputSink { + fn mouse_move(&self, _to: Point) -> Result<(), InputError> { + unimplemented!("vcli-input Windows backend — see roadmap v0.4") + } + fn click(&self, _: Point, _: Button, _: &[Modifier], _: u32) -> Result<(), InputError> { + unimplemented!("vcli-input Windows backend — see roadmap v0.4") + } + fn double_click(&self, _: Point, _: Button) -> Result<(), InputError> { + unimplemented!("vcli-input Windows backend — see roadmap v0.4") + } + fn drag(&self, _: Point, _: &[DragSegment], _: Button) -> Result<(), InputError> { + unimplemented!("vcli-input Windows backend — see roadmap v0.4") + } + fn type_text(&self, _: &str) -> Result<(), InputError> { + unimplemented!("vcli-input Windows backend — see roadmap v0.4") + } + fn key_combo(&self, _: &[Modifier], _: &str) -> Result<(), InputError> { + unimplemented!("vcli-input Windows backend — see roadmap v0.4") + } +} diff --git a/crates/vcli-input/tests/kill_switch.rs b/crates/vcli-input/tests/kill_switch.rs new file mode 100644 index 0000000..f3ff7d1 --- /dev/null +++ b/crates/vcli-input/tests/kill_switch.rs @@ -0,0 +1,94 @@ +//! End-to-end behavior: engaging the kill switch makes every subsequent call +//! fail with `Halted` and stops recording. Previously recorded calls stay. + +use std::time::Duration; + +use vcli_core::action::{Button, Modifier}; +use vcli_core::geom::Point; + +use vcli_input::kill_switch::KillSwitch; +use vcli_input::mock::{MockCall, MockInputSink}; +use vcli_input::sink::{DragSegment, InputSink}; +use vcli_input::InputError; + +#[test] +fn engage_halts_every_subsequent_method() { + let kill = KillSwitch::new(); + let mock = MockInputSink::with_kill_switch(kill.clone()); + + // Before engage: all methods record. + mock.mouse_move(Point { x: 1, y: 1 }).unwrap(); + mock.click(Point { x: 1, y: 1 }, Button::Left, &[], 0) + .unwrap(); + mock.double_click(Point { x: 2, y: 2 }, Button::Left) + .unwrap(); + mock.drag( + Point { x: 0, y: 0 }, + &[DragSegment { + to: Point { x: 5, y: 5 }, + duration: Duration::from_millis(5), + }], + Button::Left, + ) + .unwrap(); + mock.type_text("hello").unwrap(); + mock.key_combo(&[Modifier::Cmd], "s").unwrap(); + assert_eq!(mock.calls().len(), 6); + + // Engage. + kill.engage(); + + for result in [ + mock.mouse_move(Point { x: 0, y: 0 }), + mock.click(Point { x: 0, y: 0 }, Button::Left, &[], 0), + mock.double_click(Point { x: 0, y: 0 }, Button::Left), + mock.drag( + Point { x: 0, y: 0 }, + &[DragSegment { + to: Point { x: 1, y: 1 }, + duration: Duration::from_millis(1), + }], + Button::Left, + ), + mock.type_text("nope"), + mock.key_combo(&[], "a"), + ] { + let e = result.unwrap_err(); + assert!(matches!(e, InputError::Halted), "got {e:?}"); + } + + // Recorded calls are unchanged (no new ones added after engage). + assert_eq!(mock.calls().len(), 6); +} + +#[test] +fn observer_wakes_immediately_when_switch_engaged_concurrently() { + let kill = KillSwitch::new(); + let obs = kill.subscribe(); + let k2 = kill.clone(); + let h = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(20)); + k2.engage(); + }); + assert!(obs.wait_until_engaged(Duration::from_secs(1), Duration::from_millis(2))); + h.join().unwrap(); +} + +#[test] +fn disengage_reenables_calls() { + let kill = KillSwitch::new(); + let mock = MockInputSink::with_kill_switch(kill.clone()); + kill.engage(); + assert!(matches!( + mock.mouse_move(Point { x: 1, y: 1 }).unwrap_err(), + InputError::Halted + )); + kill.disengage(); + mock.mouse_move(Point { x: 2, y: 2 }).unwrap(); + assert_eq!( + mock.calls(), + vec![MockCall::Action(vcli_core::action::InputAction::Move { + at: Point { x: 2, y: 2 } + })] + ); +} diff --git a/crates/vcli-input/tests/macos_real.rs b/crates/vcli-input/tests/macos_real.rs new file mode 100644 index 0000000..fcacb34 --- /dev/null +++ b/crates/vcli-input/tests/macos_real.rs @@ -0,0 +1,96 @@ +//! Real CGEvent smoke tests. Gated with `#[ignore]` because they move the +//! cursor and need TCC Accessibility (+ optionally Input Monitoring) granted. +//! +//! Run manually with: +//! cargo test -p vcli-input --test macos_real -- --ignored +//! +//! If Accessibility is not granted, every test will produce +//! `InputError::PermissionDenied` and fail fast with a clear message. + +#![cfg(target_os = "macos")] + +use std::time::Duration; + +use vcli_core::action::{Button, Modifier}; +use vcli_core::geom::Point; + +use vcli_input::kill_switch::KillSwitch; +use vcli_input::macos::CGEventInputSink; +use vcli_input::permissions::{probe, PermissionStatus}; +use vcli_input::sink::{DragSegment, InputSink}; + +fn require_accessibility() -> Result<(), String> { + let r = probe(); + if matches!(r.accessibility, PermissionStatus::Granted) { + Ok(()) + } else { + Err(format!("Accessibility TCC not granted: {r:?}")) + } +} + +fn sink() -> CGEventInputSink { + CGEventInputSink::new(KillSwitch::new()) +} + +#[test] +#[ignore = "moves the cursor; requires TCC Accessibility"] +fn mouse_move_to_origin_and_back() { + require_accessibility().unwrap(); + let s = sink(); + s.mouse_move(Point { x: 50, y: 50 }).unwrap(); + std::thread::sleep(Duration::from_millis(50)); + s.mouse_move(Point { x: 200, y: 200 }).unwrap(); +} + +#[test] +#[ignore = "clicks the screen; requires TCC Accessibility"] +fn click_left_at_a_safe_spot() { + require_accessibility().unwrap(); + let s = sink(); + // (10, 10) is usually over the menu bar / desktop; harmless. + s.click(Point { x: 10, y: 10 }, Button::Left, &[], 20) + .unwrap(); +} + +#[test] +#[ignore = "types into the focused window; requires TCC Accessibility"] +fn type_ascii_text() { + require_accessibility().unwrap(); + let s = sink(); + s.type_text("hello").unwrap(); +} + +#[test] +#[ignore = "presses Cmd+A in the focused window; requires TCC Accessibility"] +fn key_combo_cmd_a() { + require_accessibility().unwrap(); + let s = sink(); + s.key_combo(&[Modifier::Cmd], "a").unwrap(); +} + +#[test] +#[ignore = "drags across 100 pixels; requires TCC Accessibility"] +fn drag_100_pixels() { + require_accessibility().unwrap(); + let s = sink(); + s.drag( + Point { x: 100, y: 100 }, + &[DragSegment { + to: Point { x: 200, y: 200 }, + duration: Duration::from_millis(200), + }], + Button::Left, + ) + .unwrap(); +} + +#[test] +#[ignore = "verifies kill switch engagement short-circuits real sink"] +fn kill_switch_short_circuits_real_sink() { + require_accessibility().unwrap(); + let kill = KillSwitch::new(); + let s = CGEventInputSink::new(kill.clone()); + kill.engage(); + let e = s.mouse_move(Point { x: 100, y: 100 }).unwrap_err(); + assert!(matches!(e, vcli_input::error::InputError::Halted)); +} diff --git a/crates/vcli-input/tests/mock_contract.rs b/crates/vcli-input/tests/mock_contract.rs new file mode 100644 index 0000000..1859100 --- /dev/null +++ b/crates/vcli-input/tests/mock_contract.rs @@ -0,0 +1,146 @@ +//! Contract tests that run against [`MockInputSink`] and also serve as a +//! template for asserting any future `InputSink` impl. + +use std::time::Duration; + +use vcli_core::action::{Button, InputAction, Modifier}; +use vcli_core::geom::Point; + +use vcli_input::error::InputError; +use vcli_input::kill_switch::KillSwitch; +use vcli_input::mock::{MockCall, MockInputSink}; +use vcli_input::sink::{DragSegment, InputSink}; + +fn new_mock() -> MockInputSink { + MockInputSink::new() +} + +#[test] +fn mouse_move_records_once() { + let m = new_mock(); + m.mouse_move(Point { x: 100, y: 200 }).unwrap(); + assert_eq!( + m.calls(), + vec![MockCall::Action(InputAction::Move { + at: Point { x: 100, y: 200 } + })] + ); +} + +#[test] +fn click_preserves_modifiers_and_hold() { + let m = new_mock(); + m.click( + Point { x: 5, y: 6 }, + Button::Right, + &[Modifier::Cmd, Modifier::Ctrl], + 75, + ) + .unwrap(); + assert_eq!( + m.calls(), + vec![MockCall::ClickDetailed { + at: Point { x: 5, y: 6 }, + button: Button::Right, + modifiers: vec![Modifier::Cmd, Modifier::Ctrl], + hold_ms: 75, + }] + ); +} + +#[test] +fn double_click_emits_distinct_variant() { + let m = new_mock(); + m.double_click(Point { x: 1, y: 1 }, Button::Left).unwrap(); + assert_eq!( + m.calls(), + vec![MockCall::DoubleClick { + at: Point { x: 1, y: 1 }, + button: Button::Left + }] + ); +} + +#[test] +fn drag_with_multiple_segments_records_all_endpoints() { + let m = new_mock(); + m.drag( + Point { x: 0, y: 0 }, + &[ + DragSegment { + to: Point { x: 10, y: 10 }, + duration: Duration::from_millis(5), + }, + DragSegment { + to: Point { x: 20, y: 20 }, + duration: Duration::from_millis(5), + }, + ], + Button::Left, + ) + .unwrap(); + match &m.calls()[0] { + MockCall::Drag { from, to, button } => { + assert_eq!(*from, Point { x: 0, y: 0 }); + assert_eq!(to, &vec![Point { x: 10, y: 10 }, Point { x: 20, y: 20 }]); + assert_eq!(*button, Button::Left); + } + other => panic!("wrong variant: {other:?}"), + } +} + +#[test] +fn type_text_records_input_action() { + let m = new_mock(); + m.type_text("hello 世界").unwrap(); + assert_eq!( + m.calls(), + vec![MockCall::Action(InputAction::Type { + text: "hello 世界".into() + })] + ); +} + +#[test] +fn key_combo_records_modifiers() { + let m = new_mock(); + m.key_combo(&[Modifier::Cmd, Modifier::Shift], "s").unwrap(); + assert_eq!( + m.calls(), + vec![MockCall::Action(InputAction::Key { + key: "s".into(), + modifiers: vec![Modifier::Cmd, Modifier::Shift], + })] + ); +} + +#[test] +fn empty_drag_is_rejected() { + let m = new_mock(); + let e = m.drag(Point { x: 0, y: 0 }, &[], Button::Left).unwrap_err(); + assert!(matches!(e, InputError::InvalidArgument(_))); +} + +#[test] +fn forced_error_bubbles_as_backend_failure() { + let m = MockInputSink::new(); + m.fail_with("os returned -1"); + let e = m.type_text("nope").unwrap_err(); + assert!(matches!(e, InputError::Backend { .. })); +} + +#[test] +fn kill_switch_engaged_halts_every_method() { + let kill = KillSwitch::new(); + let m = MockInputSink::with_kill_switch(kill.clone()); + kill.engage(); + assert!(matches!( + m.mouse_move(Point { x: 0, y: 0 }).unwrap_err(), + InputError::Halted + )); + assert!(matches!(m.type_text("x").unwrap_err(), InputError::Halted)); + assert!(matches!( + m.key_combo(&[], "a").unwrap_err(), + InputError::Halted + )); +}