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