1use crate::windowing::registry::{
2 self, COSMIC_WAYLAND_BACKEND, GNOME_SHELL_EXTENSION_BACKEND, GNOME_SHELL_INTROSPECT_BACKEND,
3 HYPRLAND_BACKEND, I3_BACKEND, KWIN_BACKEND, X11_BACKEND,
4};
5use schemars::JsonSchema;
6use serde::Serialize;
7use std::{
8 collections::{BTreeMap, HashMap},
9 env, fs,
10 fs::OpenOptions,
11 os::unix::{
12 fs::MetadataExt,
13 net::{UnixDatagram, UnixStream},
14 },
15 path::{Path, PathBuf},
16 process::Command,
17};
18
19const DESKTOP_ENV_KEYS: &[&str] = &[
20 "DBUS_SESSION_BUS_ADDRESS",
21 "DESKTOP_SESSION",
22 "DISPLAY",
23 "HYPRLAND_INSTANCE_SIGNATURE",
24 "XAUTHORITY",
25 "YDOTOOL_SOCKET",
26 "XDG_SESSION_DESKTOP",
27 "WAYLAND_DISPLAY",
28 "XDG_CURRENT_DESKTOP",
29 "XDG_RUNTIME_DIR",
30 "XDG_SESSION_TYPE",
31];
32
33#[derive(Debug, Clone, Serialize, JsonSchema)]
34pub struct DoctorReport {
35 pub platform: PlatformReport,
36 pub portals: PortalReport,
37 pub accessibility: AccessibilityReport,
38 pub windowing: WindowingReport,
39 pub input: InputReport,
40 pub readiness: ReadinessReport,
41 pub capabilities: CapabilityMap,
45}
46
47#[derive(Debug, Clone, Serialize, JsonSchema)]
48pub struct CapabilityMap {
49 pub input: Vec<String>,
51 pub screenshot: Vec<String>,
53 pub window_control: Vec<String>,
55 pub accessibility: Vec<String>,
57 pub isolation: Vec<String>,
59 pub preferred: PreferredBackends,
61}
62
63#[derive(Debug, Clone, Serialize, JsonSchema)]
64pub struct PreferredBackends {
65 pub input: Option<String>,
66 pub screenshot: Option<String>,
67 pub window_control: Option<String>,
68}
69
70#[derive(Debug, Clone, Serialize, JsonSchema)]
71pub struct PlatformReport {
72 pub os: String,
73 pub arch: String,
74 pub desktop_session: Option<String>,
75 pub xdg_session_type: Option<String>,
76 pub xdg_current_desktop: Option<String>,
77 pub wayland_display: Option<String>,
78 pub display: Option<String>,
79 pub xauthority: Option<String>,
80 pub dbus_session_bus_address: Option<String>,
81 pub xdg_runtime_dir: Option<String>,
82 pub gnome_shell_version: Check,
83 pub gnome_screenshot: Check,
84}
85
86#[derive(Debug, Clone, Serialize, JsonSchema)]
87pub struct PortalReport {
88 pub desktop_portal: Check,
89 pub remote_desktop: Check,
90 pub screencast: Check,
91 pub screenshot: Check,
92 pub input_capture: Check,
93 pub mutter_remote_desktop: Check,
94 pub mutter_screencast: Check,
95}
96
97#[derive(Debug, Clone, Serialize, JsonSchema)]
98pub struct AccessibilityReport {
99 pub at_spi_bus: Check,
100 pub toolkit_accessibility: Check,
101 pub at_spi_enabled: Check,
102 pub screen_reader_enabled: Check,
103}
104
105#[derive(Debug, Clone, Serialize, JsonSchema)]
106pub struct WindowingReport {
107 pub gnome_shell_introspect: Check,
108 pub computer_use_linux_gnome_shell_extension: Check,
109 pub cosmic_helper: Check,
110 pub kwin: Check,
111 pub hyprland: Check,
112 pub backends: BTreeMap<String, Check>,
113 pub can_list_windows: bool,
114 pub can_focus_apps: bool,
115 pub can_focus_windows: bool,
116 pub note: String,
117}
118
119#[derive(Debug, Clone, Serialize, JsonSchema)]
120pub struct InputReport {
121 pub ydotool: Check,
122 pub ydotoold: Check,
123 pub ydotool_socket: Check,
124 pub uinput: Check,
125}
126
127#[derive(Debug, Clone, Serialize, JsonSchema)]
128pub struct ReadinessReport {
129 pub can_register_mcp_tools: bool,
130 pub can_build_accessibility_tree: bool,
131 pub can_query_windows: bool,
132 pub can_focus_apps: bool,
133 pub can_focus_windows: bool,
134 pub can_send_development_input: bool,
135 pub recommended_next_step: String,
136 pub blockers: Vec<String>,
137}
138
139#[derive(Debug, Clone, Serialize, JsonSchema)]
140pub struct SetupReport {
141 pub before: DoctorReport,
142 pub accessibility_command: Check,
143 pub after: DoctorReport,
144 pub changed_accessibility: bool,
145 pub requires_target_app_restart: bool,
146 pub message: String,
147}
148
149#[derive(Debug, Clone, Serialize, JsonSchema)]
150pub struct Check {
151 pub ok: bool,
152 pub detail: String,
153}
154
155impl Check {
156 fn ok(detail: impl Into<String>) -> Self {
157 Self {
158 ok: true,
159 detail: detail.into(),
160 }
161 }
162
163 fn fail(detail: impl Into<String>) -> Self {
164 Self {
165 ok: false,
166 detail: detail.into(),
167 }
168 }
169}
170
171pub fn doctor_report() -> DoctorReport {
172 hydrate_session_bus_env();
173
174 let platform = platform_report();
175 let portals = portal_report();
176 let accessibility = accessibility_report();
177 let windowing = windowing_report(&platform);
178 let input = input_report();
179 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
180
181 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
182
183 DoctorReport {
184 platform,
185 portals,
186 accessibility,
187 windowing,
188 input,
189 readiness,
190 capabilities,
191 }
192}
193
194fn capability_map(
197 platform: &PlatformReport,
198 portals: &PortalReport,
199 accessibility: &AccessibilityReport,
200 windowing: &WindowingReport,
201 input: &InputReport,
202) -> CapabilityMap {
203 let mut input_backends = Vec::new();
204 if input.uinput.ok {
206 input_backends.push("abs_pointer".to_string());
207 }
208 if portals.remote_desktop.ok {
209 input_backends.push("portal".to_string());
210 }
211 if input.ydotool_socket.ok {
212 input_backends.push("ydotool".to_string());
213 }
214
215 let mut screenshot_backends = Vec::new();
216 if platform.gnome_shell_version.ok {
217 screenshot_backends.push("gnome_shell".to_string());
218 }
219 if portals.screenshot.ok {
220 screenshot_backends.push("portal".to_string());
221 }
222 if platform.gnome_screenshot.ok {
224 screenshot_backends.push("gnome_screenshot".to_string());
225 }
226
227 let mut window_backends = Vec::new();
228 if windowing.computer_use_linux_gnome_shell_extension.ok {
229 window_backends.push("gnome_shell_extension".to_string());
230 }
231 if windowing.gnome_shell_introspect.ok {
232 window_backends.push("gnome_introspect".to_string());
233 }
234 if windowing.kwin.ok {
235 window_backends.push("kwin".to_string());
236 }
237 if windowing.hyprland.ok {
238 window_backends.push("hyprland".to_string());
239 }
240 if windowing.cosmic_helper.ok {
241 window_backends.push("cosmic".to_string());
242 }
243 if windowing
247 .backends
248 .get(I3_BACKEND)
249 .is_some_and(|check| check.ok)
250 {
251 window_backends.push(I3_BACKEND.to_string());
252 }
253 if windowing
254 .backends
255 .get(X11_BACKEND)
256 .is_some_and(|check| check.ok)
257 {
258 window_backends.push(X11_BACKEND.to_string());
259 }
260
261 let mut accessibility_backends = Vec::new();
262 if accessibility.at_spi_enabled.ok || accessibility.toolkit_accessibility.ok {
263 accessibility_backends.push("at_spi".to_string());
264 }
265
266 let mut isolation = vec!["shared".to_string()];
270 if platform.gnome_shell_version.ok {
271 isolation.push("headless_gnome".to_string());
272 }
273
274 let preferred = PreferredBackends {
275 input: input_backends.first().cloned(),
276 screenshot: screenshot_backends.first().cloned(),
277 window_control: window_backends.first().cloned(),
278 };
279
280 CapabilityMap {
281 input: input_backends,
282 screenshot: screenshot_backends,
283 window_control: window_backends,
284 accessibility: accessibility_backends,
285 isolation,
286 preferred,
287 }
288}
289
290pub fn hydrate_session_bus_env() {
291 hydrate_common_command_path();
292 hydrate_desktop_env_from_process_tree();
293 hydrate_desktop_env_from_systemd_user();
294
295 if env_var("XDG_RUNTIME_DIR").is_none() {
296 if let Some(runtime) = xdg_runtime_dir() {
297 if runtime.exists() {
298 env::set_var("XDG_RUNTIME_DIR", runtime);
299 }
300 }
301 }
302
303 if env_var("DBUS_SESSION_BUS_ADDRESS").is_none() {
304 if let Some(runtime) = xdg_runtime_dir() {
305 let bus = runtime.join("bus");
306 if bus.exists() {
307 env::set_var(
308 "DBUS_SESSION_BUS_ADDRESS",
309 format!("unix:path={}", bus.display()),
310 );
311 }
312 }
313 }
314}
315
316fn hydrate_common_command_path() {
317 let mut entries = env::var_os("PATH")
318 .map(|path| env::split_paths(&path).collect::<Vec<_>>())
319 .unwrap_or_default();
320 for path in [
321 "/run/current-system/sw/bin",
322 "/usr/local/bin",
323 "/usr/bin",
324 "/bin",
325 ] {
326 let path = PathBuf::from(path);
327 if path.exists() && !entries.iter().any(|entry| entry == &path) {
328 entries.push(path);
329 }
330 }
331 if let Ok(path) = env::join_paths(entries) {
332 env::set_var("PATH", path);
333 }
334}
335
336fn hydrate_desktop_env_from_process_tree() {
337 for process_env in desktop_process_environments() {
338 hydrate_desktop_env_from_map(&process_env);
339
340 if DESKTOP_ENV_KEYS.iter().all(|key| env_var(key).is_some()) {
341 break;
342 }
343 }
344}
345
346fn hydrate_desktop_env_from_systemd_user() {
347 let Ok(output) = Command::new("systemctl")
348 .args(["--user", "show-environment"])
349 .output()
350 else {
351 return;
352 };
353 if !output.status.success() {
354 return;
355 }
356 let env_map = parse_line_environment(&output.stdout);
357 hydrate_desktop_env_from_map(&env_map);
358}
359
360fn hydrate_desktop_env_from_map(process_env: &HashMap<String, String>) {
361 for key in DESKTOP_ENV_KEYS {
362 if env_var(key).is_some() {
363 continue;
364 }
365 if let Some(value) = process_env
366 .get(*key)
367 .filter(|value| !value.trim().is_empty())
368 {
369 env::set_var(key, value);
370 }
371 }
372}
373
374fn desktop_process_environments() -> Vec<HashMap<String, String>> {
375 let mut environments = Vec::new();
376 let mut visited_pids = Vec::new();
377 let mut pid = parent_pid("self");
378
379 for _ in 0..8 {
380 let Some(current_pid) = pid else {
381 break;
382 };
383 if current_pid <= 1 {
384 break;
385 }
386
387 visited_pids.push(current_pid);
388 if let Some(process_env) = read_process_environ(current_pid) {
389 environments.push(process_env);
390 }
391 pid = parent_pid(¤t_pid.to_string());
392 }
393
394 if !visited_pids.contains(&1) && process_owner_matches_current_user(1) {
395 if let Some(process_env) = read_process_environ(1).filter(process_env_has_graphical_display)
396 {
397 environments.push(process_env);
398 }
399 }
400
401 environments
402}
403
404fn parent_pid(pid: &str) -> Option<u32> {
405 let status = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
406 parse_parent_pid(&status)
407}
408
409fn parse_parent_pid(status: &str) -> Option<u32> {
410 status.lines().find_map(|line| {
411 let value = line.strip_prefix("PPid:")?.trim();
412 value.parse::<u32>().ok()
413 })
414}
415
416fn read_process_environ(pid: u32) -> Option<HashMap<String, String>> {
417 let bytes = fs::read(format!("/proc/{pid}/environ")).ok()?;
418 Some(parse_environ(&bytes))
419}
420
421fn process_owner_matches_current_user(pid: u32) -> bool {
422 let Some(current_uid) = user_id().and_then(|uid| uid.parse::<u32>().ok()) else {
423 return false;
424 };
425 fs::metadata(format!("/proc/{pid}"))
426 .ok()
427 .is_some_and(|metadata| metadata.uid() == current_uid)
428}
429
430fn process_env_has_graphical_display(process_env: &HashMap<String, String>) -> bool {
431 process_env
432 .get("DISPLAY")
433 .or_else(|| process_env.get("WAYLAND_DISPLAY"))
434 .is_some_and(|value| !value.trim().is_empty())
435}
436
437fn parse_environ(bytes: &[u8]) -> HashMap<String, String> {
438 bytes
439 .split(|byte| *byte == 0)
440 .filter_map(|entry| {
441 if entry.is_empty() {
442 return None;
443 }
444 let split = entry.iter().position(|byte| *byte == b'=')?;
445 let (key, value) = entry.split_at(split);
446 let value = &value[1..];
447 let key = std::str::from_utf8(key).ok()?.to_string();
448 let value = std::str::from_utf8(value).ok()?.to_string();
449 Some((key, value))
450 })
451 .collect()
452}
453
454fn parse_line_environment(bytes: &[u8]) -> HashMap<String, String> {
455 bytes
456 .split(|byte| *byte == b'\n')
457 .filter_map(|entry| {
458 if entry.is_empty() {
459 return None;
460 }
461 let split = entry.iter().position(|byte| *byte == b'=')?;
462 let (key, value) = entry.split_at(split);
463 let value = &value[1..];
464 let key = std::str::from_utf8(key).ok()?.to_string();
465 let value = std::str::from_utf8(value).ok()?.to_string();
466 Some((key, value))
467 })
468 .collect()
469}
470
471pub fn setup_accessibility_report() -> SetupReport {
472 hydrate_session_bus_env();
473
474 let before = doctor_report();
475 let accessibility_command = if can_build_accessibility_tree(&before.accessibility) {
476 Check::ok("AT-SPI accessibility is already enabled")
477 } else {
478 let atspi_status = command_check_with_session_bus(
479 "busctl",
480 &[
481 "--user",
482 "set-property",
483 "org.a11y.Bus",
484 "/org/a11y/bus",
485 "org.a11y.Status",
486 "IsEnabled",
487 "b",
488 "true",
489 ],
490 );
491 if atspi_status.ok {
492 atspi_status
493 } else {
494 command_check_with_session_bus(
495 "gsettings",
496 &[
497 "set",
498 "org.gnome.desktop.interface",
499 "toolkit-accessibility",
500 "true",
501 ],
502 )
503 }
504 };
505 let after = doctor_report();
506 let before_ready = before.readiness.can_build_accessibility_tree;
507 let after_ready = after.readiness.can_build_accessibility_tree;
508 let changed_accessibility = !before_ready && after_ready;
509 let requires_target_app_restart = changed_accessibility;
510 let message = if after_ready {
511 if changed_accessibility {
512 "AT-SPI accessibility is enabled. Restart already-running target apps if their AT-SPI tree is still empty."
513 } else {
514 "AT-SPI accessibility is ready."
515 }
516 } else {
517 "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."
518 }
519 .to_string();
520
521 SetupReport {
522 before,
523 accessibility_command,
524 after,
525 changed_accessibility,
526 requires_target_app_restart,
527 message,
528 }
529}
530
531fn platform_report() -> PlatformReport {
532 PlatformReport {
533 os: std::env::consts::OS.to_string(),
534 arch: std::env::consts::ARCH.to_string(),
535 desktop_session: env_var("DESKTOP_SESSION"),
536 xdg_session_type: env_var("XDG_SESSION_TYPE"),
537 xdg_current_desktop: env_var("XDG_CURRENT_DESKTOP"),
538 wayland_display: env_var("WAYLAND_DISPLAY"),
539 display: env_var("DISPLAY"),
540 xauthority: env_var("XAUTHORITY"),
541 dbus_session_bus_address: dbus_session_address(),
542 xdg_runtime_dir: xdg_runtime_dir().map(|path| path.display().to_string()),
543 gnome_shell_version: command_check("gnome-shell", &["--version"]),
544 gnome_screenshot: command_check("gnome-screenshot", &["--version"]),
545 }
546}
547
548fn portal_report() -> PortalReport {
549 PortalReport {
550 desktop_portal: bus_name_check("org.freedesktop.portal.Desktop"),
551 remote_desktop: portal_interface_check("org.freedesktop.portal.RemoteDesktop"),
552 screencast: portal_interface_check("org.freedesktop.portal.ScreenCast"),
553 screenshot: portal_interface_check("org.freedesktop.portal.Screenshot"),
554 input_capture: portal_interface_check("org.freedesktop.portal.InputCapture"),
555 mutter_remote_desktop: bus_name_check("org.gnome.Mutter.RemoteDesktop"),
556 mutter_screencast: bus_name_check("org.gnome.Mutter.ScreenCast"),
557 }
558}
559
560fn accessibility_report() -> AccessibilityReport {
561 AccessibilityReport {
562 at_spi_bus: atspi_bus_address_check(),
563 toolkit_accessibility: command_check_with_session_bus(
564 "gsettings",
565 &[
566 "get",
567 "org.gnome.desktop.interface",
568 "toolkit-accessibility",
569 ],
570 ),
571 at_spi_enabled: atspi_status_property_check("IsEnabled"),
572 screen_reader_enabled: atspi_status_property_check("ScreenReaderEnabled"),
573 }
574}
575
576fn windowing_report(platform: &PlatformReport) -> WindowingReport {
577 let probes = registry::probe_backends();
578 let backend_check = |id: &str| {
579 probes
580 .iter()
581 .find(|probe| probe.id == id)
582 .map(check_from_backend_probe)
583 .unwrap_or_else(|| Check::fail("backend probe did not run"))
584 };
585 let gnome_shell_introspect = backend_check(GNOME_SHELL_INTROSPECT_BACKEND);
586 let computer_use_linux_gnome_shell_extension = backend_check(GNOME_SHELL_EXTENSION_BACKEND);
587 let cosmic_helper = backend_check(COSMIC_WAYLAND_BACKEND);
588 let kwin = backend_check(KWIN_BACKEND);
589 let hyprland = backend_check(HYPRLAND_BACKEND);
590 let backends = probes
591 .iter()
592 .map(|probe| (probe.id.to_string(), check_from_backend_probe(probe)))
593 .collect::<BTreeMap<_, _>>();
594 let can_list_windows = probes.iter().any(|probe| probe.can_list_windows);
595 let can_focus_apps = probes.iter().any(|probe| probe.can_focus_apps);
596 let can_focus_windows = probes.iter().any(|probe| probe.can_focus_windows);
597 let note = if can_list_windows {
598 if !can_focus_windows {
599 "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)."
600 } else if cosmic_helper.ok && is_cosmic_wayland_platform(platform) {
601 "A COSMIC Wayland window backend is available for list_windows, focused_window, and targeted input verification."
602 } else if kwin.ok {
603 "A KWin/Plasma window backend is available for list_windows, focused_window, and targeted input verification."
604 } else if hyprland.ok {
605 "A Hyprland window backend is available for list_windows, focused_window, and targeted input verification."
606 } else {
607 "A window listing backend is available for list_windows, focused_window, and targeted input verification."
608 }
609 } else {
610 "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."
611 }
612 .to_string();
613
614 WindowingReport {
615 gnome_shell_introspect,
616 computer_use_linux_gnome_shell_extension,
617 cosmic_helper,
618 kwin,
619 hyprland,
620 backends,
621 can_list_windows,
622 can_focus_apps,
623 can_focus_windows,
624 note,
625 }
626}
627
628fn check_from_backend_probe(probe: ®istry::BackendProbe) -> Check {
629 if probe.ok {
630 Check::ok(probe.detail.clone())
631 } else {
632 Check::fail(probe.detail.clone())
633 }
634}
635
636fn input_report() -> InputReport {
637 InputReport {
638 ydotool: command_path_check("ydotool"),
639 ydotoold: process_check("ydotoold"),
640 ydotool_socket: ydotool_socket_check(),
641 uinput: read_write_path_check(Path::new("/dev/uinput")),
642 }
643}
644
645fn readiness_report(
646 platform: &PlatformReport,
647 portals: &PortalReport,
648 accessibility: &AccessibilityReport,
649 windowing: &WindowingReport,
650 input: &InputReport,
651) -> ReadinessReport {
652 let mut blockers = Vec::new();
653 let can_build_accessibility_tree = can_build_accessibility_tree(accessibility);
654 let can_query_windows = windowing.can_list_windows;
655 let can_focus_apps = windowing.can_focus_apps;
656 let can_focus_windows = windowing.can_focus_windows;
657 let can_send_development_input = can_send_development_input(portals, input);
658
659 if !can_build_accessibility_tree {
660 blockers.push(
661 "AT-SPI accessibility is disabled; enable org.a11y.Status IsEnabled or org.gnome.desktop.interface toolkit-accessibility for tree extraction."
662 .to_string(),
663 );
664 }
665
666 if !can_query_windows {
667 blockers.push(if is_cosmic_wayland_platform(platform) {
668 "COSMIC Wayland window introspection is unavailable; targeted window focus and verification will be disabled.".to_string()
669 } else {
670 "Window introspection is unavailable; targeted window focus and verification will be disabled."
671 .to_string()
672 });
673 }
674
675 if can_query_windows && !can_focus_windows {
676 blockers.push(
677 "Exact window activation is unavailable; app-level focus may work, but window_id/title/terminal-targeted input cannot be verified."
678 .to_string(),
679 );
680 }
681
682 if !can_send_development_input {
683 blockers.push(
684 "Development input is unavailable; enable read/write /dev/uinput, XDG RemoteDesktop portal input, or ydotool with a connectable ydotoold socket."
685 .to_string(),
686 );
687 }
688
689 let recommended_next_step = if !can_build_accessibility_tree {
690 "Run setup_accessibility to enable AT-SPI accessibility before element-aware actions."
691 .to_string()
692 } else if !can_query_windows {
693 format!(
694 "Enable a supported window backend before using targeted keyboard input: {}",
695 registry::descriptors()
696 .iter()
697 .map(|descriptor| descriptor.missing_hint)
698 .collect::<Vec<_>>()
699 .join(" ")
700 )
701 } else if !can_focus_windows {
702 "Enable an exact-focus window backend before using window_id, title, or terminal-targeted input.".to_string()
703 } else if !can_send_development_input {
704 "Enable a supported input backend: grant read/write /dev/uinput, enable the XDG RemoteDesktop portal, or start ydotoold with a socket accessible to this desktop user."
705 .to_string()
706 } else {
707 "Computer Use is ready: AT-SPI tree support, window targeting, and a Linux input backend are available."
708 .to_string()
709 };
710
711 ReadinessReport {
712 can_register_mcp_tools: true,
713 can_build_accessibility_tree,
714 can_query_windows,
715 can_focus_apps,
716 can_focus_windows,
717 can_send_development_input,
718 recommended_next_step,
719 blockers,
720 }
721}
722
723fn can_send_development_input(portals: &PortalReport, input: &InputReport) -> bool {
724 input.uinput.ok
725 || portals.remote_desktop.ok
726 || input.ydotool.ok && input.ydotoold.ok && input.ydotool_socket.ok
727}
728
729fn is_cosmic_wayland_platform(platform: &PlatformReport) -> bool {
730 platform
731 .xdg_current_desktop
732 .as_deref()
733 .is_some_and(|desktop| desktop.to_ascii_lowercase().contains("cosmic"))
734 && platform.xdg_session_type.as_deref() == Some("wayland")
735}
736
737fn can_build_accessibility_tree(accessibility: &AccessibilityReport) -> bool {
738 accessibility.at_spi_bus.ok
739 && (check_detail_contains_true(&accessibility.at_spi_enabled)
740 || check_detail_contains_true(&accessibility.toolkit_accessibility))
741}
742
743fn check_detail_contains_true(check: &Check) -> bool {
744 check.ok && check.detail.to_ascii_lowercase().contains("true")
745}
746
747fn env_var(key: &str) -> Option<String> {
748 env::var(key).ok().filter(|value| !value.trim().is_empty())
749}
750
751fn xdg_runtime_dir() -> Option<PathBuf> {
752 if let Some(value) = env_var("XDG_RUNTIME_DIR") {
753 return Some(PathBuf::from(value));
754 }
755 user_id().map(|uid| PathBuf::from(format!("/run/user/{uid}")))
756}
757
758fn dbus_session_address() -> Option<String> {
759 if let Some(value) = env_var("DBUS_SESSION_BUS_ADDRESS") {
760 return Some(value);
761 }
762 xdg_runtime_dir()
763 .map(|runtime| format!("unix:path={}", runtime.join("bus").display()))
764 .filter(|address| {
765 address
766 .strip_prefix("unix:path=")
767 .is_some_and(|p| Path::new(p).exists())
768 })
769}
770
771fn ydotool_socket_candidates() -> Vec<PathBuf> {
772 let mut candidates = Vec::new();
773 if let Some(value) = env_var("YDOTOOL_SOCKET") {
774 candidates.push(PathBuf::from(value));
775 }
776
777 if let Some(runtime_socket) = xdg_runtime_dir().map(|runtime| runtime.join(".ydotool_socket")) {
778 candidates.push(runtime_socket);
779 }
780 candidates.push(PathBuf::from("/tmp/.ydotool_socket"));
781 candidates
782}
783
784fn ydotool_socket_check() -> Check {
785 let mut checked = Vec::new();
786 for candidate in ydotool_socket_candidates() {
787 match socket_connect_result(&candidate) {
788 Ok(()) => return Check::ok(format!("connectable: {}", candidate.display())),
789 Err(detail) => checked.push(detail),
790 }
791 }
792
793 Check::fail(format!(
794 "no connectable ydotool socket ({})",
795 checked.join("; ")
796 ))
797}
798
799fn user_id() -> Option<String> {
800 let output = Command::new("id").arg("-u").output().ok()?;
801 output
802 .status
803 .success()
804 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
805 .filter(|value| !value.is_empty())
806}
807
808fn command_path_check(command: &str) -> Check {
809 command_check("sh", &["-c", &format!("command -v {command}")])
810}
811
812fn process_check(process_name: &str) -> Check {
813 command_check("pgrep", &["-a", process_name])
814}
815
816#[cfg(test)]
817fn socket_connect_check(path: &Path) -> Check {
818 match socket_connect_result(path) {
819 Ok(()) => Check::ok(format!("connectable: {}", path.display())),
820 Err(detail) => Check::fail(detail),
821 }
822}
823
824fn socket_connect_result(path: &Path) -> std::result::Result<(), String> {
825 if !path.exists() {
826 return Err(format!("missing: {}", path.display()));
827 }
828
829 match UnixStream::connect(path) {
830 Ok(_) => Ok(()),
831 Err(stream_error) => {
832 match UnixDatagram::unbound().and_then(|socket| socket.connect(path)) {
833 Ok(()) => Ok(()),
834 Err(datagram_error) => Err(format!(
835 "{}: stream: {}; datagram: {}",
836 path.display(),
837 stream_error,
838 datagram_error
839 )),
840 }
841 }
842 }
843}
844
845fn read_write_path_check(path: &Path) -> Check {
846 if !path.exists() {
847 return Check::fail(format!("missing: {}", path.display()));
848 }
849
850 match OpenOptions::new().read(true).write(true).open(path) {
851 Ok(_) => Check::ok(format!("read/write: {}", path.display())),
852 Err(error) => Check::fail(format!("{}: {error}", path.display())),
853 }
854}
855
856fn bus_name_check(name: &str) -> Check {
857 command_check_with_session_bus("busctl", &["--user", "status", name])
858}
859
860fn portal_interface_check(interface: &str) -> Check {
861 command_check_with_session_bus(
862 "busctl",
863 &[
864 "--user",
865 "introspect",
866 "org.freedesktop.portal.Desktop",
867 "/org/freedesktop/portal/desktop",
868 interface,
869 ],
870 )
871}
872
873fn atspi_bus_address_check() -> Check {
874 let busctl = command_check_with_session_bus(
875 "busctl",
876 &[
877 "--user",
878 "call",
879 "org.a11y.Bus",
880 "/org/a11y/bus",
881 "org.a11y.Bus",
882 "GetAddress",
883 ],
884 );
885 if busctl.ok {
886 return busctl;
887 }
888
889 gdbus_call_check(
890 "org.a11y.Bus",
891 "/org/a11y/bus",
892 "org.a11y.Bus.GetAddress",
893 &[],
894 )
895}
896
897fn atspi_status_property_check(property: &str) -> Check {
898 let busctl = command_check_with_session_bus(
899 "busctl",
900 &[
901 "--user",
902 "get-property",
903 "org.a11y.Bus",
904 "/org/a11y/bus",
905 "org.a11y.Status",
906 property,
907 ],
908 );
909 if busctl.ok {
910 return busctl;
911 }
912
913 gdbus_call_check(
914 "org.a11y.Bus",
915 "/org/a11y/bus",
916 "org.freedesktop.DBus.Properties.Get",
917 &["org.a11y.Status", property],
918 )
919}
920
921fn gdbus_call_check(destination: &str, object_path: &str, method: &str, args: &[&str]) -> Check {
922 let mut command_args = vec![
923 "call",
924 "--session",
925 "--dest",
926 destination,
927 "--object-path",
928 object_path,
929 "--method",
930 method,
931 ];
932 command_args.extend_from_slice(args);
933 command_check_with_session_bus("gdbus", &command_args)
934}
935
936fn command_check(command: &str, args: &[&str]) -> Check {
937 run_command(command, args, false)
938}
939
940fn command_check_with_session_bus(command: &str, args: &[&str]) -> Check {
941 run_command(command, args, true)
942}
943
944fn run_command(command: &str, args: &[&str], with_session_bus: bool) -> Check {
945 let mut cmd = Command::new(command);
946 cmd.args(args);
947
948 if with_session_bus {
949 if let Some(address) = dbus_session_address() {
950 cmd.env("DBUS_SESSION_BUS_ADDRESS", address);
951 }
952 if let Some(runtime) = xdg_runtime_dir() {
953 cmd.env("XDG_RUNTIME_DIR", runtime);
954 }
955 }
956
957 match cmd.output() {
958 Ok(output) if output.status.success() => {
959 let detail = String::from_utf8_lossy(&output.stdout).trim().to_string();
960 Check::ok(if detail.is_empty() {
961 "ok".into()
962 } else {
963 detail
964 })
965 }
966 Ok(output) => {
967 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
968 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
969 let detail = if !stderr.is_empty() { stderr } else { stdout };
970 Check::fail(if detail.is_empty() {
971 format!("exit status {}", output.status)
972 } else {
973 detail
974 })
975 }
976 Err(error) => Check::fail(error.to_string()),
977 }
978}
979
980#[cfg(test)]
981mod tests {
982 use super::*;
983
984 fn platform_report() -> PlatformReport {
985 PlatformReport {
986 os: "linux".to_string(),
987 arch: "x86_64".to_string(),
988 desktop_session: None,
989 xdg_session_type: Some("wayland".to_string()),
990 xdg_current_desktop: Some("GNOME".to_string()),
991 wayland_display: Some("wayland-0".to_string()),
992 display: Some(":0".to_string()),
993 xauthority: Some("/run/user/1000/Xauthority".to_string()),
994 dbus_session_bus_address: Some("unix:path=/run/user/1000/bus".to_string()),
995 xdg_runtime_dir: Some("/run/user/1000".to_string()),
996 gnome_shell_version: Check::ok("GNOME Shell 46.0"),
997 gnome_screenshot: Check::ok("gnome-screenshot 41.0"),
998 }
999 }
1000
1001 fn portal_report(remote_desktop: Check) -> PortalReport {
1002 PortalReport {
1003 desktop_portal: Check::ok("ok"),
1004 remote_desktop,
1005 screencast: Check::fail("missing"),
1006 screenshot: Check::fail("missing"),
1007 input_capture: Check::fail("missing"),
1008 mutter_remote_desktop: Check::fail("missing"),
1009 mutter_screencast: Check::fail("missing"),
1010 }
1011 }
1012
1013 fn accessibility_report(
1014 at_spi_bus: Check,
1015 toolkit_accessibility: Check,
1016 ) -> AccessibilityReport {
1017 AccessibilityReport {
1018 at_spi_bus,
1019 toolkit_accessibility,
1020 at_spi_enabled: Check::fail("(<false>,)"),
1021 screen_reader_enabled: Check::fail("(<false>,)"),
1022 }
1023 }
1024
1025 fn windowing_report(can_list_windows: bool, can_focus_windows: bool) -> WindowingReport {
1026 WindowingReport {
1027 gnome_shell_introspect: if can_list_windows {
1028 Check::ok("ok")
1029 } else {
1030 Check::fail("denied")
1031 },
1032 computer_use_linux_gnome_shell_extension: if can_focus_windows {
1033 Check::ok("ok")
1034 } else {
1035 Check::fail("missing")
1036 },
1037 cosmic_helper: Check::fail("missing"),
1038 kwin: Check::fail("not a KWin session"),
1039 hyprland: Check::fail("not a Hyprland session"),
1040 backends: BTreeMap::new(),
1041 can_list_windows,
1042 can_focus_apps: true,
1043 can_focus_windows,
1044 note: String::new(),
1045 }
1046 }
1047
1048 fn input_report(can_send_input: bool) -> InputReport {
1049 let check = if can_send_input {
1050 Check::ok("ok")
1051 } else {
1052 Check::fail("missing")
1053 };
1054 input_report_parts(check.clone(), check.clone(), check.clone(), check)
1055 }
1056
1057 fn input_report_parts(
1058 ydotool: Check,
1059 ydotoold: Check,
1060 ydotool_socket: Check,
1061 uinput: Check,
1062 ) -> InputReport {
1063 InputReport {
1064 ydotool,
1065 ydotoold,
1066 ydotool_socket,
1067 uinput,
1068 }
1069 }
1070
1071 #[test]
1072 fn accessibility_tree_requires_reachable_at_spi_bus() {
1073 let report = accessibility_report(Check::fail("permission denied"), Check::ok("true"));
1074
1075 assert!(!can_build_accessibility_tree(&report));
1076 }
1077
1078 #[test]
1079 fn accessibility_tree_is_ready_when_bus_and_toolkit_are_ready() {
1080 let report = accessibility_report(
1081 Check::ok("('unix:path=/run/user/1000/at-spi/bus',)"),
1082 Check::ok("true"),
1083 );
1084
1085 assert!(can_build_accessibility_tree(&report));
1086 }
1087
1088 #[test]
1089 fn parses_parent_pid_from_proc_status() {
1090 let status = "Name:\ttest\nPid:\t42\nPPid:\t7\n";
1091
1092 assert_eq!(parse_parent_pid(status), Some(7));
1093 }
1094
1095 #[test]
1096 fn parses_nul_separated_process_environment() {
1097 let environment = parse_environ(
1098 b"DISPLAY=:0\0WAYLAND_DISPLAY=wayland-0\0EMPTY=\0NO_EQUALS\0XDG_SESSION_TYPE=wayland\0",
1099 );
1100
1101 assert_eq!(environment.get("DISPLAY").map(String::as_str), Some(":0"));
1102 assert_eq!(
1103 environment.get("WAYLAND_DISPLAY").map(String::as_str),
1104 Some("wayland-0")
1105 );
1106 assert_eq!(environment.get("EMPTY").map(String::as_str), Some(""));
1107 assert!(!environment.contains_key("NO_EQUALS"));
1108 }
1109
1110 #[test]
1111 fn desktop_env_hydration_includes_xauthority() {
1112 assert!(DESKTOP_ENV_KEYS.contains(&"XAUTHORITY"));
1113 }
1114
1115 #[test]
1116 fn graphical_process_env_requires_display() {
1117 let with_display = HashMap::from([("DISPLAY".to_string(), ":0".to_string())]);
1118 let with_wayland =
1119 HashMap::from([("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string())]);
1120 let without_display = HashMap::from([("XAUTHORITY".to_string(), "/tmp/xauth".to_string())]);
1121
1122 assert!(process_env_has_graphical_display(&with_display));
1123 assert!(process_env_has_graphical_display(&with_wayland));
1124 assert!(!process_env_has_graphical_display(&without_display));
1125 }
1126
1127 #[test]
1128 fn parses_systemd_show_environment_output() {
1129 let environment = parse_line_environment(
1130 b"DISPLAY=:0\nHYPRLAND_INSTANCE_SIGNATURE=abc\nNO_EQUALS\nYDOTOOL_SOCKET=/run/ydotoold/socket\n",
1131 );
1132
1133 assert_eq!(environment.get("DISPLAY").map(String::as_str), Some(":0"));
1134 assert_eq!(
1135 environment
1136 .get("HYPRLAND_INSTANCE_SIGNATURE")
1137 .map(String::as_str),
1138 Some("abc")
1139 );
1140 assert_eq!(
1141 environment.get("YDOTOOL_SOCKET").map(String::as_str),
1142 Some("/run/ydotoold/socket")
1143 );
1144 assert!(!environment.contains_key("NO_EQUALS"));
1145 }
1146
1147 #[test]
1148 fn readiness_requires_exact_window_focus_for_targeted_input() {
1149 let platform = platform_report();
1150 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1151 let windowing = windowing_report(true, false);
1152 let input = input_report(true);
1153
1154 let readiness = readiness_report(
1155 &platform,
1156 &portal_report(Check::fail("missing")),
1157 &accessibility,
1158 &windowing,
1159 &input,
1160 );
1161
1162 assert!(readiness.can_query_windows);
1163 assert!(!readiness.can_focus_windows);
1164 assert!(readiness
1165 .recommended_next_step
1166 .contains("exact-focus window backend"));
1167 assert!(readiness
1168 .blockers
1169 .iter()
1170 .any(|blocker| blocker.contains("Exact window activation")));
1171 }
1172
1173 #[test]
1174 fn readiness_treats_kwin_as_full_window_backend() {
1175 let platform = platform_report();
1176 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1177 let mut windowing = windowing_report(false, false);
1178 windowing.kwin = Check::ok("KWin scripting is available");
1179 windowing.can_list_windows = true;
1180 windowing.can_focus_apps = true;
1181 windowing.can_focus_windows = true;
1182 let input = input_report(true);
1183
1184 let readiness = readiness_report(
1185 &platform,
1186 &portal_report(Check::fail("missing")),
1187 &accessibility,
1188 &windowing,
1189 &input,
1190 );
1191
1192 assert!(readiness.can_query_windows);
1193 assert!(readiness.can_focus_apps);
1194 assert!(readiness.can_focus_windows);
1195 assert!(readiness.blockers.is_empty());
1196 }
1197
1198 #[test]
1199 fn readiness_message_mentions_generic_window_targeting() {
1200 let platform = platform_report();
1201 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1202 let windowing = windowing_report(true, true);
1203 let input = input_report(true);
1204
1205 let readiness = readiness_report(
1206 &platform,
1207 &portal_report(Check::fail("missing")),
1208 &accessibility,
1209 &windowing,
1210 &input,
1211 );
1212
1213 assert!(readiness.blockers.is_empty());
1214 assert!(readiness
1215 .recommended_next_step
1216 .contains("AT-SPI tree support"));
1217 assert!(readiness.recommended_next_step.contains("window targeting"));
1218 assert!(!readiness
1219 .recommended_next_step
1220 .contains("GNOME window targeting"));
1221 }
1222
1223 #[test]
1224 fn readiness_accepts_connectable_ydotool_socket_without_direct_uinput_access() {
1225 let platform = platform_report();
1226 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1227 let windowing = windowing_report(true, true);
1228 let input = input_report_parts(
1229 Check::ok("ydotool"),
1230 Check::ok("ydotoold"),
1231 Check::ok("connectable: /tmp/.ydotool_socket"),
1232 Check::fail("/dev/uinput: Permission denied"),
1233 );
1234
1235 let readiness = readiness_report(
1236 &platform,
1237 &portal_report(Check::fail("missing")),
1238 &accessibility,
1239 &windowing,
1240 &input,
1241 );
1242
1243 assert!(readiness.can_send_development_input);
1244 assert!(readiness.blockers.is_empty());
1245 }
1246
1247 #[test]
1248 fn readiness_accepts_direct_uinput_without_connectable_ydotool_socket() {
1249 let platform = platform_report();
1250 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1251 let windowing = windowing_report(true, true);
1252 let input = input_report_parts(
1253 Check::ok("ydotool"),
1254 Check::fail("ydotoold not running"),
1255 Check::fail("no connectable ydotool socket"),
1256 Check::ok("read/write: /dev/uinput"),
1257 );
1258
1259 let readiness = readiness_report(
1260 &platform,
1261 &portal_report(Check::fail("missing")),
1262 &accessibility,
1263 &windowing,
1264 &input,
1265 );
1266
1267 assert!(readiness.can_send_development_input);
1268 assert!(readiness.blockers.is_empty());
1269 }
1270
1271 #[test]
1272 fn readiness_accepts_remote_desktop_portal_without_local_input_backend() {
1273 let platform = platform_report();
1274 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1275 let windowing = windowing_report(true, true);
1276 let input = input_report_parts(
1277 Check::fail("missing ydotool"),
1278 Check::fail("ydotoold not running"),
1279 Check::fail("no connectable ydotool socket"),
1280 Check::fail("/dev/uinput: Permission denied"),
1281 );
1282
1283 let readiness = readiness_report(
1284 &platform,
1285 &portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop")),
1286 &accessibility,
1287 &windowing,
1288 &input,
1289 );
1290
1291 assert!(readiness.can_send_development_input);
1292 assert!(readiness.blockers.is_empty());
1293 }
1294
1295 #[test]
1296 fn readiness_rejects_inaccessible_ydotool_paths() {
1297 let platform = platform_report();
1298 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1299 let windowing = windowing_report(true, true);
1300 let input = input_report_parts(
1301 Check::ok("ydotool"),
1302 Check::ok("ydotoold"),
1303 Check::fail("/tmp/.ydotool_socket: Permission denied"),
1304 Check::fail("/dev/uinput: Permission denied"),
1305 );
1306
1307 let readiness = readiness_report(
1308 &platform,
1309 &portal_report(Check::fail("missing")),
1310 &accessibility,
1311 &windowing,
1312 &input,
1313 );
1314
1315 assert!(!readiness.can_send_development_input);
1316 assert!(readiness
1317 .recommended_next_step
1318 .contains("Enable a supported input backend"));
1319 assert!(readiness
1320 .blockers
1321 .iter()
1322 .any(|blocker| blocker.contains("Development input is unavailable")));
1323 }
1324
1325 #[test]
1326 fn ydotool_socket_check_requires_a_connectable_socket() {
1327 let dir = std::env::temp_dir().join(format!(
1328 "computer-use-linux-diagnostics-{}",
1329 std::process::id()
1330 ));
1331 let _ = std::fs::remove_dir_all(&dir);
1332 std::fs::create_dir_all(&dir).expect("create temp diagnostics dir");
1333 let socket = dir.join("ydotool.sock");
1334 let listener =
1335 std::os::unix::net::UnixListener::bind(&socket).expect("bind temp diagnostics socket");
1336
1337 let check = socket_connect_check(&socket);
1338
1339 assert!(check.ok, "{check:?}");
1340 drop(listener);
1341 let _ = std::fs::remove_dir_all(&dir);
1342 }
1343
1344 #[test]
1345 fn ydotool_socket_check_accepts_datagram_socket() {
1346 let dir = std::env::temp_dir().join(format!(
1347 "computer-use-linux-diagnostics-dgram-{}",
1348 std::process::id()
1349 ));
1350 let _ = std::fs::remove_dir_all(&dir);
1351 std::fs::create_dir_all(&dir).expect("create temp diagnostics dir");
1352 let socket = dir.join("ydotool.sock");
1353 let datagram =
1354 std::os::unix::net::UnixDatagram::bind(&socket).expect("bind temp datagram socket");
1355
1356 let check = socket_connect_check(&socket);
1357
1358 assert!(check.ok, "{check:?}");
1359 drop(datagram);
1360 let _ = std::fs::remove_dir_all(&dir);
1361 }
1362
1363 #[test]
1364 fn readiness_reports_cosmic_window_blocker_on_cosmic() {
1365 let mut platform = platform_report();
1366 platform.xdg_current_desktop = Some("COSMIC".to_string());
1367 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1368 let windowing = windowing_report(false, false);
1369 let input = input_report(true);
1370
1371 let readiness = readiness_report(
1372 &platform,
1373 &portal_report(Check::fail("missing")),
1374 &accessibility,
1375 &windowing,
1376 &input,
1377 );
1378
1379 assert!(readiness
1380 .blockers
1381 .iter()
1382 .any(|blocker| blocker.contains("COSMIC Wayland window introspection")));
1383 }
1384}