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