mod cli;
mod doctor;
#[cfg(target_os = "linux")]
mod eis;
mod inject;
#[cfg(target_os = "macos")]
mod mac;
#[cfg(target_os = "linux")]
mod portal;
mod run;
mod serve;
mod session;
mod verify;
#[cfg(target_os = "windows")]
mod win;
use anyhow::Result;
use clap::Parser;
use pixelactions_core::convert::Space;
use pixelactions_core::flow::Flow;
use pixelactions_core::plan::{Plan, plan};
const EXIT_MALFORMED: i32 = 2;
pub const EXIT_REFUSED: i32 = 3;
fn main() {
#[cfg(target_os = "windows")]
let _ = win::become_dpi_aware();
let cli = cli::Cli::parse();
let result = match cli.command {
cli::Command::Plan {
flow,
session,
verbs,
json,
space,
} => run_plan(
&Source {
flow,
session,
verbs,
},
json,
space.map(Into::into),
),
cli::Command::Run {
flow,
session,
verbs,
json,
yes,
} => run_flow(
&Source {
flow,
session,
verbs,
},
json,
yes,
),
cli::Command::Serve { session } => serve::run(&expand_home(&session.display().to_string())),
cli::Command::Doctor { json, probe } => doctor::run(json, probe),
};
match result {
Ok(code) => std::process::exit(code),
Err(error) => {
eprintln!("pixelactions: {error:#}");
std::process::exit(EXIT_MALFORMED);
}
}
}
fn run_flow(source: &Source, json: bool, yes: bool) -> Result<i32> {
if let Err(reason) = inject::availability() {
eprintln!("pixelactions: {reason}");
return Ok(EXIT_REFUSED);
}
if let Err(reason) = doctor::require_supported_pixelcoords() {
eprintln!("pixelactions: {reason}");
return Ok(EXIT_REFUSED);
}
let (flow, session_path, session) = load_flow(source)?;
let space = flow.settings.space;
let resolved = plan(&flow, &session, space)?;
if !yes {
eprintln!(
"about to perform {} steps — this moves your mouse and keyboard.",
resolved.steps.len()
);
eprintln!(
"run the same arguments with `plan` first to see every coordinate, then pass --yes."
);
return Ok(EXIT_REFUSED);
}
let mut verifier =
|session: &std::path::Path, label: Option<&str>| verify::find(session, label);
if !json {
println!("session: {}", session_path.display());
println!();
}
let checking = flow.acting_targets();
if flow.settings.relocate && !checking.is_empty() {
eprintln!("checking {} region(s) before acting", checking.len());
}
let found = |label: &str| {
use std::io::Write;
eprintln!(" found {label}");
let _ = std::io::stderr().flush();
};
let corrections = match run::preflight(
&flow,
&session_path,
&session.monitors,
space,
&mut verifier,
&found,
) {
Ok(corrections) => corrections,
Err(refusal) => {
eprintln!("pixelactions: {refusal:#}");
return Ok(EXIT_REFUSED);
}
};
if !corrections.is_empty() {
eprintln!(
"{} region(s) moved since capture — acting on where they are now",
corrections.len()
);
}
let live = |step: &pixelactions_core::report::StepReport| print_step(step);
let progress: &dyn Fn(&pixelactions_core::report::StepReport) =
if json { run::silent() } else { &live };
let mut injector = make_injector(&session.monitors)?;
let report = run::execute(
injector.as_mut(),
&run::Context {
flow: &flow,
plan: &resolved,
session: &session_path,
monitors: &session.monitors,
corrections: &corrections,
checked: flow.settings.relocate,
progress,
},
&mut verifier,
);
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
}
Ok(report.exit_code())
}
#[cfg(target_os = "macos")]
pub fn make_injector(
_monitors: &[pixelcoords_core::session::MonitorRecord],
) -> Result<Box<dyn inject::Injector>> {
Ok(Box::new(inject::RealInjector::new()?))
}
#[cfg(target_os = "linux")]
pub fn make_injector(
monitors: &[pixelcoords_core::session::MonitorRecord],
) -> Result<Box<dyn inject::Injector>> {
use pixelactions_core::display::Server;
match inject::session_server() {
Server::X11 => Ok(Box::new(inject::X11Injector::new()?)),
Server::Wayland => Ok(Box::new(inject::WaylandInjector::new(monitors)?)),
Server::Unknown => anyhow::bail!(
"no desktop session was found — neither XDG_SESSION_TYPE, WAYLAND_DISPLAY nor \
DISPLAY names one, so there is nothing to send input to"
),
}
}
#[cfg(target_os = "windows")]
pub fn make_injector(
_monitors: &[pixelcoords_core::session::MonitorRecord],
) -> Result<Box<dyn inject::Injector>> {
Ok(Box::new(inject::WindowsInjector::new()?))
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
pub fn make_injector(
_monitors: &[pixelcoords_core::session::MonitorRecord],
) -> Result<Box<dyn inject::Injector>> {
anyhow::bail!("input synthesis is not implemented for this platform yet")
}
fn print_step(step: &pixelactions_core::report::StepReport) {
use std::io::Write;
let mark = format!("{:<8}", step.outcome.name());
println!(
" {} {:>2}. {} ({} ms)",
mark,
step.index + 1,
step.summary,
step.elapsed_ms
);
if let Some(detail) = &step.detail {
println!(" {detail}");
}
let _ = std::io::stdout().flush();
}
pub struct Source {
pub flow: Option<std::path::PathBuf>,
pub session: Option<std::path::PathBuf>,
pub verbs: Vec<String>,
}
fn load_flow(
source: &Source,
) -> Result<(
Flow,
std::path::PathBuf,
pixelcoords_core::session::SessionFile,
)> {
let flow = build_flow(source)?;
let session_path = expand_home(&flow.session);
let session = session::load(&session_path)?;
Ok((flow, session_path, session))
}
fn build_flow(source: &Source) -> Result<Flow> {
if let Some(path) = &source.flow {
if !source.verbs.is_empty() {
anyhow::bail!(
"pass a flow file or chained verbs, not both — the flow already lists its steps"
);
}
let text = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", path.display()))?;
return Ok(Flow::parse(&text)?);
}
let Some(directory) = &source.session else {
anyhow::bail!("need either --flow FILE or --session DIR with chained verbs");
};
if source.verbs.is_empty() {
anyhow::bail!("nothing to do — pass verbs like click:submit, or --flow with a file");
}
Ok(Flow {
session: directory.display().to_string(),
settings: pixelactions_core::flow::Settings::default(),
steps: pixelactions_core::verb::parse_all(&source.verbs)?,
})
}
fn run_plan(source: &Source, json: bool, space: Option<Space>) -> Result<i32> {
let (flow, session_path, session) = load_flow(source)?;
let space = space.unwrap_or(flow.settings.space);
let resolved = plan(&flow, &session, space)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&as_json(&flow, &resolved))?
);
return Ok(0);
}
print_human(&flow, &resolved, &session_path);
Ok(0)
}
fn print_human(flow: &Flow, resolved: &Plan, session_path: &std::path::Path) {
println!("session: {}", session_path.display());
println!(
"settings: relocate={} verify={:?} space={:?} settle={}ms",
flow.settings.relocate, flow.settings.verify, flow.settings.space, flow.settings.settle_ms
);
println!("steps: {}", resolved.steps.len());
println!();
for step in &resolved.steps {
println!(" {:>2}. {}", step.index + 1, step.summary);
for point in &step.points {
println!(
" → ({:.0}, {:.0}) {:?} on monitor {} (scale {})",
point.x, point.y, point.space, point.monitor, point.scale
);
}
}
println!();
println!("nothing was executed — this build resolves only");
}
fn as_json(flow: &Flow, resolved: &Plan) -> serde_json::Value {
serde_json::json!({
"schema": 1,
"session": flow.session,
"settings": {
"relocate": flow.settings.relocate,
"space": flow.settings.space,
"settle_ms": flow.settings.settle_ms,
},
"steps": resolved.steps.iter().map(|step| serde_json::json!({
"index": step.index,
"summary": step.summary,
"points": step.points,
})).collect::<Vec<_>>(),
"executed": false,
})
}
fn expand_home(path: &str) -> std::path::PathBuf {
let Some(rest) = path.strip_prefix("~/") else {
return std::path::PathBuf::from(path);
};
let Some(home) = std::env::var_os("HOME") else {
return std::path::PathBuf::from(path);
};
std::path::PathBuf::from(home).join(rest)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn home_expansion_handles_the_common_shapes() {
unsafe { std::env::set_var("HOME", "/home/tester") };
assert_eq!(
expand_home("~/captures/x"),
std::path::PathBuf::from("/home/tester").join("captures/x")
);
assert_eq!(
expand_home("/absolute/path").to_str(),
Some("/absolute/path")
);
assert_eq!(expand_home("relative/path").to_str(), Some("relative/path"));
assert_eq!(expand_home("~").to_str(), Some("~"));
}
}