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