Skip to main content

computer_use_linux/
diagnostics.rs

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