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