use anyhow::Result;
use pixelactions_core::flow::Axis;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Button {
Left,
}
pub trait Injector {
fn move_to(&mut self, x: f64, y: f64) -> Result<()>;
fn click(&mut self, button: Button) -> Result<()>;
fn double_click(&mut self, button: Button) -> Result<()>;
fn press(&mut self, button: Button) -> Result<()>;
fn release(&mut self, button: Button) -> Result<()>;
fn text(&mut self, text: &str) -> Result<()>;
fn chord(&mut self, chord: &str) -> Result<()>;
fn scroll(&mut self, amount: i32, axis: Axis) -> Result<()>;
fn cursor(&mut self) -> Result<(f64, f64)>;
fn probe(&mut self) -> Result<()>;
}
#[cfg(test)]
#[derive(Debug, Clone, PartialEq)]
pub struct Recording {
pub events: Vec<String>,
pub cursor: (f64, f64),
}
#[cfg(test)]
impl Default for Recording {
fn default() -> Self {
Self {
events: Vec::new(),
cursor: (400.0, 300.0),
}
}
}
#[cfg(test)]
impl Injector for Recording {
fn move_to(&mut self, x: f64, y: f64) -> Result<()> {
self.events.push(format!("move {x:.0},{y:.0}"));
Ok(())
}
fn click(&mut self, _button: Button) -> Result<()> {
self.events.push("click".into());
Ok(())
}
fn double_click(&mut self, _button: Button) -> Result<()> {
self.events.push("double_click".into());
Ok(())
}
fn press(&mut self, _button: Button) -> Result<()> {
self.events.push("press".into());
Ok(())
}
fn release(&mut self, _button: Button) -> Result<()> {
self.events.push("release".into());
Ok(())
}
fn text(&mut self, text: &str) -> Result<()> {
self.events.push(format!("text {text}"));
Ok(())
}
fn chord(&mut self, chord: &str) -> Result<()> {
self.events.push(format!("chord {chord}"));
Ok(())
}
fn scroll(&mut self, amount: i32, axis: Axis) -> Result<()> {
let way = match axis {
Axis::Vertical => "v",
Axis::Horizontal => "h",
};
self.events.push(format!("scroll {way}{amount}"));
Ok(())
}
fn cursor(&mut self) -> Result<(f64, f64)> {
Ok(self.cursor)
}
fn probe(&mut self) -> Result<()> {
self.events.push("probe".into());
Ok(())
}
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
pub use platform::RealInjector;
#[cfg(target_os = "linux")]
pub fn availability() -> Result<(), String> {
use pixelactions_core::display::{Server, detect};
let server = detect(
std::env::var("XDG_SESSION_TYPE").ok().as_deref(),
std::env::var("WAYLAND_DISPLAY").ok().as_deref(),
std::env::var("DISPLAY").ok().as_deref(),
);
match server {
Server::Wayland => {}
Server::X11 => {
return Err(
"this is an X11 session, and this build only synthesizes input on Wayland. \
X11 support is tracked separately; `plan` works everywhere"
.to_string(),
);
}
Server::Unknown => {
return Err(
"no desktop session was found — neither XDG_SESSION_TYPE, WAYLAND_DISPLAY \
nor DISPLAY names one. Synthesizing input needs a compositor to send it \
to; `plan` works without one"
.to_string(),
);
}
}
let capabilities = crate::portal::capabilities()
.map_err(|error| format!("cannot ask the desktop portal what it supports: {error:#}"))?;
if !capabilities.usable() {
return Err(format!(
"this compositor cannot grant input: the portal offers RemoteDesktop version {} \
(needs 2 or newer for ConnectToEIS), device types {:#b} (needs keyboard and \
pointer), ScreenCast version {}. GNOME and KDE implement this; wlroots \
compositors do not yet",
capabilities.remote_desktop_version,
capabilities.device_types,
capabilities.screen_cast_version
));
}
Ok(())
}
#[cfg(not(target_os = "linux"))]
pub fn availability() -> Result<(), String> {
if cfg!(target_os = "macos") {
return Ok(());
}
Err("input synthesis is not implemented for this platform yet — `plan` works everywhere".into())
}
#[cfg(target_os = "linux")]
mod platform {
use anyhow::{Result, anyhow, bail};
use pixelactions_core::flow::Axis;
use pixelactions_core::stream::place;
use pixelcoords_core::session::MonitorRecord;
use super::{Button, Injector};
use crate::{eis, portal};
pub struct RealInjector {
sender: eis::Sender,
monitors: Vec<MonitorRecord>,
_grant: portal::Grant,
}
impl RealInjector {
pub fn new(monitors: &[MonitorRecord]) -> Result<Self> {
let mut grant = portal::grant()?;
let sender = eis::Sender::connect(grant.take_socket()?)?;
Ok(Self {
sender,
monitors: monitors.to_vec(),
_grant: grant,
})
}
pub fn can_type(&self) -> bool {
self.sender.can_type()
}
pub fn regions(&self) -> &[pixelactions_core::stream::Region] {
self.sender.regions()
}
}
impl Injector for RealInjector {
fn move_to(&mut self, x: f64, y: f64) -> Result<()> {
let placement = place(
&self.monitors,
self.sender.regions(),
x.round() as i32,
y.round() as i32,
)
.map_err(|error| anyhow!("cannot place the pointer: {error}"))?;
self.sender.move_to(placement)
}
fn click(&mut self, button: Button) -> Result<()> {
self.press(button)?;
self.release(button)
}
fn double_click(&mut self, button: Button) -> Result<()> {
self.click(button)?;
std::thread::sleep(std::time::Duration::from_millis(40));
self.click(button)
}
fn press(&mut self, button: Button) -> Result<()> {
match button {
Button::Left => self.sender.button(true),
}
}
fn release(&mut self, button: Button) -> Result<()> {
match button {
Button::Left => self.sender.button(false),
}
}
fn text(&mut self, text: &str) -> Result<()> {
self.sender.text(text)
}
fn chord(&mut self, chord: &str) -> Result<()> {
self.sender.chord(chord)
}
fn scroll(&mut self, amount: i32, axis: Axis) -> Result<()> {
self.sender.scroll(amount, axis)
}
fn cursor(&mut self) -> Result<(f64, f64)> {
bail!(
"Wayland exposes no way to ask where the pointer is — the same isolation \
that makes input injection require your consent also hides the pointer \
from other programs. The corner kill switch therefore has nothing to \
watch on this platform. Set failsafe = false in the flow to run without \
it, deliberately"
)
}
fn probe(&mut self) -> Result<()> {
if self.sender.regions().is_empty() {
bail!("the compositor granted input but described no region to aim in");
}
Ok(())
}
}
}
#[cfg(target_os = "macos")]
mod platform {
use anyhow::{Context, Result, anyhow};
use enigo::{
Axis as EnigoAxis, Button as EnigoButton, Coordinate, Direction, Enigo, Key, Keyboard,
Mouse, Settings,
};
use pixelactions_core::flow::Axis;
use super::{Button, Injector};
pub struct RealInjector {
enigo: Enigo,
}
impl RealInjector {
pub fn new() -> Result<Self> {
let enigo = Enigo::new(&Settings::default()).map_err(|e| {
anyhow!(
"cannot synthesize input: {e}. On macOS this usually means the \
Accessibility permission is missing — grant it under System Settings \
> Privacy & Security > Accessibility for the terminal running \
pixelactions, then try again"
)
})?;
Ok(Self { enigo })
}
}
fn to_enigo(button: Button) -> EnigoButton {
match button {
Button::Left => EnigoButton::Left,
}
}
fn key_for(token: &str) -> Result<Key> {
let key = match token.to_ascii_lowercase().as_str() {
"cmd" | "command" | "meta" | "super" => Key::Meta,
"ctrl" | "control" => Key::Control,
"alt" | "option" | "opt" => Key::Alt,
"shift" => Key::Shift,
"tab" => Key::Tab,
"enter" | "return" => Key::Return,
"esc" | "escape" => Key::Escape,
"space" => Key::Space,
"backspace" | "delete" => Key::Backspace,
"up" => Key::UpArrow,
"down" => Key::DownArrow,
"left" => Key::LeftArrow,
"right" => Key::RightArrow,
other => {
let mut chars = other.chars();
let first = chars.next().context("empty key in chord")?;
if chars.next().is_some() {
return Err(anyhow!(
"unknown key {other:?} in chord — use a single character or a \
named key (cmd, ctrl, alt, shift, tab, enter, esc, space, \
backspace, arrows)"
));
}
Key::Unicode(first)
}
};
Ok(key)
}
impl Injector for RealInjector {
fn move_to(&mut self, x: f64, y: f64) -> Result<()> {
self.enigo
.move_mouse(x as i32, y as i32, Coordinate::Abs)
.map_err(|e| anyhow!("move to ({x:.0}, {y:.0}) failed: {e}"))
}
fn click(&mut self, button: Button) -> Result<()> {
self.enigo
.button(to_enigo(button), Direction::Click)
.map_err(|e| anyhow!("click failed: {e}"))
}
fn double_click(&mut self, button: Button) -> Result<()> {
self.click(button)?;
std::thread::sleep(std::time::Duration::from_millis(40));
self.click(button)
}
fn press(&mut self, button: Button) -> Result<()> {
self.enigo
.button(to_enigo(button), Direction::Press)
.map_err(|e| anyhow!("press failed: {e}"))
}
fn release(&mut self, button: Button) -> Result<()> {
self.enigo
.button(to_enigo(button), Direction::Release)
.map_err(|e| anyhow!("release failed: {e}"))
}
fn text(&mut self, text: &str) -> Result<()> {
self.enigo
.text(text)
.map_err(|e| anyhow!("typing failed: {e}"))
}
fn scroll(&mut self, amount: i32, axis: Axis) -> Result<()> {
let axis = match axis {
Axis::Vertical => EnigoAxis::Vertical,
Axis::Horizontal => EnigoAxis::Horizontal,
};
self.enigo
.scroll(amount, axis)
.map_err(|e| anyhow!("cannot scroll: {e}"))
}
fn cursor(&mut self) -> Result<(f64, f64)> {
let (x, y) = self
.enigo
.location()
.map_err(|e| anyhow!("cannot read the cursor position: {e}"))?;
Ok((f64::from(x), f64::from(y)))
}
fn probe(&mut self) -> Result<()> {
let (x, y) = self
.enigo
.location()
.map_err(|e| anyhow!("cannot read the cursor position: {e}"))?;
let target = (x + 1, y);
self.enigo
.move_mouse(target.0, target.1, Coordinate::Abs)
.map_err(|e| anyhow!("cannot move the cursor: {e}"))?;
std::thread::sleep(std::time::Duration::from_millis(60));
let after = self
.enigo
.location()
.map_err(|e| anyhow!("cannot read the cursor position: {e}"))?;
let _ = self.enigo.move_mouse(x, y, Coordinate::Abs);
if after == (x, y) {
return Err(anyhow!(
"the cursor did not move — macOS accepted the event and discarded it, \
which is what happens without the Accessibility permission. Grant it \
under System Settings > Privacy & Security > Accessibility for the \
application running pixelactions (your terminal, if you launched it \
from one), then quit and reopen that application"
));
}
Ok(())
}
fn chord(&mut self, chord: &str) -> Result<()> {
let (modifiers, key) = pixelactions_core::chord::split(chord)?;
let mut held = Vec::new();
for token in &modifiers {
let modifier = key_for(token)?;
self.enigo
.key(modifier, Direction::Press)
.map_err(|e| anyhow!("holding {token} failed: {e}"))?;
held.push(modifier);
}
let result = self
.enigo
.key(key_for(key)?, Direction::Click)
.map_err(|e| anyhow!("pressing {key} failed: {e}"));
for modifier in held.into_iter().rev() {
let _ = self.enigo.key(modifier, Direction::Release);
}
result
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_recording_injector_captures_order() {
let mut injector = Recording::default();
injector.move_to(10.4, 20.6).expect("recorded");
injector.click(Button::Left).expect("recorded");
injector.text("hi").expect("recorded");
assert_eq!(injector.events, vec!["move 10,21", "click", "text hi"]);
}
}