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