From ecdae11cc8b57bf119fc82f94fd5f747947b9966 Mon Sep 17 00:00:00 2001 From: Yixiang Zeng Date: Fri, 17 Apr 2026 07:40:46 -0700 Subject: [PATCH 01/17] vcli-input: register workspace member + add core-graphics deps --- Cargo.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 6649def..ce7e475 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/vcli-core", + "crates/vcli-input", ] [workspace.package] @@ -23,3 +24,9 @@ unicode-normalization = "0.1" # Dev-only proptest = "1" + +# Platform-specific (used by vcli-input + vcli-capture later) +core-graphics = "0.24" +core-foundation = "0.10" +libc = "0.2" +tempfile = "3" From 8d42a26167ade5d35bde15f93e6be3c23b7eb8f0 Mon Sep 17 00:00:00 2001 From: Yixiang Zeng Date: Fri, 17 Apr 2026 07:41:14 -0700 Subject: [PATCH 02/17] vcli-input: empty crate shell with README --- crates/vcli-input/Cargo.toml | 24 ++++++++++++++++++++++++ crates/vcli-input/README.md | 5 +++++ crates/vcli-input/src/lib.rs | 15 +++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 crates/vcli-input/Cargo.toml create mode 100644 crates/vcli-input/README.md create mode 100644 crates/vcli-input/src/lib.rs 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/lib.rs b/crates/vcli-input/src/lib.rs new file mode 100644 index 0000000..ef98280 --- /dev/null +++ b/crates/vcli-input/src/lib.rs @@ -0,0 +1,15 @@ +//! 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. + +#![forbid(unsafe_code)] // relaxed inside macos/cg_events.rs via targeted allow +#![deny(rust_2018_idioms)] +#![warn(missing_docs)] +#![warn(clippy::pedantic)] +#![allow(clippy::module_name_repetitions)] From 8756808306317d7ae1d615f149a65a7962345e72 Mon Sep 17 00:00:00 2001 From: Yixiang Zeng Date: Fri, 17 Apr 2026 07:41:42 -0700 Subject: [PATCH 03/17] vcli-input: InputError (thiserror) + ErrorCode mapping --- crates/vcli-input/src/error.rs | 86 ++++++++++++++++++++++++++++++++++ crates/vcli-input/src/lib.rs | 4 ++ 2 files changed, 90 insertions(+) create mode 100644 crates/vcli-input/src/error.rs diff --git a/crates/vcli-input/src/error.rs b/crates/vcli-input/src/error.rs new file mode 100644 index 0000000..8218948 --- /dev/null +++ b/crates/vcli-input/src/error.rs @@ -0,0 +1,86 @@ +//! Errors surfaced by [`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/lib.rs b/crates/vcli-input/src/lib.rs index ef98280..84d9427 100644 --- a/crates/vcli-input/src/lib.rs +++ b/crates/vcli-input/src/lib.rs @@ -13,3 +13,7 @@ #![warn(missing_docs)] #![warn(clippy::pedantic)] #![allow(clippy::module_name_repetitions)] + +pub mod error; + +pub use error::InputError; From ddf29834425584d17fa658f2e71ec111a8eb23c6 Mon Sep 17 00:00:00 2001 From: Yixiang Zeng Date: Fri, 17 Apr 2026 07:42:15 -0700 Subject: [PATCH 04/17] vcli-input: KillSwitch primitive (Arc + observer) --- crates/vcli-input/src/kill_switch.rs | 152 +++++++++++++++++++++++++++ crates/vcli-input/src/lib.rs | 4 + 2 files changed, 156 insertions(+) create mode 100644 crates/vcli-input/src/kill_switch.rs diff --git a/crates/vcli-input/src/kill_switch.rs b/crates/vcli-input/src/kill_switch.rs new file mode 100644 index 0000000..be0b72a --- /dev/null +++ b/crates/vcli-input/src/kill_switch.rs @@ -0,0 +1,152 @@ +//! 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. + 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(|d| u64::try_from(d.as_nanos() & u128::from(u64::MAX)).unwrap_or(0)) + .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 index 84d9427..b25b5da 100644 --- a/crates/vcli-input/src/lib.rs +++ b/crates/vcli-input/src/lib.rs @@ -17,3 +17,7 @@ pub mod error; pub use error::InputError; + +pub mod kill_switch; + +pub use kill_switch::{KillSwitch, KillSwitchObserver}; From caaaaa21e0584dc0a878e3df38842eaf24696b57 Mon Sep 17 00:00:00 2001 From: Yixiang Zeng Date: Fri, 17 Apr 2026 07:42:50 -0700 Subject: [PATCH 05/17] vcli-input: InputSink trait + DragSegment --- crates/vcli-input/src/lib.rs | 4 ++ crates/vcli-input/src/sink.rs | 113 ++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 crates/vcli-input/src/sink.rs diff --git a/crates/vcli-input/src/lib.rs b/crates/vcli-input/src/lib.rs index b25b5da..4f5e38b 100644 --- a/crates/vcli-input/src/lib.rs +++ b/crates/vcli-input/src/lib.rs @@ -21,3 +21,7 @@ pub use error::InputError; pub mod kill_switch; pub use kill_switch::{KillSwitch, KillSwitchObserver}; + +pub mod sink; + +pub use sink::{DragSegment, InputSink}; diff --git a/crates/vcli-input/src/sink.rs b/crates/vcli-input/src/sink.rs new file mode 100644 index 0000000..813ede9 --- /dev/null +++ b/crates/vcli-input/src/sink.rs @@ -0,0 +1,113 @@ +//! `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). + 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. + 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). + 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. + 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. + 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. + 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); + } +} From d7d870e3e2546c1c77015b51622318a1708fab79 Mon Sep 17 00:00:00 2001 From: Yixiang Zeng Date: Fri, 17 Apr 2026 07:43:33 -0700 Subject: [PATCH 06/17] vcli-input: MockInputSink with recording log + forced-error hook --- crates/vcli-input/src/lib.rs | 4 + crates/vcli-input/src/mock.rs | 280 ++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 crates/vcli-input/src/mock.rs diff --git a/crates/vcli-input/src/lib.rs b/crates/vcli-input/src/lib.rs index 4f5e38b..fbe3d90 100644 --- a/crates/vcli-input/src/lib.rs +++ b/crates/vcli-input/src/lib.rs @@ -25,3 +25,7 @@ pub use kill_switch::{KillSwitch, KillSwitchObserver}; pub mod sink; pub use sink::{DragSegment, InputSink}; + +pub mod mock; + +pub use mock::MockInputSink; diff --git a/crates/vcli-input/src/mock.rs b/crates/vcli-input/src/mock.rs new file mode 100644 index 0000000..4c05ded --- /dev/null +++ b/crates/vcli-input/src/mock.rs @@ -0,0 +1,280 @@ +//! `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. + #[must_use] + pub fn calls(&self) -> Vec { + self.log.lock().unwrap().clone() + } + + /// Drain and return calls (clears the log). + 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 }`. + 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"); + } +} From fb547134ed2604f25ebb30ab46bb3d81284f584e Mon Sep 17 00:00:00 2001 From: Yixiang Zeng Date: Fri, 17 Apr 2026 07:43:55 -0700 Subject: [PATCH 07/17] =?UTF-8?q?vcli-input:=20integration=20test=20?= =?UTF-8?q?=E2=80=94=20kill=20switch=20halts=20mock=20mid-sequence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/vcli-input/tests/kill_switch.rs | 94 ++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 crates/vcli-input/tests/kill_switch.rs 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 } + })] + ); +} From cd536f1aee6c3535676fd71f457e4d994b2008be Mon Sep 17 00:00:00 2001 From: Yixiang Zeng Date: Fri, 17 Apr 2026 07:44:40 -0700 Subject: [PATCH 08/17] vcli-input: canonical key parser + macOS virtual keycode table --- crates/vcli-input/src/keymap.rs | 245 ++++++++++++++++++++++++++++++++ crates/vcli-input/src/lib.rs | 4 + 2 files changed, 249 insertions(+) create mode 100644 crates/vcli-input/src/keymap.rs diff --git a/crates/vcli-input/src/keymap.rs b/crates/vcli-input/src/keymap.rs new file mode 100644 index 0000000..575c743 --- /dev/null +++ b/crates/vcli-input/src/keymap.rs @@ -0,0 +1,245 @@ +//! 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. Returns `UnknownKey` on anything we don't recognize. +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/lib.rs b/crates/vcli-input/src/lib.rs index fbe3d90..fcaed17 100644 --- a/crates/vcli-input/src/lib.rs +++ b/crates/vcli-input/src/lib.rs @@ -29,3 +29,7 @@ pub use sink::{DragSegment, InputSink}; pub mod mock; pub use mock::MockInputSink; + +pub mod keymap; + +pub use keymap::{macos_keycode, CanonicalKey}; From 652e7b48648c8ccc5766157684231433dbef8425 Mon Sep 17 00:00:00 2001 From: Yixiang Zeng Date: Fri, 17 Apr 2026 07:50:33 -0700 Subject: [PATCH 09/17] vcli-input: PermissionReport + macOS TCC probes + Windows stub scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added highsierra feature to core-graphics workspace dep (required for CGEvent::new_scroll_event). Changed crate root from #![forbid(unsafe_code)] to #![deny(unsafe_code)] so macOS submodules can use #![allow(unsafe_code)] — forbid cannot be overridden by inner allow. Added framework link attributes for ApplicationServices and IOKit in tcc.rs. --- Cargo.toml | 2 +- crates/vcli-input/src/lib.rs | 12 +++- crates/vcli-input/src/macos/mod.rs | 11 ++++ crates/vcli-input/src/macos/tcc.rs | 76 ++++++++++++++++++++++ crates/vcli-input/src/permissions.rs | 97 ++++++++++++++++++++++++++++ crates/vcli-input/src/windows/mod.rs | 41 ++++++++++++ 6 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 crates/vcli-input/src/macos/mod.rs create mode 100644 crates/vcli-input/src/macos/tcc.rs create mode 100644 crates/vcli-input/src/permissions.rs create mode 100644 crates/vcli-input/src/windows/mod.rs diff --git a/Cargo.toml b/Cargo.toml index ce7e475..a4cd620 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ unicode-normalization = "0.1" proptest = "1" # Platform-specific (used by vcli-input + vcli-capture later) -core-graphics = "0.24" +core-graphics = { version = "0.24", features = ["highsierra"] } core-foundation = "0.10" libc = "0.2" tempfile = "3" diff --git a/crates/vcli-input/src/lib.rs b/crates/vcli-input/src/lib.rs index fcaed17..22ddb87 100644 --- a/crates/vcli-input/src/lib.rs +++ b/crates/vcli-input/src/lib.rs @@ -8,7 +8,7 @@ //! Windows is a stub (`unimplemented!()`). See the v0 spec §Input synthesis //! and §Action confirmation for the contract this crate implements. -#![forbid(unsafe_code)] // relaxed inside macos/cg_events.rs via targeted allow +#![deny(unsafe_code)] // relaxed inside macos/ submodules via module-level #![allow(unsafe_code)] #![deny(rust_2018_idioms)] #![warn(missing_docs)] #![warn(clippy::pedantic)] @@ -33,3 +33,13 @@ 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/mod.rs b/crates/vcli-input/src/macos/mod.rs new file mode 100644 index 0000000..ed11c12 --- /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 tcc; +pub mod cg_events; +pub mod cg_typing; +pub mod cg_sink; +pub mod hotkey_tap; + +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..69ec638 --- /dev/null +++ b/crates/vcli-input/src/macos/tcc.rs @@ -0,0 +1,76 @@ +//! 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/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/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") + } +} From 47b4a6bb03a64fab19d2ce78a5c1bc65d1da1d47 Mon Sep 17 00:00:00 2001 From: Yixiang Zeng Date: Fri, 17 Apr 2026 07:50:39 -0700 Subject: [PATCH 10/17] vcli-input: macOS CGEvent helpers (move/click/drag/key/scroll) --- crates/vcli-input/src/macos/cg_events.rs | 176 +++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 crates/vcli-input/src/macos/cg_events.rs 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..25a07a0 --- /dev/null +++ b/crates/vcli-input/src/macos/cg_events.rs @@ -0,0 +1,176 @@ +//! 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`. Returns `InputError::Backend` +/// on FFI failure. +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. +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. +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`. +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. +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. +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]`. From b1ae90a3032eb5c9eca3cb283547b41225645ca1 Mon Sep 17 00:00:00 2001 From: Yixiang Zeng Date: Fri, 17 Apr 2026 07:50:43 -0700 Subject: [PATCH 11/17] vcli-input: macOS Unicode typing helper (CGEventKeyboardSetUnicodeString) --- crates/vcli-input/src/macos/cg_typing.rs | 54 ++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 crates/vcli-input/src/macos/cg_typing.rs 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..1f0954b --- /dev/null +++ b/crates/vcli-input/src/macos/cg_typing.rs @@ -0,0 +1,54 @@ +//! `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. +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(()) +} From c281cfd3f6eb195872e9588e53da6de9bd9270e7 Mon Sep 17 00:00:00 2001 From: Yixiang Zeng Date: Fri, 17 Apr 2026 07:50:46 -0700 Subject: [PATCH 12/17] vcli-input: CGEventInputSink wires CGEvent helpers into InputSink --- crates/vcli-input/src/macos/cg_sink.rs | 199 +++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 crates/vcli-input/src/macos/cg_sink.rs 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..a17cb24 --- /dev/null +++ b/crates/vcli-input/src/macos/cg_sink.rs @@ -0,0 +1,199 @@ +//! 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; + let dist = ((dx * dx + dy * dy) as f64).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(()) + } +} From 2456440354bbb22c34cce6176c399a0f326ce4e0 Mon Sep 17 00:00:00 2001 From: Yixiang Zeng Date: Fri, 17 Apr 2026 07:50:53 -0700 Subject: [PATCH 13/17] vcli-input: macOS CGEventTap listener for Cmd+Shift+Esc kill switch chord --- crates/vcli-input/src/macos/hotkey_tap.rs | 139 ++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 crates/vcli-input/src/macos/hotkey_tap.rs 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..3681e7f --- /dev/null +++ b/crates/vcli-input/src/macos/hotkey_tap.rs @@ -0,0 +1,139 @@ +//! 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. +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 tap = match tap_result { + Ok(t) => t, + Err(()) => { + // 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); + } +} From 88ccaf54556dbd423e3a52e36208cb4942667c9c Mon Sep 17 00:00:00 2001 From: Yixiang Zeng Date: Fri, 17 Apr 2026 07:51:21 -0700 Subject: [PATCH 14/17] vcli-input: mock-vs-trait contract tests --- crates/vcli-input/tests/mock_contract.rs | 148 +++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 crates/vcli-input/tests/mock_contract.rs diff --git a/crates/vcli-input/tests/mock_contract.rs b/crates/vcli-input/tests/mock_contract.rs new file mode 100644 index 0000000..ddb8659 --- /dev/null +++ b/crates/vcli-input/tests/mock_contract.rs @@ -0,0 +1,148 @@ +//! 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 + )); +} From 0b80fdbe84a80d5911796dca14e829891a2eab4e Mon Sep 17 00:00:00 2001 From: Yixiang Zeng Date: Fri, 17 Apr 2026 07:51:43 -0700 Subject: [PATCH 15/17] vcli-input: #[ignore] CGEvent integration smoke tests --- crates/vcli-input/tests/macos_real.rs | 96 +++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 crates/vcli-input/tests/macos_real.rs 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)); +} From f0b18e15666a43dbdd9fe04b5d73afff3ba93138 Mon Sep 17 00:00:00 2001 From: Yixiang Zeng Date: Fri, 17 Apr 2026 07:57:33 -0700 Subject: [PATCH 16/17] =?UTF-8?q?vcli-input:=20full-crate=20verification?= =?UTF-8?q?=20=E2=80=94=20fmt=20+=20clippy=20-D=20warnings=20+=20doc=20lin?= =?UTF-8?q?k=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three gates pass: cargo fmt, cargo clippy --workspace --all-targets -D warnings, cargo test --workspace --locked (84 vcli-core + 28 unit + 12 integration, 6 ignored). Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 82 +++++++++++++++++++++++ crates/vcli-input/src/error.rs | 2 +- crates/vcli-input/src/keymap.rs | 28 ++++++-- crates/vcli-input/src/kill_switch.rs | 15 ++--- crates/vcli-input/src/lib.rs | 2 +- crates/vcli-input/src/macos/cg_events.rs | 75 ++++++++++++++------- crates/vcli-input/src/macos/cg_sink.rs | 13 +++- crates/vcli-input/src/macos/cg_typing.rs | 22 +++--- crates/vcli-input/src/macos/hotkey_tap.rs | 31 +++++---- crates/vcli-input/src/macos/mod.rs | 4 +- crates/vcli-input/src/macos/tcc.rs | 5 +- crates/vcli-input/src/mock.rs | 18 +++-- crates/vcli-input/src/sink.rs | 40 +++++++++-- crates/vcli-input/tests/mock_contract.rs | 4 +- 14 files changed, 260 insertions(+), 81 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ebff385..8957644 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,6 +47,46 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[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 = "equivalent" version = "1.0.2" @@ -81,6 +121,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 = "getrandom" version = "0.3.4" @@ -522,6 +589,21 @@ dependencies = [ "uuid", ] +[[package]] +name = "vcli-input" +version = "0.0.1" +dependencies = [ + "core-foundation", + "core-graphics", + "libc", + "proptest", + "serde", + "serde_json", + "tempfile", + "thiserror", + "vcli-core", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/crates/vcli-input/src/error.rs b/crates/vcli-input/src/error.rs index 8218948..2657684 100644 --- a/crates/vcli-input/src/error.rs +++ b/crates/vcli-input/src/error.rs @@ -1,4 +1,4 @@ -//! Errors surfaced by [`InputSink`] implementations. +//! Errors surfaced by [`crate::sink::InputSink`] implementations. use thiserror::Error; use vcli_core::ErrorCode; diff --git a/crates/vcli-input/src/keymap.rs b/crates/vcli-input/src/keymap.rs index 575c743..d297b35 100644 --- a/crates/vcli-input/src/keymap.rs +++ b/crates/vcli-input/src/keymap.rs @@ -52,7 +52,16 @@ pub enum NamedKey { Function(u8), } -/// Parse a canonical name. Returns `UnknownKey` on anything we don't recognize. +/// 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() { @@ -94,7 +103,7 @@ pub fn parse(name: &str) -> Result { /// 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). +/// 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 { @@ -203,10 +212,19 @@ mod tests { #[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("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)); + assert_eq!( + parse("page_up").unwrap(), + CanonicalKey::Named(NamedKey::PageUp) + ); } #[test] diff --git a/crates/vcli-input/src/kill_switch.rs b/crates/vcli-input/src/kill_switch.rs index be0b72a..6270993 100644 --- a/crates/vcli-input/src/kill_switch.rs +++ b/crates/vcli-input/src/kill_switch.rs @@ -28,12 +28,9 @@ impl KillSwitch { 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, - ); + let _ = self + .engaged_at_ns + .compare_exchange(0, now_ns, Ordering::SeqCst, Ordering::SeqCst); self.engaged.store(true, Ordering::SeqCst); } @@ -69,6 +66,7 @@ pub struct KillSwitchObserver { 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 { @@ -88,8 +86,9 @@ fn duration_since_boot_ns() -> u64 { use std::time::SystemTime; SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) - .map(|d| u64::try_from(d.as_nanos() & u128::from(u64::MAX)).unwrap_or(0)) - .unwrap_or(0) + .map_or(0, |d| { + u64::try_from(d.as_nanos() & u128::from(u64::MAX)).unwrap_or(0) + }) } #[cfg(test)] diff --git a/crates/vcli-input/src/lib.rs b/crates/vcli-input/src/lib.rs index 22ddb87..afe68c5 100644 --- a/crates/vcli-input/src/lib.rs +++ b/crates/vcli-input/src/lib.rs @@ -1,6 +1,6 @@ //! vcli-input — input synthesis for vcli. //! -//! Exposes a synchronous [`InputSink`] trait with a macOS CGEvent backend, a +//! 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. diff --git a/crates/vcli-input/src/macos/cg_events.rs b/crates/vcli-input/src/macos/cg_events.rs index 25a07a0..e228461 100644 --- a/crates/vcli-input/src/macos/cg_events.rs +++ b/crates/vcli-input/src/macos/cg_events.rs @@ -1,4 +1,4 @@ -//! Raw CGEvent builders. Each function creates an event, posts it to the HID +//! 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). @@ -19,22 +19,24 @@ use vcli_core::geom::Point; use crate::error::InputError; -/// Make a new CGEventSource for `HIDSystemState`. Returns `InputError::Backend` -/// on FFI failure. +/// 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(), - }) + CGEventSource::new(CGEventSourceStateID::HIDSystemState).map_err(|()| InputError::Backend { + detail: "CGEventSource::new failed".into(), + }) } -/// Convert a Point to CGPoint in logical display coordinates. +/// 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. +/// Translate modifiers to a `CGEventFlags` bitmask. #[must_use] pub fn flags_from_modifiers(modifiers: &[Modifier]) -> CGEventFlags { let mut f = CGEventFlags::empty(); @@ -49,7 +51,7 @@ pub fn flags_from_modifiers(modifiers: &[Modifier]) -> CGEventFlags { f } -/// Translate our Button to CGMouseButton + the (type_down, type_up) pair we +/// 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) { @@ -73,6 +75,10 @@ pub fn button_types(b: Button) -> (CGMouseButton, CGEventType, CGEventType) { } /// 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( @@ -91,6 +97,10 @@ pub fn post_move(to: Point) -> Result<(), InputError> { /// 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, @@ -103,10 +113,11 @@ pub fn post_click( 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 { + 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); @@ -116,10 +127,11 @@ pub fn post_click( } // Up. - let up = CGEvent::new_mouse_event(src, up_ty, to_cg(at), cg_btn) - .map_err(|()| InputError::Backend { + 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); @@ -127,6 +139,10 @@ pub fn post_click( } /// 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 { @@ -135,23 +151,28 @@ pub fn post_drag_move(to: Point, button: Button) -> Result<(), InputError> { 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 { + 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 { + 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)); @@ -160,13 +181,19 @@ pub fn post_key( } /// 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(), + 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(()) diff --git a/crates/vcli-input/src/macos/cg_sink.rs b/crates/vcli-input/src/macos/cg_sink.rs index a17cb24..c7b38ec 100644 --- a/crates/vcli-input/src/macos/cg_sink.rs +++ b/crates/vcli-input/src/macos/cg_sink.rs @@ -1,5 +1,5 @@ //! Real macOS `InputSink`. Enforces the [`KillSwitch`] on every entry point, -//! then delegates to the low-level CGEvent helpers. +//! then delegates to the low-level `CGEvent` helpers. #![cfg(target_os = "macos")] @@ -71,7 +71,13 @@ impl InputSink for CGEventInputSink { ) -> Result<(), InputError> { self.guard()?; cg_events::post_move(at)?; - cg_events::post_click(at, button, modifiers, Duration::from_millis(hold_ms.into()), 1) + cg_events::post_click( + at, + button, + modifiers, + Duration::from_millis(hold_ms.into()), + 1, + ) } fn double_click(&self, at: Point, button: Button) -> Result<(), InputError> { @@ -124,7 +130,8 @@ impl InputSink for CGEventInputSink { } let dx = seg.to.x - current.x; let dy = seg.to.y - current.y; - let dist = ((dx * dx + dy * dy) as f64).sqrt() as i32; + #[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 { diff --git a/crates/vcli-input/src/macos/cg_typing.rs b/crates/vcli-input/src/macos/cg_typing.rs index 1f0954b..36c943f 100644 --- a/crates/vcli-input/src/macos/cg_typing.rs +++ b/crates/vcli-input/src/macos/cg_typing.rs @@ -13,24 +13,24 @@ use crate::error::InputError; /// 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 { + 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 { + 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(), - })?; + 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(()) @@ -40,6 +40,10 @@ fn post_unicode_chunk(chunk: &str) -> Result<(), InputError> { /// 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(()); diff --git a/crates/vcli-input/src/macos/hotkey_tap.rs b/crates/vcli-input/src/macos/hotkey_tap.rs index 3681e7f..76b933a 100644 --- a/crates/vcli-input/src/macos/hotkey_tap.rs +++ b/crates/vcli-input/src/macos/hotkey_tap.rs @@ -3,7 +3,7 @@ //! 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 +//! (keycode `0x35` with Cmd + Shift flags on `KeyDown`), it calls //! `KillSwitch::engage()`. Dropping the returned `KillSwitchListenerHandle` //! stops the run loop and joins the thread. //! @@ -19,14 +19,14 @@ use std::thread::JoinHandle; use core_foundation::runloop::{kCFRunLoopCommonModes, CFRunLoop}; use core_graphics::event::{ - CGEvent, CGEventFlags, CGEventTap, CGEventTapLocation, CGEventTapOptions, - CGEventTapPlacement, CGEventTapProxy, CGEventType, + 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. +/// `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; @@ -51,6 +51,16 @@ impl Drop for KillSwitchListenerHandle { /// 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 { @@ -79,8 +89,8 @@ pub fn spawn_kill_switch_listener( core_graphics::event::EventField::KEYBOARD_EVENT_KEYCODE, ); let flags = event.get_flags(); - let want = CGEventFlags::CGEventFlagCommand - | CGEventFlags::CGEventFlagShift; + let want = + CGEventFlags::CGEventFlagCommand | CGEventFlags::CGEventFlagShift; if keycode == KVK_ESCAPE_KEYCODE && flags.contains(want) { kill.engage(); } @@ -91,12 +101,9 @@ pub fn spawn_kill_switch_listener( }, ); - let tap = match tap_result { - Ok(t) => t, - Err(()) => { - // Input Monitoring not granted — exit thread cleanly. - return; - } + let Ok(tap) = tap_result else { + // Input Monitoring not granted — exit thread cleanly. + return; }; let runloop = CFRunLoop::get_current(); diff --git a/crates/vcli-input/src/macos/mod.rs b/crates/vcli-input/src/macos/mod.rs index ed11c12..f1854dd 100644 --- a/crates/vcli-input/src/macos/mod.rs +++ b/crates/vcli-input/src/macos/mod.rs @@ -1,11 +1,11 @@ //! macOS-only backend modules. Gated on `cfg(target_os = "macos")` at the //! crate root. -pub mod tcc; pub mod cg_events; -pub mod cg_typing; 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 index 69ec638..eb97f7d 100644 --- a/crates/vcli-input/src/macos/tcc.rs +++ b/crates/vcli-input/src/macos/tcc.rs @@ -13,9 +13,8 @@ use crate::permissions::{PermissionReport, PermissionStatus}; #[link(name = "ApplicationServices", kind = "framework")] extern "C" { - fn AXIsProcessTrustedWithOptions( - options: core_foundation::dictionary::CFDictionaryRef, - ) -> bool; + fn AXIsProcessTrustedWithOptions(options: core_foundation::dictionary::CFDictionaryRef) + -> bool; } #[link(name = "IOKit", kind = "framework")] diff --git a/crates/vcli-input/src/mock.rs b/crates/vcli-input/src/mock.rs index 4c05ded..a4509cf 100644 --- a/crates/vcli-input/src/mock.rs +++ b/crates/vcli-input/src/mock.rs @@ -34,7 +34,7 @@ pub enum MockCall { /// Held button. button: Button, }, - /// A click variant that carries modifiers + hold (InputAction::Click drops them). + /// A click variant that carries modifiers + hold (`InputAction::Click` drops them). ClickDetailed { /// Point. at: Point, @@ -75,12 +75,20 @@ impl MockInputSink { } /// 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()) } @@ -92,6 +100,10 @@ impl MockInputSink { } /// 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()); } @@ -254,9 +266,7 @@ mod tests { #[test] fn mock_rejects_empty_drag() { let m = MockInputSink::new(); - let e = m - .drag(Point { x: 0, y: 0 }, &[], Button::Left) - .unwrap_err(); + let e = m.drag(Point { x: 0, y: 0 }, &[], Button::Left).unwrap_err(); matches!(e, InputError::InvalidArgument(_)); } diff --git a/crates/vcli-input/src/sink.rs b/crates/vcli-input/src/sink.rs index 813ede9..b93c6d9 100644 --- a/crates/vcli-input/src/sink.rs +++ b/crates/vcli-input/src/sink.rs @@ -29,11 +29,21 @@ pub struct DragSegment { /// 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, @@ -45,26 +55,44 @@ pub trait InputSink: Send + Sync { /// 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. - fn drag( - &self, - from: Point, - segments: &[DragSegment], - button: Button, - ) -> Result<(), InputError>; + /// + /// # 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>; } diff --git a/crates/vcli-input/tests/mock_contract.rs b/crates/vcli-input/tests/mock_contract.rs index ddb8659..1859100 100644 --- a/crates/vcli-input/tests/mock_contract.rs +++ b/crates/vcli-input/tests/mock_contract.rs @@ -117,9 +117,7 @@ fn key_combo_records_modifiers() { #[test] fn empty_drag_is_rejected() { let m = new_mock(); - let e = m - .drag(Point { x: 0, y: 0 }, &[], Button::Left) - .unwrap_err(); + let e = m.drag(Point { x: 0, y: 0 }, &[], Button::Left).unwrap_err(); assert!(matches!(e, InputError::InvalidArgument(_))); } From a036f1e53101fd112fd34e0b9f1b10112150d61f Mon Sep 17 00:00:00 2001 From: Yixiang Zeng Date: Fri, 17 Apr 2026 08:54:46 -0700 Subject: [PATCH 17/17] ci: rerun after stuck macOS runner