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