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