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 let current_env = DESKTOP_ENV_KEYS
389 .iter()
390 .filter_map(|key| env_var(key).map(|value| ((*key).to_string(), value)))
391 .collect();
392 for (key, value) in desktop_env_hydration_updates(¤t_env, process_env) {
393 env::set_var(key, value);
394 }
395}
396
397fn desktop_env_hydration_updates(
398 current_env: &HashMap<String, String>,
399 source_env: &HashMap<String, String>,
400) -> Vec<(&'static str, String)> {
401 let preserve_native_x11 = current_env
405 .get("XDG_SESSION_TYPE")
406 .is_some_and(|value| value.trim().eq_ignore_ascii_case("x11"))
407 && current_env
408 .get("DISPLAY")
409 .is_some_and(|value| !value.trim().is_empty())
410 && !current_env
411 .get("WAYLAND_DISPLAY")
412 .is_some_and(|value| !value.trim().is_empty());
413
414 DESKTOP_ENV_KEYS
415 .iter()
416 .filter_map(|key| {
417 if current_env
418 .get(*key)
419 .is_some_and(|value| !value.trim().is_empty())
420 || preserve_native_x11 && *key == "WAYLAND_DISPLAY"
421 {
422 return None;
423 }
424 source_env
425 .get(*key)
426 .filter(|value| !value.trim().is_empty())
427 .map(|value| (*key, value.clone()))
428 })
429 .collect()
430}
431
432fn desktop_process_environments() -> Vec<HashMap<String, String>> {
433 let mut environments = Vec::new();
434 let mut visited_pids = Vec::new();
435 let mut pid = parent_pid("self");
436
437 for _ in 0..8 {
438 let Some(current_pid) = pid else {
439 break;
440 };
441 if current_pid <= 1 {
442 break;
443 }
444
445 visited_pids.push(current_pid);
446 if let Some(process_env) = read_process_environ(current_pid) {
447 environments.push(process_env);
448 }
449 pid = parent_pid(¤t_pid.to_string());
450 }
451
452 if !visited_pids.contains(&1) && process_owner_matches_current_user(1) {
453 if let Some(process_env) = read_process_environ(1).filter(process_env_has_graphical_display)
454 {
455 environments.push(process_env);
456 }
457 }
458
459 environments
460}
461
462fn parent_pid(pid: &str) -> Option<u32> {
463 let status = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
464 parse_parent_pid(&status)
465}
466
467fn parse_parent_pid(status: &str) -> Option<u32> {
468 status.lines().find_map(|line| {
469 let value = line.strip_prefix("PPid:")?.trim();
470 value.parse::<u32>().ok()
471 })
472}
473
474fn read_process_environ(pid: u32) -> Option<HashMap<String, String>> {
475 let bytes = fs::read(format!("/proc/{pid}/environ")).ok()?;
476 Some(parse_environ(&bytes))
477}
478
479fn process_owner_matches_current_user(pid: u32) -> bool {
480 let Some(current_uid) = user_id().and_then(|uid| uid.parse::<u32>().ok()) else {
481 return false;
482 };
483 fs::metadata(format!("/proc/{pid}"))
484 .ok()
485 .is_some_and(|metadata| metadata.uid() == current_uid)
486}
487
488fn process_env_has_graphical_display(process_env: &HashMap<String, String>) -> bool {
489 process_env
490 .get("DISPLAY")
491 .or_else(|| process_env.get("WAYLAND_DISPLAY"))
492 .is_some_and(|value| !value.trim().is_empty())
493}
494
495fn parse_environ(bytes: &[u8]) -> HashMap<String, String> {
496 bytes
497 .split(|byte| *byte == 0)
498 .filter_map(|entry| {
499 if entry.is_empty() {
500 return None;
501 }
502 let split = entry.iter().position(|byte| *byte == b'=')?;
503 let (key, value) = entry.split_at(split);
504 let value = &value[1..];
505 let key = std::str::from_utf8(key).ok()?.to_string();
506 let value = std::str::from_utf8(value).ok()?.to_string();
507 Some((key, value))
508 })
509 .collect()
510}
511
512fn parse_line_environment(bytes: &[u8]) -> HashMap<String, String> {
513 bytes
514 .split(|byte| *byte == b'\n')
515 .filter_map(|entry| {
516 if entry.is_empty() {
517 return None;
518 }
519 let split = entry.iter().position(|byte| *byte == b'=')?;
520 let (key, value) = entry.split_at(split);
521 let value = &value[1..];
522 let key = std::str::from_utf8(key).ok()?.to_string();
523 let value = std::str::from_utf8(value).ok()?.to_string();
524 Some((key, value))
525 })
526 .collect()
527}
528
529pub fn setup_accessibility_report() -> SetupReport {
530 hydrate_session_bus_env();
531
532 let before = doctor_report();
533 let accessibility_command = if can_build_accessibility_tree(&before.accessibility) {
534 Check::ok("AT-SPI accessibility is already enabled")
535 } else {
536 let atspi_status = command_check_with_session_bus(
537 "busctl",
538 &[
539 "--user",
540 "set-property",
541 "org.a11y.Bus",
542 "/org/a11y/bus",
543 "org.a11y.Status",
544 "IsEnabled",
545 "b",
546 "true",
547 ],
548 );
549 if atspi_status.ok {
550 atspi_status
551 } else {
552 command_check_with_session_bus(
553 "gsettings",
554 &[
555 "set",
556 "org.gnome.desktop.interface",
557 "toolkit-accessibility",
558 "true",
559 ],
560 )
561 }
562 };
563 let after = doctor_report();
564 let before_ready = before.readiness.can_build_accessibility_tree;
565 let after_ready = after.readiness.can_build_accessibility_tree;
566 let changed_accessibility = !before_ready && after_ready;
567 let requires_target_app_restart = changed_accessibility;
568 let message = if after_ready {
569 if changed_accessibility {
570 "AT-SPI accessibility is enabled. Restart already-running target apps if their AT-SPI tree is still empty."
571 } else {
572 "AT-SPI accessibility is ready."
573 }
574 } else {
575 "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."
576 }
577 .to_string();
578
579 SetupReport {
580 before,
581 accessibility_command,
582 after,
583 changed_accessibility,
584 requires_target_app_restart,
585 message,
586 }
587}
588
589fn platform_report() -> PlatformReport {
590 PlatformReport {
591 os: std::env::consts::OS.to_string(),
592 arch: std::env::consts::ARCH.to_string(),
593 desktop_session: env_var("DESKTOP_SESSION"),
594 xdg_session_type: env_var("XDG_SESSION_TYPE"),
595 xdg_current_desktop: env_var("XDG_CURRENT_DESKTOP"),
596 wayland_display: env_var("WAYLAND_DISPLAY"),
597 display: env_var("DISPLAY"),
598 xauthority: env_var("XAUTHORITY"),
599 dbus_session_bus_address: dbus_session_address(),
600 xdg_runtime_dir: xdg_runtime_dir().map(|path| path.display().to_string()),
601 gnome_shell_version: command_check("gnome-shell", &["--version"]),
602 gnome_screenshot: command_check("gnome-screenshot", &["--version"]),
603 }
604}
605
606fn portal_report() -> PortalReport {
607 PortalReport {
608 desktop_portal: bus_name_check("org.freedesktop.portal.Desktop"),
609 remote_desktop: portal_interface_check("org.freedesktop.portal.RemoteDesktop"),
610 screencast: portal_interface_check("org.freedesktop.portal.ScreenCast"),
611 screenshot: portal_interface_check("org.freedesktop.portal.Screenshot"),
612 input_capture: portal_interface_check("org.freedesktop.portal.InputCapture"),
613 mutter_remote_desktop: bus_name_check("org.gnome.Mutter.RemoteDesktop"),
614 mutter_screencast: bus_name_check("org.gnome.Mutter.ScreenCast"),
615 }
616}
617
618fn accessibility_report() -> AccessibilityReport {
619 AccessibilityReport {
620 at_spi_bus: atspi_bus_address_check(),
621 toolkit_accessibility: command_check_with_session_bus(
622 "gsettings",
623 &[
624 "get",
625 "org.gnome.desktop.interface",
626 "toolkit-accessibility",
627 ],
628 ),
629 at_spi_enabled: atspi_status_property_check("IsEnabled"),
630 screen_reader_enabled: atspi_status_property_check("ScreenReaderEnabled"),
631 }
632}
633
634fn windowing_report(platform: &PlatformReport) -> WindowingReport {
635 let probes = registry::probe_backends();
636 let backend_check = |id: &str| {
637 probes
638 .iter()
639 .find(|probe| probe.id == id)
640 .map(check_from_backend_probe)
641 .unwrap_or_else(|| Check::fail("backend probe did not run"))
642 };
643 let gnome_shell_introspect = backend_check(GNOME_SHELL_INTROSPECT_BACKEND);
644 let computer_use_linux_gnome_shell_extension = backend_check(GNOME_SHELL_EXTENSION_BACKEND);
645 let cosmic_helper = backend_check(COSMIC_WAYLAND_BACKEND);
646 let kwin = backend_check(KWIN_BACKEND);
647 let hyprland = backend_check(HYPRLAND_BACKEND);
648 let backends = probes
649 .iter()
650 .map(|probe| (probe.id.to_string(), check_from_backend_probe(probe)))
651 .collect::<BTreeMap<_, _>>();
652 let can_list_windows = probes.iter().any(|probe| probe.can_list_windows);
653 let can_focus_apps = probes.iter().any(|probe| probe.can_focus_apps);
654 let can_focus_windows = probes.iter().any(|probe| probe.can_focus_windows);
655 let note = if can_list_windows {
656 if !can_focus_windows {
657 "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)."
658 } else if cosmic_helper.ok && is_cosmic_wayland_platform(platform) {
659 "A COSMIC Wayland window backend is available for list_windows, focused_window, and targeted input verification."
660 } else if kwin.ok {
661 "A KWin/Plasma window backend is available for list_windows, focused_window, and targeted input verification."
662 } else if hyprland.ok {
663 "A Hyprland window backend is available for list_windows, focused_window, and targeted input verification."
664 } else {
665 "A window listing backend is available for list_windows, focused_window, and targeted input verification."
666 }
667 } else {
668 "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."
669 }
670 .to_string();
671
672 WindowingReport {
673 gnome_shell_introspect,
674 computer_use_linux_gnome_shell_extension,
675 cosmic_helper,
676 kwin,
677 hyprland,
678 backends,
679 can_list_windows,
680 can_focus_apps,
681 can_focus_windows,
682 note,
683 }
684}
685
686fn check_from_backend_probe(probe: ®istry::BackendProbe) -> Check {
687 if probe.ok {
688 Check::ok(probe.detail.clone())
689 } else {
690 Check::fail(probe.detail.clone())
691 }
692}
693
694fn input_report() -> InputReport {
695 InputReport {
696 ydotool: match ydotool::ensure_supported() {
697 Ok(support) => Check::ok(support.detail),
698 Err(detail) => Check::fail(detail),
699 },
700 ydotoold: process_check("ydotoold"),
701 ydotool_socket: ydotool_socket_check(),
702 uinput: read_write_path_check(Path::new("/dev/uinput")),
703 xdotool: command_path_check("xdotool"),
704 }
705}
706
707fn readiness_report(
708 platform: &PlatformReport,
709 portals: &PortalReport,
710 accessibility: &AccessibilityReport,
711 windowing: &WindowingReport,
712 input: &InputReport,
713) -> ReadinessReport {
714 let mut blockers = Vec::new();
715 let can_build_accessibility_tree = can_build_accessibility_tree(accessibility);
716 let can_query_windows = windowing.can_list_windows;
717 let can_focus_apps = windowing.can_focus_apps;
718 let can_focus_windows = windowing.can_focus_windows;
719 let can_send_development_input = can_send_development_input(platform, portals, input);
720
721 if !can_build_accessibility_tree {
722 blockers.push(
723 "AT-SPI accessibility is disabled; enable org.a11y.Status IsEnabled or org.gnome.desktop.interface toolkit-accessibility for tree extraction."
724 .to_string(),
725 );
726 }
727
728 if !can_query_windows {
729 blockers.push(if is_cosmic_wayland_platform(platform) {
730 "COSMIC Wayland window introspection is unavailable; targeted window focus and verification will be disabled.".to_string()
731 } else {
732 "Window introspection is unavailable; targeted window focus and verification will be disabled."
733 .to_string()
734 });
735 }
736
737 if can_query_windows && !can_focus_windows {
738 blockers.push(
739 "Exact window activation is unavailable; app-level focus may work, but window_id/title/terminal-targeted input cannot be verified."
740 .to_string(),
741 );
742 }
743
744 if !can_send_development_input {
745 blockers.push(
746 "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."
747 .to_string(),
748 );
749 }
750
751 let recommended_next_step = if !can_build_accessibility_tree {
752 "Run setup_accessibility to enable AT-SPI accessibility before element-aware actions."
753 .to_string()
754 } else if !can_query_windows {
755 format!(
756 "Enable a supported window backend before using targeted keyboard input: {}",
757 registry::descriptors()
758 .iter()
759 .map(|descriptor| descriptor.missing_hint)
760 .collect::<Vec<_>>()
761 .join(" ")
762 )
763 } else if !can_focus_windows {
764 "Enable an exact-focus window backend before using window_id, title, or terminal-targeted input.".to_string()
765 } else if !can_send_development_input {
766 "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."
767 .to_string()
768 } else {
769 "Computer Use is ready: AT-SPI tree support, window targeting, and a Linux input backend are available."
770 .to_string()
771 };
772
773 ReadinessReport {
774 can_register_mcp_tools: true,
775 can_build_accessibility_tree,
776 can_query_windows,
777 can_focus_apps,
778 can_focus_windows,
779 can_send_development_input,
780 recommended_next_step,
781 blockers,
782 }
783}
784
785fn can_send_development_input(
786 platform: &PlatformReport,
787 portals: &PortalReport,
788 input: &InputReport,
789) -> bool {
790 let force_ydotool = env_flag_enabled_any(FORCE_YDOTOOL_KEYBOARD_ENV_KEYS);
791 let force_xdotool = env_flag_enabled_any(FORCE_XDOTOOL_KEYBOARD_ENV_KEYS);
792 input.uinput.ok
793 || portal_input_available(platform, portals)
794 || should_advertise_xdotool(platform, input, force_ydotool, force_xdotool)
795 || input.ydotool.ok && input.ydotool_socket.ok
796}
797
798fn portal_input_available(platform: &PlatformReport, portals: &PortalReport) -> bool {
799 platform_is_wayland(platform) && portals.remote_desktop.ok
800}
801
802fn is_cosmic_wayland_platform(platform: &PlatformReport) -> bool {
803 platform
804 .xdg_current_desktop
805 .as_deref()
806 .is_some_and(|desktop| desktop.to_ascii_lowercase().contains("cosmic"))
807 && platform.xdg_session_type.as_deref() == Some("wayland")
808}
809
810fn can_build_accessibility_tree(accessibility: &AccessibilityReport) -> bool {
811 accessibility.at_spi_bus.ok
812 && (check_detail_contains_true(&accessibility.at_spi_enabled)
813 || check_detail_contains_true(&accessibility.toolkit_accessibility))
814}
815
816fn check_detail_contains_true(check: &Check) -> bool {
817 check.ok && check.detail.to_ascii_lowercase().contains("true")
818}
819
820fn env_var(key: &str) -> Option<String> {
821 env::var(key).ok().filter(|value| !value.trim().is_empty())
822}
823
824fn xdg_runtime_dir() -> Option<PathBuf> {
825 if let Some(value) = env_var("XDG_RUNTIME_DIR") {
826 return Some(PathBuf::from(value));
827 }
828 user_id().map(|uid| PathBuf::from(format!("/run/user/{uid}")))
829}
830
831fn dbus_session_address() -> Option<String> {
832 if let Some(value) = env_var("DBUS_SESSION_BUS_ADDRESS") {
833 return Some(value);
834 }
835 xdg_runtime_dir()
836 .map(|runtime| format!("unix:path={}", runtime.join("bus").display()))
837 .filter(|address| {
838 address
839 .strip_prefix("unix:path=")
840 .is_some_and(|p| Path::new(p).exists())
841 })
842}
843
844fn ydotool_socket_candidates() -> Vec<PathBuf> {
845 let mut candidates = Vec::new();
846 if let Some(value) = env_var("YDOTOOL_SOCKET") {
847 candidates.push(PathBuf::from(value));
848 }
849
850 if let Some(runtime_socket) = xdg_runtime_dir().map(|runtime| runtime.join(".ydotool_socket")) {
851 candidates.push(runtime_socket);
852 }
853 candidates.push(PathBuf::from("/tmp/.ydotool_socket"));
854 candidates
855}
856
857fn ydotool_socket_check() -> Check {
858 let mut checked = Vec::new();
859 for candidate in ydotool_socket_candidates() {
860 match socket_connect_result(&candidate) {
861 Ok(()) => return Check::ok(format!("connectable: {}", candidate.display())),
862 Err(detail) => checked.push(detail),
863 }
864 }
865
866 Check::fail(format!(
867 "no connectable ydotool socket ({})",
868 checked.join("; ")
869 ))
870}
871
872fn user_id() -> Option<String> {
873 let output = Command::new("id").arg("-u").output().ok()?;
874 output
875 .status
876 .success()
877 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
878 .filter(|value| !value.is_empty())
879}
880
881fn command_path_check(command: &str) -> Check {
882 command_check("sh", &["-c", &format!("command -v {command}")])
883}
884
885fn platform_is_wayland(platform: &PlatformReport) -> bool {
886 match platform.xdg_session_type.as_deref() {
887 Some(value) => value.eq_ignore_ascii_case("wayland"),
888 None => platform
889 .wayland_display
890 .as_deref()
891 .is_some_and(|display| !display.trim().is_empty()),
892 }
893}
894
895fn should_advertise_xdotool(
896 platform: &PlatformReport,
897 input: &InputReport,
898 force_ydotool: bool,
899 force_xdotool: bool,
900) -> bool {
901 !force_ydotool
902 && input.xdotool.ok
903 && platform
904 .display
905 .as_deref()
906 .is_some_and(|display| !display.trim().is_empty())
907 && (force_xdotool || !platform_is_wayland(platform))
908}
909
910fn env_flag_enabled_any(keys: &[&str]) -> bool {
911 keys.iter()
912 .any(|key| env::var(key).ok().as_deref() == Some("1"))
913}
914
915fn force_portal_for_all_input(
916 force_portal_pointer: bool,
917 force_portal_keyboard: bool,
918 force_ydotool_pointer: bool,
919 force_ydotool_keyboard: bool,
920) -> bool {
921 force_portal_pointer
922 && force_portal_keyboard
923 && !force_ydotool_pointer
924 && !force_ydotool_keyboard
925}
926
927fn process_check(process_name: &str) -> Check {
928 command_check("pgrep", &["-a", process_name])
929}
930
931#[cfg(test)]
932fn socket_connect_check(path: &Path) -> Check {
933 match socket_connect_result(path) {
934 Ok(()) => Check::ok(format!("connectable: {}", path.display())),
935 Err(detail) => Check::fail(detail),
936 }
937}
938
939fn socket_connect_result(path: &Path) -> std::result::Result<(), String> {
940 if !path.exists() {
941 return Err(format!("missing: {}", path.display()));
942 }
943
944 UnixDatagram::unbound()
945 .and_then(|socket| socket.connect(path))
946 .map_err(|error| format!("{}: datagram: {error}", path.display()))
947}
948
949fn read_write_path_check(path: &Path) -> Check {
950 if !path.exists() {
951 return Check::fail(format!("missing: {}", path.display()));
952 }
953
954 match OpenOptions::new().read(true).write(true).open(path) {
955 Ok(_) => Check::ok(format!("read/write: {}", path.display())),
956 Err(error) => Check::fail(format!("{}: {error}", path.display())),
957 }
958}
959
960fn bus_name_check(name: &str) -> Check {
961 command_check_with_session_bus("busctl", &["--user", "status", name])
962}
963
964fn portal_interface_check(interface: &str) -> Check {
965 command_check_with_session_bus(
966 "busctl",
967 &[
968 "--user",
969 "introspect",
970 "org.freedesktop.portal.Desktop",
971 "/org/freedesktop/portal/desktop",
972 interface,
973 ],
974 )
975}
976
977fn atspi_bus_address_check() -> Check {
978 let busctl = command_check_with_session_bus(
979 "busctl",
980 &[
981 "--user",
982 "call",
983 "org.a11y.Bus",
984 "/org/a11y/bus",
985 "org.a11y.Bus",
986 "GetAddress",
987 ],
988 );
989 if busctl.ok {
990 return busctl;
991 }
992
993 gdbus_call_check(
994 "org.a11y.Bus",
995 "/org/a11y/bus",
996 "org.a11y.Bus.GetAddress",
997 &[],
998 )
999}
1000
1001fn atspi_status_property_check(property: &str) -> Check {
1002 let busctl = command_check_with_session_bus(
1003 "busctl",
1004 &[
1005 "--user",
1006 "get-property",
1007 "org.a11y.Bus",
1008 "/org/a11y/bus",
1009 "org.a11y.Status",
1010 property,
1011 ],
1012 );
1013 if busctl.ok {
1014 return busctl;
1015 }
1016
1017 gdbus_call_check(
1018 "org.a11y.Bus",
1019 "/org/a11y/bus",
1020 "org.freedesktop.DBus.Properties.Get",
1021 &["org.a11y.Status", property],
1022 )
1023}
1024
1025fn gdbus_call_check(destination: &str, object_path: &str, method: &str, args: &[&str]) -> Check {
1026 let mut command_args = vec![
1027 "call",
1028 "--session",
1029 "--dest",
1030 destination,
1031 "--object-path",
1032 object_path,
1033 "--method",
1034 method,
1035 ];
1036 command_args.extend_from_slice(args);
1037 command_check_with_session_bus("gdbus", &command_args)
1038}
1039
1040fn command_check(command: &str, args: &[&str]) -> Check {
1041 run_command(command, args, false)
1042}
1043
1044fn command_check_with_session_bus(command: &str, args: &[&str]) -> Check {
1045 run_command(command, args, true)
1046}
1047
1048fn run_command(command: &str, args: &[&str], with_session_bus: bool) -> Check {
1049 let mut cmd = Command::new(command);
1050 cmd.args(args);
1051
1052 if with_session_bus {
1053 if let Some(address) = dbus_session_address() {
1054 cmd.env("DBUS_SESSION_BUS_ADDRESS", address);
1055 }
1056 if let Some(runtime) = xdg_runtime_dir() {
1057 cmd.env("XDG_RUNTIME_DIR", runtime);
1058 }
1059 }
1060
1061 match cmd.output() {
1062 Ok(output) if output.status.success() => {
1063 let detail = String::from_utf8_lossy(&output.stdout).trim().to_string();
1064 Check::ok(if detail.is_empty() {
1065 "ok".into()
1066 } else {
1067 detail
1068 })
1069 }
1070 Ok(output) => {
1071 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1072 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
1073 let detail = if !stderr.is_empty() { stderr } else { stdout };
1074 Check::fail(if detail.is_empty() {
1075 format!("exit status {}", output.status)
1076 } else {
1077 detail
1078 })
1079 }
1080 Err(error) => Check::fail(error.to_string()),
1081 }
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086 use super::*;
1087
1088 fn platform_report() -> PlatformReport {
1089 PlatformReport {
1090 os: "linux".to_string(),
1091 arch: "x86_64".to_string(),
1092 desktop_session: None,
1093 xdg_session_type: Some("wayland".to_string()),
1094 xdg_current_desktop: Some("GNOME".to_string()),
1095 wayland_display: Some("wayland-0".to_string()),
1096 display: Some(":0".to_string()),
1097 xauthority: Some("/run/user/1000/Xauthority".to_string()),
1098 dbus_session_bus_address: Some("unix:path=/run/user/1000/bus".to_string()),
1099 xdg_runtime_dir: Some("/run/user/1000".to_string()),
1100 gnome_shell_version: Check::ok("GNOME Shell 46.0"),
1101 gnome_screenshot: Check::ok("gnome-screenshot 41.0"),
1102 }
1103 }
1104
1105 fn portal_report(remote_desktop: Check) -> PortalReport {
1106 PortalReport {
1107 desktop_portal: Check::ok("ok"),
1108 remote_desktop,
1109 screencast: Check::fail("missing"),
1110 screenshot: Check::fail("missing"),
1111 input_capture: Check::fail("missing"),
1112 mutter_remote_desktop: Check::fail("missing"),
1113 mutter_screencast: Check::fail("missing"),
1114 }
1115 }
1116
1117 fn accessibility_report(
1118 at_spi_bus: Check,
1119 toolkit_accessibility: Check,
1120 ) -> AccessibilityReport {
1121 AccessibilityReport {
1122 at_spi_bus,
1123 toolkit_accessibility,
1124 at_spi_enabled: Check::fail("(<false>,)"),
1125 screen_reader_enabled: Check::fail("(<false>,)"),
1126 }
1127 }
1128
1129 fn windowing_report(can_list_windows: bool, can_focus_windows: bool) -> WindowingReport {
1130 WindowingReport {
1131 gnome_shell_introspect: if can_list_windows {
1132 Check::ok("ok")
1133 } else {
1134 Check::fail("denied")
1135 },
1136 computer_use_linux_gnome_shell_extension: if can_focus_windows {
1137 Check::ok("ok")
1138 } else {
1139 Check::fail("missing")
1140 },
1141 cosmic_helper: Check::fail("missing"),
1142 kwin: Check::fail("not a KWin session"),
1143 hyprland: Check::fail("not a Hyprland session"),
1144 backends: BTreeMap::new(),
1145 can_list_windows,
1146 can_focus_apps: true,
1147 can_focus_windows,
1148 note: String::new(),
1149 }
1150 }
1151
1152 fn input_report(can_send_input: bool) -> InputReport {
1153 let check = if can_send_input {
1154 Check::ok("ok")
1155 } else {
1156 Check::fail("missing")
1157 };
1158 input_report_parts(check.clone(), check.clone(), check.clone(), check)
1159 }
1160
1161 fn input_report_parts(
1162 ydotool: Check,
1163 ydotoold: Check,
1164 ydotool_socket: Check,
1165 uinput: Check,
1166 ) -> InputReport {
1167 InputReport {
1168 ydotool,
1169 ydotoold,
1170 ydotool_socket,
1171 uinput,
1172 xdotool: Check::fail("missing xdotool"),
1173 }
1174 }
1175
1176 #[test]
1177 fn accessibility_tree_requires_reachable_at_spi_bus() {
1178 let report = accessibility_report(Check::fail("permission denied"), Check::ok("true"));
1179
1180 assert!(!can_build_accessibility_tree(&report));
1181 }
1182
1183 #[test]
1184 fn accessibility_tree_is_ready_when_bus_and_toolkit_are_ready() {
1185 let report = accessibility_report(
1186 Check::ok("('unix:path=/run/user/1000/at-spi/bus',)"),
1187 Check::ok("true"),
1188 );
1189
1190 assert!(can_build_accessibility_tree(&report));
1191 }
1192
1193 #[test]
1194 fn parses_parent_pid_from_proc_status() {
1195 let status = "Name:\ttest\nPid:\t42\nPPid:\t7\n";
1196
1197 assert_eq!(parse_parent_pid(status), Some(7));
1198 }
1199
1200 #[test]
1201 fn parses_nul_separated_process_environment() {
1202 let environment = parse_environ(
1203 b"DISPLAY=:0\0WAYLAND_DISPLAY=wayland-0\0EMPTY=\0NO_EQUALS\0XDG_SESSION_TYPE=wayland\0",
1204 );
1205
1206 assert_eq!(environment.get("DISPLAY").map(String::as_str), Some(":0"));
1207 assert_eq!(
1208 environment.get("WAYLAND_DISPLAY").map(String::as_str),
1209 Some("wayland-0")
1210 );
1211 assert_eq!(environment.get("EMPTY").map(String::as_str), Some(""));
1212 assert!(!environment.contains_key("NO_EQUALS"));
1213 }
1214
1215 #[test]
1216 fn desktop_env_hydration_includes_xauthority() {
1217 assert!(DESKTOP_ENV_KEYS.contains(&"XAUTHORITY"));
1218 }
1219
1220 #[test]
1221 fn desktop_env_hydration_preserves_explicit_native_x11() {
1222 let current_env = HashMap::from([
1223 ("DISPLAY".to_string(), ":90".to_string()),
1224 ("XDG_SESSION_TYPE".to_string(), "x11".to_string()),
1225 ]);
1226 let host_env = HashMap::from([
1227 ("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string()),
1228 (
1229 "XDG_CURRENT_DESKTOP".to_string(),
1230 "ubuntu:GNOME".to_string(),
1231 ),
1232 ]);
1233
1234 let updates = desktop_env_hydration_updates(¤t_env, &host_env);
1235
1236 assert!(!updates.iter().any(|(key, _)| *key == "WAYLAND_DISPLAY"));
1237 assert!(updates
1238 .iter()
1239 .any(|(key, value)| { *key == "XDG_CURRENT_DESKTOP" && value == "ubuntu:GNOME" }));
1240 }
1241
1242 #[test]
1243 fn desktop_env_hydration_still_imports_wayland_for_incomplete_sessions() {
1244 let current_env = HashMap::new();
1245 let host_env = HashMap::from([("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string())]);
1246
1247 let updates = desktop_env_hydration_updates(¤t_env, &host_env);
1248
1249 assert!(updates
1250 .iter()
1251 .any(|(key, value)| *key == "WAYLAND_DISPLAY" && value == "wayland-0"));
1252 }
1253
1254 #[test]
1255 fn graphical_process_env_requires_display() {
1256 let with_display = HashMap::from([("DISPLAY".to_string(), ":0".to_string())]);
1257 let with_wayland =
1258 HashMap::from([("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string())]);
1259 let without_display = HashMap::from([("XAUTHORITY".to_string(), "/tmp/xauth".to_string())]);
1260
1261 assert!(process_env_has_graphical_display(&with_display));
1262 assert!(process_env_has_graphical_display(&with_wayland));
1263 assert!(!process_env_has_graphical_display(&without_display));
1264 }
1265
1266 #[test]
1267 fn capabilities_prefer_xdotool_before_ydotool_on_x11() {
1268 let mut platform = platform_report();
1269 platform.xdg_session_type = Some("x11".to_string());
1270 platform.wayland_display = None;
1271 platform.display = Some(":0".to_string());
1272 let input = InputReport {
1273 ydotool: Check::ok("ydotool"),
1274 ydotoold: Check::ok("ydotoold"),
1275 ydotool_socket: Check::ok("connectable"),
1276 uinput: Check::fail("missing"),
1277 xdotool: Check::ok("xdotool"),
1278 };
1279
1280 let capabilities = capability_map(
1281 &platform,
1282 &portal_report(Check::fail("missing")),
1283 &accessibility_report(Check::fail("missing"), Check::fail("missing")),
1284 &windowing_report(false, false),
1285 &input,
1286 );
1287
1288 assert_eq!(capabilities.input, ["xdotool", "ydotool"]);
1289 assert_eq!(capabilities.preferred.input.as_deref(), Some("xdotool"));
1290 }
1291
1292 #[test]
1293 fn x11_diagnostics_ignore_portal_and_accept_xdotool() {
1294 let mut platform = platform_report();
1295 platform.xdg_session_type = Some("x11".to_string());
1296 platform.wayland_display = None;
1297 platform.display = Some(":0".to_string());
1298 let portals = portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop"));
1299 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1300 let windowing = windowing_report(true, true);
1301 let mut input = input_report(false);
1302 input.xdotool = Check::ok("xdotool");
1303
1304 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1305 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
1306
1307 assert_eq!(capabilities.input, ["xdotool"]);
1308 assert_eq!(capabilities.preferred.input.as_deref(), Some("xdotool"));
1309 assert!(readiness.can_send_development_input);
1310 }
1311
1312 #[test]
1313 fn wayland_diagnostics_prefer_ydotool_before_portal() {
1314 let platform = platform_report();
1315 let portals = portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop"));
1316 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1317 let windowing = windowing_report(true, true);
1318 let input = input_report_parts(
1319 Check::ok("ydotool"),
1320 Check::ok("ydotoold"),
1321 Check::ok("connectable"),
1322 Check::fail("missing uinput"),
1323 );
1324
1325 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1326
1327 assert_eq!(capabilities.input, ["ydotool", "portal"]);
1328 assert_eq!(capabilities.preferred.input.as_deref(), Some("ydotool"));
1329 }
1330
1331 #[test]
1332 fn portal_force_order_requires_both_input_modalities() {
1333 assert!(force_portal_for_all_input(true, true, false, false));
1334 assert!(!force_portal_for_all_input(true, false, false, false));
1335 assert!(!force_portal_for_all_input(true, true, true, false));
1336 assert!(!force_portal_for_all_input(true, true, false, true));
1337 }
1338
1339 #[test]
1340 fn capabilities_require_display_to_advertise_xdotool() {
1341 let mut platform = platform_report();
1342 platform.xdg_session_type = Some("x11".to_string());
1343 platform.wayland_display = None;
1344 platform.display = None;
1345 let input = InputReport {
1346 ydotool: Check::ok("ydotool"),
1347 ydotoold: Check::ok("ydotoold"),
1348 ydotool_socket: Check::ok("connectable"),
1349 uinput: Check::fail("missing"),
1350 xdotool: Check::ok("xdotool"),
1351 };
1352
1353 let capabilities = capability_map(
1354 &platform,
1355 &portal_report(Check::fail("missing")),
1356 &accessibility_report(Check::fail("missing"), Check::fail("missing")),
1357 &windowing_report(false, false),
1358 &input,
1359 );
1360
1361 assert_eq!(capabilities.input, ["ydotool"]);
1362 assert_eq!(capabilities.preferred.input.as_deref(), Some("ydotool"));
1363 }
1364
1365 #[test]
1366 fn xdotool_diagnostics_force_precedence_matches_runtime() {
1367 let mut platform = platform_report();
1368 platform.xdg_session_type = Some("wayland".to_string());
1369 platform.display = Some(":0".to_string());
1370 let mut input = input_report(false);
1371 input.xdotool = Check::ok("xdotool");
1372
1373 assert!(should_advertise_xdotool(&platform, &input, false, true));
1374 assert!(!should_advertise_xdotool(&platform, &input, true, true));
1375 }
1376
1377 #[test]
1378 fn parses_systemd_show_environment_output() {
1379 let environment = parse_line_environment(
1380 b"DISPLAY=:0\nHYPRLAND_INSTANCE_SIGNATURE=abc\nNO_EQUALS\nYDOTOOL_SOCKET=/run/ydotoold/socket\n",
1381 );
1382
1383 assert_eq!(environment.get("DISPLAY").map(String::as_str), Some(":0"));
1384 assert_eq!(
1385 environment
1386 .get("HYPRLAND_INSTANCE_SIGNATURE")
1387 .map(String::as_str),
1388 Some("abc")
1389 );
1390 assert_eq!(
1391 environment.get("YDOTOOL_SOCKET").map(String::as_str),
1392 Some("/run/ydotoold/socket")
1393 );
1394 assert!(!environment.contains_key("NO_EQUALS"));
1395 }
1396
1397 #[test]
1398 fn readiness_requires_exact_window_focus_for_targeted_input() {
1399 let platform = platform_report();
1400 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1401 let windowing = windowing_report(true, false);
1402 let input = input_report(true);
1403
1404 let readiness = readiness_report(
1405 &platform,
1406 &portal_report(Check::fail("missing")),
1407 &accessibility,
1408 &windowing,
1409 &input,
1410 );
1411
1412 assert!(readiness.can_query_windows);
1413 assert!(!readiness.can_focus_windows);
1414 assert!(readiness
1415 .recommended_next_step
1416 .contains("exact-focus window backend"));
1417 assert!(readiness
1418 .blockers
1419 .iter()
1420 .any(|blocker| blocker.contains("Exact window activation")));
1421 }
1422
1423 #[test]
1424 fn readiness_treats_kwin_as_full_window_backend() {
1425 let platform = platform_report();
1426 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1427 let mut windowing = windowing_report(false, false);
1428 windowing.kwin = Check::ok("KWin scripting is available");
1429 windowing.can_list_windows = true;
1430 windowing.can_focus_apps = true;
1431 windowing.can_focus_windows = true;
1432 let input = input_report(true);
1433
1434 let readiness = readiness_report(
1435 &platform,
1436 &portal_report(Check::fail("missing")),
1437 &accessibility,
1438 &windowing,
1439 &input,
1440 );
1441
1442 assert!(readiness.can_query_windows);
1443 assert!(readiness.can_focus_apps);
1444 assert!(readiness.can_focus_windows);
1445 assert!(readiness.blockers.is_empty());
1446 }
1447
1448 #[test]
1449 fn readiness_message_mentions_generic_window_targeting() {
1450 let platform = platform_report();
1451 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1452 let windowing = windowing_report(true, true);
1453 let input = input_report(true);
1454
1455 let readiness = readiness_report(
1456 &platform,
1457 &portal_report(Check::fail("missing")),
1458 &accessibility,
1459 &windowing,
1460 &input,
1461 );
1462
1463 assert!(readiness.blockers.is_empty());
1464 assert!(readiness
1465 .recommended_next_step
1466 .contains("AT-SPI tree support"));
1467 assert!(readiness.recommended_next_step.contains("window targeting"));
1468 assert!(!readiness
1469 .recommended_next_step
1470 .contains("GNOME window targeting"));
1471 }
1472
1473 #[test]
1474 fn readiness_accepts_connectable_ydotool_socket_without_direct_uinput_access() {
1475 let platform = platform_report();
1476 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1477 let windowing = windowing_report(true, true);
1478 let input = input_report_parts(
1479 Check::ok("ydotool"),
1480 Check::ok("ydotoold"),
1481 Check::ok("connectable: /tmp/.ydotool_socket"),
1482 Check::fail("/dev/uinput: Permission denied"),
1483 );
1484
1485 let readiness = readiness_report(
1486 &platform,
1487 &portal_report(Check::fail("missing")),
1488 &accessibility,
1489 &windowing,
1490 &input,
1491 );
1492
1493 assert!(readiness.can_send_development_input);
1494 assert!(readiness.blockers.is_empty());
1495 }
1496
1497 #[test]
1498 fn readiness_uses_connectable_ydotool_socket_when_process_probe_fails() {
1499 let platform = platform_report();
1500 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1501 let windowing = windowing_report(true, true);
1502 let input = input_report_parts(
1503 Check::ok("ydotool"),
1504 Check::fail("ydotoold process name not found"),
1505 Check::ok("connectable: /run/user/1000/.ydotool_socket"),
1506 Check::fail("/dev/uinput: Permission denied"),
1507 );
1508 let portals = portal_report(Check::fail("missing"));
1509
1510 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1511 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
1512
1513 assert!(capabilities
1514 .input
1515 .iter()
1516 .any(|backend| backend == "ydotool"));
1517 assert!(readiness.can_send_development_input);
1518 }
1519
1520 #[test]
1521 fn readiness_accepts_direct_uinput_without_connectable_ydotool_socket() {
1522 let platform = platform_report();
1523 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1524 let windowing = windowing_report(true, true);
1525 let input = input_report_parts(
1526 Check::ok("ydotool"),
1527 Check::fail("ydotoold not running"),
1528 Check::fail("no connectable ydotool socket"),
1529 Check::ok("read/write: /dev/uinput"),
1530 );
1531
1532 let readiness = readiness_report(
1533 &platform,
1534 &portal_report(Check::fail("missing")),
1535 &accessibility,
1536 &windowing,
1537 &input,
1538 );
1539
1540 assert!(readiness.can_send_development_input);
1541 assert!(readiness.blockers.is_empty());
1542 }
1543
1544 #[test]
1545 fn readiness_accepts_remote_desktop_portal_without_local_input_backend() {
1546 let platform = platform_report();
1547 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1548 let windowing = windowing_report(true, true);
1549 let input = input_report_parts(
1550 Check::fail("missing ydotool"),
1551 Check::fail("ydotoold not running"),
1552 Check::fail("no connectable ydotool socket"),
1553 Check::fail("/dev/uinput: Permission denied"),
1554 );
1555
1556 let readiness = readiness_report(
1557 &platform,
1558 &portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop")),
1559 &accessibility,
1560 &windowing,
1561 &input,
1562 );
1563
1564 assert!(readiness.can_send_development_input);
1565 assert!(readiness.blockers.is_empty());
1566 }
1567
1568 #[test]
1569 fn readiness_rejects_inaccessible_ydotool_paths() {
1570 let platform = platform_report();
1571 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1572 let windowing = windowing_report(true, true);
1573 let input = input_report_parts(
1574 Check::ok("ydotool"),
1575 Check::ok("ydotoold"),
1576 Check::fail("/tmp/.ydotool_socket: Permission denied"),
1577 Check::fail("/dev/uinput: Permission denied"),
1578 );
1579
1580 let readiness = readiness_report(
1581 &platform,
1582 &portal_report(Check::fail("missing")),
1583 &accessibility,
1584 &windowing,
1585 &input,
1586 );
1587
1588 assert!(!readiness.can_send_development_input);
1589 assert!(readiness
1590 .recommended_next_step
1591 .contains("Enable a supported input backend"));
1592 assert!(readiness
1593 .blockers
1594 .iter()
1595 .any(|blocker| blocker.contains("Development input is unavailable")));
1596 }
1597
1598 #[test]
1599 fn capability_map_rejects_incompatible_ydotool_with_live_daemon() {
1600 let platform = platform_report();
1601 let portals = portal_report(Check::fail("missing"));
1602 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1603 let windowing = windowing_report(true, true);
1604 let input = input_report_parts(
1605 Check::fail("unsupported legacy ydotool CLI"),
1606 Check::ok("ydotoold"),
1607 Check::ok("connectable socket"),
1608 Check::fail("/dev/uinput: Permission denied"),
1609 );
1610
1611 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1612 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
1613
1614 assert!(!capabilities
1615 .input
1616 .iter()
1617 .any(|backend| backend == "ydotool"));
1618 assert!(!readiness.can_send_development_input);
1619 }
1620
1621 #[test]
1622 fn ydotool_socket_check_rejects_legacy_stream_socket() {
1623 let dir = std::env::temp_dir().join(format!(
1624 "computer-use-linux-diagnostics-{}",
1625 std::process::id()
1626 ));
1627 let _ = std::fs::remove_dir_all(&dir);
1628 std::fs::create_dir_all(&dir).expect("create temp diagnostics dir");
1629 let socket = dir.join("ydotool.sock");
1630 let listener =
1631 std::os::unix::net::UnixListener::bind(&socket).expect("bind temp diagnostics socket");
1632
1633 let check = socket_connect_check(&socket);
1634
1635 assert!(!check.ok, "{check:?}");
1636 drop(listener);
1637 let _ = std::fs::remove_dir_all(&dir);
1638 }
1639
1640 #[test]
1641 fn ydotool_socket_check_accepts_datagram_socket() {
1642 let dir = std::env::temp_dir().join(format!(
1643 "computer-use-linux-diagnostics-dgram-{}",
1644 std::process::id()
1645 ));
1646 let _ = std::fs::remove_dir_all(&dir);
1647 std::fs::create_dir_all(&dir).expect("create temp diagnostics dir");
1648 let socket = dir.join("ydotool.sock");
1649 let datagram =
1650 std::os::unix::net::UnixDatagram::bind(&socket).expect("bind temp datagram socket");
1651
1652 let check = socket_connect_check(&socket);
1653
1654 assert!(check.ok, "{check:?}");
1655 drop(datagram);
1656 let _ = std::fs::remove_dir_all(&dir);
1657 }
1658
1659 #[test]
1660 fn readiness_reports_cosmic_window_blocker_on_cosmic() {
1661 let mut platform = platform_report();
1662 platform.xdg_current_desktop = Some("COSMIC".to_string());
1663 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1664 let windowing = windowing_report(false, false);
1665 let input = input_report(true);
1666
1667 let readiness = readiness_report(
1668 &platform,
1669 &portal_report(Check::fail("missing")),
1670 &accessibility,
1671 &windowing,
1672 &input,
1673 );
1674
1675 assert!(readiness
1676 .blockers
1677 .iter()
1678 .any(|blocker| blocker.contains("COSMIC Wayland window introspection")));
1679 }
1680}