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"];
35const PORTAL_DEVICE_KEYBOARD: u32 = 1;
36const PORTAL_DEVICE_POINTER: u32 = 2;
37const PORTAL_SOURCE_MONITOR: u32 = 1;
38const REMOTE_DESKTOP_KEYBOARD_METHODS: &[&str] = &[
39 "CreateSession",
40 "SelectDevices",
41 "Start",
42 "NotifyKeyboardKeycode",
43 "NotifyKeyboardKeysym",
44];
45const REMOTE_DESKTOP_POINTER_METHODS: &[&str] = &[
46 "CreateSession",
47 "SelectDevices",
48 "Start",
49 "NotifyPointerMotionAbsolute",
50 "NotifyPointerButton",
51 "NotifyPointerAxisDiscrete",
52];
53const SCREENCAST_POINTER_METHODS: &[&str] = &["SelectSources"];
54
55#[derive(Debug, Clone, Serialize, JsonSchema)]
56pub struct DoctorReport {
57 pub platform: PlatformReport,
58 pub portals: PortalReport,
59 pub accessibility: AccessibilityReport,
60 pub windowing: WindowingReport,
61 pub input: InputReport,
62 pub readiness: ReadinessReport,
63 pub capabilities: CapabilityMap,
67}
68
69#[derive(Debug, Clone, Serialize, JsonSchema)]
70pub struct CapabilityMap {
71 pub input: Vec<String>,
73 pub screenshot: Vec<String>,
75 pub window_control: Vec<String>,
77 pub accessibility: Vec<String>,
79 pub isolation: Vec<String>,
81 pub preferred: PreferredBackends,
83}
84
85#[derive(Debug, Clone, Serialize, JsonSchema)]
86pub struct PreferredBackends {
87 pub input: Option<String>,
88 pub screenshot: Option<String>,
89 pub window_control: Option<String>,
90}
91
92#[derive(Debug, Clone, Serialize, JsonSchema)]
93pub struct PlatformReport {
94 pub os: String,
95 pub arch: String,
96 pub desktop_session: Option<String>,
97 pub xdg_session_type: Option<String>,
98 pub xdg_current_desktop: Option<String>,
99 pub wayland_display: Option<String>,
100 pub display: Option<String>,
101 pub xauthority: Option<String>,
102 pub dbus_session_bus_address: Option<String>,
103 pub xdg_runtime_dir: Option<String>,
104 pub gnome_shell_version: Check,
105 pub gnome_screenshot: Check,
106}
107
108#[derive(Debug, Clone, Serialize, JsonSchema)]
109pub struct PortalReport {
110 pub desktop_portal: Check,
111 pub remote_desktop: Check,
112 pub screencast: Check,
113 pub screenshot: Check,
114 pub input_capture: Check,
115 pub mutter_remote_desktop: Check,
116 pub mutter_screencast: Check,
117}
118
119#[derive(Debug, Clone, Serialize, JsonSchema)]
120pub struct AccessibilityReport {
121 pub at_spi_bus: Check,
122 pub toolkit_accessibility: Check,
123 pub at_spi_enabled: Check,
124 pub screen_reader_enabled: Check,
125}
126
127#[derive(Debug, Clone, Serialize, JsonSchema)]
128pub struct WindowingReport {
129 pub gnome_shell_introspect: Check,
130 pub computer_use_linux_gnome_shell_extension: Check,
131 pub cosmic_helper: Check,
132 pub kwin: Check,
133 pub hyprland: Check,
134 pub backends: BTreeMap<String, Check>,
135 pub can_list_windows: bool,
136 pub can_focus_apps: bool,
137 pub can_focus_windows: bool,
138 pub note: String,
139}
140
141#[derive(Debug, Clone, Serialize, JsonSchema)]
142pub struct InputReport {
143 pub ydotool: Check,
144 pub ydotoold: Check,
145 pub ydotool_socket: Check,
146 pub uinput: Check,
147 pub xdotool: Check,
150 pub wtype: Check,
152}
153
154#[derive(Debug, Clone, Serialize, JsonSchema)]
155pub struct ReadinessReport {
156 pub can_register_mcp_tools: bool,
157 pub can_build_accessibility_tree: bool,
158 pub can_query_windows: bool,
159 pub can_focus_apps: bool,
160 pub can_focus_windows: bool,
161 pub can_send_development_input: bool,
162 pub recommended_next_step: String,
163 pub blockers: Vec<String>,
164}
165
166#[derive(Debug, Clone, Serialize, JsonSchema)]
167pub struct SetupReport {
168 pub before: DoctorReport,
169 pub accessibility_command: Check,
170 pub after: DoctorReport,
171 pub changed_accessibility: bool,
172 pub requires_target_app_restart: bool,
173 pub message: String,
174}
175
176#[derive(Debug, Clone, Serialize, JsonSchema)]
177pub struct Check {
178 pub ok: bool,
179 pub detail: String,
180}
181
182impl Check {
183 fn ok(detail: impl Into<String>) -> Self {
184 Self {
185 ok: true,
186 detail: detail.into(),
187 }
188 }
189
190 fn fail(detail: impl Into<String>) -> Self {
191 Self {
192 ok: false,
193 detail: detail.into(),
194 }
195 }
196}
197
198pub fn doctor_report() -> DoctorReport {
199 hydrate_session_bus_env();
200
201 let platform = platform_report();
202 let (portals, remote_desktop_keyboard) = portal_report();
203 let accessibility = accessibility_report();
204 let windowing = windowing_report(&platform);
205 let input = input_report();
206 let readiness = readiness_report_with_portal_keyboard(
207 &platform,
208 &remote_desktop_keyboard,
209 &accessibility,
210 &windowing,
211 &input,
212 );
213
214 let capabilities = capability_map_with_portal_keyboard(
215 &platform,
216 &portals,
217 &remote_desktop_keyboard,
218 &accessibility,
219 &windowing,
220 &input,
221 );
222
223 DoctorReport {
224 platform,
225 portals,
226 accessibility,
227 windowing,
228 input,
229 readiness,
230 capabilities,
231 }
232}
233
234#[cfg(test)]
237fn capability_map(
238 platform: &PlatformReport,
239 portals: &PortalReport,
240 accessibility: &AccessibilityReport,
241 windowing: &WindowingReport,
242 input: &InputReport,
243) -> CapabilityMap {
244 capability_map_with_portal_keyboard(
245 platform,
246 portals,
247 &portals.remote_desktop,
248 accessibility,
249 windowing,
250 input,
251 )
252}
253
254fn capability_map_with_portal_keyboard(
255 platform: &PlatformReport,
256 portals: &PortalReport,
257 remote_desktop_keyboard: &Check,
258 accessibility: &AccessibilityReport,
259 windowing: &WindowingReport,
260 input: &InputReport,
261) -> CapabilityMap {
262 let mut input_backends = Vec::new();
263 if input.uinput.ok {
265 input_backends.push("abs_pointer".to_string());
266 }
267 let force_ydotool = env_flag_enabled_any(FORCE_YDOTOOL_KEYBOARD_ENV_KEYS);
268 let force_xdotool = env_flag_enabled_any(FORCE_XDOTOOL_KEYBOARD_ENV_KEYS);
269 let portal_pointer_available = portal_pointer_input_available(platform, portals);
270 let portal_keyboard_available =
271 portal_keyboard_input_available(platform, remote_desktop_keyboard);
272 let portal_available = portal_pointer_available || portal_keyboard_available;
273 let portal_forced_for_all_input = portal_pointer_available
274 && portal_keyboard_available
275 && force_portal_for_all_input(
276 env_flag_enabled_any(FORCE_PORTAL_POINTER_ENV_KEYS),
277 env_flag_enabled_any(FORCE_PORTAL_KEYBOARD_ENV_KEYS),
278 env_flag_enabled_any(FORCE_YDOTOOL_POINTER_ENV_KEYS),
279 force_ydotool,
280 );
281 if should_advertise_xdotool(platform, input, force_ydotool, force_xdotool) {
282 input_backends.push("xdotool".to_string());
283 }
284 if platform_is_wayland(platform)
285 && wtype_compatible_wayland_desktop(platform.xdg_current_desktop.as_deref())
286 && input.wtype.ok
287 && !force_ydotool
288 {
289 input_backends.push("wtype".to_string());
290 }
291 if portal_available && portal_forced_for_all_input {
292 input_backends.push("portal".to_string());
293 }
294 if input.ydotool.ok && input.ydotool_socket.ok {
295 input_backends.push("ydotool".to_string());
296 }
297 if portal_available && !portal_forced_for_all_input {
298 input_backends.push("portal".to_string());
299 }
300
301 let mut screenshot_backends = Vec::new();
302 if platform.gnome_shell_version.ok {
303 screenshot_backends.push("gnome_shell".to_string());
304 }
305 if portals.screenshot.ok {
306 screenshot_backends.push("portal".to_string());
307 }
308 if platform.gnome_screenshot.ok {
310 screenshot_backends.push("gnome_screenshot".to_string());
311 }
312
313 let mut window_backends = Vec::new();
314 let x11_available = windowing
315 .backends
316 .get(X11_BACKEND)
317 .is_some_and(|check| check.ok);
318 let prefer_x11_over_introspect = windowing.gnome_shell_introspect.ok
319 && x11_available
320 && registry::backend_can_exact_focus(X11_BACKEND);
321 if windowing.computer_use_linux_gnome_shell_extension.ok {
322 window_backends.push("gnome_shell_extension".to_string());
323 }
324 if prefer_x11_over_introspect {
325 window_backends.push(X11_BACKEND.to_string());
326 }
327 if windowing.gnome_shell_introspect.ok {
328 window_backends.push("gnome_introspect".to_string());
329 }
330 if windowing.cosmic_helper.ok {
331 window_backends.push("cosmic".to_string());
332 }
333 if windowing.kwin.ok {
334 window_backends.push("kwin".to_string());
335 }
336 if windowing.hyprland.ok {
337 window_backends.push("hyprland".to_string());
338 }
339 if windowing
343 .backends
344 .get(I3_BACKEND)
345 .is_some_and(|check| check.ok)
346 {
347 window_backends.push(I3_BACKEND.to_string());
348 }
349 if x11_available && !prefer_x11_over_introspect {
350 window_backends.push(X11_BACKEND.to_string());
351 }
352
353 let mut accessibility_backends = Vec::new();
354 if can_build_accessibility_tree(accessibility) {
355 accessibility_backends.push("at_spi".to_string());
356 }
357
358 let mut isolation = vec!["shared".to_string()];
362 if platform.gnome_shell_version.ok {
363 isolation.push("headless_gnome".to_string());
364 }
365
366 let preferred = PreferredBackends {
367 input: input_backends.first().cloned(),
368 screenshot: screenshot_backends.first().cloned(),
369 window_control: window_backends.first().cloned(),
370 };
371
372 CapabilityMap {
373 input: input_backends,
374 screenshot: screenshot_backends,
375 window_control: window_backends,
376 accessibility: accessibility_backends,
377 isolation,
378 preferred,
379 }
380}
381
382pub fn hydrate_session_bus_env() {
383 hydrate_common_command_path();
384 hydrate_desktop_env_from_process_tree();
385 hydrate_desktop_env_from_systemd_user();
386
387 if env_var("XDG_RUNTIME_DIR").is_none() {
388 if let Some(runtime) = xdg_runtime_dir() {
389 if runtime.exists() {
390 env::set_var("XDG_RUNTIME_DIR", runtime);
391 }
392 }
393 }
394
395 if env_var("DBUS_SESSION_BUS_ADDRESS").is_none() {
396 if let Some(runtime) = xdg_runtime_dir() {
397 let bus = runtime.join("bus");
398 if bus.exists() {
399 env::set_var(
400 "DBUS_SESSION_BUS_ADDRESS",
401 format!("unix:path={}", bus.display()),
402 );
403 }
404 }
405 }
406}
407
408fn hydrate_common_command_path() {
409 let mut entries = env::var_os("PATH")
410 .map(|path| env::split_paths(&path).collect::<Vec<_>>())
411 .unwrap_or_default();
412 for path in [
413 "/run/current-system/sw/bin",
414 "/usr/local/bin",
415 "/usr/bin",
416 "/bin",
417 ] {
418 let path = PathBuf::from(path);
419 if path.exists() && !entries.iter().any(|entry| entry == &path) {
420 entries.push(path);
421 }
422 }
423 if let Ok(path) = env::join_paths(entries) {
424 env::set_var("PATH", path);
425 }
426}
427
428fn hydrate_desktop_env_from_process_tree() {
429 for process_env in desktop_process_environments() {
430 hydrate_desktop_env_from_map(&process_env);
431
432 if DESKTOP_ENV_KEYS.iter().all(|key| env_var(key).is_some()) {
433 break;
434 }
435 }
436}
437
438fn hydrate_desktop_env_from_systemd_user() {
439 let Ok(output) = Command::new("systemctl")
440 .args(["--user", "show-environment"])
441 .output()
442 else {
443 return;
444 };
445 if !output.status.success() {
446 return;
447 }
448 let env_map = parse_line_environment(&output.stdout);
449 hydrate_desktop_env_from_map(&env_map);
450}
451
452fn hydrate_desktop_env_from_map(process_env: &HashMap<String, String>) {
453 let current_env = DESKTOP_ENV_KEYS
454 .iter()
455 .filter_map(|key| env_var(key).map(|value| ((*key).to_string(), value)))
456 .collect();
457 for (key, value) in desktop_env_hydration_updates(¤t_env, process_env) {
458 env::set_var(key, value);
459 }
460}
461
462fn desktop_env_hydration_updates(
463 current_env: &HashMap<String, String>,
464 source_env: &HashMap<String, String>,
465) -> Vec<(&'static str, String)> {
466 let preserve_native_x11 = current_env
470 .get("XDG_SESSION_TYPE")
471 .is_some_and(|value| value.trim().eq_ignore_ascii_case("x11"))
472 && current_env
473 .get("DISPLAY")
474 .is_some_and(|value| !value.trim().is_empty())
475 && current_env
476 .get("WAYLAND_DISPLAY")
477 .is_none_or(|value| value.trim().is_empty());
478
479 DESKTOP_ENV_KEYS
480 .iter()
481 .filter_map(|key| {
482 if current_env
483 .get(*key)
484 .is_some_and(|value| !value.trim().is_empty())
485 || preserve_native_x11 && *key == "WAYLAND_DISPLAY"
486 {
487 return None;
488 }
489 source_env
490 .get(*key)
491 .filter(|value| !value.trim().is_empty())
492 .map(|value| (*key, value.clone()))
493 })
494 .collect()
495}
496
497fn desktop_process_environments() -> Vec<HashMap<String, String>> {
498 let mut environments = Vec::new();
499 let mut visited_pids = Vec::new();
500 let mut pid = parent_pid("self");
501
502 for _ in 0..8 {
503 let Some(current_pid) = pid else {
504 break;
505 };
506 if current_pid <= 1 {
507 break;
508 }
509
510 visited_pids.push(current_pid);
511 if let Some(process_env) = read_process_environ(current_pid) {
512 environments.push(process_env);
513 }
514 pid = parent_pid(¤t_pid.to_string());
515 }
516
517 if !visited_pids.contains(&1) && process_owner_matches_current_user(1) {
518 if let Some(process_env) = read_process_environ(1).filter(process_env_has_graphical_display)
519 {
520 environments.push(process_env);
521 }
522 }
523
524 environments
525}
526
527fn parent_pid(pid: &str) -> Option<u32> {
528 let status = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
529 parse_parent_pid(&status)
530}
531
532fn parse_parent_pid(status: &str) -> Option<u32> {
533 status.lines().find_map(|line| {
534 let value = line.strip_prefix("PPid:")?.trim();
535 value.parse::<u32>().ok()
536 })
537}
538
539fn read_process_environ(pid: u32) -> Option<HashMap<String, String>> {
540 let bytes = fs::read(format!("/proc/{pid}/environ")).ok()?;
541 Some(parse_environ(&bytes))
542}
543
544fn process_owner_matches_current_user(pid: u32) -> bool {
545 let Some(current_uid) = user_id().and_then(|uid| uid.parse::<u32>().ok()) else {
546 return false;
547 };
548 fs::metadata(format!("/proc/{pid}"))
549 .ok()
550 .is_some_and(|metadata| metadata.uid() == current_uid)
551}
552
553fn process_env_has_graphical_display(process_env: &HashMap<String, String>) -> bool {
554 process_env
555 .get("DISPLAY")
556 .or_else(|| process_env.get("WAYLAND_DISPLAY"))
557 .is_some_and(|value| !value.trim().is_empty())
558}
559
560fn parse_environ(bytes: &[u8]) -> HashMap<String, String> {
561 bytes
562 .split(|byte| *byte == 0)
563 .filter_map(|entry| {
564 if entry.is_empty() {
565 return None;
566 }
567 let split = entry.iter().position(|byte| *byte == b'=')?;
568 let (key, value) = entry.split_at(split);
569 let value = &value[1..];
570 let key = std::str::from_utf8(key).ok()?.to_string();
571 let value = std::str::from_utf8(value).ok()?.to_string();
572 Some((key, value))
573 })
574 .collect()
575}
576
577fn parse_line_environment(bytes: &[u8]) -> HashMap<String, String> {
578 bytes
579 .split(|byte| *byte == b'\n')
580 .filter_map(|entry| {
581 if entry.is_empty() {
582 return None;
583 }
584 let split = entry.iter().position(|byte| *byte == b'=')?;
585 let (key, value) = entry.split_at(split);
586 let value = &value[1..];
587 let key = std::str::from_utf8(key).ok()?.to_string();
588 let value = std::str::from_utf8(value).ok()?.to_string();
589 Some((key, value))
590 })
591 .collect()
592}
593
594pub fn setup_accessibility_report() -> SetupReport {
595 hydrate_session_bus_env();
596
597 let before = doctor_report();
598 let accessibility_command = if can_build_accessibility_tree(&before.accessibility) {
599 Check::ok("AT-SPI accessibility is already enabled")
600 } else {
601 let atspi_status = command_check_with_session_bus(
602 "busctl",
603 &[
604 "--user",
605 "set-property",
606 "org.a11y.Bus",
607 "/org/a11y/bus",
608 "org.a11y.Status",
609 "IsEnabled",
610 "b",
611 "true",
612 ],
613 );
614 if atspi_status.ok {
615 atspi_status
616 } else {
617 command_check_with_session_bus(
618 "gsettings",
619 &[
620 "set",
621 "org.gnome.desktop.interface",
622 "toolkit-accessibility",
623 "true",
624 ],
625 )
626 }
627 };
628 let after = doctor_report();
629 let before_ready = before.readiness.can_build_accessibility_tree;
630 let after_ready = after.readiness.can_build_accessibility_tree;
631 let changed_accessibility = !before_ready && after_ready;
632 let requires_target_app_restart = changed_accessibility;
633 let message = if after_ready {
634 if changed_accessibility {
635 "AT-SPI accessibility is enabled. Restart already-running target apps if their AT-SPI tree is still empty."
636 } else {
637 "AT-SPI accessibility is ready."
638 }
639 } else {
640 "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."
641 }
642 .to_string();
643
644 SetupReport {
645 before,
646 accessibility_command,
647 after,
648 changed_accessibility,
649 requires_target_app_restart,
650 message,
651 }
652}
653
654fn platform_report() -> PlatformReport {
655 PlatformReport {
656 os: std::env::consts::OS.to_string(),
657 arch: std::env::consts::ARCH.to_string(),
658 desktop_session: env_var("DESKTOP_SESSION"),
659 xdg_session_type: env_var("XDG_SESSION_TYPE"),
660 xdg_current_desktop: env_var("XDG_CURRENT_DESKTOP"),
661 wayland_display: env_var("WAYLAND_DISPLAY"),
662 display: env_var("DISPLAY"),
663 xauthority: env_var("XAUTHORITY"),
664 dbus_session_bus_address: dbus_session_address(),
665 xdg_runtime_dir: xdg_runtime_dir().map(|path| path.display().to_string()),
666 gnome_shell_version: command_check("gnome-shell", &["--version"]),
667 gnome_screenshot: command_check("gnome-screenshot", &["--version"]),
668 }
669}
670
671fn portal_report() -> (PortalReport, Check) {
672 let (remote_desktop, remote_desktop_keyboard, screencast) = remote_desktop_portal_checks();
673 (
674 PortalReport {
675 desktop_portal: bus_name_check("org.freedesktop.portal.Desktop"),
676 remote_desktop,
677 screencast,
678 screenshot: portal_interface_check("org.freedesktop.portal.Screenshot"),
679 input_capture: portal_interface_check("org.freedesktop.portal.InputCapture"),
680 mutter_remote_desktop: bus_name_check("org.gnome.Mutter.RemoteDesktop"),
681 mutter_screencast: bus_name_check("org.gnome.Mutter.ScreenCast"),
682 },
683 remote_desktop_keyboard,
684 )
685}
686
687fn accessibility_report() -> AccessibilityReport {
688 AccessibilityReport {
689 at_spi_bus: atspi_bus_address_check(),
690 toolkit_accessibility: command_check_with_session_bus(
691 "gsettings",
692 &[
693 "get",
694 "org.gnome.desktop.interface",
695 "toolkit-accessibility",
696 ],
697 ),
698 at_spi_enabled: atspi_status_property_check("IsEnabled"),
699 screen_reader_enabled: atspi_status_property_check("ScreenReaderEnabled"),
700 }
701}
702
703fn windowing_report(platform: &PlatformReport) -> WindowingReport {
704 let probes = registry::probe_backends();
705 let backend_check = |id: &str| {
706 probes
707 .iter()
708 .find(|probe| probe.id == id)
709 .map(check_from_backend_probe)
710 .unwrap_or_else(|| Check::fail("backend probe did not run"))
711 };
712 let gnome_shell_introspect = backend_check(GNOME_SHELL_INTROSPECT_BACKEND);
713 let computer_use_linux_gnome_shell_extension = backend_check(GNOME_SHELL_EXTENSION_BACKEND);
714 let cosmic_helper = backend_check(COSMIC_WAYLAND_BACKEND);
715 let kwin = backend_check(KWIN_BACKEND);
716 let hyprland = backend_check(HYPRLAND_BACKEND);
717 let backends = probes
718 .iter()
719 .map(|probe| (probe.id.to_string(), check_from_backend_probe(probe)))
720 .collect::<BTreeMap<_, _>>();
721 let can_list_windows = probes.iter().any(|probe| probe.can_list_windows);
722 let can_focus_apps = probes.iter().any(|probe| probe.can_focus_apps);
723 let can_focus_windows = probes.iter().any(|probe| probe.can_focus_windows);
724 let note = if can_list_windows {
725 if !can_focus_windows {
726 "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)."
727 } else if cosmic_helper.ok && is_cosmic_wayland_platform(platform) {
728 "A COSMIC Wayland window backend is available for list_windows, focused_window, and targeted input verification."
729 } else if kwin.ok {
730 "A KWin/Plasma window backend is available for list_windows, focused_window, and targeted input verification."
731 } else if hyprland.ok {
732 "A Hyprland window backend is available for list_windows, focused_window, and targeted input verification."
733 } else {
734 "A window listing backend is available for list_windows, focused_window, and targeted input verification."
735 }
736 } else {
737 "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."
738 }
739 .to_string();
740
741 WindowingReport {
742 gnome_shell_introspect,
743 computer_use_linux_gnome_shell_extension,
744 cosmic_helper,
745 kwin,
746 hyprland,
747 backends,
748 can_list_windows,
749 can_focus_apps,
750 can_focus_windows,
751 note,
752 }
753}
754
755fn check_from_backend_probe(probe: ®istry::BackendProbe) -> Check {
756 if probe.ok {
757 Check::ok(probe.detail.clone())
758 } else {
759 Check::fail(probe.detail.clone())
760 }
761}
762
763fn input_report() -> InputReport {
764 InputReport {
765 ydotool: match ydotool::ensure_supported() {
766 Ok(support) => Check::ok(support.detail),
767 Err(detail) => Check::fail(detail),
768 },
769 ydotoold: process_check("ydotoold"),
770 ydotool_socket: ydotool_socket_check(),
771 uinput: read_write_path_check(Path::new("/dev/uinput")),
772 xdotool: command_path_check("xdotool"),
773 wtype: command_path_check("wtype"),
774 }
775}
776
777#[cfg(test)]
778fn readiness_report(
779 platform: &PlatformReport,
780 portals: &PortalReport,
781 accessibility: &AccessibilityReport,
782 windowing: &WindowingReport,
783 input: &InputReport,
784) -> ReadinessReport {
785 readiness_report_with_portal_keyboard(
786 platform,
787 &portals.remote_desktop,
788 accessibility,
789 windowing,
790 input,
791 )
792}
793
794fn readiness_report_with_portal_keyboard(
795 platform: &PlatformReport,
796 remote_desktop_keyboard: &Check,
797 accessibility: &AccessibilityReport,
798 windowing: &WindowingReport,
799 input: &InputReport,
800) -> ReadinessReport {
801 let mut blockers = Vec::new();
802 let can_build_accessibility_tree = can_build_accessibility_tree(accessibility);
803 let can_query_windows = windowing.can_list_windows;
804 let can_focus_apps = windowing.can_focus_apps;
805 let can_focus_windows = windowing.can_focus_windows;
806 let can_send_development_input =
807 can_send_development_input(platform, remote_desktop_keyboard, input);
808
809 if !can_build_accessibility_tree {
810 blockers.push(
811 "AT-SPI accessibility is disabled; enable org.a11y.Status IsEnabled or org.gnome.desktop.interface toolkit-accessibility for tree extraction."
812 .to_string(),
813 );
814 }
815
816 if !can_query_windows {
817 blockers.push(if is_cosmic_wayland_platform(platform) {
818 "COSMIC Wayland window introspection is unavailable; targeted window focus and verification will be disabled.".to_string()
819 } else {
820 "Window introspection is unavailable; targeted window focus and verification will be disabled."
821 .to_string()
822 });
823 }
824
825 if can_query_windows && !can_focus_windows {
826 blockers.push(
827 "Exact window activation is unavailable; app-level focus may work, but window_id/title/terminal-targeted input cannot be verified."
828 .to_string(),
829 );
830 }
831
832 if !can_send_development_input {
833 blockers.push(
834 "Development keyboard input is unavailable; enable XDG RemoteDesktop portal input or install wtype on compatible Wayland compositors, install xdotool with DISPLAY on X11, or use ydotool with a connectable ydotoold socket. Read/write /dev/uinput alone provides only absolute pointer input."
835 .to_string(),
836 );
837 }
838
839 let recommended_next_step = if !can_build_accessibility_tree {
840 "Run setup_accessibility to enable AT-SPI accessibility before element-aware actions."
841 .to_string()
842 } else if !can_query_windows {
843 format!(
844 "Enable a supported window backend before using targeted keyboard input: {}",
845 registry::descriptors()
846 .iter()
847 .map(|descriptor| descriptor.missing_hint)
848 .collect::<Vec<_>>()
849 .join(" ")
850 )
851 } else if !can_focus_windows {
852 "Enable an exact-focus window backend before using window_id, title, or terminal-targeted input.".to_string()
853 } else if !can_send_development_input {
854 "Enable a keyboard-capable input backend: enable the XDG RemoteDesktop portal or install wtype on compatible Wayland compositors, install xdotool for X11, or start ydotoold with a socket accessible to this desktop user."
855 .to_string()
856 } else {
857 "Computer Use is ready: AT-SPI tree support, window targeting, and a Linux input backend are available."
858 .to_string()
859 };
860
861 ReadinessReport {
862 can_register_mcp_tools: true,
863 can_build_accessibility_tree,
864 can_query_windows,
865 can_focus_apps,
866 can_focus_windows,
867 can_send_development_input,
868 recommended_next_step,
869 blockers,
870 }
871}
872
873fn can_send_development_input(
874 platform: &PlatformReport,
875 remote_desktop_keyboard: &Check,
876 input: &InputReport,
877) -> bool {
878 let force_ydotool = env_flag_enabled_any(FORCE_YDOTOOL_KEYBOARD_ENV_KEYS);
879 let force_xdotool = env_flag_enabled_any(FORCE_XDOTOOL_KEYBOARD_ENV_KEYS);
880 portal_keyboard_input_available(platform, remote_desktop_keyboard)
881 || platform_is_wayland(platform)
882 && wtype_compatible_wayland_desktop(platform.xdg_current_desktop.as_deref())
883 && input.wtype.ok
884 && !force_ydotool
885 || should_advertise_xdotool(platform, input, force_ydotool, force_xdotool)
886 || input.ydotool.ok && input.ydotool_socket.ok
887}
888
889fn portal_pointer_input_available(platform: &PlatformReport, portals: &PortalReport) -> bool {
890 platform_is_wayland(platform) && portals.remote_desktop.ok
891}
892
893fn portal_keyboard_input_available(
894 platform: &PlatformReport,
895 remote_desktop_keyboard: &Check,
896) -> bool {
897 platform_is_wayland(platform) && remote_desktop_keyboard.ok
898}
899
900fn is_cosmic_wayland_platform(platform: &PlatformReport) -> bool {
901 platform
902 .xdg_current_desktop
903 .as_deref()
904 .is_some_and(|desktop| desktop.to_ascii_lowercase().contains("cosmic"))
905 && platform.xdg_session_type.as_deref() == Some("wayland")
906}
907
908fn can_build_accessibility_tree(accessibility: &AccessibilityReport) -> bool {
909 accessibility.at_spi_bus.ok
910 && (check_detail_contains_true(&accessibility.at_spi_enabled)
911 || check_detail_contains_true(&accessibility.toolkit_accessibility))
912}
913
914fn check_detail_contains_true(check: &Check) -> bool {
915 check.ok && check.detail.to_ascii_lowercase().contains("true")
916}
917
918fn env_var(key: &str) -> Option<String> {
919 env::var(key).ok().filter(|value| !value.trim().is_empty())
920}
921
922fn xdg_runtime_dir() -> Option<PathBuf> {
923 if let Some(value) = env_var("XDG_RUNTIME_DIR") {
924 return Some(PathBuf::from(value));
925 }
926 user_id().map(|uid| PathBuf::from(format!("/run/user/{uid}")))
927}
928
929fn dbus_session_address() -> Option<String> {
930 if let Some(value) = env_var("DBUS_SESSION_BUS_ADDRESS") {
931 return Some(value);
932 }
933 xdg_runtime_dir()
934 .map(|runtime| format!("unix:path={}", runtime.join("bus").display()))
935 .filter(|address| {
936 address
937 .strip_prefix("unix:path=")
938 .is_some_and(|p| Path::new(p).exists())
939 })
940}
941
942fn ydotool_socket_candidates() -> Vec<PathBuf> {
943 let mut candidates = Vec::new();
944 if let Some(value) = env_var("YDOTOOL_SOCKET") {
945 candidates.push(PathBuf::from(value));
946 }
947
948 if let Some(runtime_socket) = xdg_runtime_dir().map(|runtime| runtime.join(".ydotool_socket")) {
949 candidates.push(runtime_socket);
950 }
951 candidates.push(PathBuf::from("/tmp/.ydotool_socket"));
952 candidates
953}
954
955fn ydotool_socket_check() -> Check {
956 let mut checked = Vec::new();
957 for candidate in ydotool_socket_candidates() {
958 match socket_connect_result(&candidate) {
959 Ok(()) => return Check::ok(format!("connectable: {}", candidate.display())),
960 Err(detail) => checked.push(detail),
961 }
962 }
963
964 Check::fail(format!(
965 "no connectable ydotool socket ({})",
966 checked.join("; ")
967 ))
968}
969
970fn user_id() -> Option<String> {
971 let output = Command::new("id").arg("-u").output().ok()?;
972 output
973 .status
974 .success()
975 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
976 .filter(|value| !value.is_empty())
977}
978
979fn command_path_check(command: &str) -> Check {
980 command_check("sh", &["-c", &format!("command -v {command}")])
981}
982
983fn platform_is_wayland(platform: &PlatformReport) -> bool {
984 match platform.xdg_session_type.as_deref() {
985 Some(value) => value.eq_ignore_ascii_case("wayland"),
986 None => platform
987 .wayland_display
988 .as_deref()
989 .is_some_and(|display| !display.trim().is_empty()),
990 }
991}
992
993pub(crate) fn wtype_compatible_wayland_desktop(desktop: Option<&str>) -> bool {
994 desktop.is_none_or(|desktop| {
995 let desktop = desktop.to_ascii_lowercase();
996 !["gnome", "kde", "plasma", "cosmic"]
997 .iter()
998 .any(|known_incompatible| desktop.contains(known_incompatible))
999 })
1000}
1001
1002fn should_advertise_xdotool(
1003 platform: &PlatformReport,
1004 input: &InputReport,
1005 force_ydotool: bool,
1006 force_xdotool: bool,
1007) -> bool {
1008 !force_ydotool
1009 && input.xdotool.ok
1010 && platform
1011 .display
1012 .as_deref()
1013 .is_some_and(|display| !display.trim().is_empty())
1014 && (force_xdotool || !platform_is_wayland(platform))
1015}
1016
1017fn env_flag_enabled_any(keys: &[&str]) -> bool {
1018 keys.iter()
1019 .any(|key| env::var(key).ok().as_deref() == Some("1"))
1020}
1021
1022fn force_portal_for_all_input(
1023 force_portal_pointer: bool,
1024 force_portal_keyboard: bool,
1025 force_ydotool_pointer: bool,
1026 force_ydotool_keyboard: bool,
1027) -> bool {
1028 force_portal_pointer
1029 && force_portal_keyboard
1030 && !force_ydotool_pointer
1031 && !force_ydotool_keyboard
1032}
1033
1034fn process_check(process_name: &str) -> Check {
1035 command_check("pgrep", &["-a", process_name])
1036}
1037
1038#[cfg(test)]
1039fn socket_connect_check(path: &Path) -> Check {
1040 match socket_connect_result(path) {
1041 Ok(()) => Check::ok(format!("connectable: {}", path.display())),
1042 Err(detail) => Check::fail(detail),
1043 }
1044}
1045
1046fn socket_connect_result(path: &Path) -> std::result::Result<(), String> {
1047 if !path.exists() {
1048 return Err(format!("missing: {}", path.display()));
1049 }
1050
1051 UnixDatagram::unbound()
1052 .and_then(|socket| socket.connect(path))
1053 .map_err(|error| format!("{}: datagram: {error}", path.display()))
1054}
1055
1056fn read_write_path_check(path: &Path) -> Check {
1057 if !path.exists() {
1058 return Check::fail(format!("missing: {}", path.display()));
1059 }
1060
1061 match OpenOptions::new().read(true).write(true).open(path) {
1062 Ok(_) => Check::ok(format!("read/write: {}", path.display())),
1063 Err(error) => Check::fail(format!("{}: {error}", path.display())),
1064 }
1065}
1066
1067fn bus_name_check(name: &str) -> Check {
1068 command_check_with_session_bus("busctl", &["--user", "status", name])
1069}
1070
1071fn portal_interface_check(interface: &str) -> Check {
1072 command_check_with_session_bus(
1073 "busctl",
1074 &[
1075 "--user",
1076 "introspect",
1077 "org.freedesktop.portal.Desktop",
1078 "/org/freedesktop/portal/desktop",
1079 interface,
1080 ],
1081 )
1082}
1083
1084fn remote_desktop_portal_checks() -> (Check, Check, Check) {
1085 let introspection = portal_interface_check("org.freedesktop.portal.RemoteDesktop");
1086 let screencast = portal_interface_check("org.freedesktop.portal.ScreenCast");
1087 if !introspection.ok {
1088 return (introspection.clone(), introspection, screencast);
1089 }
1090
1091 let available_device_types = command_check_with_session_bus(
1092 "busctl",
1093 &[
1094 "--user",
1095 "get-property",
1096 "org.freedesktop.portal.Desktop",
1097 "/org/freedesktop/portal/desktop",
1098 "org.freedesktop.portal.RemoteDesktop",
1099 "AvailableDeviceTypes",
1100 ],
1101 );
1102 let available_source_types = command_check_with_session_bus(
1103 "busctl",
1104 &[
1105 "--user",
1106 "get-property",
1107 "org.freedesktop.portal.Desktop",
1108 "/org/freedesktop/portal/desktop",
1109 "org.freedesktop.portal.ScreenCast",
1110 "AvailableSourceTypes",
1111 ],
1112 );
1113 let pointer = remote_desktop_pointer_check_from(
1114 &introspection,
1115 &screencast,
1116 &available_device_types,
1117 &available_source_types,
1118 );
1119 let keyboard = remote_desktop_keyboard_check_from(&introspection, &available_device_types);
1120 (pointer, keyboard, screencast)
1121}
1122
1123fn remote_desktop_pointer_check_from(
1124 introspection: &Check,
1125 screencast: &Check,
1126 available_device_types: &Check,
1127 available_source_types: &Check,
1128) -> Check {
1129 if !introspection.ok {
1130 return Check::fail(introspection.detail.clone());
1131 }
1132 let missing_methods = missing_busctl_methods(introspection, REMOTE_DESKTOP_POINTER_METHODS);
1133 if !missing_methods.is_empty() {
1134 return Check::fail(format!(
1135 "RemoteDesktop interface is missing required pointer methods: {}",
1136 missing_methods.join(", ")
1137 ));
1138 }
1139
1140 if !screencast.ok {
1141 return Check::fail(format!(
1142 "ScreenCast interface is unavailable for portal pointer input: {}",
1143 screencast.detail
1144 ));
1145 }
1146 let missing_screencast_methods = missing_busctl_methods(screencast, SCREENCAST_POINTER_METHODS);
1147 if !missing_screencast_methods.is_empty() {
1148 return Check::fail(format!(
1149 "ScreenCast interface is missing required pointer methods: {}",
1150 missing_screencast_methods.join(", ")
1151 ));
1152 }
1153
1154 let device_types = match remote_desktop_device_types(available_device_types) {
1155 Ok(device_types) => device_types,
1156 Err(detail) => return Check::fail(detail),
1157 };
1158 if device_types & PORTAL_DEVICE_POINTER == 0 {
1159 return Check::fail(format!(
1160 "RemoteDesktop AvailableDeviceTypes={device_types} does not include pointer input"
1161 ));
1162 }
1163
1164 let source_types = match screencast_source_types(available_source_types) {
1165 Ok(source_types) => source_types,
1166 Err(detail) => return Check::fail(detail),
1167 };
1168 if source_types & PORTAL_SOURCE_MONITOR == 0 {
1169 return Check::fail(format!(
1170 "ScreenCast AvailableSourceTypes={source_types} does not include monitor sources"
1171 ));
1172 }
1173
1174 Check::ok(format!(
1175 "pointer-capable RemoteDesktop portal (AvailableDeviceTypes={device_types}, AvailableSourceTypes={source_types})"
1176 ))
1177}
1178
1179fn remote_desktop_keyboard_check_from(
1180 introspection: &Check,
1181 available_device_types: &Check,
1182) -> Check {
1183 if !introspection.ok {
1184 return Check::fail(introspection.detail.clone());
1185 }
1186
1187 let missing_methods = missing_busctl_methods(introspection, REMOTE_DESKTOP_KEYBOARD_METHODS);
1188 if !missing_methods.is_empty() {
1189 return Check::fail(format!(
1190 "RemoteDesktop interface is missing required keyboard methods: {}",
1191 missing_methods.join(", ")
1192 ));
1193 }
1194
1195 let device_types = match remote_desktop_device_types(available_device_types) {
1196 Ok(device_types) => device_types,
1197 Err(detail) => return Check::fail(detail),
1198 };
1199 if device_types & PORTAL_DEVICE_KEYBOARD == 0 {
1200 return Check::fail(format!(
1201 "RemoteDesktop AvailableDeviceTypes={device_types} does not include keyboard input"
1202 ));
1203 }
1204
1205 Check::ok(format!(
1206 "keyboard-capable RemoteDesktop portal (AvailableDeviceTypes={device_types})"
1207 ))
1208}
1209
1210fn missing_busctl_methods<'a>(introspection: &Check, methods: &'a [&'a str]) -> Vec<&'a str> {
1211 methods
1212 .iter()
1213 .copied()
1214 .filter(|method| !busctl_introspection_has_method(&introspection.detail, method))
1215 .collect()
1216}
1217
1218fn remote_desktop_device_types(available_device_types: &Check) -> Result<u32, String> {
1219 if !available_device_types.ok {
1220 return Err(format!(
1221 "RemoteDesktop AvailableDeviceTypes is unavailable: {}",
1222 available_device_types.detail
1223 ));
1224 }
1225 parse_busctl_u32_property(&available_device_types.detail).ok_or_else(|| {
1226 format!(
1227 "RemoteDesktop AvailableDeviceTypes has an unexpected value: {}",
1228 available_device_types.detail
1229 )
1230 })
1231}
1232
1233fn screencast_source_types(available_source_types: &Check) -> Result<u32, String> {
1234 if !available_source_types.ok {
1235 return Err(format!(
1236 "ScreenCast AvailableSourceTypes is unavailable: {}",
1237 available_source_types.detail
1238 ));
1239 }
1240 parse_busctl_u32_property(&available_source_types.detail).ok_or_else(|| {
1241 format!(
1242 "ScreenCast AvailableSourceTypes has an unexpected value: {}",
1243 available_source_types.detail
1244 )
1245 })
1246}
1247
1248fn busctl_introspection_has_method(detail: &str, method: &str) -> bool {
1249 detail.lines().any(|line| {
1250 let mut fields = line.split_whitespace();
1251 fields
1252 .next()
1253 .is_some_and(|name| name.trim_start_matches('.') == method)
1254 && fields.next() == Some("method")
1255 })
1256}
1257
1258fn parse_busctl_u32_property(detail: &str) -> Option<u32> {
1259 let mut fields = detail.split_whitespace();
1260 if fields.next()? != "u" {
1261 return None;
1262 }
1263 let value = fields.next()?;
1264 value
1265 .strip_prefix("0x")
1266 .map(|hex| u32::from_str_radix(hex, 16).ok())
1267 .unwrap_or_else(|| value.parse().ok())
1268}
1269
1270fn atspi_bus_address_check() -> Check {
1271 let busctl = command_check_with_session_bus(
1272 "busctl",
1273 &[
1274 "--user",
1275 "call",
1276 "org.a11y.Bus",
1277 "/org/a11y/bus",
1278 "org.a11y.Bus",
1279 "GetAddress",
1280 ],
1281 );
1282 if busctl.ok {
1283 return busctl;
1284 }
1285
1286 gdbus_call_check(
1287 "org.a11y.Bus",
1288 "/org/a11y/bus",
1289 "org.a11y.Bus.GetAddress",
1290 &[],
1291 )
1292}
1293
1294fn atspi_status_property_check(property: &str) -> Check {
1295 let busctl = command_check_with_session_bus(
1296 "busctl",
1297 &[
1298 "--user",
1299 "get-property",
1300 "org.a11y.Bus",
1301 "/org/a11y/bus",
1302 "org.a11y.Status",
1303 property,
1304 ],
1305 );
1306 if busctl.ok {
1307 return busctl;
1308 }
1309
1310 gdbus_call_check(
1311 "org.a11y.Bus",
1312 "/org/a11y/bus",
1313 "org.freedesktop.DBus.Properties.Get",
1314 &["org.a11y.Status", property],
1315 )
1316}
1317
1318fn gdbus_call_check(destination: &str, object_path: &str, method: &str, args: &[&str]) -> Check {
1319 let mut command_args = vec![
1320 "call",
1321 "--session",
1322 "--dest",
1323 destination,
1324 "--object-path",
1325 object_path,
1326 "--method",
1327 method,
1328 ];
1329 command_args.extend_from_slice(args);
1330 command_check_with_session_bus("gdbus", &command_args)
1331}
1332
1333fn command_check(command: &str, args: &[&str]) -> Check {
1334 run_command(command, args, false)
1335}
1336
1337fn command_check_with_session_bus(command: &str, args: &[&str]) -> Check {
1338 run_command(command, args, true)
1339}
1340
1341fn run_command(command: &str, args: &[&str], with_session_bus: bool) -> Check {
1342 let mut cmd = Command::new(command);
1343 cmd.args(args);
1344
1345 if with_session_bus {
1346 if let Some(address) = dbus_session_address() {
1347 cmd.env("DBUS_SESSION_BUS_ADDRESS", address);
1348 }
1349 if let Some(runtime) = xdg_runtime_dir() {
1350 cmd.env("XDG_RUNTIME_DIR", runtime);
1351 }
1352 }
1353
1354 match cmd.output() {
1355 Ok(output) if output.status.success() => {
1356 let detail = String::from_utf8_lossy(&output.stdout).trim().to_string();
1357 Check::ok(if detail.is_empty() {
1358 "ok".into()
1359 } else {
1360 detail
1361 })
1362 }
1363 Ok(output) => {
1364 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1365 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
1366 let detail = if !stderr.is_empty() { stderr } else { stdout };
1367 Check::fail(if detail.is_empty() {
1368 format!("exit status {}", output.status)
1369 } else {
1370 detail
1371 })
1372 }
1373 Err(error) => Check::fail(error.to_string()),
1374 }
1375}
1376
1377#[cfg(test)]
1378mod tests {
1379 use super::*;
1380
1381 fn platform_report() -> PlatformReport {
1382 PlatformReport {
1383 os: "linux".to_string(),
1384 arch: "x86_64".to_string(),
1385 desktop_session: None,
1386 xdg_session_type: Some("wayland".to_string()),
1387 xdg_current_desktop: Some("GNOME".to_string()),
1388 wayland_display: Some("wayland-0".to_string()),
1389 display: Some(":0".to_string()),
1390 xauthority: Some("/run/user/1000/Xauthority".to_string()),
1391 dbus_session_bus_address: Some("unix:path=/run/user/1000/bus".to_string()),
1392 xdg_runtime_dir: Some("/run/user/1000".to_string()),
1393 gnome_shell_version: Check::ok("GNOME Shell 46.0"),
1394 gnome_screenshot: Check::ok("gnome-screenshot 41.0"),
1395 }
1396 }
1397
1398 fn portal_report(remote_desktop: Check) -> PortalReport {
1399 PortalReport {
1400 desktop_portal: Check::ok("ok"),
1401 remote_desktop,
1402 screencast: Check::fail("missing"),
1403 screenshot: Check::fail("missing"),
1404 input_capture: Check::fail("missing"),
1405 mutter_remote_desktop: Check::fail("missing"),
1406 mutter_screencast: Check::fail("missing"),
1407 }
1408 }
1409
1410 fn accessibility_report(
1411 at_spi_bus: Check,
1412 toolkit_accessibility: Check,
1413 ) -> AccessibilityReport {
1414 AccessibilityReport {
1415 at_spi_bus,
1416 toolkit_accessibility,
1417 at_spi_enabled: Check::fail("(<false>,)"),
1418 screen_reader_enabled: Check::fail("(<false>,)"),
1419 }
1420 }
1421
1422 fn windowing_report(can_list_windows: bool, can_focus_windows: bool) -> WindowingReport {
1423 WindowingReport {
1424 gnome_shell_introspect: if can_list_windows {
1425 Check::ok("ok")
1426 } else {
1427 Check::fail("denied")
1428 },
1429 computer_use_linux_gnome_shell_extension: if can_focus_windows {
1430 Check::ok("ok")
1431 } else {
1432 Check::fail("missing")
1433 },
1434 cosmic_helper: Check::fail("missing"),
1435 kwin: Check::fail("not a KWin session"),
1436 hyprland: Check::fail("not a Hyprland session"),
1437 backends: BTreeMap::new(),
1438 can_list_windows,
1439 can_focus_apps: true,
1440 can_focus_windows,
1441 note: String::new(),
1442 }
1443 }
1444
1445 fn input_report(can_send_input: bool) -> InputReport {
1446 let check = if can_send_input {
1447 Check::ok("ok")
1448 } else {
1449 Check::fail("missing")
1450 };
1451 input_report_parts(check.clone(), check.clone(), check.clone(), check)
1452 }
1453
1454 fn input_report_parts(
1455 ydotool: Check,
1456 ydotoold: Check,
1457 ydotool_socket: Check,
1458 uinput: Check,
1459 ) -> InputReport {
1460 InputReport {
1461 ydotool,
1462 ydotoold,
1463 ydotool_socket,
1464 uinput,
1465 xdotool: Check::fail("missing xdotool"),
1466 wtype: Check::fail("missing wtype"),
1467 }
1468 }
1469
1470 #[test]
1471 fn accessibility_tree_requires_reachable_at_spi_bus() {
1472 let report = accessibility_report(Check::fail("permission denied"), Check::ok("true"));
1473
1474 assert!(!can_build_accessibility_tree(&report));
1475 }
1476
1477 #[test]
1478 fn accessibility_tree_is_ready_when_bus_and_toolkit_are_ready() {
1479 let report = accessibility_report(
1480 Check::ok("('unix:path=/run/user/1000/at-spi/bus',)"),
1481 Check::ok("true"),
1482 );
1483
1484 assert!(can_build_accessibility_tree(&report));
1485 }
1486
1487 #[test]
1488 fn capability_map_advertises_only_a_buildable_accessibility_tree() {
1489 let platform = platform_report();
1490 let portals = portal_report(Check::fail("missing"));
1491 let windowing = windowing_report(false, false);
1492 let input = input_report(false);
1493
1494 for accessibility in [
1495 accessibility_report(Check::fail("permission denied"), Check::ok("true")),
1496 accessibility_report(
1497 Check::ok("('unix:path=/run/user/1000/at-spi/bus',)"),
1498 Check::ok("false"),
1499 ),
1500 ] {
1501 let capabilities =
1502 capability_map(&platform, &portals, &accessibility, &windowing, &input);
1503 assert!(capabilities.accessibility.is_empty());
1504 }
1505 }
1506
1507 #[test]
1508 fn parses_parent_pid_from_proc_status() {
1509 let status = "Name:\ttest\nPid:\t42\nPPid:\t7\n";
1510
1511 assert_eq!(parse_parent_pid(status), Some(7));
1512 }
1513
1514 #[test]
1515 fn parses_nul_separated_process_environment() {
1516 let environment = parse_environ(
1517 b"DISPLAY=:0\0WAYLAND_DISPLAY=wayland-0\0EMPTY=\0NO_EQUALS\0XDG_SESSION_TYPE=wayland\0",
1518 );
1519
1520 assert_eq!(environment.get("DISPLAY").map(String::as_str), Some(":0"));
1521 assert_eq!(
1522 environment.get("WAYLAND_DISPLAY").map(String::as_str),
1523 Some("wayland-0")
1524 );
1525 assert_eq!(environment.get("EMPTY").map(String::as_str), Some(""));
1526 assert!(!environment.contains_key("NO_EQUALS"));
1527 }
1528
1529 #[test]
1530 fn desktop_env_hydration_includes_xauthority() {
1531 assert!(DESKTOP_ENV_KEYS.contains(&"XAUTHORITY"));
1532 }
1533
1534 #[test]
1535 fn desktop_env_hydration_preserves_explicit_native_x11() {
1536 let current_env = HashMap::from([
1537 ("DISPLAY".to_string(), ":90".to_string()),
1538 ("XDG_SESSION_TYPE".to_string(), "x11".to_string()),
1539 ]);
1540 let host_env = HashMap::from([
1541 ("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string()),
1542 (
1543 "XDG_CURRENT_DESKTOP".to_string(),
1544 "ubuntu:GNOME".to_string(),
1545 ),
1546 ]);
1547
1548 let updates = desktop_env_hydration_updates(¤t_env, &host_env);
1549
1550 assert!(!updates.iter().any(|(key, _)| *key == "WAYLAND_DISPLAY"));
1551 assert!(updates
1552 .iter()
1553 .any(|(key, value)| { *key == "XDG_CURRENT_DESKTOP" && value == "ubuntu:GNOME" }));
1554 }
1555
1556 #[test]
1557 fn desktop_env_hydration_still_imports_wayland_for_incomplete_sessions() {
1558 let current_env = HashMap::new();
1559 let host_env = HashMap::from([("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string())]);
1560
1561 let updates = desktop_env_hydration_updates(¤t_env, &host_env);
1562
1563 assert!(updates
1564 .iter()
1565 .any(|(key, value)| *key == "WAYLAND_DISPLAY" && value == "wayland-0"));
1566 }
1567
1568 #[test]
1569 fn graphical_process_env_requires_display() {
1570 let with_display = HashMap::from([("DISPLAY".to_string(), ":0".to_string())]);
1571 let with_wayland =
1572 HashMap::from([("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string())]);
1573 let without_display = HashMap::from([("XAUTHORITY".to_string(), "/tmp/xauth".to_string())]);
1574
1575 assert!(process_env_has_graphical_display(&with_display));
1576 assert!(process_env_has_graphical_display(&with_wayland));
1577 assert!(!process_env_has_graphical_display(&without_display));
1578 }
1579
1580 #[test]
1581 fn capabilities_prefer_xdotool_before_ydotool_on_x11() {
1582 let mut platform = platform_report();
1583 platform.xdg_session_type = Some("x11".to_string());
1584 platform.wayland_display = None;
1585 platform.display = Some(":0".to_string());
1586 let input = InputReport {
1587 ydotool: Check::ok("ydotool"),
1588 ydotoold: Check::ok("ydotoold"),
1589 ydotool_socket: Check::ok("connectable"),
1590 uinput: Check::fail("missing"),
1591 xdotool: Check::ok("xdotool"),
1592 wtype: Check::fail("missing wtype"),
1593 };
1594
1595 let capabilities = capability_map(
1596 &platform,
1597 &portal_report(Check::fail("missing")),
1598 &accessibility_report(Check::fail("missing"), Check::fail("missing")),
1599 &windowing_report(false, false),
1600 &input,
1601 );
1602
1603 assert_eq!(capabilities.input, ["xdotool", "ydotool"]);
1604 assert_eq!(capabilities.preferred.input.as_deref(), Some("xdotool"));
1605 }
1606
1607 #[test]
1608 fn x11_diagnostics_ignore_portal_and_accept_xdotool() {
1609 let mut platform = platform_report();
1610 platform.xdg_session_type = Some("x11".to_string());
1611 platform.wayland_display = None;
1612 platform.display = Some(":0".to_string());
1613 let portals = portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop"));
1614 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1615 let windowing = windowing_report(true, true);
1616 let mut input = input_report(false);
1617 input.xdotool = Check::ok("xdotool");
1618
1619 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1620 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
1621
1622 assert_eq!(capabilities.input, ["xdotool"]);
1623 assert_eq!(capabilities.preferred.input.as_deref(), Some("xdotool"));
1624 assert!(readiness.can_send_development_input);
1625 }
1626
1627 #[test]
1628 fn wayland_diagnostics_advertise_wtype_without_portal_or_ydotool() {
1629 let mut platform = platform_report();
1630 platform.xdg_session_type = Some("wayland".to_string());
1631 platform.xdg_current_desktop = Some("Hyprland".to_string());
1632 platform.wayland_display = Some("wayland-0".to_string());
1633 let portals = portal_report(Check::fail("missing"));
1634 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1635 let windowing = windowing_report(true, true);
1636 let mut input = input_report(false);
1637 input.wtype = Check::ok("wtype");
1638
1639 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1640 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
1641
1642 assert_eq!(capabilities.input, ["wtype"]);
1643 assert_eq!(capabilities.preferred.input.as_deref(), Some("wtype"));
1644 assert!(readiness.can_send_development_input);
1645 }
1646
1647 #[test]
1648 fn wtype_excludes_known_incompatible_wayland_desktops() {
1649 assert!(wtype_compatible_wayland_desktop(Some("Hyprland")));
1650 assert!(wtype_compatible_wayland_desktop(Some("sway")));
1651 assert!(wtype_compatible_wayland_desktop(None));
1652 assert!(!wtype_compatible_wayland_desktop(Some("GNOME")));
1653 assert!(!wtype_compatible_wayland_desktop(Some("KDE;Plasma")));
1654 assert!(!wtype_compatible_wayland_desktop(Some("COSMIC")));
1655 }
1656
1657 #[test]
1658 fn wayland_diagnostics_prefer_ydotool_before_portal() {
1659 let platform = platform_report();
1660 let portals = portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop"));
1661 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1662 let windowing = windowing_report(true, true);
1663 let input = input_report_parts(
1664 Check::ok("ydotool"),
1665 Check::ok("ydotoold"),
1666 Check::ok("connectable"),
1667 Check::fail("missing uinput"),
1668 );
1669
1670 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1671
1672 assert_eq!(capabilities.input, ["ydotool", "portal"]);
1673 assert_eq!(capabilities.preferred.input.as_deref(), Some("ydotool"));
1674 }
1675
1676 #[test]
1677 fn portal_force_order_requires_both_input_modalities() {
1678 assert!(force_portal_for_all_input(true, true, false, false));
1679 assert!(!force_portal_for_all_input(true, false, false, false));
1680 assert!(!force_portal_for_all_input(true, true, true, false));
1681 assert!(!force_portal_for_all_input(true, true, false, true));
1682 }
1683
1684 fn remote_desktop_runtime_introspection() -> Check {
1685 Check::ok(
1686 "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n\
1687 .CreateSession method a{sv} o -\n\
1688 .SelectDevices method oa{sv} o -\n\
1689 .Start method osa{sv} o -\n\
1690 .NotifyPointerMotionAbsolute method oa{sv}udd - -\n\
1691 .NotifyPointerButton method oa{sv}iu - -\n\
1692 .NotifyPointerAxisDiscrete method oa{sv}ui - -\n\
1693 .NotifyKeyboardKeycode method ouu - -\n\
1694 .NotifyKeyboardKeysym method ouu - -\n\
1695 .AvailableDeviceTypes property u 3 emits-change",
1696 )
1697 }
1698
1699 fn screencast_runtime_introspection() -> Check {
1700 Check::ok(
1701 "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n\
1702 .SelectSources method oa{sv} o -",
1703 )
1704 }
1705
1706 #[test]
1707 fn remote_desktop_portal_rejects_header_only_introspection() {
1708 let introspection = Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS");
1709 let available_device_types = Check::ok("u 3");
1710 let pointer = remote_desktop_pointer_check_from(
1711 &introspection,
1712 &screencast_runtime_introspection(),
1713 &available_device_types,
1714 &Check::ok("u 1"),
1715 );
1716 let keyboard = remote_desktop_keyboard_check_from(&introspection, &available_device_types);
1717
1718 assert!(!pointer.ok);
1719 assert!(pointer.detail.contains("CreateSession"));
1720 assert!(pointer.detail.contains("NotifyPointerButton"));
1721 assert!(!keyboard.ok);
1722 assert!(keyboard.detail.contains("NotifyKeyboardKeysym"));
1723
1724 let input = input_report_parts(
1725 Check::ok("ydotool"),
1726 Check::ok("ydotoold"),
1727 Check::fail("no connectable ydotool socket"),
1728 Check::ok("read/write: /dev/uinput"),
1729 );
1730 let platform = platform_report();
1731 let portals = portal_report(pointer);
1732 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1733 let windowing = windowing_report(true, true);
1734 let capabilities = capability_map_with_portal_keyboard(
1735 &platform,
1736 &portals,
1737 &keyboard,
1738 &accessibility,
1739 &windowing,
1740 &input,
1741 );
1742 let readiness = readiness_report_with_portal_keyboard(
1743 &platform,
1744 &keyboard,
1745 &accessibility,
1746 &windowing,
1747 &input,
1748 );
1749
1750 assert!(!capabilities.input.iter().any(|backend| backend == "portal"));
1751 assert!(!readiness.can_send_development_input);
1752 }
1753
1754 #[test]
1755 fn keyboard_only_portal_remains_advertised_without_pointer_capability() {
1756 let platform = platform_report();
1757 let keyboard = remote_desktop_keyboard_check_from(
1758 &remote_desktop_runtime_introspection(),
1759 &Check::ok("u 1"),
1760 );
1761 let portals = portal_report(Check::fail(
1762 "ScreenCast AvailableSourceTypes=2 does not include monitor sources",
1763 ));
1764 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1765 let windowing = windowing_report(true, true);
1766 let input = input_report_parts(
1767 Check::fail("missing ydotool"),
1768 Check::fail("ydotoold not running"),
1769 Check::fail("no connectable ydotool socket"),
1770 Check::fail("/dev/uinput: Permission denied"),
1771 );
1772
1773 let capabilities = capability_map_with_portal_keyboard(
1774 &platform,
1775 &portals,
1776 &keyboard,
1777 &accessibility,
1778 &windowing,
1779 &input,
1780 );
1781 let readiness = readiness_report_with_portal_keyboard(
1782 &platform,
1783 &keyboard,
1784 &accessibility,
1785 &windowing,
1786 &input,
1787 );
1788
1789 assert!(!portals.remote_desktop.ok);
1790 assert_eq!(capabilities.input, ["portal"]);
1791 assert_eq!(capabilities.preferred.input.as_deref(), Some("portal"));
1792 assert!(readiness.can_send_development_input);
1793 }
1794
1795 #[test]
1796 fn remote_desktop_pointer_rejects_missing_screencast_contract() {
1797 let check = remote_desktop_pointer_check_from(
1798 &remote_desktop_runtime_introspection(),
1799 &Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS"),
1800 &Check::ok("u 2"),
1801 &Check::ok("u 1"),
1802 );
1803
1804 assert!(!check.ok);
1805 assert!(check.detail.contains("SelectSources"));
1806 }
1807
1808 #[test]
1809 fn remote_desktop_pointer_rejects_missing_monitor_source_type() {
1810 let check = remote_desktop_pointer_check_from(
1811 &remote_desktop_runtime_introspection(),
1812 &screencast_runtime_introspection(),
1813 &Check::ok("u 2"),
1814 &Check::ok("u 2"),
1815 );
1816
1817 assert!(!check.ok);
1818 assert!(check.detail.contains("does not include monitor sources"));
1819 }
1820
1821 #[test]
1822 fn remote_desktop_portal_rejects_missing_keyboard_device_type() {
1823 let check = remote_desktop_keyboard_check_from(
1824 &remote_desktop_runtime_introspection(),
1825 &Check::ok("u 2"),
1826 );
1827
1828 assert!(!check.ok);
1829 assert!(check.detail.contains("does not include keyboard input"));
1830 }
1831
1832 #[test]
1833 fn remote_desktop_portal_accepts_runtime_keyboard_contract() {
1834 let check = remote_desktop_keyboard_check_from(
1835 &remote_desktop_runtime_introspection(),
1836 &Check::ok("u 3"),
1837 );
1838
1839 assert!(check.ok);
1840 assert!(check.detail.contains("AvailableDeviceTypes=3"));
1841 }
1842
1843 #[test]
1844 fn pointer_only_portal_remains_advertised_without_keyboard_readiness() {
1845 let platform = platform_report();
1846 let available_device_types = Check::ok("u 2");
1847 let pointer = remote_desktop_pointer_check_from(
1848 &remote_desktop_runtime_introspection(),
1849 &screencast_runtime_introspection(),
1850 &available_device_types,
1851 &Check::ok("u 1"),
1852 );
1853 let keyboard = remote_desktop_keyboard_check_from(
1854 &remote_desktop_runtime_introspection(),
1855 &available_device_types,
1856 );
1857 let portals = portal_report(pointer);
1858 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1859 let windowing = windowing_report(true, true);
1860 let input = input_report_parts(
1861 Check::fail("missing ydotool"),
1862 Check::fail("ydotoold not running"),
1863 Check::fail("no connectable ydotool socket"),
1864 Check::fail("/dev/uinput: Permission denied"),
1865 );
1866
1867 let capabilities = capability_map_with_portal_keyboard(
1868 &platform,
1869 &portals,
1870 &keyboard,
1871 &accessibility,
1872 &windowing,
1873 &input,
1874 );
1875 let readiness = readiness_report_with_portal_keyboard(
1876 &platform,
1877 &keyboard,
1878 &accessibility_report(Check::ok("bus"), Check::ok("true")),
1879 &windowing_report(true, true),
1880 &input,
1881 );
1882
1883 assert!(portals.remote_desktop.ok);
1884 assert!(capabilities.input.iter().any(|backend| backend == "portal"));
1885 assert!(!readiness.can_send_development_input);
1886 }
1887
1888 #[test]
1889 fn capabilities_require_display_to_advertise_xdotool() {
1890 let mut platform = platform_report();
1891 platform.xdg_session_type = Some("x11".to_string());
1892 platform.wayland_display = None;
1893 platform.display = None;
1894 let input = InputReport {
1895 ydotool: Check::ok("ydotool"),
1896 ydotoold: Check::ok("ydotoold"),
1897 ydotool_socket: Check::ok("connectable"),
1898 uinput: Check::fail("missing"),
1899 xdotool: Check::ok("xdotool"),
1900 wtype: Check::fail("missing wtype"),
1901 };
1902
1903 let capabilities = capability_map(
1904 &platform,
1905 &portal_report(Check::fail("missing")),
1906 &accessibility_report(Check::fail("missing"), Check::fail("missing")),
1907 &windowing_report(false, false),
1908 &input,
1909 );
1910
1911 assert_eq!(capabilities.input, ["ydotool"]);
1912 assert_eq!(capabilities.preferred.input.as_deref(), Some("ydotool"));
1913 }
1914
1915 #[test]
1916 fn xdotool_diagnostics_force_precedence_matches_runtime() {
1917 let mut platform = platform_report();
1918 platform.xdg_session_type = Some("wayland".to_string());
1919 platform.display = Some(":0".to_string());
1920 let mut input = input_report(false);
1921 input.xdotool = Check::ok("xdotool");
1922
1923 assert!(should_advertise_xdotool(&platform, &input, false, true));
1924 assert!(!should_advertise_xdotool(&platform, &input, true, true));
1925 }
1926
1927 #[test]
1928 fn parses_systemd_show_environment_output() {
1929 let environment = parse_line_environment(
1930 b"DISPLAY=:0\nHYPRLAND_INSTANCE_SIGNATURE=abc\nNO_EQUALS\nYDOTOOL_SOCKET=/run/ydotoold/socket\n",
1931 );
1932
1933 assert_eq!(environment.get("DISPLAY").map(String::as_str), Some(":0"));
1934 assert_eq!(
1935 environment
1936 .get("HYPRLAND_INSTANCE_SIGNATURE")
1937 .map(String::as_str),
1938 Some("abc")
1939 );
1940 assert_eq!(
1941 environment.get("YDOTOOL_SOCKET").map(String::as_str),
1942 Some("/run/ydotoold/socket")
1943 );
1944 assert!(!environment.contains_key("NO_EQUALS"));
1945 }
1946
1947 #[test]
1948 fn readiness_requires_exact_window_focus_for_targeted_input() {
1949 let platform = platform_report();
1950 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1951 let windowing = windowing_report(true, false);
1952 let input = input_report(true);
1953
1954 let readiness = readiness_report(
1955 &platform,
1956 &portal_report(Check::fail("missing")),
1957 &accessibility,
1958 &windowing,
1959 &input,
1960 );
1961
1962 assert!(readiness.can_query_windows);
1963 assert!(!readiness.can_focus_windows);
1964 assert!(readiness
1965 .recommended_next_step
1966 .contains("exact-focus window backend"));
1967 assert!(readiness
1968 .blockers
1969 .iter()
1970 .any(|blocker| blocker.contains("Exact window activation")));
1971 }
1972
1973 #[test]
1974 fn readiness_treats_kwin_as_full_window_backend() {
1975 let platform = platform_report();
1976 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1977 let mut windowing = windowing_report(false, false);
1978 windowing.kwin = Check::ok("KWin scripting is available");
1979 windowing.can_list_windows = true;
1980 windowing.can_focus_apps = true;
1981 windowing.can_focus_windows = true;
1982 let input = input_report(true);
1983
1984 let readiness = readiness_report(
1985 &platform,
1986 &portal_report(Check::fail("missing")),
1987 &accessibility,
1988 &windowing,
1989 &input,
1990 );
1991
1992 assert!(readiness.can_query_windows);
1993 assert!(readiness.can_focus_apps);
1994 assert!(readiness.can_focus_windows);
1995 assert!(readiness.blockers.is_empty());
1996 }
1997
1998 #[test]
1999 fn readiness_message_mentions_generic_window_targeting() {
2000 let platform = platform_report();
2001 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2002 let windowing = windowing_report(true, true);
2003 let input = input_report(true);
2004
2005 let readiness = readiness_report(
2006 &platform,
2007 &portal_report(Check::fail("missing")),
2008 &accessibility,
2009 &windowing,
2010 &input,
2011 );
2012
2013 assert!(readiness.blockers.is_empty());
2014 assert!(readiness
2015 .recommended_next_step
2016 .contains("AT-SPI tree support"));
2017 assert!(readiness.recommended_next_step.contains("window targeting"));
2018 assert!(!readiness
2019 .recommended_next_step
2020 .contains("GNOME window targeting"));
2021 }
2022
2023 #[test]
2024 fn readiness_accepts_connectable_ydotool_socket_without_direct_uinput_access() {
2025 let platform = platform_report();
2026 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2027 let windowing = windowing_report(true, true);
2028 let input = input_report_parts(
2029 Check::ok("ydotool"),
2030 Check::ok("ydotoold"),
2031 Check::ok("connectable: /tmp/.ydotool_socket"),
2032 Check::fail("/dev/uinput: Permission denied"),
2033 );
2034
2035 let readiness = readiness_report(
2036 &platform,
2037 &portal_report(Check::fail("missing")),
2038 &accessibility,
2039 &windowing,
2040 &input,
2041 );
2042
2043 assert!(readiness.can_send_development_input);
2044 assert!(readiness.blockers.is_empty());
2045 }
2046
2047 #[test]
2048 fn readiness_uses_connectable_ydotool_socket_when_process_probe_fails() {
2049 let platform = platform_report();
2050 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2051 let windowing = windowing_report(true, true);
2052 let input = input_report_parts(
2053 Check::ok("ydotool"),
2054 Check::fail("ydotoold process name not found"),
2055 Check::ok("connectable: /run/user/1000/.ydotool_socket"),
2056 Check::fail("/dev/uinput: Permission denied"),
2057 );
2058 let portals = portal_report(Check::fail("missing"));
2059
2060 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2061 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2062
2063 assert!(capabilities
2064 .input
2065 .iter()
2066 .any(|backend| backend == "ydotool"));
2067 assert!(readiness.can_send_development_input);
2068 }
2069
2070 #[test]
2071 fn wayland_readiness_rejects_pointer_only_uinput_without_keyboard_backend() {
2072 let platform = platform_report();
2073 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2074 let windowing = windowing_report(true, true);
2075 let input = input_report_parts(
2076 Check::ok("ydotool"),
2077 Check::fail("ydotoold not running"),
2078 Check::fail("no connectable ydotool socket"),
2079 Check::ok("read/write: /dev/uinput"),
2080 );
2081
2082 let readiness = readiness_report(
2083 &platform,
2084 &portal_report(Check::fail("missing")),
2085 &accessibility,
2086 &windowing,
2087 &input,
2088 );
2089
2090 assert!(!readiness.can_send_development_input);
2091 assert!(readiness
2092 .blockers
2093 .iter()
2094 .any(|blocker| blocker.contains("absolute pointer input")));
2095 assert!(readiness
2096 .recommended_next_step
2097 .contains("keyboard-capable input backend"));
2098 }
2099
2100 #[test]
2101 fn x11_readiness_rejects_pointer_only_uinput_without_keyboard_backend() {
2102 let mut platform = platform_report();
2103 platform.xdg_session_type = Some("x11".to_string());
2104 platform.wayland_display = None;
2105 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2106 let windowing = windowing_report(true, true);
2107 let input = input_report_parts(
2108 Check::fail("missing ydotool"),
2109 Check::fail("ydotoold not running"),
2110 Check::fail("no connectable ydotool socket"),
2111 Check::ok("read/write: /dev/uinput"),
2112 );
2113
2114 let readiness = readiness_report(
2115 &platform,
2116 &portal_report(Check::fail("missing")),
2117 &accessibility,
2118 &windowing,
2119 &input,
2120 );
2121
2122 assert!(!readiness.can_send_development_input);
2123 }
2124
2125 #[test]
2126 fn readiness_accepts_remote_desktop_portal_without_local_input_backend() {
2127 let platform = platform_report();
2128 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2129 let windowing = windowing_report(true, true);
2130 let input = input_report_parts(
2131 Check::fail("missing ydotool"),
2132 Check::fail("ydotoold not running"),
2133 Check::fail("no connectable ydotool socket"),
2134 Check::fail("/dev/uinput: Permission denied"),
2135 );
2136
2137 let readiness = readiness_report(
2138 &platform,
2139 &portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop")),
2140 &accessibility,
2141 &windowing,
2142 &input,
2143 );
2144
2145 assert!(readiness.can_send_development_input);
2146 assert!(readiness.blockers.is_empty());
2147 }
2148
2149 #[test]
2150 fn readiness_rejects_inaccessible_ydotool_paths() {
2151 let platform = platform_report();
2152 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2153 let windowing = windowing_report(true, true);
2154 let input = input_report_parts(
2155 Check::ok("ydotool"),
2156 Check::ok("ydotoold"),
2157 Check::fail("/tmp/.ydotool_socket: Permission denied"),
2158 Check::fail("/dev/uinput: Permission denied"),
2159 );
2160
2161 let readiness = readiness_report(
2162 &platform,
2163 &portal_report(Check::fail("missing")),
2164 &accessibility,
2165 &windowing,
2166 &input,
2167 );
2168
2169 assert!(!readiness.can_send_development_input);
2170 assert!(readiness
2171 .recommended_next_step
2172 .contains("Enable a keyboard-capable input backend"));
2173 assert!(readiness
2174 .blockers
2175 .iter()
2176 .any(|blocker| blocker.contains("Development keyboard input is unavailable")));
2177 }
2178
2179 #[test]
2180 fn capability_map_rejects_incompatible_ydotool_with_live_daemon() {
2181 let platform = platform_report();
2182 let portals = portal_report(Check::fail("missing"));
2183 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2184 let windowing = windowing_report(true, true);
2185 let input = input_report_parts(
2186 Check::fail("unsupported legacy ydotool CLI"),
2187 Check::ok("ydotoold"),
2188 Check::ok("connectable socket"),
2189 Check::fail("/dev/uinput: Permission denied"),
2190 );
2191
2192 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2193 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2194
2195 assert!(!capabilities
2196 .input
2197 .iter()
2198 .any(|backend| backend == "ydotool"));
2199 assert!(!readiness.can_send_development_input);
2200 }
2201
2202 #[test]
2203 fn ydotool_socket_check_rejects_legacy_stream_socket() {
2204 let dir = std::env::temp_dir().join(format!(
2205 "computer-use-linux-diagnostics-{}",
2206 std::process::id()
2207 ));
2208 let _ = std::fs::remove_dir_all(&dir);
2209 std::fs::create_dir_all(&dir).expect("create temp diagnostics dir");
2210 let socket = dir.join("ydotool.sock");
2211 let listener =
2212 std::os::unix::net::UnixListener::bind(&socket).expect("bind temp diagnostics socket");
2213
2214 let check = socket_connect_check(&socket);
2215
2216 assert!(!check.ok, "{check:?}");
2217 drop(listener);
2218 let _ = std::fs::remove_dir_all(&dir);
2219 }
2220
2221 #[test]
2222 fn ydotool_socket_check_accepts_datagram_socket() {
2223 let dir = std::env::temp_dir().join(format!(
2224 "computer-use-linux-diagnostics-dgram-{}",
2225 std::process::id()
2226 ));
2227 let _ = std::fs::remove_dir_all(&dir);
2228 std::fs::create_dir_all(&dir).expect("create temp diagnostics dir");
2229 let socket = dir.join("ydotool.sock");
2230 let datagram =
2231 std::os::unix::net::UnixDatagram::bind(&socket).expect("bind temp datagram socket");
2232
2233 let check = socket_connect_check(&socket);
2234
2235 assert!(check.ok, "{check:?}");
2236 drop(datagram);
2237 let _ = std::fs::remove_dir_all(&dir);
2238 }
2239
2240 #[test]
2241 fn readiness_reports_cosmic_window_blocker_on_cosmic() {
2242 let mut platform = platform_report();
2243 platform.xdg_current_desktop = Some("COSMIC".to_string());
2244 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2245 let windowing = windowing_report(false, false);
2246 let input = input_report(true);
2247
2248 let readiness = readiness_report(
2249 &platform,
2250 &portal_report(Check::fail("missing")),
2251 &accessibility,
2252 &windowing,
2253 &input,
2254 );
2255
2256 assert!(readiness
2257 .blockers
2258 .iter()
2259 .any(|blocker| blocker.contains("COSMIC Wayland window introspection")));
2260 }
2261}