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 let (sender, receiver) = std::sync::mpsc::channel();
732 std::thread::spawn(move || {
733 let result = crate::x11_display::X11Display::connect().map(|display| display.describe());
734 let _ = sender.send(result);
735 });
736 match receiver.recv_timeout(crate::x11_display::X11_QUERY_TIMEOUT) {
737 Ok(Ok(detail)) => Check::ok(detail),
738 Ok(Err(error)) => Check::fail(format!("{error:#}")),
739 Err(_) => Check::fail("X server did not answer within 2s"),
740 }
741}
742
743fn portal_report() -> (PortalReport, Check) {
744 let (remote_desktop, remote_desktop_keyboard, screencast) = remote_desktop_portal_checks();
745 (
746 PortalReport {
747 desktop_portal: bus_name_check("org.freedesktop.portal.Desktop"),
748 remote_desktop,
749 screencast,
750 screenshot: portal_interface_methods_check(
751 "org.freedesktop.portal.Screenshot",
752 SCREENSHOT_PORTAL_METHODS,
753 ),
754 input_capture: portal_interface_methods_check(
755 "org.freedesktop.portal.InputCapture",
756 INPUT_CAPTURE_PORTAL_METHODS,
757 ),
758 mutter_remote_desktop: bus_name_check("org.gnome.Mutter.RemoteDesktop"),
759 mutter_screencast: bus_name_check("org.gnome.Mutter.ScreenCast"),
760 },
761 remote_desktop_keyboard,
762 )
763}
764
765fn accessibility_report() -> AccessibilityReport {
766 AccessibilityReport {
767 at_spi_bus: atspi_bus_address_check(),
768 toolkit_accessibility: command_check_with_session_bus(
769 "gsettings",
770 &[
771 "get",
772 "org.gnome.desktop.interface",
773 "toolkit-accessibility",
774 ],
775 ),
776 at_spi_enabled: atspi_status_property_check("IsEnabled"),
777 screen_reader_enabled: atspi_status_property_check("ScreenReaderEnabled"),
778 }
779}
780
781fn windowing_report(platform: &PlatformReport) -> WindowingReport {
782 let probes = registry::probe_backends();
783 let backend_check = |id: &str| {
784 probes
785 .iter()
786 .find(|probe| probe.id == id)
787 .map(check_from_backend_probe)
788 .unwrap_or_else(|| Check::fail("backend probe did not run"))
789 };
790 let gnome_shell_introspect = backend_check(GNOME_SHELL_INTROSPECT_BACKEND);
791 let computer_use_linux_gnome_shell_extension = backend_check(GNOME_SHELL_EXTENSION_BACKEND);
792 let cosmic_helper = backend_check(COSMIC_WAYLAND_BACKEND);
793 let kwin = backend_check(KWIN_BACKEND);
794 let hyprland = backend_check(HYPRLAND_BACKEND);
795 let niri = backend_check(NIRI_BACKEND);
796 let backends = probes
797 .iter()
798 .map(|probe| (probe.id.to_string(), check_from_backend_probe(probe)))
799 .collect::<BTreeMap<_, _>>();
800 let can_list_windows = probes.iter().any(|probe| probe.can_list_windows);
801 let can_focus_apps = probes.iter().any(|probe| probe.can_focus_apps);
802 let can_focus_windows = probes.iter().any(|probe| probe.can_focus_windows);
803 let note = if can_list_windows {
804 if !can_focus_windows {
805 "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)."
806 } else if cosmic_helper.ok && is_cosmic_wayland_platform(platform) {
807 "A COSMIC Wayland window backend is available for list_windows, focused_window, and targeted input verification."
808 } else if kwin.ok {
809 "A KWin/Plasma window backend is available for list_windows, focused_window, and targeted input verification."
810 } else if hyprland.ok {
811 "A Hyprland window backend is available for list_windows, focused_window, and targeted input verification."
812 } else if niri.ok {
813 "A niri window backend is available for list_windows, focused_window, and targeted input verification."
814 } else {
815 "A window listing backend is available for list_windows, focused_window, and targeted input verification."
816 }
817 } else {
818 "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."
819 }
820 .to_string();
821
822 WindowingReport {
823 gnome_shell_introspect,
824 computer_use_linux_gnome_shell_extension,
825 cosmic_helper,
826 kwin,
827 hyprland,
828 niri,
829 backends,
830 can_list_windows,
831 can_focus_apps,
832 can_focus_windows,
833 note,
834 }
835}
836
837fn check_from_backend_probe(probe: ®istry::BackendProbe) -> Check {
838 if probe.ok {
839 Check::ok(probe.detail.clone())
840 } else {
841 Check::fail(probe.detail.clone())
842 }
843}
844
845fn input_report() -> InputReport {
846 InputReport {
847 ydotool: match ydotool::ensure_supported() {
848 Ok(support) => Check::ok(support.detail),
849 Err(detail) => Check::fail(detail),
850 },
851 ydotoold: process_check("ydotoold"),
852 ydotool_socket: ydotool_socket_check(),
853 uinput: read_write_path_check(Path::new("/dev/uinput")),
854 xdotool: command_path_check("xdotool"),
855 wtype: command_path_check("wtype"),
856 }
857}
858
859#[cfg(test)]
860fn readiness_report(
861 platform: &PlatformReport,
862 portals: &PortalReport,
863 accessibility: &AccessibilityReport,
864 windowing: &WindowingReport,
865 input: &InputReport,
866) -> ReadinessReport {
867 readiness_report_with_portal_keyboard(
868 platform,
869 &portals.remote_desktop,
870 accessibility,
871 windowing,
872 input,
873 !screenshot_backends(platform, portals).is_empty(),
874 )
875}
876
877fn screenshot_backends(platform: &PlatformReport, portals: &PortalReport) -> Vec<String> {
880 let mut backends = Vec::new();
881 if platform.gnome_shell_version.ok {
882 backends.push("gnome_shell".to_string());
883 }
884 if portals.screenshot.ok {
885 backends.push("portal".to_string());
886 }
887 if platform.x11_display.ok {
888 backends.push("x11".to_string());
889 }
890 if platform.gnome_screenshot.ok {
892 backends.push("gnome_screenshot".to_string());
893 }
894 backends
895}
896
897fn readiness_report_with_portal_keyboard(
898 platform: &PlatformReport,
899 remote_desktop_keyboard: &Check,
900 accessibility: &AccessibilityReport,
901 windowing: &WindowingReport,
902 input: &InputReport,
903 can_capture_screenshots: bool,
904) -> ReadinessReport {
905 let mut blockers = Vec::new();
906 let can_build_accessibility_tree = can_build_accessibility_tree(accessibility);
907 let can_query_windows = windowing.can_list_windows;
908 let can_focus_apps = windowing.can_focus_apps;
909 let can_focus_windows = windowing.can_focus_windows;
910 let can_send_development_input =
911 can_send_development_input(platform, remote_desktop_keyboard, input);
912
913 if !can_build_accessibility_tree {
914 blockers.push(
915 "AT-SPI accessibility is disabled; enable org.a11y.Status IsEnabled or org.gnome.desktop.interface toolkit-accessibility for tree extraction."
916 .to_string(),
917 );
918 }
919
920 if !can_query_windows {
921 blockers.push(if is_cosmic_wayland_platform(platform) {
922 "COSMIC Wayland window introspection is unavailable; targeted window focus and verification will be disabled.".to_string()
923 } else {
924 "Window introspection is unavailable; targeted window focus and verification will be disabled."
925 .to_string()
926 });
927 }
928
929 if can_query_windows && !can_focus_windows {
930 blockers.push(
931 "Exact window activation is unavailable; app-level focus may work, but window_id/title/terminal-targeted input cannot be verified."
932 .to_string(),
933 );
934 }
935
936 if !can_send_development_input {
937 blockers.push(
938 "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."
939 .to_string(),
940 );
941 }
942
943 if !can_capture_screenshots {
944 blockers.push(
945 "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."
946 .to_string(),
947 );
948 }
949
950 let recommended_next_step = if !can_build_accessibility_tree {
951 "Run setup_accessibility to enable AT-SPI accessibility before element-aware actions."
952 .to_string()
953 } else if !can_query_windows {
954 format!(
955 "Enable a supported window backend before using targeted keyboard input: {}",
956 registry::descriptors()
957 .iter()
958 .map(|descriptor| descriptor.missing_hint)
959 .collect::<Vec<_>>()
960 .join(" ")
961 )
962 } else if !can_focus_windows {
963 "Enable an exact-focus window backend before using window_id, title, or terminal-targeted input.".to_string()
964 } else if !can_send_development_input {
965 "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."
966 .to_string()
967 } else if !can_capture_screenshots {
968 "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."
969 .to_string()
970 } else {
971 "Computer Use is ready: AT-SPI tree support, window targeting, and a Linux input backend are available."
972 .to_string()
973 };
974
975 ReadinessReport {
976 can_register_mcp_tools: true,
977 can_build_accessibility_tree,
978 can_query_windows,
979 can_focus_apps,
980 can_focus_windows,
981 can_send_development_input,
982 can_capture_screenshots,
983 recommended_next_step,
984 blockers,
985 }
986}
987
988fn can_send_development_input(
989 platform: &PlatformReport,
990 remote_desktop_keyboard: &Check,
991 input: &InputReport,
992) -> bool {
993 let force_ydotool = env_flag_enabled_any(FORCE_YDOTOOL_KEYBOARD_ENV_KEYS);
994 let force_xdotool = env_flag_enabled_any(FORCE_XDOTOOL_KEYBOARD_ENV_KEYS);
995 portal_keyboard_input_available(platform, remote_desktop_keyboard)
996 || platform_is_wayland(platform)
997 && wtype_compatible_wayland_desktop(platform.xdg_current_desktop.as_deref())
998 && input.wtype.ok
999 && !force_ydotool
1000 || should_advertise_xdotool(platform, input, force_ydotool, force_xdotool)
1001 || input.ydotool.ok && input.ydotool_socket.ok
1002}
1003
1004fn portal_pointer_input_available(platform: &PlatformReport, portals: &PortalReport) -> bool {
1005 platform_is_wayland(platform) && portals.remote_desktop.ok
1006}
1007
1008fn portal_keyboard_input_available(
1009 platform: &PlatformReport,
1010 remote_desktop_keyboard: &Check,
1011) -> bool {
1012 platform_is_wayland(platform) && remote_desktop_keyboard.ok
1013}
1014
1015fn is_cosmic_wayland_platform(platform: &PlatformReport) -> bool {
1016 platform
1017 .xdg_current_desktop
1018 .as_deref()
1019 .is_some_and(|desktop| desktop.to_ascii_lowercase().contains("cosmic"))
1020 && platform.xdg_session_type.as_deref() == Some("wayland")
1021}
1022
1023fn can_build_accessibility_tree(accessibility: &AccessibilityReport) -> bool {
1024 accessibility.at_spi_bus.ok
1025 && (check_detail_contains_true(&accessibility.at_spi_enabled)
1026 || check_detail_contains_true(&accessibility.toolkit_accessibility))
1027}
1028
1029fn check_detail_contains_true(check: &Check) -> bool {
1030 check.ok && check.detail.to_ascii_lowercase().contains("true")
1031}
1032
1033fn env_var(key: &str) -> Option<String> {
1034 env::var(key).ok().filter(|value| !value.trim().is_empty())
1035}
1036
1037fn xdg_runtime_dir() -> Option<PathBuf> {
1038 if let Some(value) = env_var("XDG_RUNTIME_DIR") {
1039 return Some(PathBuf::from(value));
1040 }
1041 user_id().map(|uid| PathBuf::from(format!("/run/user/{uid}")))
1042}
1043
1044fn dbus_session_address() -> Option<String> {
1045 if let Some(value) = env_var("DBUS_SESSION_BUS_ADDRESS") {
1046 return Some(value);
1047 }
1048 xdg_runtime_dir()
1049 .map(|runtime| format!("unix:path={}", runtime.join("bus").display()))
1050 .filter(|address| {
1051 address
1052 .strip_prefix("unix:path=")
1053 .is_some_and(|p| Path::new(p).exists())
1054 })
1055}
1056
1057fn ydotool_socket_candidates() -> Vec<PathBuf> {
1058 let mut candidates = Vec::new();
1059 if let Some(value) = env_var("YDOTOOL_SOCKET") {
1060 candidates.push(PathBuf::from(value));
1061 }
1062
1063 if let Some(runtime_socket) = xdg_runtime_dir().map(|runtime| runtime.join(".ydotool_socket")) {
1064 candidates.push(runtime_socket);
1065 }
1066 candidates.push(PathBuf::from("/tmp/.ydotool_socket"));
1067 candidates
1068}
1069
1070fn ydotool_socket_check() -> Check {
1071 let mut checked = Vec::new();
1072 for candidate in ydotool_socket_candidates() {
1073 match socket_connect_result(&candidate) {
1074 Ok(()) => return Check::ok(format!("connectable: {}", candidate.display())),
1075 Err(detail) => checked.push(detail),
1076 }
1077 }
1078
1079 Check::fail(format!(
1080 "no connectable ydotool socket ({})",
1081 checked.join("; ")
1082 ))
1083}
1084
1085fn user_id() -> Option<String> {
1086 let output = Command::new("id").arg("-u").output().ok()?;
1087 output
1088 .status
1089 .success()
1090 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
1091 .filter(|value| !value.is_empty())
1092}
1093
1094fn command_path_check(command: &str) -> Check {
1095 command_check("sh", &["-c", &format!("command -v {command}")])
1096}
1097
1098fn platform_is_wayland(platform: &PlatformReport) -> bool {
1099 match platform.xdg_session_type.as_deref() {
1100 Some(value) => value.eq_ignore_ascii_case("wayland"),
1101 None => platform
1102 .wayland_display
1103 .as_deref()
1104 .is_some_and(|display| !display.trim().is_empty()),
1105 }
1106}
1107
1108pub(crate) fn wtype_compatible_wayland_desktop(desktop: Option<&str>) -> bool {
1109 desktop.is_none_or(|desktop| {
1110 let desktop = desktop.to_ascii_lowercase();
1111 !["gnome", "kde", "plasma", "cosmic"]
1112 .iter()
1113 .any(|known_incompatible| desktop.contains(known_incompatible))
1114 })
1115}
1116
1117fn should_advertise_xdotool(
1118 platform: &PlatformReport,
1119 input: &InputReport,
1120 force_ydotool: bool,
1121 force_xdotool: bool,
1122) -> bool {
1123 !force_ydotool
1124 && input.xdotool.ok
1125 && platform
1126 .display
1127 .as_deref()
1128 .is_some_and(|display| !display.trim().is_empty())
1129 && (force_xdotool || !platform_is_wayland(platform))
1130}
1131
1132fn env_flag_enabled_any(keys: &[&str]) -> bool {
1133 keys.iter()
1134 .any(|key| env::var(key).ok().as_deref() == Some("1"))
1135}
1136
1137fn force_portal_for_all_input(
1138 force_portal_pointer: bool,
1139 force_portal_keyboard: bool,
1140 force_ydotool_pointer: bool,
1141 force_ydotool_keyboard: bool,
1142) -> bool {
1143 force_portal_pointer
1144 && force_portal_keyboard
1145 && !force_ydotool_pointer
1146 && !force_ydotool_keyboard
1147}
1148
1149fn process_check(process_name: &str) -> Check {
1150 command_check("pgrep", &["-a", process_name])
1151}
1152
1153#[cfg(test)]
1154fn socket_connect_check(path: &Path) -> Check {
1155 match socket_connect_result(path) {
1156 Ok(()) => Check::ok(format!("connectable: {}", path.display())),
1157 Err(detail) => Check::fail(detail),
1158 }
1159}
1160
1161fn socket_connect_result(path: &Path) -> std::result::Result<(), String> {
1162 if !path.exists() {
1163 return Err(format!("missing: {}", path.display()));
1164 }
1165
1166 UnixDatagram::unbound()
1167 .and_then(|socket| socket.connect(path))
1168 .map_err(|error| format!("{}: datagram: {error}", path.display()))
1169}
1170
1171fn read_write_path_check(path: &Path) -> Check {
1172 if !path.exists() {
1173 return Check::fail(format!("missing: {}", path.display()));
1174 }
1175
1176 match OpenOptions::new().read(true).write(true).open(path) {
1177 Ok(_) => Check::ok(format!("read/write: {}", path.display())),
1178 Err(error) => Check::fail(format!("{}: {error}", path.display())),
1179 }
1180}
1181
1182fn bus_name_check(name: &str) -> Check {
1183 command_check_with_session_bus("busctl", &["--user", "status", name])
1184}
1185
1186fn portal_interface_check(interface: &str) -> Check {
1187 command_check_with_session_bus(
1188 "busctl",
1189 &[
1190 "--user",
1191 "introspect",
1192 "org.freedesktop.portal.Desktop",
1193 "/org/freedesktop/portal/desktop",
1194 interface,
1195 ],
1196 )
1197}
1198
1199fn portal_interface_methods_check(interface: &str, methods: &[&str]) -> Check {
1201 require_busctl_methods(portal_interface_check(interface), interface, methods)
1202}
1203
1204fn require_busctl_methods(introspection: Check, interface: &str, methods: &[&str]) -> Check {
1205 if !introspection.ok {
1206 return introspection;
1207 }
1208 let missing = missing_busctl_methods(&introspection, methods);
1209 if missing.is_empty() {
1210 return introspection;
1211 }
1212 if !busctl_introspection_has_members(&introspection.detail) {
1213 return Check::fail(format!(
1214 "{interface} is not exported by the portal (introspection listed no members)"
1215 ));
1216 }
1217 Check::fail(format!(
1218 "{interface} is missing required methods: {}",
1219 missing.join(", ")
1220 ))
1221}
1222
1223fn busctl_introspection_has_members(detail: &str) -> bool {
1224 detail
1225 .lines()
1226 .any(|line| line.trim_start().starts_with('.'))
1227}
1228
1229fn remote_desktop_portal_checks() -> (Check, Check, Check) {
1230 let introspection = portal_interface_check("org.freedesktop.portal.RemoteDesktop");
1231 let screencast = portal_interface_methods_check(
1232 "org.freedesktop.portal.ScreenCast",
1233 SCREENCAST_PORTAL_METHODS,
1234 );
1235 if !introspection.ok {
1236 return (introspection.clone(), introspection, screencast);
1237 }
1238
1239 let available_device_types = command_check_with_session_bus(
1240 "busctl",
1241 &[
1242 "--user",
1243 "get-property",
1244 "org.freedesktop.portal.Desktop",
1245 "/org/freedesktop/portal/desktop",
1246 "org.freedesktop.portal.RemoteDesktop",
1247 "AvailableDeviceTypes",
1248 ],
1249 );
1250 let available_source_types = command_check_with_session_bus(
1251 "busctl",
1252 &[
1253 "--user",
1254 "get-property",
1255 "org.freedesktop.portal.Desktop",
1256 "/org/freedesktop/portal/desktop",
1257 "org.freedesktop.portal.ScreenCast",
1258 "AvailableSourceTypes",
1259 ],
1260 );
1261 let pointer = remote_desktop_pointer_check_from(
1262 &introspection,
1263 &screencast,
1264 &available_device_types,
1265 &available_source_types,
1266 );
1267 let keyboard = remote_desktop_keyboard_check_from(&introspection, &available_device_types);
1268 (pointer, keyboard, screencast)
1269}
1270
1271fn remote_desktop_pointer_check_from(
1272 introspection: &Check,
1273 screencast: &Check,
1274 available_device_types: &Check,
1275 available_source_types: &Check,
1276) -> Check {
1277 if !introspection.ok {
1278 return Check::fail(introspection.detail.clone());
1279 }
1280 let missing_methods = missing_busctl_methods(introspection, REMOTE_DESKTOP_POINTER_METHODS);
1281 if !missing_methods.is_empty() {
1282 return Check::fail(format!(
1283 "RemoteDesktop interface is missing required pointer methods: {}",
1284 missing_methods.join(", ")
1285 ));
1286 }
1287
1288 if !screencast.ok {
1289 return Check::fail(format!(
1290 "ScreenCast interface is unavailable for portal pointer input: {}",
1291 screencast.detail
1292 ));
1293 }
1294 let missing_screencast_methods = missing_busctl_methods(screencast, SCREENCAST_POINTER_METHODS);
1295 if !missing_screencast_methods.is_empty() {
1296 return Check::fail(format!(
1297 "ScreenCast interface is missing required pointer methods: {}",
1298 missing_screencast_methods.join(", ")
1299 ));
1300 }
1301
1302 let device_types = match remote_desktop_device_types(available_device_types) {
1303 Ok(device_types) => device_types,
1304 Err(detail) => return Check::fail(detail),
1305 };
1306 if device_types & PORTAL_DEVICE_POINTER == 0 {
1307 return Check::fail(format!(
1308 "RemoteDesktop AvailableDeviceTypes={device_types} does not include pointer input"
1309 ));
1310 }
1311
1312 let source_types = match screencast_source_types(available_source_types) {
1313 Ok(source_types) => source_types,
1314 Err(detail) => return Check::fail(detail),
1315 };
1316 if source_types & PORTAL_SOURCE_MONITOR == 0 {
1317 return Check::fail(format!(
1318 "ScreenCast AvailableSourceTypes={source_types} does not include monitor sources"
1319 ));
1320 }
1321
1322 Check::ok(format!(
1323 "pointer-capable RemoteDesktop portal (AvailableDeviceTypes={device_types}, AvailableSourceTypes={source_types})"
1324 ))
1325}
1326
1327fn remote_desktop_keyboard_check_from(
1328 introspection: &Check,
1329 available_device_types: &Check,
1330) -> Check {
1331 if !introspection.ok {
1332 return Check::fail(introspection.detail.clone());
1333 }
1334
1335 let missing_methods = missing_busctl_methods(introspection, REMOTE_DESKTOP_KEYBOARD_METHODS);
1336 if !missing_methods.is_empty() {
1337 return Check::fail(format!(
1338 "RemoteDesktop interface is missing required keyboard methods: {}",
1339 missing_methods.join(", ")
1340 ));
1341 }
1342
1343 let device_types = match remote_desktop_device_types(available_device_types) {
1344 Ok(device_types) => device_types,
1345 Err(detail) => return Check::fail(detail),
1346 };
1347 if device_types & PORTAL_DEVICE_KEYBOARD == 0 {
1348 return Check::fail(format!(
1349 "RemoteDesktop AvailableDeviceTypes={device_types} does not include keyboard input"
1350 ));
1351 }
1352
1353 Check::ok(format!(
1354 "keyboard-capable RemoteDesktop portal (AvailableDeviceTypes={device_types})"
1355 ))
1356}
1357
1358fn missing_busctl_methods<'a>(introspection: &Check, methods: &'a [&'a str]) -> Vec<&'a str> {
1359 methods
1360 .iter()
1361 .copied()
1362 .filter(|method| !busctl_introspection_has_method(&introspection.detail, method))
1363 .collect()
1364}
1365
1366fn remote_desktop_device_types(available_device_types: &Check) -> Result<u32, String> {
1367 if !available_device_types.ok {
1368 return Err(format!(
1369 "RemoteDesktop AvailableDeviceTypes is unavailable: {}",
1370 available_device_types.detail
1371 ));
1372 }
1373 parse_busctl_u32_property(&available_device_types.detail).ok_or_else(|| {
1374 format!(
1375 "RemoteDesktop AvailableDeviceTypes has an unexpected value: {}",
1376 available_device_types.detail
1377 )
1378 })
1379}
1380
1381fn screencast_source_types(available_source_types: &Check) -> Result<u32, String> {
1382 if !available_source_types.ok {
1383 return Err(format!(
1384 "ScreenCast AvailableSourceTypes is unavailable: {}",
1385 available_source_types.detail
1386 ));
1387 }
1388 parse_busctl_u32_property(&available_source_types.detail).ok_or_else(|| {
1389 format!(
1390 "ScreenCast AvailableSourceTypes has an unexpected value: {}",
1391 available_source_types.detail
1392 )
1393 })
1394}
1395
1396fn busctl_introspection_has_method(detail: &str, method: &str) -> bool {
1397 detail.lines().any(|line| {
1398 let mut fields = line.split_whitespace();
1399 fields
1400 .next()
1401 .is_some_and(|name| name.trim_start_matches('.') == method)
1402 && fields.next() == Some("method")
1403 })
1404}
1405
1406fn parse_busctl_u32_property(detail: &str) -> Option<u32> {
1407 let mut fields = detail.split_whitespace();
1408 if fields.next()? != "u" {
1409 return None;
1410 }
1411 let value = fields.next()?;
1412 value
1413 .strip_prefix("0x")
1414 .map(|hex| u32::from_str_radix(hex, 16).ok())
1415 .unwrap_or_else(|| value.parse().ok())
1416}
1417
1418fn atspi_bus_address_check() -> Check {
1419 let busctl = command_check_with_session_bus(
1420 "busctl",
1421 &[
1422 "--user",
1423 "call",
1424 "org.a11y.Bus",
1425 "/org/a11y/bus",
1426 "org.a11y.Bus",
1427 "GetAddress",
1428 ],
1429 );
1430 if busctl.ok {
1431 return busctl;
1432 }
1433
1434 gdbus_call_check(
1435 "org.a11y.Bus",
1436 "/org/a11y/bus",
1437 "org.a11y.Bus.GetAddress",
1438 &[],
1439 )
1440}
1441
1442fn atspi_status_property_check(property: &str) -> Check {
1443 let busctl = command_check_with_session_bus(
1444 "busctl",
1445 &[
1446 "--user",
1447 "get-property",
1448 "org.a11y.Bus",
1449 "/org/a11y/bus",
1450 "org.a11y.Status",
1451 property,
1452 ],
1453 );
1454 if busctl.ok {
1455 return busctl;
1456 }
1457
1458 gdbus_call_check(
1459 "org.a11y.Bus",
1460 "/org/a11y/bus",
1461 "org.freedesktop.DBus.Properties.Get",
1462 &["org.a11y.Status", property],
1463 )
1464}
1465
1466fn gdbus_call_check(destination: &str, object_path: &str, method: &str, args: &[&str]) -> Check {
1467 let mut command_args = vec![
1468 "call",
1469 "--session",
1470 "--dest",
1471 destination,
1472 "--object-path",
1473 object_path,
1474 "--method",
1475 method,
1476 ];
1477 command_args.extend_from_slice(args);
1478 command_check_with_session_bus("gdbus", &command_args)
1479}
1480
1481fn command_check(command: &str, args: &[&str]) -> Check {
1482 run_command(command, args, false)
1483}
1484
1485fn command_check_with_session_bus(command: &str, args: &[&str]) -> Check {
1486 run_command(command, args, true)
1487}
1488
1489fn run_command(command: &str, args: &[&str], with_session_bus: bool) -> Check {
1490 let mut cmd = Command::new(command);
1491 cmd.args(args);
1492
1493 if with_session_bus {
1494 if let Some(address) = dbus_session_address() {
1495 cmd.env("DBUS_SESSION_BUS_ADDRESS", address);
1496 }
1497 if let Some(runtime) = xdg_runtime_dir() {
1498 cmd.env("XDG_RUNTIME_DIR", runtime);
1499 }
1500 }
1501
1502 match cmd.output() {
1503 Ok(output) if output.status.success() => {
1504 let detail = String::from_utf8_lossy(&output.stdout).trim().to_string();
1505 Check::ok(if detail.is_empty() {
1506 "ok".into()
1507 } else {
1508 detail
1509 })
1510 }
1511 Ok(output) => {
1512 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1513 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
1514 let detail = if !stderr.is_empty() { stderr } else { stdout };
1515 Check::fail(if detail.is_empty() {
1516 format!("exit status {}", output.status)
1517 } else {
1518 detail
1519 })
1520 }
1521 Err(error) => Check::fail(error.to_string()),
1522 }
1523}
1524
1525#[cfg(test)]
1526mod tests {
1527 use super::*;
1528
1529 #[test]
1530 fn setup_repairs_saved_key_even_when_runtime_is_ready() {
1531 let mut before = accessibility_report(Check::ok("bus"), Check::ok("false"));
1532 before.at_spi_enabled = Check::ok("b true");
1533 let mut calls = Vec::new();
1534 let result = enable_accessibility(&before, |program, args| {
1535 calls.push((program.to_string(), args.join(" ")));
1536 Check::ok(if args[0] == "get" { "true" } else { "" })
1537 });
1538 assert!(result.ok);
1539 assert_eq!(
1540 calls,
1541 vec![
1542 (
1543 "gsettings".into(),
1544 "set org.gnome.desktop.interface toolkit-accessibility true".into()
1545 ),
1546 (
1547 "gsettings".into(),
1548 "get org.gnome.desktop.interface toolkit-accessibility".into()
1549 ),
1550 ]
1551 );
1552 }
1553
1554 #[test]
1555 fn setup_does_not_claim_saved_success_from_command_exit_status() {
1556 let before = accessibility_report(Check::ok("bus"), Check::ok("false"));
1557 let mut calls = Vec::new();
1558 let result = enable_accessibility(&before, |program, args| {
1559 calls.push(program.to_string());
1560 Check::ok(if args[0] == "get" {
1561 "false"
1562 } else {
1563 "exit status 0"
1564 })
1565 });
1566 assert!(!result.ok);
1567 assert!(result.detail.contains("not verified"));
1568 assert_eq!(calls, ["gsettings", "gsettings", "busctl"]);
1569 }
1570
1571 #[test]
1572 fn setup_preserves_enabled_settings_without_writing() {
1573 let before = accessibility_report(Check::ok("bus"), Check::ok("true"));
1574 let result = enable_accessibility(&before, |_, _| panic!("already enabled"));
1575 assert!(result.ok);
1576 }
1577
1578 fn platform_report() -> PlatformReport {
1579 PlatformReport {
1580 os: "linux".to_string(),
1581 arch: "x86_64".to_string(),
1582 desktop_session: None,
1583 xdg_session_type: Some("wayland".to_string()),
1584 xdg_current_desktop: Some("GNOME".to_string()),
1585 wayland_display: Some("wayland-0".to_string()),
1586 display: Some(":0".to_string()),
1587 xauthority: Some("/run/user/1000/Xauthority".to_string()),
1588 dbus_session_bus_address: Some("unix:path=/run/user/1000/bus".to_string()),
1589 xdg_runtime_dir: Some("/run/user/1000".to_string()),
1590 gnome_shell_version: Check::ok("GNOME Shell 46.0"),
1591 gnome_screenshot: Check::ok("gnome-screenshot 41.0"),
1592 x11_display: Check::fail("not a native X11 session"),
1593 }
1594 }
1595
1596 fn portal_report(remote_desktop: Check) -> PortalReport {
1597 PortalReport {
1598 desktop_portal: Check::ok("ok"),
1599 remote_desktop,
1600 screencast: Check::fail("missing"),
1601 screenshot: Check::fail("missing"),
1602 input_capture: Check::fail("missing"),
1603 mutter_remote_desktop: Check::fail("missing"),
1604 mutter_screencast: Check::fail("missing"),
1605 }
1606 }
1607
1608 fn accessibility_report(
1609 at_spi_bus: Check,
1610 toolkit_accessibility: Check,
1611 ) -> AccessibilityReport {
1612 AccessibilityReport {
1613 at_spi_bus,
1614 toolkit_accessibility,
1615 at_spi_enabled: Check::fail("(<false>,)"),
1616 screen_reader_enabled: Check::fail("(<false>,)"),
1617 }
1618 }
1619
1620 fn windowing_report(can_list_windows: bool, can_focus_windows: bool) -> WindowingReport {
1621 WindowingReport {
1622 gnome_shell_introspect: if can_list_windows {
1623 Check::ok("ok")
1624 } else {
1625 Check::fail("denied")
1626 },
1627 computer_use_linux_gnome_shell_extension: if can_focus_windows {
1628 Check::ok("ok")
1629 } else {
1630 Check::fail("missing")
1631 },
1632 cosmic_helper: Check::fail("missing"),
1633 kwin: Check::fail("not a KWin session"),
1634 hyprland: Check::fail("not a Hyprland session"),
1635 niri: Check::fail("not a niri session"),
1636 backends: BTreeMap::new(),
1637 can_list_windows,
1638 can_focus_apps: true,
1639 can_focus_windows,
1640 note: String::new(),
1641 }
1642 }
1643
1644 fn input_report(can_send_input: bool) -> InputReport {
1645 let check = if can_send_input {
1646 Check::ok("ok")
1647 } else {
1648 Check::fail("missing")
1649 };
1650 input_report_parts(check.clone(), check.clone(), check.clone(), check)
1651 }
1652
1653 fn input_report_parts(
1654 ydotool: Check,
1655 ydotoold: Check,
1656 ydotool_socket: Check,
1657 uinput: Check,
1658 ) -> InputReport {
1659 InputReport {
1660 ydotool,
1661 ydotoold,
1662 ydotool_socket,
1663 uinput,
1664 xdotool: Check::fail("missing xdotool"),
1665 wtype: Check::fail("missing wtype"),
1666 }
1667 }
1668
1669 #[test]
1670 fn accessibility_tree_requires_reachable_at_spi_bus() {
1671 let report = accessibility_report(Check::fail("permission denied"), Check::ok("true"));
1672
1673 assert!(!can_build_accessibility_tree(&report));
1674 }
1675
1676 #[test]
1677 fn accessibility_tree_is_ready_when_bus_and_toolkit_are_ready() {
1678 let report = accessibility_report(
1679 Check::ok("('unix:path=/run/user/1000/at-spi/bus',)"),
1680 Check::ok("true"),
1681 );
1682
1683 assert!(can_build_accessibility_tree(&report));
1684 }
1685
1686 #[test]
1687 fn capability_map_advertises_only_a_buildable_accessibility_tree() {
1688 let platform = platform_report();
1689 let portals = portal_report(Check::fail("missing"));
1690 let windowing = windowing_report(false, false);
1691 let input = input_report(false);
1692
1693 for accessibility in [
1694 accessibility_report(Check::fail("permission denied"), Check::ok("true")),
1695 accessibility_report(
1696 Check::ok("('unix:path=/run/user/1000/at-spi/bus',)"),
1697 Check::ok("false"),
1698 ),
1699 ] {
1700 let capabilities =
1701 capability_map(&platform, &portals, &accessibility, &windowing, &input);
1702 assert!(capabilities.accessibility.is_empty());
1703 }
1704 }
1705
1706 #[test]
1707 fn parses_parent_pid_from_proc_status() {
1708 let status = "Name:\ttest\nPid:\t42\nPPid:\t7\n";
1709
1710 assert_eq!(parse_parent_pid(status), Some(7));
1711 }
1712
1713 #[test]
1714 fn parses_nul_separated_process_environment() {
1715 let environment = parse_environ(
1716 b"DISPLAY=:0\0WAYLAND_DISPLAY=wayland-0\0EMPTY=\0NO_EQUALS\0XDG_SESSION_TYPE=wayland\0",
1717 );
1718
1719 assert_eq!(environment.get("DISPLAY").map(String::as_str), Some(":0"));
1720 assert_eq!(
1721 environment.get("WAYLAND_DISPLAY").map(String::as_str),
1722 Some("wayland-0")
1723 );
1724 assert_eq!(environment.get("EMPTY").map(String::as_str), Some(""));
1725 assert!(!environment.contains_key("NO_EQUALS"));
1726 }
1727
1728 #[test]
1729 fn desktop_env_hydration_includes_xauthority() {
1730 assert!(DESKTOP_ENV_KEYS.contains(&"XAUTHORITY"));
1731 }
1732
1733 #[test]
1734 fn desktop_env_hydration_preserves_explicit_native_x11() {
1735 let current_env = HashMap::from([
1736 ("DISPLAY".to_string(), ":90".to_string()),
1737 ("XDG_SESSION_TYPE".to_string(), "x11".to_string()),
1738 ]);
1739 let host_env = HashMap::from([
1740 ("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string()),
1741 (
1742 "XDG_CURRENT_DESKTOP".to_string(),
1743 "ubuntu:GNOME".to_string(),
1744 ),
1745 ]);
1746
1747 let updates = desktop_env_hydration_updates(¤t_env, &host_env);
1748
1749 assert!(!updates.iter().any(|(key, _)| *key == "WAYLAND_DISPLAY"));
1750 assert!(updates
1751 .iter()
1752 .any(|(key, value)| { *key == "XDG_CURRENT_DESKTOP" && value == "ubuntu:GNOME" }));
1753 }
1754
1755 #[test]
1756 fn desktop_env_hydration_still_imports_wayland_for_incomplete_sessions() {
1757 let current_env = HashMap::new();
1758 let host_env = HashMap::from([("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string())]);
1759
1760 let updates = desktop_env_hydration_updates(¤t_env, &host_env);
1761
1762 assert!(updates
1763 .iter()
1764 .any(|(key, value)| *key == "WAYLAND_DISPLAY" && value == "wayland-0"));
1765 }
1766
1767 #[test]
1768 fn graphical_process_env_requires_display() {
1769 let with_display = HashMap::from([("DISPLAY".to_string(), ":0".to_string())]);
1770 let with_wayland =
1771 HashMap::from([("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string())]);
1772 let without_display = HashMap::from([("XAUTHORITY".to_string(), "/tmp/xauth".to_string())]);
1773
1774 assert!(process_env_has_graphical_display(&with_display));
1775 assert!(process_env_has_graphical_display(&with_wayland));
1776 assert!(!process_env_has_graphical_display(&without_display));
1777 }
1778
1779 #[test]
1780 fn capabilities_prefer_xdotool_before_ydotool_on_x11() {
1781 let mut platform = platform_report();
1782 platform.xdg_session_type = Some("x11".to_string());
1783 platform.wayland_display = None;
1784 platform.display = Some(":0".to_string());
1785 let input = InputReport {
1786 ydotool: Check::ok("ydotool"),
1787 ydotoold: Check::ok("ydotoold"),
1788 ydotool_socket: Check::ok("connectable"),
1789 uinput: Check::fail("missing"),
1790 xdotool: Check::ok("xdotool"),
1791 wtype: Check::fail("missing wtype"),
1792 };
1793
1794 let capabilities = capability_map(
1795 &platform,
1796 &portal_report(Check::fail("missing")),
1797 &accessibility_report(Check::fail("missing"), Check::fail("missing")),
1798 &windowing_report(false, false),
1799 &input,
1800 );
1801
1802 assert_eq!(capabilities.input, ["xdotool", "ydotool"]);
1803 assert_eq!(capabilities.preferred.input.as_deref(), Some("xdotool"));
1804 }
1805
1806 #[test]
1807 fn x11_diagnostics_ignore_portal_and_accept_xdotool() {
1808 let mut platform = platform_report();
1809 platform.xdg_session_type = Some("x11".to_string());
1810 platform.wayland_display = None;
1811 platform.display = Some(":0".to_string());
1812 let portals = portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop"));
1813 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1814 let windowing = windowing_report(true, true);
1815 let mut input = input_report(false);
1816 input.xdotool = Check::ok("xdotool");
1817
1818 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1819 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
1820
1821 assert_eq!(capabilities.input, ["xdotool"]);
1822 assert_eq!(capabilities.preferred.input.as_deref(), Some("xdotool"));
1823 assert!(readiness.can_send_development_input);
1824 }
1825
1826 #[test]
1827 fn wayland_diagnostics_advertise_wtype_without_portal_or_ydotool() {
1828 let mut platform = platform_report();
1829 platform.xdg_session_type = Some("wayland".to_string());
1830 platform.xdg_current_desktop = Some("Hyprland".to_string());
1831 platform.wayland_display = Some("wayland-0".to_string());
1832 let portals = portal_report(Check::fail("missing"));
1833 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1834 let windowing = windowing_report(true, true);
1835 let mut input = input_report(false);
1836 input.wtype = Check::ok("wtype");
1837
1838 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1839 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
1840
1841 assert_eq!(capabilities.input, ["wtype"]);
1842 assert_eq!(capabilities.preferred.input.as_deref(), Some("wtype"));
1843 assert!(readiness.can_send_development_input);
1844 }
1845
1846 #[test]
1847 fn wtype_excludes_known_incompatible_wayland_desktops() {
1848 assert!(wtype_compatible_wayland_desktop(Some("Hyprland")));
1849 assert!(wtype_compatible_wayland_desktop(Some("sway")));
1850 assert!(wtype_compatible_wayland_desktop(None));
1851 assert!(!wtype_compatible_wayland_desktop(Some("GNOME")));
1852 assert!(!wtype_compatible_wayland_desktop(Some("KDE;Plasma")));
1853 assert!(!wtype_compatible_wayland_desktop(Some("COSMIC")));
1854 }
1855
1856 #[test]
1857 fn wayland_diagnostics_prefer_ydotool_before_portal() {
1858 let platform = platform_report();
1859 let portals = portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop"));
1860 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1861 let windowing = windowing_report(true, true);
1862 let input = input_report_parts(
1863 Check::ok("ydotool"),
1864 Check::ok("ydotoold"),
1865 Check::ok("connectable"),
1866 Check::fail("missing uinput"),
1867 );
1868
1869 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1870
1871 assert_eq!(capabilities.input, ["ydotool", "portal"]);
1872 assert_eq!(capabilities.preferred.input.as_deref(), Some("ydotool"));
1873 }
1874
1875 #[test]
1876 fn portal_force_order_requires_both_input_modalities() {
1877 assert!(force_portal_for_all_input(true, true, false, false));
1878 assert!(!force_portal_for_all_input(true, false, false, false));
1879 assert!(!force_portal_for_all_input(true, true, true, false));
1880 assert!(!force_portal_for_all_input(true, true, false, true));
1881 }
1882
1883 fn remote_desktop_runtime_introspection() -> Check {
1884 Check::ok(
1885 "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n\
1886 .CreateSession method a{sv} o -\n\
1887 .SelectDevices method oa{sv} o -\n\
1888 .Start method osa{sv} o -\n\
1889 .NotifyPointerMotionAbsolute method oa{sv}udd - -\n\
1890 .NotifyPointerButton method oa{sv}iu - -\n\
1891 .NotifyPointerAxisDiscrete method oa{sv}ui - -\n\
1892 .NotifyKeyboardKeycode method ouu - -\n\
1893 .NotifyKeyboardKeysym method ouu - -\n\
1894 .AvailableDeviceTypes property u 3 emits-change",
1895 )
1896 }
1897
1898 fn screencast_runtime_introspection() -> Check {
1899 Check::ok(
1900 "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n\
1901 .SelectSources method oa{sv} o -",
1902 )
1903 }
1904
1905 #[test]
1906 fn remote_desktop_portal_rejects_header_only_introspection() {
1907 let introspection = Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS");
1908 let available_device_types = Check::ok("u 3");
1909 let pointer = remote_desktop_pointer_check_from(
1910 &introspection,
1911 &screencast_runtime_introspection(),
1912 &available_device_types,
1913 &Check::ok("u 1"),
1914 );
1915 let keyboard = remote_desktop_keyboard_check_from(&introspection, &available_device_types);
1916
1917 assert!(!pointer.ok);
1918 assert!(pointer.detail.contains("CreateSession"));
1919 assert!(pointer.detail.contains("NotifyPointerButton"));
1920 assert!(!keyboard.ok);
1921 assert!(keyboard.detail.contains("NotifyKeyboardKeysym"));
1922
1923 let input = input_report_parts(
1924 Check::ok("ydotool"),
1925 Check::ok("ydotoold"),
1926 Check::fail("no connectable ydotool socket"),
1927 Check::ok("read/write: /dev/uinput"),
1928 );
1929 let platform = platform_report();
1930 let portals = portal_report(pointer);
1931 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1932 let windowing = windowing_report(true, true);
1933 let capabilities = capability_map_with_portal_keyboard(
1934 &platform,
1935 &portals,
1936 &keyboard,
1937 &accessibility,
1938 &windowing,
1939 &input,
1940 );
1941 let readiness = readiness_report_with_portal_keyboard(
1942 &platform,
1943 &keyboard,
1944 &accessibility,
1945 &windowing,
1946 &input,
1947 true,
1948 );
1949
1950 assert!(!capabilities.input.iter().any(|backend| backend == "portal"));
1951 assert!(!readiness.can_send_development_input);
1952 }
1953
1954 #[test]
1955 fn keyboard_only_portal_remains_advertised_without_pointer_capability() {
1956 let platform = platform_report();
1957 let keyboard = remote_desktop_keyboard_check_from(
1958 &remote_desktop_runtime_introspection(),
1959 &Check::ok("u 1"),
1960 );
1961 let portals = portal_report(Check::fail(
1962 "ScreenCast AvailableSourceTypes=2 does not include monitor sources",
1963 ));
1964 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1965 let windowing = windowing_report(true, true);
1966 let input = input_report_parts(
1967 Check::fail("missing ydotool"),
1968 Check::fail("ydotoold not running"),
1969 Check::fail("no connectable ydotool socket"),
1970 Check::fail("/dev/uinput: Permission denied"),
1971 );
1972
1973 let capabilities = capability_map_with_portal_keyboard(
1974 &platform,
1975 &portals,
1976 &keyboard,
1977 &accessibility,
1978 &windowing,
1979 &input,
1980 );
1981 let readiness = readiness_report_with_portal_keyboard(
1982 &platform,
1983 &keyboard,
1984 &accessibility,
1985 &windowing,
1986 &input,
1987 true,
1988 );
1989
1990 assert!(!portals.remote_desktop.ok);
1991 assert_eq!(capabilities.input, ["portal"]);
1992 assert_eq!(capabilities.preferred.input.as_deref(), Some("portal"));
1993 assert!(readiness.can_send_development_input);
1994 }
1995
1996 #[test]
1997 fn remote_desktop_pointer_rejects_missing_screencast_contract() {
1998 let check = remote_desktop_pointer_check_from(
1999 &remote_desktop_runtime_introspection(),
2000 &Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS"),
2001 &Check::ok("u 2"),
2002 &Check::ok("u 1"),
2003 );
2004
2005 assert!(!check.ok);
2006 assert!(check.detail.contains("SelectSources"));
2007 }
2008
2009 #[test]
2010 fn remote_desktop_pointer_rejects_missing_monitor_source_type() {
2011 let check = remote_desktop_pointer_check_from(
2012 &remote_desktop_runtime_introspection(),
2013 &screencast_runtime_introspection(),
2014 &Check::ok("u 2"),
2015 &Check::ok("u 2"),
2016 );
2017
2018 assert!(!check.ok);
2019 assert!(check.detail.contains("does not include monitor sources"));
2020 }
2021
2022 #[test]
2023 fn remote_desktop_portal_rejects_missing_keyboard_device_type() {
2024 let check = remote_desktop_keyboard_check_from(
2025 &remote_desktop_runtime_introspection(),
2026 &Check::ok("u 2"),
2027 );
2028
2029 assert!(!check.ok);
2030 assert!(check.detail.contains("does not include keyboard input"));
2031 }
2032
2033 #[test]
2034 fn remote_desktop_portal_accepts_runtime_keyboard_contract() {
2035 let check = remote_desktop_keyboard_check_from(
2036 &remote_desktop_runtime_introspection(),
2037 &Check::ok("u 3"),
2038 );
2039
2040 assert!(check.ok);
2041 assert!(check.detail.contains("AvailableDeviceTypes=3"));
2042 }
2043
2044 #[test]
2045 fn pointer_only_portal_remains_advertised_without_keyboard_readiness() {
2046 let platform = platform_report();
2047 let available_device_types = Check::ok("u 2");
2048 let pointer = remote_desktop_pointer_check_from(
2049 &remote_desktop_runtime_introspection(),
2050 &screencast_runtime_introspection(),
2051 &available_device_types,
2052 &Check::ok("u 1"),
2053 );
2054 let keyboard = remote_desktop_keyboard_check_from(
2055 &remote_desktop_runtime_introspection(),
2056 &available_device_types,
2057 );
2058 let portals = portal_report(pointer);
2059 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2060 let windowing = windowing_report(true, true);
2061 let input = input_report_parts(
2062 Check::fail("missing ydotool"),
2063 Check::fail("ydotoold not running"),
2064 Check::fail("no connectable ydotool socket"),
2065 Check::fail("/dev/uinput: Permission denied"),
2066 );
2067
2068 let capabilities = capability_map_with_portal_keyboard(
2069 &platform,
2070 &portals,
2071 &keyboard,
2072 &accessibility,
2073 &windowing,
2074 &input,
2075 );
2076 let readiness = readiness_report_with_portal_keyboard(
2077 &platform,
2078 &keyboard,
2079 &accessibility_report(Check::ok("bus"), Check::ok("true")),
2080 &windowing_report(true, true),
2081 &input,
2082 true,
2083 );
2084
2085 assert!(portals.remote_desktop.ok);
2086 assert!(capabilities.input.iter().any(|backend| backend == "portal"));
2087 assert!(!readiness.can_send_development_input);
2088 }
2089
2090 #[test]
2091 fn capabilities_require_display_to_advertise_xdotool() {
2092 let mut platform = platform_report();
2093 platform.xdg_session_type = Some("x11".to_string());
2094 platform.wayland_display = None;
2095 platform.display = None;
2096 let input = InputReport {
2097 ydotool: Check::ok("ydotool"),
2098 ydotoold: Check::ok("ydotoold"),
2099 ydotool_socket: Check::ok("connectable"),
2100 uinput: Check::fail("missing"),
2101 xdotool: Check::ok("xdotool"),
2102 wtype: Check::fail("missing wtype"),
2103 };
2104
2105 let capabilities = capability_map(
2106 &platform,
2107 &portal_report(Check::fail("missing")),
2108 &accessibility_report(Check::fail("missing"), Check::fail("missing")),
2109 &windowing_report(false, false),
2110 &input,
2111 );
2112
2113 assert_eq!(capabilities.input, ["ydotool"]);
2114 assert_eq!(capabilities.preferred.input.as_deref(), Some("ydotool"));
2115 }
2116
2117 #[test]
2118 fn xdotool_diagnostics_force_precedence_matches_runtime() {
2119 let mut platform = platform_report();
2120 platform.xdg_session_type = Some("wayland".to_string());
2121 platform.display = Some(":0".to_string());
2122 let mut input = input_report(false);
2123 input.xdotool = Check::ok("xdotool");
2124
2125 assert!(should_advertise_xdotool(&platform, &input, false, true));
2126 assert!(!should_advertise_xdotool(&platform, &input, true, true));
2127 }
2128
2129 #[test]
2130 fn parses_systemd_show_environment_output() {
2131 let environment = parse_line_environment(
2132 b"DISPLAY=:0\nHYPRLAND_INSTANCE_SIGNATURE=abc\nNO_EQUALS\nYDOTOOL_SOCKET=/run/ydotoold/socket\n",
2133 );
2134
2135 assert_eq!(environment.get("DISPLAY").map(String::as_str), Some(":0"));
2136 assert_eq!(
2137 environment
2138 .get("HYPRLAND_INSTANCE_SIGNATURE")
2139 .map(String::as_str),
2140 Some("abc")
2141 );
2142 assert_eq!(
2143 environment.get("YDOTOOL_SOCKET").map(String::as_str),
2144 Some("/run/ydotoold/socket")
2145 );
2146 assert!(!environment.contains_key("NO_EQUALS"));
2147 }
2148
2149 #[test]
2150 fn readiness_requires_exact_window_focus_for_targeted_input() {
2151 let platform = platform_report();
2152 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2153 let windowing = windowing_report(true, false);
2154 let input = input_report(true);
2155
2156 let readiness = readiness_report(
2157 &platform,
2158 &portal_report(Check::fail("missing")),
2159 &accessibility,
2160 &windowing,
2161 &input,
2162 );
2163
2164 assert!(readiness.can_query_windows);
2165 assert!(!readiness.can_focus_windows);
2166 assert!(readiness
2167 .recommended_next_step
2168 .contains("exact-focus window backend"));
2169 assert!(readiness
2170 .blockers
2171 .iter()
2172 .any(|blocker| blocker.contains("Exact window activation")));
2173 }
2174
2175 #[test]
2176 fn header_only_introspection_is_not_an_exported_interface() {
2177 let header_only = Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS");
2179
2180 let check = require_busctl_methods(
2181 header_only,
2182 "org.freedesktop.portal.Screenshot",
2183 SCREENSHOT_PORTAL_METHODS,
2184 );
2185
2186 assert!(!check.ok);
2187 assert!(check.detail.contains("is not exported by the portal"));
2188 }
2189
2190 #[test]
2191 fn portal_interface_must_export_every_required_method() {
2192 let partial = Check::ok(
2193 "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n.PickColor method sa{sv} o -\n.version property u 2 emits-change",
2194 );
2195 let check = require_busctl_methods(
2196 partial,
2197 "org.freedesktop.portal.Screenshot",
2198 SCREENSHOT_PORTAL_METHODS,
2199 );
2200 assert!(!check.ok);
2201 assert!(check
2202 .detail
2203 .contains("missing required methods: Screenshot"));
2204
2205 let full = Check::ok(
2206 "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n.PickColor method sa{sv} o -\n.Screenshot method sa{sv} o -",
2207 );
2208 assert!(
2209 require_busctl_methods(
2210 full,
2211 "org.freedesktop.portal.Screenshot",
2212 SCREENSHOT_PORTAL_METHODS
2213 )
2214 .ok
2215 );
2216
2217 let failed = Check::fail("busctl: No such interface");
2218 let passthrough = require_busctl_methods(
2219 failed,
2220 "org.freedesktop.portal.Screenshot",
2221 SCREENSHOT_PORTAL_METHODS,
2222 );
2223 assert!(!passthrough.ok);
2224 assert_eq!(passthrough.detail, "busctl: No such interface");
2225 }
2226
2227 #[test]
2228 fn readiness_blocks_when_no_screenshot_route_is_detected() {
2229 let mut platform = platform_report();
2230 platform.gnome_shell_version = Check::fail("No such file or directory (os error 2)");
2231 platform.gnome_screenshot = Check::fail("No such file or directory (os error 2)");
2232 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2233 let windowing = windowing_report(true, true);
2234 let input = input_report(true);
2235 let portals = portal_report(Check::fail("missing"));
2236
2237 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2238
2239 assert!(!readiness.can_capture_screenshots);
2240 assert!(readiness
2241 .blockers
2242 .iter()
2243 .any(|blocker| blocker.contains("No screenshot route was detected")));
2244 assert!(readiness
2245 .recommended_next_step
2246 .contains("Enable a screenshot route"));
2247 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2248 assert!(capabilities.screenshot.is_empty());
2249 assert_eq!(capabilities.preferred.screenshot, None);
2250 }
2251
2252 #[test]
2253 fn native_x11_display_is_a_screenshot_route_ahead_of_gnome_screenshot() {
2254 let mut platform = platform_report();
2255 platform.gnome_shell_version = Check::fail("missing");
2256 platform.x11_display = Check::ok("native X11 root window 2880x1920, depth 24");
2257 let portals = portal_report(Check::fail("missing"));
2258 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2259 let windowing = windowing_report(true, true);
2260 let input = input_report(true);
2261
2262 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2263 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2264
2265 assert_eq!(capabilities.screenshot, ["x11", "gnome_screenshot"]);
2266 assert_eq!(capabilities.preferred.screenshot.as_deref(), Some("x11"));
2267 assert!(readiness.can_capture_screenshots);
2268 }
2269
2270 #[test]
2271 fn readiness_and_capabilities_share_the_screenshot_route_list() {
2272 let mut platform = platform_report();
2273 platform.gnome_shell_version = Check::fail("missing");
2274 platform.gnome_screenshot = Check::fail("missing");
2275 let mut portals = portal_report(Check::fail("missing"));
2276 portals.screenshot = Check::ok(".Screenshot method sa{sv} o -");
2277 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2278 let windowing = windowing_report(true, true);
2279 let input = input_report(true);
2280
2281 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2282 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2283
2284 assert!(readiness.can_capture_screenshots);
2285 assert_eq!(capabilities.screenshot, ["portal"]);
2286 }
2287
2288 #[test]
2289 fn readiness_treats_kwin_as_full_window_backend() {
2290 let platform = platform_report();
2291 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2292 let mut windowing = windowing_report(false, false);
2293 windowing.kwin = Check::ok("KWin scripting is available");
2294 windowing.can_list_windows = true;
2295 windowing.can_focus_apps = true;
2296 windowing.can_focus_windows = true;
2297 let input = input_report(true);
2298
2299 let readiness = readiness_report(
2300 &platform,
2301 &portal_report(Check::fail("missing")),
2302 &accessibility,
2303 &windowing,
2304 &input,
2305 );
2306
2307 assert!(readiness.can_query_windows);
2308 assert!(readiness.can_focus_apps);
2309 assert!(readiness.can_focus_windows);
2310 assert!(readiness.blockers.is_empty());
2311 }
2312
2313 #[test]
2314 fn readiness_message_mentions_generic_window_targeting() {
2315 let platform = platform_report();
2316 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2317 let windowing = windowing_report(true, true);
2318 let input = input_report(true);
2319
2320 let readiness = readiness_report(
2321 &platform,
2322 &portal_report(Check::fail("missing")),
2323 &accessibility,
2324 &windowing,
2325 &input,
2326 );
2327
2328 assert!(readiness.blockers.is_empty());
2329 assert!(readiness
2330 .recommended_next_step
2331 .contains("AT-SPI tree support"));
2332 assert!(readiness.recommended_next_step.contains("window targeting"));
2333 assert!(!readiness
2334 .recommended_next_step
2335 .contains("GNOME window targeting"));
2336 }
2337
2338 #[test]
2339 fn readiness_accepts_connectable_ydotool_socket_without_direct_uinput_access() {
2340 let platform = platform_report();
2341 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2342 let windowing = windowing_report(true, true);
2343 let input = input_report_parts(
2344 Check::ok("ydotool"),
2345 Check::ok("ydotoold"),
2346 Check::ok("connectable: /tmp/.ydotool_socket"),
2347 Check::fail("/dev/uinput: Permission denied"),
2348 );
2349
2350 let readiness = readiness_report(
2351 &platform,
2352 &portal_report(Check::fail("missing")),
2353 &accessibility,
2354 &windowing,
2355 &input,
2356 );
2357
2358 assert!(readiness.can_send_development_input);
2359 assert!(readiness.blockers.is_empty());
2360 }
2361
2362 #[test]
2363 fn readiness_uses_connectable_ydotool_socket_when_process_probe_fails() {
2364 let platform = platform_report();
2365 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2366 let windowing = windowing_report(true, true);
2367 let input = input_report_parts(
2368 Check::ok("ydotool"),
2369 Check::fail("ydotoold process name not found"),
2370 Check::ok("connectable: /run/user/1000/.ydotool_socket"),
2371 Check::fail("/dev/uinput: Permission denied"),
2372 );
2373 let portals = portal_report(Check::fail("missing"));
2374
2375 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2376 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2377
2378 assert!(capabilities
2379 .input
2380 .iter()
2381 .any(|backend| backend == "ydotool"));
2382 assert!(readiness.can_send_development_input);
2383 }
2384
2385 #[test]
2386 fn wayland_readiness_rejects_pointer_only_uinput_without_keyboard_backend() {
2387 let platform = platform_report();
2388 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2389 let windowing = windowing_report(true, true);
2390 let input = input_report_parts(
2391 Check::ok("ydotool"),
2392 Check::fail("ydotoold not running"),
2393 Check::fail("no connectable ydotool socket"),
2394 Check::ok("read/write: /dev/uinput"),
2395 );
2396
2397 let readiness = readiness_report(
2398 &platform,
2399 &portal_report(Check::fail("missing")),
2400 &accessibility,
2401 &windowing,
2402 &input,
2403 );
2404
2405 assert!(!readiness.can_send_development_input);
2406 assert!(readiness
2407 .blockers
2408 .iter()
2409 .any(|blocker| blocker.contains("absolute pointer input")));
2410 assert!(readiness
2411 .recommended_next_step
2412 .contains("keyboard-capable input backend"));
2413 }
2414
2415 #[test]
2416 fn x11_readiness_rejects_pointer_only_uinput_without_keyboard_backend() {
2417 let mut platform = platform_report();
2418 platform.xdg_session_type = Some("x11".to_string());
2419 platform.wayland_display = None;
2420 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2421 let windowing = windowing_report(true, true);
2422 let input = input_report_parts(
2423 Check::fail("missing ydotool"),
2424 Check::fail("ydotoold not running"),
2425 Check::fail("no connectable ydotool socket"),
2426 Check::ok("read/write: /dev/uinput"),
2427 );
2428
2429 let readiness = readiness_report(
2430 &platform,
2431 &portal_report(Check::fail("missing")),
2432 &accessibility,
2433 &windowing,
2434 &input,
2435 );
2436
2437 assert!(!readiness.can_send_development_input);
2438 }
2439
2440 #[test]
2441 fn readiness_accepts_remote_desktop_portal_without_local_input_backend() {
2442 let platform = platform_report();
2443 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2444 let windowing = windowing_report(true, true);
2445 let input = input_report_parts(
2446 Check::fail("missing ydotool"),
2447 Check::fail("ydotoold not running"),
2448 Check::fail("no connectable ydotool socket"),
2449 Check::fail("/dev/uinput: Permission denied"),
2450 );
2451
2452 let readiness = readiness_report(
2453 &platform,
2454 &portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop")),
2455 &accessibility,
2456 &windowing,
2457 &input,
2458 );
2459
2460 assert!(readiness.can_send_development_input);
2461 assert!(readiness.blockers.is_empty());
2462 }
2463
2464 #[test]
2465 fn readiness_rejects_inaccessible_ydotool_paths() {
2466 let platform = platform_report();
2467 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2468 let windowing = windowing_report(true, true);
2469 let input = input_report_parts(
2470 Check::ok("ydotool"),
2471 Check::ok("ydotoold"),
2472 Check::fail("/tmp/.ydotool_socket: Permission denied"),
2473 Check::fail("/dev/uinput: Permission denied"),
2474 );
2475
2476 let readiness = readiness_report(
2477 &platform,
2478 &portal_report(Check::fail("missing")),
2479 &accessibility,
2480 &windowing,
2481 &input,
2482 );
2483
2484 assert!(!readiness.can_send_development_input);
2485 assert!(readiness
2486 .recommended_next_step
2487 .contains("Enable a keyboard-capable input backend"));
2488 assert!(readiness
2489 .blockers
2490 .iter()
2491 .any(|blocker| blocker.contains("Development keyboard input is unavailable")));
2492 }
2493
2494 #[test]
2495 fn capability_map_rejects_incompatible_ydotool_with_live_daemon() {
2496 let platform = platform_report();
2497 let portals = portal_report(Check::fail("missing"));
2498 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2499 let windowing = windowing_report(true, true);
2500 let input = input_report_parts(
2501 Check::fail("unsupported legacy ydotool CLI"),
2502 Check::ok("ydotoold"),
2503 Check::ok("connectable socket"),
2504 Check::fail("/dev/uinput: Permission denied"),
2505 );
2506
2507 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2508 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2509
2510 assert!(!capabilities
2511 .input
2512 .iter()
2513 .any(|backend| backend == "ydotool"));
2514 assert!(!readiness.can_send_development_input);
2515 }
2516
2517 #[test]
2518 fn ydotool_socket_check_rejects_legacy_stream_socket() {
2519 let dir = std::env::temp_dir().join(format!(
2520 "computer-use-linux-diagnostics-{}",
2521 std::process::id()
2522 ));
2523 let _ = std::fs::remove_dir_all(&dir);
2524 std::fs::create_dir_all(&dir).expect("create temp diagnostics dir");
2525 let socket = dir.join("ydotool.sock");
2526 let listener =
2527 std::os::unix::net::UnixListener::bind(&socket).expect("bind temp diagnostics socket");
2528
2529 let check = socket_connect_check(&socket);
2530
2531 assert!(!check.ok, "{check:?}");
2532 drop(listener);
2533 let _ = std::fs::remove_dir_all(&dir);
2534 }
2535
2536 #[test]
2537 fn ydotool_socket_check_accepts_datagram_socket() {
2538 let dir = std::env::temp_dir().join(format!(
2539 "computer-use-linux-diagnostics-dgram-{}",
2540 std::process::id()
2541 ));
2542 let _ = std::fs::remove_dir_all(&dir);
2543 std::fs::create_dir_all(&dir).expect("create temp diagnostics dir");
2544 let socket = dir.join("ydotool.sock");
2545 let datagram =
2546 std::os::unix::net::UnixDatagram::bind(&socket).expect("bind temp datagram socket");
2547
2548 let check = socket_connect_check(&socket);
2549
2550 assert!(check.ok, "{check:?}");
2551 drop(datagram);
2552 let _ = std::fs::remove_dir_all(&dir);
2553 }
2554
2555 #[test]
2556 fn readiness_reports_cosmic_window_blocker_on_cosmic() {
2557 let mut platform = platform_report();
2558 platform.xdg_current_desktop = Some("COSMIC".to_string());
2559 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2560 let windowing = windowing_report(false, false);
2561 let input = input_report(true);
2562
2563 let readiness = readiness_report(
2564 &platform,
2565 &portal_report(Check::fail("missing")),
2566 &accessibility,
2567 &windowing,
2568 &input,
2569 );
2570
2571 assert!(readiness
2572 .blockers
2573 .iter()
2574 .any(|blocker| blocker.contains("COSMIC Wayland window introspection")));
2575 }
2576}