1use crate::windowing::registry::{
2 self, COSMIC_WAYLAND_BACKEND, GNOME_SHELL_EXTENSION_BACKEND, GNOME_SHELL_INTROSPECT_BACKEND,
3 HYPRLAND_BACKEND, I3_BACKEND, KWIN_BACKEND, X11_BACKEND,
4};
5use crate::ydotool;
6use schemars::JsonSchema;
7use serde::Serialize;
8use std::{
9 collections::{BTreeMap, HashMap},
10 env, fs,
11 fs::OpenOptions,
12 os::unix::{fs::MetadataExt, net::UnixDatagram},
13 path::{Path, PathBuf},
14 process::Command,
15};
16
17const DESKTOP_ENV_KEYS: &[&str] = &[
18 "DBUS_SESSION_BUS_ADDRESS",
19 "DESKTOP_SESSION",
20 "DISPLAY",
21 "HYPRLAND_INSTANCE_SIGNATURE",
22 "XAUTHORITY",
23 "YDOTOOL_SOCKET",
24 "XDG_SESSION_DESKTOP",
25 "WAYLAND_DISPLAY",
26 "XDG_CURRENT_DESKTOP",
27 "XDG_RUNTIME_DIR",
28 "XDG_SESSION_TYPE",
29];
30const FORCE_YDOTOOL_KEYBOARD_ENV_KEYS: &[&str] = &["COMPUTER_USE_LINUX_FORCE_YDOTOOL_KEYBOARD"];
31const FORCE_YDOTOOL_POINTER_ENV_KEYS: &[&str] = &["COMPUTER_USE_LINUX_FORCE_YDOTOOL_POINTER"];
32const FORCE_XDOTOOL_KEYBOARD_ENV_KEYS: &[&str] = &["COMPUTER_USE_LINUX_FORCE_XDOTOOL_KEYBOARD"];
33const FORCE_PORTAL_KEYBOARD_ENV_KEYS: &[&str] = &["COMPUTER_USE_LINUX_FORCE_PORTAL_KEYBOARD"];
34const FORCE_PORTAL_POINTER_ENV_KEYS: &[&str] = &["COMPUTER_USE_LINUX_FORCE_PORTAL_POINTER"];
35const PORTAL_DEVICE_KEYBOARD: u32 = 1;
36const PORTAL_DEVICE_POINTER: u32 = 2;
37const PORTAL_SOURCE_MONITOR: u32 = 1;
38const REMOTE_DESKTOP_KEYBOARD_METHODS: &[&str] = &[
39 "CreateSession",
40 "SelectDevices",
41 "Start",
42 "NotifyKeyboardKeycode",
43 "NotifyKeyboardKeysym",
44];
45const REMOTE_DESKTOP_POINTER_METHODS: &[&str] = &[
46 "CreateSession",
47 "SelectDevices",
48 "Start",
49 "NotifyPointerMotionAbsolute",
50 "NotifyPointerButton",
51 "NotifyPointerAxisDiscrete",
52];
53const SCREENCAST_POINTER_METHODS: &[&str] = &["SelectSources"];
54const SCREENSHOT_PORTAL_METHODS: &[&str] = &["Screenshot"];
58const SCREENCAST_PORTAL_METHODS: &[&str] = &["CreateSession", "SelectSources", "Start"];
59const INPUT_CAPTURE_PORTAL_METHODS: &[&str] = &["GetZones", "Enable", "ConnectToEIS"];
60
61#[derive(Debug, Clone, Serialize, JsonSchema)]
62pub struct DoctorReport {
63 pub platform: PlatformReport,
64 pub portals: PortalReport,
65 pub accessibility: AccessibilityReport,
66 pub windowing: WindowingReport,
67 pub input: InputReport,
68 pub readiness: ReadinessReport,
69 pub capabilities: CapabilityMap,
73}
74
75#[derive(Debug, Clone, Serialize, JsonSchema)]
76pub struct CapabilityMap {
77 pub input: Vec<String>,
79 pub screenshot: Vec<String>,
81 pub window_control: Vec<String>,
83 pub accessibility: Vec<String>,
85 pub isolation: Vec<String>,
87 pub preferred: PreferredBackends,
89}
90
91#[derive(Debug, Clone, Serialize, JsonSchema)]
92pub struct PreferredBackends {
93 pub input: Option<String>,
94 pub screenshot: Option<String>,
95 pub window_control: Option<String>,
96}
97
98#[derive(Debug, Clone, Serialize, JsonSchema)]
99pub struct PlatformReport {
100 pub os: String,
101 pub arch: String,
102 pub desktop_session: Option<String>,
103 pub xdg_session_type: Option<String>,
104 pub xdg_current_desktop: Option<String>,
105 pub wayland_display: Option<String>,
106 pub display: Option<String>,
107 pub xauthority: Option<String>,
108 pub dbus_session_bus_address: Option<String>,
109 pub xdg_runtime_dir: Option<String>,
110 pub gnome_shell_version: Check,
111 pub gnome_screenshot: Check,
112 pub x11_display: Check,
115}
116
117#[derive(Debug, Clone, Serialize, JsonSchema)]
118pub struct PortalReport {
119 pub desktop_portal: Check,
120 pub remote_desktop: Check,
121 pub screencast: Check,
122 pub screenshot: Check,
123 pub input_capture: Check,
124 pub mutter_remote_desktop: Check,
125 pub mutter_screencast: Check,
126}
127
128#[derive(Debug, Clone, Serialize, JsonSchema)]
129pub struct AccessibilityReport {
130 pub at_spi_bus: Check,
131 pub toolkit_accessibility: Check,
132 pub at_spi_enabled: Check,
133 pub screen_reader_enabled: Check,
134}
135
136#[derive(Debug, Clone, Serialize, JsonSchema)]
137pub struct WindowingReport {
138 pub gnome_shell_introspect: Check,
139 pub computer_use_linux_gnome_shell_extension: Check,
140 pub cosmic_helper: Check,
141 pub kwin: Check,
142 pub hyprland: Check,
143 pub backends: BTreeMap<String, Check>,
144 pub can_list_windows: bool,
145 pub can_focus_apps: bool,
146 pub can_focus_windows: bool,
147 pub note: String,
148}
149
150#[derive(Debug, Clone, Serialize, JsonSchema)]
151pub struct InputReport {
152 pub ydotool: Check,
153 pub ydotoold: Check,
154 pub ydotool_socket: Check,
155 pub uinput: Check,
156 pub xdotool: Check,
159 pub wtype: Check,
161}
162
163#[derive(Debug, Clone, Serialize, JsonSchema)]
164pub struct ReadinessReport {
165 pub can_register_mcp_tools: bool,
166 pub can_build_accessibility_tree: bool,
167 pub can_query_windows: bool,
168 pub can_focus_apps: bool,
169 pub can_focus_windows: bool,
170 pub can_send_development_input: bool,
171 pub can_capture_screenshots: bool,
175 pub recommended_next_step: String,
176 pub blockers: Vec<String>,
177}
178
179#[derive(Debug, Clone, Serialize, JsonSchema)]
180pub struct SetupReport {
181 pub before: DoctorReport,
182 pub accessibility_command: Check,
183 pub after: DoctorReport,
184 pub changed_accessibility: bool,
185 pub requires_target_app_restart: bool,
186 pub message: String,
187}
188
189#[derive(Debug, Clone, Serialize, JsonSchema)]
190pub struct Check {
191 pub ok: bool,
192 pub detail: String,
193}
194
195impl Check {
196 fn ok(detail: impl Into<String>) -> Self {
197 Self {
198 ok: true,
199 detail: detail.into(),
200 }
201 }
202
203 fn fail(detail: impl Into<String>) -> Self {
204 Self {
205 ok: false,
206 detail: detail.into(),
207 }
208 }
209}
210
211pub fn doctor_report() -> DoctorReport {
212 hydrate_session_bus_env();
213
214 let platform = platform_report();
215 let (portals, remote_desktop_keyboard) = portal_report();
216 let accessibility = accessibility_report();
217 let windowing = windowing_report(&platform);
218 let input = input_report();
219 let readiness = readiness_report_with_portal_keyboard(
220 &platform,
221 &remote_desktop_keyboard,
222 &accessibility,
223 &windowing,
224 &input,
225 !screenshot_backends(&platform, &portals).is_empty(),
226 );
227
228 let capabilities = capability_map_with_portal_keyboard(
229 &platform,
230 &portals,
231 &remote_desktop_keyboard,
232 &accessibility,
233 &windowing,
234 &input,
235 );
236
237 DoctorReport {
238 platform,
239 portals,
240 accessibility,
241 windowing,
242 input,
243 readiness,
244 capabilities,
245 }
246}
247
248#[cfg(test)]
251fn capability_map(
252 platform: &PlatformReport,
253 portals: &PortalReport,
254 accessibility: &AccessibilityReport,
255 windowing: &WindowingReport,
256 input: &InputReport,
257) -> CapabilityMap {
258 capability_map_with_portal_keyboard(
259 platform,
260 portals,
261 &portals.remote_desktop,
262 accessibility,
263 windowing,
264 input,
265 )
266}
267
268fn capability_map_with_portal_keyboard(
269 platform: &PlatformReport,
270 portals: &PortalReport,
271 remote_desktop_keyboard: &Check,
272 accessibility: &AccessibilityReport,
273 windowing: &WindowingReport,
274 input: &InputReport,
275) -> CapabilityMap {
276 let mut input_backends = Vec::new();
277 if input.uinput.ok {
279 input_backends.push("abs_pointer".to_string());
280 }
281 let force_ydotool = env_flag_enabled_any(FORCE_YDOTOOL_KEYBOARD_ENV_KEYS);
282 let force_xdotool = env_flag_enabled_any(FORCE_XDOTOOL_KEYBOARD_ENV_KEYS);
283 let portal_pointer_available = portal_pointer_input_available(platform, portals);
284 let portal_keyboard_available =
285 portal_keyboard_input_available(platform, remote_desktop_keyboard);
286 let portal_available = portal_pointer_available || portal_keyboard_available;
287 let portal_forced_for_all_input = portal_pointer_available
288 && portal_keyboard_available
289 && force_portal_for_all_input(
290 env_flag_enabled_any(FORCE_PORTAL_POINTER_ENV_KEYS),
291 env_flag_enabled_any(FORCE_PORTAL_KEYBOARD_ENV_KEYS),
292 env_flag_enabled_any(FORCE_YDOTOOL_POINTER_ENV_KEYS),
293 force_ydotool,
294 );
295 if should_advertise_xdotool(platform, input, force_ydotool, force_xdotool) {
296 input_backends.push("xdotool".to_string());
297 }
298 if platform_is_wayland(platform)
299 && wtype_compatible_wayland_desktop(platform.xdg_current_desktop.as_deref())
300 && input.wtype.ok
301 && !force_ydotool
302 {
303 input_backends.push("wtype".to_string());
304 }
305 if portal_available && portal_forced_for_all_input {
306 input_backends.push("portal".to_string());
307 }
308 if input.ydotool.ok && input.ydotool_socket.ok {
309 input_backends.push("ydotool".to_string());
310 }
311 if portal_available && !portal_forced_for_all_input {
312 input_backends.push("portal".to_string());
313 }
314
315 let screenshot_backends = screenshot_backends(platform, portals);
316
317 let mut window_backends = Vec::new();
318 let x11_available = windowing
319 .backends
320 .get(X11_BACKEND)
321 .is_some_and(|check| check.ok);
322 let prefer_x11_over_introspect = windowing.gnome_shell_introspect.ok
323 && x11_available
324 && registry::backend_can_exact_focus(X11_BACKEND);
325 if windowing.computer_use_linux_gnome_shell_extension.ok {
326 window_backends.push("gnome_shell_extension".to_string());
327 }
328 if prefer_x11_over_introspect {
329 window_backends.push(X11_BACKEND.to_string());
330 }
331 if windowing.gnome_shell_introspect.ok {
332 window_backends.push("gnome_introspect".to_string());
333 }
334 if windowing.cosmic_helper.ok {
335 window_backends.push("cosmic".to_string());
336 }
337 if windowing.kwin.ok {
338 window_backends.push("kwin".to_string());
339 }
340 if windowing.hyprland.ok {
341 window_backends.push("hyprland".to_string());
342 }
343 if windowing
347 .backends
348 .get(I3_BACKEND)
349 .is_some_and(|check| check.ok)
350 {
351 window_backends.push(I3_BACKEND.to_string());
352 }
353 if x11_available && !prefer_x11_over_introspect {
354 window_backends.push(X11_BACKEND.to_string());
355 }
356
357 let mut accessibility_backends = Vec::new();
358 if can_build_accessibility_tree(accessibility) {
359 accessibility_backends.push("at_spi".to_string());
360 }
361
362 let mut isolation = vec!["shared".to_string()];
366 if platform.gnome_shell_version.ok {
367 isolation.push("headless_gnome".to_string());
368 }
369
370 let preferred = PreferredBackends {
371 input: input_backends.first().cloned(),
372 screenshot: screenshot_backends.first().cloned(),
373 window_control: window_backends.first().cloned(),
374 };
375
376 CapabilityMap {
377 input: input_backends,
378 screenshot: screenshot_backends,
379 window_control: window_backends,
380 accessibility: accessibility_backends,
381 isolation,
382 preferred,
383 }
384}
385
386pub fn hydrate_session_bus_env() {
387 hydrate_common_command_path();
388 hydrate_desktop_env_from_process_tree();
389 hydrate_desktop_env_from_systemd_user();
390
391 if env_var("XDG_RUNTIME_DIR").is_none() {
392 if let Some(runtime) = xdg_runtime_dir() {
393 if runtime.exists() {
394 env::set_var("XDG_RUNTIME_DIR", runtime);
395 }
396 }
397 }
398
399 if env_var("DBUS_SESSION_BUS_ADDRESS").is_none() {
400 if let Some(runtime) = xdg_runtime_dir() {
401 let bus = runtime.join("bus");
402 if bus.exists() {
403 env::set_var(
404 "DBUS_SESSION_BUS_ADDRESS",
405 format!("unix:path={}", bus.display()),
406 );
407 }
408 }
409 }
410}
411
412fn hydrate_common_command_path() {
413 let mut entries = env::var_os("PATH")
414 .map(|path| env::split_paths(&path).collect::<Vec<_>>())
415 .unwrap_or_default();
416 for path in [
417 "/run/current-system/sw/bin",
418 "/usr/local/bin",
419 "/usr/bin",
420 "/bin",
421 ] {
422 let path = PathBuf::from(path);
423 if path.exists() && !entries.iter().any(|entry| entry == &path) {
424 entries.push(path);
425 }
426 }
427 if let Ok(path) = env::join_paths(entries) {
428 env::set_var("PATH", path);
429 }
430}
431
432fn hydrate_desktop_env_from_process_tree() {
433 for process_env in desktop_process_environments() {
434 hydrate_desktop_env_from_map(&process_env);
435
436 if DESKTOP_ENV_KEYS.iter().all(|key| env_var(key).is_some()) {
437 break;
438 }
439 }
440}
441
442fn hydrate_desktop_env_from_systemd_user() {
443 let Ok(output) = Command::new("systemctl")
444 .args(["--user", "show-environment"])
445 .output()
446 else {
447 return;
448 };
449 if !output.status.success() {
450 return;
451 }
452 let env_map = parse_line_environment(&output.stdout);
453 hydrate_desktop_env_from_map(&env_map);
454}
455
456fn hydrate_desktop_env_from_map(process_env: &HashMap<String, String>) {
457 let current_env = DESKTOP_ENV_KEYS
458 .iter()
459 .filter_map(|key| env_var(key).map(|value| ((*key).to_string(), value)))
460 .collect();
461 for (key, value) in desktop_env_hydration_updates(¤t_env, process_env) {
462 env::set_var(key, value);
463 }
464}
465
466fn desktop_env_hydration_updates(
467 current_env: &HashMap<String, String>,
468 source_env: &HashMap<String, String>,
469) -> Vec<(&'static str, String)> {
470 let preserve_native_x11 = current_env
474 .get("XDG_SESSION_TYPE")
475 .is_some_and(|value| value.trim().eq_ignore_ascii_case("x11"))
476 && current_env
477 .get("DISPLAY")
478 .is_some_and(|value| !value.trim().is_empty())
479 && current_env
480 .get("WAYLAND_DISPLAY")
481 .is_none_or(|value| value.trim().is_empty());
482
483 DESKTOP_ENV_KEYS
484 .iter()
485 .filter_map(|key| {
486 if current_env
487 .get(*key)
488 .is_some_and(|value| !value.trim().is_empty())
489 || preserve_native_x11 && *key == "WAYLAND_DISPLAY"
490 {
491 return None;
492 }
493 source_env
494 .get(*key)
495 .filter(|value| !value.trim().is_empty())
496 .map(|value| (*key, value.clone()))
497 })
498 .collect()
499}
500
501fn desktop_process_environments() -> Vec<HashMap<String, String>> {
502 let mut environments = Vec::new();
503 let mut visited_pids = Vec::new();
504 let mut pid = parent_pid("self");
505
506 for _ in 0..8 {
507 let Some(current_pid) = pid else {
508 break;
509 };
510 if current_pid <= 1 {
511 break;
512 }
513
514 visited_pids.push(current_pid);
515 if let Some(process_env) = read_process_environ(current_pid) {
516 environments.push(process_env);
517 }
518 pid = parent_pid(¤t_pid.to_string());
519 }
520
521 if !visited_pids.contains(&1) && process_owner_matches_current_user(1) {
522 if let Some(process_env) = read_process_environ(1).filter(process_env_has_graphical_display)
523 {
524 environments.push(process_env);
525 }
526 }
527
528 environments
529}
530
531fn parent_pid(pid: &str) -> Option<u32> {
532 let status = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
533 parse_parent_pid(&status)
534}
535
536fn parse_parent_pid(status: &str) -> Option<u32> {
537 status.lines().find_map(|line| {
538 let value = line.strip_prefix("PPid:")?.trim();
539 value.parse::<u32>().ok()
540 })
541}
542
543fn read_process_environ(pid: u32) -> Option<HashMap<String, String>> {
544 let bytes = fs::read(format!("/proc/{pid}/environ")).ok()?;
545 Some(parse_environ(&bytes))
546}
547
548fn process_owner_matches_current_user(pid: u32) -> bool {
549 let Some(current_uid) = user_id().and_then(|uid| uid.parse::<u32>().ok()) else {
550 return false;
551 };
552 fs::metadata(format!("/proc/{pid}"))
553 .ok()
554 .is_some_and(|metadata| metadata.uid() == current_uid)
555}
556
557fn process_env_has_graphical_display(process_env: &HashMap<String, String>) -> bool {
558 process_env
559 .get("DISPLAY")
560 .or_else(|| process_env.get("WAYLAND_DISPLAY"))
561 .is_some_and(|value| !value.trim().is_empty())
562}
563
564fn parse_environ(bytes: &[u8]) -> HashMap<String, String> {
565 bytes
566 .split(|byte| *byte == 0)
567 .filter_map(|entry| {
568 if entry.is_empty() {
569 return None;
570 }
571 let split = entry.iter().position(|byte| *byte == b'=')?;
572 let (key, value) = entry.split_at(split);
573 let value = &value[1..];
574 let key = std::str::from_utf8(key).ok()?.to_string();
575 let value = std::str::from_utf8(value).ok()?.to_string();
576 Some((key, value))
577 })
578 .collect()
579}
580
581fn parse_line_environment(bytes: &[u8]) -> HashMap<String, String> {
582 bytes
583 .split(|byte| *byte == b'\n')
584 .filter_map(|entry| {
585 if entry.is_empty() {
586 return None;
587 }
588 let split = entry.iter().position(|byte| *byte == b'=')?;
589 let (key, value) = entry.split_at(split);
590 let value = &value[1..];
591 let key = std::str::from_utf8(key).ok()?.to_string();
592 let value = std::str::from_utf8(value).ok()?.to_string();
593 Some((key, value))
594 })
595 .collect()
596}
597
598pub fn setup_accessibility_report() -> SetupReport {
599 hydrate_session_bus_env();
600
601 let before = doctor_report();
602 let accessibility_command =
603 enable_accessibility(&before.accessibility, command_check_with_session_bus);
604 let after = doctor_report();
605 let before_ready = before.readiness.can_build_accessibility_tree;
606 let after_ready = after.readiness.can_build_accessibility_tree;
607 let saved_before = check_detail_contains_true(&before.accessibility.toolkit_accessibility);
608 let saved_after = check_detail_contains_true(&after.accessibility.toolkit_accessibility);
609 let changed_accessibility = after_ready && saved_after && (!before_ready || !saved_before);
610 let requires_target_app_restart = changed_accessibility;
611 let message = if after_ready && !saved_after {
612 "AT-SPI is available at runtime, but toolkit-accessibility could not be verified as enabled in GSettings. Newly launched apps may have no accessibility tree. Check accessibility_command and the saved setting; other accessibility tools can change it."
613 } else if after_ready {
614 if changed_accessibility {
615 "AT-SPI accessibility is enabled. Restart already-running target apps if their AT-SPI tree is still empty."
616 } else {
617 "AT-SPI accessibility is ready."
618 }
619 } else {
620 "Could not enable AT-SPI accessibility automatically. Check the accessibility_command detail and enable org.a11y.Status IsEnabled or org.gnome.desktop.interface toolkit-accessibility manually."
621 }
622 .to_string();
623
624 SetupReport {
625 before,
626 accessibility_command,
627 after,
628 changed_accessibility,
629 requires_target_app_restart,
630 message,
631 }
632}
633
634fn enable_accessibility(
637 before: &AccessibilityReport,
638 mut run: impl FnMut(&str, &[&str]) -> Check,
639) -> Check {
640 let setting = if check_detail_contains_true(&before.toolkit_accessibility) {
641 before.toolkit_accessibility.clone()
642 } else {
643 let write = run(
644 "gsettings",
645 &[
646 "set",
647 "org.gnome.desktop.interface",
648 "toolkit-accessibility",
649 "true",
650 ],
651 );
652 let read = run(
653 "gsettings",
654 &[
655 "get",
656 "org.gnome.desktop.interface",
657 "toolkit-accessibility",
658 ],
659 );
660 if !check_detail_contains_true(&read) {
661 let runtime = run(
662 "busctl",
663 &[
664 "--user",
665 "set-property",
666 "org.a11y.Bus",
667 "/org/a11y/bus",
668 "org.a11y.Status",
669 "IsEnabled",
670 "b",
671 "true",
672 ],
673 );
674 return Check::fail(format!(
675 "Saved toolkit-accessibility was not verified: {}; write: {}; runtime fallback: {}",
676 read.detail, write.detail, runtime.detail
677 ));
678 }
679 read
680 };
681 if !can_build_accessibility_tree(before) {
682 let runtime = run(
683 "busctl",
684 &[
685 "--user",
686 "set-property",
687 "org.a11y.Bus",
688 "/org/a11y/bus",
689 "org.a11y.Status",
690 "IsEnabled",
691 "b",
692 "true",
693 ],
694 );
695 return Check::ok(format!(
696 "Saved toolkit-accessibility verified: {}; runtime request: {}",
697 setting.detail, runtime.detail
698 ));
699 }
700 Check::ok("Saved toolkit-accessibility is enabled; runtime AT-SPI is available")
701}
702
703fn platform_report() -> PlatformReport {
704 PlatformReport {
705 os: std::env::consts::OS.to_string(),
706 arch: std::env::consts::ARCH.to_string(),
707 desktop_session: env_var("DESKTOP_SESSION"),
708 xdg_session_type: env_var("XDG_SESSION_TYPE"),
709 xdg_current_desktop: env_var("XDG_CURRENT_DESKTOP"),
710 wayland_display: env_var("WAYLAND_DISPLAY"),
711 display: env_var("DISPLAY"),
712 xauthority: env_var("XAUTHORITY"),
713 dbus_session_bus_address: dbus_session_address(),
714 xdg_runtime_dir: xdg_runtime_dir().map(|path| path.display().to_string()),
715 gnome_shell_version: command_check("gnome-shell", &["--version"]),
716 gnome_screenshot: command_check("gnome-screenshot", &["--version"]),
717 x11_display: x11_display_check(),
718 }
719}
720
721fn x11_display_check() -> Check {
722 if !crate::x11_display::is_native_x11_session() {
723 return Check::fail("not a native X11 session");
724 }
725 let (sender, receiver) = std::sync::mpsc::channel();
728 std::thread::spawn(move || {
729 let result = crate::x11_display::X11Display::connect().map(|display| display.describe());
730 let _ = sender.send(result);
731 });
732 match receiver.recv_timeout(crate::x11_display::X11_QUERY_TIMEOUT) {
733 Ok(Ok(detail)) => Check::ok(detail),
734 Ok(Err(error)) => Check::fail(format!("{error:#}")),
735 Err(_) => Check::fail("X server did not answer within 2s"),
736 }
737}
738
739fn portal_report() -> (PortalReport, Check) {
740 let (remote_desktop, remote_desktop_keyboard, screencast) = remote_desktop_portal_checks();
741 (
742 PortalReport {
743 desktop_portal: bus_name_check("org.freedesktop.portal.Desktop"),
744 remote_desktop,
745 screencast,
746 screenshot: portal_interface_methods_check(
747 "org.freedesktop.portal.Screenshot",
748 SCREENSHOT_PORTAL_METHODS,
749 ),
750 input_capture: portal_interface_methods_check(
751 "org.freedesktop.portal.InputCapture",
752 INPUT_CAPTURE_PORTAL_METHODS,
753 ),
754 mutter_remote_desktop: bus_name_check("org.gnome.Mutter.RemoteDesktop"),
755 mutter_screencast: bus_name_check("org.gnome.Mutter.ScreenCast"),
756 },
757 remote_desktop_keyboard,
758 )
759}
760
761fn accessibility_report() -> AccessibilityReport {
762 AccessibilityReport {
763 at_spi_bus: atspi_bus_address_check(),
764 toolkit_accessibility: command_check_with_session_bus(
765 "gsettings",
766 &[
767 "get",
768 "org.gnome.desktop.interface",
769 "toolkit-accessibility",
770 ],
771 ),
772 at_spi_enabled: atspi_status_property_check("IsEnabled"),
773 screen_reader_enabled: atspi_status_property_check("ScreenReaderEnabled"),
774 }
775}
776
777fn windowing_report(platform: &PlatformReport) -> WindowingReport {
778 let probes = registry::probe_backends();
779 let backend_check = |id: &str| {
780 probes
781 .iter()
782 .find(|probe| probe.id == id)
783 .map(check_from_backend_probe)
784 .unwrap_or_else(|| Check::fail("backend probe did not run"))
785 };
786 let gnome_shell_introspect = backend_check(GNOME_SHELL_INTROSPECT_BACKEND);
787 let computer_use_linux_gnome_shell_extension = backend_check(GNOME_SHELL_EXTENSION_BACKEND);
788 let cosmic_helper = backend_check(COSMIC_WAYLAND_BACKEND);
789 let kwin = backend_check(KWIN_BACKEND);
790 let hyprland = backend_check(HYPRLAND_BACKEND);
791 let backends = probes
792 .iter()
793 .map(|probe| (probe.id.to_string(), check_from_backend_probe(probe)))
794 .collect::<BTreeMap<_, _>>();
795 let can_list_windows = probes.iter().any(|probe| probe.can_list_windows);
796 let can_focus_apps = probes.iter().any(|probe| probe.can_focus_apps);
797 let can_focus_windows = probes.iter().any(|probe| probe.can_focus_windows);
798 let note = if can_list_windows {
799 if !can_focus_windows {
800 "A window listing backend is available for list_windows, but focused-window and targeted-input verification are unavailable (for example wmctrl is present but xprop is missing on X11)."
801 } else if cosmic_helper.ok && is_cosmic_wayland_platform(platform) {
802 "A COSMIC Wayland window backend is available for list_windows, focused_window, and targeted input verification."
803 } else if kwin.ok {
804 "A KWin/Plasma window backend is available for list_windows, focused_window, and targeted input verification."
805 } else if hyprland.ok {
806 "A Hyprland window backend is available for list_windows, focused_window, and targeted input verification."
807 } else {
808 "A window listing backend is available for list_windows, focused_window, and targeted input verification."
809 }
810 } else {
811 "Window listing is unavailable or denied. Computer Use can still use screenshots, AT-SPI, and global ydotool input, but targeted window input cannot be verified. On GNOME, run setup_window_targeting to install the optional GNOME Shell extension backend. On COSMIC, ensure the bundled COSMIC helper is present and can connect to the session. On KDE/Plasma, ensure KWin exposes org.kde.KWin scripting on the session bus. On Hyprland, ensure hyprctl is available in the session."
812 }
813 .to_string();
814
815 WindowingReport {
816 gnome_shell_introspect,
817 computer_use_linux_gnome_shell_extension,
818 cosmic_helper,
819 kwin,
820 hyprland,
821 backends,
822 can_list_windows,
823 can_focus_apps,
824 can_focus_windows,
825 note,
826 }
827}
828
829fn check_from_backend_probe(probe: ®istry::BackendProbe) -> Check {
830 if probe.ok {
831 Check::ok(probe.detail.clone())
832 } else {
833 Check::fail(probe.detail.clone())
834 }
835}
836
837fn input_report() -> InputReport {
838 InputReport {
839 ydotool: match ydotool::ensure_supported() {
840 Ok(support) => Check::ok(support.detail),
841 Err(detail) => Check::fail(detail),
842 },
843 ydotoold: process_check("ydotoold"),
844 ydotool_socket: ydotool_socket_check(),
845 uinput: read_write_path_check(Path::new("/dev/uinput")),
846 xdotool: command_path_check("xdotool"),
847 wtype: command_path_check("wtype"),
848 }
849}
850
851#[cfg(test)]
852fn readiness_report(
853 platform: &PlatformReport,
854 portals: &PortalReport,
855 accessibility: &AccessibilityReport,
856 windowing: &WindowingReport,
857 input: &InputReport,
858) -> ReadinessReport {
859 readiness_report_with_portal_keyboard(
860 platform,
861 &portals.remote_desktop,
862 accessibility,
863 windowing,
864 input,
865 !screenshot_backends(platform, portals).is_empty(),
866 )
867}
868
869fn screenshot_backends(platform: &PlatformReport, portals: &PortalReport) -> Vec<String> {
872 let mut backends = Vec::new();
873 if platform.gnome_shell_version.ok {
874 backends.push("gnome_shell".to_string());
875 }
876 if portals.screenshot.ok {
877 backends.push("portal".to_string());
878 }
879 if platform.x11_display.ok {
880 backends.push("x11".to_string());
881 }
882 if platform.gnome_screenshot.ok {
884 backends.push("gnome_screenshot".to_string());
885 }
886 backends
887}
888
889fn readiness_report_with_portal_keyboard(
890 platform: &PlatformReport,
891 remote_desktop_keyboard: &Check,
892 accessibility: &AccessibilityReport,
893 windowing: &WindowingReport,
894 input: &InputReport,
895 can_capture_screenshots: bool,
896) -> ReadinessReport {
897 let mut blockers = Vec::new();
898 let can_build_accessibility_tree = can_build_accessibility_tree(accessibility);
899 let can_query_windows = windowing.can_list_windows;
900 let can_focus_apps = windowing.can_focus_apps;
901 let can_focus_windows = windowing.can_focus_windows;
902 let can_send_development_input =
903 can_send_development_input(platform, remote_desktop_keyboard, input);
904
905 if !can_build_accessibility_tree {
906 blockers.push(
907 "AT-SPI accessibility is disabled; enable org.a11y.Status IsEnabled or org.gnome.desktop.interface toolkit-accessibility for tree extraction."
908 .to_string(),
909 );
910 }
911
912 if !can_query_windows {
913 blockers.push(if is_cosmic_wayland_platform(platform) {
914 "COSMIC Wayland window introspection is unavailable; targeted window focus and verification will be disabled.".to_string()
915 } else {
916 "Window introspection is unavailable; targeted window focus and verification will be disabled."
917 .to_string()
918 });
919 }
920
921 if can_query_windows && !can_focus_windows {
922 blockers.push(
923 "Exact window activation is unavailable; app-level focus may work, but window_id/title/terminal-targeted input cannot be verified."
924 .to_string(),
925 );
926 }
927
928 if !can_send_development_input {
929 blockers.push(
930 "Development keyboard input is unavailable; enable XDG RemoteDesktop portal input or install wtype on compatible Wayland compositors, install xdotool with DISPLAY on X11, or use ydotool with a connectable ydotoold socket. Read/write /dev/uinput alone provides only absolute pointer input."
931 .to_string(),
932 );
933 }
934
935 if !can_capture_screenshots {
936 blockers.push(
937 "No screenshot route was detected: GNOME Shell is absent, the XDG portal does not export org.freedesktop.portal.Screenshot, this is not a native X11 session, and gnome-screenshot is not installed. get_app_state and screenshot return no image; element-aware actions from the accessibility tree still work."
938 .to_string(),
939 );
940 }
941
942 let recommended_next_step = if !can_build_accessibility_tree {
943 "Run setup_accessibility to enable AT-SPI accessibility before element-aware actions."
944 .to_string()
945 } else if !can_query_windows {
946 format!(
947 "Enable a supported window backend before using targeted keyboard input: {}",
948 registry::descriptors()
949 .iter()
950 .map(|descriptor| descriptor.missing_hint)
951 .collect::<Vec<_>>()
952 .join(" ")
953 )
954 } else if !can_focus_windows {
955 "Enable an exact-focus window backend before using window_id, title, or terminal-targeted input.".to_string()
956 } else if !can_send_development_input {
957 "Enable a keyboard-capable input backend: enable the XDG RemoteDesktop portal or install wtype on compatible Wayland compositors, install xdotool for X11, or start ydotoold with a socket accessible to this desktop user."
958 .to_string()
959 } else if !can_capture_screenshots {
960 "Enable a screenshot route: install an XDG desktop portal backend that implements Screenshot for this desktop, or install gnome-screenshot. Accessibility-tree actions work meanwhile."
961 .to_string()
962 } else {
963 "Computer Use is ready: AT-SPI tree support, window targeting, and a Linux input backend are available."
964 .to_string()
965 };
966
967 ReadinessReport {
968 can_register_mcp_tools: true,
969 can_build_accessibility_tree,
970 can_query_windows,
971 can_focus_apps,
972 can_focus_windows,
973 can_send_development_input,
974 can_capture_screenshots,
975 recommended_next_step,
976 blockers,
977 }
978}
979
980fn can_send_development_input(
981 platform: &PlatformReport,
982 remote_desktop_keyboard: &Check,
983 input: &InputReport,
984) -> bool {
985 let force_ydotool = env_flag_enabled_any(FORCE_YDOTOOL_KEYBOARD_ENV_KEYS);
986 let force_xdotool = env_flag_enabled_any(FORCE_XDOTOOL_KEYBOARD_ENV_KEYS);
987 portal_keyboard_input_available(platform, remote_desktop_keyboard)
988 || platform_is_wayland(platform)
989 && wtype_compatible_wayland_desktop(platform.xdg_current_desktop.as_deref())
990 && input.wtype.ok
991 && !force_ydotool
992 || should_advertise_xdotool(platform, input, force_ydotool, force_xdotool)
993 || input.ydotool.ok && input.ydotool_socket.ok
994}
995
996fn portal_pointer_input_available(platform: &PlatformReport, portals: &PortalReport) -> bool {
997 platform_is_wayland(platform) && portals.remote_desktop.ok
998}
999
1000fn portal_keyboard_input_available(
1001 platform: &PlatformReport,
1002 remote_desktop_keyboard: &Check,
1003) -> bool {
1004 platform_is_wayland(platform) && remote_desktop_keyboard.ok
1005}
1006
1007fn is_cosmic_wayland_platform(platform: &PlatformReport) -> bool {
1008 platform
1009 .xdg_current_desktop
1010 .as_deref()
1011 .is_some_and(|desktop| desktop.to_ascii_lowercase().contains("cosmic"))
1012 && platform.xdg_session_type.as_deref() == Some("wayland")
1013}
1014
1015fn can_build_accessibility_tree(accessibility: &AccessibilityReport) -> bool {
1016 accessibility.at_spi_bus.ok
1017 && (check_detail_contains_true(&accessibility.at_spi_enabled)
1018 || check_detail_contains_true(&accessibility.toolkit_accessibility))
1019}
1020
1021fn check_detail_contains_true(check: &Check) -> bool {
1022 check.ok && check.detail.to_ascii_lowercase().contains("true")
1023}
1024
1025fn env_var(key: &str) -> Option<String> {
1026 env::var(key).ok().filter(|value| !value.trim().is_empty())
1027}
1028
1029fn xdg_runtime_dir() -> Option<PathBuf> {
1030 if let Some(value) = env_var("XDG_RUNTIME_DIR") {
1031 return Some(PathBuf::from(value));
1032 }
1033 user_id().map(|uid| PathBuf::from(format!("/run/user/{uid}")))
1034}
1035
1036fn dbus_session_address() -> Option<String> {
1037 if let Some(value) = env_var("DBUS_SESSION_BUS_ADDRESS") {
1038 return Some(value);
1039 }
1040 xdg_runtime_dir()
1041 .map(|runtime| format!("unix:path={}", runtime.join("bus").display()))
1042 .filter(|address| {
1043 address
1044 .strip_prefix("unix:path=")
1045 .is_some_and(|p| Path::new(p).exists())
1046 })
1047}
1048
1049fn ydotool_socket_candidates() -> Vec<PathBuf> {
1050 let mut candidates = Vec::new();
1051 if let Some(value) = env_var("YDOTOOL_SOCKET") {
1052 candidates.push(PathBuf::from(value));
1053 }
1054
1055 if let Some(runtime_socket) = xdg_runtime_dir().map(|runtime| runtime.join(".ydotool_socket")) {
1056 candidates.push(runtime_socket);
1057 }
1058 candidates.push(PathBuf::from("/tmp/.ydotool_socket"));
1059 candidates
1060}
1061
1062fn ydotool_socket_check() -> Check {
1063 let mut checked = Vec::new();
1064 for candidate in ydotool_socket_candidates() {
1065 match socket_connect_result(&candidate) {
1066 Ok(()) => return Check::ok(format!("connectable: {}", candidate.display())),
1067 Err(detail) => checked.push(detail),
1068 }
1069 }
1070
1071 Check::fail(format!(
1072 "no connectable ydotool socket ({})",
1073 checked.join("; ")
1074 ))
1075}
1076
1077fn user_id() -> Option<String> {
1078 let output = Command::new("id").arg("-u").output().ok()?;
1079 output
1080 .status
1081 .success()
1082 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
1083 .filter(|value| !value.is_empty())
1084}
1085
1086fn command_path_check(command: &str) -> Check {
1087 command_check("sh", &["-c", &format!("command -v {command}")])
1088}
1089
1090fn platform_is_wayland(platform: &PlatformReport) -> bool {
1091 match platform.xdg_session_type.as_deref() {
1092 Some(value) => value.eq_ignore_ascii_case("wayland"),
1093 None => platform
1094 .wayland_display
1095 .as_deref()
1096 .is_some_and(|display| !display.trim().is_empty()),
1097 }
1098}
1099
1100pub(crate) fn wtype_compatible_wayland_desktop(desktop: Option<&str>) -> bool {
1101 desktop.is_none_or(|desktop| {
1102 let desktop = desktop.to_ascii_lowercase();
1103 !["gnome", "kde", "plasma", "cosmic"]
1104 .iter()
1105 .any(|known_incompatible| desktop.contains(known_incompatible))
1106 })
1107}
1108
1109fn should_advertise_xdotool(
1110 platform: &PlatformReport,
1111 input: &InputReport,
1112 force_ydotool: bool,
1113 force_xdotool: bool,
1114) -> bool {
1115 !force_ydotool
1116 && input.xdotool.ok
1117 && platform
1118 .display
1119 .as_deref()
1120 .is_some_and(|display| !display.trim().is_empty())
1121 && (force_xdotool || !platform_is_wayland(platform))
1122}
1123
1124fn env_flag_enabled_any(keys: &[&str]) -> bool {
1125 keys.iter()
1126 .any(|key| env::var(key).ok().as_deref() == Some("1"))
1127}
1128
1129fn force_portal_for_all_input(
1130 force_portal_pointer: bool,
1131 force_portal_keyboard: bool,
1132 force_ydotool_pointer: bool,
1133 force_ydotool_keyboard: bool,
1134) -> bool {
1135 force_portal_pointer
1136 && force_portal_keyboard
1137 && !force_ydotool_pointer
1138 && !force_ydotool_keyboard
1139}
1140
1141fn process_check(process_name: &str) -> Check {
1142 command_check("pgrep", &["-a", process_name])
1143}
1144
1145#[cfg(test)]
1146fn socket_connect_check(path: &Path) -> Check {
1147 match socket_connect_result(path) {
1148 Ok(()) => Check::ok(format!("connectable: {}", path.display())),
1149 Err(detail) => Check::fail(detail),
1150 }
1151}
1152
1153fn socket_connect_result(path: &Path) -> std::result::Result<(), String> {
1154 if !path.exists() {
1155 return Err(format!("missing: {}", path.display()));
1156 }
1157
1158 UnixDatagram::unbound()
1159 .and_then(|socket| socket.connect(path))
1160 .map_err(|error| format!("{}: datagram: {error}", path.display()))
1161}
1162
1163fn read_write_path_check(path: &Path) -> Check {
1164 if !path.exists() {
1165 return Check::fail(format!("missing: {}", path.display()));
1166 }
1167
1168 match OpenOptions::new().read(true).write(true).open(path) {
1169 Ok(_) => Check::ok(format!("read/write: {}", path.display())),
1170 Err(error) => Check::fail(format!("{}: {error}", path.display())),
1171 }
1172}
1173
1174fn bus_name_check(name: &str) -> Check {
1175 command_check_with_session_bus("busctl", &["--user", "status", name])
1176}
1177
1178fn portal_interface_check(interface: &str) -> Check {
1179 command_check_with_session_bus(
1180 "busctl",
1181 &[
1182 "--user",
1183 "introspect",
1184 "org.freedesktop.portal.Desktop",
1185 "/org/freedesktop/portal/desktop",
1186 interface,
1187 ],
1188 )
1189}
1190
1191fn portal_interface_methods_check(interface: &str, methods: &[&str]) -> Check {
1193 require_busctl_methods(portal_interface_check(interface), interface, methods)
1194}
1195
1196fn require_busctl_methods(introspection: Check, interface: &str, methods: &[&str]) -> Check {
1197 if !introspection.ok {
1198 return introspection;
1199 }
1200 let missing = missing_busctl_methods(&introspection, methods);
1201 if missing.is_empty() {
1202 return introspection;
1203 }
1204 if !busctl_introspection_has_members(&introspection.detail) {
1205 return Check::fail(format!(
1206 "{interface} is not exported by the portal (introspection listed no members)"
1207 ));
1208 }
1209 Check::fail(format!(
1210 "{interface} is missing required methods: {}",
1211 missing.join(", ")
1212 ))
1213}
1214
1215fn busctl_introspection_has_members(detail: &str) -> bool {
1216 detail
1217 .lines()
1218 .any(|line| line.trim_start().starts_with('.'))
1219}
1220
1221fn remote_desktop_portal_checks() -> (Check, Check, Check) {
1222 let introspection = portal_interface_check("org.freedesktop.portal.RemoteDesktop");
1223 let screencast = portal_interface_methods_check(
1224 "org.freedesktop.portal.ScreenCast",
1225 SCREENCAST_PORTAL_METHODS,
1226 );
1227 if !introspection.ok {
1228 return (introspection.clone(), introspection, screencast);
1229 }
1230
1231 let available_device_types = command_check_with_session_bus(
1232 "busctl",
1233 &[
1234 "--user",
1235 "get-property",
1236 "org.freedesktop.portal.Desktop",
1237 "/org/freedesktop/portal/desktop",
1238 "org.freedesktop.portal.RemoteDesktop",
1239 "AvailableDeviceTypes",
1240 ],
1241 );
1242 let available_source_types = command_check_with_session_bus(
1243 "busctl",
1244 &[
1245 "--user",
1246 "get-property",
1247 "org.freedesktop.portal.Desktop",
1248 "/org/freedesktop/portal/desktop",
1249 "org.freedesktop.portal.ScreenCast",
1250 "AvailableSourceTypes",
1251 ],
1252 );
1253 let pointer = remote_desktop_pointer_check_from(
1254 &introspection,
1255 &screencast,
1256 &available_device_types,
1257 &available_source_types,
1258 );
1259 let keyboard = remote_desktop_keyboard_check_from(&introspection, &available_device_types);
1260 (pointer, keyboard, screencast)
1261}
1262
1263fn remote_desktop_pointer_check_from(
1264 introspection: &Check,
1265 screencast: &Check,
1266 available_device_types: &Check,
1267 available_source_types: &Check,
1268) -> Check {
1269 if !introspection.ok {
1270 return Check::fail(introspection.detail.clone());
1271 }
1272 let missing_methods = missing_busctl_methods(introspection, REMOTE_DESKTOP_POINTER_METHODS);
1273 if !missing_methods.is_empty() {
1274 return Check::fail(format!(
1275 "RemoteDesktop interface is missing required pointer methods: {}",
1276 missing_methods.join(", ")
1277 ));
1278 }
1279
1280 if !screencast.ok {
1281 return Check::fail(format!(
1282 "ScreenCast interface is unavailable for portal pointer input: {}",
1283 screencast.detail
1284 ));
1285 }
1286 let missing_screencast_methods = missing_busctl_methods(screencast, SCREENCAST_POINTER_METHODS);
1287 if !missing_screencast_methods.is_empty() {
1288 return Check::fail(format!(
1289 "ScreenCast interface is missing required pointer methods: {}",
1290 missing_screencast_methods.join(", ")
1291 ));
1292 }
1293
1294 let device_types = match remote_desktop_device_types(available_device_types) {
1295 Ok(device_types) => device_types,
1296 Err(detail) => return Check::fail(detail),
1297 };
1298 if device_types & PORTAL_DEVICE_POINTER == 0 {
1299 return Check::fail(format!(
1300 "RemoteDesktop AvailableDeviceTypes={device_types} does not include pointer input"
1301 ));
1302 }
1303
1304 let source_types = match screencast_source_types(available_source_types) {
1305 Ok(source_types) => source_types,
1306 Err(detail) => return Check::fail(detail),
1307 };
1308 if source_types & PORTAL_SOURCE_MONITOR == 0 {
1309 return Check::fail(format!(
1310 "ScreenCast AvailableSourceTypes={source_types} does not include monitor sources"
1311 ));
1312 }
1313
1314 Check::ok(format!(
1315 "pointer-capable RemoteDesktop portal (AvailableDeviceTypes={device_types}, AvailableSourceTypes={source_types})"
1316 ))
1317}
1318
1319fn remote_desktop_keyboard_check_from(
1320 introspection: &Check,
1321 available_device_types: &Check,
1322) -> Check {
1323 if !introspection.ok {
1324 return Check::fail(introspection.detail.clone());
1325 }
1326
1327 let missing_methods = missing_busctl_methods(introspection, REMOTE_DESKTOP_KEYBOARD_METHODS);
1328 if !missing_methods.is_empty() {
1329 return Check::fail(format!(
1330 "RemoteDesktop interface is missing required keyboard methods: {}",
1331 missing_methods.join(", ")
1332 ));
1333 }
1334
1335 let device_types = match remote_desktop_device_types(available_device_types) {
1336 Ok(device_types) => device_types,
1337 Err(detail) => return Check::fail(detail),
1338 };
1339 if device_types & PORTAL_DEVICE_KEYBOARD == 0 {
1340 return Check::fail(format!(
1341 "RemoteDesktop AvailableDeviceTypes={device_types} does not include keyboard input"
1342 ));
1343 }
1344
1345 Check::ok(format!(
1346 "keyboard-capable RemoteDesktop portal (AvailableDeviceTypes={device_types})"
1347 ))
1348}
1349
1350fn missing_busctl_methods<'a>(introspection: &Check, methods: &'a [&'a str]) -> Vec<&'a str> {
1351 methods
1352 .iter()
1353 .copied()
1354 .filter(|method| !busctl_introspection_has_method(&introspection.detail, method))
1355 .collect()
1356}
1357
1358fn remote_desktop_device_types(available_device_types: &Check) -> Result<u32, String> {
1359 if !available_device_types.ok {
1360 return Err(format!(
1361 "RemoteDesktop AvailableDeviceTypes is unavailable: {}",
1362 available_device_types.detail
1363 ));
1364 }
1365 parse_busctl_u32_property(&available_device_types.detail).ok_or_else(|| {
1366 format!(
1367 "RemoteDesktop AvailableDeviceTypes has an unexpected value: {}",
1368 available_device_types.detail
1369 )
1370 })
1371}
1372
1373fn screencast_source_types(available_source_types: &Check) -> Result<u32, String> {
1374 if !available_source_types.ok {
1375 return Err(format!(
1376 "ScreenCast AvailableSourceTypes is unavailable: {}",
1377 available_source_types.detail
1378 ));
1379 }
1380 parse_busctl_u32_property(&available_source_types.detail).ok_or_else(|| {
1381 format!(
1382 "ScreenCast AvailableSourceTypes has an unexpected value: {}",
1383 available_source_types.detail
1384 )
1385 })
1386}
1387
1388fn busctl_introspection_has_method(detail: &str, method: &str) -> bool {
1389 detail.lines().any(|line| {
1390 let mut fields = line.split_whitespace();
1391 fields
1392 .next()
1393 .is_some_and(|name| name.trim_start_matches('.') == method)
1394 && fields.next() == Some("method")
1395 })
1396}
1397
1398fn parse_busctl_u32_property(detail: &str) -> Option<u32> {
1399 let mut fields = detail.split_whitespace();
1400 if fields.next()? != "u" {
1401 return None;
1402 }
1403 let value = fields.next()?;
1404 value
1405 .strip_prefix("0x")
1406 .map(|hex| u32::from_str_radix(hex, 16).ok())
1407 .unwrap_or_else(|| value.parse().ok())
1408}
1409
1410fn atspi_bus_address_check() -> Check {
1411 let busctl = command_check_with_session_bus(
1412 "busctl",
1413 &[
1414 "--user",
1415 "call",
1416 "org.a11y.Bus",
1417 "/org/a11y/bus",
1418 "org.a11y.Bus",
1419 "GetAddress",
1420 ],
1421 );
1422 if busctl.ok {
1423 return busctl;
1424 }
1425
1426 gdbus_call_check(
1427 "org.a11y.Bus",
1428 "/org/a11y/bus",
1429 "org.a11y.Bus.GetAddress",
1430 &[],
1431 )
1432}
1433
1434fn atspi_status_property_check(property: &str) -> Check {
1435 let busctl = command_check_with_session_bus(
1436 "busctl",
1437 &[
1438 "--user",
1439 "get-property",
1440 "org.a11y.Bus",
1441 "/org/a11y/bus",
1442 "org.a11y.Status",
1443 property,
1444 ],
1445 );
1446 if busctl.ok {
1447 return busctl;
1448 }
1449
1450 gdbus_call_check(
1451 "org.a11y.Bus",
1452 "/org/a11y/bus",
1453 "org.freedesktop.DBus.Properties.Get",
1454 &["org.a11y.Status", property],
1455 )
1456}
1457
1458fn gdbus_call_check(destination: &str, object_path: &str, method: &str, args: &[&str]) -> Check {
1459 let mut command_args = vec![
1460 "call",
1461 "--session",
1462 "--dest",
1463 destination,
1464 "--object-path",
1465 object_path,
1466 "--method",
1467 method,
1468 ];
1469 command_args.extend_from_slice(args);
1470 command_check_with_session_bus("gdbus", &command_args)
1471}
1472
1473fn command_check(command: &str, args: &[&str]) -> Check {
1474 run_command(command, args, false)
1475}
1476
1477fn command_check_with_session_bus(command: &str, args: &[&str]) -> Check {
1478 run_command(command, args, true)
1479}
1480
1481fn run_command(command: &str, args: &[&str], with_session_bus: bool) -> Check {
1482 let mut cmd = Command::new(command);
1483 cmd.args(args);
1484
1485 if with_session_bus {
1486 if let Some(address) = dbus_session_address() {
1487 cmd.env("DBUS_SESSION_BUS_ADDRESS", address);
1488 }
1489 if let Some(runtime) = xdg_runtime_dir() {
1490 cmd.env("XDG_RUNTIME_DIR", runtime);
1491 }
1492 }
1493
1494 match cmd.output() {
1495 Ok(output) if output.status.success() => {
1496 let detail = String::from_utf8_lossy(&output.stdout).trim().to_string();
1497 Check::ok(if detail.is_empty() {
1498 "ok".into()
1499 } else {
1500 detail
1501 })
1502 }
1503 Ok(output) => {
1504 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1505 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
1506 let detail = if !stderr.is_empty() { stderr } else { stdout };
1507 Check::fail(if detail.is_empty() {
1508 format!("exit status {}", output.status)
1509 } else {
1510 detail
1511 })
1512 }
1513 Err(error) => Check::fail(error.to_string()),
1514 }
1515}
1516
1517#[cfg(test)]
1518mod tests {
1519 use super::*;
1520
1521 #[test]
1522 fn setup_repairs_saved_key_even_when_runtime_is_ready() {
1523 let mut before = accessibility_report(Check::ok("bus"), Check::ok("false"));
1524 before.at_spi_enabled = Check::ok("b true");
1525 let mut calls = Vec::new();
1526 let result = enable_accessibility(&before, |program, args| {
1527 calls.push((program.to_string(), args.join(" ")));
1528 Check::ok(if args[0] == "get" { "true" } else { "" })
1529 });
1530 assert!(result.ok);
1531 assert_eq!(
1532 calls,
1533 vec![
1534 (
1535 "gsettings".into(),
1536 "set org.gnome.desktop.interface toolkit-accessibility true".into()
1537 ),
1538 (
1539 "gsettings".into(),
1540 "get org.gnome.desktop.interface toolkit-accessibility".into()
1541 ),
1542 ]
1543 );
1544 }
1545
1546 #[test]
1547 fn setup_does_not_claim_saved_success_from_command_exit_status() {
1548 let before = accessibility_report(Check::ok("bus"), Check::ok("false"));
1549 let mut calls = Vec::new();
1550 let result = enable_accessibility(&before, |program, args| {
1551 calls.push(program.to_string());
1552 Check::ok(if args[0] == "get" {
1553 "false"
1554 } else {
1555 "exit status 0"
1556 })
1557 });
1558 assert!(!result.ok);
1559 assert!(result.detail.contains("not verified"));
1560 assert_eq!(calls, ["gsettings", "gsettings", "busctl"]);
1561 }
1562
1563 #[test]
1564 fn setup_preserves_enabled_settings_without_writing() {
1565 let before = accessibility_report(Check::ok("bus"), Check::ok("true"));
1566 let result = enable_accessibility(&before, |_, _| panic!("already enabled"));
1567 assert!(result.ok);
1568 }
1569
1570 fn platform_report() -> PlatformReport {
1571 PlatformReport {
1572 os: "linux".to_string(),
1573 arch: "x86_64".to_string(),
1574 desktop_session: None,
1575 xdg_session_type: Some("wayland".to_string()),
1576 xdg_current_desktop: Some("GNOME".to_string()),
1577 wayland_display: Some("wayland-0".to_string()),
1578 display: Some(":0".to_string()),
1579 xauthority: Some("/run/user/1000/Xauthority".to_string()),
1580 dbus_session_bus_address: Some("unix:path=/run/user/1000/bus".to_string()),
1581 xdg_runtime_dir: Some("/run/user/1000".to_string()),
1582 gnome_shell_version: Check::ok("GNOME Shell 46.0"),
1583 gnome_screenshot: Check::ok("gnome-screenshot 41.0"),
1584 x11_display: Check::fail("not a native X11 session"),
1585 }
1586 }
1587
1588 fn portal_report(remote_desktop: Check) -> PortalReport {
1589 PortalReport {
1590 desktop_portal: Check::ok("ok"),
1591 remote_desktop,
1592 screencast: Check::fail("missing"),
1593 screenshot: Check::fail("missing"),
1594 input_capture: Check::fail("missing"),
1595 mutter_remote_desktop: Check::fail("missing"),
1596 mutter_screencast: Check::fail("missing"),
1597 }
1598 }
1599
1600 fn accessibility_report(
1601 at_spi_bus: Check,
1602 toolkit_accessibility: Check,
1603 ) -> AccessibilityReport {
1604 AccessibilityReport {
1605 at_spi_bus,
1606 toolkit_accessibility,
1607 at_spi_enabled: Check::fail("(<false>,)"),
1608 screen_reader_enabled: Check::fail("(<false>,)"),
1609 }
1610 }
1611
1612 fn windowing_report(can_list_windows: bool, can_focus_windows: bool) -> WindowingReport {
1613 WindowingReport {
1614 gnome_shell_introspect: if can_list_windows {
1615 Check::ok("ok")
1616 } else {
1617 Check::fail("denied")
1618 },
1619 computer_use_linux_gnome_shell_extension: if can_focus_windows {
1620 Check::ok("ok")
1621 } else {
1622 Check::fail("missing")
1623 },
1624 cosmic_helper: Check::fail("missing"),
1625 kwin: Check::fail("not a KWin session"),
1626 hyprland: Check::fail("not a Hyprland session"),
1627 backends: BTreeMap::new(),
1628 can_list_windows,
1629 can_focus_apps: true,
1630 can_focus_windows,
1631 note: String::new(),
1632 }
1633 }
1634
1635 fn input_report(can_send_input: bool) -> InputReport {
1636 let check = if can_send_input {
1637 Check::ok("ok")
1638 } else {
1639 Check::fail("missing")
1640 };
1641 input_report_parts(check.clone(), check.clone(), check.clone(), check)
1642 }
1643
1644 fn input_report_parts(
1645 ydotool: Check,
1646 ydotoold: Check,
1647 ydotool_socket: Check,
1648 uinput: Check,
1649 ) -> InputReport {
1650 InputReport {
1651 ydotool,
1652 ydotoold,
1653 ydotool_socket,
1654 uinput,
1655 xdotool: Check::fail("missing xdotool"),
1656 wtype: Check::fail("missing wtype"),
1657 }
1658 }
1659
1660 #[test]
1661 fn accessibility_tree_requires_reachable_at_spi_bus() {
1662 let report = accessibility_report(Check::fail("permission denied"), Check::ok("true"));
1663
1664 assert!(!can_build_accessibility_tree(&report));
1665 }
1666
1667 #[test]
1668 fn accessibility_tree_is_ready_when_bus_and_toolkit_are_ready() {
1669 let report = accessibility_report(
1670 Check::ok("('unix:path=/run/user/1000/at-spi/bus',)"),
1671 Check::ok("true"),
1672 );
1673
1674 assert!(can_build_accessibility_tree(&report));
1675 }
1676
1677 #[test]
1678 fn capability_map_advertises_only_a_buildable_accessibility_tree() {
1679 let platform = platform_report();
1680 let portals = portal_report(Check::fail("missing"));
1681 let windowing = windowing_report(false, false);
1682 let input = input_report(false);
1683
1684 for accessibility in [
1685 accessibility_report(Check::fail("permission denied"), Check::ok("true")),
1686 accessibility_report(
1687 Check::ok("('unix:path=/run/user/1000/at-spi/bus',)"),
1688 Check::ok("false"),
1689 ),
1690 ] {
1691 let capabilities =
1692 capability_map(&platform, &portals, &accessibility, &windowing, &input);
1693 assert!(capabilities.accessibility.is_empty());
1694 }
1695 }
1696
1697 #[test]
1698 fn parses_parent_pid_from_proc_status() {
1699 let status = "Name:\ttest\nPid:\t42\nPPid:\t7\n";
1700
1701 assert_eq!(parse_parent_pid(status), Some(7));
1702 }
1703
1704 #[test]
1705 fn parses_nul_separated_process_environment() {
1706 let environment = parse_environ(
1707 b"DISPLAY=:0\0WAYLAND_DISPLAY=wayland-0\0EMPTY=\0NO_EQUALS\0XDG_SESSION_TYPE=wayland\0",
1708 );
1709
1710 assert_eq!(environment.get("DISPLAY").map(String::as_str), Some(":0"));
1711 assert_eq!(
1712 environment.get("WAYLAND_DISPLAY").map(String::as_str),
1713 Some("wayland-0")
1714 );
1715 assert_eq!(environment.get("EMPTY").map(String::as_str), Some(""));
1716 assert!(!environment.contains_key("NO_EQUALS"));
1717 }
1718
1719 #[test]
1720 fn desktop_env_hydration_includes_xauthority() {
1721 assert!(DESKTOP_ENV_KEYS.contains(&"XAUTHORITY"));
1722 }
1723
1724 #[test]
1725 fn desktop_env_hydration_preserves_explicit_native_x11() {
1726 let current_env = HashMap::from([
1727 ("DISPLAY".to_string(), ":90".to_string()),
1728 ("XDG_SESSION_TYPE".to_string(), "x11".to_string()),
1729 ]);
1730 let host_env = HashMap::from([
1731 ("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string()),
1732 (
1733 "XDG_CURRENT_DESKTOP".to_string(),
1734 "ubuntu:GNOME".to_string(),
1735 ),
1736 ]);
1737
1738 let updates = desktop_env_hydration_updates(¤t_env, &host_env);
1739
1740 assert!(!updates.iter().any(|(key, _)| *key == "WAYLAND_DISPLAY"));
1741 assert!(updates
1742 .iter()
1743 .any(|(key, value)| { *key == "XDG_CURRENT_DESKTOP" && value == "ubuntu:GNOME" }));
1744 }
1745
1746 #[test]
1747 fn desktop_env_hydration_still_imports_wayland_for_incomplete_sessions() {
1748 let current_env = HashMap::new();
1749 let host_env = HashMap::from([("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string())]);
1750
1751 let updates = desktop_env_hydration_updates(¤t_env, &host_env);
1752
1753 assert!(updates
1754 .iter()
1755 .any(|(key, value)| *key == "WAYLAND_DISPLAY" && value == "wayland-0"));
1756 }
1757
1758 #[test]
1759 fn graphical_process_env_requires_display() {
1760 let with_display = HashMap::from([("DISPLAY".to_string(), ":0".to_string())]);
1761 let with_wayland =
1762 HashMap::from([("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string())]);
1763 let without_display = HashMap::from([("XAUTHORITY".to_string(), "/tmp/xauth".to_string())]);
1764
1765 assert!(process_env_has_graphical_display(&with_display));
1766 assert!(process_env_has_graphical_display(&with_wayland));
1767 assert!(!process_env_has_graphical_display(&without_display));
1768 }
1769
1770 #[test]
1771 fn capabilities_prefer_xdotool_before_ydotool_on_x11() {
1772 let mut platform = platform_report();
1773 platform.xdg_session_type = Some("x11".to_string());
1774 platform.wayland_display = None;
1775 platform.display = Some(":0".to_string());
1776 let input = InputReport {
1777 ydotool: Check::ok("ydotool"),
1778 ydotoold: Check::ok("ydotoold"),
1779 ydotool_socket: Check::ok("connectable"),
1780 uinput: Check::fail("missing"),
1781 xdotool: Check::ok("xdotool"),
1782 wtype: Check::fail("missing wtype"),
1783 };
1784
1785 let capabilities = capability_map(
1786 &platform,
1787 &portal_report(Check::fail("missing")),
1788 &accessibility_report(Check::fail("missing"), Check::fail("missing")),
1789 &windowing_report(false, false),
1790 &input,
1791 );
1792
1793 assert_eq!(capabilities.input, ["xdotool", "ydotool"]);
1794 assert_eq!(capabilities.preferred.input.as_deref(), Some("xdotool"));
1795 }
1796
1797 #[test]
1798 fn x11_diagnostics_ignore_portal_and_accept_xdotool() {
1799 let mut platform = platform_report();
1800 platform.xdg_session_type = Some("x11".to_string());
1801 platform.wayland_display = None;
1802 platform.display = Some(":0".to_string());
1803 let portals = portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop"));
1804 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1805 let windowing = windowing_report(true, true);
1806 let mut input = input_report(false);
1807 input.xdotool = Check::ok("xdotool");
1808
1809 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1810 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
1811
1812 assert_eq!(capabilities.input, ["xdotool"]);
1813 assert_eq!(capabilities.preferred.input.as_deref(), Some("xdotool"));
1814 assert!(readiness.can_send_development_input);
1815 }
1816
1817 #[test]
1818 fn wayland_diagnostics_advertise_wtype_without_portal_or_ydotool() {
1819 let mut platform = platform_report();
1820 platform.xdg_session_type = Some("wayland".to_string());
1821 platform.xdg_current_desktop = Some("Hyprland".to_string());
1822 platform.wayland_display = Some("wayland-0".to_string());
1823 let portals = portal_report(Check::fail("missing"));
1824 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1825 let windowing = windowing_report(true, true);
1826 let mut input = input_report(false);
1827 input.wtype = Check::ok("wtype");
1828
1829 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1830 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
1831
1832 assert_eq!(capabilities.input, ["wtype"]);
1833 assert_eq!(capabilities.preferred.input.as_deref(), Some("wtype"));
1834 assert!(readiness.can_send_development_input);
1835 }
1836
1837 #[test]
1838 fn wtype_excludes_known_incompatible_wayland_desktops() {
1839 assert!(wtype_compatible_wayland_desktop(Some("Hyprland")));
1840 assert!(wtype_compatible_wayland_desktop(Some("sway")));
1841 assert!(wtype_compatible_wayland_desktop(None));
1842 assert!(!wtype_compatible_wayland_desktop(Some("GNOME")));
1843 assert!(!wtype_compatible_wayland_desktop(Some("KDE;Plasma")));
1844 assert!(!wtype_compatible_wayland_desktop(Some("COSMIC")));
1845 }
1846
1847 #[test]
1848 fn wayland_diagnostics_prefer_ydotool_before_portal() {
1849 let platform = platform_report();
1850 let portals = portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop"));
1851 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1852 let windowing = windowing_report(true, true);
1853 let input = input_report_parts(
1854 Check::ok("ydotool"),
1855 Check::ok("ydotoold"),
1856 Check::ok("connectable"),
1857 Check::fail("missing uinput"),
1858 );
1859
1860 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1861
1862 assert_eq!(capabilities.input, ["ydotool", "portal"]);
1863 assert_eq!(capabilities.preferred.input.as_deref(), Some("ydotool"));
1864 }
1865
1866 #[test]
1867 fn portal_force_order_requires_both_input_modalities() {
1868 assert!(force_portal_for_all_input(true, true, false, false));
1869 assert!(!force_portal_for_all_input(true, false, false, false));
1870 assert!(!force_portal_for_all_input(true, true, true, false));
1871 assert!(!force_portal_for_all_input(true, true, false, true));
1872 }
1873
1874 fn remote_desktop_runtime_introspection() -> Check {
1875 Check::ok(
1876 "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n\
1877 .CreateSession method a{sv} o -\n\
1878 .SelectDevices method oa{sv} o -\n\
1879 .Start method osa{sv} o -\n\
1880 .NotifyPointerMotionAbsolute method oa{sv}udd - -\n\
1881 .NotifyPointerButton method oa{sv}iu - -\n\
1882 .NotifyPointerAxisDiscrete method oa{sv}ui - -\n\
1883 .NotifyKeyboardKeycode method ouu - -\n\
1884 .NotifyKeyboardKeysym method ouu - -\n\
1885 .AvailableDeviceTypes property u 3 emits-change",
1886 )
1887 }
1888
1889 fn screencast_runtime_introspection() -> Check {
1890 Check::ok(
1891 "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n\
1892 .SelectSources method oa{sv} o -",
1893 )
1894 }
1895
1896 #[test]
1897 fn remote_desktop_portal_rejects_header_only_introspection() {
1898 let introspection = Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS");
1899 let available_device_types = Check::ok("u 3");
1900 let pointer = remote_desktop_pointer_check_from(
1901 &introspection,
1902 &screencast_runtime_introspection(),
1903 &available_device_types,
1904 &Check::ok("u 1"),
1905 );
1906 let keyboard = remote_desktop_keyboard_check_from(&introspection, &available_device_types);
1907
1908 assert!(!pointer.ok);
1909 assert!(pointer.detail.contains("CreateSession"));
1910 assert!(pointer.detail.contains("NotifyPointerButton"));
1911 assert!(!keyboard.ok);
1912 assert!(keyboard.detail.contains("NotifyKeyboardKeysym"));
1913
1914 let input = input_report_parts(
1915 Check::ok("ydotool"),
1916 Check::ok("ydotoold"),
1917 Check::fail("no connectable ydotool socket"),
1918 Check::ok("read/write: /dev/uinput"),
1919 );
1920 let platform = platform_report();
1921 let portals = portal_report(pointer);
1922 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1923 let windowing = windowing_report(true, true);
1924 let capabilities = capability_map_with_portal_keyboard(
1925 &platform,
1926 &portals,
1927 &keyboard,
1928 &accessibility,
1929 &windowing,
1930 &input,
1931 );
1932 let readiness = readiness_report_with_portal_keyboard(
1933 &platform,
1934 &keyboard,
1935 &accessibility,
1936 &windowing,
1937 &input,
1938 true,
1939 );
1940
1941 assert!(!capabilities.input.iter().any(|backend| backend == "portal"));
1942 assert!(!readiness.can_send_development_input);
1943 }
1944
1945 #[test]
1946 fn keyboard_only_portal_remains_advertised_without_pointer_capability() {
1947 let platform = platform_report();
1948 let keyboard = remote_desktop_keyboard_check_from(
1949 &remote_desktop_runtime_introspection(),
1950 &Check::ok("u 1"),
1951 );
1952 let portals = portal_report(Check::fail(
1953 "ScreenCast AvailableSourceTypes=2 does not include monitor sources",
1954 ));
1955 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1956 let windowing = windowing_report(true, true);
1957 let input = input_report_parts(
1958 Check::fail("missing ydotool"),
1959 Check::fail("ydotoold not running"),
1960 Check::fail("no connectable ydotool socket"),
1961 Check::fail("/dev/uinput: Permission denied"),
1962 );
1963
1964 let capabilities = capability_map_with_portal_keyboard(
1965 &platform,
1966 &portals,
1967 &keyboard,
1968 &accessibility,
1969 &windowing,
1970 &input,
1971 );
1972 let readiness = readiness_report_with_portal_keyboard(
1973 &platform,
1974 &keyboard,
1975 &accessibility,
1976 &windowing,
1977 &input,
1978 true,
1979 );
1980
1981 assert!(!portals.remote_desktop.ok);
1982 assert_eq!(capabilities.input, ["portal"]);
1983 assert_eq!(capabilities.preferred.input.as_deref(), Some("portal"));
1984 assert!(readiness.can_send_development_input);
1985 }
1986
1987 #[test]
1988 fn remote_desktop_pointer_rejects_missing_screencast_contract() {
1989 let check = remote_desktop_pointer_check_from(
1990 &remote_desktop_runtime_introspection(),
1991 &Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS"),
1992 &Check::ok("u 2"),
1993 &Check::ok("u 1"),
1994 );
1995
1996 assert!(!check.ok);
1997 assert!(check.detail.contains("SelectSources"));
1998 }
1999
2000 #[test]
2001 fn remote_desktop_pointer_rejects_missing_monitor_source_type() {
2002 let check = remote_desktop_pointer_check_from(
2003 &remote_desktop_runtime_introspection(),
2004 &screencast_runtime_introspection(),
2005 &Check::ok("u 2"),
2006 &Check::ok("u 2"),
2007 );
2008
2009 assert!(!check.ok);
2010 assert!(check.detail.contains("does not include monitor sources"));
2011 }
2012
2013 #[test]
2014 fn remote_desktop_portal_rejects_missing_keyboard_device_type() {
2015 let check = remote_desktop_keyboard_check_from(
2016 &remote_desktop_runtime_introspection(),
2017 &Check::ok("u 2"),
2018 );
2019
2020 assert!(!check.ok);
2021 assert!(check.detail.contains("does not include keyboard input"));
2022 }
2023
2024 #[test]
2025 fn remote_desktop_portal_accepts_runtime_keyboard_contract() {
2026 let check = remote_desktop_keyboard_check_from(
2027 &remote_desktop_runtime_introspection(),
2028 &Check::ok("u 3"),
2029 );
2030
2031 assert!(check.ok);
2032 assert!(check.detail.contains("AvailableDeviceTypes=3"));
2033 }
2034
2035 #[test]
2036 fn pointer_only_portal_remains_advertised_without_keyboard_readiness() {
2037 let platform = platform_report();
2038 let available_device_types = Check::ok("u 2");
2039 let pointer = remote_desktop_pointer_check_from(
2040 &remote_desktop_runtime_introspection(),
2041 &screencast_runtime_introspection(),
2042 &available_device_types,
2043 &Check::ok("u 1"),
2044 );
2045 let keyboard = remote_desktop_keyboard_check_from(
2046 &remote_desktop_runtime_introspection(),
2047 &available_device_types,
2048 );
2049 let portals = portal_report(pointer);
2050 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2051 let windowing = windowing_report(true, true);
2052 let input = input_report_parts(
2053 Check::fail("missing ydotool"),
2054 Check::fail("ydotoold not running"),
2055 Check::fail("no connectable ydotool socket"),
2056 Check::fail("/dev/uinput: Permission denied"),
2057 );
2058
2059 let capabilities = capability_map_with_portal_keyboard(
2060 &platform,
2061 &portals,
2062 &keyboard,
2063 &accessibility,
2064 &windowing,
2065 &input,
2066 );
2067 let readiness = readiness_report_with_portal_keyboard(
2068 &platform,
2069 &keyboard,
2070 &accessibility_report(Check::ok("bus"), Check::ok("true")),
2071 &windowing_report(true, true),
2072 &input,
2073 true,
2074 );
2075
2076 assert!(portals.remote_desktop.ok);
2077 assert!(capabilities.input.iter().any(|backend| backend == "portal"));
2078 assert!(!readiness.can_send_development_input);
2079 }
2080
2081 #[test]
2082 fn capabilities_require_display_to_advertise_xdotool() {
2083 let mut platform = platform_report();
2084 platform.xdg_session_type = Some("x11".to_string());
2085 platform.wayland_display = None;
2086 platform.display = None;
2087 let input = InputReport {
2088 ydotool: Check::ok("ydotool"),
2089 ydotoold: Check::ok("ydotoold"),
2090 ydotool_socket: Check::ok("connectable"),
2091 uinput: Check::fail("missing"),
2092 xdotool: Check::ok("xdotool"),
2093 wtype: Check::fail("missing wtype"),
2094 };
2095
2096 let capabilities = capability_map(
2097 &platform,
2098 &portal_report(Check::fail("missing")),
2099 &accessibility_report(Check::fail("missing"), Check::fail("missing")),
2100 &windowing_report(false, false),
2101 &input,
2102 );
2103
2104 assert_eq!(capabilities.input, ["ydotool"]);
2105 assert_eq!(capabilities.preferred.input.as_deref(), Some("ydotool"));
2106 }
2107
2108 #[test]
2109 fn xdotool_diagnostics_force_precedence_matches_runtime() {
2110 let mut platform = platform_report();
2111 platform.xdg_session_type = Some("wayland".to_string());
2112 platform.display = Some(":0".to_string());
2113 let mut input = input_report(false);
2114 input.xdotool = Check::ok("xdotool");
2115
2116 assert!(should_advertise_xdotool(&platform, &input, false, true));
2117 assert!(!should_advertise_xdotool(&platform, &input, true, true));
2118 }
2119
2120 #[test]
2121 fn parses_systemd_show_environment_output() {
2122 let environment = parse_line_environment(
2123 b"DISPLAY=:0\nHYPRLAND_INSTANCE_SIGNATURE=abc\nNO_EQUALS\nYDOTOOL_SOCKET=/run/ydotoold/socket\n",
2124 );
2125
2126 assert_eq!(environment.get("DISPLAY").map(String::as_str), Some(":0"));
2127 assert_eq!(
2128 environment
2129 .get("HYPRLAND_INSTANCE_SIGNATURE")
2130 .map(String::as_str),
2131 Some("abc")
2132 );
2133 assert_eq!(
2134 environment.get("YDOTOOL_SOCKET").map(String::as_str),
2135 Some("/run/ydotoold/socket")
2136 );
2137 assert!(!environment.contains_key("NO_EQUALS"));
2138 }
2139
2140 #[test]
2141 fn readiness_requires_exact_window_focus_for_targeted_input() {
2142 let platform = platform_report();
2143 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2144 let windowing = windowing_report(true, false);
2145 let input = input_report(true);
2146
2147 let readiness = readiness_report(
2148 &platform,
2149 &portal_report(Check::fail("missing")),
2150 &accessibility,
2151 &windowing,
2152 &input,
2153 );
2154
2155 assert!(readiness.can_query_windows);
2156 assert!(!readiness.can_focus_windows);
2157 assert!(readiness
2158 .recommended_next_step
2159 .contains("exact-focus window backend"));
2160 assert!(readiness
2161 .blockers
2162 .iter()
2163 .any(|blocker| blocker.contains("Exact window activation")));
2164 }
2165
2166 #[test]
2167 fn header_only_introspection_is_not_an_exported_interface() {
2168 let header_only = Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS");
2170
2171 let check = require_busctl_methods(
2172 header_only,
2173 "org.freedesktop.portal.Screenshot",
2174 SCREENSHOT_PORTAL_METHODS,
2175 );
2176
2177 assert!(!check.ok);
2178 assert!(check.detail.contains("is not exported by the portal"));
2179 }
2180
2181 #[test]
2182 fn portal_interface_must_export_every_required_method() {
2183 let partial = Check::ok(
2184 "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n.PickColor method sa{sv} o -\n.version property u 2 emits-change",
2185 );
2186 let check = require_busctl_methods(
2187 partial,
2188 "org.freedesktop.portal.Screenshot",
2189 SCREENSHOT_PORTAL_METHODS,
2190 );
2191 assert!(!check.ok);
2192 assert!(check
2193 .detail
2194 .contains("missing required methods: Screenshot"));
2195
2196 let full = Check::ok(
2197 "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n.PickColor method sa{sv} o -\n.Screenshot method sa{sv} o -",
2198 );
2199 assert!(
2200 require_busctl_methods(
2201 full,
2202 "org.freedesktop.portal.Screenshot",
2203 SCREENSHOT_PORTAL_METHODS
2204 )
2205 .ok
2206 );
2207
2208 let failed = Check::fail("busctl: No such interface");
2209 let passthrough = require_busctl_methods(
2210 failed,
2211 "org.freedesktop.portal.Screenshot",
2212 SCREENSHOT_PORTAL_METHODS,
2213 );
2214 assert!(!passthrough.ok);
2215 assert_eq!(passthrough.detail, "busctl: No such interface");
2216 }
2217
2218 #[test]
2219 fn readiness_blocks_when_no_screenshot_route_is_detected() {
2220 let mut platform = platform_report();
2221 platform.gnome_shell_version = Check::fail("No such file or directory (os error 2)");
2222 platform.gnome_screenshot = Check::fail("No such file or directory (os error 2)");
2223 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2224 let windowing = windowing_report(true, true);
2225 let input = input_report(true);
2226 let portals = portal_report(Check::fail("missing"));
2227
2228 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2229
2230 assert!(!readiness.can_capture_screenshots);
2231 assert!(readiness
2232 .blockers
2233 .iter()
2234 .any(|blocker| blocker.contains("No screenshot route was detected")));
2235 assert!(readiness
2236 .recommended_next_step
2237 .contains("Enable a screenshot route"));
2238 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2239 assert!(capabilities.screenshot.is_empty());
2240 assert_eq!(capabilities.preferred.screenshot, None);
2241 }
2242
2243 #[test]
2244 fn native_x11_display_is_a_screenshot_route_ahead_of_gnome_screenshot() {
2245 let mut platform = platform_report();
2246 platform.gnome_shell_version = Check::fail("missing");
2247 platform.x11_display = Check::ok("native X11 root window 2880x1920, depth 24");
2248 let portals = portal_report(Check::fail("missing"));
2249 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2250 let windowing = windowing_report(true, true);
2251 let input = input_report(true);
2252
2253 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2254 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2255
2256 assert_eq!(capabilities.screenshot, ["x11", "gnome_screenshot"]);
2257 assert_eq!(capabilities.preferred.screenshot.as_deref(), Some("x11"));
2258 assert!(readiness.can_capture_screenshots);
2259 }
2260
2261 #[test]
2262 fn readiness_and_capabilities_share_the_screenshot_route_list() {
2263 let mut platform = platform_report();
2264 platform.gnome_shell_version = Check::fail("missing");
2265 platform.gnome_screenshot = Check::fail("missing");
2266 let mut portals = portal_report(Check::fail("missing"));
2267 portals.screenshot = Check::ok(".Screenshot method sa{sv} o -");
2268 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2269 let windowing = windowing_report(true, true);
2270 let input = input_report(true);
2271
2272 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2273 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2274
2275 assert!(readiness.can_capture_screenshots);
2276 assert_eq!(capabilities.screenshot, ["portal"]);
2277 }
2278
2279 #[test]
2280 fn readiness_treats_kwin_as_full_window_backend() {
2281 let platform = platform_report();
2282 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2283 let mut windowing = windowing_report(false, false);
2284 windowing.kwin = Check::ok("KWin scripting is available");
2285 windowing.can_list_windows = true;
2286 windowing.can_focus_apps = true;
2287 windowing.can_focus_windows = true;
2288 let input = input_report(true);
2289
2290 let readiness = readiness_report(
2291 &platform,
2292 &portal_report(Check::fail("missing")),
2293 &accessibility,
2294 &windowing,
2295 &input,
2296 );
2297
2298 assert!(readiness.can_query_windows);
2299 assert!(readiness.can_focus_apps);
2300 assert!(readiness.can_focus_windows);
2301 assert!(readiness.blockers.is_empty());
2302 }
2303
2304 #[test]
2305 fn readiness_message_mentions_generic_window_targeting() {
2306 let platform = platform_report();
2307 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2308 let windowing = windowing_report(true, true);
2309 let input = input_report(true);
2310
2311 let readiness = readiness_report(
2312 &platform,
2313 &portal_report(Check::fail("missing")),
2314 &accessibility,
2315 &windowing,
2316 &input,
2317 );
2318
2319 assert!(readiness.blockers.is_empty());
2320 assert!(readiness
2321 .recommended_next_step
2322 .contains("AT-SPI tree support"));
2323 assert!(readiness.recommended_next_step.contains("window targeting"));
2324 assert!(!readiness
2325 .recommended_next_step
2326 .contains("GNOME window targeting"));
2327 }
2328
2329 #[test]
2330 fn readiness_accepts_connectable_ydotool_socket_without_direct_uinput_access() {
2331 let platform = platform_report();
2332 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2333 let windowing = windowing_report(true, true);
2334 let input = input_report_parts(
2335 Check::ok("ydotool"),
2336 Check::ok("ydotoold"),
2337 Check::ok("connectable: /tmp/.ydotool_socket"),
2338 Check::fail("/dev/uinput: Permission denied"),
2339 );
2340
2341 let readiness = readiness_report(
2342 &platform,
2343 &portal_report(Check::fail("missing")),
2344 &accessibility,
2345 &windowing,
2346 &input,
2347 );
2348
2349 assert!(readiness.can_send_development_input);
2350 assert!(readiness.blockers.is_empty());
2351 }
2352
2353 #[test]
2354 fn readiness_uses_connectable_ydotool_socket_when_process_probe_fails() {
2355 let platform = platform_report();
2356 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2357 let windowing = windowing_report(true, true);
2358 let input = input_report_parts(
2359 Check::ok("ydotool"),
2360 Check::fail("ydotoold process name not found"),
2361 Check::ok("connectable: /run/user/1000/.ydotool_socket"),
2362 Check::fail("/dev/uinput: Permission denied"),
2363 );
2364 let portals = portal_report(Check::fail("missing"));
2365
2366 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2367 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2368
2369 assert!(capabilities
2370 .input
2371 .iter()
2372 .any(|backend| backend == "ydotool"));
2373 assert!(readiness.can_send_development_input);
2374 }
2375
2376 #[test]
2377 fn wayland_readiness_rejects_pointer_only_uinput_without_keyboard_backend() {
2378 let platform = platform_report();
2379 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2380 let windowing = windowing_report(true, true);
2381 let input = input_report_parts(
2382 Check::ok("ydotool"),
2383 Check::fail("ydotoold not running"),
2384 Check::fail("no connectable ydotool socket"),
2385 Check::ok("read/write: /dev/uinput"),
2386 );
2387
2388 let readiness = readiness_report(
2389 &platform,
2390 &portal_report(Check::fail("missing")),
2391 &accessibility,
2392 &windowing,
2393 &input,
2394 );
2395
2396 assert!(!readiness.can_send_development_input);
2397 assert!(readiness
2398 .blockers
2399 .iter()
2400 .any(|blocker| blocker.contains("absolute pointer input")));
2401 assert!(readiness
2402 .recommended_next_step
2403 .contains("keyboard-capable input backend"));
2404 }
2405
2406 #[test]
2407 fn x11_readiness_rejects_pointer_only_uinput_without_keyboard_backend() {
2408 let mut platform = platform_report();
2409 platform.xdg_session_type = Some("x11".to_string());
2410 platform.wayland_display = None;
2411 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2412 let windowing = windowing_report(true, true);
2413 let input = input_report_parts(
2414 Check::fail("missing ydotool"),
2415 Check::fail("ydotoold not running"),
2416 Check::fail("no connectable ydotool socket"),
2417 Check::ok("read/write: /dev/uinput"),
2418 );
2419
2420 let readiness = readiness_report(
2421 &platform,
2422 &portal_report(Check::fail("missing")),
2423 &accessibility,
2424 &windowing,
2425 &input,
2426 );
2427
2428 assert!(!readiness.can_send_development_input);
2429 }
2430
2431 #[test]
2432 fn readiness_accepts_remote_desktop_portal_without_local_input_backend() {
2433 let platform = platform_report();
2434 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2435 let windowing = windowing_report(true, true);
2436 let input = input_report_parts(
2437 Check::fail("missing ydotool"),
2438 Check::fail("ydotoold not running"),
2439 Check::fail("no connectable ydotool socket"),
2440 Check::fail("/dev/uinput: Permission denied"),
2441 );
2442
2443 let readiness = readiness_report(
2444 &platform,
2445 &portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop")),
2446 &accessibility,
2447 &windowing,
2448 &input,
2449 );
2450
2451 assert!(readiness.can_send_development_input);
2452 assert!(readiness.blockers.is_empty());
2453 }
2454
2455 #[test]
2456 fn readiness_rejects_inaccessible_ydotool_paths() {
2457 let platform = platform_report();
2458 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2459 let windowing = windowing_report(true, true);
2460 let input = input_report_parts(
2461 Check::ok("ydotool"),
2462 Check::ok("ydotoold"),
2463 Check::fail("/tmp/.ydotool_socket: Permission denied"),
2464 Check::fail("/dev/uinput: Permission denied"),
2465 );
2466
2467 let readiness = readiness_report(
2468 &platform,
2469 &portal_report(Check::fail("missing")),
2470 &accessibility,
2471 &windowing,
2472 &input,
2473 );
2474
2475 assert!(!readiness.can_send_development_input);
2476 assert!(readiness
2477 .recommended_next_step
2478 .contains("Enable a keyboard-capable input backend"));
2479 assert!(readiness
2480 .blockers
2481 .iter()
2482 .any(|blocker| blocker.contains("Development keyboard input is unavailable")));
2483 }
2484
2485 #[test]
2486 fn capability_map_rejects_incompatible_ydotool_with_live_daemon() {
2487 let platform = platform_report();
2488 let portals = portal_report(Check::fail("missing"));
2489 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2490 let windowing = windowing_report(true, true);
2491 let input = input_report_parts(
2492 Check::fail("unsupported legacy ydotool CLI"),
2493 Check::ok("ydotoold"),
2494 Check::ok("connectable socket"),
2495 Check::fail("/dev/uinput: Permission denied"),
2496 );
2497
2498 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2499 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2500
2501 assert!(!capabilities
2502 .input
2503 .iter()
2504 .any(|backend| backend == "ydotool"));
2505 assert!(!readiness.can_send_development_input);
2506 }
2507
2508 #[test]
2509 fn ydotool_socket_check_rejects_legacy_stream_socket() {
2510 let dir = std::env::temp_dir().join(format!(
2511 "computer-use-linux-diagnostics-{}",
2512 std::process::id()
2513 ));
2514 let _ = std::fs::remove_dir_all(&dir);
2515 std::fs::create_dir_all(&dir).expect("create temp diagnostics dir");
2516 let socket = dir.join("ydotool.sock");
2517 let listener =
2518 std::os::unix::net::UnixListener::bind(&socket).expect("bind temp diagnostics socket");
2519
2520 let check = socket_connect_check(&socket);
2521
2522 assert!(!check.ok, "{check:?}");
2523 drop(listener);
2524 let _ = std::fs::remove_dir_all(&dir);
2525 }
2526
2527 #[test]
2528 fn ydotool_socket_check_accepts_datagram_socket() {
2529 let dir = std::env::temp_dir().join(format!(
2530 "computer-use-linux-diagnostics-dgram-{}",
2531 std::process::id()
2532 ));
2533 let _ = std::fs::remove_dir_all(&dir);
2534 std::fs::create_dir_all(&dir).expect("create temp diagnostics dir");
2535 let socket = dir.join("ydotool.sock");
2536 let datagram =
2537 std::os::unix::net::UnixDatagram::bind(&socket).expect("bind temp datagram socket");
2538
2539 let check = socket_connect_check(&socket);
2540
2541 assert!(check.ok, "{check:?}");
2542 drop(datagram);
2543 let _ = std::fs::remove_dir_all(&dir);
2544 }
2545
2546 #[test]
2547 fn readiness_reports_cosmic_window_blocker_on_cosmic() {
2548 let mut platform = platform_report();
2549 platform.xdg_current_desktop = Some("COSMIC".to_string());
2550 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2551 let windowing = windowing_report(false, false);
2552 let input = input_report(true);
2553
2554 let readiness = readiness_report(
2555 &platform,
2556 &portal_report(Check::fail("missing")),
2557 &accessibility,
2558 &windowing,
2559 &input,
2560 );
2561
2562 assert!(readiness
2563 .blockers
2564 .iter()
2565 .any(|blocker| blocker.contains("COSMIC Wayland window introspection")));
2566 }
2567}