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