#![cfg(target_os = "linux")]
#![allow(unsafe_op_in_unsafe_fn)]
pub mod atspi;
pub mod portal;
pub mod runtime;
pub mod x11;
use std::time::Duration;
use crate::doctor::{self, DoctorInput, DoctorOptions};
use crate::element::{ElementError, ElementOutcome, ElementRequest};
use crate::error::ComputerError;
use crate::target::{AppInfo, DoctorReport, LaunchResult, QuitResult, WindowList};
use crate::tier::{InputJob, InputReport, TierName};
use crate::tree::TreeQuery;
use crate::types::FrontmostInfo;
use super::command::{run_timeout, stderr_snippet};
use super::unsupported;
use x11rb::connection::Connection as X11Connection;
#[derive(Debug, Default, Clone, Copy)]
pub struct LinuxDesktopBackend;
pub(crate) fn frontmost() -> FrontmostInfo {
match run_timeout(
"xdotool",
&["getactivewindow", "getwindowname"],
Duration::from_secs(2),
) {
Ok(output) if output.status.success() => {
let window = String::from_utf8_lossy(&output.stdout).trim().to_string();
let app = window_class();
FrontmostInfo {
app,
window: if window.is_empty() { None } else { Some(window) },
note: None,
}
}
Ok(output) => FrontmostInfo {
note: Some(format!(
"xdotool failed: {}",
stderr_snippet(&output.stderr)
)),
..FrontmostInfo::default()
},
Err(error) => FrontmostInfo {
note: Some(format!(
"frontmost window unavailable ({error}). On X11, install xdotool; Wayland often hides the active window from this build"
)),
..FrontmostInfo::default()
},
}
}
fn window_class() -> Option<String> {
let output = run_timeout(
"xdotool",
&["getactivewindow", "getwindowclassname"],
Duration::from_secs(2),
)
.ok()?;
if !output.status.success() {
return None;
}
let class = String::from_utf8_lossy(&output.stdout).trim().to_string();
if class.is_empty() {
None
} else {
Some(class)
}
}
pub(crate) fn inspect_accessibility(app_name: Option<&str>) -> Result<String, ComputerError> {
let query = TreeQuery {
app: app_name.map(str::to_owned),
window: None,
full: true,
session: None,
};
let report = atspi::build_tree(&query)?;
Ok(report.full_text)
}
pub(crate) fn list_apps(_all: bool) -> Result<Vec<AppInfo>, ComputerError> {
Err(unsupported("list_apps"))
}
pub(crate) fn list_windows() -> Result<WindowList, ComputerError> {
Err(unsupported("list_windows"))
}
pub(crate) fn launch(_app: &str) -> Result<LaunchResult, ComputerError> {
Err(unsupported("launch"))
}
pub(crate) fn activate(_app: &str, _window: Option<&str>) -> Result<LaunchResult, ComputerError> {
Err(unsupported("activate"))
}
pub(crate) fn quit(_app: &str, _force: bool) -> Result<QuitResult, ComputerError> {
Err(unsupported("quit"))
}
pub(crate) fn tree(query: &TreeQuery) -> Result<crate::tree::TreeReport, ComputerError> {
atspi::build_tree(query)
}
pub(crate) fn end_session(session: Option<&str>) -> Result<Vec<i32>, ComputerError> {
clear_cached_restores(session);
Ok(Vec::new())
}
fn clear_cached_restores(session: Option<&str>) {
if let Ok(pending) = crate::cache::pending_restores(session) {
let pids: Vec<i32> = pending.into_iter().map(|(pid, _)| pid).collect();
if !pids.is_empty() {
let _ = crate::cache::clear_restores(session, &pids);
}
}
}
pub(crate) fn element_action(request: &ElementRequest) -> Result<ElementOutcome, ElementError> {
atspi::element_action(request)
}
pub(crate) fn doctor(options: DoctorOptions) -> Result<DoctorReport, ComputerError> {
let mut notes =
vec!["P13b: AT-SPI tree + Linux actions + XTest event tier + XDG portals".to_string()];
let display = env_nonempty("DISPLAY");
let wayland = env_nonempty("WAYLAND_DISPLAY");
let session = doctor::session_kind(display.as_deref(), wayland.as_deref());
if session.is_none() {
notes.push("no DISPLAY or WAYLAND_DISPLAY".to_string());
}
let (atspi_bus, atspi_note) = atspi_presence();
if let Some(note) = atspi_note {
notes.push(note);
}
if x11::event_capable(options.x11) {
notes.push(format!(
"XTest event tier reachable on {session:?} (synchronous X11, not per-window)",
));
}
match event_route() {
EventRoute::X11Test => notes.push("event tier: X11 XTest".to_string()),
EventRoute::Portal => notes.push(
"event tier: XDG RemoteDesktop portal (opens a system dialog the user must click)"
.to_string(),
),
EventRoute::Unsupported => notes.push(format!(
"event tier is unsupported on {session:?} (Wayland): built without the `portal` feature"
)),
}
if portal::compiled() {
notes.push(
"portal feature compiled: ScreenCast capture and RemoteDesktop input are available; \
both open a system dialog the agent cannot click for you"
.to_string(),
);
}
let report = doctor::assemble(DoctorInput {
path: doctor::current_executable(),
session: session.map(str::to_string),
atspi_bus,
portal: Some(portal::compiled()),
notes,
prompt_ignored: options.prompt,
open_settings_ignored: options.open_settings,
..DoctorInput::default()
});
Ok(report)
}
fn env_nonempty(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.filter(|value| !value.trim().is_empty())
}
fn atspi_presence() -> (Option<bool>, Option<String>) {
let bus = env_nonempty("AT_SPI_BUS");
let runtime = env_nonempty("XDG_RUNTIME_DIR");
if bus.is_none() && runtime.is_none() {
return (
None,
Some(
"AT_SPI_BUS and XDG_RUNTIME_DIR are unset; bus was not probed (P13a: install at-spi2-core)"
.to_string(),
),
);
}
let present =
doctor::atspi_bus_present(bus.as_deref(), runtime.as_deref().map(std::path::Path::new));
let note = if present {
None
} else {
Some("AT-SPI bus address was not found; install at-spi2-core + at-spi2-atk".to_string())
};
(Some(present), note)
}
#[allow(dead_code)]
pub(crate) fn x11_event_capable(use_x11_override: bool) -> bool {
x11::event_capable(use_x11_override)
}
#[allow(dead_code)]
pub(crate) fn x11_tier_name() -> &'static str {
x11::TIER_NAME
}
pub(crate) fn perform_input(job: &InputJob) -> Result<InputReport, ComputerError> {
match job.tier {
TierName::Event => perform_event_tier(job),
TierName::Synthetic => Err(unsupported("tier synthetic")),
TierName::Ax => Err(unsupported("tier ax for coordinate and keyboard input")),
TierName::Activate => perform_activate_tier(job),
}
}
pub(crate) fn capture_window(
target: &crate::backend::WindowTarget,
) -> Result<crate::types::RawCapture, ComputerError> {
portal::capture_window(target)
}
#[cfg(feature = "portal")]
fn event_route() -> EventRoute {
if portal::wayland_session() {
EventRoute::Portal
} else {
EventRoute::X11Test
}
}
#[cfg(not(feature = "portal"))]
fn event_route() -> EventRoute {
if portal::wayland_session() {
EventRoute::Unsupported
} else {
EventRoute::X11Test
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EventRoute {
X11Test,
Portal,
#[cfg_attr(feature = "portal", allow(dead_code))]
Unsupported,
}
fn perform_activate_tier(
job: &crate::tier::InputJob,
) -> Result<crate::tier::InputReport, ComputerError> {
use crate::backend::input;
use crate::tier::{InputKind, InputReport};
if job.require_background {
return Err(ComputerError::ExecutionFailed(
"--require-background refuses tier activate because it uses the foreground cursor"
.into(),
));
}
match &job.kind {
InputKind::Click {
x,
y,
button,
count,
} => {
for index in 0..*count {
if index > 0 {
std::thread::sleep(std::time::Duration::from_millis(50));
}
input::click(*x, *y, *button)?;
}
}
InputKind::Move { x, y } => input::move_mouse(*x, *y)?,
InputKind::Scroll { x, y, dx, dy } => {
if let (Some(x), Some(y)) = (x, y) {
input::move_mouse(*x, *y)?;
}
input::scroll(*dx, *dy)?;
}
InputKind::Type { text } => input::type_text(text)?,
InputKind::Key { strokes } => {
for stroke in strokes {
input::press_key(&stroke.modifiers, &stroke.key)?;
}
}
InputKind::Drag { points, button } => input::drag(points, *button)?,
InputKind::MouseDown { x, y, button } => input::button_down(*x, *y, *button)?,
InputKind::MouseUp { x, y, button } => input::button_up(*x, *y, *button)?,
InputKind::HoldKey { modifiers, key, ms } => input::hold_key(modifiers, key, *ms)?,
}
Ok(InputReport {
tier: "activate",
cursor_moved: true,
focus_restored: None,
warnings: Vec::new(),
scroll: None,
})
}
fn perform_event_tier(job: &InputJob) -> Result<InputReport, ComputerError> {
match event_route() {
EventRoute::X11Test => x11_event_tier(job),
#[cfg(feature = "portal")]
EventRoute::Portal => portal::perform_event_tier(job),
EventRoute::Unsupported => Err(unsupported(
"tier event on Wayland needs the `portal` feature; this build does not have it",
)),
}
}
fn x11_event_tier(job: &InputJob) -> Result<InputReport, ComputerError> {
use crate::tier::InputKind;
let connection = x11::connect()?;
let (root, _w, _h) = read_setup(&connection)?;
let report = match &job.kind {
InputKind::Move { x, y } => {
x11::motion_notify(
&connection,
root,
i16::try_from(*x).unwrap_or(i16::MAX),
i16::try_from(*y).unwrap_or(i16::MAX),
)?;
InputReport {
tier: x11::TIER_NAME,
cursor_moved: true,
focus_restored: None,
warnings: Vec::new(),
scroll: None,
}
}
InputKind::Click {
x,
y,
button,
count,
} => {
x11::motion_notify(
&connection,
root,
i16::try_from(*x).unwrap_or(i16::MAX),
i16::try_from(*y).unwrap_or(i16::MAX),
)?;
for index in 0..*count {
if index > 0 {
std::thread::sleep(std::time::Duration::from_millis(50));
}
x11::button_press(
&connection,
root,
map_mouse_button(*button),
i16::try_from(*x).unwrap_or(i16::MAX),
i16::try_from(*y).unwrap_or(i16::MAX),
)?;
}
InputReport {
tier: x11::TIER_NAME,
cursor_moved: true,
focus_restored: None,
warnings: Vec::new(),
scroll: None,
}
}
InputKind::Scroll { dx, dy, .. } => {
let _ = (dx, dy);
Err(ComputerError::Unsupported(
"scroll via XTest is not yet supported on Linux; part B adds portals".into(),
))?
}
InputKind::Key { strokes } => {
for stroke in strokes {
let keycode = keycode_for_canonical(&stroke.key)?;
x11::key_press(&connection, root, keycode)?;
}
InputReport {
tier: x11::TIER_NAME,
cursor_moved: false,
focus_restored: None,
warnings: Vec::new(),
scroll: None,
}
}
InputKind::Type { text } => {
let _ = text;
Err(ComputerError::Unsupported(
"type via XTest is not yet supported on Linux; use `tier activate` for now".into(),
))?
}
InputKind::Drag { .. } | InputKind::MouseDown { .. } | InputKind::MouseUp { .. } => {
return Err(ComputerError::Unsupported(
"tier event drag/down/up is not implemented for XTest; part B adds portals".into(),
))
}
InputKind::HoldKey { .. } => {
return Err(ComputerError::Unsupported(
"tier event hold-key is not implemented for XTest; part B adds portals".into(),
))
}
};
Ok(report)
}
fn read_setup(
connection: &x11rb::rust_connection::RustConnection,
) -> Result<(x11rb::protocol::xproto::Window, u16, u16), ComputerError> {
let setup = connection.setup();
let screen = setup
.roots
.first()
.ok_or_else(|| ComputerError::DisplayUnavailable("X server has no screens".into()))?;
Ok((screen.root, screen.width_in_pixels, screen.height_in_pixels))
}
fn map_mouse_button(button: crate::types::MouseButton) -> u8 {
match button {
crate::types::MouseButton::Left => 1,
crate::types::MouseButton::Right => 3,
crate::types::MouseButton::Middle => 2,
crate::types::MouseButton::Back => 8,
crate::types::MouseButton::Forward => 9,
}
}
fn keycode_for_canonical(name: &str) -> Result<u8, ComputerError> {
let code = match name.to_ascii_lowercase().as_str() {
"return" | "enter" => 36,
"escape" | "esc" => 9,
"tab" => 23,
"space" => 65,
"backspace" => 22,
"delete" | "del" => 119,
"home" => 110,
"end" => 115,
"pageup" | "page_up" | "pgup" => 112,
"pagedown" | "page_down" | "pgdn" => 117,
"left" | "arrowleft" => 113,
"right" | "arrowright" => 114,
"up" | "arrowup" => 111,
"down" | "arrowdown" => 116,
other => {
return Err(ComputerError::Unsupported(format!(
"XTest keycode for `{other}` is not in the P13a table; part B adds portals"
)))
}
};
Ok(code)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::DesktopBackend;
#[test]
fn tree_uses_atspi_symbol() {
let _tree: fn(&TreeQuery) -> Result<crate::tree::TreeReport, ComputerError> = tree;
}
#[test]
fn element_action_routes_to_atspi_on_linux() {
use crate::element::{ElementOp, ElementRequest};
let _: fn(
&ElementRequest,
) -> Result<crate::element::ElementOutcome, crate::element::ElementError> =
atspi::element_action;
let error = LinuxDesktopBackend
.element_action(&ElementRequest {
op: ElementOp::Focus,
index: 0,
require_visible: false,
app: None,
window: None,
session: None,
tier: None,
allow_foreground: false,
require_background: false,
no_overlay: true,
})
.unwrap_err();
let code = error.error.code();
assert_ne!(code, "unsupported", "got {error:?}");
}
#[test]
fn event_route_follows_the_session_and_the_feature() {
let route = event_route();
if portal::wayland_session() {
assert_eq!(
route,
if cfg!(feature = "portal") {
EventRoute::Portal
} else {
EventRoute::Unsupported
}
);
} else {
assert_eq!(route, EventRoute::X11Test);
}
}
#[test]
fn the_event_route_is_exhaustive_so_a_new_arm_cannot_be_forgotten() {
let all = [
EventRoute::X11Test,
EventRoute::Portal,
EventRoute::Unsupported,
];
assert_eq!(all.len(), 3);
assert_ne!(EventRoute::X11Test, EventRoute::Portal);
assert_ne!(EventRoute::Portal, EventRoute::Unsupported);
}
#[test]
fn doctor_uses_the_shared_shape() {
let report = doctor(DoctorOptions::default()).unwrap();
let value = serde_json::to_value(&report).unwrap();
assert!(value["permissions_ok"].is_boolean());
assert!(value["missing"].is_array());
assert!(value["accessibility"].is_null());
assert!(value["screen_capture"].is_null());
assert!(value["screen_recording"].is_null());
assert!(value["post_events"].is_null());
assert!(value["input_monitoring"].is_null());
assert!(value["input_monitor"].is_null());
assert!(value["fix"].is_array());
assert!(value["fixes"].is_array());
assert!(value["text"].is_string());
assert!(value["identity"].is_object());
assert!(value["session"].is_string() || value["session"].is_null());
assert!(value["atspi_bus"].is_boolean() || value["atspi_bus"].is_null());
assert!(value["portal"].is_boolean() || value["portal"].is_null());
assert_eq!(value["portal"], serde_json::json!(portal::compiled()));
assert!(value["uiaccess"].is_null());
assert!(value["integrity"].is_null());
let note = value["note"].as_str().unwrap();
assert!(note.contains("P13b"), "{note}");
assert!(report.permissions_ok);
assert!(report.missing.is_empty());
}
#[test]
fn atspi_module_wires_async_through_runtime() {
let _: fn(&TreeQuery) -> Result<crate::tree::TreeReport, ComputerError> = atspi::build_tree;
}
#[test]
fn x11_helpers_link() {
let _: fn(bool) -> bool = x11::event_capable;
assert_eq!(x11::TIER_NAME, "x11");
}
#[test]
fn perform_event_tier_wires_xtest_fake_input() {
use crate::tier::{InputJob, InputKind, InputReport, TierName};
let job = InputJob {
tier: TierName::Event,
kind: InputKind::Move { x: 0, y: 0 },
app: None,
window: None,
allow_foreground: false,
require_background: false,
};
let _: fn(&InputJob) -> Result<InputReport, ComputerError> = perform_input;
let result = perform_input(&job);
match result {
Ok(_) => {
}
Err(error) => {
let code = error.code();
assert!(
code == "display_unavailable" || code == "execution_failed",
"event tier must fail with display_unavailable/execution_failed, got: {error:?}"
);
}
}
}
#[test]
fn perform_activate_tier_is_enigo_on_linux() {
use crate::tier::{InputJob, InputKind, InputReport, TierName};
let _: fn(&InputJob) -> Result<InputReport, ComputerError> = perform_activate_tier;
let job = InputJob {
tier: TierName::Activate,
kind: InputKind::Move { x: 0, y: 0 },
app: None,
window: None,
allow_foreground: false,
require_background: false,
};
let result = perform_input(&job);
match result {
Ok(report) => assert_eq!(report.tier, "activate"),
Err(_) => {
}
}
}
#[test]
fn x11_session_detection_compiles() {
let _: fn(bool, bool) -> bool = x11::is_x11_session;
}
#[test]
fn doctor_options_default_x11_is_false() {
let options = DoctorOptions::default();
assert!(!options.x11);
}
}