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