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 Portal,
318 #[cfg_attr(feature = "portal", allow(dead_code))]
322 Unsupported,
323}
324
325fn perform_activate_tier(
330 job: &crate::tier::InputJob,
331) -> Result<crate::tier::InputReport, ComputerError> {
332 use crate::backend::input;
333 use crate::tier::{InputKind, InputReport};
334 if job.require_background {
335 return Err(ComputerError::ExecutionFailed(
336 "--require-background refuses tier activate because it uses the foreground cursor"
337 .into(),
338 ));
339 }
340 match &job.kind {
341 InputKind::Click {
342 x,
343 y,
344 button,
345 count,
346 } => {
347 for index in 0..*count {
348 if index > 0 {
349 std::thread::sleep(std::time::Duration::from_millis(50));
350 }
351 input::click(*x, *y, *button)?;
352 }
353 }
354 InputKind::Move { x, y } => input::move_mouse(*x, *y)?,
355 InputKind::Scroll { x, y, dx, dy } => {
356 if let (Some(x), Some(y)) = (x, y) {
357 input::move_mouse(*x, *y)?;
358 }
359 input::scroll(*dx, *dy)?;
360 }
361 InputKind::Type { text } => input::type_text(text)?,
362 InputKind::Key { strokes } => {
363 for stroke in strokes {
364 input::press_key(&stroke.modifiers, &stroke.key)?;
365 }
366 }
367 InputKind::Drag { points, button } => input::drag(points, *button)?,
368 InputKind::MouseDown { x, y, button } => input::button_down(*x, *y, *button)?,
369 InputKind::MouseUp { x, y, button } => input::button_up(*x, *y, *button)?,
370 InputKind::HoldKey { modifiers, key, ms } => input::hold_key(modifiers, key, *ms)?,
371 }
372 Ok(InputReport {
373 tier: "activate",
374 cursor_moved: true,
375 focus_restored: None,
376 warnings: Vec::new(),
377 scroll: None,
378 })
379}
380
381fn perform_event_tier(job: &InputJob) -> Result<InputReport, ComputerError> {
389 match event_route() {
390 EventRoute::X11Test => x11_event_tier(job),
391 #[cfg(feature = "portal")]
392 EventRoute::Portal => portal::perform_event_tier(job),
393 EventRoute::Unsupported => Err(unsupported(
394 "tier event on Wayland needs the `portal` feature; this build does not have it",
395 )),
396 }
397}
398
399fn x11_event_tier(job: &InputJob) -> Result<InputReport, ComputerError> {
401 use crate::tier::InputKind;
402 let connection = x11::connect()?;
403 let (root, _w, _h) = read_setup(&connection)?;
404 let report = match &job.kind {
405 InputKind::Move { x, y } => {
406 x11::motion_notify(
407 &connection,
408 root,
409 i16::try_from(*x).unwrap_or(i16::MAX),
410 i16::try_from(*y).unwrap_or(i16::MAX),
411 )?;
412 InputReport {
413 tier: x11::TIER_NAME,
414 cursor_moved: true,
415 focus_restored: None,
416 warnings: Vec::new(),
417 scroll: None,
418 }
419 }
420 InputKind::Click {
421 x,
422 y,
423 button,
424 count,
425 } => {
426 x11::motion_notify(
427 &connection,
428 root,
429 i16::try_from(*x).unwrap_or(i16::MAX),
430 i16::try_from(*y).unwrap_or(i16::MAX),
431 )?;
432 for index in 0..*count {
433 if index > 0 {
434 std::thread::sleep(std::time::Duration::from_millis(50));
435 }
436 x11::button_press(
437 &connection,
438 root,
439 map_mouse_button(*button),
440 i16::try_from(*x).unwrap_or(i16::MAX),
441 i16::try_from(*y).unwrap_or(i16::MAX),
442 )?;
443 }
444 InputReport {
445 tier: x11::TIER_NAME,
446 cursor_moved: true,
447 focus_restored: None,
448 warnings: Vec::new(),
449 scroll: None,
450 }
451 }
452 InputKind::Scroll { dx, dy, .. } => {
453 let _ = (dx, dy);
457 Err(ComputerError::Unsupported(
458 "scroll via XTest is not yet supported on Linux; part B adds portals".into(),
459 ))?
460 }
461 InputKind::Key { strokes } => {
462 for stroke in strokes {
463 let keycode = keycode_for_canonical(&stroke.key)?;
464 x11::key_press(&connection, root, keycode)?;
465 }
466 InputReport {
467 tier: x11::TIER_NAME,
468 cursor_moved: false,
469 focus_restored: None,
470 warnings: Vec::new(),
471 scroll: None,
472 }
473 }
474 InputKind::Type { text } => {
475 let _ = text;
478 Err(ComputerError::Unsupported(
479 "type via XTest is not yet supported on Linux; use `tier activate` for now".into(),
480 ))?
481 }
482 InputKind::Drag { .. } | InputKind::MouseDown { .. } | InputKind::MouseUp { .. } => {
483 return Err(ComputerError::Unsupported(
484 "tier event drag/down/up is not implemented for XTest; part B adds portals".into(),
485 ))
486 }
487 InputKind::HoldKey { .. } => {
488 return Err(ComputerError::Unsupported(
489 "tier event hold-key is not implemented for XTest; part B adds portals".into(),
490 ))
491 }
492 };
493 Ok(report)
494}
495
496fn read_setup(
497 connection: &x11rb::rust_connection::RustConnection,
498) -> Result<(x11rb::protocol::xproto::Window, u16, u16), ComputerError> {
499 let setup = connection.setup();
502 let screen = setup
503 .roots
504 .first()
505 .ok_or_else(|| ComputerError::DisplayUnavailable("X server has no screens".into()))?;
506 Ok((screen.root, screen.width_in_pixels, screen.height_in_pixels))
507}
508
509fn map_mouse_button(button: crate::types::MouseButton) -> u8 {
510 match button {
511 crate::types::MouseButton::Left => 1,
512 crate::types::MouseButton::Right => 3,
513 crate::types::MouseButton::Middle => 2,
514 crate::types::MouseButton::Back => 8,
515 crate::types::MouseButton::Forward => 9,
516 }
517}
518
519fn keycode_for_canonical(name: &str) -> Result<u8, ComputerError> {
525 let code = match name.to_ascii_lowercase().as_str() {
526 "return" | "enter" => 36,
527 "escape" | "esc" => 9,
528 "tab" => 23,
529 "space" => 65,
530 "backspace" => 22,
531 "delete" | "del" => 119,
532 "home" => 110,
533 "end" => 115,
534 "pageup" | "page_up" | "pgup" => 112,
535 "pagedown" | "page_down" | "pgdn" => 117,
536 "left" | "arrowleft" => 113,
537 "right" | "arrowright" => 114,
538 "up" | "arrowup" => 111,
539 "down" | "arrowdown" => 116,
540 other => {
541 return Err(ComputerError::Unsupported(format!(
542 "XTest keycode for `{other}` is not in the P13a table; part B adds portals"
543 )))
544 }
545 };
546 Ok(code)
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use crate::backend::DesktopBackend;
553
554 #[test]
555 fn tree_uses_atspi_symbol() {
556 let _tree: fn(&TreeQuery) -> Result<crate::tree::TreeReport, ComputerError> = tree;
561 }
562
563 #[test]
570 fn element_action_routes_to_atspi_on_linux() {
571 use crate::element::{ElementOp, ElementRequest};
572 let _: fn(
575 &ElementRequest,
576 ) -> Result<crate::element::ElementOutcome, crate::element::ElementError> =
577 atspi::element_action;
578 let error = LinuxDesktopBackend
581 .element_action(&ElementRequest {
582 op: ElementOp::Focus,
583 index: 0,
584 require_visible: false,
585 app: None,
586 window: None,
587 session: None,
588 tier: None,
589 allow_foreground: false,
590 require_background: false,
591 no_overlay: true,
592 })
593 .unwrap_err();
594 let code = error.error.code();
595 assert_ne!(code, "unsupported", "got {error:?}");
597 }
598
599 #[test]
600 fn event_route_follows_the_session_and_the_feature() {
601 let route = event_route();
602 if portal::wayland_session() {
603 assert_eq!(
605 route,
606 if cfg!(feature = "portal") {
607 EventRoute::Portal
608 } else {
609 EventRoute::Unsupported
610 }
611 );
612 } else {
613 assert_eq!(route, EventRoute::X11Test);
615 }
616 }
617
618 #[test]
619 fn the_event_route_is_exhaustive_so_a_new_arm_cannot_be_forgotten() {
620 let all = [
621 EventRoute::X11Test,
622 EventRoute::Portal,
623 EventRoute::Unsupported,
624 ];
625 assert_eq!(all.len(), 3);
628 assert_ne!(EventRoute::X11Test, EventRoute::Portal);
629 assert_ne!(EventRoute::Portal, EventRoute::Unsupported);
630 }
631
632 #[test]
633 fn doctor_uses_the_shared_shape() {
634 let report = doctor(DoctorOptions::default()).unwrap();
635 let value = serde_json::to_value(&report).unwrap();
636 assert!(value["permissions_ok"].is_boolean());
637 assert!(value["missing"].is_array());
638 assert!(value["accessibility"].is_null());
639 assert!(value["screen_capture"].is_null());
640 assert!(value["screen_recording"].is_null());
641 assert!(value["post_events"].is_null());
642 assert!(value["input_monitoring"].is_null());
643 assert!(value["input_monitor"].is_null());
644 assert!(value["fix"].is_array());
645 assert!(value["fixes"].is_array());
646 assert!(value["text"].is_string());
647 assert!(value["identity"].is_object());
648 assert!(value["session"].is_string() || value["session"].is_null());
649 assert!(value["atspi_bus"].is_boolean() || value["atspi_bus"].is_null());
650 assert!(value["portal"].is_boolean() || value["portal"].is_null());
652 assert_eq!(value["portal"], serde_json::json!(portal::compiled()));
653 assert!(value["uiaccess"].is_null());
654 assert!(value["integrity"].is_null());
655 let note = value["note"].as_str().unwrap();
656 assert!(note.contains("P13b"), "{note}");
657 assert!(report.permissions_ok);
658 assert!(report.missing.is_empty());
659 }
660
661 #[test]
665 fn atspi_module_wires_async_through_runtime() {
666 let _: fn(&TreeQuery) -> Result<crate::tree::TreeReport, ComputerError> = atspi::build_tree;
670 }
671
672 #[test]
674 fn x11_helpers_link() {
675 let _: fn(bool) -> bool = x11::event_capable;
676 assert_eq!(x11::TIER_NAME, "x11");
677 }
678
679 #[test]
686 fn perform_event_tier_wires_xtest_fake_input() {
687 use crate::tier::{InputJob, InputKind, InputReport, TierName};
688 let job = InputJob {
689 tier: TierName::Event,
690 kind: InputKind::Move { x: 0, y: 0 },
691 app: None,
692 window: None,
693 allow_foreground: false,
694 require_background: false,
695 };
696 let _: fn(&InputJob) -> Result<InputReport, ComputerError> = perform_input;
700 let result = perform_input(&job);
704 match result {
705 Ok(_) => {
706 }
710 Err(error) => {
711 let code = error.code();
712 assert!(
713 code == "display_unavailable" || code == "execution_failed",
714 "event tier must fail with display_unavailable/execution_failed, got: {error:?}"
715 );
716 }
717 }
718 }
719
720 #[test]
725 fn perform_activate_tier_is_enigo_on_linux() {
726 use crate::tier::{InputJob, InputKind, InputReport, TierName};
727 let _: fn(&InputJob) -> Result<InputReport, ComputerError> = perform_activate_tier;
728 let job = InputJob {
729 tier: TierName::Activate,
730 kind: InputKind::Move { x: 0, y: 0 },
731 app: None,
732 window: None,
733 allow_foreground: false,
734 require_background: false,
735 };
736 let result = perform_input(&job);
740 match result {
741 Ok(report) => assert_eq!(report.tier, "activate"),
742 Err(_) => {
743 }
746 }
747 }
748
749 #[test]
756 fn x11_session_detection_compiles() {
757 let _: fn(bool, bool) -> bool = x11::is_x11_session;
758 }
759
760 #[test]
762 fn doctor_options_default_x11_is_false() {
763 let options = DoctorOptions::default();
764 assert!(!options.x11);
765 }
766}