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"];
35
36#[derive(Debug, Clone, Serialize, JsonSchema)]
37pub struct DoctorReport {
38    pub platform: PlatformReport,
39    pub portals: PortalReport,
40    pub accessibility: AccessibilityReport,
41    pub windowing: WindowingReport,
42    pub input: InputReport,
43    pub readiness: ReadinessReport,
44    /// Which interchangeable backends this environment supports, per layer, plus
45    /// the one the tool prefers. Lets an agent (or selector) understand what's
46    /// available and choose accordingly instead of assuming one fixed path.
47    pub capabilities: CapabilityMap,
48}
49
50#[derive(Debug, Clone, Serialize, JsonSchema)]
51pub struct CapabilityMap {
52    /// Pointer/keyboard injection backends, best-first.
53    pub input: Vec<String>,
54    /// Screen capture backends, best-first.
55    pub screenshot: Vec<String>,
56    /// Window listing/focus backends available.
57    pub window_control: Vec<String>,
58    /// Accessibility (element-targeted, non-pointer) backends.
59    pub accessibility: Vec<String>,
60    /// Display/session isolation contexts the host can provide.
61    pub isolation: Vec<String>,
62    /// The backend the tool will use by default for each selectable layer.
63    pub preferred: PreferredBackends,
64}
65
66#[derive(Debug, Clone, Serialize, JsonSchema)]
67pub struct PreferredBackends {
68    pub input: Option<String>,
69    pub screenshot: Option<String>,
70    pub window_control: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, JsonSchema)]
74pub struct PlatformReport {
75    pub os: String,
76    pub arch: String,
77    pub desktop_session: Option<String>,
78    pub xdg_session_type: Option<String>,
79    pub xdg_current_desktop: Option<String>,
80    pub wayland_display: Option<String>,
81    pub display: Option<String>,
82    pub xauthority: Option<String>,
83    pub dbus_session_bus_address: Option<String>,
84    pub xdg_runtime_dir: Option<String>,
85    pub gnome_shell_version: Check,
86    pub gnome_screenshot: Check,
87}
88
89#[derive(Debug, Clone, Serialize, JsonSchema)]
90pub struct PortalReport {
91    pub desktop_portal: Check,
92    pub remote_desktop: Check,
93    pub screencast: Check,
94    pub screenshot: Check,
95    pub input_capture: Check,
96    pub mutter_remote_desktop: Check,
97    pub mutter_screencast: Check,
98}
99
100#[derive(Debug, Clone, Serialize, JsonSchema)]
101pub struct AccessibilityReport {
102    pub at_spi_bus: Check,
103    pub toolkit_accessibility: Check,
104    pub at_spi_enabled: Check,
105    pub screen_reader_enabled: Check,
106}
107
108#[derive(Debug, Clone, Serialize, JsonSchema)]
109pub struct WindowingReport {
110    pub gnome_shell_introspect: Check,
111    pub computer_use_linux_gnome_shell_extension: Check,
112    pub cosmic_helper: Check,
113    pub kwin: Check,
114    pub hyprland: Check,
115    pub backends: BTreeMap<String, Check>,
116    pub can_list_windows: bool,
117    pub can_focus_apps: bool,
118    pub can_focus_windows: bool,
119    pub note: String,
120}
121
122#[derive(Debug, Clone, Serialize, JsonSchema)]
123pub struct InputReport {
124    pub ydotool: Check,
125    pub ydotoold: Check,
126    pub ydotool_socket: Check,
127    pub uinput: Check,
128    /// X11 XTEST keyboard backend. Preferred over ydotool on X11 sessions,
129    /// where raw evdev scancodes are re-mapped by the active XKB layout.
130    pub xdotool: Check,
131}
132
133#[derive(Debug, Clone, Serialize, JsonSchema)]
134pub struct ReadinessReport {
135    pub can_register_mcp_tools: bool,
136    pub can_build_accessibility_tree: bool,
137    pub can_query_windows: bool,
138    pub can_focus_apps: bool,
139    pub can_focus_windows: bool,
140    pub can_send_development_input: bool,
141    pub recommended_next_step: String,
142    pub blockers: Vec<String>,
143}
144
145#[derive(Debug, Clone, Serialize, JsonSchema)]
146pub struct SetupReport {
147    pub before: DoctorReport,
148    pub accessibility_command: Check,
149    pub after: DoctorReport,
150    pub changed_accessibility: bool,
151    pub requires_target_app_restart: bool,
152    pub message: String,
153}
154
155#[derive(Debug, Clone, Serialize, JsonSchema)]
156pub struct Check {
157    pub ok: bool,
158    pub detail: String,
159}
160
161impl Check {
162    fn ok(detail: impl Into<String>) -> Self {
163        Self {
164            ok: true,
165            detail: detail.into(),
166        }
167    }
168
169    fn fail(detail: impl Into<String>) -> Self {
170        Self {
171            ok: false,
172            detail: detail.into(),
173        }
174    }
175}
176
177pub fn doctor_report() -> DoctorReport {
178    hydrate_session_bus_env();
179
180    let platform = platform_report();
181    let portals = portal_report();
182    let accessibility = accessibility_report();
183    let windowing = windowing_report(&platform);
184    let input = input_report();
185    let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
186
187    let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
188
189    DoctorReport {
190        platform,
191        portals,
192        accessibility,
193        windowing,
194        input,
195        readiness,
196        capabilities,
197    }
198}
199
200/// Derive the per-layer backend capability map from the individual checks. Lists
201/// are ordered best-first and mirror the order the tool actually tries them.
202fn capability_map(
203    platform: &PlatformReport,
204    portals: &PortalReport,
205    accessibility: &AccessibilityReport,
206    windowing: &WindowingReport,
207    input: &InputReport,
208) -> CapabilityMap {
209    let mut input_backends = Vec::new();
210    // Absolute uinput pointer: accurate, non-blocking of coordinates; preferred.
211    if input.uinput.ok {
212        input_backends.push("abs_pointer".to_string());
213    }
214    let force_ydotool = env_flag_enabled_any(FORCE_YDOTOOL_KEYBOARD_ENV_KEYS);
215    let force_xdotool = env_flag_enabled_any(FORCE_XDOTOOL_KEYBOARD_ENV_KEYS);
216    let portal_available = portal_input_available(platform, portals);
217    let portal_forced_for_all_input = force_portal_for_all_input(
218        env_flag_enabled_any(FORCE_PORTAL_POINTER_ENV_KEYS),
219        env_flag_enabled_any(FORCE_PORTAL_KEYBOARD_ENV_KEYS),
220        env_flag_enabled_any(FORCE_YDOTOOL_POINTER_ENV_KEYS),
221        force_ydotool,
222    );
223    if should_advertise_xdotool(platform, input, force_ydotool, force_xdotool) {
224        input_backends.push("xdotool".to_string());
225    }
226    if portal_available && portal_forced_for_all_input {
227        input_backends.push("portal".to_string());
228    }
229    if input.ydotool.ok && input.ydotool_socket.ok {
230        input_backends.push("ydotool".to_string());
231    }
232    if portal_available && !portal_forced_for_all_input {
233        input_backends.push("portal".to_string());
234    }
235
236    let mut screenshot_backends = Vec::new();
237    if platform.gnome_shell_version.ok {
238        screenshot_backends.push("gnome_shell".to_string());
239    }
240    if portals.screenshot.ok {
241        screenshot_backends.push("portal".to_string());
242    }
243    // Subprocess fallback for background/systemd contexts the DBus paths reject.
244    if platform.gnome_screenshot.ok {
245        screenshot_backends.push("gnome_screenshot".to_string());
246    }
247
248    let mut window_backends = Vec::new();
249    let x11_available = windowing
250        .backends
251        .get(X11_BACKEND)
252        .is_some_and(|check| check.ok);
253    let prefer_x11_over_introspect = windowing.gnome_shell_introspect.ok
254        && x11_available
255        && registry::backend_can_exact_focus(X11_BACKEND);
256    if windowing.computer_use_linux_gnome_shell_extension.ok {
257        window_backends.push("gnome_shell_extension".to_string());
258    }
259    if prefer_x11_over_introspect {
260        window_backends.push(X11_BACKEND.to_string());
261    }
262    if windowing.gnome_shell_introspect.ok {
263        window_backends.push("gnome_introspect".to_string());
264    }
265    if windowing.cosmic_helper.ok {
266        window_backends.push("cosmic".to_string());
267    }
268    if windowing.kwin.ok {
269        window_backends.push("kwin".to_string());
270    }
271    if windowing.hyprland.ok {
272        window_backends.push("hyprland".to_string());
273    }
274    // i3 and the generic X11/EWMH backend have no dedicated WindowingReport
275    // field; read them from the probe map (tried last) so the capability list
276    // matches the backends the registry will actually use.
277    if windowing
278        .backends
279        .get(I3_BACKEND)
280        .is_some_and(|check| check.ok)
281    {
282        window_backends.push(I3_BACKEND.to_string());
283    }
284    if x11_available && !prefer_x11_over_introspect {
285        window_backends.push(X11_BACKEND.to_string());
286    }
287
288    let mut accessibility_backends = Vec::new();
289    if accessibility.at_spi_enabled.ok || accessibility.toolkit_accessibility.ok {
290        accessibility_backends.push("at_spi".to_string());
291    }
292
293    // Isolation contexts: the live shared session is always available; a headless
294    // GNOME session is possible when gnome-shell is installed (it supports
295    // --headless --virtual-monitor), giving the agent its own seat.
296    let mut isolation = vec!["shared".to_string()];
297    if platform.gnome_shell_version.ok {
298        isolation.push("headless_gnome".to_string());
299    }
300
301    let preferred = PreferredBackends {
302        input: input_backends.first().cloned(),
303        screenshot: screenshot_backends.first().cloned(),
304        window_control: window_backends.first().cloned(),
305    };
306
307    CapabilityMap {
308        input: input_backends,
309        screenshot: screenshot_backends,
310        window_control: window_backends,
311        accessibility: accessibility_backends,
312        isolation,
313        preferred,
314    }
315}
316
317pub fn hydrate_session_bus_env() {
318    hydrate_common_command_path();
319    hydrate_desktop_env_from_process_tree();
320    hydrate_desktop_env_from_systemd_user();
321
322    if env_var("XDG_RUNTIME_DIR").is_none() {
323        if let Some(runtime) = xdg_runtime_dir() {
324            if runtime.exists() {
325                env::set_var("XDG_RUNTIME_DIR", runtime);
326            }
327        }
328    }
329
330    if env_var("DBUS_SESSION_BUS_ADDRESS").is_none() {
331        if let Some(runtime) = xdg_runtime_dir() {
332            let bus = runtime.join("bus");
333            if bus.exists() {
334                env::set_var(
335                    "DBUS_SESSION_BUS_ADDRESS",
336                    format!("unix:path={}", bus.display()),
337                );
338            }
339        }
340    }
341}
342
343fn hydrate_common_command_path() {
344    let mut entries = env::var_os("PATH")
345        .map(|path| env::split_paths(&path).collect::<Vec<_>>())
346        .unwrap_or_default();
347    for path in [
348        "/run/current-system/sw/bin",
349        "/usr/local/bin",
350        "/usr/bin",
351        "/bin",
352    ] {
353        let path = PathBuf::from(path);
354        if path.exists() && !entries.iter().any(|entry| entry == &path) {
355            entries.push(path);
356        }
357    }
358    if let Ok(path) = env::join_paths(entries) {
359        env::set_var("PATH", path);
360    }
361}
362
363fn hydrate_desktop_env_from_process_tree() {
364    for process_env in desktop_process_environments() {
365        hydrate_desktop_env_from_map(&process_env);
366
367        if DESKTOP_ENV_KEYS.iter().all(|key| env_var(key).is_some()) {
368            break;
369        }
370    }
371}
372
373fn hydrate_desktop_env_from_systemd_user() {
374    let Ok(output) = Command::new("systemctl")
375        .args(["--user", "show-environment"])
376        .output()
377    else {
378        return;
379    };
380    if !output.status.success() {
381        return;
382    }
383    let env_map = parse_line_environment(&output.stdout);
384    hydrate_desktop_env_from_map(&env_map);
385}
386
387fn hydrate_desktop_env_from_map(process_env: &HashMap<String, String>) {
388    let current_env = DESKTOP_ENV_KEYS
389        .iter()
390        .filter_map(|key| env_var(key).map(|value| ((*key).to_string(), value)))
391        .collect();
392    for (key, value) in desktop_env_hydration_updates(&current_env, process_env) {
393        env::set_var(key, value);
394    }
395}
396
397fn desktop_env_hydration_updates(
398    current_env: &HashMap<String, String>,
399    source_env: &HashMap<String, String>,
400) -> Vec<(&'static str, String)> {
401    // A nested X11 desktop can share a user manager with a Wayland host.
402    // Preserve its complete process-local session instead of grafting the
403    // host's WAYLAND_DISPLAY onto it.
404    let preserve_native_x11 = current_env
405        .get("XDG_SESSION_TYPE")
406        .is_some_and(|value| value.trim().eq_ignore_ascii_case("x11"))
407        && current_env
408            .get("DISPLAY")
409            .is_some_and(|value| !value.trim().is_empty())
410        && !current_env
411            .get("WAYLAND_DISPLAY")
412            .is_some_and(|value| !value.trim().is_empty());
413
414    DESKTOP_ENV_KEYS
415        .iter()
416        .filter_map(|key| {
417            if current_env
418                .get(*key)
419                .is_some_and(|value| !value.trim().is_empty())
420                || preserve_native_x11 && *key == "WAYLAND_DISPLAY"
421            {
422                return None;
423            }
424            source_env
425                .get(*key)
426                .filter(|value| !value.trim().is_empty())
427                .map(|value| (*key, value.clone()))
428        })
429        .collect()
430}
431
432fn desktop_process_environments() -> Vec<HashMap<String, String>> {
433    let mut environments = Vec::new();
434    let mut visited_pids = Vec::new();
435    let mut pid = parent_pid("self");
436
437    for _ in 0..8 {
438        let Some(current_pid) = pid else {
439            break;
440        };
441        if current_pid <= 1 {
442            break;
443        }
444
445        visited_pids.push(current_pid);
446        if let Some(process_env) = read_process_environ(current_pid) {
447            environments.push(process_env);
448        }
449        pid = parent_pid(&current_pid.to_string());
450    }
451
452    if !visited_pids.contains(&1) && process_owner_matches_current_user(1) {
453        if let Some(process_env) = read_process_environ(1).filter(process_env_has_graphical_display)
454        {
455            environments.push(process_env);
456        }
457    }
458
459    environments
460}
461
462fn parent_pid(pid: &str) -> Option<u32> {
463    let status = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
464    parse_parent_pid(&status)
465}
466
467fn parse_parent_pid(status: &str) -> Option<u32> {
468    status.lines().find_map(|line| {
469        let value = line.strip_prefix("PPid:")?.trim();
470        value.parse::<u32>().ok()
471    })
472}
473
474fn read_process_environ(pid: u32) -> Option<HashMap<String, String>> {
475    let bytes = fs::read(format!("/proc/{pid}/environ")).ok()?;
476    Some(parse_environ(&bytes))
477}
478
479fn process_owner_matches_current_user(pid: u32) -> bool {
480    let Some(current_uid) = user_id().and_then(|uid| uid.parse::<u32>().ok()) else {
481        return false;
482    };
483    fs::metadata(format!("/proc/{pid}"))
484        .ok()
485        .is_some_and(|metadata| metadata.uid() == current_uid)
486}
487
488fn process_env_has_graphical_display(process_env: &HashMap<String, String>) -> bool {
489    process_env
490        .get("DISPLAY")
491        .or_else(|| process_env.get("WAYLAND_DISPLAY"))
492        .is_some_and(|value| !value.trim().is_empty())
493}
494
495fn parse_environ(bytes: &[u8]) -> HashMap<String, String> {
496    bytes
497        .split(|byte| *byte == 0)
498        .filter_map(|entry| {
499            if entry.is_empty() {
500                return None;
501            }
502            let split = entry.iter().position(|byte| *byte == b'=')?;
503            let (key, value) = entry.split_at(split);
504            let value = &value[1..];
505            let key = std::str::from_utf8(key).ok()?.to_string();
506            let value = std::str::from_utf8(value).ok()?.to_string();
507            Some((key, value))
508        })
509        .collect()
510}
511
512fn parse_line_environment(bytes: &[u8]) -> HashMap<String, String> {
513    bytes
514        .split(|byte| *byte == b'\n')
515        .filter_map(|entry| {
516            if entry.is_empty() {
517                return None;
518            }
519            let split = entry.iter().position(|byte| *byte == b'=')?;
520            let (key, value) = entry.split_at(split);
521            let value = &value[1..];
522            let key = std::str::from_utf8(key).ok()?.to_string();
523            let value = std::str::from_utf8(value).ok()?.to_string();
524            Some((key, value))
525        })
526        .collect()
527}
528
529pub fn setup_accessibility_report() -> SetupReport {
530    hydrate_session_bus_env();
531
532    let before = doctor_report();
533    let accessibility_command = if can_build_accessibility_tree(&before.accessibility) {
534        Check::ok("AT-SPI accessibility is already enabled")
535    } else {
536        let atspi_status = command_check_with_session_bus(
537            "busctl",
538            &[
539                "--user",
540                "set-property",
541                "org.a11y.Bus",
542                "/org/a11y/bus",
543                "org.a11y.Status",
544                "IsEnabled",
545                "b",
546                "true",
547            ],
548        );
549        if atspi_status.ok {
550            atspi_status
551        } else {
552            command_check_with_session_bus(
553                "gsettings",
554                &[
555                    "set",
556                    "org.gnome.desktop.interface",
557                    "toolkit-accessibility",
558                    "true",
559                ],
560            )
561        }
562    };
563    let after = doctor_report();
564    let before_ready = before.readiness.can_build_accessibility_tree;
565    let after_ready = after.readiness.can_build_accessibility_tree;
566    let changed_accessibility = !before_ready && after_ready;
567    let requires_target_app_restart = changed_accessibility;
568    let message = if after_ready {
569        if changed_accessibility {
570            "AT-SPI accessibility is enabled. Restart already-running target apps if their AT-SPI tree is still empty."
571        } else {
572            "AT-SPI accessibility is ready."
573        }
574    } else {
575        "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."
576    }
577    .to_string();
578
579    SetupReport {
580        before,
581        accessibility_command,
582        after,
583        changed_accessibility,
584        requires_target_app_restart,
585        message,
586    }
587}
588
589fn platform_report() -> PlatformReport {
590    PlatformReport {
591        os: std::env::consts::OS.to_string(),
592        arch: std::env::consts::ARCH.to_string(),
593        desktop_session: env_var("DESKTOP_SESSION"),
594        xdg_session_type: env_var("XDG_SESSION_TYPE"),
595        xdg_current_desktop: env_var("XDG_CURRENT_DESKTOP"),
596        wayland_display: env_var("WAYLAND_DISPLAY"),
597        display: env_var("DISPLAY"),
598        xauthority: env_var("XAUTHORITY"),
599        dbus_session_bus_address: dbus_session_address(),
600        xdg_runtime_dir: xdg_runtime_dir().map(|path| path.display().to_string()),
601        gnome_shell_version: command_check("gnome-shell", &["--version"]),
602        gnome_screenshot: command_check("gnome-screenshot", &["--version"]),
603    }
604}
605
606fn portal_report() -> PortalReport {
607    PortalReport {
608        desktop_portal: bus_name_check("org.freedesktop.portal.Desktop"),
609        remote_desktop: portal_interface_check("org.freedesktop.portal.RemoteDesktop"),
610        screencast: portal_interface_check("org.freedesktop.portal.ScreenCast"),
611        screenshot: portal_interface_check("org.freedesktop.portal.Screenshot"),
612        input_capture: portal_interface_check("org.freedesktop.portal.InputCapture"),
613        mutter_remote_desktop: bus_name_check("org.gnome.Mutter.RemoteDesktop"),
614        mutter_screencast: bus_name_check("org.gnome.Mutter.ScreenCast"),
615    }
616}
617
618fn accessibility_report() -> AccessibilityReport {
619    AccessibilityReport {
620        at_spi_bus: atspi_bus_address_check(),
621        toolkit_accessibility: command_check_with_session_bus(
622            "gsettings",
623            &[
624                "get",
625                "org.gnome.desktop.interface",
626                "toolkit-accessibility",
627            ],
628        ),
629        at_spi_enabled: atspi_status_property_check("IsEnabled"),
630        screen_reader_enabled: atspi_status_property_check("ScreenReaderEnabled"),
631    }
632}
633
634fn windowing_report(platform: &PlatformReport) -> WindowingReport {
635    let probes = registry::probe_backends();
636    let backend_check = |id: &str| {
637        probes
638            .iter()
639            .find(|probe| probe.id == id)
640            .map(check_from_backend_probe)
641            .unwrap_or_else(|| Check::fail("backend probe did not run"))
642    };
643    let gnome_shell_introspect = backend_check(GNOME_SHELL_INTROSPECT_BACKEND);
644    let computer_use_linux_gnome_shell_extension = backend_check(GNOME_SHELL_EXTENSION_BACKEND);
645    let cosmic_helper = backend_check(COSMIC_WAYLAND_BACKEND);
646    let kwin = backend_check(KWIN_BACKEND);
647    let hyprland = backend_check(HYPRLAND_BACKEND);
648    let backends = probes
649        .iter()
650        .map(|probe| (probe.id.to_string(), check_from_backend_probe(probe)))
651        .collect::<BTreeMap<_, _>>();
652    let can_list_windows = probes.iter().any(|probe| probe.can_list_windows);
653    let can_focus_apps = probes.iter().any(|probe| probe.can_focus_apps);
654    let can_focus_windows = probes.iter().any(|probe| probe.can_focus_windows);
655    let note = if can_list_windows {
656        if !can_focus_windows {
657            "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)."
658        } else if cosmic_helper.ok && is_cosmic_wayland_platform(platform) {
659            "A COSMIC Wayland window backend is available for list_windows, focused_window, and targeted input verification."
660        } else if kwin.ok {
661            "A KWin/Plasma window backend is available for list_windows, focused_window, and targeted input verification."
662        } else if hyprland.ok {
663            "A Hyprland window backend is available for list_windows, focused_window, and targeted input verification."
664        } else {
665            "A window listing backend is available for list_windows, focused_window, and targeted input verification."
666        }
667    } else {
668        "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."
669    }
670    .to_string();
671
672    WindowingReport {
673        gnome_shell_introspect,
674        computer_use_linux_gnome_shell_extension,
675        cosmic_helper,
676        kwin,
677        hyprland,
678        backends,
679        can_list_windows,
680        can_focus_apps,
681        can_focus_windows,
682        note,
683    }
684}
685
686fn check_from_backend_probe(probe: &registry::BackendProbe) -> Check {
687    if probe.ok {
688        Check::ok(probe.detail.clone())
689    } else {
690        Check::fail(probe.detail.clone())
691    }
692}
693
694fn input_report() -> InputReport {
695    InputReport {
696        ydotool: match ydotool::ensure_supported() {
697            Ok(support) => Check::ok(support.detail),
698            Err(detail) => Check::fail(detail),
699        },
700        ydotoold: process_check("ydotoold"),
701        ydotool_socket: ydotool_socket_check(),
702        uinput: read_write_path_check(Path::new("/dev/uinput")),
703        xdotool: command_path_check("xdotool"),
704    }
705}
706
707fn readiness_report(
708    platform: &PlatformReport,
709    portals: &PortalReport,
710    accessibility: &AccessibilityReport,
711    windowing: &WindowingReport,
712    input: &InputReport,
713) -> ReadinessReport {
714    let mut blockers = Vec::new();
715    let can_build_accessibility_tree = can_build_accessibility_tree(accessibility);
716    let can_query_windows = windowing.can_list_windows;
717    let can_focus_apps = windowing.can_focus_apps;
718    let can_focus_windows = windowing.can_focus_windows;
719    let can_send_development_input = can_send_development_input(platform, portals, input);
720
721    if !can_build_accessibility_tree {
722        blockers.push(
723            "AT-SPI accessibility is disabled; enable org.a11y.Status IsEnabled or org.gnome.desktop.interface toolkit-accessibility for tree extraction."
724                .to_string(),
725        );
726    }
727
728    if !can_query_windows {
729        blockers.push(if is_cosmic_wayland_platform(platform) {
730            "COSMIC Wayland window introspection is unavailable; targeted window focus and verification will be disabled.".to_string()
731        } else {
732            "Window introspection is unavailable; targeted window focus and verification will be disabled."
733                .to_string()
734        });
735    }
736
737    if can_query_windows && !can_focus_windows {
738        blockers.push(
739            "Exact window activation is unavailable; app-level focus may work, but window_id/title/terminal-targeted input cannot be verified."
740                .to_string(),
741        );
742    }
743
744    if !can_send_development_input {
745        blockers.push(
746            "Development input is unavailable; enable read/write /dev/uinput, XDG RemoteDesktop portal input on Wayland, xdotool with DISPLAY on X11, or ydotool with a connectable ydotoold socket."
747                .to_string(),
748        );
749    }
750
751    let recommended_next_step = if !can_build_accessibility_tree {
752        "Run setup_accessibility to enable AT-SPI accessibility before element-aware actions."
753            .to_string()
754    } else if !can_query_windows {
755        format!(
756            "Enable a supported window backend before using targeted keyboard input: {}",
757            registry::descriptors()
758                .iter()
759                .map(|descriptor| descriptor.missing_hint)
760                .collect::<Vec<_>>()
761                .join(" ")
762        )
763    } else if !can_focus_windows {
764        "Enable an exact-focus window backend before using window_id, title, or terminal-targeted input.".to_string()
765    } else if !can_send_development_input {
766        "Enable a supported input backend: grant read/write /dev/uinput, enable the XDG RemoteDesktop portal on Wayland, install xdotool for X11, or start ydotoold with a socket accessible to this desktop user."
767            .to_string()
768    } else {
769        "Computer Use is ready: AT-SPI tree support, window targeting, and a Linux input backend are available."
770            .to_string()
771    };
772
773    ReadinessReport {
774        can_register_mcp_tools: true,
775        can_build_accessibility_tree,
776        can_query_windows,
777        can_focus_apps,
778        can_focus_windows,
779        can_send_development_input,
780        recommended_next_step,
781        blockers,
782    }
783}
784
785fn can_send_development_input(
786    platform: &PlatformReport,
787    portals: &PortalReport,
788    input: &InputReport,
789) -> bool {
790    let force_ydotool = env_flag_enabled_any(FORCE_YDOTOOL_KEYBOARD_ENV_KEYS);
791    let force_xdotool = env_flag_enabled_any(FORCE_XDOTOOL_KEYBOARD_ENV_KEYS);
792    input.uinput.ok
793        || portal_input_available(platform, portals)
794        || should_advertise_xdotool(platform, input, force_ydotool, force_xdotool)
795        || input.ydotool.ok && input.ydotool_socket.ok
796}
797
798fn portal_input_available(platform: &PlatformReport, portals: &PortalReport) -> bool {
799    platform_is_wayland(platform) && portals.remote_desktop.ok
800}
801
802fn is_cosmic_wayland_platform(platform: &PlatformReport) -> bool {
803    platform
804        .xdg_current_desktop
805        .as_deref()
806        .is_some_and(|desktop| desktop.to_ascii_lowercase().contains("cosmic"))
807        && platform.xdg_session_type.as_deref() == Some("wayland")
808}
809
810fn can_build_accessibility_tree(accessibility: &AccessibilityReport) -> bool {
811    accessibility.at_spi_bus.ok
812        && (check_detail_contains_true(&accessibility.at_spi_enabled)
813            || check_detail_contains_true(&accessibility.toolkit_accessibility))
814}
815
816fn check_detail_contains_true(check: &Check) -> bool {
817    check.ok && check.detail.to_ascii_lowercase().contains("true")
818}
819
820fn env_var(key: &str) -> Option<String> {
821    env::var(key).ok().filter(|value| !value.trim().is_empty())
822}
823
824fn xdg_runtime_dir() -> Option<PathBuf> {
825    if let Some(value) = env_var("XDG_RUNTIME_DIR") {
826        return Some(PathBuf::from(value));
827    }
828    user_id().map(|uid| PathBuf::from(format!("/run/user/{uid}")))
829}
830
831fn dbus_session_address() -> Option<String> {
832    if let Some(value) = env_var("DBUS_SESSION_BUS_ADDRESS") {
833        return Some(value);
834    }
835    xdg_runtime_dir()
836        .map(|runtime| format!("unix:path={}", runtime.join("bus").display()))
837        .filter(|address| {
838            address
839                .strip_prefix("unix:path=")
840                .is_some_and(|p| Path::new(p).exists())
841        })
842}
843
844fn ydotool_socket_candidates() -> Vec<PathBuf> {
845    let mut candidates = Vec::new();
846    if let Some(value) = env_var("YDOTOOL_SOCKET") {
847        candidates.push(PathBuf::from(value));
848    }
849
850    if let Some(runtime_socket) = xdg_runtime_dir().map(|runtime| runtime.join(".ydotool_socket")) {
851        candidates.push(runtime_socket);
852    }
853    candidates.push(PathBuf::from("/tmp/.ydotool_socket"));
854    candidates
855}
856
857fn ydotool_socket_check() -> Check {
858    let mut checked = Vec::new();
859    for candidate in ydotool_socket_candidates() {
860        match socket_connect_result(&candidate) {
861            Ok(()) => return Check::ok(format!("connectable: {}", candidate.display())),
862            Err(detail) => checked.push(detail),
863        }
864    }
865
866    Check::fail(format!(
867        "no connectable ydotool socket ({})",
868        checked.join("; ")
869    ))
870}
871
872fn user_id() -> Option<String> {
873    let output = Command::new("id").arg("-u").output().ok()?;
874    output
875        .status
876        .success()
877        .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
878        .filter(|value| !value.is_empty())
879}
880
881fn command_path_check(command: &str) -> Check {
882    command_check("sh", &["-c", &format!("command -v {command}")])
883}
884
885fn platform_is_wayland(platform: &PlatformReport) -> bool {
886    match platform.xdg_session_type.as_deref() {
887        Some(value) => value.eq_ignore_ascii_case("wayland"),
888        None => platform
889            .wayland_display
890            .as_deref()
891            .is_some_and(|display| !display.trim().is_empty()),
892    }
893}
894
895fn should_advertise_xdotool(
896    platform: &PlatformReport,
897    input: &InputReport,
898    force_ydotool: bool,
899    force_xdotool: bool,
900) -> bool {
901    !force_ydotool
902        && input.xdotool.ok
903        && platform
904            .display
905            .as_deref()
906            .is_some_and(|display| !display.trim().is_empty())
907        && (force_xdotool || !platform_is_wayland(platform))
908}
909
910fn env_flag_enabled_any(keys: &[&str]) -> bool {
911    keys.iter()
912        .any(|key| env::var(key).ok().as_deref() == Some("1"))
913}
914
915fn force_portal_for_all_input(
916    force_portal_pointer: bool,
917    force_portal_keyboard: bool,
918    force_ydotool_pointer: bool,
919    force_ydotool_keyboard: bool,
920) -> bool {
921    force_portal_pointer
922        && force_portal_keyboard
923        && !force_ydotool_pointer
924        && !force_ydotool_keyboard
925}
926
927fn process_check(process_name: &str) -> Check {
928    command_check("pgrep", &["-a", process_name])
929}
930
931#[cfg(test)]
932fn socket_connect_check(path: &Path) -> Check {
933    match socket_connect_result(path) {
934        Ok(()) => Check::ok(format!("connectable: {}", path.display())),
935        Err(detail) => Check::fail(detail),
936    }
937}
938
939fn socket_connect_result(path: &Path) -> std::result::Result<(), String> {
940    if !path.exists() {
941        return Err(format!("missing: {}", path.display()));
942    }
943
944    UnixDatagram::unbound()
945        .and_then(|socket| socket.connect(path))
946        .map_err(|error| format!("{}: datagram: {error}", path.display()))
947}
948
949fn read_write_path_check(path: &Path) -> Check {
950    if !path.exists() {
951        return Check::fail(format!("missing: {}", path.display()));
952    }
953
954    match OpenOptions::new().read(true).write(true).open(path) {
955        Ok(_) => Check::ok(format!("read/write: {}", path.display())),
956        Err(error) => Check::fail(format!("{}: {error}", path.display())),
957    }
958}
959
960fn bus_name_check(name: &str) -> Check {
961    command_check_with_session_bus("busctl", &["--user", "status", name])
962}
963
964fn portal_interface_check(interface: &str) -> Check {
965    command_check_with_session_bus(
966        "busctl",
967        &[
968            "--user",
969            "introspect",
970            "org.freedesktop.portal.Desktop",
971            "/org/freedesktop/portal/desktop",
972            interface,
973        ],
974    )
975}
976
977fn atspi_bus_address_check() -> Check {
978    let busctl = command_check_with_session_bus(
979        "busctl",
980        &[
981            "--user",
982            "call",
983            "org.a11y.Bus",
984            "/org/a11y/bus",
985            "org.a11y.Bus",
986            "GetAddress",
987        ],
988    );
989    if busctl.ok {
990        return busctl;
991    }
992
993    gdbus_call_check(
994        "org.a11y.Bus",
995        "/org/a11y/bus",
996        "org.a11y.Bus.GetAddress",
997        &[],
998    )
999}
1000
1001fn atspi_status_property_check(property: &str) -> Check {
1002    let busctl = command_check_with_session_bus(
1003        "busctl",
1004        &[
1005            "--user",
1006            "get-property",
1007            "org.a11y.Bus",
1008            "/org/a11y/bus",
1009            "org.a11y.Status",
1010            property,
1011        ],
1012    );
1013    if busctl.ok {
1014        return busctl;
1015    }
1016
1017    gdbus_call_check(
1018        "org.a11y.Bus",
1019        "/org/a11y/bus",
1020        "org.freedesktop.DBus.Properties.Get",
1021        &["org.a11y.Status", property],
1022    )
1023}
1024
1025fn gdbus_call_check(destination: &str, object_path: &str, method: &str, args: &[&str]) -> Check {
1026    let mut command_args = vec![
1027        "call",
1028        "--session",
1029        "--dest",
1030        destination,
1031        "--object-path",
1032        object_path,
1033        "--method",
1034        method,
1035    ];
1036    command_args.extend_from_slice(args);
1037    command_check_with_session_bus("gdbus", &command_args)
1038}
1039
1040fn command_check(command: &str, args: &[&str]) -> Check {
1041    run_command(command, args, false)
1042}
1043
1044fn command_check_with_session_bus(command: &str, args: &[&str]) -> Check {
1045    run_command(command, args, true)
1046}
1047
1048fn run_command(command: &str, args: &[&str], with_session_bus: bool) -> Check {
1049    let mut cmd = Command::new(command);
1050    cmd.args(args);
1051
1052    if with_session_bus {
1053        if let Some(address) = dbus_session_address() {
1054            cmd.env("DBUS_SESSION_BUS_ADDRESS", address);
1055        }
1056        if let Some(runtime) = xdg_runtime_dir() {
1057            cmd.env("XDG_RUNTIME_DIR", runtime);
1058        }
1059    }
1060
1061    match cmd.output() {
1062        Ok(output) if output.status.success() => {
1063            let detail = String::from_utf8_lossy(&output.stdout).trim().to_string();
1064            Check::ok(if detail.is_empty() {
1065                "ok".into()
1066            } else {
1067                detail
1068            })
1069        }
1070        Ok(output) => {
1071            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1072            let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
1073            let detail = if !stderr.is_empty() { stderr } else { stdout };
1074            Check::fail(if detail.is_empty() {
1075                format!("exit status {}", output.status)
1076            } else {
1077                detail
1078            })
1079        }
1080        Err(error) => Check::fail(error.to_string()),
1081    }
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086    use super::*;
1087
1088    fn platform_report() -> PlatformReport {
1089        PlatformReport {
1090            os: "linux".to_string(),
1091            arch: "x86_64".to_string(),
1092            desktop_session: None,
1093            xdg_session_type: Some("wayland".to_string()),
1094            xdg_current_desktop: Some("GNOME".to_string()),
1095            wayland_display: Some("wayland-0".to_string()),
1096            display: Some(":0".to_string()),
1097            xauthority: Some("/run/user/1000/Xauthority".to_string()),
1098            dbus_session_bus_address: Some("unix:path=/run/user/1000/bus".to_string()),
1099            xdg_runtime_dir: Some("/run/user/1000".to_string()),
1100            gnome_shell_version: Check::ok("GNOME Shell 46.0"),
1101            gnome_screenshot: Check::ok("gnome-screenshot 41.0"),
1102        }
1103    }
1104
1105    fn portal_report(remote_desktop: Check) -> PortalReport {
1106        PortalReport {
1107            desktop_portal: Check::ok("ok"),
1108            remote_desktop,
1109            screencast: Check::fail("missing"),
1110            screenshot: Check::fail("missing"),
1111            input_capture: Check::fail("missing"),
1112            mutter_remote_desktop: Check::fail("missing"),
1113            mutter_screencast: Check::fail("missing"),
1114        }
1115    }
1116
1117    fn accessibility_report(
1118        at_spi_bus: Check,
1119        toolkit_accessibility: Check,
1120    ) -> AccessibilityReport {
1121        AccessibilityReport {
1122            at_spi_bus,
1123            toolkit_accessibility,
1124            at_spi_enabled: Check::fail("(<false>,)"),
1125            screen_reader_enabled: Check::fail("(<false>,)"),
1126        }
1127    }
1128
1129    fn windowing_report(can_list_windows: bool, can_focus_windows: bool) -> WindowingReport {
1130        WindowingReport {
1131            gnome_shell_introspect: if can_list_windows {
1132                Check::ok("ok")
1133            } else {
1134                Check::fail("denied")
1135            },
1136            computer_use_linux_gnome_shell_extension: if can_focus_windows {
1137                Check::ok("ok")
1138            } else {
1139                Check::fail("missing")
1140            },
1141            cosmic_helper: Check::fail("missing"),
1142            kwin: Check::fail("not a KWin session"),
1143            hyprland: Check::fail("not a Hyprland session"),
1144            backends: BTreeMap::new(),
1145            can_list_windows,
1146            can_focus_apps: true,
1147            can_focus_windows,
1148            note: String::new(),
1149        }
1150    }
1151
1152    fn input_report(can_send_input: bool) -> InputReport {
1153        let check = if can_send_input {
1154            Check::ok("ok")
1155        } else {
1156            Check::fail("missing")
1157        };
1158        input_report_parts(check.clone(), check.clone(), check.clone(), check)
1159    }
1160
1161    fn input_report_parts(
1162        ydotool: Check,
1163        ydotoold: Check,
1164        ydotool_socket: Check,
1165        uinput: Check,
1166    ) -> InputReport {
1167        InputReport {
1168            ydotool,
1169            ydotoold,
1170            ydotool_socket,
1171            uinput,
1172            xdotool: Check::fail("missing xdotool"),
1173        }
1174    }
1175
1176    #[test]
1177    fn accessibility_tree_requires_reachable_at_spi_bus() {
1178        let report = accessibility_report(Check::fail("permission denied"), Check::ok("true"));
1179
1180        assert!(!can_build_accessibility_tree(&report));
1181    }
1182
1183    #[test]
1184    fn accessibility_tree_is_ready_when_bus_and_toolkit_are_ready() {
1185        let report = accessibility_report(
1186            Check::ok("('unix:path=/run/user/1000/at-spi/bus',)"),
1187            Check::ok("true"),
1188        );
1189
1190        assert!(can_build_accessibility_tree(&report));
1191    }
1192
1193    #[test]
1194    fn parses_parent_pid_from_proc_status() {
1195        let status = "Name:\ttest\nPid:\t42\nPPid:\t7\n";
1196
1197        assert_eq!(parse_parent_pid(status), Some(7));
1198    }
1199
1200    #[test]
1201    fn parses_nul_separated_process_environment() {
1202        let environment = parse_environ(
1203            b"DISPLAY=:0\0WAYLAND_DISPLAY=wayland-0\0EMPTY=\0NO_EQUALS\0XDG_SESSION_TYPE=wayland\0",
1204        );
1205
1206        assert_eq!(environment.get("DISPLAY").map(String::as_str), Some(":0"));
1207        assert_eq!(
1208            environment.get("WAYLAND_DISPLAY").map(String::as_str),
1209            Some("wayland-0")
1210        );
1211        assert_eq!(environment.get("EMPTY").map(String::as_str), Some(""));
1212        assert!(!environment.contains_key("NO_EQUALS"));
1213    }
1214
1215    #[test]
1216    fn desktop_env_hydration_includes_xauthority() {
1217        assert!(DESKTOP_ENV_KEYS.contains(&"XAUTHORITY"));
1218    }
1219
1220    #[test]
1221    fn desktop_env_hydration_preserves_explicit_native_x11() {
1222        let current_env = HashMap::from([
1223            ("DISPLAY".to_string(), ":90".to_string()),
1224            ("XDG_SESSION_TYPE".to_string(), "x11".to_string()),
1225        ]);
1226        let host_env = HashMap::from([
1227            ("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string()),
1228            (
1229                "XDG_CURRENT_DESKTOP".to_string(),
1230                "ubuntu:GNOME".to_string(),
1231            ),
1232        ]);
1233
1234        let updates = desktop_env_hydration_updates(&current_env, &host_env);
1235
1236        assert!(!updates.iter().any(|(key, _)| *key == "WAYLAND_DISPLAY"));
1237        assert!(updates
1238            .iter()
1239            .any(|(key, value)| { *key == "XDG_CURRENT_DESKTOP" && value == "ubuntu:GNOME" }));
1240    }
1241
1242    #[test]
1243    fn desktop_env_hydration_still_imports_wayland_for_incomplete_sessions() {
1244        let current_env = HashMap::new();
1245        let host_env = HashMap::from([("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string())]);
1246
1247        let updates = desktop_env_hydration_updates(&current_env, &host_env);
1248
1249        assert!(updates
1250            .iter()
1251            .any(|(key, value)| *key == "WAYLAND_DISPLAY" && value == "wayland-0"));
1252    }
1253
1254    #[test]
1255    fn graphical_process_env_requires_display() {
1256        let with_display = HashMap::from([("DISPLAY".to_string(), ":0".to_string())]);
1257        let with_wayland =
1258            HashMap::from([("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string())]);
1259        let without_display = HashMap::from([("XAUTHORITY".to_string(), "/tmp/xauth".to_string())]);
1260
1261        assert!(process_env_has_graphical_display(&with_display));
1262        assert!(process_env_has_graphical_display(&with_wayland));
1263        assert!(!process_env_has_graphical_display(&without_display));
1264    }
1265
1266    #[test]
1267    fn capabilities_prefer_xdotool_before_ydotool_on_x11() {
1268        let mut platform = platform_report();
1269        platform.xdg_session_type = Some("x11".to_string());
1270        platform.wayland_display = None;
1271        platform.display = Some(":0".to_string());
1272        let input = InputReport {
1273            ydotool: Check::ok("ydotool"),
1274            ydotoold: Check::ok("ydotoold"),
1275            ydotool_socket: Check::ok("connectable"),
1276            uinput: Check::fail("missing"),
1277            xdotool: Check::ok("xdotool"),
1278        };
1279
1280        let capabilities = capability_map(
1281            &platform,
1282            &portal_report(Check::fail("missing")),
1283            &accessibility_report(Check::fail("missing"), Check::fail("missing")),
1284            &windowing_report(false, false),
1285            &input,
1286        );
1287
1288        assert_eq!(capabilities.input, ["xdotool", "ydotool"]);
1289        assert_eq!(capabilities.preferred.input.as_deref(), Some("xdotool"));
1290    }
1291
1292    #[test]
1293    fn x11_diagnostics_ignore_portal_and_accept_xdotool() {
1294        let mut platform = platform_report();
1295        platform.xdg_session_type = Some("x11".to_string());
1296        platform.wayland_display = None;
1297        platform.display = Some(":0".to_string());
1298        let portals = portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop"));
1299        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1300        let windowing = windowing_report(true, true);
1301        let mut input = input_report(false);
1302        input.xdotool = Check::ok("xdotool");
1303
1304        let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1305        let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
1306
1307        assert_eq!(capabilities.input, ["xdotool"]);
1308        assert_eq!(capabilities.preferred.input.as_deref(), Some("xdotool"));
1309        assert!(readiness.can_send_development_input);
1310    }
1311
1312    #[test]
1313    fn wayland_diagnostics_prefer_ydotool_before_portal() {
1314        let platform = platform_report();
1315        let portals = portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop"));
1316        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1317        let windowing = windowing_report(true, true);
1318        let input = input_report_parts(
1319            Check::ok("ydotool"),
1320            Check::ok("ydotoold"),
1321            Check::ok("connectable"),
1322            Check::fail("missing uinput"),
1323        );
1324
1325        let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1326
1327        assert_eq!(capabilities.input, ["ydotool", "portal"]);
1328        assert_eq!(capabilities.preferred.input.as_deref(), Some("ydotool"));
1329    }
1330
1331    #[test]
1332    fn portal_force_order_requires_both_input_modalities() {
1333        assert!(force_portal_for_all_input(true, true, false, false));
1334        assert!(!force_portal_for_all_input(true, false, false, false));
1335        assert!(!force_portal_for_all_input(true, true, true, false));
1336        assert!(!force_portal_for_all_input(true, true, false, true));
1337    }
1338
1339    #[test]
1340    fn capabilities_require_display_to_advertise_xdotool() {
1341        let mut platform = platform_report();
1342        platform.xdg_session_type = Some("x11".to_string());
1343        platform.wayland_display = None;
1344        platform.display = None;
1345        let input = InputReport {
1346            ydotool: Check::ok("ydotool"),
1347            ydotoold: Check::ok("ydotoold"),
1348            ydotool_socket: Check::ok("connectable"),
1349            uinput: Check::fail("missing"),
1350            xdotool: Check::ok("xdotool"),
1351        };
1352
1353        let capabilities = capability_map(
1354            &platform,
1355            &portal_report(Check::fail("missing")),
1356            &accessibility_report(Check::fail("missing"), Check::fail("missing")),
1357            &windowing_report(false, false),
1358            &input,
1359        );
1360
1361        assert_eq!(capabilities.input, ["ydotool"]);
1362        assert_eq!(capabilities.preferred.input.as_deref(), Some("ydotool"));
1363    }
1364
1365    #[test]
1366    fn xdotool_diagnostics_force_precedence_matches_runtime() {
1367        let mut platform = platform_report();
1368        platform.xdg_session_type = Some("wayland".to_string());
1369        platform.display = Some(":0".to_string());
1370        let mut input = input_report(false);
1371        input.xdotool = Check::ok("xdotool");
1372
1373        assert!(should_advertise_xdotool(&platform, &input, false, true));
1374        assert!(!should_advertise_xdotool(&platform, &input, true, true));
1375    }
1376
1377    #[test]
1378    fn parses_systemd_show_environment_output() {
1379        let environment = parse_line_environment(
1380            b"DISPLAY=:0\nHYPRLAND_INSTANCE_SIGNATURE=abc\nNO_EQUALS\nYDOTOOL_SOCKET=/run/ydotoold/socket\n",
1381        );
1382
1383        assert_eq!(environment.get("DISPLAY").map(String::as_str), Some(":0"));
1384        assert_eq!(
1385            environment
1386                .get("HYPRLAND_INSTANCE_SIGNATURE")
1387                .map(String::as_str),
1388            Some("abc")
1389        );
1390        assert_eq!(
1391            environment.get("YDOTOOL_SOCKET").map(String::as_str),
1392            Some("/run/ydotoold/socket")
1393        );
1394        assert!(!environment.contains_key("NO_EQUALS"));
1395    }
1396
1397    #[test]
1398    fn readiness_requires_exact_window_focus_for_targeted_input() {
1399        let platform = platform_report();
1400        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1401        let windowing = windowing_report(true, false);
1402        let input = input_report(true);
1403
1404        let readiness = readiness_report(
1405            &platform,
1406            &portal_report(Check::fail("missing")),
1407            &accessibility,
1408            &windowing,
1409            &input,
1410        );
1411
1412        assert!(readiness.can_query_windows);
1413        assert!(!readiness.can_focus_windows);
1414        assert!(readiness
1415            .recommended_next_step
1416            .contains("exact-focus window backend"));
1417        assert!(readiness
1418            .blockers
1419            .iter()
1420            .any(|blocker| blocker.contains("Exact window activation")));
1421    }
1422
1423    #[test]
1424    fn readiness_treats_kwin_as_full_window_backend() {
1425        let platform = platform_report();
1426        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1427        let mut windowing = windowing_report(false, false);
1428        windowing.kwin = Check::ok("KWin scripting is available");
1429        windowing.can_list_windows = true;
1430        windowing.can_focus_apps = true;
1431        windowing.can_focus_windows = true;
1432        let input = input_report(true);
1433
1434        let readiness = readiness_report(
1435            &platform,
1436            &portal_report(Check::fail("missing")),
1437            &accessibility,
1438            &windowing,
1439            &input,
1440        );
1441
1442        assert!(readiness.can_query_windows);
1443        assert!(readiness.can_focus_apps);
1444        assert!(readiness.can_focus_windows);
1445        assert!(readiness.blockers.is_empty());
1446    }
1447
1448    #[test]
1449    fn readiness_message_mentions_generic_window_targeting() {
1450        let platform = platform_report();
1451        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1452        let windowing = windowing_report(true, true);
1453        let input = input_report(true);
1454
1455        let readiness = readiness_report(
1456            &platform,
1457            &portal_report(Check::fail("missing")),
1458            &accessibility,
1459            &windowing,
1460            &input,
1461        );
1462
1463        assert!(readiness.blockers.is_empty());
1464        assert!(readiness
1465            .recommended_next_step
1466            .contains("AT-SPI tree support"));
1467        assert!(readiness.recommended_next_step.contains("window targeting"));
1468        assert!(!readiness
1469            .recommended_next_step
1470            .contains("GNOME window targeting"));
1471    }
1472
1473    #[test]
1474    fn readiness_accepts_connectable_ydotool_socket_without_direct_uinput_access() {
1475        let platform = platform_report();
1476        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1477        let windowing = windowing_report(true, true);
1478        let input = input_report_parts(
1479            Check::ok("ydotool"),
1480            Check::ok("ydotoold"),
1481            Check::ok("connectable: /tmp/.ydotool_socket"),
1482            Check::fail("/dev/uinput: Permission denied"),
1483        );
1484
1485        let readiness = readiness_report(
1486            &platform,
1487            &portal_report(Check::fail("missing")),
1488            &accessibility,
1489            &windowing,
1490            &input,
1491        );
1492
1493        assert!(readiness.can_send_development_input);
1494        assert!(readiness.blockers.is_empty());
1495    }
1496
1497    #[test]
1498    fn readiness_uses_connectable_ydotool_socket_when_process_probe_fails() {
1499        let platform = platform_report();
1500        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1501        let windowing = windowing_report(true, true);
1502        let input = input_report_parts(
1503            Check::ok("ydotool"),
1504            Check::fail("ydotoold process name not found"),
1505            Check::ok("connectable: /run/user/1000/.ydotool_socket"),
1506            Check::fail("/dev/uinput: Permission denied"),
1507        );
1508        let portals = portal_report(Check::fail("missing"));
1509
1510        let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1511        let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
1512
1513        assert!(capabilities
1514            .input
1515            .iter()
1516            .any(|backend| backend == "ydotool"));
1517        assert!(readiness.can_send_development_input);
1518    }
1519
1520    #[test]
1521    fn readiness_accepts_direct_uinput_without_connectable_ydotool_socket() {
1522        let platform = platform_report();
1523        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1524        let windowing = windowing_report(true, true);
1525        let input = input_report_parts(
1526            Check::ok("ydotool"),
1527            Check::fail("ydotoold not running"),
1528            Check::fail("no connectable ydotool socket"),
1529            Check::ok("read/write: /dev/uinput"),
1530        );
1531
1532        let readiness = readiness_report(
1533            &platform,
1534            &portal_report(Check::fail("missing")),
1535            &accessibility,
1536            &windowing,
1537            &input,
1538        );
1539
1540        assert!(readiness.can_send_development_input);
1541        assert!(readiness.blockers.is_empty());
1542    }
1543
1544    #[test]
1545    fn readiness_accepts_remote_desktop_portal_without_local_input_backend() {
1546        let platform = platform_report();
1547        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1548        let windowing = windowing_report(true, true);
1549        let input = input_report_parts(
1550            Check::fail("missing ydotool"),
1551            Check::fail("ydotoold not running"),
1552            Check::fail("no connectable ydotool socket"),
1553            Check::fail("/dev/uinput: Permission denied"),
1554        );
1555
1556        let readiness = readiness_report(
1557            &platform,
1558            &portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop")),
1559            &accessibility,
1560            &windowing,
1561            &input,
1562        );
1563
1564        assert!(readiness.can_send_development_input);
1565        assert!(readiness.blockers.is_empty());
1566    }
1567
1568    #[test]
1569    fn readiness_rejects_inaccessible_ydotool_paths() {
1570        let platform = platform_report();
1571        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1572        let windowing = windowing_report(true, true);
1573        let input = input_report_parts(
1574            Check::ok("ydotool"),
1575            Check::ok("ydotoold"),
1576            Check::fail("/tmp/.ydotool_socket: Permission denied"),
1577            Check::fail("/dev/uinput: Permission denied"),
1578        );
1579
1580        let readiness = readiness_report(
1581            &platform,
1582            &portal_report(Check::fail("missing")),
1583            &accessibility,
1584            &windowing,
1585            &input,
1586        );
1587
1588        assert!(!readiness.can_send_development_input);
1589        assert!(readiness
1590            .recommended_next_step
1591            .contains("Enable a supported input backend"));
1592        assert!(readiness
1593            .blockers
1594            .iter()
1595            .any(|blocker| blocker.contains("Development input is unavailable")));
1596    }
1597
1598    #[test]
1599    fn capability_map_rejects_incompatible_ydotool_with_live_daemon() {
1600        let platform = platform_report();
1601        let portals = portal_report(Check::fail("missing"));
1602        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1603        let windowing = windowing_report(true, true);
1604        let input = input_report_parts(
1605            Check::fail("unsupported legacy ydotool CLI"),
1606            Check::ok("ydotoold"),
1607            Check::ok("connectable socket"),
1608            Check::fail("/dev/uinput: Permission denied"),
1609        );
1610
1611        let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1612        let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
1613
1614        assert!(!capabilities
1615            .input
1616            .iter()
1617            .any(|backend| backend == "ydotool"));
1618        assert!(!readiness.can_send_development_input);
1619    }
1620
1621    #[test]
1622    fn ydotool_socket_check_rejects_legacy_stream_socket() {
1623        let dir = std::env::temp_dir().join(format!(
1624            "computer-use-linux-diagnostics-{}",
1625            std::process::id()
1626        ));
1627        let _ = std::fs::remove_dir_all(&dir);
1628        std::fs::create_dir_all(&dir).expect("create temp diagnostics dir");
1629        let socket = dir.join("ydotool.sock");
1630        let listener =
1631            std::os::unix::net::UnixListener::bind(&socket).expect("bind temp diagnostics socket");
1632
1633        let check = socket_connect_check(&socket);
1634
1635        assert!(!check.ok, "{check:?}");
1636        drop(listener);
1637        let _ = std::fs::remove_dir_all(&dir);
1638    }
1639
1640    #[test]
1641    fn ydotool_socket_check_accepts_datagram_socket() {
1642        let dir = std::env::temp_dir().join(format!(
1643            "computer-use-linux-diagnostics-dgram-{}",
1644            std::process::id()
1645        ));
1646        let _ = std::fs::remove_dir_all(&dir);
1647        std::fs::create_dir_all(&dir).expect("create temp diagnostics dir");
1648        let socket = dir.join("ydotool.sock");
1649        let datagram =
1650            std::os::unix::net::UnixDatagram::bind(&socket).expect("bind temp datagram socket");
1651
1652        let check = socket_connect_check(&socket);
1653
1654        assert!(check.ok, "{check:?}");
1655        drop(datagram);
1656        let _ = std::fs::remove_dir_all(&dir);
1657    }
1658
1659    #[test]
1660    fn readiness_reports_cosmic_window_blocker_on_cosmic() {
1661        let mut platform = platform_report();
1662        platform.xdg_current_desktop = Some("COSMIC".to_string());
1663        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1664        let windowing = windowing_report(false, false);
1665        let input = input_report(true);
1666
1667        let readiness = readiness_report(
1668            &platform,
1669            &portal_report(Check::fail("missing")),
1670            &accessibility,
1671            &windowing,
1672            &input,
1673        );
1674
1675        assert!(readiness
1676            .blockers
1677            .iter()
1678            .any(|blocker| blocker.contains("COSMIC Wayland window introspection")));
1679    }
1680}