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