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