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