use anyhow::Result;
use serde::Serialize;
use crate::session::SUPPORTED_SCHEMA;
pub const MIN_PIXELCOORDS: &str = "0.1.2";
fn parts(version: &str) -> Option<(u32, u32, u32)> {
let mut fields = version.trim().split('.');
let major = fields.next()?.parse().ok()?;
let minor = fields.next()?.parse().ok()?;
let patch = fields.next()?.split(['-', '+']).next()?.parse().ok()?;
if fields.next().is_some() {
return None;
}
Some((major, minor, patch))
}
pub fn meets_minimum(found: &str) -> bool {
let (Some(found), Some(needed)) = (parts(found), parts(MIN_PIXELCOORDS)) else {
return false;
};
found >= needed
}
pub fn require_supported_pixelcoords() -> Result<(), String> {
let status = pixelcoords_status();
if !status.found {
return Err(
"pixelcoords is not on PATH — it is what relocates and verifies regions. \
Install it with `cargo install pixelcoords`"
.to_string(),
);
}
let Some(version) = status.version else {
return Err(
"could not read `pixelcoords --version`, so its version cannot be trusted. \
Reinstall with `cargo install pixelcoords`"
.to_string(),
);
};
if !meets_minimum(&version) {
return Err(format!(
"pixelcoords {version} is too old — this build needs {MIN_PIXELCOORDS} or newer. \
Older captures composite the mouse pointer into the image, which makes \
relocation unreliable in a way that looks like flakiness. \
Upgrade with `cargo install pixelcoords`"
));
}
Ok(())
}
#[derive(Debug, Serialize)]
struct Report {
schema: u32,
platform: &'static str,
supported_platform: bool,
native_space: pixelactions_core::convert::Space,
session_schema_supported: u32,
pixelcoords: PixelcoordsStatus,
capabilities: Capabilities,
accessibility_trusted: Option<bool>,
probe: Probe,
}
#[derive(Debug, Serialize)]
struct PixelcoordsStatus {
found: bool,
version: Option<String>,
minimum: &'static str,
}
#[derive(Debug, Serialize)]
struct Capabilities {
resolve: bool,
inject: bool,
verify: bool,
}
#[derive(Debug, Serialize)]
struct Probe {
attempted: bool,
moved: bool,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
}
fn pixelcoords_status() -> PixelcoordsStatus {
let output = std::process::Command::new("pixelcoords")
.arg("--version")
.output();
let Ok(output) = output else {
return PixelcoordsStatus {
found: false,
version: None,
minimum: MIN_PIXELCOORDS,
};
};
let text = String::from_utf8_lossy(&output.stdout);
let version = text.split_whitespace().nth(1).map(str::to_string);
PixelcoordsStatus {
found: true,
version,
minimum: MIN_PIXELCOORDS,
}
}
pub fn run(json: bool, probe: bool) -> Result<i32> {
let probe_result = run_probe(probe);
let report = Report {
schema: 1,
platform: std::env::consts::OS,
supported_platform: cfg!(target_os = "macos"),
native_space: pixelactions_core::convert::native_space(),
session_schema_supported: SUPPORTED_SCHEMA,
pixelcoords: pixelcoords_status(),
capabilities: Capabilities {
resolve: true,
inject: cfg!(target_os = "macos"),
verify: true,
},
accessibility_trusted: trusted(),
probe: probe_result,
};
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
return Ok(0);
}
println!("platform: {}", report.platform);
println!(
"supported: {}",
if report.supported_platform {
"yes"
} else {
"not yet — macOS only in this build"
}
);
println!("native space: {:?}", report.native_space);
println!(
"session schema: {} and older",
report.session_schema_supported
);
match (&report.pixelcoords.found, &report.pixelcoords.version) {
(true, Some(version)) => {
let verdict = if meets_minimum(version) {
"ok"
} else {
"TOO OLD"
};
println!("pixelcoords: {version} (minimum {MIN_PIXELCOORDS}) — {verdict}");
}
(true, None) => println!("pixelcoords: found, version unreadable"),
(false, _) => println!("pixelcoords: not on PATH — needed to relocate and verify"),
}
println!();
println!("capabilities:");
println!(" resolve a plan yes");
println!(
" inject input {}",
if report.capabilities.inject {
"yes — needs macOS Accessibility permission"
} else {
"no — macOS only in this build"
}
);
println!(" verify a step yes — via pixelcoords find");
if report.probe.attempted {
println!();
match (&report.probe.moved, &report.probe.detail) {
(true, _) => println!("probe: the cursor moved — input permission is real"),
(false, Some(detail)) => println!("probe: FAILED\n {detail}"),
(false, None) => println!("probe: failed, no detail"),
}
}
if report.probe.attempted && !report.probe.moved {
return Ok(3);
}
Ok(0)
}
fn trusted() -> Option<bool> {
#[cfg(target_os = "macos")]
{
Some(crate::mac::is_trusted())
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
#[cfg(target_os = "macos")]
fn run_probe(requested: bool) -> Probe {
if !requested {
return Probe {
attempted: false,
moved: false,
detail: None,
};
}
if !crate::mac::is_trusted() {
crate::mac::request_trust();
return Probe {
attempted: true,
moved: false,
detail: Some(
"Accessibility is not granted to the application running pixelactions, \
so synthetic events would be discarded silently. A system dialog was \
just requested — approve it, or add the app under System Settings > \
Privacy & Security > Accessibility, then quit and reopen it and run \
this again. The grant attaches to the app you launched from (your \
terminal), not to the pixelactions binary."
.to_string(),
),
};
}
let outcome = crate::inject::RealInjector::new().and_then(|mut injector| {
use crate::inject::Injector;
injector.probe()
});
match outcome {
Ok(()) => Probe {
attempted: true,
moved: true,
detail: None,
},
Err(error) => Probe {
attempted: true,
moved: false,
detail: Some(format!("{error:#}")),
},
}
}
#[cfg(not(target_os = "macos"))]
fn run_probe(requested: bool) -> Probe {
Probe {
attempted: requested,
moved: false,
detail: requested.then(|| "input synthesis is macOS-only in this build".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_declared_minimum_is_itself_readable() {
assert!(parts(MIN_PIXELCOORDS).is_some(), "{MIN_PIXELCOORDS}");
}
#[test]
fn newer_and_equal_versions_are_accepted() {
assert!(meets_minimum(MIN_PIXELCOORDS));
assert!(meets_minimum("0.1.3"));
assert!(meets_minimum("0.2.0"));
assert!(meets_minimum("1.0.0"));
assert!(meets_minimum("0.1.2-rc1"));
}
#[test]
fn older_versions_are_refused() {
assert!(!meets_minimum("0.1.1"));
assert!(!meets_minimum("0.1.0"));
assert!(!meets_minimum("0.0.9"));
}
#[test]
fn an_unreadable_version_is_refused_rather_than_assumed_good() {
for bad in ["", "0.1", "0.1.2.3", "banana", "v0.1.2", "0.x.2"] {
assert!(!meets_minimum(bad), "should refuse {bad:?}");
}
}
}