Skip to main content

computer_use_linux/
diagnostics.rs

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    /// Which interchangeable backends this environment supports, per layer, plus
64    /// the one the tool prefers. Lets an agent (or selector) understand what's
65    /// available and choose accordingly instead of assuming one fixed path.
66    pub capabilities: CapabilityMap,
67}
68
69#[derive(Debug, Clone, Serialize, JsonSchema)]
70pub struct CapabilityMap {
71    /// Pointer/keyboard injection backends, best-first.
72    pub input: Vec<String>,
73    /// Screen capture backends, best-first.
74    pub screenshot: Vec<String>,
75    /// Window listing/focus backends available.
76    pub window_control: Vec<String>,
77    /// Accessibility (element-targeted, non-pointer) backends.
78    pub accessibility: Vec<String>,
79    /// Display/session isolation contexts the host can provide.
80    pub isolation: Vec<String>,
81    /// The backend the tool will use by default for each selectable layer.
82    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    /// X11 XTEST keyboard backend. Preferred over ydotool on X11 sessions,
148    /// where raw evdev scancodes are re-mapped by the active XKB layout.
149    pub xdotool: Check,
150    /// Wayland virtual-keyboard backend for layout-safe Unicode literal text.
151    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/// Derive the per-layer backend capability map from the individual checks. Lists
235/// are ordered best-first and mirror the order the tool actually tries them.
236#[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    // Absolute uinput pointer: accurate, non-blocking of coordinates; preferred.
264    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    // Subprocess fallback for background/systemd contexts the DBus paths reject.
309    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    // i3 and the generic X11/EWMH backend have no dedicated
340    // WindowingReport field; read them from the probe map so the capability
341    // list matches the registry order.
342    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    // Isolation contexts: the live shared session is always available; a headless
359    // GNOME session is possible when gnome-shell is installed (it supports
360    // --headless --virtual-monitor), giving the agent its own seat.
361    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(&current_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    // A nested X11 desktop can share a user manager with a Wayland host.
467    // Preserve its complete process-local session instead of grafting the
468    // host's WAYLAND_DISPLAY onto it.
469    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(&current_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 = if can_build_accessibility_tree(&before.accessibility) {
599        Check::ok("AT-SPI accessibility is already enabled")
600    } else {
601        let atspi_status = command_check_with_session_bus(
602            "busctl",
603            &[
604                "--user",
605                "set-property",
606                "org.a11y.Bus",
607                "/org/a11y/bus",
608                "org.a11y.Status",
609                "IsEnabled",
610                "b",
611                "true",
612            ],
613        );
614        if atspi_status.ok {
615            atspi_status
616        } else {
617            command_check_with_session_bus(
618                "gsettings",
619                &[
620                    "set",
621                    "org.gnome.desktop.interface",
622                    "toolkit-accessibility",
623                    "true",
624                ],
625            )
626        }
627    };
628    let after = doctor_report();
629    let before_ready = before.readiness.can_build_accessibility_tree;
630    let after_ready = after.readiness.can_build_accessibility_tree;
631    let changed_accessibility = !before_ready && after_ready;
632    let requires_target_app_restart = changed_accessibility;
633    let message = if after_ready {
634        if changed_accessibility {
635            "AT-SPI accessibility is enabled. Restart already-running target apps if their AT-SPI tree is still empty."
636        } else {
637            "AT-SPI accessibility is ready."
638        }
639    } else {
640        "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."
641    }
642    .to_string();
643
644    SetupReport {
645        before,
646        accessibility_command,
647        after,
648        changed_accessibility,
649        requires_target_app_restart,
650        message,
651    }
652}
653
654fn platform_report() -> PlatformReport {
655    PlatformReport {
656        os: std::env::consts::OS.to_string(),
657        arch: std::env::consts::ARCH.to_string(),
658        desktop_session: env_var("DESKTOP_SESSION"),
659        xdg_session_type: env_var("XDG_SESSION_TYPE"),
660        xdg_current_desktop: env_var("XDG_CURRENT_DESKTOP"),
661        wayland_display: env_var("WAYLAND_DISPLAY"),
662        display: env_var("DISPLAY"),
663        xauthority: env_var("XAUTHORITY"),
664        dbus_session_bus_address: dbus_session_address(),
665        xdg_runtime_dir: xdg_runtime_dir().map(|path| path.display().to_string()),
666        gnome_shell_version: command_check("gnome-shell", &["--version"]),
667        gnome_screenshot: command_check("gnome-screenshot", &["--version"]),
668    }
669}
670
671fn portal_report() -> (PortalReport, Check) {
672    let (remote_desktop, remote_desktop_keyboard, screencast) = remote_desktop_portal_checks();
673    (
674        PortalReport {
675            desktop_portal: bus_name_check("org.freedesktop.portal.Desktop"),
676            remote_desktop,
677            screencast,
678            screenshot: portal_interface_check("org.freedesktop.portal.Screenshot"),
679            input_capture: portal_interface_check("org.freedesktop.portal.InputCapture"),
680            mutter_remote_desktop: bus_name_check("org.gnome.Mutter.RemoteDesktop"),
681            mutter_screencast: bus_name_check("org.gnome.Mutter.ScreenCast"),
682        },
683        remote_desktop_keyboard,
684    )
685}
686
687fn accessibility_report() -> AccessibilityReport {
688    AccessibilityReport {
689        at_spi_bus: atspi_bus_address_check(),
690        toolkit_accessibility: command_check_with_session_bus(
691            "gsettings",
692            &[
693                "get",
694                "org.gnome.desktop.interface",
695                "toolkit-accessibility",
696            ],
697        ),
698        at_spi_enabled: atspi_status_property_check("IsEnabled"),
699        screen_reader_enabled: atspi_status_property_check("ScreenReaderEnabled"),
700    }
701}
702
703fn windowing_report(platform: &PlatformReport) -> WindowingReport {
704    let probes = registry::probe_backends();
705    let backend_check = |id: &str| {
706        probes
707            .iter()
708            .find(|probe| probe.id == id)
709            .map(check_from_backend_probe)
710            .unwrap_or_else(|| Check::fail("backend probe did not run"))
711    };
712    let gnome_shell_introspect = backend_check(GNOME_SHELL_INTROSPECT_BACKEND);
713    let computer_use_linux_gnome_shell_extension = backend_check(GNOME_SHELL_EXTENSION_BACKEND);
714    let cosmic_helper = backend_check(COSMIC_WAYLAND_BACKEND);
715    let kwin = backend_check(KWIN_BACKEND);
716    let hyprland = backend_check(HYPRLAND_BACKEND);
717    let backends = probes
718        .iter()
719        .map(|probe| (probe.id.to_string(), check_from_backend_probe(probe)))
720        .collect::<BTreeMap<_, _>>();
721    let can_list_windows = probes.iter().any(|probe| probe.can_list_windows);
722    let can_focus_apps = probes.iter().any(|probe| probe.can_focus_apps);
723    let can_focus_windows = probes.iter().any(|probe| probe.can_focus_windows);
724    let note = if can_list_windows {
725        if !can_focus_windows {
726            "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)."
727        } else if cosmic_helper.ok && is_cosmic_wayland_platform(platform) {
728            "A COSMIC Wayland window backend is available for list_windows, focused_window, and targeted input verification."
729        } else if kwin.ok {
730            "A KWin/Plasma window backend is available for list_windows, focused_window, and targeted input verification."
731        } else if hyprland.ok {
732            "A Hyprland window backend is available for list_windows, focused_window, and targeted input verification."
733        } else {
734            "A window listing backend is available for list_windows, focused_window, and targeted input verification."
735        }
736    } else {
737        "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."
738    }
739    .to_string();
740
741    WindowingReport {
742        gnome_shell_introspect,
743        computer_use_linux_gnome_shell_extension,
744        cosmic_helper,
745        kwin,
746        hyprland,
747        backends,
748        can_list_windows,
749        can_focus_apps,
750        can_focus_windows,
751        note,
752    }
753}
754
755fn check_from_backend_probe(probe: &registry::BackendProbe) -> Check {
756    if probe.ok {
757        Check::ok(probe.detail.clone())
758    } else {
759        Check::fail(probe.detail.clone())
760    }
761}
762
763fn input_report() -> InputReport {
764    InputReport {
765        ydotool: match ydotool::ensure_supported() {
766            Ok(support) => Check::ok(support.detail),
767            Err(detail) => Check::fail(detail),
768        },
769        ydotoold: process_check("ydotoold"),
770        ydotool_socket: ydotool_socket_check(),
771        uinput: read_write_path_check(Path::new("/dev/uinput")),
772        xdotool: command_path_check("xdotool"),
773        wtype: command_path_check("wtype"),
774    }
775}
776
777#[cfg(test)]
778fn readiness_report(
779    platform: &PlatformReport,
780    portals: &PortalReport,
781    accessibility: &AccessibilityReport,
782    windowing: &WindowingReport,
783    input: &InputReport,
784) -> ReadinessReport {
785    readiness_report_with_portal_keyboard(
786        platform,
787        &portals.remote_desktop,
788        accessibility,
789        windowing,
790        input,
791    )
792}
793
794fn readiness_report_with_portal_keyboard(
795    platform: &PlatformReport,
796    remote_desktop_keyboard: &Check,
797    accessibility: &AccessibilityReport,
798    windowing: &WindowingReport,
799    input: &InputReport,
800) -> ReadinessReport {
801    let mut blockers = Vec::new();
802    let can_build_accessibility_tree = can_build_accessibility_tree(accessibility);
803    let can_query_windows = windowing.can_list_windows;
804    let can_focus_apps = windowing.can_focus_apps;
805    let can_focus_windows = windowing.can_focus_windows;
806    let can_send_development_input =
807        can_send_development_input(platform, remote_desktop_keyboard, input);
808
809    if !can_build_accessibility_tree {
810        blockers.push(
811            "AT-SPI accessibility is disabled; enable org.a11y.Status IsEnabled or org.gnome.desktop.interface toolkit-accessibility for tree extraction."
812                .to_string(),
813        );
814    }
815
816    if !can_query_windows {
817        blockers.push(if is_cosmic_wayland_platform(platform) {
818            "COSMIC Wayland window introspection is unavailable; targeted window focus and verification will be disabled.".to_string()
819        } else {
820            "Window introspection is unavailable; targeted window focus and verification will be disabled."
821                .to_string()
822        });
823    }
824
825    if can_query_windows && !can_focus_windows {
826        blockers.push(
827            "Exact window activation is unavailable; app-level focus may work, but window_id/title/terminal-targeted input cannot be verified."
828                .to_string(),
829        );
830    }
831
832    if !can_send_development_input {
833        blockers.push(
834            "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."
835                .to_string(),
836        );
837    }
838
839    let recommended_next_step = if !can_build_accessibility_tree {
840        "Run setup_accessibility to enable AT-SPI accessibility before element-aware actions."
841            .to_string()
842    } else if !can_query_windows {
843        format!(
844            "Enable a supported window backend before using targeted keyboard input: {}",
845            registry::descriptors()
846                .iter()
847                .map(|descriptor| descriptor.missing_hint)
848                .collect::<Vec<_>>()
849                .join(" ")
850        )
851    } else if !can_focus_windows {
852        "Enable an exact-focus window backend before using window_id, title, or terminal-targeted input.".to_string()
853    } else if !can_send_development_input {
854        "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."
855            .to_string()
856    } else {
857        "Computer Use is ready: AT-SPI tree support, window targeting, and a Linux input backend are available."
858            .to_string()
859    };
860
861    ReadinessReport {
862        can_register_mcp_tools: true,
863        can_build_accessibility_tree,
864        can_query_windows,
865        can_focus_apps,
866        can_focus_windows,
867        can_send_development_input,
868        recommended_next_step,
869        blockers,
870    }
871}
872
873fn can_send_development_input(
874    platform: &PlatformReport,
875    remote_desktop_keyboard: &Check,
876    input: &InputReport,
877) -> bool {
878    let force_ydotool = env_flag_enabled_any(FORCE_YDOTOOL_KEYBOARD_ENV_KEYS);
879    let force_xdotool = env_flag_enabled_any(FORCE_XDOTOOL_KEYBOARD_ENV_KEYS);
880    portal_keyboard_input_available(platform, remote_desktop_keyboard)
881        || platform_is_wayland(platform)
882            && wtype_compatible_wayland_desktop(platform.xdg_current_desktop.as_deref())
883            && input.wtype.ok
884            && !force_ydotool
885        || should_advertise_xdotool(platform, input, force_ydotool, force_xdotool)
886        || input.ydotool.ok && input.ydotool_socket.ok
887}
888
889fn portal_pointer_input_available(platform: &PlatformReport, portals: &PortalReport) -> bool {
890    platform_is_wayland(platform) && portals.remote_desktop.ok
891}
892
893fn portal_keyboard_input_available(
894    platform: &PlatformReport,
895    remote_desktop_keyboard: &Check,
896) -> bool {
897    platform_is_wayland(platform) && remote_desktop_keyboard.ok
898}
899
900fn is_cosmic_wayland_platform(platform: &PlatformReport) -> bool {
901    platform
902        .xdg_current_desktop
903        .as_deref()
904        .is_some_and(|desktop| desktop.to_ascii_lowercase().contains("cosmic"))
905        && platform.xdg_session_type.as_deref() == Some("wayland")
906}
907
908fn can_build_accessibility_tree(accessibility: &AccessibilityReport) -> bool {
909    accessibility.at_spi_bus.ok
910        && (check_detail_contains_true(&accessibility.at_spi_enabled)
911            || check_detail_contains_true(&accessibility.toolkit_accessibility))
912}
913
914fn check_detail_contains_true(check: &Check) -> bool {
915    check.ok && check.detail.to_ascii_lowercase().contains("true")
916}
917
918fn env_var(key: &str) -> Option<String> {
919    env::var(key).ok().filter(|value| !value.trim().is_empty())
920}
921
922fn xdg_runtime_dir() -> Option<PathBuf> {
923    if let Some(value) = env_var("XDG_RUNTIME_DIR") {
924        return Some(PathBuf::from(value));
925    }
926    user_id().map(|uid| PathBuf::from(format!("/run/user/{uid}")))
927}
928
929fn dbus_session_address() -> Option<String> {
930    if let Some(value) = env_var("DBUS_SESSION_BUS_ADDRESS") {
931        return Some(value);
932    }
933    xdg_runtime_dir()
934        .map(|runtime| format!("unix:path={}", runtime.join("bus").display()))
935        .filter(|address| {
936            address
937                .strip_prefix("unix:path=")
938                .is_some_and(|p| Path::new(p).exists())
939        })
940}
941
942fn ydotool_socket_candidates() -> Vec<PathBuf> {
943    let mut candidates = Vec::new();
944    if let Some(value) = env_var("YDOTOOL_SOCKET") {
945        candidates.push(PathBuf::from(value));
946    }
947
948    if let Some(runtime_socket) = xdg_runtime_dir().map(|runtime| runtime.join(".ydotool_socket")) {
949        candidates.push(runtime_socket);
950    }
951    candidates.push(PathBuf::from("/tmp/.ydotool_socket"));
952    candidates
953}
954
955fn ydotool_socket_check() -> Check {
956    let mut checked = Vec::new();
957    for candidate in ydotool_socket_candidates() {
958        match socket_connect_result(&candidate) {
959            Ok(()) => return Check::ok(format!("connectable: {}", candidate.display())),
960            Err(detail) => checked.push(detail),
961        }
962    }
963
964    Check::fail(format!(
965        "no connectable ydotool socket ({})",
966        checked.join("; ")
967    ))
968}
969
970fn user_id() -> Option<String> {
971    let output = Command::new("id").arg("-u").output().ok()?;
972    output
973        .status
974        .success()
975        .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
976        .filter(|value| !value.is_empty())
977}
978
979fn command_path_check(command: &str) -> Check {
980    command_check("sh", &["-c", &format!("command -v {command}")])
981}
982
983fn platform_is_wayland(platform: &PlatformReport) -> bool {
984    match platform.xdg_session_type.as_deref() {
985        Some(value) => value.eq_ignore_ascii_case("wayland"),
986        None => platform
987            .wayland_display
988            .as_deref()
989            .is_some_and(|display| !display.trim().is_empty()),
990    }
991}
992
993pub(crate) fn wtype_compatible_wayland_desktop(desktop: Option<&str>) -> bool {
994    desktop.is_none_or(|desktop| {
995        let desktop = desktop.to_ascii_lowercase();
996        !["gnome", "kde", "plasma", "cosmic"]
997            .iter()
998            .any(|known_incompatible| desktop.contains(known_incompatible))
999    })
1000}
1001
1002fn should_advertise_xdotool(
1003    platform: &PlatformReport,
1004    input: &InputReport,
1005    force_ydotool: bool,
1006    force_xdotool: bool,
1007) -> bool {
1008    !force_ydotool
1009        && input.xdotool.ok
1010        && platform
1011            .display
1012            .as_deref()
1013            .is_some_and(|display| !display.trim().is_empty())
1014        && (force_xdotool || !platform_is_wayland(platform))
1015}
1016
1017fn env_flag_enabled_any(keys: &[&str]) -> bool {
1018    keys.iter()
1019        .any(|key| env::var(key).ok().as_deref() == Some("1"))
1020}
1021
1022fn force_portal_for_all_input(
1023    force_portal_pointer: bool,
1024    force_portal_keyboard: bool,
1025    force_ydotool_pointer: bool,
1026    force_ydotool_keyboard: bool,
1027) -> bool {
1028    force_portal_pointer
1029        && force_portal_keyboard
1030        && !force_ydotool_pointer
1031        && !force_ydotool_keyboard
1032}
1033
1034fn process_check(process_name: &str) -> Check {
1035    command_check("pgrep", &["-a", process_name])
1036}
1037
1038#[cfg(test)]
1039fn socket_connect_check(path: &Path) -> Check {
1040    match socket_connect_result(path) {
1041        Ok(()) => Check::ok(format!("connectable: {}", path.display())),
1042        Err(detail) => Check::fail(detail),
1043    }
1044}
1045
1046fn socket_connect_result(path: &Path) -> std::result::Result<(), String> {
1047    if !path.exists() {
1048        return Err(format!("missing: {}", path.display()));
1049    }
1050
1051    UnixDatagram::unbound()
1052        .and_then(|socket| socket.connect(path))
1053        .map_err(|error| format!("{}: datagram: {error}", path.display()))
1054}
1055
1056fn read_write_path_check(path: &Path) -> Check {
1057    if !path.exists() {
1058        return Check::fail(format!("missing: {}", path.display()));
1059    }
1060
1061    match OpenOptions::new().read(true).write(true).open(path) {
1062        Ok(_) => Check::ok(format!("read/write: {}", path.display())),
1063        Err(error) => Check::fail(format!("{}: {error}", path.display())),
1064    }
1065}
1066
1067fn bus_name_check(name: &str) -> Check {
1068    command_check_with_session_bus("busctl", &["--user", "status", name])
1069}
1070
1071fn portal_interface_check(interface: &str) -> Check {
1072    command_check_with_session_bus(
1073        "busctl",
1074        &[
1075            "--user",
1076            "introspect",
1077            "org.freedesktop.portal.Desktop",
1078            "/org/freedesktop/portal/desktop",
1079            interface,
1080        ],
1081    )
1082}
1083
1084fn remote_desktop_portal_checks() -> (Check, Check, Check) {
1085    let introspection = portal_interface_check("org.freedesktop.portal.RemoteDesktop");
1086    let screencast = portal_interface_check("org.freedesktop.portal.ScreenCast");
1087    if !introspection.ok {
1088        return (introspection.clone(), introspection, screencast);
1089    }
1090
1091    let available_device_types = command_check_with_session_bus(
1092        "busctl",
1093        &[
1094            "--user",
1095            "get-property",
1096            "org.freedesktop.portal.Desktop",
1097            "/org/freedesktop/portal/desktop",
1098            "org.freedesktop.portal.RemoteDesktop",
1099            "AvailableDeviceTypes",
1100        ],
1101    );
1102    let available_source_types = command_check_with_session_bus(
1103        "busctl",
1104        &[
1105            "--user",
1106            "get-property",
1107            "org.freedesktop.portal.Desktop",
1108            "/org/freedesktop/portal/desktop",
1109            "org.freedesktop.portal.ScreenCast",
1110            "AvailableSourceTypes",
1111        ],
1112    );
1113    let pointer = remote_desktop_pointer_check_from(
1114        &introspection,
1115        &screencast,
1116        &available_device_types,
1117        &available_source_types,
1118    );
1119    let keyboard = remote_desktop_keyboard_check_from(&introspection, &available_device_types);
1120    (pointer, keyboard, screencast)
1121}
1122
1123fn remote_desktop_pointer_check_from(
1124    introspection: &Check,
1125    screencast: &Check,
1126    available_device_types: &Check,
1127    available_source_types: &Check,
1128) -> Check {
1129    if !introspection.ok {
1130        return Check::fail(introspection.detail.clone());
1131    }
1132    let missing_methods = missing_busctl_methods(introspection, REMOTE_DESKTOP_POINTER_METHODS);
1133    if !missing_methods.is_empty() {
1134        return Check::fail(format!(
1135            "RemoteDesktop interface is missing required pointer methods: {}",
1136            missing_methods.join(", ")
1137        ));
1138    }
1139
1140    if !screencast.ok {
1141        return Check::fail(format!(
1142            "ScreenCast interface is unavailable for portal pointer input: {}",
1143            screencast.detail
1144        ));
1145    }
1146    let missing_screencast_methods = missing_busctl_methods(screencast, SCREENCAST_POINTER_METHODS);
1147    if !missing_screencast_methods.is_empty() {
1148        return Check::fail(format!(
1149            "ScreenCast interface is missing required pointer methods: {}",
1150            missing_screencast_methods.join(", ")
1151        ));
1152    }
1153
1154    let device_types = match remote_desktop_device_types(available_device_types) {
1155        Ok(device_types) => device_types,
1156        Err(detail) => return Check::fail(detail),
1157    };
1158    if device_types & PORTAL_DEVICE_POINTER == 0 {
1159        return Check::fail(format!(
1160            "RemoteDesktop AvailableDeviceTypes={device_types} does not include pointer input"
1161        ));
1162    }
1163
1164    let source_types = match screencast_source_types(available_source_types) {
1165        Ok(source_types) => source_types,
1166        Err(detail) => return Check::fail(detail),
1167    };
1168    if source_types & PORTAL_SOURCE_MONITOR == 0 {
1169        return Check::fail(format!(
1170            "ScreenCast AvailableSourceTypes={source_types} does not include monitor sources"
1171        ));
1172    }
1173
1174    Check::ok(format!(
1175        "pointer-capable RemoteDesktop portal (AvailableDeviceTypes={device_types}, AvailableSourceTypes={source_types})"
1176    ))
1177}
1178
1179fn remote_desktop_keyboard_check_from(
1180    introspection: &Check,
1181    available_device_types: &Check,
1182) -> Check {
1183    if !introspection.ok {
1184        return Check::fail(introspection.detail.clone());
1185    }
1186
1187    let missing_methods = missing_busctl_methods(introspection, REMOTE_DESKTOP_KEYBOARD_METHODS);
1188    if !missing_methods.is_empty() {
1189        return Check::fail(format!(
1190            "RemoteDesktop interface is missing required keyboard methods: {}",
1191            missing_methods.join(", ")
1192        ));
1193    }
1194
1195    let device_types = match remote_desktop_device_types(available_device_types) {
1196        Ok(device_types) => device_types,
1197        Err(detail) => return Check::fail(detail),
1198    };
1199    if device_types & PORTAL_DEVICE_KEYBOARD == 0 {
1200        return Check::fail(format!(
1201            "RemoteDesktop AvailableDeviceTypes={device_types} does not include keyboard input"
1202        ));
1203    }
1204
1205    Check::ok(format!(
1206        "keyboard-capable RemoteDesktop portal (AvailableDeviceTypes={device_types})"
1207    ))
1208}
1209
1210fn missing_busctl_methods<'a>(introspection: &Check, methods: &'a [&'a str]) -> Vec<&'a str> {
1211    methods
1212        .iter()
1213        .copied()
1214        .filter(|method| !busctl_introspection_has_method(&introspection.detail, method))
1215        .collect()
1216}
1217
1218fn remote_desktop_device_types(available_device_types: &Check) -> Result<u32, String> {
1219    if !available_device_types.ok {
1220        return Err(format!(
1221            "RemoteDesktop AvailableDeviceTypes is unavailable: {}",
1222            available_device_types.detail
1223        ));
1224    }
1225    parse_busctl_u32_property(&available_device_types.detail).ok_or_else(|| {
1226        format!(
1227            "RemoteDesktop AvailableDeviceTypes has an unexpected value: {}",
1228            available_device_types.detail
1229        )
1230    })
1231}
1232
1233fn screencast_source_types(available_source_types: &Check) -> Result<u32, String> {
1234    if !available_source_types.ok {
1235        return Err(format!(
1236            "ScreenCast AvailableSourceTypes is unavailable: {}",
1237            available_source_types.detail
1238        ));
1239    }
1240    parse_busctl_u32_property(&available_source_types.detail).ok_or_else(|| {
1241        format!(
1242            "ScreenCast AvailableSourceTypes has an unexpected value: {}",
1243            available_source_types.detail
1244        )
1245    })
1246}
1247
1248fn busctl_introspection_has_method(detail: &str, method: &str) -> bool {
1249    detail.lines().any(|line| {
1250        let mut fields = line.split_whitespace();
1251        fields
1252            .next()
1253            .is_some_and(|name| name.trim_start_matches('.') == method)
1254            && fields.next() == Some("method")
1255    })
1256}
1257
1258fn parse_busctl_u32_property(detail: &str) -> Option<u32> {
1259    let mut fields = detail.split_whitespace();
1260    if fields.next()? != "u" {
1261        return None;
1262    }
1263    let value = fields.next()?;
1264    value
1265        .strip_prefix("0x")
1266        .map(|hex| u32::from_str_radix(hex, 16).ok())
1267        .unwrap_or_else(|| value.parse().ok())
1268}
1269
1270fn atspi_bus_address_check() -> Check {
1271    let busctl = command_check_with_session_bus(
1272        "busctl",
1273        &[
1274            "--user",
1275            "call",
1276            "org.a11y.Bus",
1277            "/org/a11y/bus",
1278            "org.a11y.Bus",
1279            "GetAddress",
1280        ],
1281    );
1282    if busctl.ok {
1283        return busctl;
1284    }
1285
1286    gdbus_call_check(
1287        "org.a11y.Bus",
1288        "/org/a11y/bus",
1289        "org.a11y.Bus.GetAddress",
1290        &[],
1291    )
1292}
1293
1294fn atspi_status_property_check(property: &str) -> Check {
1295    let busctl = command_check_with_session_bus(
1296        "busctl",
1297        &[
1298            "--user",
1299            "get-property",
1300            "org.a11y.Bus",
1301            "/org/a11y/bus",
1302            "org.a11y.Status",
1303            property,
1304        ],
1305    );
1306    if busctl.ok {
1307        return busctl;
1308    }
1309
1310    gdbus_call_check(
1311        "org.a11y.Bus",
1312        "/org/a11y/bus",
1313        "org.freedesktop.DBus.Properties.Get",
1314        &["org.a11y.Status", property],
1315    )
1316}
1317
1318fn gdbus_call_check(destination: &str, object_path: &str, method: &str, args: &[&str]) -> Check {
1319    let mut command_args = vec![
1320        "call",
1321        "--session",
1322        "--dest",
1323        destination,
1324        "--object-path",
1325        object_path,
1326        "--method",
1327        method,
1328    ];
1329    command_args.extend_from_slice(args);
1330    command_check_with_session_bus("gdbus", &command_args)
1331}
1332
1333fn command_check(command: &str, args: &[&str]) -> Check {
1334    run_command(command, args, false)
1335}
1336
1337fn command_check_with_session_bus(command: &str, args: &[&str]) -> Check {
1338    run_command(command, args, true)
1339}
1340
1341fn run_command(command: &str, args: &[&str], with_session_bus: bool) -> Check {
1342    let mut cmd = Command::new(command);
1343    cmd.args(args);
1344
1345    if with_session_bus {
1346        if let Some(address) = dbus_session_address() {
1347            cmd.env("DBUS_SESSION_BUS_ADDRESS", address);
1348        }
1349        if let Some(runtime) = xdg_runtime_dir() {
1350            cmd.env("XDG_RUNTIME_DIR", runtime);
1351        }
1352    }
1353
1354    match cmd.output() {
1355        Ok(output) if output.status.success() => {
1356            let detail = String::from_utf8_lossy(&output.stdout).trim().to_string();
1357            Check::ok(if detail.is_empty() {
1358                "ok".into()
1359            } else {
1360                detail
1361            })
1362        }
1363        Ok(output) => {
1364            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1365            let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
1366            let detail = if !stderr.is_empty() { stderr } else { stdout };
1367            Check::fail(if detail.is_empty() {
1368                format!("exit status {}", output.status)
1369            } else {
1370                detail
1371            })
1372        }
1373        Err(error) => Check::fail(error.to_string()),
1374    }
1375}
1376
1377#[cfg(test)]
1378mod tests {
1379    use super::*;
1380
1381    fn platform_report() -> PlatformReport {
1382        PlatformReport {
1383            os: "linux".to_string(),
1384            arch: "x86_64".to_string(),
1385            desktop_session: None,
1386            xdg_session_type: Some("wayland".to_string()),
1387            xdg_current_desktop: Some("GNOME".to_string()),
1388            wayland_display: Some("wayland-0".to_string()),
1389            display: Some(":0".to_string()),
1390            xauthority: Some("/run/user/1000/Xauthority".to_string()),
1391            dbus_session_bus_address: Some("unix:path=/run/user/1000/bus".to_string()),
1392            xdg_runtime_dir: Some("/run/user/1000".to_string()),
1393            gnome_shell_version: Check::ok("GNOME Shell 46.0"),
1394            gnome_screenshot: Check::ok("gnome-screenshot 41.0"),
1395        }
1396    }
1397
1398    fn portal_report(remote_desktop: Check) -> PortalReport {
1399        PortalReport {
1400            desktop_portal: Check::ok("ok"),
1401            remote_desktop,
1402            screencast: Check::fail("missing"),
1403            screenshot: Check::fail("missing"),
1404            input_capture: Check::fail("missing"),
1405            mutter_remote_desktop: Check::fail("missing"),
1406            mutter_screencast: Check::fail("missing"),
1407        }
1408    }
1409
1410    fn accessibility_report(
1411        at_spi_bus: Check,
1412        toolkit_accessibility: Check,
1413    ) -> AccessibilityReport {
1414        AccessibilityReport {
1415            at_spi_bus,
1416            toolkit_accessibility,
1417            at_spi_enabled: Check::fail("(<false>,)"),
1418            screen_reader_enabled: Check::fail("(<false>,)"),
1419        }
1420    }
1421
1422    fn windowing_report(can_list_windows: bool, can_focus_windows: bool) -> WindowingReport {
1423        WindowingReport {
1424            gnome_shell_introspect: if can_list_windows {
1425                Check::ok("ok")
1426            } else {
1427                Check::fail("denied")
1428            },
1429            computer_use_linux_gnome_shell_extension: if can_focus_windows {
1430                Check::ok("ok")
1431            } else {
1432                Check::fail("missing")
1433            },
1434            cosmic_helper: Check::fail("missing"),
1435            kwin: Check::fail("not a KWin session"),
1436            hyprland: Check::fail("not a Hyprland session"),
1437            backends: BTreeMap::new(),
1438            can_list_windows,
1439            can_focus_apps: true,
1440            can_focus_windows,
1441            note: String::new(),
1442        }
1443    }
1444
1445    fn input_report(can_send_input: bool) -> InputReport {
1446        let check = if can_send_input {
1447            Check::ok("ok")
1448        } else {
1449            Check::fail("missing")
1450        };
1451        input_report_parts(check.clone(), check.clone(), check.clone(), check)
1452    }
1453
1454    fn input_report_parts(
1455        ydotool: Check,
1456        ydotoold: Check,
1457        ydotool_socket: Check,
1458        uinput: Check,
1459    ) -> InputReport {
1460        InputReport {
1461            ydotool,
1462            ydotoold,
1463            ydotool_socket,
1464            uinput,
1465            xdotool: Check::fail("missing xdotool"),
1466            wtype: Check::fail("missing wtype"),
1467        }
1468    }
1469
1470    #[test]
1471    fn accessibility_tree_requires_reachable_at_spi_bus() {
1472        let report = accessibility_report(Check::fail("permission denied"), Check::ok("true"));
1473
1474        assert!(!can_build_accessibility_tree(&report));
1475    }
1476
1477    #[test]
1478    fn accessibility_tree_is_ready_when_bus_and_toolkit_are_ready() {
1479        let report = accessibility_report(
1480            Check::ok("('unix:path=/run/user/1000/at-spi/bus',)"),
1481            Check::ok("true"),
1482        );
1483
1484        assert!(can_build_accessibility_tree(&report));
1485    }
1486
1487    #[test]
1488    fn capability_map_advertises_only_a_buildable_accessibility_tree() {
1489        let platform = platform_report();
1490        let portals = portal_report(Check::fail("missing"));
1491        let windowing = windowing_report(false, false);
1492        let input = input_report(false);
1493
1494        for accessibility in [
1495            accessibility_report(Check::fail("permission denied"), Check::ok("true")),
1496            accessibility_report(
1497                Check::ok("('unix:path=/run/user/1000/at-spi/bus',)"),
1498                Check::ok("false"),
1499            ),
1500        ] {
1501            let capabilities =
1502                capability_map(&platform, &portals, &accessibility, &windowing, &input);
1503            assert!(capabilities.accessibility.is_empty());
1504        }
1505    }
1506
1507    #[test]
1508    fn parses_parent_pid_from_proc_status() {
1509        let status = "Name:\ttest\nPid:\t42\nPPid:\t7\n";
1510
1511        assert_eq!(parse_parent_pid(status), Some(7));
1512    }
1513
1514    #[test]
1515    fn parses_nul_separated_process_environment() {
1516        let environment = parse_environ(
1517            b"DISPLAY=:0\0WAYLAND_DISPLAY=wayland-0\0EMPTY=\0NO_EQUALS\0XDG_SESSION_TYPE=wayland\0",
1518        );
1519
1520        assert_eq!(environment.get("DISPLAY").map(String::as_str), Some(":0"));
1521        assert_eq!(
1522            environment.get("WAYLAND_DISPLAY").map(String::as_str),
1523            Some("wayland-0")
1524        );
1525        assert_eq!(environment.get("EMPTY").map(String::as_str), Some(""));
1526        assert!(!environment.contains_key("NO_EQUALS"));
1527    }
1528
1529    #[test]
1530    fn desktop_env_hydration_includes_xauthority() {
1531        assert!(DESKTOP_ENV_KEYS.contains(&"XAUTHORITY"));
1532    }
1533
1534    #[test]
1535    fn desktop_env_hydration_preserves_explicit_native_x11() {
1536        let current_env = HashMap::from([
1537            ("DISPLAY".to_string(), ":90".to_string()),
1538            ("XDG_SESSION_TYPE".to_string(), "x11".to_string()),
1539        ]);
1540        let host_env = HashMap::from([
1541            ("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string()),
1542            (
1543                "XDG_CURRENT_DESKTOP".to_string(),
1544                "ubuntu:GNOME".to_string(),
1545            ),
1546        ]);
1547
1548        let updates = desktop_env_hydration_updates(&current_env, &host_env);
1549
1550        assert!(!updates.iter().any(|(key, _)| *key == "WAYLAND_DISPLAY"));
1551        assert!(updates
1552            .iter()
1553            .any(|(key, value)| { *key == "XDG_CURRENT_DESKTOP" && value == "ubuntu:GNOME" }));
1554    }
1555
1556    #[test]
1557    fn desktop_env_hydration_still_imports_wayland_for_incomplete_sessions() {
1558        let current_env = HashMap::new();
1559        let host_env = HashMap::from([("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string())]);
1560
1561        let updates = desktop_env_hydration_updates(&current_env, &host_env);
1562
1563        assert!(updates
1564            .iter()
1565            .any(|(key, value)| *key == "WAYLAND_DISPLAY" && value == "wayland-0"));
1566    }
1567
1568    #[test]
1569    fn graphical_process_env_requires_display() {
1570        let with_display = HashMap::from([("DISPLAY".to_string(), ":0".to_string())]);
1571        let with_wayland =
1572            HashMap::from([("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string())]);
1573        let without_display = HashMap::from([("XAUTHORITY".to_string(), "/tmp/xauth".to_string())]);
1574
1575        assert!(process_env_has_graphical_display(&with_display));
1576        assert!(process_env_has_graphical_display(&with_wayland));
1577        assert!(!process_env_has_graphical_display(&without_display));
1578    }
1579
1580    #[test]
1581    fn capabilities_prefer_xdotool_before_ydotool_on_x11() {
1582        let mut platform = platform_report();
1583        platform.xdg_session_type = Some("x11".to_string());
1584        platform.wayland_display = None;
1585        platform.display = Some(":0".to_string());
1586        let input = InputReport {
1587            ydotool: Check::ok("ydotool"),
1588            ydotoold: Check::ok("ydotoold"),
1589            ydotool_socket: Check::ok("connectable"),
1590            uinput: Check::fail("missing"),
1591            xdotool: Check::ok("xdotool"),
1592            wtype: Check::fail("missing wtype"),
1593        };
1594
1595        let capabilities = capability_map(
1596            &platform,
1597            &portal_report(Check::fail("missing")),
1598            &accessibility_report(Check::fail("missing"), Check::fail("missing")),
1599            &windowing_report(false, false),
1600            &input,
1601        );
1602
1603        assert_eq!(capabilities.input, ["xdotool", "ydotool"]);
1604        assert_eq!(capabilities.preferred.input.as_deref(), Some("xdotool"));
1605    }
1606
1607    #[test]
1608    fn x11_diagnostics_ignore_portal_and_accept_xdotool() {
1609        let mut platform = platform_report();
1610        platform.xdg_session_type = Some("x11".to_string());
1611        platform.wayland_display = None;
1612        platform.display = Some(":0".to_string());
1613        let portals = portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop"));
1614        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1615        let windowing = windowing_report(true, true);
1616        let mut input = input_report(false);
1617        input.xdotool = Check::ok("xdotool");
1618
1619        let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1620        let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
1621
1622        assert_eq!(capabilities.input, ["xdotool"]);
1623        assert_eq!(capabilities.preferred.input.as_deref(), Some("xdotool"));
1624        assert!(readiness.can_send_development_input);
1625    }
1626
1627    #[test]
1628    fn wayland_diagnostics_advertise_wtype_without_portal_or_ydotool() {
1629        let mut platform = platform_report();
1630        platform.xdg_session_type = Some("wayland".to_string());
1631        platform.xdg_current_desktop = Some("Hyprland".to_string());
1632        platform.wayland_display = Some("wayland-0".to_string());
1633        let portals = portal_report(Check::fail("missing"));
1634        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1635        let windowing = windowing_report(true, true);
1636        let mut input = input_report(false);
1637        input.wtype = Check::ok("wtype");
1638
1639        let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1640        let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
1641
1642        assert_eq!(capabilities.input, ["wtype"]);
1643        assert_eq!(capabilities.preferred.input.as_deref(), Some("wtype"));
1644        assert!(readiness.can_send_development_input);
1645    }
1646
1647    #[test]
1648    fn wtype_excludes_known_incompatible_wayland_desktops() {
1649        assert!(wtype_compatible_wayland_desktop(Some("Hyprland")));
1650        assert!(wtype_compatible_wayland_desktop(Some("sway")));
1651        assert!(wtype_compatible_wayland_desktop(None));
1652        assert!(!wtype_compatible_wayland_desktop(Some("GNOME")));
1653        assert!(!wtype_compatible_wayland_desktop(Some("KDE;Plasma")));
1654        assert!(!wtype_compatible_wayland_desktop(Some("COSMIC")));
1655    }
1656
1657    #[test]
1658    fn wayland_diagnostics_prefer_ydotool_before_portal() {
1659        let platform = platform_report();
1660        let portals = portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop"));
1661        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1662        let windowing = windowing_report(true, true);
1663        let input = input_report_parts(
1664            Check::ok("ydotool"),
1665            Check::ok("ydotoold"),
1666            Check::ok("connectable"),
1667            Check::fail("missing uinput"),
1668        );
1669
1670        let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1671
1672        assert_eq!(capabilities.input, ["ydotool", "portal"]);
1673        assert_eq!(capabilities.preferred.input.as_deref(), Some("ydotool"));
1674    }
1675
1676    #[test]
1677    fn portal_force_order_requires_both_input_modalities() {
1678        assert!(force_portal_for_all_input(true, true, false, false));
1679        assert!(!force_portal_for_all_input(true, false, false, false));
1680        assert!(!force_portal_for_all_input(true, true, true, false));
1681        assert!(!force_portal_for_all_input(true, true, false, true));
1682    }
1683
1684    fn remote_desktop_runtime_introspection() -> Check {
1685        Check::ok(
1686            "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n\
1687             .CreateSession method a{sv} o -\n\
1688             .SelectDevices method oa{sv} o -\n\
1689             .Start method osa{sv} o -\n\
1690             .NotifyPointerMotionAbsolute method oa{sv}udd - -\n\
1691             .NotifyPointerButton method oa{sv}iu - -\n\
1692             .NotifyPointerAxisDiscrete method oa{sv}ui - -\n\
1693             .NotifyKeyboardKeycode method ouu - -\n\
1694             .NotifyKeyboardKeysym method ouu - -\n\
1695             .AvailableDeviceTypes property u 3 emits-change",
1696        )
1697    }
1698
1699    fn screencast_runtime_introspection() -> Check {
1700        Check::ok(
1701            "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n\
1702             .SelectSources method oa{sv} o -",
1703        )
1704    }
1705
1706    #[test]
1707    fn remote_desktop_portal_rejects_header_only_introspection() {
1708        let introspection = Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS");
1709        let available_device_types = Check::ok("u 3");
1710        let pointer = remote_desktop_pointer_check_from(
1711            &introspection,
1712            &screencast_runtime_introspection(),
1713            &available_device_types,
1714            &Check::ok("u 1"),
1715        );
1716        let keyboard = remote_desktop_keyboard_check_from(&introspection, &available_device_types);
1717
1718        assert!(!pointer.ok);
1719        assert!(pointer.detail.contains("CreateSession"));
1720        assert!(pointer.detail.contains("NotifyPointerButton"));
1721        assert!(!keyboard.ok);
1722        assert!(keyboard.detail.contains("NotifyKeyboardKeysym"));
1723
1724        let input = input_report_parts(
1725            Check::ok("ydotool"),
1726            Check::ok("ydotoold"),
1727            Check::fail("no connectable ydotool socket"),
1728            Check::ok("read/write: /dev/uinput"),
1729        );
1730        let platform = platform_report();
1731        let portals = portal_report(pointer);
1732        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1733        let windowing = windowing_report(true, true);
1734        let capabilities = capability_map_with_portal_keyboard(
1735            &platform,
1736            &portals,
1737            &keyboard,
1738            &accessibility,
1739            &windowing,
1740            &input,
1741        );
1742        let readiness = readiness_report_with_portal_keyboard(
1743            &platform,
1744            &keyboard,
1745            &accessibility,
1746            &windowing,
1747            &input,
1748        );
1749
1750        assert!(!capabilities.input.iter().any(|backend| backend == "portal"));
1751        assert!(!readiness.can_send_development_input);
1752    }
1753
1754    #[test]
1755    fn keyboard_only_portal_remains_advertised_without_pointer_capability() {
1756        let platform = platform_report();
1757        let keyboard = remote_desktop_keyboard_check_from(
1758            &remote_desktop_runtime_introspection(),
1759            &Check::ok("u 1"),
1760        );
1761        let portals = portal_report(Check::fail(
1762            "ScreenCast AvailableSourceTypes=2 does not include monitor sources",
1763        ));
1764        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1765        let windowing = windowing_report(true, true);
1766        let input = input_report_parts(
1767            Check::fail("missing ydotool"),
1768            Check::fail("ydotoold not running"),
1769            Check::fail("no connectable ydotool socket"),
1770            Check::fail("/dev/uinput: Permission denied"),
1771        );
1772
1773        let capabilities = capability_map_with_portal_keyboard(
1774            &platform,
1775            &portals,
1776            &keyboard,
1777            &accessibility,
1778            &windowing,
1779            &input,
1780        );
1781        let readiness = readiness_report_with_portal_keyboard(
1782            &platform,
1783            &keyboard,
1784            &accessibility,
1785            &windowing,
1786            &input,
1787        );
1788
1789        assert!(!portals.remote_desktop.ok);
1790        assert_eq!(capabilities.input, ["portal"]);
1791        assert_eq!(capabilities.preferred.input.as_deref(), Some("portal"));
1792        assert!(readiness.can_send_development_input);
1793    }
1794
1795    #[test]
1796    fn remote_desktop_pointer_rejects_missing_screencast_contract() {
1797        let check = remote_desktop_pointer_check_from(
1798            &remote_desktop_runtime_introspection(),
1799            &Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS"),
1800            &Check::ok("u 2"),
1801            &Check::ok("u 1"),
1802        );
1803
1804        assert!(!check.ok);
1805        assert!(check.detail.contains("SelectSources"));
1806    }
1807
1808    #[test]
1809    fn remote_desktop_pointer_rejects_missing_monitor_source_type() {
1810        let check = remote_desktop_pointer_check_from(
1811            &remote_desktop_runtime_introspection(),
1812            &screencast_runtime_introspection(),
1813            &Check::ok("u 2"),
1814            &Check::ok("u 2"),
1815        );
1816
1817        assert!(!check.ok);
1818        assert!(check.detail.contains("does not include monitor sources"));
1819    }
1820
1821    #[test]
1822    fn remote_desktop_portal_rejects_missing_keyboard_device_type() {
1823        let check = remote_desktop_keyboard_check_from(
1824            &remote_desktop_runtime_introspection(),
1825            &Check::ok("u 2"),
1826        );
1827
1828        assert!(!check.ok);
1829        assert!(check.detail.contains("does not include keyboard input"));
1830    }
1831
1832    #[test]
1833    fn remote_desktop_portal_accepts_runtime_keyboard_contract() {
1834        let check = remote_desktop_keyboard_check_from(
1835            &remote_desktop_runtime_introspection(),
1836            &Check::ok("u 3"),
1837        );
1838
1839        assert!(check.ok);
1840        assert!(check.detail.contains("AvailableDeviceTypes=3"));
1841    }
1842
1843    #[test]
1844    fn pointer_only_portal_remains_advertised_without_keyboard_readiness() {
1845        let platform = platform_report();
1846        let available_device_types = Check::ok("u 2");
1847        let pointer = remote_desktop_pointer_check_from(
1848            &remote_desktop_runtime_introspection(),
1849            &screencast_runtime_introspection(),
1850            &available_device_types,
1851            &Check::ok("u 1"),
1852        );
1853        let keyboard = remote_desktop_keyboard_check_from(
1854            &remote_desktop_runtime_introspection(),
1855            &available_device_types,
1856        );
1857        let portals = portal_report(pointer);
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_report(Check::ok("bus"), Check::ok("true")),
1879            &windowing_report(true, true),
1880            &input,
1881        );
1882
1883        assert!(portals.remote_desktop.ok);
1884        assert!(capabilities.input.iter().any(|backend| backend == "portal"));
1885        assert!(!readiness.can_send_development_input);
1886    }
1887
1888    #[test]
1889    fn capabilities_require_display_to_advertise_xdotool() {
1890        let mut platform = platform_report();
1891        platform.xdg_session_type = Some("x11".to_string());
1892        platform.wayland_display = None;
1893        platform.display = None;
1894        let input = InputReport {
1895            ydotool: Check::ok("ydotool"),
1896            ydotoold: Check::ok("ydotoold"),
1897            ydotool_socket: Check::ok("connectable"),
1898            uinput: Check::fail("missing"),
1899            xdotool: Check::ok("xdotool"),
1900            wtype: Check::fail("missing wtype"),
1901        };
1902
1903        let capabilities = capability_map(
1904            &platform,
1905            &portal_report(Check::fail("missing")),
1906            &accessibility_report(Check::fail("missing"), Check::fail("missing")),
1907            &windowing_report(false, false),
1908            &input,
1909        );
1910
1911        assert_eq!(capabilities.input, ["ydotool"]);
1912        assert_eq!(capabilities.preferred.input.as_deref(), Some("ydotool"));
1913    }
1914
1915    #[test]
1916    fn xdotool_diagnostics_force_precedence_matches_runtime() {
1917        let mut platform = platform_report();
1918        platform.xdg_session_type = Some("wayland".to_string());
1919        platform.display = Some(":0".to_string());
1920        let mut input = input_report(false);
1921        input.xdotool = Check::ok("xdotool");
1922
1923        assert!(should_advertise_xdotool(&platform, &input, false, true));
1924        assert!(!should_advertise_xdotool(&platform, &input, true, true));
1925    }
1926
1927    #[test]
1928    fn parses_systemd_show_environment_output() {
1929        let environment = parse_line_environment(
1930            b"DISPLAY=:0\nHYPRLAND_INSTANCE_SIGNATURE=abc\nNO_EQUALS\nYDOTOOL_SOCKET=/run/ydotoold/socket\n",
1931        );
1932
1933        assert_eq!(environment.get("DISPLAY").map(String::as_str), Some(":0"));
1934        assert_eq!(
1935            environment
1936                .get("HYPRLAND_INSTANCE_SIGNATURE")
1937                .map(String::as_str),
1938            Some("abc")
1939        );
1940        assert_eq!(
1941            environment.get("YDOTOOL_SOCKET").map(String::as_str),
1942            Some("/run/ydotoold/socket")
1943        );
1944        assert!(!environment.contains_key("NO_EQUALS"));
1945    }
1946
1947    #[test]
1948    fn readiness_requires_exact_window_focus_for_targeted_input() {
1949        let platform = platform_report();
1950        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1951        let windowing = windowing_report(true, false);
1952        let input = input_report(true);
1953
1954        let readiness = readiness_report(
1955            &platform,
1956            &portal_report(Check::fail("missing")),
1957            &accessibility,
1958            &windowing,
1959            &input,
1960        );
1961
1962        assert!(readiness.can_query_windows);
1963        assert!(!readiness.can_focus_windows);
1964        assert!(readiness
1965            .recommended_next_step
1966            .contains("exact-focus window backend"));
1967        assert!(readiness
1968            .blockers
1969            .iter()
1970            .any(|blocker| blocker.contains("Exact window activation")));
1971    }
1972
1973    #[test]
1974    fn readiness_treats_kwin_as_full_window_backend() {
1975        let platform = platform_report();
1976        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1977        let mut windowing = windowing_report(false, false);
1978        windowing.kwin = Check::ok("KWin scripting is available");
1979        windowing.can_list_windows = true;
1980        windowing.can_focus_apps = true;
1981        windowing.can_focus_windows = true;
1982        let input = input_report(true);
1983
1984        let readiness = readiness_report(
1985            &platform,
1986            &portal_report(Check::fail("missing")),
1987            &accessibility,
1988            &windowing,
1989            &input,
1990        );
1991
1992        assert!(readiness.can_query_windows);
1993        assert!(readiness.can_focus_apps);
1994        assert!(readiness.can_focus_windows);
1995        assert!(readiness.blockers.is_empty());
1996    }
1997
1998    #[test]
1999    fn readiness_message_mentions_generic_window_targeting() {
2000        let platform = platform_report();
2001        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2002        let windowing = windowing_report(true, true);
2003        let input = input_report(true);
2004
2005        let readiness = readiness_report(
2006            &platform,
2007            &portal_report(Check::fail("missing")),
2008            &accessibility,
2009            &windowing,
2010            &input,
2011        );
2012
2013        assert!(readiness.blockers.is_empty());
2014        assert!(readiness
2015            .recommended_next_step
2016            .contains("AT-SPI tree support"));
2017        assert!(readiness.recommended_next_step.contains("window targeting"));
2018        assert!(!readiness
2019            .recommended_next_step
2020            .contains("GNOME window targeting"));
2021    }
2022
2023    #[test]
2024    fn readiness_accepts_connectable_ydotool_socket_without_direct_uinput_access() {
2025        let platform = platform_report();
2026        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2027        let windowing = windowing_report(true, true);
2028        let input = input_report_parts(
2029            Check::ok("ydotool"),
2030            Check::ok("ydotoold"),
2031            Check::ok("connectable: /tmp/.ydotool_socket"),
2032            Check::fail("/dev/uinput: Permission denied"),
2033        );
2034
2035        let readiness = readiness_report(
2036            &platform,
2037            &portal_report(Check::fail("missing")),
2038            &accessibility,
2039            &windowing,
2040            &input,
2041        );
2042
2043        assert!(readiness.can_send_development_input);
2044        assert!(readiness.blockers.is_empty());
2045    }
2046
2047    #[test]
2048    fn readiness_uses_connectable_ydotool_socket_when_process_probe_fails() {
2049        let platform = platform_report();
2050        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2051        let windowing = windowing_report(true, true);
2052        let input = input_report_parts(
2053            Check::ok("ydotool"),
2054            Check::fail("ydotoold process name not found"),
2055            Check::ok("connectable: /run/user/1000/.ydotool_socket"),
2056            Check::fail("/dev/uinput: Permission denied"),
2057        );
2058        let portals = portal_report(Check::fail("missing"));
2059
2060        let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2061        let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2062
2063        assert!(capabilities
2064            .input
2065            .iter()
2066            .any(|backend| backend == "ydotool"));
2067        assert!(readiness.can_send_development_input);
2068    }
2069
2070    #[test]
2071    fn wayland_readiness_rejects_pointer_only_uinput_without_keyboard_backend() {
2072        let platform = platform_report();
2073        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2074        let windowing = windowing_report(true, true);
2075        let input = input_report_parts(
2076            Check::ok("ydotool"),
2077            Check::fail("ydotoold not running"),
2078            Check::fail("no connectable ydotool socket"),
2079            Check::ok("read/write: /dev/uinput"),
2080        );
2081
2082        let readiness = readiness_report(
2083            &platform,
2084            &portal_report(Check::fail("missing")),
2085            &accessibility,
2086            &windowing,
2087            &input,
2088        );
2089
2090        assert!(!readiness.can_send_development_input);
2091        assert!(readiness
2092            .blockers
2093            .iter()
2094            .any(|blocker| blocker.contains("absolute pointer input")));
2095        assert!(readiness
2096            .recommended_next_step
2097            .contains("keyboard-capable input backend"));
2098    }
2099
2100    #[test]
2101    fn x11_readiness_rejects_pointer_only_uinput_without_keyboard_backend() {
2102        let mut platform = platform_report();
2103        platform.xdg_session_type = Some("x11".to_string());
2104        platform.wayland_display = None;
2105        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2106        let windowing = windowing_report(true, true);
2107        let input = input_report_parts(
2108            Check::fail("missing ydotool"),
2109            Check::fail("ydotoold not running"),
2110            Check::fail("no connectable ydotool socket"),
2111            Check::ok("read/write: /dev/uinput"),
2112        );
2113
2114        let readiness = readiness_report(
2115            &platform,
2116            &portal_report(Check::fail("missing")),
2117            &accessibility,
2118            &windowing,
2119            &input,
2120        );
2121
2122        assert!(!readiness.can_send_development_input);
2123    }
2124
2125    #[test]
2126    fn readiness_accepts_remote_desktop_portal_without_local_input_backend() {
2127        let platform = platform_report();
2128        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2129        let windowing = windowing_report(true, true);
2130        let input = input_report_parts(
2131            Check::fail("missing ydotool"),
2132            Check::fail("ydotoold not running"),
2133            Check::fail("no connectable ydotool socket"),
2134            Check::fail("/dev/uinput: Permission denied"),
2135        );
2136
2137        let readiness = readiness_report(
2138            &platform,
2139            &portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop")),
2140            &accessibility,
2141            &windowing,
2142            &input,
2143        );
2144
2145        assert!(readiness.can_send_development_input);
2146        assert!(readiness.blockers.is_empty());
2147    }
2148
2149    #[test]
2150    fn readiness_rejects_inaccessible_ydotool_paths() {
2151        let platform = platform_report();
2152        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2153        let windowing = windowing_report(true, true);
2154        let input = input_report_parts(
2155            Check::ok("ydotool"),
2156            Check::ok("ydotoold"),
2157            Check::fail("/tmp/.ydotool_socket: Permission denied"),
2158            Check::fail("/dev/uinput: Permission denied"),
2159        );
2160
2161        let readiness = readiness_report(
2162            &platform,
2163            &portal_report(Check::fail("missing")),
2164            &accessibility,
2165            &windowing,
2166            &input,
2167        );
2168
2169        assert!(!readiness.can_send_development_input);
2170        assert!(readiness
2171            .recommended_next_step
2172            .contains("Enable a keyboard-capable input backend"));
2173        assert!(readiness
2174            .blockers
2175            .iter()
2176            .any(|blocker| blocker.contains("Development keyboard input is unavailable")));
2177    }
2178
2179    #[test]
2180    fn capability_map_rejects_incompatible_ydotool_with_live_daemon() {
2181        let platform = platform_report();
2182        let portals = portal_report(Check::fail("missing"));
2183        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2184        let windowing = windowing_report(true, true);
2185        let input = input_report_parts(
2186            Check::fail("unsupported legacy ydotool CLI"),
2187            Check::ok("ydotoold"),
2188            Check::ok("connectable socket"),
2189            Check::fail("/dev/uinput: Permission denied"),
2190        );
2191
2192        let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2193        let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2194
2195        assert!(!capabilities
2196            .input
2197            .iter()
2198            .any(|backend| backend == "ydotool"));
2199        assert!(!readiness.can_send_development_input);
2200    }
2201
2202    #[test]
2203    fn ydotool_socket_check_rejects_legacy_stream_socket() {
2204        let dir = std::env::temp_dir().join(format!(
2205            "computer-use-linux-diagnostics-{}",
2206            std::process::id()
2207        ));
2208        let _ = std::fs::remove_dir_all(&dir);
2209        std::fs::create_dir_all(&dir).expect("create temp diagnostics dir");
2210        let socket = dir.join("ydotool.sock");
2211        let listener =
2212            std::os::unix::net::UnixListener::bind(&socket).expect("bind temp diagnostics socket");
2213
2214        let check = socket_connect_check(&socket);
2215
2216        assert!(!check.ok, "{check:?}");
2217        drop(listener);
2218        let _ = std::fs::remove_dir_all(&dir);
2219    }
2220
2221    #[test]
2222    fn ydotool_socket_check_accepts_datagram_socket() {
2223        let dir = std::env::temp_dir().join(format!(
2224            "computer-use-linux-diagnostics-dgram-{}",
2225            std::process::id()
2226        ));
2227        let _ = std::fs::remove_dir_all(&dir);
2228        std::fs::create_dir_all(&dir).expect("create temp diagnostics dir");
2229        let socket = dir.join("ydotool.sock");
2230        let datagram =
2231            std::os::unix::net::UnixDatagram::bind(&socket).expect("bind temp datagram socket");
2232
2233        let check = socket_connect_check(&socket);
2234
2235        assert!(check.ok, "{check:?}");
2236        drop(datagram);
2237        let _ = std::fs::remove_dir_all(&dir);
2238    }
2239
2240    #[test]
2241    fn readiness_reports_cosmic_window_blocker_on_cosmic() {
2242        let mut platform = platform_report();
2243        platform.xdg_current_desktop = Some("COSMIC".to_string());
2244        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2245        let windowing = windowing_report(false, false);
2246        let input = input_report(true);
2247
2248        let readiness = readiness_report(
2249            &platform,
2250            &portal_report(Check::fail("missing")),
2251            &accessibility,
2252            &windowing,
2253            &input,
2254        );
2255
2256        assert!(readiness
2257            .blockers
2258            .iter()
2259            .any(|blocker| blocker.contains("COSMIC Wayland window introspection")));
2260    }
2261}