1#![cfg(target_os = "linux")]
28#![allow(unsafe_op_in_unsafe_fn)]
31
32pub mod atspi;
33pub mod portal;
34pub mod runtime;
35pub mod x11;
36
37use std::time::Duration;
38
39use crate::doctor::{self, DoctorInput, DoctorOptions};
40use crate::element::{ElementError, ElementOutcome, ElementRequest};
41use crate::error::ComputerError;
42use crate::target::{AppInfo, DoctorReport, LaunchResult, QuitResult, WindowList};
43use crate::tier::{InputJob, InputReport, TierName};
44use crate::tree::TreeQuery;
45use crate::types::FrontmostInfo;
46
47use super::command::{run_timeout, stderr_snippet};
48use super::unsupported;
49use x11rb::connection::Connection as X11Connection;
50
51#[derive(Debug, Default, Clone, Copy)]
53pub struct LinuxDesktopBackend;
54
55pub(crate) fn frontmost() -> FrontmostInfo {
56 match run_timeout(
57 "xdotool",
58 &["getactivewindow", "getwindowname"],
59 Duration::from_secs(2),
60 ) {
61 Ok(output) if output.status.success() => {
62 let window = String::from_utf8_lossy(&output.stdout).trim().to_string();
63 let app = window_class();
64 FrontmostInfo {
65 app,
66 window: if window.is_empty() { None } else { Some(window) },
67 note: None,
68 }
69 }
70 Ok(output) => FrontmostInfo {
71 note: Some(format!(
72 "xdotool failed: {}",
73 stderr_snippet(&output.stderr)
74 )),
75 ..FrontmostInfo::default()
76 },
77 Err(error) => FrontmostInfo {
78 note: Some(format!(
79 "frontmost window unavailable ({error}). On X11, install xdotool; Wayland often hides the active window from this build"
80 )),
81 ..FrontmostInfo::default()
82 },
83 }
84}
85
86fn window_class() -> Option<String> {
87 let output = run_timeout(
88 "xdotool",
89 &["getactivewindow", "getwindowclassname"],
90 Duration::from_secs(2),
91 )
92 .ok()?;
93 if !output.status.success() {
94 return None;
95 }
96 let class = String::from_utf8_lossy(&output.stdout).trim().to_string();
97 if class.is_empty() {
98 None
99 } else {
100 Some(class)
101 }
102}
103
104pub(crate) fn inspect_accessibility(app_name: Option<&str>) -> Result<String, ComputerError> {
105 let query = TreeQuery {
106 app: app_name.map(str::to_owned),
107 window: None,
108 full: true,
109 session: None,
110 };
111 let report = atspi::build_tree(&query)?;
112 Ok(report.full_text)
113}
114
115pub(crate) fn list_apps(_all: bool) -> Result<Vec<AppInfo>, ComputerError> {
116 Err(unsupported("list_apps"))
117}
118
119pub(crate) fn list_windows() -> Result<WindowList, ComputerError> {
120 Err(unsupported("list_windows"))
121}
122
123pub(crate) fn launch(_app: &str) -> Result<LaunchResult, ComputerError> {
124 Err(unsupported("launch"))
125}
126
127pub(crate) fn activate(_app: &str, _window: Option<&str>) -> Result<LaunchResult, ComputerError> {
128 Err(unsupported("activate"))
129}
130
131pub(crate) fn quit(_app: &str, _force: bool) -> Result<QuitResult, ComputerError> {
132 Err(unsupported("quit"))
133}
134
135pub(crate) fn tree(query: &TreeQuery) -> Result<crate::tree::TreeReport, ComputerError> {
136 atspi::build_tree(query)
137}
138
139pub(crate) fn end_session(session: Option<&str>) -> Result<Vec<i32>, ComputerError> {
140 clear_cached_restores(session);
141 Ok(Vec::new())
142}
143
144fn clear_cached_restores(session: Option<&str>) {
145 if let Ok(pending) = crate::cache::pending_restores(session) {
146 let pids: Vec<i32> = pending.into_iter().map(|(pid, _)| pid).collect();
147 if !pids.is_empty() {
148 let _ = crate::cache::clear_restores(session, &pids);
149 }
150 }
151}
152
153pub(crate) fn element_action(request: &ElementRequest) -> Result<ElementOutcome, ElementError> {
164 atspi::element_action(request)
165}
166
167pub(crate) fn doctor(options: DoctorOptions) -> Result<DoctorReport, ComputerError> {
168 let mut notes =
169 vec!["P13b: AT-SPI tree + Linux actions + XTest event tier + XDG portals".to_string()];
170 let display = env_nonempty("DISPLAY");
171 let wayland = env_nonempty("WAYLAND_DISPLAY");
172 let session = doctor::session_kind(display.as_deref(), wayland.as_deref());
173 if session.is_none() {
174 notes.push("no DISPLAY or WAYLAND_DISPLAY".to_string());
175 }
176 let (atspi_bus, atspi_note) = atspi_presence();
177 if let Some(note) = atspi_note {
178 notes.push(note);
179 }
180 if x11::event_capable(options.x11) {
181 notes.push(format!(
182 "XTest event tier reachable on {session:?} (synchronous X11, not per-window)",
183 ));
184 }
185 match event_route() {
186 EventRoute::X11Test => notes.push("event tier: X11 XTest".to_string()),
187 EventRoute::Portal => notes.push(
188 "event tier: XDG RemoteDesktop portal (opens a system dialog the user must click)"
189 .to_string(),
190 ),
191 EventRoute::Unsupported => notes.push(format!(
192 "event tier is unsupported on {session:?} (Wayland): built without the `portal` feature"
193 )),
194 }
195 if portal::compiled() {
196 notes.push(
197 "portal feature compiled: ScreenCast capture and RemoteDesktop input are available; \
198 both open a system dialog the agent cannot click for you"
199 .to_string(),
200 );
201 }
202 let report = doctor::assemble(DoctorInput {
203 path: doctor::current_executable(),
204 session: session.map(str::to_string),
205 atspi_bus,
206 portal: Some(portal::compiled()),
207 notes,
208 prompt_ignored: options.prompt,
209 open_settings_ignored: options.open_settings,
210 ..DoctorInput::default()
211 });
212 Ok(report)
213}
214
215fn env_nonempty(name: &str) -> Option<String> {
216 std::env::var(name)
217 .ok()
218 .filter(|value| !value.trim().is_empty())
219}
220
221fn atspi_presence() -> (Option<bool>, Option<String>) {
222 let bus = env_nonempty("AT_SPI_BUS");
223 let runtime = env_nonempty("XDG_RUNTIME_DIR");
224 if bus.is_none() && runtime.is_none() {
225 return (
226 None,
227 Some(
228 "AT_SPI_BUS and XDG_RUNTIME_DIR are unset; bus was not probed (P13a: install at-spi2-core)"
229 .to_string(),
230 ),
231 );
232 }
233 let present =
234 doctor::atspi_bus_present(bus.as_deref(), runtime.as_deref().map(std::path::Path::new));
235 let note = if present {
236 None
237 } else {
238 Some("AT-SPI bus address was not found; install at-spi2-core + at-spi2-atk".to_string())
239 };
240 (Some(present), note)
241}
242
243#[allow(dead_code)]
246pub(crate) fn x11_event_capable(use_x11_override: bool) -> bool {
247 x11::event_capable(use_x11_override)
248}
249
250#[allow(dead_code)]
253pub(crate) fn x11_tier_name() -> &'static str {
254 x11::TIER_NAME
255}
256
257pub(crate) fn perform_input(job: &InputJob) -> Result<InputReport, ComputerError> {
264 match job.tier {
265 TierName::Event => perform_event_tier(job),
266 TierName::Synthetic => Err(unsupported("tier synthetic")),
270 TierName::Ax => Err(unsupported("tier ax for coordinate and keyboard input")),
271 TierName::Activate => perform_activate_tier(job),
272 }
273}
274
275pub(crate) fn capture_window(
281 target: &crate::backend::WindowTarget,
282) -> Result<crate::types::RawCapture, ComputerError> {
283 portal::capture_window(target)
284}
285
286#[cfg(feature = "portal")]
293fn event_route() -> EventRoute {
294 if portal::wayland_session() {
295 EventRoute::Portal
296 } else {
297 EventRoute::X11Test
298 }
299}
300
301#[cfg(not(feature = "portal"))]
303fn event_route() -> EventRoute {
304 if portal::wayland_session() {
305 EventRoute::Unsupported
306 } else {
307 EventRoute::X11Test
308 }
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313enum EventRoute {
314 X11Test,
316 #[cfg_attr(not(feature = "portal"), allow(dead_code))]
320 Portal,
321 #[cfg_attr(feature = "portal", allow(dead_code))]
325 Unsupported,
326}
327
328fn perform_activate_tier(
333 job: &crate::tier::InputJob,
334) -> Result<crate::tier::InputReport, ComputerError> {
335 use crate::backend::input;
336 use crate::tier::{InputKind, InputReport};
337 if job.require_background {
338 return Err(ComputerError::ExecutionFailed(
339 "--require-background refuses tier activate because it uses the foreground cursor"
340 .into(),
341 ));
342 }
343 match &job.kind {
344 InputKind::Click {
345 x,
346 y,
347 button,
348 count,
349 } => {
350 for index in 0..*count {
351 if index > 0 {
352 std::thread::sleep(std::time::Duration::from_millis(50));
353 }
354 input::click(*x, *y, *button)?;
355 }
356 }
357 InputKind::Move { x, y } => input::move_mouse(*x, *y)?,
358 InputKind::Scroll { x, y, dx, dy } => {
359 if let (Some(x), Some(y)) = (x, y) {
360 input::move_mouse(*x, *y)?;
361 }
362 input::scroll(*dx, *dy)?;
363 }
364 InputKind::Type { text } => input::type_text(text)?,
365 InputKind::Key { strokes } => {
366 for stroke in strokes {
367 input::press_key(&stroke.modifiers, &stroke.key)?;
368 }
369 }
370 InputKind::Drag { points, button } => input::drag(points, *button)?,
371 InputKind::MouseDown { x, y, button } => input::button_down(*x, *y, *button)?,
372 InputKind::MouseUp { x, y, button } => input::button_up(*x, *y, *button)?,
373 InputKind::HoldKey { modifiers, key, ms } => input::hold_key(modifiers, key, *ms)?,
374 }
375 Ok(InputReport {
376 tier: "activate",
377 cursor_moved: true,
378 focus_restored: None,
379 warnings: Vec::new(),
380 scroll: None,
381 })
382}
383
384fn perform_event_tier(job: &InputJob) -> Result<InputReport, ComputerError> {
392 match event_route() {
393 EventRoute::X11Test => x11_event_tier(job),
394 EventRoute::Portal => portal::perform_event_tier(job),
395 EventRoute::Unsupported => Err(unsupported(
396 "tier event on Wayland needs the `portal` feature; this build does not have it",
397 )),
398 }
399}
400
401fn x11_event_tier(job: &InputJob) -> Result<InputReport, ComputerError> {
403 use crate::tier::InputKind;
404 let connection = x11::connect()?;
405 let (root, _w, _h) = read_setup(&connection)?;
406 let report = match &job.kind {
407 InputKind::Move { x, y } => {
408 x11::motion_notify(
409 &connection,
410 root,
411 i16::try_from(*x).unwrap_or(i16::MAX),
412 i16::try_from(*y).unwrap_or(i16::MAX),
413 )?;
414 InputReport {
415 tier: x11::TIER_NAME,
416 cursor_moved: true,
417 focus_restored: None,
418 warnings: Vec::new(),
419 scroll: None,
420 }
421 }
422 InputKind::Click {
423 x,
424 y,
425 button,
426 count,
427 } => {
428 x11::motion_notify(
429 &connection,
430 root,
431 i16::try_from(*x).unwrap_or(i16::MAX),
432 i16::try_from(*y).unwrap_or(i16::MAX),
433 )?;
434 for index in 0..*count {
435 if index > 0 {
436 std::thread::sleep(std::time::Duration::from_millis(50));
437 }
438 x11::button_press(
439 &connection,
440 root,
441 map_mouse_button(*button),
442 i16::try_from(*x).unwrap_or(i16::MAX),
443 i16::try_from(*y).unwrap_or(i16::MAX),
444 )?;
445 }
446 InputReport {
447 tier: x11::TIER_NAME,
448 cursor_moved: true,
449 focus_restored: None,
450 warnings: Vec::new(),
451 scroll: None,
452 }
453 }
454 InputKind::Scroll { dx, dy, .. } => {
455 let _ = (dx, dy);
459 Err(ComputerError::Unsupported(
460 "scroll via XTest is not yet supported on Linux; part B adds portals".into(),
461 ))?
462 }
463 InputKind::Key { strokes } => {
464 for stroke in strokes {
465 let keycode = keycode_for_canonical(&stroke.key)?;
466 x11::key_press(&connection, root, keycode)?;
467 }
468 InputReport {
469 tier: x11::TIER_NAME,
470 cursor_moved: false,
471 focus_restored: None,
472 warnings: Vec::new(),
473 scroll: None,
474 }
475 }
476 InputKind::Type { text } => {
477 let _ = text;
480 Err(ComputerError::Unsupported(
481 "type via XTest is not yet supported on Linux; use `tier activate` for now".into(),
482 ))?
483 }
484 InputKind::Drag { .. } | InputKind::MouseDown { .. } | InputKind::MouseUp { .. } => {
485 return Err(ComputerError::Unsupported(
486 "tier event drag/down/up is not implemented for XTest; part B adds portals".into(),
487 ))
488 }
489 InputKind::HoldKey { .. } => {
490 return Err(ComputerError::Unsupported(
491 "tier event hold-key is not implemented for XTest; part B adds portals".into(),
492 ))
493 }
494 };
495 Ok(report)
496}
497
498fn read_setup(
499 connection: &x11rb::rust_connection::RustConnection,
500) -> Result<(x11rb::protocol::xproto::Window, u16, u16), ComputerError> {
501 let setup = connection.setup();
504 let screen = setup
505 .roots
506 .first()
507 .ok_or_else(|| ComputerError::DisplayUnavailable("X server has no screens".into()))?;
508 Ok((screen.root, screen.width_in_pixels, screen.height_in_pixels))
509}
510
511fn map_mouse_button(button: crate::types::MouseButton) -> u8 {
512 match button {
513 crate::types::MouseButton::Left => 1,
514 crate::types::MouseButton::Right => 3,
515 crate::types::MouseButton::Middle => 2,
516 crate::types::MouseButton::Back => 8,
517 crate::types::MouseButton::Forward => 9,
518 }
519}
520
521fn keycode_for_canonical(name: &str) -> Result<u8, ComputerError> {
527 let code = match name.to_ascii_lowercase().as_str() {
528 "return" | "enter" => 36,
529 "escape" | "esc" => 9,
530 "tab" => 23,
531 "space" => 65,
532 "backspace" => 22,
533 "delete" | "del" => 119,
534 "home" => 110,
535 "end" => 115,
536 "pageup" | "page_up" | "pgup" => 112,
537 "pagedown" | "page_down" | "pgdn" => 117,
538 "left" | "arrowleft" => 113,
539 "right" | "arrowright" => 114,
540 "up" | "arrowup" => 111,
541 "down" | "arrowdown" => 116,
542 other => {
543 return Err(ComputerError::Unsupported(format!(
544 "XTest keycode for `{other}` is not in the P13a table; part B adds portals"
545 )))
546 }
547 };
548 Ok(code)
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use crate::backend::DesktopBackend;
555
556 #[test]
557 fn tree_uses_atspi_symbol() {
558 let _tree: fn(&TreeQuery) -> Result<crate::tree::TreeReport, ComputerError> = tree;
563 }
564
565 #[test]
572 fn element_action_routes_to_atspi_on_linux() {
573 use crate::element::{ElementOp, ElementRequest};
574 let _: fn(
577 &ElementRequest,
578 ) -> Result<crate::element::ElementOutcome, crate::element::ElementError> =
579 atspi::element_action;
580 let error = LinuxDesktopBackend
583 .element_action(&ElementRequest {
584 op: ElementOp::Focus,
585 index: 0,
586 require_visible: false,
587 app: None,
588 window: None,
589 session: None,
590 tier: None,
591 allow_foreground: false,
592 require_background: false,
593 no_overlay: true,
594 })
595 .unwrap_err();
596 let code = error.error.code();
597 assert_ne!(code, "unsupported", "got {error:?}");
599 }
600
601 #[test]
602 fn event_route_follows_the_session_and_the_feature() {
603 let route = event_route();
604 if portal::wayland_session() {
605 assert_eq!(
607 route,
608 if cfg!(feature = "portal") {
609 EventRoute::Portal
610 } else {
611 EventRoute::Unsupported
612 }
613 );
614 } else {
615 assert_eq!(route, EventRoute::X11Test);
617 }
618 }
619
620 #[test]
621 fn the_event_route_is_exhaustive_so_a_new_arm_cannot_be_forgotten() {
622 let all = [
623 EventRoute::X11Test,
624 EventRoute::Portal,
625 EventRoute::Unsupported,
626 ];
627 assert_eq!(all.len(), 3);
630 assert_ne!(EventRoute::X11Test, EventRoute::Portal);
631 assert_ne!(EventRoute::Portal, EventRoute::Unsupported);
632 }
633
634 #[test]
635 fn doctor_uses_the_shared_shape() {
636 let report = doctor(DoctorOptions::default()).unwrap();
637 let value = serde_json::to_value(&report).unwrap();
638 assert!(value["permissions_ok"].is_boolean());
639 assert!(value["missing"].is_array());
640 assert!(value["accessibility"].is_null());
641 assert!(value["screen_capture"].is_null());
642 assert!(value["screen_recording"].is_null());
643 assert!(value["post_events"].is_null());
644 assert!(value["input_monitoring"].is_null());
645 assert!(value["input_monitor"].is_null());
646 assert!(value["fix"].is_array());
647 assert!(value["fixes"].is_array());
648 assert!(value["text"].is_string());
649 assert!(value["identity"].is_object());
650 assert!(value["session"].is_string() || value["session"].is_null());
651 assert!(value["atspi_bus"].is_boolean() || value["atspi_bus"].is_null());
652 assert!(value["portal"].is_boolean() || value["portal"].is_null());
654 assert_eq!(value["portal"], serde_json::json!(portal::compiled()));
655 assert!(value["uiaccess"].is_null());
656 assert!(value["integrity"].is_null());
657 let note = value["note"].as_str().unwrap();
658 assert!(note.contains("P13b"), "{note}");
659 assert!(report.permissions_ok);
660 assert!(report.missing.is_empty());
661 }
662
663 #[test]
667 fn atspi_module_wires_async_through_runtime() {
668 let _: fn(&TreeQuery) -> Result<crate::tree::TreeReport, ComputerError> = atspi::build_tree;
672 }
673
674 #[test]
676 fn x11_helpers_link() {
677 let _: fn(bool) -> bool = x11::event_capable;
678 assert_eq!(x11::TIER_NAME, "x11");
679 }
680
681 #[test]
688 fn perform_event_tier_wires_xtest_fake_input() {
689 use crate::tier::{InputJob, InputKind, InputReport, TierName};
690 let job = InputJob {
691 tier: TierName::Event,
692 kind: InputKind::Move { x: 0, y: 0 },
693 app: None,
694 window: None,
695 allow_foreground: false,
696 require_background: false,
697 };
698 let _: fn(&InputJob) -> Result<InputReport, ComputerError> = perform_input;
702 let result = perform_input(&job);
706 match result {
707 Ok(_) => {
708 }
712 Err(error) => {
713 let code = error.code();
714 assert!(
715 code == "display_unavailable" || code == "execution_failed",
716 "event tier must fail with display_unavailable/execution_failed, got: {error:?}"
717 );
718 }
719 }
720 }
721
722 #[test]
727 fn perform_activate_tier_is_enigo_on_linux() {
728 use crate::tier::{InputJob, InputKind, InputReport, TierName};
729 let _: fn(&InputJob) -> Result<InputReport, ComputerError> = perform_activate_tier;
730 let job = InputJob {
731 tier: TierName::Activate,
732 kind: InputKind::Move { x: 0, y: 0 },
733 app: None,
734 window: None,
735 allow_foreground: false,
736 require_background: false,
737 };
738 let result = perform_input(&job);
742 match result {
743 Ok(report) => assert_eq!(report.tier, "activate"),
744 Err(_) => {
745 }
748 }
749 }
750
751 #[test]
758 fn x11_session_detection_compiles() {
759 let _: fn(bool, bool) -> bool = x11::is_x11_session;
760 }
761
762 #[test]
764 fn doctor_options_default_x11_is_false() {
765 let options = DoctorOptions::default();
766 assert!(!options.x11);
767 }
768}