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