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