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