use crate::error::ComputerError;
use crate::policy::SYNTHETIC_EVENT_USER_DATA;
use crate::types::{Modifier, MouseButton};
#[cfg_attr(not(any(test, target_os = "macos")), allow(dead_code))]
pub const FIELD_TARGET_UNIX_PROCESS_ID: u32 = 40;
#[cfg_attr(not(any(test, target_os = "macos")), allow(dead_code))]
pub const FIELD_EVENT_SOURCE_USER_DATA: u32 = 42;
#[cfg_attr(not(any(test, target_os = "macos")), allow(dead_code))]
pub const FIELD_MOUSE_WINDOW_UNDER_POINTER: u32 = 91;
#[cfg_attr(not(any(test, target_os = "macos")), allow(dead_code))]
pub const FIELD_MOUSE_WINDOW_THAT_CAN_HANDLE: u32 = 92;
#[cfg_attr(not(all(feature = "skylight", target_os = "macos")), allow(dead_code))]
pub const SKYLIGHT_FRONT_SYMBOLS: [&str; 3] = [
"SLPSSetFrontProcessWithOptions",
"SLSSetFrontProcessWithOptions",
"CPSSetFrontProcess",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TierName {
Ax,
Event,
Synthetic,
Activate,
}
impl TierName {
pub fn as_str(self) -> &'static str {
match self {
Self::Ax => "ax",
Self::Event => "event",
Self::Synthetic => "synthetic",
Self::Activate => "activate",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestedTier {
Auto,
Named(TierName),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputClass {
#[allow(dead_code)]
Accessibility,
Background,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Capabilities {
pub event: bool,
}
pub fn skylight_compiled() -> bool {
cfg!(feature = "skylight")
}
pub fn parse_tier(raw: Option<&str>) -> Result<RequestedTier, ComputerError> {
let Some(raw) = raw.map(str::trim).filter(|text| !text.is_empty()) else {
return Ok(RequestedTier::Auto);
};
let named = match raw {
"ax" => TierName::Ax,
"event" => TierName::Event,
"synthetic" => TierName::Synthetic,
"activate" => TierName::Activate,
other => {
return Err(ComputerError::InvalidArguments(format!(
"unknown tier {other:?}; expected ax, event, synthetic, or activate"
)))
}
};
Ok(RequestedTier::Named(named))
}
pub fn plan(
requested: RequestedTier,
class: InputClass,
caps: Capabilities,
skylight: bool,
) -> Result<Vec<TierName>, ComputerError> {
match requested {
RequestedTier::Named(TierName::Synthetic) if !skylight => Err(ComputerError::Unsupported(
"built without skylight feature".into(),
)),
RequestedTier::Named(TierName::Synthetic) => Ok(vec![TierName::Synthetic]),
RequestedTier::Named(TierName::Event) if !caps.event => Err(ComputerError::Unsupported(
format!("tier event is not available on {}", std::env::consts::OS),
)),
RequestedTier::Named(TierName::Event) => Ok(vec![TierName::Event]),
RequestedTier::Named(TierName::Ax) if matches!(class, InputClass::Background) => {
Err(ComputerError::Unsupported(
"tier ax does not post coordinate or keyboard events; pass --element to use an accessibility action".into(),
))
}
RequestedTier::Named(tier) => Ok(vec![tier]),
RequestedTier::Auto => match class {
InputClass::Accessibility => Ok(vec![TierName::Ax]),
InputClass::Background if caps.event => Ok(vec![TierName::Event, TierName::Activate]),
InputClass::Background => Ok(vec![TierName::Activate]),
},
}
}
pub fn fallback_to_activate(lock_held: bool, require_background: bool) -> bool {
!lock_held && !require_background
}
pub fn plan_input(
requested: RequestedTier,
kind: &InputKind,
caps: Capabilities,
skylight: bool,
) -> Result<Vec<TierName>, ComputerError> {
if matches!(requested, RequestedTier::Named(TierName::Ax)) && is_scroll(kind) {
return Ok(vec![TierName::Ax]);
}
plan(requested, InputClass::Background, caps, skylight)
}
pub fn is_scroll(kind: &InputKind) -> bool {
matches!(kind, InputKind::Scroll { .. })
}
#[cfg_attr(not(any(test, target_os = "macos")), allow(dead_code))]
pub fn window_local_point(
screen_x: i32,
screen_y: i32,
origin_x: i32,
origin_y: i32,
) -> (i32, i32) {
(
screen_x.saturating_sub(origin_x),
screen_y.saturating_sub(origin_y),
)
}
#[cfg_attr(not(any(test, target_os = "macos")), allow(dead_code))]
pub fn event_integer_fields(window_id: u32, pid: i32) -> [(u32, i64); 4] {
[
(FIELD_EVENT_SOURCE_USER_DATA, SYNTHETIC_EVENT_USER_DATA),
(FIELD_TARGET_UNIX_PROCESS_ID, i64::from(pid)),
(FIELD_MOUSE_WINDOW_UNDER_POINTER, i64::from(window_id)),
(FIELD_MOUSE_WINDOW_THAT_CAN_HANDLE, i64::from(window_id)),
]
}
#[cfg_attr(not(any(test, target_os = "macos")), allow(dead_code))]
pub fn cursor_moved(before_x: f64, before_y: f64, after_x: f64, after_y: f64) -> bool {
(after_x - before_x).abs() > 1.0 || (after_y - before_y).abs() > 1.0
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn first_present<'a>(names: &'a [&'a str], present: &[bool]) -> Option<&'a str> {
names
.iter()
.zip(present.iter())
.find_map(|(name, found)| found.then_some(*name))
}
pub const MAX_HOLD_MS: u64 = 30_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyStroke {
pub modifiers: Vec<Modifier>,
pub key: String,
}
#[cfg_attr(not(any(test, target_os = "macos")), allow(dead_code))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClickHalf {
Down,
Up,
}
#[cfg_attr(not(any(test, target_os = "macos")), allow(dead_code))]
pub fn click_state_sequence(count: u8) -> Vec<(ClickHalf, u8)> {
let clicks = count.max(1);
let mut pairs = Vec::with_capacity(usize::from(clicks) * 2);
for state in 1..=clicks {
pairs.push((ClickHalf::Down, state));
pairs.push((ClickHalf::Up, state));
}
pairs
}
#[derive(Debug, Clone, PartialEq)]
pub struct InputJob {
pub tier: TierName,
pub kind: InputKind,
pub app: Option<String>,
pub window: Option<String>,
pub allow_foreground: bool,
pub require_background: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum InputKind {
Click {
x: i32,
y: i32,
button: MouseButton,
count: u8,
},
Move {
x: i32,
y: i32,
},
Scroll {
x: Option<i32>,
y: Option<i32>,
dx: i32,
dy: i32,
},
Type {
text: String,
},
Key {
strokes: Vec<KeyStroke>,
},
Drag {
points: Vec<(i32, i32)>,
button: MouseButton,
},
MouseDown {
x: i32,
y: i32,
button: MouseButton,
},
MouseUp {
x: i32,
y: i32,
button: MouseButton,
},
HoldKey {
modifiers: Vec<Modifier>,
key: String,
ms: u64,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct InputReport {
pub tier: &'static str,
pub cursor_moved: bool,
pub focus_restored: Option<bool>,
pub warnings: Vec<String>,
pub scroll: Option<crate::scroll::Outcome>,
}
pub fn input_point(kind: &InputKind) -> Option<(i32, i32)> {
match kind {
InputKind::Click { x, y, .. }
| InputKind::Move { x, y }
| InputKind::MouseDown { x, y, .. }
| InputKind::MouseUp { x, y, .. } => Some((*x, *y)),
InputKind::Drag { points, .. } => points.last().copied(),
InputKind::Scroll {
x: Some(x),
y: Some(y),
..
} => Some((*x, *y)),
InputKind::Scroll { .. }
| InputKind::Type { .. }
| InputKind::Key { .. }
| InputKind::HoldKey { .. } => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy;
#[test]
fn auto_plan_uses_ax_for_presses_and_event_then_activate_for_points() {
let caps = Capabilities { event: true };
assert_eq!(
plan(RequestedTier::Auto, InputClass::Accessibility, caps, false).unwrap(),
vec![TierName::Ax]
);
assert_eq!(
plan(RequestedTier::Auto, InputClass::Background, caps, false).unwrap(),
vec![TierName::Event, TierName::Activate]
);
assert_eq!(
plan(
RequestedTier::Auto,
InputClass::Background,
Capabilities { event: false },
false
)
.unwrap(),
vec![TierName::Activate]
);
}
#[test]
fn explicit_event_without_support_is_unsupported() {
let error = plan(
RequestedTier::Named(TierName::Event),
InputClass::Background,
Capabilities { event: false },
false,
)
.unwrap_err();
assert_eq!(error.code(), "unsupported");
assert!(error.to_string().contains("tier event"));
}
#[test]
fn synthetic_without_the_feature_is_unsupported_and_never_selected_automatically() {
let error = plan(
RequestedTier::Named(TierName::Synthetic),
InputClass::Background,
Capabilities { event: true },
false,
)
.unwrap_err();
assert_eq!(error.code(), "unsupported");
assert!(error.to_string().contains("built without skylight feature"));
let auto = plan(
RequestedTier::Auto,
InputClass::Background,
Capabilities { event: true },
true,
)
.unwrap();
assert!(!auto.contains(&TierName::Synthetic));
assert_eq!(
plan(
RequestedTier::Named(TierName::Synthetic),
InputClass::Background,
Capabilities { event: true },
true
)
.unwrap(),
vec![TierName::Synthetic]
);
}
#[test]
fn event_failure_falls_back_only_when_the_lock_is_free() {
assert!(fallback_to_activate(false, false));
assert!(!fallback_to_activate(true, false));
assert!(!fallback_to_activate(false, true));
}
#[test]
fn a_named_ax_tier_runs_only_for_a_scroll() {
let caps = Capabilities { event: true };
let scroll = InputKind::Scroll {
x: None,
y: None,
dx: 0,
dy: 3,
};
assert_eq!(
plan_input(RequestedTier::Named(TierName::Ax), &scroll, caps, false).unwrap(),
vec![TierName::Ax]
);
let click = InputKind::Click {
x: 1,
y: 2,
button: crate::types::MouseButton::Left,
count: 1,
};
let error =
plan_input(RequestedTier::Named(TierName::Ax), &click, caps, false).unwrap_err();
assert_eq!(error.code(), "unsupported");
assert!(error.to_string().contains("--element"));
let key = InputKind::Key {
strokes: vec![KeyStroke {
modifiers: Vec::new(),
key: "a".into(),
}],
};
assert_eq!(
plan_input(RequestedTier::Named(TierName::Ax), &key, caps, false)
.unwrap_err()
.code(),
"unsupported"
);
assert!(is_scroll(&scroll));
assert!(!is_scroll(&click));
}
#[test]
fn scroll_keeps_the_auto_plan_and_the_other_named_tiers() {
let caps = Capabilities { event: true };
let scroll = InputKind::Scroll {
x: None,
y: None,
dx: 0,
dy: 3,
};
assert_eq!(
plan_input(RequestedTier::Auto, &scroll, caps, false).unwrap(),
vec![TierName::Event, TierName::Activate]
);
assert_eq!(
plan_input(RequestedTier::Named(TierName::Event), &scroll, caps, false).unwrap(),
vec![TierName::Event]
);
assert_eq!(
plan_input(
RequestedTier::Named(TierName::Activate),
&scroll,
caps,
false
)
.unwrap(),
vec![TierName::Activate]
);
assert_eq!(
plan_input(
RequestedTier::Named(TierName::Event),
&scroll,
Capabilities { event: false },
false
)
.unwrap_err()
.code(),
"unsupported"
);
assert_eq!(
plan_input(
RequestedTier::Named(TierName::Synthetic),
&scroll,
caps,
false
)
.unwrap_err()
.code(),
"unsupported"
);
}
#[test]
fn window_local_point_subtracts_the_origin() {
assert_eq!(window_local_point(200, 150, 100, 100), (100, 50));
}
#[test]
fn stamped_user_data_is_the_tap_marker() {
let fields = event_integer_fields(88, 412);
assert_eq!(fields[0].0, 42);
assert_eq!(fields[1].0, 40);
assert_eq!(fields[2].0, 91);
assert_eq!(fields[3].0, 92);
assert_eq!(fields[2].1, 88);
assert_eq!(fields[1].1, 412);
assert!(policy::event_is_synthetic(fields[0].1));
assert!(!policy::event_is_synthetic(0));
assert_eq!(fields[0].1, SYNTHETIC_EVENT_USER_DATA);
}
#[test]
fn cursor_motion_threshold_is_one_point() {
assert!(!cursor_moved(10.0, 10.0, 11.0, 10.0));
assert!(cursor_moved(10.0, 10.0, 11.1, 10.0));
assert!(cursor_moved(10.0, 10.0, 10.0, 8.5));
}
#[test]
fn first_present_symbol_wins_and_a_total_miss_is_none() {
let names = SKYLIGHT_FRONT_SYMBOLS.as_slice();
assert_eq!(
first_present(names, &[false, true, true]),
Some("SLSSetFrontProcessWithOptions")
);
assert_eq!(first_present(names, &[false, false, false]), None);
assert_eq!(
first_present(names, &[true, false, false]),
Some("SLPSSetFrontProcessWithOptions")
);
}
#[test]
fn ax_is_unsupported_for_coordinate_and_keyboard_input() {
let error = plan(
RequestedTier::Named(TierName::Ax),
InputClass::Background,
Capabilities { event: true },
false,
)
.unwrap_err();
assert_eq!(error.code(), "unsupported");
assert!(error.to_string().contains("pass --element"));
assert_eq!(
plan(
RequestedTier::Named(TierName::Ax),
InputClass::Accessibility,
Capabilities { event: true },
false
)
.unwrap(),
vec![TierName::Ax]
);
let drag = InputKind::Drag {
points: vec![(1, 2), (3, 4), (8, 9)],
button: MouseButton::Left,
};
assert_eq!(input_point(&drag), Some((8, 9)));
assert_eq!(
input_point(&InputKind::MouseDown {
x: 5,
y: 6,
button: MouseButton::Right,
}),
Some((5, 6))
);
assert!(input_point(&InputKind::HoldKey {
modifiers: vec![],
key: "shift".into(),
ms: 10,
})
.is_none());
}
#[test]
fn triple_click_states_are_three_down_up_pairs() {
assert_eq!(
click_state_sequence(3),
vec![
(ClickHalf::Down, 1),
(ClickHalf::Up, 1),
(ClickHalf::Down, 2),
(ClickHalf::Up, 2),
(ClickHalf::Down, 3),
(ClickHalf::Up, 3),
]
);
assert_eq!(
click_state_sequence(2),
vec![
(ClickHalf::Down, 1),
(ClickHalf::Up, 1),
(ClickHalf::Down, 2),
(ClickHalf::Up, 2),
]
);
}
#[test]
fn unknown_tier_name_is_invalid_arguments() {
let error = parse_tier(Some("hid")).unwrap_err();
assert_eq!(error.code(), "invalid_arguments");
assert!(matches!(parse_tier(None).unwrap(), RequestedTier::Auto));
assert_eq!(
parse_tier(Some("activate")).unwrap(),
RequestedTier::Named(TierName::Activate)
);
}
}