1use crate::windowing::registry::{
2 self, COSMIC_WAYLAND_BACKEND, GNOME_SHELL_EXTENSION_BACKEND, GNOME_SHELL_INTROSPECT_BACKEND,
3 HYPRLAND_BACKEND, I3_BACKEND, KWIN_BACKEND, LABWC_BACKEND, NIRI_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,
13 path::{Path, PathBuf},
14 process::Command,
15 sync::Mutex,
16};
17
18const DESKTOP_ENV_KEYS: &[&str] = &[
19 "DBUS_SESSION_BUS_ADDRESS",
20 "DESKTOP_SESSION",
21 "DISPLAY",
22 "HYPRLAND_INSTANCE_SIGNATURE",
23 "XAUTHORITY",
24 "YDOTOOL_SOCKET",
25 "XDG_SESSION_DESKTOP",
26 "WAYLAND_DISPLAY",
27 "XDG_CURRENT_DESKTOP",
28 "XDG_RUNTIME_DIR",
29 "XDG_SESSION_TYPE",
30];
31const FORCE_YDOTOOL_KEYBOARD_ENV_KEYS: &[&str] = &["COMPUTER_USE_LINUX_FORCE_YDOTOOL_KEYBOARD"];
32const FORCE_YDOTOOL_POINTER_ENV_KEYS: &[&str] = &["COMPUTER_USE_LINUX_FORCE_YDOTOOL_POINTER"];
33const FORCE_XDOTOOL_KEYBOARD_ENV_KEYS: &[&str] = &["COMPUTER_USE_LINUX_FORCE_XDOTOOL_KEYBOARD"];
34const FORCE_PORTAL_KEYBOARD_ENV_KEYS: &[&str] = &["COMPUTER_USE_LINUX_FORCE_PORTAL_KEYBOARD"];
35const FORCE_PORTAL_POINTER_ENV_KEYS: &[&str] = &["COMPUTER_USE_LINUX_FORCE_PORTAL_POINTER"];
36const PORTAL_DEVICE_KEYBOARD: u32 = 1;
37const PORTAL_DEVICE_POINTER: u32 = 2;
38const PORTAL_SOURCE_MONITOR: u32 = 1;
39const REMOTE_DESKTOP_KEYBOARD_METHODS: &[&str] = &[
40 "CreateSession",
41 "SelectDevices",
42 "Start",
43 "NotifyKeyboardKeycode",
44 "NotifyKeyboardKeysym",
45];
46const REMOTE_DESKTOP_POINTER_METHODS: &[&str] = &[
47 "CreateSession",
48 "SelectDevices",
49 "Start",
50 "NotifyPointerMotionAbsolute",
51 "NotifyPointerButton",
52 "NotifyPointerAxisDiscrete",
53];
54const SCREENCAST_POINTER_METHODS: &[&str] = &["SelectSources"];
55const SCREENSHOT_PORTAL_METHODS: &[&str] = &["Screenshot"];
59const SCREENCAST_PORTAL_METHODS: &[&str] = &["CreateSession", "SelectSources", "Start"];
60const INPUT_CAPTURE_PORTAL_METHODS: &[&str] = &["GetZones", "Enable", "ConnectToEIS"];
61
62#[derive(Debug, Clone, Serialize, JsonSchema)]
63pub struct DoctorReport {
64 pub platform: PlatformReport,
65 pub portals: PortalReport,
66 pub accessibility: AccessibilityReport,
67 pub windowing: WindowingReport,
68 pub input: InputReport,
69 pub readiness: ReadinessReport,
70 pub capabilities: CapabilityMap,
74}
75
76#[derive(Debug, Clone, Serialize, JsonSchema)]
77pub struct CapabilityMap {
78 pub input: Vec<String>,
80 pub screenshot: Vec<String>,
82 pub window_control: Vec<String>,
84 pub accessibility: Vec<String>,
86 pub isolation: Vec<String>,
88 pub preferred: PreferredBackends,
90}
91
92#[derive(Debug, Clone, Serialize, JsonSchema)]
93pub struct PreferredBackends {
94 pub input: Option<String>,
95 pub screenshot: Option<String>,
96 pub window_control: Option<String>,
97}
98
99#[derive(Debug, Clone, Serialize, JsonSchema)]
100pub struct PlatformReport {
101 pub os: String,
102 pub arch: String,
103 pub desktop_session: Option<String>,
104 pub xdg_session_type: Option<String>,
105 pub xdg_current_desktop: Option<String>,
106 pub wayland_display: Option<String>,
107 pub display: Option<String>,
108 pub xauthority: Option<String>,
109 pub dbus_session_bus_address: Option<String>,
110 pub xdg_runtime_dir: Option<String>,
111 pub gnome_shell_version: Check,
112 pub gnome_screenshot: Check,
113 pub x11_display: Check,
116}
117
118#[derive(Debug, Clone, Serialize, JsonSchema)]
119pub struct PortalReport {
120 pub desktop_portal: Check,
121 pub remote_desktop: Check,
122 pub screencast: Check,
123 pub screenshot: Check,
124 pub input_capture: Check,
125 pub mutter_remote_desktop: Check,
126 pub mutter_screencast: Check,
127}
128
129#[derive(Debug, Clone, Serialize, JsonSchema)]
130pub struct AccessibilityReport {
131 pub at_spi_bus: Check,
132 pub toolkit_accessibility: Check,
133 pub at_spi_enabled: Check,
134 pub screen_reader_enabled: Check,
135 pub forced_toolkit_bridge: bool,
136}
137
138#[derive(Debug, Clone, Serialize, JsonSchema)]
139pub struct WindowingReport {
140 pub gnome_shell_introspect: Check,
141 pub computer_use_linux_gnome_shell_extension: Check,
142 pub cosmic_helper: Check,
143 pub kwin: Check,
144 pub hyprland: Check,
145 pub niri: Check,
146 pub backends: BTreeMap<String, Check>,
147 pub can_list_windows: bool,
148 pub can_focus_apps: bool,
149 pub can_focus_windows: bool,
150 pub note: String,
151}
152
153#[derive(Debug, Clone, Serialize, JsonSchema)]
154pub struct InputReport {
155 pub ydotool: Check,
156 pub ydotoold: Check,
157 pub ydotool_socket: Check,
158 pub uinput: Check,
159 pub xdotool: Check,
162 pub wtype: Check,
164}
165
166#[derive(Debug, Clone, Serialize, JsonSchema)]
167pub struct ReadinessReport {
168 pub can_register_mcp_tools: bool,
169 pub can_build_accessibility_tree: bool,
170 pub can_query_windows: bool,
171 pub can_focus_apps: bool,
172 pub can_focus_windows: bool,
173 pub can_send_development_input: bool,
174 pub can_capture_screenshots: bool,
177 pub screenshot_capture_status: ScreenshotCaptureStatus,
178 pub recommended_next_step: String,
179 pub blockers: Vec<String>,
180 pub warnings: Vec<String>,
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
185#[serde(rename_all = "snake_case")]
186pub enum ScreenshotCaptureStatus {
187 Unavailable,
189 Unverified,
191 Verified,
193 Failed,
195}
196
197const SCREENSHOT_UNAVAILABLE: &str = "No screenshot route was detected: GNOME Shell is absent, the XDG portal does not export org.freedesktop.portal.Screenshot, this is not a native X11 session, and gnome-screenshot is not installed. get_app_state and screenshot return no image; element-aware actions from the accessibility tree still work.";
198const SCREENSHOT_UNVERIFIED: &str = "Screenshot routes were detected, but capture is unverified. Doctor does not take a screenshot or check screenshot consent. GNOME Shell can reject callers, and a portal may require consent for the caller's app ID. Call screenshot or get_app_state to verify capture.";
199const SCREENSHOT_MISSING_GNOME_FALLBACK: &str = "gnome-screenshot is not installed. On GNOME it provides a fallback when Shell or portal calls are denied; install it or inspect the desktop portal logs and screenshot permissions for the actual caller.";
200const SCREENSHOT_FAILED: &str = "Screenshot capture failed for this request. Inspect screenshot_error for the full backend errors; accessibility-tree actions still work.";
201const SCREENSHOT_FAILED_NEXT_STEP: &str = "Inspect screenshot_error and the desktop portal logs for the failed capture. On GNOME, gnome-screenshot can provide a fallback when Shell or portal calls are denied.";
202const READY_NEXT_STEP: &str = "Computer Use is ready: AT-SPI tree support, window targeting, and a Linux input backend are available.";
203
204impl ReadinessReport {
205 pub(crate) fn record_screenshot_result(&mut self, succeeded: bool) {
207 self.can_capture_screenshots = succeeded;
208 self.screenshot_capture_status = if succeeded {
209 ScreenshotCaptureStatus::Verified
210 } else {
211 ScreenshotCaptureStatus::Failed
212 };
213 self.warnings.retain(|warning| {
214 warning != SCREENSHOT_UNVERIFIED && warning != SCREENSHOT_MISSING_GNOME_FALLBACK
215 });
216 self.blockers
217 .retain(|blocker| blocker != SCREENSHOT_UNAVAILABLE && blocker != SCREENSHOT_FAILED);
218 if !succeeded {
219 self.blockers.push(SCREENSHOT_FAILED.to_string());
220 }
221 if self.can_build_accessibility_tree
222 && self.can_query_windows
223 && self.can_focus_windows
224 && self.can_send_development_input
225 {
226 self.recommended_next_step = if succeeded {
227 READY_NEXT_STEP
228 } else {
229 SCREENSHOT_FAILED_NEXT_STEP
230 }
231 .to_string();
232 }
233 }
234}
235
236#[derive(Debug, Clone, Serialize, JsonSchema)]
237pub struct SetupReport {
238 pub before: DoctorReport,
239 pub accessibility_command: Check,
240 pub after: DoctorReport,
241 pub changed_accessibility: bool,
242 pub requires_target_app_restart: bool,
243 pub message: String,
244}
245
246#[derive(Debug, Clone, Serialize, JsonSchema)]
247pub struct Check {
248 pub ok: bool,
249 pub detail: String,
250}
251
252impl Check {
253 fn ok(detail: impl Into<String>) -> Self {
254 Self {
255 ok: true,
256 detail: detail.into(),
257 }
258 }
259
260 fn fail(detail: impl Into<String>) -> Self {
261 Self {
262 ok: false,
263 detail: detail.into(),
264 }
265 }
266}
267
268pub fn doctor_report() -> DoctorReport {
269 hydrate_session_bus_env();
270
271 let platform = platform_report();
272 let (portals, remote_desktop_keyboard) = portal_report();
273 let accessibility = accessibility_report();
274 let windowing = windowing_report(&platform);
275 let input = input_report();
276 let readiness = readiness_report_with_portal_keyboard(
277 &platform,
278 &remote_desktop_keyboard,
279 &accessibility,
280 &windowing,
281 &input,
282 !screenshot_backends(&platform, &portals).is_empty(),
283 );
284
285 let capabilities = capability_map_with_portal_keyboard(
286 &platform,
287 &portals,
288 &remote_desktop_keyboard,
289 &accessibility,
290 &windowing,
291 &input,
292 );
293
294 DoctorReport {
295 platform,
296 portals,
297 accessibility,
298 windowing,
299 input,
300 readiness,
301 capabilities,
302 }
303}
304
305#[cfg(test)]
308fn capability_map(
309 platform: &PlatformReport,
310 portals: &PortalReport,
311 accessibility: &AccessibilityReport,
312 windowing: &WindowingReport,
313 input: &InputReport,
314) -> CapabilityMap {
315 capability_map_with_portal_keyboard(
316 platform,
317 portals,
318 &portals.remote_desktop,
319 accessibility,
320 windowing,
321 input,
322 )
323}
324
325fn capability_map_with_portal_keyboard(
326 platform: &PlatformReport,
327 portals: &PortalReport,
328 remote_desktop_keyboard: &Check,
329 accessibility: &AccessibilityReport,
330 windowing: &WindowingReport,
331 input: &InputReport,
332) -> CapabilityMap {
333 let mut input_backends = Vec::new();
334 if input.uinput.ok && platform_is_wayland(platform) && !is_cosmic_wayland_platform(platform) {
336 input_backends.push("abs_pointer".to_string());
337 }
338 let force_ydotool = env_flag_enabled_any(FORCE_YDOTOOL_KEYBOARD_ENV_KEYS);
339 let force_xdotool = env_flag_enabled_any(FORCE_XDOTOOL_KEYBOARD_ENV_KEYS);
340 let portal_pointer_available = portal_pointer_input_available(platform, portals);
341 let portal_keyboard_available =
342 portal_keyboard_input_available(platform, remote_desktop_keyboard);
343 let portal_available = portal_pointer_available || portal_keyboard_available;
344 let portal_forced_for_all_input = portal_pointer_available
345 && portal_keyboard_available
346 && force_portal_for_all_input(
347 env_flag_enabled_any(FORCE_PORTAL_POINTER_ENV_KEYS),
348 env_flag_enabled_any(FORCE_PORTAL_KEYBOARD_ENV_KEYS),
349 env_flag_enabled_any(FORCE_YDOTOOL_POINTER_ENV_KEYS),
350 force_ydotool,
351 );
352 if should_advertise_xdotool(platform, input, force_ydotool, force_xdotool) {
353 input_backends.push("xdotool".to_string());
354 }
355 if platform_is_wayland(platform)
356 && wtype_compatible_wayland_desktop(platform.xdg_current_desktop.as_deref())
357 && input.wtype.ok
358 && !force_ydotool
359 {
360 input_backends.push("wtype".to_string());
361 }
362 let portal_first = portal_forced_for_all_input
363 || (is_cosmic_wayland_platform(platform)
364 && portal_pointer_available
365 && !env_flag_enabled_any(FORCE_YDOTOOL_POINTER_ENV_KEYS));
366 if portal_available && portal_first {
367 input_backends.push("portal".to_string());
368 }
369 if input.ydotool.ok && input.ydotool_socket.ok {
370 input_backends.push("ydotool".to_string());
371 }
372 if portal_available && !portal_first {
373 input_backends.push("portal".to_string());
374 }
375
376 let screenshot_backends = screenshot_backends(platform, portals);
377
378 let mut window_backends = Vec::new();
379 let x11_available = windowing
380 .backends
381 .get(X11_BACKEND)
382 .is_some_and(|check| check.ok);
383 let prefer_x11_over_introspect = windowing.gnome_shell_introspect.ok
384 && x11_available
385 && registry::backend_can_exact_focus(X11_BACKEND);
386 if windowing.computer_use_linux_gnome_shell_extension.ok {
387 window_backends.push("gnome_shell_extension".to_string());
388 }
389 if prefer_x11_over_introspect {
390 window_backends.push(X11_BACKEND.to_string());
391 }
392 if windowing.gnome_shell_introspect.ok {
393 window_backends.push("gnome_introspect".to_string());
394 }
395 if windowing.cosmic_helper.ok {
396 window_backends.push("cosmic".to_string());
397 }
398 if windowing.kwin.ok {
399 window_backends.push("kwin".to_string());
400 }
401 if windowing.hyprland.ok {
402 window_backends.push("hyprland".to_string());
403 }
404 if windowing.niri.ok {
405 window_backends.push(NIRI_BACKEND.to_string());
406 }
407 for backend in [I3_BACKEND, LABWC_BACKEND] {
411 if windowing
412 .backends
413 .get(backend)
414 .is_some_and(|check| check.ok)
415 {
416 window_backends.push(backend.to_string());
417 }
418 }
419 if x11_available && !prefer_x11_over_introspect {
420 window_backends.push(X11_BACKEND.to_string());
421 }
422
423 let mut accessibility_backends = Vec::new();
424 if can_build_accessibility_tree(accessibility) {
425 accessibility_backends.push("at_spi".to_string());
426 }
427
428 let mut isolation = vec!["shared".to_string()];
432 if platform.gnome_shell_version.ok {
433 isolation.push("headless_gnome".to_string());
434 }
435
436 let preferred = PreferredBackends {
437 input: input_backends.first().cloned(),
438 screenshot: screenshot_backends.first().cloned(),
439 window_control: window_backends.first().cloned(),
440 };
441
442 CapabilityMap {
443 input: input_backends,
444 screenshot: screenshot_backends,
445 window_control: window_backends,
446 accessibility: accessibility_backends,
447 isolation,
448 preferred,
449 }
450}
451
452pub fn hydrate_session_bus_env() {
453 static HYDRATED_ENV: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
454 let mut hydrated = HYDRATED_ENV
455 .lock()
456 .unwrap_or_else(|error| error.into_inner());
457 let current = desktop_environment();
458 if hydrated.as_ref() == Some(¤t) {
459 return;
460 }
461 hydrate_common_command_path();
462 hydrate_desktop_env_from_process_tree();
463 let systemd_discovery_succeeded = hydrate_desktop_env_from_systemd_user();
464
465 if env_var("XDG_RUNTIME_DIR").is_none() {
466 if let Some(runtime) = xdg_runtime_dir() {
467 if runtime.exists() {
468 env::set_var("XDG_RUNTIME_DIR", runtime);
469 }
470 }
471 }
472
473 if env_var("DBUS_SESSION_BUS_ADDRESS").is_none() {
474 if let Some(runtime) = xdg_runtime_dir() {
475 let bus = runtime.join("bus");
476 if bus.exists() {
477 env::set_var(
478 "DBUS_SESSION_BUS_ADDRESS",
479 format!("unix:path={}", bus.display()),
480 );
481 }
482 }
483 }
484 let current = desktop_environment();
485 if systemd_discovery_succeeded
486 && current.contains_key("DBUS_SESSION_BUS_ADDRESS")
487 && current.contains_key("XDG_RUNTIME_DIR")
488 && current.contains_key("XDG_CURRENT_DESKTOP")
489 && current.contains_key("XDG_SESSION_TYPE")
490 && process_env_has_graphical_display(¤t)
491 {
492 *hydrated = Some(current);
493 }
494}
495
496fn desktop_environment() -> HashMap<String, String> {
497 DESKTOP_ENV_KEYS
498 .iter()
499 .copied()
500 .chain(["PATH"])
501 .filter_map(|key| env_var(key).map(|value| (key.to_string(), value)))
502 .collect()
503}
504
505fn hydrate_common_command_path() {
506 let mut entries = env::var_os("PATH")
507 .map(|path| env::split_paths(&path).collect::<Vec<_>>())
508 .unwrap_or_default();
509 for path in [
510 "/run/current-system/sw/bin",
511 "/usr/local/bin",
512 "/usr/bin",
513 "/bin",
514 ] {
515 let path = PathBuf::from(path);
516 if path.exists() && !entries.iter().any(|entry| entry == &path) {
517 entries.push(path);
518 }
519 }
520 if let Ok(path) = env::join_paths(entries) {
521 env::set_var("PATH", path);
522 }
523}
524
525fn hydrate_desktop_env_from_process_tree() {
526 for process_env in desktop_process_environments() {
527 hydrate_desktop_env_from_map(&process_env);
528
529 if DESKTOP_ENV_KEYS.iter().all(|key| env_var(key).is_some()) {
530 break;
531 }
532 }
533}
534
535fn hydrate_desktop_env_from_systemd_user() -> bool {
536 let mut command = Command::new("systemctl");
537 command.args(["--user", "show-environment"]);
538 let Ok(output) =
539 crate::command_runner::output_blocking(&mut command, "read systemd user environment")
540 else {
541 return false;
542 };
543 if !output.status.success() {
544 return false;
545 }
546 let env_map = parse_line_environment(&output.stdout);
547 hydrate_desktop_env_from_map(&env_map);
548 true
549}
550
551fn hydrate_desktop_env_from_map(process_env: &HashMap<String, String>) {
552 let current_env = DESKTOP_ENV_KEYS
553 .iter()
554 .filter_map(|key| env_var(key).map(|value| ((*key).to_string(), value)))
555 .collect();
556 for (key, value) in desktop_env_hydration_updates(¤t_env, process_env) {
557 env::set_var(key, value);
558 }
559}
560
561fn desktop_env_hydration_updates(
562 current_env: &HashMap<String, String>,
563 source_env: &HashMap<String, String>,
564) -> Vec<(&'static str, String)> {
565 let preserve_native_x11 = current_env
569 .get("XDG_SESSION_TYPE")
570 .is_some_and(|value| value.trim().eq_ignore_ascii_case("x11"))
571 && current_env
572 .get("DISPLAY")
573 .is_some_and(|value| !value.trim().is_empty())
574 && current_env
575 .get("WAYLAND_DISPLAY")
576 .is_none_or(|value| value.trim().is_empty());
577
578 DESKTOP_ENV_KEYS
579 .iter()
580 .filter_map(|key| {
581 if current_env
582 .get(*key)
583 .is_some_and(|value| !value.trim().is_empty())
584 || preserve_native_x11 && *key == "WAYLAND_DISPLAY"
585 {
586 return None;
587 }
588 source_env
589 .get(*key)
590 .filter(|value| !value.trim().is_empty())
591 .map(|value| (*key, value.clone()))
592 })
593 .collect()
594}
595
596fn desktop_process_environments() -> Vec<HashMap<String, String>> {
597 let mut environments = Vec::new();
598 let mut visited_pids = Vec::new();
599 let mut pid = parent_pid("self");
600
601 for _ in 0..8 {
602 let Some(current_pid) = pid else {
603 break;
604 };
605 if current_pid <= 1 {
606 break;
607 }
608
609 visited_pids.push(current_pid);
610 if let Some(process_env) = read_process_environ(current_pid) {
611 environments.push(process_env);
612 }
613 pid = parent_pid(¤t_pid.to_string());
614 }
615
616 if !visited_pids.contains(&1) && process_owner_matches_current_user(1) {
617 if let Some(process_env) = read_process_environ(1).filter(process_env_has_graphical_display)
618 {
619 environments.push(process_env);
620 }
621 }
622
623 environments
624}
625
626fn parent_pid(pid: &str) -> Option<u32> {
627 let status = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
628 parse_parent_pid(&status)
629}
630
631fn parse_parent_pid(status: &str) -> Option<u32> {
632 status.lines().find_map(|line| {
633 let value = line.strip_prefix("PPid:")?.trim();
634 value.parse::<u32>().ok()
635 })
636}
637
638fn read_process_environ(pid: u32) -> Option<HashMap<String, String>> {
639 let bytes = fs::read(format!("/proc/{pid}/environ")).ok()?;
640 Some(parse_environ(&bytes))
641}
642
643fn process_owner_matches_current_user(pid: u32) -> bool {
644 let Some(current_uid) = user_id().and_then(|uid| uid.parse::<u32>().ok()) else {
645 return false;
646 };
647 fs::metadata(format!("/proc/{pid}"))
648 .ok()
649 .is_some_and(|metadata| metadata.uid() == current_uid)
650}
651
652fn process_env_has_graphical_display(process_env: &HashMap<String, String>) -> bool {
653 process_env
654 .get("DISPLAY")
655 .or_else(|| process_env.get("WAYLAND_DISPLAY"))
656 .is_some_and(|value| !value.trim().is_empty())
657}
658
659fn parse_environ(bytes: &[u8]) -> HashMap<String, String> {
660 bytes
661 .split(|byte| *byte == 0)
662 .filter_map(|entry| {
663 if entry.is_empty() {
664 return None;
665 }
666 let split = entry.iter().position(|byte| *byte == b'=')?;
667 let (key, value) = entry.split_at(split);
668 let value = &value[1..];
669 let key = std::str::from_utf8(key).ok()?.to_string();
670 let value = std::str::from_utf8(value).ok()?.to_string();
671 Some((key, value))
672 })
673 .collect()
674}
675
676fn parse_line_environment(bytes: &[u8]) -> HashMap<String, String> {
677 bytes
678 .split(|byte| *byte == b'\n')
679 .filter_map(|entry| {
680 if entry.is_empty() {
681 return None;
682 }
683 let split = entry.iter().position(|byte| *byte == b'=')?;
684 let (key, value) = entry.split_at(split);
685 let value = &value[1..];
686 let key = std::str::from_utf8(key).ok()?.to_string();
687 let value = std::str::from_utf8(value).ok()?.to_string();
688 Some((key, value))
689 })
690 .collect()
691}
692
693pub fn setup_accessibility_report() -> SetupReport {
694 hydrate_session_bus_env();
695
696 let before = doctor_report();
697 let accessibility_command =
698 enable_accessibility(&before.accessibility, command_check_with_session_bus);
699 let after = doctor_report();
700 let before_ready = before.readiness.can_build_accessibility_tree;
701 let after_ready = after.readiness.can_build_accessibility_tree;
702 let saved_before = check_detail_contains_true(&before.accessibility.toolkit_accessibility);
703 let saved_after = check_detail_contains_true(&after.accessibility.toolkit_accessibility);
704 let changed_accessibility = after_ready && saved_after && (!before_ready || !saved_before);
705 let requires_target_app_restart = changed_accessibility;
706 let message = if after_ready && !saved_after {
707 "AT-SPI is available at runtime, but toolkit-accessibility could not be verified as enabled in GSettings. Newly launched apps may have no accessibility tree. Check accessibility_command and the saved setting; other accessibility tools can change it."
708 } else if after_ready {
709 if changed_accessibility {
710 "AT-SPI accessibility is enabled. Restart already-running target apps if their AT-SPI tree is still empty."
711 } else {
712 "AT-SPI accessibility is ready."
713 }
714 } else {
715 "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."
716 }
717 .to_string();
718
719 SetupReport {
720 before,
721 accessibility_command,
722 after,
723 changed_accessibility,
724 requires_target_app_restart,
725 message,
726 }
727}
728
729fn enable_accessibility(
732 before: &AccessibilityReport,
733 mut run: impl FnMut(&str, &[&str]) -> Check,
734) -> Check {
735 let setting = if check_detail_contains_true(&before.toolkit_accessibility) {
736 before.toolkit_accessibility.clone()
737 } else {
738 let write = run(
739 "gsettings",
740 &[
741 "set",
742 "org.gnome.desktop.interface",
743 "toolkit-accessibility",
744 "true",
745 ],
746 );
747 let read = run(
748 "gsettings",
749 &[
750 "get",
751 "org.gnome.desktop.interface",
752 "toolkit-accessibility",
753 ],
754 );
755 if !check_detail_contains_true(&read) {
756 let runtime = run(
757 "busctl",
758 &[
759 "--user",
760 "set-property",
761 "org.a11y.Bus",
762 "/org/a11y/bus",
763 "org.a11y.Status",
764 "IsEnabled",
765 "b",
766 "true",
767 ],
768 );
769 return Check::fail(format!(
770 "Saved toolkit-accessibility was not verified: {}; write: {}; runtime fallback: {}",
771 read.detail, write.detail, runtime.detail
772 ));
773 }
774 read
775 };
776 if !can_build_accessibility_tree(before) {
777 let runtime = run(
778 "busctl",
779 &[
780 "--user",
781 "set-property",
782 "org.a11y.Bus",
783 "/org/a11y/bus",
784 "org.a11y.Status",
785 "IsEnabled",
786 "b",
787 "true",
788 ],
789 );
790 return Check::ok(format!(
791 "Saved toolkit-accessibility verified: {}; runtime request: {}",
792 setting.detail, runtime.detail
793 ));
794 }
795 Check::ok("Saved toolkit-accessibility is enabled; runtime AT-SPI is available")
796}
797
798fn platform_report() -> PlatformReport {
799 PlatformReport {
800 os: std::env::consts::OS.to_string(),
801 arch: std::env::consts::ARCH.to_string(),
802 desktop_session: env_var("DESKTOP_SESSION"),
803 xdg_session_type: env_var("XDG_SESSION_TYPE"),
804 xdg_current_desktop: env_var("XDG_CURRENT_DESKTOP"),
805 wayland_display: env_var("WAYLAND_DISPLAY"),
806 display: env_var("DISPLAY"),
807 xauthority: env_var("XAUTHORITY"),
808 dbus_session_bus_address: dbus_session_address(),
809 xdg_runtime_dir: xdg_runtime_dir().map(|path| path.display().to_string()),
810 gnome_shell_version: command_check("gnome-shell", &["--version"]),
811 gnome_screenshot: command_check("gnome-screenshot", &["--version"]),
812 x11_display: x11_display_check(),
813 }
814}
815
816fn x11_display_check() -> Check {
817 if !crate::x11_display::is_native_x11_session() {
818 return Check::fail("not a native X11 session");
819 }
820 match crate::x11_display::X11Display::connect().map(|display| display.describe()) {
823 Ok(detail) => Check::ok(detail),
824 Err(error) => Check::fail(format!("{error:#}")),
825 }
826}
827
828fn portal_report() -> (PortalReport, Check) {
829 let (remote_desktop, remote_desktop_keyboard, screencast) = remote_desktop_portal_checks();
830 (
831 PortalReport {
832 desktop_portal: bus_name_check("org.freedesktop.portal.Desktop"),
833 remote_desktop,
834 screencast,
835 screenshot: portal_interface_methods_check(
836 "org.freedesktop.portal.Screenshot",
837 SCREENSHOT_PORTAL_METHODS,
838 ),
839 input_capture: portal_interface_methods_check(
840 "org.freedesktop.portal.InputCapture",
841 INPUT_CAPTURE_PORTAL_METHODS,
842 ),
843 mutter_remote_desktop: bus_name_check("org.gnome.Mutter.RemoteDesktop"),
844 mutter_screencast: bus_name_check("org.gnome.Mutter.ScreenCast"),
845 },
846 remote_desktop_keyboard,
847 )
848}
849
850fn accessibility_report() -> AccessibilityReport {
851 AccessibilityReport {
852 at_spi_bus: atspi_bus_address_check(),
853 toolkit_accessibility: command_check_with_session_bus(
854 "gsettings",
855 &[
856 "get",
857 "org.gnome.desktop.interface",
858 "toolkit-accessibility",
859 ],
860 ),
861 at_spi_enabled: at_spi_enabled_check(),
862 screen_reader_enabled: screen_reader_enabled_check(),
863 forced_toolkit_bridge: toolkit_bridge_forced(),
864 }
865}
866
867fn windowing_report(platform: &PlatformReport) -> WindowingReport {
868 let probes = registry::probe_backends();
869 let backend_check = |id: &str| {
870 probes
871 .iter()
872 .find(|probe| probe.id == id)
873 .map(check_from_backend_probe)
874 .unwrap_or_else(|| Check::fail("backend probe did not run"))
875 };
876 let gnome_shell_introspect = backend_check(GNOME_SHELL_INTROSPECT_BACKEND);
877 let computer_use_linux_gnome_shell_extension = backend_check(GNOME_SHELL_EXTENSION_BACKEND);
878 let cosmic_helper = backend_check(COSMIC_WAYLAND_BACKEND);
879 let kwin = backend_check(KWIN_BACKEND);
880 let hyprland = backend_check(HYPRLAND_BACKEND);
881 let niri = backend_check(NIRI_BACKEND);
882 let backends = probes
883 .iter()
884 .map(|probe| (probe.id.to_string(), check_from_backend_probe(probe)))
885 .collect::<BTreeMap<_, _>>();
886 let can_list_windows = probes.iter().any(|probe| probe.can_list_windows);
887 let can_focus_apps = probes.iter().any(|probe| probe.can_focus_apps);
888 let can_focus_windows = probes.iter().any(|probe| probe.can_focus_windows);
889 let note = if can_list_windows {
890 if !can_focus_windows {
891 "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)."
892 } else if cosmic_helper.ok && is_cosmic_wayland_platform(platform) {
893 "A COSMIC Wayland window backend is available for list_windows, focused_window, and targeted input verification."
894 } else if kwin.ok {
895 "A KWin/Plasma window backend is available for list_windows, focused_window, and targeted input verification."
896 } else if hyprland.ok {
897 "A Hyprland window backend is available for list_windows, focused_window, and targeted input verification."
898 } else if niri.ok {
899 "A niri window backend is available for list_windows, focused_window, and targeted input verification."
900 } else {
901 "A window listing backend is available for list_windows, focused_window, and targeted input verification."
902 }
903 } else {
904 "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. On niri, ensure the session exposes NIRI_SOCKET so the niri IPC socket is reachable. On labwc, the session socket labwc.sock lists windows."
905 }
906 .to_string();
907
908 WindowingReport {
909 gnome_shell_introspect,
910 computer_use_linux_gnome_shell_extension,
911 cosmic_helper,
912 kwin,
913 hyprland,
914 niri,
915 backends,
916 can_list_windows,
917 can_focus_apps,
918 can_focus_windows,
919 note,
920 }
921}
922
923fn check_from_backend_probe(probe: ®istry::BackendProbe) -> Check {
924 if probe.ok {
925 Check::ok(probe.detail.clone())
926 } else {
927 Check::fail(probe.detail.clone())
928 }
929}
930
931fn input_report() -> InputReport {
932 InputReport {
933 ydotool: match ydotool::ensure_supported() {
934 Ok(support) => Check::ok(support.detail),
935 Err(detail) => Check::fail(detail),
936 },
937 ydotoold: process_check("ydotoold"),
938 ydotool_socket: ydotool_socket_check(),
939 uinput: read_write_path_check(Path::new("/dev/uinput")),
940 xdotool: command_path_check("xdotool"),
941 wtype: command_path_check("wtype"),
942 }
943}
944
945#[cfg(test)]
946fn readiness_report(
947 platform: &PlatformReport,
948 portals: &PortalReport,
949 accessibility: &AccessibilityReport,
950 windowing: &WindowingReport,
951 input: &InputReport,
952) -> ReadinessReport {
953 readiness_report_with_portal_keyboard(
954 platform,
955 &portals.remote_desktop,
956 accessibility,
957 windowing,
958 input,
959 !screenshot_backends(platform, portals).is_empty(),
960 )
961}
962
963fn screenshot_backends(platform: &PlatformReport, portals: &PortalReport) -> Vec<String> {
966 let mut backends = Vec::new();
967 if platform.gnome_shell_version.ok {
968 backends.push("gnome_shell".to_string());
969 }
970 if portals.screenshot.ok {
971 backends.push("portal".to_string());
972 }
973 if platform.x11_display.ok {
974 backends.push("x11".to_string());
975 }
976 if platform.gnome_screenshot.ok {
978 backends.push("gnome_screenshot".to_string());
979 }
980 backends
981}
982
983fn readiness_report_with_portal_keyboard(
984 platform: &PlatformReport,
985 remote_desktop_keyboard: &Check,
986 accessibility: &AccessibilityReport,
987 windowing: &WindowingReport,
988 input: &InputReport,
989 screenshot_routes_detected: bool,
990) -> ReadinessReport {
991 let mut blockers = Vec::new();
992 let mut warnings = Vec::new();
993 let can_build_accessibility_tree = can_build_accessibility_tree(accessibility);
994 let can_query_windows = windowing.can_list_windows;
995 let can_focus_apps = windowing.can_focus_apps;
996 let can_focus_windows = windowing.can_focus_windows;
997 let can_send_development_input =
998 can_send_development_input(platform, remote_desktop_keyboard, input);
999
1000 if let Some(blocker) =
1001 atspi_disabled_blocker(accessibility, accessibility.forced_toolkit_bridge)
1002 {
1003 blockers.push(blocker);
1004 }
1005
1006 if !can_query_windows {
1007 blockers.push(if is_cosmic_wayland_platform(platform) {
1008 "COSMIC Wayland window introspection is unavailable; targeted window focus and verification will be disabled.".to_string()
1009 } else {
1010 "Window introspection is unavailable; targeted window focus and verification will be disabled."
1011 .to_string()
1012 });
1013 }
1014
1015 if can_query_windows && !can_focus_windows {
1016 blockers.push(
1017 "Exact window activation is unavailable; app-level focus may work, but window_id/title/terminal-targeted input cannot be verified."
1018 .to_string(),
1019 );
1020 }
1021
1022 if !can_send_development_input {
1023 blockers.push(
1024 "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."
1025 .to_string(),
1026 );
1027 }
1028
1029 if screenshot_routes_detected {
1030 warnings.push(SCREENSHOT_UNVERIFIED.to_string());
1031 if platform.gnome_shell_version.ok && !platform.gnome_screenshot.ok {
1032 warnings.push(SCREENSHOT_MISSING_GNOME_FALLBACK.to_string());
1033 }
1034 } else {
1035 blockers.push(SCREENSHOT_UNAVAILABLE.to_string());
1036 }
1037
1038 let recommended_next_step = if !can_build_accessibility_tree {
1039 "Run setup_accessibility to enable AT-SPI accessibility before element-aware actions."
1040 .to_string()
1041 } else if !can_query_windows {
1042 format!(
1043 "Enable a supported window backend before using targeted keyboard input: {}",
1044 registry::descriptors()
1045 .iter()
1046 .map(|descriptor| descriptor.missing_hint)
1047 .collect::<Vec<_>>()
1048 .join(" ")
1049 )
1050 } else if !can_focus_windows {
1051 "Enable an exact-focus window backend before using window_id, title, or terminal-targeted input.".to_string()
1052 } else if !can_send_development_input {
1053 "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."
1054 .to_string()
1055 } else if !screenshot_routes_detected {
1056 "Enable a screenshot route: install an XDG desktop portal backend that implements Screenshot for this desktop, or install gnome-screenshot. Accessibility-tree actions work meanwhile."
1057 .to_string()
1058 } else {
1059 "AT-SPI tree support, window targeting, and a Linux input backend are available. Call screenshot or get_app_state to verify screenshot capture."
1060 .to_string()
1061 };
1062
1063 ReadinessReport {
1064 can_register_mcp_tools: true,
1065 can_build_accessibility_tree,
1066 can_query_windows,
1067 can_focus_apps,
1068 can_focus_windows,
1069 can_send_development_input,
1070 can_capture_screenshots: false,
1071 screenshot_capture_status: if screenshot_routes_detected {
1072 ScreenshotCaptureStatus::Unverified
1073 } else {
1074 ScreenshotCaptureStatus::Unavailable
1075 },
1076 recommended_next_step,
1077 blockers,
1078 warnings,
1079 }
1080}
1081
1082fn can_send_development_input(
1083 platform: &PlatformReport,
1084 remote_desktop_keyboard: &Check,
1085 input: &InputReport,
1086) -> bool {
1087 let force_ydotool = env_flag_enabled_any(FORCE_YDOTOOL_KEYBOARD_ENV_KEYS);
1088 let force_xdotool = env_flag_enabled_any(FORCE_XDOTOOL_KEYBOARD_ENV_KEYS);
1089 portal_keyboard_input_available(platform, remote_desktop_keyboard)
1090 || platform_is_wayland(platform)
1091 && wtype_compatible_wayland_desktop(platform.xdg_current_desktop.as_deref())
1092 && input.wtype.ok
1093 && !force_ydotool
1094 || should_advertise_xdotool(platform, input, force_ydotool, force_xdotool)
1095 || input.ydotool.ok && input.ydotool_socket.ok
1096}
1097
1098fn portal_pointer_input_available(platform: &PlatformReport, portals: &PortalReport) -> bool {
1099 platform_is_wayland(platform) && portals.remote_desktop.ok
1100}
1101
1102fn portal_keyboard_input_available(
1103 platform: &PlatformReport,
1104 remote_desktop_keyboard: &Check,
1105) -> bool {
1106 platform_is_wayland(platform) && remote_desktop_keyboard.ok
1107}
1108
1109fn is_cosmic_wayland_platform(platform: &PlatformReport) -> bool {
1110 platform
1111 .xdg_current_desktop
1112 .as_deref()
1113 .is_some_and(|desktop| desktop.to_ascii_lowercase().contains("cosmic"))
1114 && platform.xdg_session_type.as_deref() == Some("wayland")
1115}
1116
1117pub(crate) fn can_build_accessibility_tree_now() -> bool {
1118 hydrate_session_bus_env();
1119 let accessibility = accessibility_report();
1120 can_build_accessibility_tree(&accessibility)
1121}
1122
1123fn can_build_accessibility_tree(accessibility: &AccessibilityReport) -> bool {
1124 accessibility_tree_ready(accessibility, accessibility.forced_toolkit_bridge)
1125}
1126
1127fn accessibility_tree_ready(accessibility: &AccessibilityReport, forced_bridge: bool) -> bool {
1128 accessibility.at_spi_bus.ok
1129 && (forced_bridge
1130 || check_detail_contains_true(&accessibility.at_spi_enabled)
1131 || check_detail_contains_true(&accessibility.toolkit_accessibility))
1132}
1133
1134fn atspi_disabled_blocker(
1135 accessibility: &AccessibilityReport,
1136 forced_bridge: bool,
1137) -> Option<String> {
1138 if accessibility_tree_ready(accessibility, forced_bridge) {
1139 None
1140 } else {
1141 Some(
1142 "AT-SPI accessibility is disabled; enable org.a11y.Status IsEnabled or org.gnome.desktop.interface toolkit-accessibility for tree extraction."
1143 .to_string(),
1144 )
1145 }
1146}
1147
1148fn toolkit_bridge_forced() -> bool {
1149 if toolkit_bridge_forced_by_env(env_var) {
1150 return true;
1151 }
1152 let compositor = compositor_environ();
1153 toolkit_bridge_forced_by_env(|key| {
1154 env_var(key).or_else(|| compositor.as_ref().and_then(|vars| vars.get(key).cloned()))
1155 })
1156}
1157
1158fn toolkit_bridge_forced_by_env(vars: impl Fn(&str) -> Option<String>) -> bool {
1159 vars("QT_LINUX_ACCESSIBILITY_ALWAYS_ON").is_some_and(|value| value == "1")
1160 || vars("GTK_MODULES").is_some_and(|value| value.contains("atk-bridge"))
1161}
1162
1163fn compositor_environ() -> Option<HashMap<String, String>> {
1164 let pid = crate::windowing::wayland_session::compositor_pid()?;
1167 read_process_environ(pid)
1168}
1169
1170fn check_detail_contains_true(check: &Check) -> bool {
1171 check.ok && check.detail.to_ascii_lowercase().contains("true")
1172}
1173
1174fn env_var(key: &str) -> Option<String> {
1175 env::var(key).ok().filter(|value| !value.trim().is_empty())
1176}
1177
1178fn xdg_runtime_dir() -> Option<PathBuf> {
1179 if let Some(value) = env_var("XDG_RUNTIME_DIR") {
1180 return Some(PathBuf::from(value));
1181 }
1182 user_id().map(|uid| PathBuf::from(format!("/run/user/{uid}")))
1183}
1184
1185fn dbus_session_address() -> Option<String> {
1186 if let Some(value) = env_var("DBUS_SESSION_BUS_ADDRESS") {
1187 return Some(value);
1188 }
1189 xdg_runtime_dir()
1190 .map(|runtime| format!("unix:path={}", runtime.join("bus").display()))
1191 .filter(|address| {
1192 address
1193 .strip_prefix("unix:path=")
1194 .is_some_and(|p| Path::new(p).exists())
1195 })
1196}
1197
1198fn ydotool_socket_check() -> Check {
1199 match ydotool::connectable_socket_path() {
1200 Ok(path) => Check::ok(format!("connectable: {}", path.display())),
1201 Err(detail) => Check::fail(format!("no trusted connectable ydotool socket ({detail})")),
1202 }
1203}
1204
1205fn user_id() -> Option<String> {
1206 let mut command = Command::new("id");
1207 command.arg("-u");
1208 let output = crate::command_runner::output_blocking(&mut command, "query user id").ok()?;
1209 output
1210 .status
1211 .success()
1212 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
1213 .filter(|value| !value.is_empty())
1214}
1215
1216fn command_path_check(command: &str) -> Check {
1217 command_check("sh", &["-c", &format!("command -v {command}")])
1218}
1219
1220fn platform_is_wayland(platform: &PlatformReport) -> bool {
1221 match platform.xdg_session_type.as_deref() {
1222 Some(value) => value.eq_ignore_ascii_case("wayland"),
1223 None => platform
1224 .wayland_display
1225 .as_deref()
1226 .is_some_and(|display| !display.trim().is_empty()),
1227 }
1228}
1229
1230pub(crate) fn wtype_compatible_wayland_desktop(desktop: Option<&str>) -> bool {
1231 desktop.is_none_or(|desktop| {
1232 let desktop = desktop.to_ascii_lowercase();
1233 !["gnome", "kde", "plasma", "cosmic"]
1234 .iter()
1235 .any(|known_incompatible| desktop.contains(known_incompatible))
1236 })
1237}
1238
1239fn should_advertise_xdotool(
1240 platform: &PlatformReport,
1241 input: &InputReport,
1242 force_ydotool: bool,
1243 force_xdotool: bool,
1244) -> bool {
1245 !force_ydotool
1246 && input.xdotool.ok
1247 && platform
1248 .display
1249 .as_deref()
1250 .is_some_and(|display| !display.trim().is_empty())
1251 && (force_xdotool || !platform_is_wayland(platform))
1252}
1253
1254fn env_flag_enabled_any(keys: &[&str]) -> bool {
1255 keys.iter()
1256 .any(|key| env::var(key).ok().as_deref() == Some("1"))
1257}
1258
1259fn force_portal_for_all_input(
1260 force_portal_pointer: bool,
1261 force_portal_keyboard: bool,
1262 force_ydotool_pointer: bool,
1263 force_ydotool_keyboard: bool,
1264) -> bool {
1265 force_portal_pointer
1266 && force_portal_keyboard
1267 && !force_ydotool_pointer
1268 && !force_ydotool_keyboard
1269}
1270
1271fn process_check(process_name: &str) -> Check {
1272 command_check("pgrep", &["-a", process_name])
1273}
1274
1275#[cfg(test)]
1276fn socket_connect_check(path: &Path) -> Check {
1277 match socket_connect_result(path) {
1278 Ok(()) => Check::ok(format!("connectable: {}", path.display())),
1279 Err(detail) => Check::fail(detail),
1280 }
1281}
1282
1283#[cfg(test)]
1284fn socket_connect_result(path: &Path) -> std::result::Result<(), String> {
1285 if !path.exists() {
1286 return Err(format!("missing: {}", path.display()));
1287 }
1288
1289 std::os::unix::net::UnixDatagram::unbound()
1290 .and_then(|socket| socket.connect(path))
1291 .map_err(|error| format!("{}: datagram: {error}", path.display()))
1292}
1293
1294fn read_write_path_check(path: &Path) -> Check {
1295 if !path.exists() {
1296 return Check::fail(format!("missing: {}", path.display()));
1297 }
1298
1299 match OpenOptions::new().read(true).write(true).open(path) {
1300 Ok(_) => Check::ok(format!("read/write: {}", path.display())),
1301 Err(error) => Check::fail(format!("{}: {error}", path.display())),
1302 }
1303}
1304
1305fn bus_name_check(name: &str) -> Check {
1306 command_check_with_session_bus("busctl", &["--user", "status", name])
1307}
1308
1309fn portal_interface_check(interface: &str) -> Check {
1310 command_check_with_session_bus(
1311 "busctl",
1312 &[
1313 "--user",
1314 "introspect",
1315 "org.freedesktop.portal.Desktop",
1316 "/org/freedesktop/portal/desktop",
1317 interface,
1318 ],
1319 )
1320}
1321
1322fn portal_interface_methods_check(interface: &str, methods: &[&str]) -> Check {
1324 require_busctl_methods(portal_interface_check(interface), interface, methods)
1325}
1326
1327fn require_busctl_methods(introspection: Check, interface: &str, methods: &[&str]) -> Check {
1328 if !introspection.ok {
1329 return introspection;
1330 }
1331 let missing = missing_busctl_methods(&introspection, methods);
1332 if missing.is_empty() {
1333 return introspection;
1334 }
1335 if !busctl_introspection_has_members(&introspection.detail) {
1336 return Check::fail(format!(
1337 "{interface} is not exported by the portal (introspection listed no members)"
1338 ));
1339 }
1340 Check::fail(format!(
1341 "{interface} is missing required methods: {}",
1342 missing.join(", ")
1343 ))
1344}
1345
1346fn busctl_introspection_has_members(detail: &str) -> bool {
1347 detail
1348 .lines()
1349 .any(|line| line.trim_start().starts_with('.'))
1350}
1351
1352fn remote_desktop_portal_checks() -> (Check, Check, Check) {
1353 let introspection = portal_interface_check("org.freedesktop.portal.RemoteDesktop");
1354 let screencast = portal_interface_methods_check(
1355 "org.freedesktop.portal.ScreenCast",
1356 SCREENCAST_PORTAL_METHODS,
1357 );
1358 if !introspection.ok {
1359 return (introspection.clone(), introspection, screencast);
1360 }
1361
1362 let available_device_types = command_check_with_session_bus(
1363 "busctl",
1364 &[
1365 "--user",
1366 "get-property",
1367 "org.freedesktop.portal.Desktop",
1368 "/org/freedesktop/portal/desktop",
1369 "org.freedesktop.portal.RemoteDesktop",
1370 "AvailableDeviceTypes",
1371 ],
1372 );
1373 let available_source_types = command_check_with_session_bus(
1374 "busctl",
1375 &[
1376 "--user",
1377 "get-property",
1378 "org.freedesktop.portal.Desktop",
1379 "/org/freedesktop/portal/desktop",
1380 "org.freedesktop.portal.ScreenCast",
1381 "AvailableSourceTypes",
1382 ],
1383 );
1384 let pointer = remote_desktop_pointer_check_from(
1385 &introspection,
1386 &screencast,
1387 &available_device_types,
1388 &available_source_types,
1389 );
1390 let keyboard = remote_desktop_keyboard_check_from(&introspection, &available_device_types);
1391 (pointer, keyboard, screencast)
1392}
1393
1394fn remote_desktop_pointer_check_from(
1395 introspection: &Check,
1396 screencast: &Check,
1397 available_device_types: &Check,
1398 available_source_types: &Check,
1399) -> Check {
1400 if !introspection.ok {
1401 return Check::fail(introspection.detail.clone());
1402 }
1403 let missing_methods = missing_busctl_methods(introspection, REMOTE_DESKTOP_POINTER_METHODS);
1404 if !missing_methods.is_empty() {
1405 return Check::fail(format!(
1406 "RemoteDesktop interface is missing required pointer methods: {}",
1407 missing_methods.join(", ")
1408 ));
1409 }
1410
1411 if !screencast.ok {
1412 return Check::fail(format!(
1413 "ScreenCast interface is unavailable for portal pointer input: {}",
1414 screencast.detail
1415 ));
1416 }
1417 let missing_screencast_methods = missing_busctl_methods(screencast, SCREENCAST_POINTER_METHODS);
1418 if !missing_screencast_methods.is_empty() {
1419 return Check::fail(format!(
1420 "ScreenCast interface is missing required pointer methods: {}",
1421 missing_screencast_methods.join(", ")
1422 ));
1423 }
1424
1425 let device_types = match remote_desktop_device_types(available_device_types) {
1426 Ok(device_types) => device_types,
1427 Err(detail) => return Check::fail(detail),
1428 };
1429 if device_types & PORTAL_DEVICE_POINTER == 0 {
1430 return Check::fail(format!(
1431 "RemoteDesktop AvailableDeviceTypes={device_types} does not include pointer input"
1432 ));
1433 }
1434
1435 let source_types = match screencast_source_types(available_source_types) {
1436 Ok(source_types) => source_types,
1437 Err(detail) => return Check::fail(detail),
1438 };
1439 if source_types & PORTAL_SOURCE_MONITOR == 0 {
1440 return Check::fail(format!(
1441 "ScreenCast AvailableSourceTypes={source_types} does not include monitor sources"
1442 ));
1443 }
1444
1445 Check::ok(format!(
1446 "pointer-capable RemoteDesktop portal (AvailableDeviceTypes={device_types}, AvailableSourceTypes={source_types})"
1447 ))
1448}
1449
1450fn remote_desktop_keyboard_check_from(
1451 introspection: &Check,
1452 available_device_types: &Check,
1453) -> Check {
1454 if !introspection.ok {
1455 return Check::fail(introspection.detail.clone());
1456 }
1457
1458 let missing_methods = missing_busctl_methods(introspection, REMOTE_DESKTOP_KEYBOARD_METHODS);
1459 if !missing_methods.is_empty() {
1460 return Check::fail(format!(
1461 "RemoteDesktop interface is missing required keyboard methods: {}",
1462 missing_methods.join(", ")
1463 ));
1464 }
1465
1466 let device_types = match remote_desktop_device_types(available_device_types) {
1467 Ok(device_types) => device_types,
1468 Err(detail) => return Check::fail(detail),
1469 };
1470 if device_types & PORTAL_DEVICE_KEYBOARD == 0 {
1471 return Check::fail(format!(
1472 "RemoteDesktop AvailableDeviceTypes={device_types} does not include keyboard input"
1473 ));
1474 }
1475
1476 Check::ok(format!(
1477 "keyboard-capable RemoteDesktop portal (AvailableDeviceTypes={device_types})"
1478 ))
1479}
1480
1481fn missing_busctl_methods<'a>(introspection: &Check, methods: &'a [&'a str]) -> Vec<&'a str> {
1482 methods
1483 .iter()
1484 .copied()
1485 .filter(|method| !busctl_introspection_has_method(&introspection.detail, method))
1486 .collect()
1487}
1488
1489fn remote_desktop_device_types(available_device_types: &Check) -> Result<u32, String> {
1490 if !available_device_types.ok {
1491 return Err(format!(
1492 "RemoteDesktop AvailableDeviceTypes is unavailable: {}",
1493 available_device_types.detail
1494 ));
1495 }
1496 parse_busctl_u32_property(&available_device_types.detail).ok_or_else(|| {
1497 format!(
1498 "RemoteDesktop AvailableDeviceTypes has an unexpected value: {}",
1499 available_device_types.detail
1500 )
1501 })
1502}
1503
1504fn screencast_source_types(available_source_types: &Check) -> Result<u32, String> {
1505 if !available_source_types.ok {
1506 return Err(format!(
1507 "ScreenCast AvailableSourceTypes is unavailable: {}",
1508 available_source_types.detail
1509 ));
1510 }
1511 parse_busctl_u32_property(&available_source_types.detail).ok_or_else(|| {
1512 format!(
1513 "ScreenCast AvailableSourceTypes has an unexpected value: {}",
1514 available_source_types.detail
1515 )
1516 })
1517}
1518
1519fn busctl_introspection_has_method(detail: &str, method: &str) -> bool {
1520 detail.lines().any(|line| {
1521 let mut fields = line.split_whitespace();
1522 fields
1523 .next()
1524 .is_some_and(|name| name.trim_start_matches('.') == method)
1525 && fields.next() == Some("method")
1526 })
1527}
1528
1529fn parse_busctl_u32_property(detail: &str) -> Option<u32> {
1530 let mut fields = detail.split_whitespace();
1531 if fields.next()? != "u" {
1532 return None;
1533 }
1534 let value = fields.next()?;
1535 value
1536 .strip_prefix("0x")
1537 .map(|hex| u32::from_str_radix(hex, 16).ok())
1538 .unwrap_or_else(|| value.parse().ok())
1539}
1540
1541fn atspi_bus_address_check() -> Check {
1542 let busctl = command_check_with_session_bus(
1543 "busctl",
1544 &[
1545 "--user",
1546 "call",
1547 "org.a11y.Bus",
1548 "/org/a11y/bus",
1549 "org.a11y.Bus",
1550 "GetAddress",
1551 ],
1552 );
1553 if busctl.ok {
1554 return busctl;
1555 }
1556
1557 gdbus_call_check(
1558 "org.a11y.Bus",
1559 "/org/a11y/bus",
1560 "org.a11y.Bus.GetAddress",
1561 &[],
1562 )
1563}
1564
1565fn atspi_status_property_check(property: &str) -> Check {
1566 let busctl = command_check_with_session_bus(
1567 "busctl",
1568 &[
1569 "--user",
1570 "get-property",
1571 "org.a11y.Bus",
1572 "/org/a11y/bus",
1573 "org.a11y.Status",
1574 property,
1575 ],
1576 );
1577 if busctl.ok {
1578 return busctl;
1579 }
1580
1581 gdbus_call_check(
1582 "org.a11y.Bus",
1583 "/org/a11y/bus",
1584 "org.freedesktop.DBus.Properties.Get",
1585 &["org.a11y.Status", property],
1586 )
1587}
1588
1589fn at_spi_enabled_check() -> Check {
1591 describe_bool(
1592 atspi_status_property_check("IsEnabled"),
1593 "AT-SPI IsEnabled is true",
1594 "AT-SPI IsEnabled is false",
1595 )
1596}
1597
1598fn screen_reader_enabled_check() -> Check {
1600 let mut check = atspi_status_property_check("ScreenReaderEnabled");
1601 if !check.ok {
1602 check = command_check_with_session_bus(
1603 "gsettings",
1604 &[
1605 "get",
1606 "org.gnome.desktop.a11y.applications",
1607 "screen-reader-enabled",
1608 ],
1609 );
1610 }
1611 describe_bool(check, "Screen reader is on", "Screen reader is off")
1612}
1613
1614fn describe_bool(check: Check, on: &str, off: &str) -> Check {
1616 let detail = check.detail.to_ascii_lowercase();
1617 match (check.ok, detail.contains("true"), detail.contains("false")) {
1618 (true, true, false) => Check::ok(on),
1619 (true, false, true) => Check::ok(off),
1620 _ => check,
1621 }
1622}
1623
1624fn gdbus_call_check(destination: &str, object_path: &str, method: &str, args: &[&str]) -> Check {
1625 let mut command_args = vec![
1626 "call",
1627 "--session",
1628 "--dest",
1629 destination,
1630 "--object-path",
1631 object_path,
1632 "--method",
1633 method,
1634 ];
1635 command_args.extend_from_slice(args);
1636 command_check_with_session_bus("gdbus", &command_args)
1637}
1638
1639fn command_check(command: &str, args: &[&str]) -> Check {
1640 run_command(command, args, false)
1641}
1642
1643fn command_check_with_session_bus(command: &str, args: &[&str]) -> Check {
1644 run_command(command, args, true)
1645}
1646
1647fn run_command(command: &str, args: &[&str], with_session_bus: bool) -> Check {
1648 let mut cmd = Command::new(command);
1649 cmd.args(args);
1650
1651 if with_session_bus {
1652 if let Some(address) = dbus_session_address() {
1653 cmd.env("DBUS_SESSION_BUS_ADDRESS", address);
1654 }
1655 if let Some(runtime) = xdg_runtime_dir() {
1656 cmd.env("XDG_RUNTIME_DIR", runtime);
1657 }
1658 }
1659
1660 match crate::command_runner::output_blocking(&mut cmd, &format!("run diagnostic {command}")) {
1664 Ok(output) if output.status.success() => {
1665 let detail = String::from_utf8_lossy(&output.stdout).trim().to_string();
1666 Check::ok(if detail.is_empty() {
1667 "ok".into()
1668 } else {
1669 detail
1670 })
1671 }
1672 Ok(output) => {
1673 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1674 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
1675 let detail = if !stderr.is_empty() { stderr } else { stdout };
1676 Check::fail(if detail.is_empty() {
1677 format!("exit status {}", output.status)
1678 } else {
1679 detail
1680 })
1681 }
1682 Err(error) => Check::fail(format!("{error:#}")),
1683 }
1684}
1685
1686#[cfg(test)]
1687pub(crate) mod tests {
1688 use super::*;
1689
1690 pub(crate) fn doctor_report_fixture(
1693 accessibility_enabled: bool,
1694 windows_available: bool,
1695 ) -> DoctorReport {
1696 let platform = platform_report();
1697 let portals = portal_report(Check::fail("missing"));
1698 let accessibility = accessibility_report(
1699 Check::ok("bus"),
1700 Check::ok(accessibility_enabled.to_string()),
1701 );
1702 let windowing = windowing_report(windows_available, windows_available);
1703 let input = input_report(true);
1704 DoctorReport {
1705 readiness: readiness_report(&platform, &portals, &accessibility, &windowing, &input),
1706 capabilities: capability_map(&platform, &portals, &accessibility, &windowing, &input),
1707 platform,
1708 portals,
1709 accessibility,
1710 windowing,
1711 input,
1712 }
1713 }
1714
1715 #[test]
1716 fn describe_bool_reads_busctl_gdbus_and_gsettings_output() {
1717 for (raw, expected) in [("b true", "on"), ("(<false>,)", "off"), ("false", "off")] {
1718 assert_eq!(describe_bool(Check::ok(raw), "on", "off").detail, expected);
1719 }
1720 let failed = describe_bool(Check::fail("no such property"), "on", "off");
1721 assert!(!failed.ok);
1722 assert_eq!(failed.detail, "no such property");
1723 }
1724
1725 #[test]
1726 fn diagnostic_command_timeout_reaps_the_hung_process() {
1727 use std::time::{Duration, Instant};
1728
1729 let pid_path = std::env::temp_dir().join(format!(
1730 "cul-diagnostic-timeout-{}-{}.pid",
1731 std::process::id(),
1732 getrandom::u64().unwrap()
1733 ));
1734 let started = Instant::now();
1735 let result = run_command(
1736 "/bin/sh",
1737 &[
1738 "-c",
1739 "printf '%s' \"$$\" > \"$1\"; exec /bin/sleep 60",
1740 "diagnostic-timeout-test",
1741 pid_path.to_str().unwrap(),
1742 ],
1743 false,
1744 );
1745 let elapsed = started.elapsed();
1746 let pid = fs::read_to_string(&pid_path);
1747 let _ = fs::remove_file(&pid_path);
1748 let pid: u32 = pid.expect("fixture must record its pid").parse().unwrap();
1749
1750 assert!(!result.ok, "hung diagnostic must fail");
1751 assert!(result.detail.contains("timed out after 2000 ms"));
1752 assert!(elapsed < Duration::from_secs(4), "took {elapsed:?}");
1753 assert!(
1754 !Path::new(&format!("/proc/{pid}")).exists(),
1755 "diagnostic child must be killed and reaped before returning"
1756 );
1757 }
1758
1759 #[test]
1760 fn diagnostic_spawn_failure_keeps_the_os_cause() {
1761 let path = std::env::temp_dir().join(format!(
1762 "cul-missing-diagnostic-{}-{}",
1763 std::process::id(),
1764 getrandom::u64().unwrap()
1765 ));
1766 let result = run_command(path.to_str().unwrap(), &[], false);
1767 assert!(!result.ok);
1768 assert!(result.detail.contains("failed to run diagnostic"));
1769 assert!(result.detail.contains("os error 2"));
1770 }
1771
1772 #[test]
1773 fn setup_repairs_saved_key_even_when_runtime_is_ready() {
1774 let mut before = accessibility_report(Check::ok("bus"), Check::ok("false"));
1775 before.at_spi_enabled = Check::ok("b true");
1776 let mut calls = Vec::new();
1777 let result = enable_accessibility(&before, |program, args| {
1778 calls.push((program.to_string(), args.join(" ")));
1779 Check::ok(if args[0] == "get" { "true" } else { "" })
1780 });
1781 assert!(result.ok);
1782 assert_eq!(
1783 calls,
1784 vec![
1785 (
1786 "gsettings".into(),
1787 "set org.gnome.desktop.interface toolkit-accessibility true".into()
1788 ),
1789 (
1790 "gsettings".into(),
1791 "get org.gnome.desktop.interface toolkit-accessibility".into()
1792 ),
1793 ]
1794 );
1795 }
1796
1797 #[test]
1798 fn setup_does_not_claim_saved_success_from_command_exit_status() {
1799 let before = accessibility_report(Check::ok("bus"), Check::ok("false"));
1800 let mut calls = Vec::new();
1801 let result = enable_accessibility(&before, |program, args| {
1802 calls.push(program.to_string());
1803 Check::ok(if args[0] == "get" {
1804 "false"
1805 } else {
1806 "exit status 0"
1807 })
1808 });
1809 assert!(!result.ok);
1810 assert!(result.detail.contains("not verified"));
1811 assert_eq!(calls, ["gsettings", "gsettings", "busctl"]);
1812 }
1813
1814 #[test]
1815 fn setup_preserves_enabled_settings_without_writing() {
1816 let before = accessibility_report(Check::ok("bus"), Check::ok("true"));
1817 let result = enable_accessibility(&before, |_, _| panic!("already enabled"));
1818 assert!(result.ok);
1819 }
1820
1821 fn platform_report() -> PlatformReport {
1822 PlatformReport {
1823 os: "linux".to_string(),
1824 arch: "x86_64".to_string(),
1825 desktop_session: None,
1826 xdg_session_type: Some("wayland".to_string()),
1827 xdg_current_desktop: Some("GNOME".to_string()),
1828 wayland_display: Some("wayland-0".to_string()),
1829 display: Some(":0".to_string()),
1830 xauthority: Some("/run/user/1000/Xauthority".to_string()),
1831 dbus_session_bus_address: Some("unix:path=/run/user/1000/bus".to_string()),
1832 xdg_runtime_dir: Some("/run/user/1000".to_string()),
1833 gnome_shell_version: Check::ok("GNOME Shell 46.0"),
1834 gnome_screenshot: Check::ok("gnome-screenshot 41.0"),
1835 x11_display: Check::fail("not a native X11 session"),
1836 }
1837 }
1838
1839 fn portal_report(remote_desktop: Check) -> PortalReport {
1840 PortalReport {
1841 desktop_portal: Check::ok("ok"),
1842 remote_desktop,
1843 screencast: Check::fail("missing"),
1844 screenshot: Check::fail("missing"),
1845 input_capture: Check::fail("missing"),
1846 mutter_remote_desktop: Check::fail("missing"),
1847 mutter_screencast: Check::fail("missing"),
1848 }
1849 }
1850
1851 fn accessibility_report(
1852 at_spi_bus: Check,
1853 toolkit_accessibility: Check,
1854 ) -> AccessibilityReport {
1855 AccessibilityReport {
1856 at_spi_bus,
1857 toolkit_accessibility,
1858 at_spi_enabled: Check::fail("(<false>,)"),
1859 screen_reader_enabled: Check::fail("(<false>,)"),
1860 forced_toolkit_bridge: false,
1861 }
1862 }
1863 #[test]
1864 fn bus_reachable_and_forced_bridge_is_enabled() {
1865 let enabled = accessibility_report(Check::ok("bus"), Check::fail("false"));
1866 assert!(accessibility_tree_ready(&enabled, true));
1867 assert!(atspi_disabled_blocker(&enabled, true).is_none());
1868 let down = accessibility_report(Check::fail("no bus"), Check::fail("false"));
1869 assert!(!accessibility_tree_ready(&down, true));
1870 assert!(
1871 atspi_disabled_blocker(&down, true).is_some_and(|blocker| blocker.contains("disabled"))
1872 );
1873 assert!(toolkit_bridge_forced_by_env(|key| {
1874 (key == "QT_LINUX_ACCESSIBILITY_ALWAYS_ON").then(|| "1".to_string())
1875 }));
1876 assert!(toolkit_bridge_forced_by_env(|key| {
1877 (key == "GTK_MODULES").then(|| "gail:atk-bridge".to_string())
1878 }));
1879 assert!(!toolkit_bridge_forced_by_env(|_| None));
1880 }
1881
1882 fn windowing_report(can_list_windows: bool, can_focus_windows: bool) -> WindowingReport {
1883 WindowingReport {
1884 gnome_shell_introspect: if can_list_windows {
1885 Check::ok("ok")
1886 } else {
1887 Check::fail("denied")
1888 },
1889 computer_use_linux_gnome_shell_extension: if can_focus_windows {
1890 Check::ok("ok")
1891 } else {
1892 Check::fail("missing")
1893 },
1894 cosmic_helper: Check::fail("missing"),
1895 kwin: Check::fail("not a KWin session"),
1896 hyprland: Check::fail("not a Hyprland session"),
1897 niri: Check::fail("not a niri session"),
1898 backends: BTreeMap::new(),
1899 can_list_windows,
1900 can_focus_apps: true,
1901 can_focus_windows,
1902 note: String::new(),
1903 }
1904 }
1905
1906 fn input_report(can_send_input: bool) -> InputReport {
1907 let check = if can_send_input {
1908 Check::ok("ok")
1909 } else {
1910 Check::fail("missing")
1911 };
1912 input_report_parts(check.clone(), check.clone(), check.clone(), check)
1913 }
1914
1915 fn input_report_parts(
1916 ydotool: Check,
1917 ydotoold: Check,
1918 ydotool_socket: Check,
1919 uinput: Check,
1920 ) -> InputReport {
1921 InputReport {
1922 ydotool,
1923 ydotoold,
1924 ydotool_socket,
1925 uinput,
1926 xdotool: Check::fail("missing xdotool"),
1927 wtype: Check::fail("missing wtype"),
1928 }
1929 }
1930
1931 #[test]
1932 fn accessibility_tree_requires_reachable_at_spi_bus() {
1933 let report = accessibility_report(Check::fail("permission denied"), Check::ok("true"));
1934
1935 assert!(!can_build_accessibility_tree(&report));
1936 }
1937
1938 #[test]
1939 fn accessibility_tree_is_ready_when_bus_and_toolkit_are_ready() {
1940 let report = accessibility_report(
1941 Check::ok("('unix:path=/run/user/1000/at-spi/bus',)"),
1942 Check::ok("true"),
1943 );
1944
1945 assert!(can_build_accessibility_tree(&report));
1946 }
1947
1948 #[test]
1949 fn capability_map_advertises_only_a_buildable_accessibility_tree() {
1950 let platform = platform_report();
1951 let portals = portal_report(Check::fail("missing"));
1952 let windowing = windowing_report(false, false);
1953 let input = input_report(false);
1954
1955 for accessibility in [
1956 accessibility_report(Check::fail("permission denied"), Check::ok("true")),
1957 accessibility_report(
1958 Check::ok("('unix:path=/run/user/1000/at-spi/bus',)"),
1959 Check::ok("false"),
1960 ),
1961 ] {
1962 let capabilities =
1963 capability_map(&platform, &portals, &accessibility, &windowing, &input);
1964 assert!(capabilities.accessibility.is_empty());
1965 }
1966 }
1967
1968 #[test]
1969 fn parses_parent_pid_from_proc_status() {
1970 let status = "Name:\ttest\nPid:\t42\nPPid:\t7\n";
1971
1972 assert_eq!(parse_parent_pid(status), Some(7));
1973 }
1974
1975 #[test]
1976 fn parses_nul_separated_process_environment() {
1977 let environment = parse_environ(
1978 b"DISPLAY=:0\0WAYLAND_DISPLAY=wayland-0\0EMPTY=\0NO_EQUALS\0XDG_SESSION_TYPE=wayland\0",
1979 );
1980
1981 assert_eq!(environment.get("DISPLAY").map(String::as_str), Some(":0"));
1982 assert_eq!(
1983 environment.get("WAYLAND_DISPLAY").map(String::as_str),
1984 Some("wayland-0")
1985 );
1986 assert_eq!(environment.get("EMPTY").map(String::as_str), Some(""));
1987 assert!(!environment.contains_key("NO_EQUALS"));
1988 }
1989
1990 #[test]
1991 fn desktop_env_hydration_includes_xauthority() {
1992 assert!(DESKTOP_ENV_KEYS.contains(&"XAUTHORITY"));
1993 }
1994
1995 #[test]
1996 fn desktop_env_hydration_preserves_explicit_native_x11() {
1997 let current_env = HashMap::from([
1998 ("DISPLAY".to_string(), ":90".to_string()),
1999 ("XDG_SESSION_TYPE".to_string(), "x11".to_string()),
2000 ]);
2001 let host_env = HashMap::from([
2002 ("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string()),
2003 (
2004 "XDG_CURRENT_DESKTOP".to_string(),
2005 "ubuntu:GNOME".to_string(),
2006 ),
2007 ]);
2008
2009 let updates = desktop_env_hydration_updates(¤t_env, &host_env);
2010
2011 assert!(!updates.iter().any(|(key, _)| *key == "WAYLAND_DISPLAY"));
2012 assert!(updates
2013 .iter()
2014 .any(|(key, value)| { *key == "XDG_CURRENT_DESKTOP" && value == "ubuntu:GNOME" }));
2015 }
2016
2017 #[test]
2018 fn desktop_env_hydration_still_imports_wayland_for_incomplete_sessions() {
2019 let current_env = HashMap::new();
2020 let host_env = HashMap::from([("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string())]);
2021
2022 let updates = desktop_env_hydration_updates(¤t_env, &host_env);
2023
2024 assert!(updates
2025 .iter()
2026 .any(|(key, value)| *key == "WAYLAND_DISPLAY" && value == "wayland-0"));
2027 }
2028
2029 #[test]
2030 fn graphical_process_env_requires_display() {
2031 let with_display = HashMap::from([("DISPLAY".to_string(), ":0".to_string())]);
2032 let with_wayland =
2033 HashMap::from([("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string())]);
2034 let without_display = HashMap::from([("XAUTHORITY".to_string(), "/tmp/xauth".to_string())]);
2035
2036 assert!(process_env_has_graphical_display(&with_display));
2037 assert!(process_env_has_graphical_display(&with_wayland));
2038 assert!(!process_env_has_graphical_display(&without_display));
2039 }
2040
2041 #[test]
2042 fn capabilities_prefer_xdotool_before_ydotool_on_x11() {
2043 let mut platform = platform_report();
2044 platform.xdg_session_type = Some("x11".to_string());
2045 platform.wayland_display = None;
2046 platform.display = Some(":0".to_string());
2047 let input = InputReport {
2048 ydotool: Check::ok("ydotool"),
2049 ydotoold: Check::ok("ydotoold"),
2050 ydotool_socket: Check::ok("connectable"),
2051 uinput: Check::fail("missing"),
2052 xdotool: Check::ok("xdotool"),
2053 wtype: Check::fail("missing wtype"),
2054 };
2055
2056 let capabilities = capability_map(
2057 &platform,
2058 &portal_report(Check::fail("missing")),
2059 &accessibility_report(Check::fail("missing"), Check::fail("missing")),
2060 &windowing_report(false, false),
2061 &input,
2062 );
2063
2064 assert_eq!(capabilities.input, ["xdotool", "ydotool"]);
2065 assert_eq!(capabilities.preferred.input.as_deref(), Some("xdotool"));
2066 }
2067
2068 #[test]
2069 fn x11_diagnostics_ignore_portal_and_accept_xdotool() {
2070 let mut platform = platform_report();
2071 platform.xdg_session_type = Some("x11".to_string());
2072 platform.wayland_display = None;
2073 platform.display = Some(":0".to_string());
2074 let portals = portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop"));
2075 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2076 let windowing = windowing_report(true, true);
2077 let mut input = input_report(false);
2078 input.xdotool = Check::ok("xdotool");
2079
2080 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2081 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2082
2083 assert_eq!(capabilities.input, ["xdotool"]);
2084 assert_eq!(capabilities.preferred.input.as_deref(), Some("xdotool"));
2085 assert!(readiness.can_send_development_input);
2086 }
2087
2088 #[test]
2089 fn wayland_diagnostics_advertise_wtype_without_portal_or_ydotool() {
2090 let mut platform = platform_report();
2091 platform.xdg_session_type = Some("wayland".to_string());
2092 platform.xdg_current_desktop = Some("Hyprland".to_string());
2093 platform.wayland_display = Some("wayland-0".to_string());
2094 let portals = portal_report(Check::fail("missing"));
2095 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2096 let windowing = windowing_report(true, true);
2097 let mut input = input_report(false);
2098 input.wtype = Check::ok("wtype");
2099
2100 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2101 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2102
2103 assert_eq!(capabilities.input, ["wtype"]);
2104 assert_eq!(capabilities.preferred.input.as_deref(), Some("wtype"));
2105 assert!(readiness.can_send_development_input);
2106 }
2107
2108 #[test]
2109 fn wtype_excludes_known_incompatible_wayland_desktops() {
2110 assert!(wtype_compatible_wayland_desktop(Some("Hyprland")));
2111 assert!(wtype_compatible_wayland_desktop(Some("sway")));
2112 assert!(wtype_compatible_wayland_desktop(None));
2113 assert!(!wtype_compatible_wayland_desktop(Some("GNOME")));
2114 assert!(!wtype_compatible_wayland_desktop(Some("KDE;Plasma")));
2115 assert!(!wtype_compatible_wayland_desktop(Some("COSMIC")));
2116 }
2117
2118 #[test]
2119 fn wayland_diagnostics_prefer_ydotool_before_portal() {
2120 let platform = platform_report();
2121 let portals = portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop"));
2122 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2123 let windowing = windowing_report(true, true);
2124 let input = input_report_parts(
2125 Check::ok("ydotool"),
2126 Check::ok("ydotoold"),
2127 Check::ok("connectable"),
2128 Check::fail("missing uinput"),
2129 );
2130
2131 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2132
2133 assert_eq!(capabilities.input, ["ydotool", "portal"]);
2134 assert_eq!(capabilities.preferred.input.as_deref(), Some("ydotool"));
2135 }
2136
2137 #[test]
2138 fn portal_force_order_requires_both_input_modalities() {
2139 assert!(force_portal_for_all_input(true, true, false, false));
2140 assert!(!force_portal_for_all_input(true, false, false, false));
2141 assert!(!force_portal_for_all_input(true, true, true, false));
2142 assert!(!force_portal_for_all_input(true, true, false, true));
2143 }
2144
2145 fn remote_desktop_runtime_introspection() -> Check {
2146 Check::ok(
2147 "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n\
2148 .CreateSession method a{sv} o -\n\
2149 .SelectDevices method oa{sv} o -\n\
2150 .Start method osa{sv} o -\n\
2151 .NotifyPointerMotionAbsolute method oa{sv}udd - -\n\
2152 .NotifyPointerButton method oa{sv}iu - -\n\
2153 .NotifyPointerAxisDiscrete method oa{sv}ui - -\n\
2154 .NotifyKeyboardKeycode method ouu - -\n\
2155 .NotifyKeyboardKeysym method ouu - -\n\
2156 .AvailableDeviceTypes property u 3 emits-change",
2157 )
2158 }
2159
2160 fn screencast_runtime_introspection() -> Check {
2161 Check::ok(
2162 "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n\
2163 .SelectSources method oa{sv} o -",
2164 )
2165 }
2166
2167 #[test]
2168 fn cosmic_prefers_per_monitor_portal_over_uinput_and_ydotool() {
2169 let mut platform = platform_report();
2170 platform.xdg_session_type = Some("wayland".to_string());
2171 platform.xdg_current_desktop = Some("COSMIC".to_string());
2172 let input = input_report_parts(
2173 Check::ok("ydotool"),
2174 Check::ok("socket"),
2175 Check::ok("socket"),
2176 Check::ok("uinput"),
2177 );
2178 let capabilities = capability_map(
2179 &platform,
2180 &portal_report(Check::ok("portal")),
2181 &accessibility_report(Check::ok("bus"), Check::ok("true")),
2182 &windowing_report(true, true),
2183 &input,
2184 );
2185 assert_eq!(capabilities.preferred.input.as_deref(), Some("portal"));
2186 assert!(!capabilities
2187 .input
2188 .iter()
2189 .any(|backend| backend == "abs_pointer"));
2190 }
2191
2192 #[test]
2193 fn remote_desktop_portal_rejects_header_only_introspection() {
2194 let introspection = Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS");
2195 let available_device_types = Check::ok("u 3");
2196 let pointer = remote_desktop_pointer_check_from(
2197 &introspection,
2198 &screencast_runtime_introspection(),
2199 &available_device_types,
2200 &Check::ok("u 1"),
2201 );
2202 let keyboard = remote_desktop_keyboard_check_from(&introspection, &available_device_types);
2203
2204 assert!(!pointer.ok);
2205 assert!(pointer.detail.contains("CreateSession"));
2206 assert!(pointer.detail.contains("NotifyPointerButton"));
2207 assert!(!keyboard.ok);
2208 assert!(keyboard.detail.contains("NotifyKeyboardKeysym"));
2209
2210 let input = input_report_parts(
2211 Check::ok("ydotool"),
2212 Check::ok("ydotoold"),
2213 Check::fail("no connectable ydotool socket"),
2214 Check::ok("read/write: /dev/uinput"),
2215 );
2216 let platform = platform_report();
2217 let portals = portal_report(pointer);
2218 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2219 let windowing = windowing_report(true, true);
2220 let capabilities = capability_map_with_portal_keyboard(
2221 &platform,
2222 &portals,
2223 &keyboard,
2224 &accessibility,
2225 &windowing,
2226 &input,
2227 );
2228 let readiness = readiness_report_with_portal_keyboard(
2229 &platform,
2230 &keyboard,
2231 &accessibility,
2232 &windowing,
2233 &input,
2234 true,
2235 );
2236
2237 assert!(!capabilities.input.iter().any(|backend| backend == "portal"));
2238 assert!(!readiness.can_send_development_input);
2239 }
2240
2241 #[test]
2242 fn keyboard_only_portal_remains_advertised_without_pointer_capability() {
2243 let platform = platform_report();
2244 let keyboard = remote_desktop_keyboard_check_from(
2245 &remote_desktop_runtime_introspection(),
2246 &Check::ok("u 1"),
2247 );
2248 let portals = portal_report(Check::fail(
2249 "ScreenCast AvailableSourceTypes=2 does not include monitor sources",
2250 ));
2251 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2252 let windowing = windowing_report(true, true);
2253 let input = input_report_parts(
2254 Check::fail("missing ydotool"),
2255 Check::fail("ydotoold not running"),
2256 Check::fail("no connectable ydotool socket"),
2257 Check::fail("/dev/uinput: Permission denied"),
2258 );
2259
2260 let capabilities = capability_map_with_portal_keyboard(
2261 &platform,
2262 &portals,
2263 &keyboard,
2264 &accessibility,
2265 &windowing,
2266 &input,
2267 );
2268 let readiness = readiness_report_with_portal_keyboard(
2269 &platform,
2270 &keyboard,
2271 &accessibility,
2272 &windowing,
2273 &input,
2274 true,
2275 );
2276
2277 assert!(!portals.remote_desktop.ok);
2278 assert_eq!(capabilities.input, ["portal"]);
2279 assert_eq!(capabilities.preferred.input.as_deref(), Some("portal"));
2280 assert!(readiness.can_send_development_input);
2281 }
2282
2283 #[test]
2284 fn remote_desktop_pointer_rejects_missing_screencast_contract() {
2285 let check = remote_desktop_pointer_check_from(
2286 &remote_desktop_runtime_introspection(),
2287 &Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS"),
2288 &Check::ok("u 2"),
2289 &Check::ok("u 1"),
2290 );
2291
2292 assert!(!check.ok);
2293 assert!(check.detail.contains("SelectSources"));
2294 }
2295
2296 #[test]
2297 fn remote_desktop_pointer_rejects_missing_monitor_source_type() {
2298 let check = remote_desktop_pointer_check_from(
2299 &remote_desktop_runtime_introspection(),
2300 &screencast_runtime_introspection(),
2301 &Check::ok("u 2"),
2302 &Check::ok("u 2"),
2303 );
2304
2305 assert!(!check.ok);
2306 assert!(check.detail.contains("does not include monitor sources"));
2307 }
2308
2309 #[test]
2310 fn remote_desktop_portal_rejects_missing_keyboard_device_type() {
2311 let check = remote_desktop_keyboard_check_from(
2312 &remote_desktop_runtime_introspection(),
2313 &Check::ok("u 2"),
2314 );
2315
2316 assert!(!check.ok);
2317 assert!(check.detail.contains("does not include keyboard input"));
2318 }
2319
2320 #[test]
2321 fn remote_desktop_portal_accepts_runtime_keyboard_contract() {
2322 let check = remote_desktop_keyboard_check_from(
2323 &remote_desktop_runtime_introspection(),
2324 &Check::ok("u 3"),
2325 );
2326
2327 assert!(check.ok);
2328 assert!(check.detail.contains("AvailableDeviceTypes=3"));
2329 }
2330
2331 #[test]
2332 fn pointer_only_portal_remains_advertised_without_keyboard_readiness() {
2333 let platform = platform_report();
2334 let available_device_types = Check::ok("u 2");
2335 let pointer = remote_desktop_pointer_check_from(
2336 &remote_desktop_runtime_introspection(),
2337 &screencast_runtime_introspection(),
2338 &available_device_types,
2339 &Check::ok("u 1"),
2340 );
2341 let keyboard = remote_desktop_keyboard_check_from(
2342 &remote_desktop_runtime_introspection(),
2343 &available_device_types,
2344 );
2345 let portals = portal_report(pointer);
2346 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2347 let windowing = windowing_report(true, true);
2348 let input = input_report_parts(
2349 Check::fail("missing ydotool"),
2350 Check::fail("ydotoold not running"),
2351 Check::fail("no connectable ydotool socket"),
2352 Check::fail("/dev/uinput: Permission denied"),
2353 );
2354
2355 let capabilities = capability_map_with_portal_keyboard(
2356 &platform,
2357 &portals,
2358 &keyboard,
2359 &accessibility,
2360 &windowing,
2361 &input,
2362 );
2363 let readiness = readiness_report_with_portal_keyboard(
2364 &platform,
2365 &keyboard,
2366 &accessibility_report(Check::ok("bus"), Check::ok("true")),
2367 &windowing_report(true, true),
2368 &input,
2369 true,
2370 );
2371
2372 assert!(portals.remote_desktop.ok);
2373 assert!(capabilities.input.iter().any(|backend| backend == "portal"));
2374 assert!(!readiness.can_send_development_input);
2375 }
2376
2377 #[test]
2378 fn capabilities_require_display_to_advertise_xdotool() {
2379 let mut platform = platform_report();
2380 platform.xdg_session_type = Some("x11".to_string());
2381 platform.wayland_display = None;
2382 platform.display = None;
2383 let input = InputReport {
2384 ydotool: Check::ok("ydotool"),
2385 ydotoold: Check::ok("ydotoold"),
2386 ydotool_socket: Check::ok("connectable"),
2387 uinput: Check::fail("missing"),
2388 xdotool: Check::ok("xdotool"),
2389 wtype: Check::fail("missing wtype"),
2390 };
2391
2392 let capabilities = capability_map(
2393 &platform,
2394 &portal_report(Check::fail("missing")),
2395 &accessibility_report(Check::fail("missing"), Check::fail("missing")),
2396 &windowing_report(false, false),
2397 &input,
2398 );
2399
2400 assert_eq!(capabilities.input, ["ydotool"]);
2401 assert_eq!(capabilities.preferred.input.as_deref(), Some("ydotool"));
2402 }
2403
2404 #[test]
2405 fn xdotool_diagnostics_force_precedence_matches_runtime() {
2406 let mut platform = platform_report();
2407 platform.xdg_session_type = Some("wayland".to_string());
2408 platform.display = Some(":0".to_string());
2409 let mut input = input_report(false);
2410 input.xdotool = Check::ok("xdotool");
2411
2412 assert!(should_advertise_xdotool(&platform, &input, false, true));
2413 assert!(!should_advertise_xdotool(&platform, &input, true, true));
2414 }
2415
2416 #[test]
2417 fn parses_systemd_show_environment_output() {
2418 let environment = parse_line_environment(
2419 b"DISPLAY=:0\nHYPRLAND_INSTANCE_SIGNATURE=abc\nNO_EQUALS\nYDOTOOL_SOCKET=/run/ydotoold/socket\n",
2420 );
2421
2422 assert_eq!(environment.get("DISPLAY").map(String::as_str), Some(":0"));
2423 assert_eq!(
2424 environment
2425 .get("HYPRLAND_INSTANCE_SIGNATURE")
2426 .map(String::as_str),
2427 Some("abc")
2428 );
2429 assert_eq!(
2430 environment.get("YDOTOOL_SOCKET").map(String::as_str),
2431 Some("/run/ydotoold/socket")
2432 );
2433 assert!(!environment.contains_key("NO_EQUALS"));
2434 }
2435
2436 #[test]
2437 fn readiness_requires_exact_window_focus_for_targeted_input() {
2438 let platform = platform_report();
2439 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2440 let windowing = windowing_report(true, false);
2441 let input = input_report(true);
2442
2443 let mut readiness = readiness_report(
2444 &platform,
2445 &portal_report(Check::fail("missing")),
2446 &accessibility,
2447 &windowing,
2448 &input,
2449 );
2450
2451 readiness.record_screenshot_result(true);
2452 assert!(readiness.can_capture_screenshots);
2453
2454 assert!(readiness.can_query_windows);
2455 assert!(!readiness.can_focus_windows);
2456 assert!(readiness
2457 .recommended_next_step
2458 .contains("exact-focus window backend"));
2459 assert!(readiness
2460 .blockers
2461 .iter()
2462 .any(|blocker| blocker.contains("Exact window activation")));
2463 }
2464
2465 #[test]
2466 fn header_only_introspection_is_not_an_exported_interface() {
2467 let header_only = Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS");
2469
2470 let check = require_busctl_methods(
2471 header_only,
2472 "org.freedesktop.portal.Screenshot",
2473 SCREENSHOT_PORTAL_METHODS,
2474 );
2475
2476 assert!(!check.ok);
2477 assert!(check.detail.contains("is not exported by the portal"));
2478 }
2479
2480 #[test]
2481 fn portal_interface_must_export_every_required_method() {
2482 let partial = Check::ok(
2483 "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n.PickColor method sa{sv} o -\n.version property u 2 emits-change",
2484 );
2485 let check = require_busctl_methods(
2486 partial,
2487 "org.freedesktop.portal.Screenshot",
2488 SCREENSHOT_PORTAL_METHODS,
2489 );
2490 assert!(!check.ok);
2491 assert!(check
2492 .detail
2493 .contains("missing required methods: Screenshot"));
2494
2495 let full = Check::ok(
2496 "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n.PickColor method sa{sv} o -\n.Screenshot method sa{sv} o -",
2497 );
2498 assert!(
2499 require_busctl_methods(
2500 full,
2501 "org.freedesktop.portal.Screenshot",
2502 SCREENSHOT_PORTAL_METHODS
2503 )
2504 .ok
2505 );
2506
2507 let failed = Check::fail("busctl: No such interface");
2508 let passthrough = require_busctl_methods(
2509 failed,
2510 "org.freedesktop.portal.Screenshot",
2511 SCREENSHOT_PORTAL_METHODS,
2512 );
2513 assert!(!passthrough.ok);
2514 assert_eq!(passthrough.detail, "busctl: No such interface");
2515 }
2516
2517 #[test]
2518 fn readiness_blocks_when_no_screenshot_route_is_detected() {
2519 let mut platform = platform_report();
2520 platform.gnome_shell_version = Check::fail("No such file or directory (os error 2)");
2521 platform.gnome_screenshot = Check::fail("No such file or directory (os error 2)");
2522 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2523 let windowing = windowing_report(true, true);
2524 let input = input_report(true);
2525 let portals = portal_report(Check::fail("missing"));
2526
2527 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2528
2529 assert!(!readiness.can_capture_screenshots);
2530 assert_eq!(
2531 readiness.screenshot_capture_status,
2532 ScreenshotCaptureStatus::Unavailable
2533 );
2534 assert!(readiness.warnings.is_empty());
2535 assert!(readiness
2536 .blockers
2537 .iter()
2538 .any(|blocker| blocker.contains("No screenshot route was detected")));
2539 assert!(readiness
2540 .recommended_next_step
2541 .contains("Enable a screenshot route"));
2542 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2543 assert!(capabilities.screenshot.is_empty());
2544 assert_eq!(capabilities.preferred.screenshot, None);
2545 }
2546
2547 #[test]
2548 fn native_x11_display_is_a_screenshot_route_ahead_of_gnome_screenshot() {
2549 let mut platform = platform_report();
2550 platform.gnome_shell_version = Check::fail("missing");
2551 platform.x11_display = Check::ok("native X11 root window 2880x1920, depth 24");
2552 let portals = portal_report(Check::fail("missing"));
2553 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2554 let windowing = windowing_report(true, true);
2555 let input = input_report(true);
2556
2557 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2558 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2559
2560 assert_eq!(capabilities.screenshot, ["x11", "gnome_screenshot"]);
2561 assert_eq!(capabilities.preferred.screenshot.as_deref(), Some("x11"));
2562 assert!(!readiness.can_capture_screenshots);
2563 assert_eq!(
2564 readiness.screenshot_capture_status,
2565 ScreenshotCaptureStatus::Unverified
2566 );
2567 assert!(readiness.blockers.is_empty());
2568 }
2569
2570 #[test]
2571 fn screenshot_route_detection_is_not_capture_verification() {
2572 let mut platform = platform_report();
2573 platform.gnome_shell_version = Check::fail("missing");
2574 platform.gnome_screenshot = Check::fail("missing");
2575 let mut portals = portal_report(Check::fail("missing"));
2576 portals.screenshot = Check::ok(".Screenshot method sa{sv} o -");
2577 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2578 let windowing = windowing_report(true, true);
2579 let input = input_report(true);
2580
2581 let mut readiness =
2582 readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2583 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2584
2585 assert!(!readiness.can_capture_screenshots);
2586 assert_eq!(
2587 readiness.screenshot_capture_status,
2588 ScreenshotCaptureStatus::Unverified
2589 );
2590 assert_eq!(readiness.warnings, [SCREENSHOT_UNVERIFIED]);
2591 assert!(readiness.blockers.is_empty());
2592 assert_eq!(capabilities.screenshot, ["portal"]);
2593
2594 readiness.record_screenshot_result(false);
2595 assert!(!readiness.can_capture_screenshots);
2596 assert_eq!(
2597 readiness.screenshot_capture_status,
2598 ScreenshotCaptureStatus::Failed
2599 );
2600 assert_eq!(readiness.blockers, [SCREENSHOT_FAILED]);
2601 assert!(readiness.warnings.is_empty());
2602 assert_eq!(readiness.recommended_next_step, SCREENSHOT_FAILED_NEXT_STEP);
2603
2604 readiness.record_screenshot_result(true);
2605 assert!(readiness.can_capture_screenshots);
2606 assert_eq!(
2607 readiness.screenshot_capture_status,
2608 ScreenshotCaptureStatus::Verified
2609 );
2610 assert!(readiness.blockers.is_empty());
2611 assert_eq!(readiness.recommended_next_step, READY_NEXT_STEP);
2612 }
2613
2614 #[test]
2615 fn readiness_treats_kwin_as_full_window_backend() {
2616 let platform = platform_report();
2617 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2618 let mut windowing = windowing_report(false, false);
2619 windowing.kwin = Check::ok("KWin scripting is available");
2620 windowing.can_list_windows = true;
2621 windowing.can_focus_apps = true;
2622 windowing.can_focus_windows = true;
2623 let input = input_report(true);
2624
2625 let readiness = readiness_report(
2626 &platform,
2627 &portal_report(Check::fail("missing")),
2628 &accessibility,
2629 &windowing,
2630 &input,
2631 );
2632
2633 assert!(readiness.can_query_windows);
2634 assert!(readiness.can_focus_apps);
2635 assert!(readiness.can_focus_windows);
2636 assert!(readiness.blockers.is_empty());
2637 }
2638
2639 #[test]
2640 fn readiness_message_mentions_generic_window_targeting() {
2641 let platform = platform_report();
2642 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2643 let windowing = windowing_report(true, true);
2644 let input = input_report(true);
2645
2646 let readiness = readiness_report(
2647 &platform,
2648 &portal_report(Check::fail("missing")),
2649 &accessibility,
2650 &windowing,
2651 &input,
2652 );
2653
2654 assert!(readiness.blockers.is_empty());
2655 assert!(readiness
2656 .recommended_next_step
2657 .contains("AT-SPI tree support"));
2658 assert!(readiness.recommended_next_step.contains("window targeting"));
2659 assert!(!readiness
2660 .recommended_next_step
2661 .contains("GNOME window targeting"));
2662 }
2663
2664 #[test]
2665 fn readiness_accepts_connectable_ydotool_socket_without_direct_uinput_access() {
2666 let platform = platform_report();
2667 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2668 let windowing = windowing_report(true, true);
2669 let input = input_report_parts(
2670 Check::ok("ydotool"),
2671 Check::ok("ydotoold"),
2672 Check::ok("connectable: /tmp/.ydotool_socket"),
2673 Check::fail("/dev/uinput: Permission denied"),
2674 );
2675
2676 let readiness = readiness_report(
2677 &platform,
2678 &portal_report(Check::fail("missing")),
2679 &accessibility,
2680 &windowing,
2681 &input,
2682 );
2683
2684 assert!(readiness.can_send_development_input);
2685 assert!(readiness.blockers.is_empty());
2686 }
2687
2688 #[test]
2689 fn readiness_uses_connectable_ydotool_socket_when_process_probe_fails() {
2690 let platform = platform_report();
2691 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2692 let windowing = windowing_report(true, true);
2693 let input = input_report_parts(
2694 Check::ok("ydotool"),
2695 Check::fail("ydotoold process name not found"),
2696 Check::ok("connectable: /run/user/1000/.ydotool_socket"),
2697 Check::fail("/dev/uinput: Permission denied"),
2698 );
2699 let portals = portal_report(Check::fail("missing"));
2700
2701 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2702 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2703
2704 assert!(capabilities
2705 .input
2706 .iter()
2707 .any(|backend| backend == "ydotool"));
2708 assert!(readiness.can_send_development_input);
2709 }
2710
2711 #[test]
2712 fn wayland_readiness_rejects_pointer_only_uinput_without_keyboard_backend() {
2713 let platform = platform_report();
2714 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2715 let windowing = windowing_report(true, true);
2716 let input = input_report_parts(
2717 Check::ok("ydotool"),
2718 Check::fail("ydotoold not running"),
2719 Check::fail("no connectable ydotool socket"),
2720 Check::ok("read/write: /dev/uinput"),
2721 );
2722
2723 let readiness = readiness_report(
2724 &platform,
2725 &portal_report(Check::fail("missing")),
2726 &accessibility,
2727 &windowing,
2728 &input,
2729 );
2730
2731 assert!(!readiness.can_send_development_input);
2732 assert!(readiness
2733 .blockers
2734 .iter()
2735 .any(|blocker| blocker.contains("absolute pointer input")));
2736 assert!(readiness
2737 .recommended_next_step
2738 .contains("keyboard-capable input backend"));
2739 }
2740
2741 #[test]
2742 fn x11_readiness_rejects_pointer_only_uinput_without_keyboard_backend() {
2743 let mut platform = platform_report();
2744 platform.xdg_session_type = Some("x11".to_string());
2745 platform.wayland_display = None;
2746 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2747 let windowing = windowing_report(true, true);
2748 let input = input_report_parts(
2749 Check::fail("missing ydotool"),
2750 Check::fail("ydotoold not running"),
2751 Check::fail("no connectable ydotool socket"),
2752 Check::ok("read/write: /dev/uinput"),
2753 );
2754
2755 let readiness = readiness_report(
2756 &platform,
2757 &portal_report(Check::fail("missing")),
2758 &accessibility,
2759 &windowing,
2760 &input,
2761 );
2762
2763 assert!(!readiness.can_send_development_input);
2764 }
2765
2766 #[test]
2767 fn readiness_accepts_remote_desktop_portal_without_local_input_backend() {
2768 let platform = platform_report();
2769 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2770 let windowing = windowing_report(true, true);
2771 let input = input_report_parts(
2772 Check::fail("missing ydotool"),
2773 Check::fail("ydotoold not running"),
2774 Check::fail("no connectable ydotool socket"),
2775 Check::fail("/dev/uinput: Permission denied"),
2776 );
2777
2778 let readiness = readiness_report(
2779 &platform,
2780 &portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop")),
2781 &accessibility,
2782 &windowing,
2783 &input,
2784 );
2785
2786 assert!(readiness.can_send_development_input);
2787 assert!(readiness.blockers.is_empty());
2788 }
2789
2790 #[test]
2791 fn readiness_rejects_inaccessible_ydotool_paths() {
2792 let platform = platform_report();
2793 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2794 let windowing = windowing_report(true, true);
2795 let input = input_report_parts(
2796 Check::ok("ydotool"),
2797 Check::ok("ydotoold"),
2798 Check::fail("/tmp/.ydotool_socket: Permission denied"),
2799 Check::fail("/dev/uinput: Permission denied"),
2800 );
2801
2802 let readiness = readiness_report(
2803 &platform,
2804 &portal_report(Check::fail("missing")),
2805 &accessibility,
2806 &windowing,
2807 &input,
2808 );
2809
2810 assert!(!readiness.can_send_development_input);
2811 assert!(readiness
2812 .recommended_next_step
2813 .contains("Enable a keyboard-capable input backend"));
2814 assert!(readiness
2815 .blockers
2816 .iter()
2817 .any(|blocker| blocker.contains("Development keyboard input is unavailable")));
2818 }
2819
2820 #[test]
2821 fn capability_map_rejects_incompatible_ydotool_with_live_daemon() {
2822 let platform = platform_report();
2823 let portals = portal_report(Check::fail("missing"));
2824 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2825 let windowing = windowing_report(true, true);
2826 let input = input_report_parts(
2827 Check::fail("unsupported legacy ydotool CLI"),
2828 Check::ok("ydotoold"),
2829 Check::ok("connectable socket"),
2830 Check::fail("/dev/uinput: Permission denied"),
2831 );
2832
2833 let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2834 let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2835
2836 assert!(!capabilities
2837 .input
2838 .iter()
2839 .any(|backend| backend == "ydotool"));
2840 assert!(!readiness.can_send_development_input);
2841 }
2842
2843 #[test]
2844 fn ydotool_socket_check_rejects_legacy_stream_socket() {
2845 let dir = std::env::temp_dir().join(format!(
2846 "computer-use-linux-diagnostics-{}",
2847 std::process::id()
2848 ));
2849 let _ = std::fs::remove_dir_all(&dir);
2850 std::fs::create_dir_all(&dir).expect("create temp diagnostics dir");
2851 let socket = dir.join("ydotool.sock");
2852 let listener =
2853 std::os::unix::net::UnixListener::bind(&socket).expect("bind temp diagnostics socket");
2854
2855 let check = socket_connect_check(&socket);
2856
2857 assert!(!check.ok, "{check:?}");
2858 drop(listener);
2859 let _ = std::fs::remove_dir_all(&dir);
2860 }
2861
2862 #[test]
2863 fn ydotool_socket_check_accepts_datagram_socket() {
2864 let dir = std::env::temp_dir().join(format!(
2865 "computer-use-linux-diagnostics-dgram-{}",
2866 std::process::id()
2867 ));
2868 let _ = std::fs::remove_dir_all(&dir);
2869 std::fs::create_dir_all(&dir).expect("create temp diagnostics dir");
2870 let socket = dir.join("ydotool.sock");
2871 let datagram =
2872 std::os::unix::net::UnixDatagram::bind(&socket).expect("bind temp datagram socket");
2873
2874 let check = socket_connect_check(&socket);
2875
2876 assert!(check.ok, "{check:?}");
2877 drop(datagram);
2878 let _ = std::fs::remove_dir_all(&dir);
2879 }
2880
2881 #[test]
2882 fn readiness_reports_cosmic_window_blocker_on_cosmic() {
2883 let mut platform = platform_report();
2884 platform.xdg_current_desktop = Some("COSMIC".to_string());
2885 let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2886 let windowing = windowing_report(false, false);
2887 let input = input_report(true);
2888
2889 let readiness = readiness_report(
2890 &platform,
2891 &portal_report(Check::fail("missing")),
2892 &accessibility,
2893 &windowing,
2894 &input,
2895 );
2896
2897 assert!(readiness
2898 .blockers
2899 .iter()
2900 .any(|blocker| blocker.contains("COSMIC Wayland window introspection")));
2901 }
2902}