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"];
35const PORTAL_DEVICE_KEYBOARD: u32 = 1;
36const PORTAL_DEVICE_POINTER: u32 = 2;
37const PORTAL_SOURCE_MONITOR: u32 = 1;
38const REMOTE_DESKTOP_KEYBOARD_METHODS: &[&str] = &[
39    "CreateSession",
40    "SelectDevices",
41    "Start",
42    "NotifyKeyboardKeycode",
43    "NotifyKeyboardKeysym",
44];
45const REMOTE_DESKTOP_POINTER_METHODS: &[&str] = &[
46    "CreateSession",
47    "SelectDevices",
48    "Start",
49    "NotifyPointerMotionAbsolute",
50    "NotifyPointerButton",
51    "NotifyPointerAxisDiscrete",
52];
53const SCREENCAST_POINTER_METHODS: &[&str] = &["SelectSources"];
54
55#[derive(Debug, Clone, Serialize, JsonSchema)]
56pub struct DoctorReport {
57    pub platform: PlatformReport,
58    pub portals: PortalReport,
59    pub accessibility: AccessibilityReport,
60    pub windowing: WindowingReport,
61    pub input: InputReport,
62    pub readiness: ReadinessReport,
63    /// Which interchangeable backends this environment supports, per layer, plus
64    /// the one the tool prefers. Lets an agent (or selector) understand what's
65    /// available and choose accordingly instead of assuming one fixed path.
66    pub capabilities: CapabilityMap,
67}
68
69#[derive(Debug, Clone, Serialize, JsonSchema)]
70pub struct CapabilityMap {
71    /// Pointer/keyboard injection backends, best-first.
72    pub input: Vec<String>,
73    /// Screen capture backends, best-first.
74    pub screenshot: Vec<String>,
75    /// Window listing/focus backends available.
76    pub window_control: Vec<String>,
77    /// Accessibility (element-targeted, non-pointer) backends.
78    pub accessibility: Vec<String>,
79    /// Display/session isolation contexts the host can provide.
80    pub isolation: Vec<String>,
81    /// The backend the tool will use by default for each selectable layer.
82    pub preferred: PreferredBackends,
83}
84
85#[derive(Debug, Clone, Serialize, JsonSchema)]
86pub struct PreferredBackends {
87    pub input: Option<String>,
88    pub screenshot: Option<String>,
89    pub window_control: Option<String>,
90}
91
92#[derive(Debug, Clone, Serialize, JsonSchema)]
93pub struct PlatformReport {
94    pub os: String,
95    pub arch: String,
96    pub desktop_session: Option<String>,
97    pub xdg_session_type: Option<String>,
98    pub xdg_current_desktop: Option<String>,
99    pub wayland_display: Option<String>,
100    pub display: Option<String>,
101    pub xauthority: Option<String>,
102    pub dbus_session_bus_address: Option<String>,
103    pub xdg_runtime_dir: Option<String>,
104    pub gnome_shell_version: Check,
105    pub gnome_screenshot: Check,
106}
107
108#[derive(Debug, Clone, Serialize, JsonSchema)]
109pub struct PortalReport {
110    pub desktop_portal: Check,
111    pub remote_desktop: Check,
112    pub screencast: Check,
113    pub screenshot: Check,
114    pub input_capture: Check,
115    pub mutter_remote_desktop: Check,
116    pub mutter_screencast: Check,
117}
118
119#[derive(Debug, Clone, Serialize, JsonSchema)]
120pub struct AccessibilityReport {
121    pub at_spi_bus: Check,
122    pub toolkit_accessibility: Check,
123    pub at_spi_enabled: Check,
124    pub screen_reader_enabled: Check,
125}
126
127#[derive(Debug, Clone, Serialize, JsonSchema)]
128pub struct WindowingReport {
129    pub gnome_shell_introspect: Check,
130    pub computer_use_linux_gnome_shell_extension: Check,
131    pub cosmic_helper: Check,
132    pub kwin: Check,
133    pub hyprland: Check,
134    pub backends: BTreeMap<String, Check>,
135    pub can_list_windows: bool,
136    pub can_focus_apps: bool,
137    pub can_focus_windows: bool,
138    pub note: String,
139}
140
141#[derive(Debug, Clone, Serialize, JsonSchema)]
142pub struct InputReport {
143    pub ydotool: Check,
144    pub ydotoold: Check,
145    pub ydotool_socket: Check,
146    pub uinput: Check,
147    /// X11 XTEST keyboard backend. Preferred over ydotool on X11 sessions,
148    /// where raw evdev scancodes are re-mapped by the active XKB layout.
149    pub xdotool: Check,
150}
151
152#[derive(Debug, Clone, Serialize, JsonSchema)]
153pub struct ReadinessReport {
154    pub can_register_mcp_tools: bool,
155    pub can_build_accessibility_tree: bool,
156    pub can_query_windows: bool,
157    pub can_focus_apps: bool,
158    pub can_focus_windows: bool,
159    pub can_send_development_input: bool,
160    pub recommended_next_step: String,
161    pub blockers: Vec<String>,
162}
163
164#[derive(Debug, Clone, Serialize, JsonSchema)]
165pub struct SetupReport {
166    pub before: DoctorReport,
167    pub accessibility_command: Check,
168    pub after: DoctorReport,
169    pub changed_accessibility: bool,
170    pub requires_target_app_restart: bool,
171    pub message: String,
172}
173
174#[derive(Debug, Clone, Serialize, JsonSchema)]
175pub struct Check {
176    pub ok: bool,
177    pub detail: String,
178}
179
180impl Check {
181    fn ok(detail: impl Into<String>) -> Self {
182        Self {
183            ok: true,
184            detail: detail.into(),
185        }
186    }
187
188    fn fail(detail: impl Into<String>) -> Self {
189        Self {
190            ok: false,
191            detail: detail.into(),
192        }
193    }
194}
195
196pub fn doctor_report() -> DoctorReport {
197    hydrate_session_bus_env();
198
199    let platform = platform_report();
200    let (portals, remote_desktop_keyboard) = portal_report();
201    let accessibility = accessibility_report();
202    let windowing = windowing_report(&platform);
203    let input = input_report();
204    let readiness = readiness_report_with_portal_keyboard(
205        &platform,
206        &remote_desktop_keyboard,
207        &accessibility,
208        &windowing,
209        &input,
210    );
211
212    let capabilities = capability_map_with_portal_keyboard(
213        &platform,
214        &portals,
215        &remote_desktop_keyboard,
216        &accessibility,
217        &windowing,
218        &input,
219    );
220
221    DoctorReport {
222        platform,
223        portals,
224        accessibility,
225        windowing,
226        input,
227        readiness,
228        capabilities,
229    }
230}
231
232/// Derive the per-layer backend capability map from the individual checks. Lists
233/// are ordered best-first and mirror the order the tool actually tries them.
234#[cfg(test)]
235fn capability_map(
236    platform: &PlatformReport,
237    portals: &PortalReport,
238    accessibility: &AccessibilityReport,
239    windowing: &WindowingReport,
240    input: &InputReport,
241) -> CapabilityMap {
242    capability_map_with_portal_keyboard(
243        platform,
244        portals,
245        &portals.remote_desktop,
246        accessibility,
247        windowing,
248        input,
249    )
250}
251
252fn capability_map_with_portal_keyboard(
253    platform: &PlatformReport,
254    portals: &PortalReport,
255    remote_desktop_keyboard: &Check,
256    accessibility: &AccessibilityReport,
257    windowing: &WindowingReport,
258    input: &InputReport,
259) -> CapabilityMap {
260    let mut input_backends = Vec::new();
261    // Absolute uinput pointer: accurate, non-blocking of coordinates; preferred.
262    if input.uinput.ok {
263        input_backends.push("abs_pointer".to_string());
264    }
265    let force_ydotool = env_flag_enabled_any(FORCE_YDOTOOL_KEYBOARD_ENV_KEYS);
266    let force_xdotool = env_flag_enabled_any(FORCE_XDOTOOL_KEYBOARD_ENV_KEYS);
267    let portal_pointer_available = portal_pointer_input_available(platform, portals);
268    let portal_keyboard_available =
269        portal_keyboard_input_available(platform, remote_desktop_keyboard);
270    let portal_available = portal_pointer_available || portal_keyboard_available;
271    let portal_forced_for_all_input = portal_pointer_available
272        && portal_keyboard_available
273        && force_portal_for_all_input(
274            env_flag_enabled_any(FORCE_PORTAL_POINTER_ENV_KEYS),
275            env_flag_enabled_any(FORCE_PORTAL_KEYBOARD_ENV_KEYS),
276            env_flag_enabled_any(FORCE_YDOTOOL_POINTER_ENV_KEYS),
277            force_ydotool,
278        );
279    if should_advertise_xdotool(platform, input, force_ydotool, force_xdotool) {
280        input_backends.push("xdotool".to_string());
281    }
282    if portal_available && portal_forced_for_all_input {
283        input_backends.push("portal".to_string());
284    }
285    if input.ydotool.ok && input.ydotool_socket.ok {
286        input_backends.push("ydotool".to_string());
287    }
288    if portal_available && !portal_forced_for_all_input {
289        input_backends.push("portal".to_string());
290    }
291
292    let mut screenshot_backends = Vec::new();
293    if platform.gnome_shell_version.ok {
294        screenshot_backends.push("gnome_shell".to_string());
295    }
296    if portals.screenshot.ok {
297        screenshot_backends.push("portal".to_string());
298    }
299    // Subprocess fallback for background/systemd contexts the DBus paths reject.
300    if platform.gnome_screenshot.ok {
301        screenshot_backends.push("gnome_screenshot".to_string());
302    }
303
304    let mut window_backends = Vec::new();
305    let x11_available = windowing
306        .backends
307        .get(X11_BACKEND)
308        .is_some_and(|check| check.ok);
309    let prefer_x11_over_introspect = windowing.gnome_shell_introspect.ok
310        && x11_available
311        && registry::backend_can_exact_focus(X11_BACKEND);
312    if windowing.computer_use_linux_gnome_shell_extension.ok {
313        window_backends.push("gnome_shell_extension".to_string());
314    }
315    if prefer_x11_over_introspect {
316        window_backends.push(X11_BACKEND.to_string());
317    }
318    if windowing.gnome_shell_introspect.ok {
319        window_backends.push("gnome_introspect".to_string());
320    }
321    if windowing.cosmic_helper.ok {
322        window_backends.push("cosmic".to_string());
323    }
324    if windowing.kwin.ok {
325        window_backends.push("kwin".to_string());
326    }
327    if windowing.hyprland.ok {
328        window_backends.push("hyprland".to_string());
329    }
330    // i3 and the generic X11/EWMH backend have no dedicated WindowingReport
331    // field; read them from the probe map (tried last) so the capability list
332    // matches the backends the registry will actually use.
333    if windowing
334        .backends
335        .get(I3_BACKEND)
336        .is_some_and(|check| check.ok)
337    {
338        window_backends.push(I3_BACKEND.to_string());
339    }
340    if x11_available && !prefer_x11_over_introspect {
341        window_backends.push(X11_BACKEND.to_string());
342    }
343
344    let mut accessibility_backends = Vec::new();
345    if accessibility.at_spi_enabled.ok || accessibility.toolkit_accessibility.ok {
346        accessibility_backends.push("at_spi".to_string());
347    }
348
349    // Isolation contexts: the live shared session is always available; a headless
350    // GNOME session is possible when gnome-shell is installed (it supports
351    // --headless --virtual-monitor), giving the agent its own seat.
352    let mut isolation = vec!["shared".to_string()];
353    if platform.gnome_shell_version.ok {
354        isolation.push("headless_gnome".to_string());
355    }
356
357    let preferred = PreferredBackends {
358        input: input_backends.first().cloned(),
359        screenshot: screenshot_backends.first().cloned(),
360        window_control: window_backends.first().cloned(),
361    };
362
363    CapabilityMap {
364        input: input_backends,
365        screenshot: screenshot_backends,
366        window_control: window_backends,
367        accessibility: accessibility_backends,
368        isolation,
369        preferred,
370    }
371}
372
373pub fn hydrate_session_bus_env() {
374    hydrate_common_command_path();
375    hydrate_desktop_env_from_process_tree();
376    hydrate_desktop_env_from_systemd_user();
377
378    if env_var("XDG_RUNTIME_DIR").is_none() {
379        if let Some(runtime) = xdg_runtime_dir() {
380            if runtime.exists() {
381                env::set_var("XDG_RUNTIME_DIR", runtime);
382            }
383        }
384    }
385
386    if env_var("DBUS_SESSION_BUS_ADDRESS").is_none() {
387        if let Some(runtime) = xdg_runtime_dir() {
388            let bus = runtime.join("bus");
389            if bus.exists() {
390                env::set_var(
391                    "DBUS_SESSION_BUS_ADDRESS",
392                    format!("unix:path={}", bus.display()),
393                );
394            }
395        }
396    }
397}
398
399fn hydrate_common_command_path() {
400    let mut entries = env::var_os("PATH")
401        .map(|path| env::split_paths(&path).collect::<Vec<_>>())
402        .unwrap_or_default();
403    for path in [
404        "/run/current-system/sw/bin",
405        "/usr/local/bin",
406        "/usr/bin",
407        "/bin",
408    ] {
409        let path = PathBuf::from(path);
410        if path.exists() && !entries.iter().any(|entry| entry == &path) {
411            entries.push(path);
412        }
413    }
414    if let Ok(path) = env::join_paths(entries) {
415        env::set_var("PATH", path);
416    }
417}
418
419fn hydrate_desktop_env_from_process_tree() {
420    for process_env in desktop_process_environments() {
421        hydrate_desktop_env_from_map(&process_env);
422
423        if DESKTOP_ENV_KEYS.iter().all(|key| env_var(key).is_some()) {
424            break;
425        }
426    }
427}
428
429fn hydrate_desktop_env_from_systemd_user() {
430    let Ok(output) = Command::new("systemctl")
431        .args(["--user", "show-environment"])
432        .output()
433    else {
434        return;
435    };
436    if !output.status.success() {
437        return;
438    }
439    let env_map = parse_line_environment(&output.stdout);
440    hydrate_desktop_env_from_map(&env_map);
441}
442
443fn hydrate_desktop_env_from_map(process_env: &HashMap<String, String>) {
444    let current_env = DESKTOP_ENV_KEYS
445        .iter()
446        .filter_map(|key| env_var(key).map(|value| ((*key).to_string(), value)))
447        .collect();
448    for (key, value) in desktop_env_hydration_updates(&current_env, process_env) {
449        env::set_var(key, value);
450    }
451}
452
453fn desktop_env_hydration_updates(
454    current_env: &HashMap<String, String>,
455    source_env: &HashMap<String, String>,
456) -> Vec<(&'static str, String)> {
457    // A nested X11 desktop can share a user manager with a Wayland host.
458    // Preserve its complete process-local session instead of grafting the
459    // host's WAYLAND_DISPLAY onto it.
460    let preserve_native_x11 = current_env
461        .get("XDG_SESSION_TYPE")
462        .is_some_and(|value| value.trim().eq_ignore_ascii_case("x11"))
463        && current_env
464            .get("DISPLAY")
465            .is_some_and(|value| !value.trim().is_empty())
466        && !current_env
467            .get("WAYLAND_DISPLAY")
468            .is_some_and(|value| !value.trim().is_empty());
469
470    DESKTOP_ENV_KEYS
471        .iter()
472        .filter_map(|key| {
473            if current_env
474                .get(*key)
475                .is_some_and(|value| !value.trim().is_empty())
476                || preserve_native_x11 && *key == "WAYLAND_DISPLAY"
477            {
478                return None;
479            }
480            source_env
481                .get(*key)
482                .filter(|value| !value.trim().is_empty())
483                .map(|value| (*key, value.clone()))
484        })
485        .collect()
486}
487
488fn desktop_process_environments() -> Vec<HashMap<String, String>> {
489    let mut environments = Vec::new();
490    let mut visited_pids = Vec::new();
491    let mut pid = parent_pid("self");
492
493    for _ in 0..8 {
494        let Some(current_pid) = pid else {
495            break;
496        };
497        if current_pid <= 1 {
498            break;
499        }
500
501        visited_pids.push(current_pid);
502        if let Some(process_env) = read_process_environ(current_pid) {
503            environments.push(process_env);
504        }
505        pid = parent_pid(&current_pid.to_string());
506    }
507
508    if !visited_pids.contains(&1) && process_owner_matches_current_user(1) {
509        if let Some(process_env) = read_process_environ(1).filter(process_env_has_graphical_display)
510        {
511            environments.push(process_env);
512        }
513    }
514
515    environments
516}
517
518fn parent_pid(pid: &str) -> Option<u32> {
519    let status = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
520    parse_parent_pid(&status)
521}
522
523fn parse_parent_pid(status: &str) -> Option<u32> {
524    status.lines().find_map(|line| {
525        let value = line.strip_prefix("PPid:")?.trim();
526        value.parse::<u32>().ok()
527    })
528}
529
530fn read_process_environ(pid: u32) -> Option<HashMap<String, String>> {
531    let bytes = fs::read(format!("/proc/{pid}/environ")).ok()?;
532    Some(parse_environ(&bytes))
533}
534
535fn process_owner_matches_current_user(pid: u32) -> bool {
536    let Some(current_uid) = user_id().and_then(|uid| uid.parse::<u32>().ok()) else {
537        return false;
538    };
539    fs::metadata(format!("/proc/{pid}"))
540        .ok()
541        .is_some_and(|metadata| metadata.uid() == current_uid)
542}
543
544fn process_env_has_graphical_display(process_env: &HashMap<String, String>) -> bool {
545    process_env
546        .get("DISPLAY")
547        .or_else(|| process_env.get("WAYLAND_DISPLAY"))
548        .is_some_and(|value| !value.trim().is_empty())
549}
550
551fn parse_environ(bytes: &[u8]) -> HashMap<String, String> {
552    bytes
553        .split(|byte| *byte == 0)
554        .filter_map(|entry| {
555            if entry.is_empty() {
556                return None;
557            }
558            let split = entry.iter().position(|byte| *byte == b'=')?;
559            let (key, value) = entry.split_at(split);
560            let value = &value[1..];
561            let key = std::str::from_utf8(key).ok()?.to_string();
562            let value = std::str::from_utf8(value).ok()?.to_string();
563            Some((key, value))
564        })
565        .collect()
566}
567
568fn parse_line_environment(bytes: &[u8]) -> HashMap<String, String> {
569    bytes
570        .split(|byte| *byte == b'\n')
571        .filter_map(|entry| {
572            if entry.is_empty() {
573                return None;
574            }
575            let split = entry.iter().position(|byte| *byte == b'=')?;
576            let (key, value) = entry.split_at(split);
577            let value = &value[1..];
578            let key = std::str::from_utf8(key).ok()?.to_string();
579            let value = std::str::from_utf8(value).ok()?.to_string();
580            Some((key, value))
581        })
582        .collect()
583}
584
585pub fn setup_accessibility_report() -> SetupReport {
586    hydrate_session_bus_env();
587
588    let before = doctor_report();
589    let accessibility_command = if can_build_accessibility_tree(&before.accessibility) {
590        Check::ok("AT-SPI accessibility is already enabled")
591    } else {
592        let atspi_status = command_check_with_session_bus(
593            "busctl",
594            &[
595                "--user",
596                "set-property",
597                "org.a11y.Bus",
598                "/org/a11y/bus",
599                "org.a11y.Status",
600                "IsEnabled",
601                "b",
602                "true",
603            ],
604        );
605        if atspi_status.ok {
606            atspi_status
607        } else {
608            command_check_with_session_bus(
609                "gsettings",
610                &[
611                    "set",
612                    "org.gnome.desktop.interface",
613                    "toolkit-accessibility",
614                    "true",
615                ],
616            )
617        }
618    };
619    let after = doctor_report();
620    let before_ready = before.readiness.can_build_accessibility_tree;
621    let after_ready = after.readiness.can_build_accessibility_tree;
622    let changed_accessibility = !before_ready && after_ready;
623    let requires_target_app_restart = changed_accessibility;
624    let message = if after_ready {
625        if changed_accessibility {
626            "AT-SPI accessibility is enabled. Restart already-running target apps if their AT-SPI tree is still empty."
627        } else {
628            "AT-SPI accessibility is ready."
629        }
630    } else {
631        "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."
632    }
633    .to_string();
634
635    SetupReport {
636        before,
637        accessibility_command,
638        after,
639        changed_accessibility,
640        requires_target_app_restart,
641        message,
642    }
643}
644
645fn platform_report() -> PlatformReport {
646    PlatformReport {
647        os: std::env::consts::OS.to_string(),
648        arch: std::env::consts::ARCH.to_string(),
649        desktop_session: env_var("DESKTOP_SESSION"),
650        xdg_session_type: env_var("XDG_SESSION_TYPE"),
651        xdg_current_desktop: env_var("XDG_CURRENT_DESKTOP"),
652        wayland_display: env_var("WAYLAND_DISPLAY"),
653        display: env_var("DISPLAY"),
654        xauthority: env_var("XAUTHORITY"),
655        dbus_session_bus_address: dbus_session_address(),
656        xdg_runtime_dir: xdg_runtime_dir().map(|path| path.display().to_string()),
657        gnome_shell_version: command_check("gnome-shell", &["--version"]),
658        gnome_screenshot: command_check("gnome-screenshot", &["--version"]),
659    }
660}
661
662fn portal_report() -> (PortalReport, Check) {
663    let (remote_desktop, remote_desktop_keyboard, screencast) = remote_desktop_portal_checks();
664    (
665        PortalReport {
666            desktop_portal: bus_name_check("org.freedesktop.portal.Desktop"),
667            remote_desktop,
668            screencast,
669            screenshot: portal_interface_check("org.freedesktop.portal.Screenshot"),
670            input_capture: portal_interface_check("org.freedesktop.portal.InputCapture"),
671            mutter_remote_desktop: bus_name_check("org.gnome.Mutter.RemoteDesktop"),
672            mutter_screencast: bus_name_check("org.gnome.Mutter.ScreenCast"),
673        },
674        remote_desktop_keyboard,
675    )
676}
677
678fn accessibility_report() -> AccessibilityReport {
679    AccessibilityReport {
680        at_spi_bus: atspi_bus_address_check(),
681        toolkit_accessibility: command_check_with_session_bus(
682            "gsettings",
683            &[
684                "get",
685                "org.gnome.desktop.interface",
686                "toolkit-accessibility",
687            ],
688        ),
689        at_spi_enabled: atspi_status_property_check("IsEnabled"),
690        screen_reader_enabled: atspi_status_property_check("ScreenReaderEnabled"),
691    }
692}
693
694fn windowing_report(platform: &PlatformReport) -> WindowingReport {
695    let probes = registry::probe_backends();
696    let backend_check = |id: &str| {
697        probes
698            .iter()
699            .find(|probe| probe.id == id)
700            .map(check_from_backend_probe)
701            .unwrap_or_else(|| Check::fail("backend probe did not run"))
702    };
703    let gnome_shell_introspect = backend_check(GNOME_SHELL_INTROSPECT_BACKEND);
704    let computer_use_linux_gnome_shell_extension = backend_check(GNOME_SHELL_EXTENSION_BACKEND);
705    let cosmic_helper = backend_check(COSMIC_WAYLAND_BACKEND);
706    let kwin = backend_check(KWIN_BACKEND);
707    let hyprland = backend_check(HYPRLAND_BACKEND);
708    let backends = probes
709        .iter()
710        .map(|probe| (probe.id.to_string(), check_from_backend_probe(probe)))
711        .collect::<BTreeMap<_, _>>();
712    let can_list_windows = probes.iter().any(|probe| probe.can_list_windows);
713    let can_focus_apps = probes.iter().any(|probe| probe.can_focus_apps);
714    let can_focus_windows = probes.iter().any(|probe| probe.can_focus_windows);
715    let note = if can_list_windows {
716        if !can_focus_windows {
717            "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)."
718        } else if cosmic_helper.ok && is_cosmic_wayland_platform(platform) {
719            "A COSMIC Wayland window backend is available for list_windows, focused_window, and targeted input verification."
720        } else if kwin.ok {
721            "A KWin/Plasma window backend is available for list_windows, focused_window, and targeted input verification."
722        } else if hyprland.ok {
723            "A Hyprland window backend is available for list_windows, focused_window, and targeted input verification."
724        } else {
725            "A window listing backend is available for list_windows, focused_window, and targeted input verification."
726        }
727    } else {
728        "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."
729    }
730    .to_string();
731
732    WindowingReport {
733        gnome_shell_introspect,
734        computer_use_linux_gnome_shell_extension,
735        cosmic_helper,
736        kwin,
737        hyprland,
738        backends,
739        can_list_windows,
740        can_focus_apps,
741        can_focus_windows,
742        note,
743    }
744}
745
746fn check_from_backend_probe(probe: &registry::BackendProbe) -> Check {
747    if probe.ok {
748        Check::ok(probe.detail.clone())
749    } else {
750        Check::fail(probe.detail.clone())
751    }
752}
753
754fn input_report() -> InputReport {
755    InputReport {
756        ydotool: match ydotool::ensure_supported() {
757            Ok(support) => Check::ok(support.detail),
758            Err(detail) => Check::fail(detail),
759        },
760        ydotoold: process_check("ydotoold"),
761        ydotool_socket: ydotool_socket_check(),
762        uinput: read_write_path_check(Path::new("/dev/uinput")),
763        xdotool: command_path_check("xdotool"),
764    }
765}
766
767#[cfg(test)]
768fn readiness_report(
769    platform: &PlatformReport,
770    portals: &PortalReport,
771    accessibility: &AccessibilityReport,
772    windowing: &WindowingReport,
773    input: &InputReport,
774) -> ReadinessReport {
775    readiness_report_with_portal_keyboard(
776        platform,
777        &portals.remote_desktop,
778        accessibility,
779        windowing,
780        input,
781    )
782}
783
784fn readiness_report_with_portal_keyboard(
785    platform: &PlatformReport,
786    remote_desktop_keyboard: &Check,
787    accessibility: &AccessibilityReport,
788    windowing: &WindowingReport,
789    input: &InputReport,
790) -> ReadinessReport {
791    let mut blockers = Vec::new();
792    let can_build_accessibility_tree = can_build_accessibility_tree(accessibility);
793    let can_query_windows = windowing.can_list_windows;
794    let can_focus_apps = windowing.can_focus_apps;
795    let can_focus_windows = windowing.can_focus_windows;
796    let can_send_development_input =
797        can_send_development_input(platform, remote_desktop_keyboard, input);
798
799    if !can_build_accessibility_tree {
800        blockers.push(
801            "AT-SPI accessibility is disabled; enable org.a11y.Status IsEnabled or org.gnome.desktop.interface toolkit-accessibility for tree extraction."
802                .to_string(),
803        );
804    }
805
806    if !can_query_windows {
807        blockers.push(if is_cosmic_wayland_platform(platform) {
808            "COSMIC Wayland window introspection is unavailable; targeted window focus and verification will be disabled.".to_string()
809        } else {
810            "Window introspection is unavailable; targeted window focus and verification will be disabled."
811                .to_string()
812        });
813    }
814
815    if can_query_windows && !can_focus_windows {
816        blockers.push(
817            "Exact window activation is unavailable; app-level focus may work, but window_id/title/terminal-targeted input cannot be verified."
818                .to_string(),
819        );
820    }
821
822    if !can_send_development_input {
823        blockers.push(
824            "Development keyboard input is unavailable; enable XDG RemoteDesktop portal input on Wayland, xdotool with DISPLAY on X11, or ydotool with a connectable ydotoold socket. Read/write /dev/uinput alone provides only absolute pointer input."
825                .to_string(),
826        );
827    }
828
829    let recommended_next_step = if !can_build_accessibility_tree {
830        "Run setup_accessibility to enable AT-SPI accessibility before element-aware actions."
831            .to_string()
832    } else if !can_query_windows {
833        format!(
834            "Enable a supported window backend before using targeted keyboard input: {}",
835            registry::descriptors()
836                .iter()
837                .map(|descriptor| descriptor.missing_hint)
838                .collect::<Vec<_>>()
839                .join(" ")
840        )
841    } else if !can_focus_windows {
842        "Enable an exact-focus window backend before using window_id, title, or terminal-targeted input.".to_string()
843    } else if !can_send_development_input {
844        "Enable a keyboard-capable input backend: enable the XDG RemoteDesktop portal on Wayland, install xdotool for X11, or start ydotoold with a socket accessible to this desktop user."
845            .to_string()
846    } else {
847        "Computer Use is ready: AT-SPI tree support, window targeting, and a Linux input backend are available."
848            .to_string()
849    };
850
851    ReadinessReport {
852        can_register_mcp_tools: true,
853        can_build_accessibility_tree,
854        can_query_windows,
855        can_focus_apps,
856        can_focus_windows,
857        can_send_development_input,
858        recommended_next_step,
859        blockers,
860    }
861}
862
863fn can_send_development_input(
864    platform: &PlatformReport,
865    remote_desktop_keyboard: &Check,
866    input: &InputReport,
867) -> bool {
868    let force_ydotool = env_flag_enabled_any(FORCE_YDOTOOL_KEYBOARD_ENV_KEYS);
869    let force_xdotool = env_flag_enabled_any(FORCE_XDOTOOL_KEYBOARD_ENV_KEYS);
870    portal_keyboard_input_available(platform, remote_desktop_keyboard)
871        || should_advertise_xdotool(platform, input, force_ydotool, force_xdotool)
872        || input.ydotool.ok && input.ydotool_socket.ok
873}
874
875fn portal_pointer_input_available(platform: &PlatformReport, portals: &PortalReport) -> bool {
876    platform_is_wayland(platform) && portals.remote_desktop.ok
877}
878
879fn portal_keyboard_input_available(
880    platform: &PlatformReport,
881    remote_desktop_keyboard: &Check,
882) -> bool {
883    platform_is_wayland(platform) && remote_desktop_keyboard.ok
884}
885
886fn is_cosmic_wayland_platform(platform: &PlatformReport) -> bool {
887    platform
888        .xdg_current_desktop
889        .as_deref()
890        .is_some_and(|desktop| desktop.to_ascii_lowercase().contains("cosmic"))
891        && platform.xdg_session_type.as_deref() == Some("wayland")
892}
893
894fn can_build_accessibility_tree(accessibility: &AccessibilityReport) -> bool {
895    accessibility.at_spi_bus.ok
896        && (check_detail_contains_true(&accessibility.at_spi_enabled)
897            || check_detail_contains_true(&accessibility.toolkit_accessibility))
898}
899
900fn check_detail_contains_true(check: &Check) -> bool {
901    check.ok && check.detail.to_ascii_lowercase().contains("true")
902}
903
904fn env_var(key: &str) -> Option<String> {
905    env::var(key).ok().filter(|value| !value.trim().is_empty())
906}
907
908fn xdg_runtime_dir() -> Option<PathBuf> {
909    if let Some(value) = env_var("XDG_RUNTIME_DIR") {
910        return Some(PathBuf::from(value));
911    }
912    user_id().map(|uid| PathBuf::from(format!("/run/user/{uid}")))
913}
914
915fn dbus_session_address() -> Option<String> {
916    if let Some(value) = env_var("DBUS_SESSION_BUS_ADDRESS") {
917        return Some(value);
918    }
919    xdg_runtime_dir()
920        .map(|runtime| format!("unix:path={}", runtime.join("bus").display()))
921        .filter(|address| {
922            address
923                .strip_prefix("unix:path=")
924                .is_some_and(|p| Path::new(p).exists())
925        })
926}
927
928fn ydotool_socket_candidates() -> Vec<PathBuf> {
929    let mut candidates = Vec::new();
930    if let Some(value) = env_var("YDOTOOL_SOCKET") {
931        candidates.push(PathBuf::from(value));
932    }
933
934    if let Some(runtime_socket) = xdg_runtime_dir().map(|runtime| runtime.join(".ydotool_socket")) {
935        candidates.push(runtime_socket);
936    }
937    candidates.push(PathBuf::from("/tmp/.ydotool_socket"));
938    candidates
939}
940
941fn ydotool_socket_check() -> Check {
942    let mut checked = Vec::new();
943    for candidate in ydotool_socket_candidates() {
944        match socket_connect_result(&candidate) {
945            Ok(()) => return Check::ok(format!("connectable: {}", candidate.display())),
946            Err(detail) => checked.push(detail),
947        }
948    }
949
950    Check::fail(format!(
951        "no connectable ydotool socket ({})",
952        checked.join("; ")
953    ))
954}
955
956fn user_id() -> Option<String> {
957    let output = Command::new("id").arg("-u").output().ok()?;
958    output
959        .status
960        .success()
961        .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
962        .filter(|value| !value.is_empty())
963}
964
965fn command_path_check(command: &str) -> Check {
966    command_check("sh", &["-c", &format!("command -v {command}")])
967}
968
969fn platform_is_wayland(platform: &PlatformReport) -> bool {
970    match platform.xdg_session_type.as_deref() {
971        Some(value) => value.eq_ignore_ascii_case("wayland"),
972        None => platform
973            .wayland_display
974            .as_deref()
975            .is_some_and(|display| !display.trim().is_empty()),
976    }
977}
978
979fn should_advertise_xdotool(
980    platform: &PlatformReport,
981    input: &InputReport,
982    force_ydotool: bool,
983    force_xdotool: bool,
984) -> bool {
985    !force_ydotool
986        && input.xdotool.ok
987        && platform
988            .display
989            .as_deref()
990            .is_some_and(|display| !display.trim().is_empty())
991        && (force_xdotool || !platform_is_wayland(platform))
992}
993
994fn env_flag_enabled_any(keys: &[&str]) -> bool {
995    keys.iter()
996        .any(|key| env::var(key).ok().as_deref() == Some("1"))
997}
998
999fn force_portal_for_all_input(
1000    force_portal_pointer: bool,
1001    force_portal_keyboard: bool,
1002    force_ydotool_pointer: bool,
1003    force_ydotool_keyboard: bool,
1004) -> bool {
1005    force_portal_pointer
1006        && force_portal_keyboard
1007        && !force_ydotool_pointer
1008        && !force_ydotool_keyboard
1009}
1010
1011fn process_check(process_name: &str) -> Check {
1012    command_check("pgrep", &["-a", process_name])
1013}
1014
1015#[cfg(test)]
1016fn socket_connect_check(path: &Path) -> Check {
1017    match socket_connect_result(path) {
1018        Ok(()) => Check::ok(format!("connectable: {}", path.display())),
1019        Err(detail) => Check::fail(detail),
1020    }
1021}
1022
1023fn socket_connect_result(path: &Path) -> std::result::Result<(), String> {
1024    if !path.exists() {
1025        return Err(format!("missing: {}", path.display()));
1026    }
1027
1028    UnixDatagram::unbound()
1029        .and_then(|socket| socket.connect(path))
1030        .map_err(|error| format!("{}: datagram: {error}", path.display()))
1031}
1032
1033fn read_write_path_check(path: &Path) -> Check {
1034    if !path.exists() {
1035        return Check::fail(format!("missing: {}", path.display()));
1036    }
1037
1038    match OpenOptions::new().read(true).write(true).open(path) {
1039        Ok(_) => Check::ok(format!("read/write: {}", path.display())),
1040        Err(error) => Check::fail(format!("{}: {error}", path.display())),
1041    }
1042}
1043
1044fn bus_name_check(name: &str) -> Check {
1045    command_check_with_session_bus("busctl", &["--user", "status", name])
1046}
1047
1048fn portal_interface_check(interface: &str) -> Check {
1049    command_check_with_session_bus(
1050        "busctl",
1051        &[
1052            "--user",
1053            "introspect",
1054            "org.freedesktop.portal.Desktop",
1055            "/org/freedesktop/portal/desktop",
1056            interface,
1057        ],
1058    )
1059}
1060
1061fn remote_desktop_portal_checks() -> (Check, Check, Check) {
1062    let introspection = portal_interface_check("org.freedesktop.portal.RemoteDesktop");
1063    let screencast = portal_interface_check("org.freedesktop.portal.ScreenCast");
1064    if !introspection.ok {
1065        return (introspection.clone(), introspection, screencast);
1066    }
1067
1068    let available_device_types = command_check_with_session_bus(
1069        "busctl",
1070        &[
1071            "--user",
1072            "get-property",
1073            "org.freedesktop.portal.Desktop",
1074            "/org/freedesktop/portal/desktop",
1075            "org.freedesktop.portal.RemoteDesktop",
1076            "AvailableDeviceTypes",
1077        ],
1078    );
1079    let available_source_types = command_check_with_session_bus(
1080        "busctl",
1081        &[
1082            "--user",
1083            "get-property",
1084            "org.freedesktop.portal.Desktop",
1085            "/org/freedesktop/portal/desktop",
1086            "org.freedesktop.portal.ScreenCast",
1087            "AvailableSourceTypes",
1088        ],
1089    );
1090    let pointer = remote_desktop_pointer_check_from(
1091        &introspection,
1092        &screencast,
1093        &available_device_types,
1094        &available_source_types,
1095    );
1096    let keyboard = remote_desktop_keyboard_check_from(&introspection, &available_device_types);
1097    (pointer, keyboard, screencast)
1098}
1099
1100fn remote_desktop_pointer_check_from(
1101    introspection: &Check,
1102    screencast: &Check,
1103    available_device_types: &Check,
1104    available_source_types: &Check,
1105) -> Check {
1106    if !introspection.ok {
1107        return Check::fail(introspection.detail.clone());
1108    }
1109    let missing_methods = missing_busctl_methods(introspection, REMOTE_DESKTOP_POINTER_METHODS);
1110    if !missing_methods.is_empty() {
1111        return Check::fail(format!(
1112            "RemoteDesktop interface is missing required pointer methods: {}",
1113            missing_methods.join(", ")
1114        ));
1115    }
1116
1117    if !screencast.ok {
1118        return Check::fail(format!(
1119            "ScreenCast interface is unavailable for portal pointer input: {}",
1120            screencast.detail
1121        ));
1122    }
1123    let missing_screencast_methods = missing_busctl_methods(screencast, SCREENCAST_POINTER_METHODS);
1124    if !missing_screencast_methods.is_empty() {
1125        return Check::fail(format!(
1126            "ScreenCast interface is missing required pointer methods: {}",
1127            missing_screencast_methods.join(", ")
1128        ));
1129    }
1130
1131    let device_types = match remote_desktop_device_types(available_device_types) {
1132        Ok(device_types) => device_types,
1133        Err(detail) => return Check::fail(detail),
1134    };
1135    if device_types & PORTAL_DEVICE_POINTER == 0 {
1136        return Check::fail(format!(
1137            "RemoteDesktop AvailableDeviceTypes={device_types} does not include pointer input"
1138        ));
1139    }
1140
1141    let source_types = match screencast_source_types(available_source_types) {
1142        Ok(source_types) => source_types,
1143        Err(detail) => return Check::fail(detail),
1144    };
1145    if source_types & PORTAL_SOURCE_MONITOR == 0 {
1146        return Check::fail(format!(
1147            "ScreenCast AvailableSourceTypes={source_types} does not include monitor sources"
1148        ));
1149    }
1150
1151    Check::ok(format!(
1152        "pointer-capable RemoteDesktop portal (AvailableDeviceTypes={device_types}, AvailableSourceTypes={source_types})"
1153    ))
1154}
1155
1156fn remote_desktop_keyboard_check_from(
1157    introspection: &Check,
1158    available_device_types: &Check,
1159) -> Check {
1160    if !introspection.ok {
1161        return Check::fail(introspection.detail.clone());
1162    }
1163
1164    let missing_methods = missing_busctl_methods(introspection, REMOTE_DESKTOP_KEYBOARD_METHODS);
1165    if !missing_methods.is_empty() {
1166        return Check::fail(format!(
1167            "RemoteDesktop interface is missing required keyboard methods: {}",
1168            missing_methods.join(", ")
1169        ));
1170    }
1171
1172    let device_types = match remote_desktop_device_types(available_device_types) {
1173        Ok(device_types) => device_types,
1174        Err(detail) => return Check::fail(detail),
1175    };
1176    if device_types & PORTAL_DEVICE_KEYBOARD == 0 {
1177        return Check::fail(format!(
1178            "RemoteDesktop AvailableDeviceTypes={device_types} does not include keyboard input"
1179        ));
1180    }
1181
1182    Check::ok(format!(
1183        "keyboard-capable RemoteDesktop portal (AvailableDeviceTypes={device_types})"
1184    ))
1185}
1186
1187fn missing_busctl_methods<'a>(introspection: &Check, methods: &'a [&'a str]) -> Vec<&'a str> {
1188    methods
1189        .iter()
1190        .copied()
1191        .filter(|method| !busctl_introspection_has_method(&introspection.detail, method))
1192        .collect()
1193}
1194
1195fn remote_desktop_device_types(available_device_types: &Check) -> Result<u32, String> {
1196    if !available_device_types.ok {
1197        return Err(format!(
1198            "RemoteDesktop AvailableDeviceTypes is unavailable: {}",
1199            available_device_types.detail
1200        ));
1201    }
1202    parse_busctl_u32_property(&available_device_types.detail).ok_or_else(|| {
1203        format!(
1204            "RemoteDesktop AvailableDeviceTypes has an unexpected value: {}",
1205            available_device_types.detail
1206        )
1207    })
1208}
1209
1210fn screencast_source_types(available_source_types: &Check) -> Result<u32, String> {
1211    if !available_source_types.ok {
1212        return Err(format!(
1213            "ScreenCast AvailableSourceTypes is unavailable: {}",
1214            available_source_types.detail
1215        ));
1216    }
1217    parse_busctl_u32_property(&available_source_types.detail).ok_or_else(|| {
1218        format!(
1219            "ScreenCast AvailableSourceTypes has an unexpected value: {}",
1220            available_source_types.detail
1221        )
1222    })
1223}
1224
1225fn busctl_introspection_has_method(detail: &str, method: &str) -> bool {
1226    detail.lines().any(|line| {
1227        let mut fields = line.split_whitespace();
1228        fields
1229            .next()
1230            .is_some_and(|name| name.trim_start_matches('.') == method)
1231            && fields.next() == Some("method")
1232    })
1233}
1234
1235fn parse_busctl_u32_property(detail: &str) -> Option<u32> {
1236    let mut fields = detail.split_whitespace();
1237    if fields.next()? != "u" {
1238        return None;
1239    }
1240    let value = fields.next()?;
1241    value
1242        .strip_prefix("0x")
1243        .map(|hex| u32::from_str_radix(hex, 16).ok())
1244        .unwrap_or_else(|| value.parse().ok())
1245}
1246
1247fn atspi_bus_address_check() -> Check {
1248    let busctl = command_check_with_session_bus(
1249        "busctl",
1250        &[
1251            "--user",
1252            "call",
1253            "org.a11y.Bus",
1254            "/org/a11y/bus",
1255            "org.a11y.Bus",
1256            "GetAddress",
1257        ],
1258    );
1259    if busctl.ok {
1260        return busctl;
1261    }
1262
1263    gdbus_call_check(
1264        "org.a11y.Bus",
1265        "/org/a11y/bus",
1266        "org.a11y.Bus.GetAddress",
1267        &[],
1268    )
1269}
1270
1271fn atspi_status_property_check(property: &str) -> Check {
1272    let busctl = command_check_with_session_bus(
1273        "busctl",
1274        &[
1275            "--user",
1276            "get-property",
1277            "org.a11y.Bus",
1278            "/org/a11y/bus",
1279            "org.a11y.Status",
1280            property,
1281        ],
1282    );
1283    if busctl.ok {
1284        return busctl;
1285    }
1286
1287    gdbus_call_check(
1288        "org.a11y.Bus",
1289        "/org/a11y/bus",
1290        "org.freedesktop.DBus.Properties.Get",
1291        &["org.a11y.Status", property],
1292    )
1293}
1294
1295fn gdbus_call_check(destination: &str, object_path: &str, method: &str, args: &[&str]) -> Check {
1296    let mut command_args = vec![
1297        "call",
1298        "--session",
1299        "--dest",
1300        destination,
1301        "--object-path",
1302        object_path,
1303        "--method",
1304        method,
1305    ];
1306    command_args.extend_from_slice(args);
1307    command_check_with_session_bus("gdbus", &command_args)
1308}
1309
1310fn command_check(command: &str, args: &[&str]) -> Check {
1311    run_command(command, args, false)
1312}
1313
1314fn command_check_with_session_bus(command: &str, args: &[&str]) -> Check {
1315    run_command(command, args, true)
1316}
1317
1318fn run_command(command: &str, args: &[&str], with_session_bus: bool) -> Check {
1319    let mut cmd = Command::new(command);
1320    cmd.args(args);
1321
1322    if with_session_bus {
1323        if let Some(address) = dbus_session_address() {
1324            cmd.env("DBUS_SESSION_BUS_ADDRESS", address);
1325        }
1326        if let Some(runtime) = xdg_runtime_dir() {
1327            cmd.env("XDG_RUNTIME_DIR", runtime);
1328        }
1329    }
1330
1331    match cmd.output() {
1332        Ok(output) if output.status.success() => {
1333            let detail = String::from_utf8_lossy(&output.stdout).trim().to_string();
1334            Check::ok(if detail.is_empty() {
1335                "ok".into()
1336            } else {
1337                detail
1338            })
1339        }
1340        Ok(output) => {
1341            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1342            let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
1343            let detail = if !stderr.is_empty() { stderr } else { stdout };
1344            Check::fail(if detail.is_empty() {
1345                format!("exit status {}", output.status)
1346            } else {
1347                detail
1348            })
1349        }
1350        Err(error) => Check::fail(error.to_string()),
1351    }
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356    use super::*;
1357
1358    fn platform_report() -> PlatformReport {
1359        PlatformReport {
1360            os: "linux".to_string(),
1361            arch: "x86_64".to_string(),
1362            desktop_session: None,
1363            xdg_session_type: Some("wayland".to_string()),
1364            xdg_current_desktop: Some("GNOME".to_string()),
1365            wayland_display: Some("wayland-0".to_string()),
1366            display: Some(":0".to_string()),
1367            xauthority: Some("/run/user/1000/Xauthority".to_string()),
1368            dbus_session_bus_address: Some("unix:path=/run/user/1000/bus".to_string()),
1369            xdg_runtime_dir: Some("/run/user/1000".to_string()),
1370            gnome_shell_version: Check::ok("GNOME Shell 46.0"),
1371            gnome_screenshot: Check::ok("gnome-screenshot 41.0"),
1372        }
1373    }
1374
1375    fn portal_report(remote_desktop: Check) -> PortalReport {
1376        PortalReport {
1377            desktop_portal: Check::ok("ok"),
1378            remote_desktop,
1379            screencast: Check::fail("missing"),
1380            screenshot: Check::fail("missing"),
1381            input_capture: Check::fail("missing"),
1382            mutter_remote_desktop: Check::fail("missing"),
1383            mutter_screencast: Check::fail("missing"),
1384        }
1385    }
1386
1387    fn accessibility_report(
1388        at_spi_bus: Check,
1389        toolkit_accessibility: Check,
1390    ) -> AccessibilityReport {
1391        AccessibilityReport {
1392            at_spi_bus,
1393            toolkit_accessibility,
1394            at_spi_enabled: Check::fail("(<false>,)"),
1395            screen_reader_enabled: Check::fail("(<false>,)"),
1396        }
1397    }
1398
1399    fn windowing_report(can_list_windows: bool, can_focus_windows: bool) -> WindowingReport {
1400        WindowingReport {
1401            gnome_shell_introspect: if can_list_windows {
1402                Check::ok("ok")
1403            } else {
1404                Check::fail("denied")
1405            },
1406            computer_use_linux_gnome_shell_extension: if can_focus_windows {
1407                Check::ok("ok")
1408            } else {
1409                Check::fail("missing")
1410            },
1411            cosmic_helper: Check::fail("missing"),
1412            kwin: Check::fail("not a KWin session"),
1413            hyprland: Check::fail("not a Hyprland session"),
1414            backends: BTreeMap::new(),
1415            can_list_windows,
1416            can_focus_apps: true,
1417            can_focus_windows,
1418            note: String::new(),
1419        }
1420    }
1421
1422    fn input_report(can_send_input: bool) -> InputReport {
1423        let check = if can_send_input {
1424            Check::ok("ok")
1425        } else {
1426            Check::fail("missing")
1427        };
1428        input_report_parts(check.clone(), check.clone(), check.clone(), check)
1429    }
1430
1431    fn input_report_parts(
1432        ydotool: Check,
1433        ydotoold: Check,
1434        ydotool_socket: Check,
1435        uinput: Check,
1436    ) -> InputReport {
1437        InputReport {
1438            ydotool,
1439            ydotoold,
1440            ydotool_socket,
1441            uinput,
1442            xdotool: Check::fail("missing xdotool"),
1443        }
1444    }
1445
1446    #[test]
1447    fn accessibility_tree_requires_reachable_at_spi_bus() {
1448        let report = accessibility_report(Check::fail("permission denied"), Check::ok("true"));
1449
1450        assert!(!can_build_accessibility_tree(&report));
1451    }
1452
1453    #[test]
1454    fn accessibility_tree_is_ready_when_bus_and_toolkit_are_ready() {
1455        let report = accessibility_report(
1456            Check::ok("('unix:path=/run/user/1000/at-spi/bus',)"),
1457            Check::ok("true"),
1458        );
1459
1460        assert!(can_build_accessibility_tree(&report));
1461    }
1462
1463    #[test]
1464    fn parses_parent_pid_from_proc_status() {
1465        let status = "Name:\ttest\nPid:\t42\nPPid:\t7\n";
1466
1467        assert_eq!(parse_parent_pid(status), Some(7));
1468    }
1469
1470    #[test]
1471    fn parses_nul_separated_process_environment() {
1472        let environment = parse_environ(
1473            b"DISPLAY=:0\0WAYLAND_DISPLAY=wayland-0\0EMPTY=\0NO_EQUALS\0XDG_SESSION_TYPE=wayland\0",
1474        );
1475
1476        assert_eq!(environment.get("DISPLAY").map(String::as_str), Some(":0"));
1477        assert_eq!(
1478            environment.get("WAYLAND_DISPLAY").map(String::as_str),
1479            Some("wayland-0")
1480        );
1481        assert_eq!(environment.get("EMPTY").map(String::as_str), Some(""));
1482        assert!(!environment.contains_key("NO_EQUALS"));
1483    }
1484
1485    #[test]
1486    fn desktop_env_hydration_includes_xauthority() {
1487        assert!(DESKTOP_ENV_KEYS.contains(&"XAUTHORITY"));
1488    }
1489
1490    #[test]
1491    fn desktop_env_hydration_preserves_explicit_native_x11() {
1492        let current_env = HashMap::from([
1493            ("DISPLAY".to_string(), ":90".to_string()),
1494            ("XDG_SESSION_TYPE".to_string(), "x11".to_string()),
1495        ]);
1496        let host_env = HashMap::from([
1497            ("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string()),
1498            (
1499                "XDG_CURRENT_DESKTOP".to_string(),
1500                "ubuntu:GNOME".to_string(),
1501            ),
1502        ]);
1503
1504        let updates = desktop_env_hydration_updates(&current_env, &host_env);
1505
1506        assert!(!updates.iter().any(|(key, _)| *key == "WAYLAND_DISPLAY"));
1507        assert!(updates
1508            .iter()
1509            .any(|(key, value)| { *key == "XDG_CURRENT_DESKTOP" && value == "ubuntu:GNOME" }));
1510    }
1511
1512    #[test]
1513    fn desktop_env_hydration_still_imports_wayland_for_incomplete_sessions() {
1514        let current_env = HashMap::new();
1515        let host_env = HashMap::from([("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string())]);
1516
1517        let updates = desktop_env_hydration_updates(&current_env, &host_env);
1518
1519        assert!(updates
1520            .iter()
1521            .any(|(key, value)| *key == "WAYLAND_DISPLAY" && value == "wayland-0"));
1522    }
1523
1524    #[test]
1525    fn graphical_process_env_requires_display() {
1526        let with_display = HashMap::from([("DISPLAY".to_string(), ":0".to_string())]);
1527        let with_wayland =
1528            HashMap::from([("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string())]);
1529        let without_display = HashMap::from([("XAUTHORITY".to_string(), "/tmp/xauth".to_string())]);
1530
1531        assert!(process_env_has_graphical_display(&with_display));
1532        assert!(process_env_has_graphical_display(&with_wayland));
1533        assert!(!process_env_has_graphical_display(&without_display));
1534    }
1535
1536    #[test]
1537    fn capabilities_prefer_xdotool_before_ydotool_on_x11() {
1538        let mut platform = platform_report();
1539        platform.xdg_session_type = Some("x11".to_string());
1540        platform.wayland_display = None;
1541        platform.display = Some(":0".to_string());
1542        let input = InputReport {
1543            ydotool: Check::ok("ydotool"),
1544            ydotoold: Check::ok("ydotoold"),
1545            ydotool_socket: Check::ok("connectable"),
1546            uinput: Check::fail("missing"),
1547            xdotool: Check::ok("xdotool"),
1548        };
1549
1550        let capabilities = capability_map(
1551            &platform,
1552            &portal_report(Check::fail("missing")),
1553            &accessibility_report(Check::fail("missing"), Check::fail("missing")),
1554            &windowing_report(false, false),
1555            &input,
1556        );
1557
1558        assert_eq!(capabilities.input, ["xdotool", "ydotool"]);
1559        assert_eq!(capabilities.preferred.input.as_deref(), Some("xdotool"));
1560    }
1561
1562    #[test]
1563    fn x11_diagnostics_ignore_portal_and_accept_xdotool() {
1564        let mut platform = platform_report();
1565        platform.xdg_session_type = Some("x11".to_string());
1566        platform.wayland_display = None;
1567        platform.display = Some(":0".to_string());
1568        let portals = portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop"));
1569        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1570        let windowing = windowing_report(true, true);
1571        let mut input = input_report(false);
1572        input.xdotool = Check::ok("xdotool");
1573
1574        let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1575        let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
1576
1577        assert_eq!(capabilities.input, ["xdotool"]);
1578        assert_eq!(capabilities.preferred.input.as_deref(), Some("xdotool"));
1579        assert!(readiness.can_send_development_input);
1580    }
1581
1582    #[test]
1583    fn wayland_diagnostics_prefer_ydotool_before_portal() {
1584        let platform = platform_report();
1585        let portals = portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop"));
1586        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1587        let windowing = windowing_report(true, true);
1588        let input = input_report_parts(
1589            Check::ok("ydotool"),
1590            Check::ok("ydotoold"),
1591            Check::ok("connectable"),
1592            Check::fail("missing uinput"),
1593        );
1594
1595        let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1596
1597        assert_eq!(capabilities.input, ["ydotool", "portal"]);
1598        assert_eq!(capabilities.preferred.input.as_deref(), Some("ydotool"));
1599    }
1600
1601    #[test]
1602    fn portal_force_order_requires_both_input_modalities() {
1603        assert!(force_portal_for_all_input(true, true, false, false));
1604        assert!(!force_portal_for_all_input(true, false, false, false));
1605        assert!(!force_portal_for_all_input(true, true, true, false));
1606        assert!(!force_portal_for_all_input(true, true, false, true));
1607    }
1608
1609    fn remote_desktop_runtime_introspection() -> Check {
1610        Check::ok(
1611            "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n\
1612             .CreateSession method a{sv} o -\n\
1613             .SelectDevices method oa{sv} o -\n\
1614             .Start method osa{sv} o -\n\
1615             .NotifyPointerMotionAbsolute method oa{sv}udd - -\n\
1616             .NotifyPointerButton method oa{sv}iu - -\n\
1617             .NotifyPointerAxisDiscrete method oa{sv}ui - -\n\
1618             .NotifyKeyboardKeycode method ouu - -\n\
1619             .NotifyKeyboardKeysym method ouu - -\n\
1620             .AvailableDeviceTypes property u 3 emits-change",
1621        )
1622    }
1623
1624    fn screencast_runtime_introspection() -> Check {
1625        Check::ok(
1626            "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n\
1627             .SelectSources method oa{sv} o -",
1628        )
1629    }
1630
1631    #[test]
1632    fn remote_desktop_portal_rejects_header_only_introspection() {
1633        let introspection = Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS");
1634        let available_device_types = Check::ok("u 3");
1635        let pointer = remote_desktop_pointer_check_from(
1636            &introspection,
1637            &screencast_runtime_introspection(),
1638            &available_device_types,
1639            &Check::ok("u 1"),
1640        );
1641        let keyboard = remote_desktop_keyboard_check_from(&introspection, &available_device_types);
1642
1643        assert!(!pointer.ok);
1644        assert!(pointer.detail.contains("CreateSession"));
1645        assert!(pointer.detail.contains("NotifyPointerButton"));
1646        assert!(!keyboard.ok);
1647        assert!(keyboard.detail.contains("NotifyKeyboardKeysym"));
1648
1649        let input = input_report_parts(
1650            Check::ok("ydotool"),
1651            Check::ok("ydotoold"),
1652            Check::fail("no connectable ydotool socket"),
1653            Check::ok("read/write: /dev/uinput"),
1654        );
1655        let platform = platform_report();
1656        let portals = portal_report(pointer);
1657        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1658        let windowing = windowing_report(true, true);
1659        let capabilities = capability_map_with_portal_keyboard(
1660            &platform,
1661            &portals,
1662            &keyboard,
1663            &accessibility,
1664            &windowing,
1665            &input,
1666        );
1667        let readiness = readiness_report_with_portal_keyboard(
1668            &platform,
1669            &keyboard,
1670            &accessibility,
1671            &windowing,
1672            &input,
1673        );
1674
1675        assert!(!capabilities.input.iter().any(|backend| backend == "portal"));
1676        assert!(!readiness.can_send_development_input);
1677    }
1678
1679    #[test]
1680    fn keyboard_only_portal_remains_advertised_without_pointer_capability() {
1681        let platform = platform_report();
1682        let keyboard = remote_desktop_keyboard_check_from(
1683            &remote_desktop_runtime_introspection(),
1684            &Check::ok("u 1"),
1685        );
1686        let portals = portal_report(Check::fail(
1687            "ScreenCast AvailableSourceTypes=2 does not include monitor sources",
1688        ));
1689        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1690        let windowing = windowing_report(true, true);
1691        let input = input_report_parts(
1692            Check::fail("missing ydotool"),
1693            Check::fail("ydotoold not running"),
1694            Check::fail("no connectable ydotool socket"),
1695            Check::fail("/dev/uinput: Permission denied"),
1696        );
1697
1698        let capabilities = capability_map_with_portal_keyboard(
1699            &platform,
1700            &portals,
1701            &keyboard,
1702            &accessibility,
1703            &windowing,
1704            &input,
1705        );
1706        let readiness = readiness_report_with_portal_keyboard(
1707            &platform,
1708            &keyboard,
1709            &accessibility,
1710            &windowing,
1711            &input,
1712        );
1713
1714        assert!(!portals.remote_desktop.ok);
1715        assert_eq!(capabilities.input, ["portal"]);
1716        assert_eq!(capabilities.preferred.input.as_deref(), Some("portal"));
1717        assert!(readiness.can_send_development_input);
1718    }
1719
1720    #[test]
1721    fn remote_desktop_pointer_rejects_missing_screencast_contract() {
1722        let check = remote_desktop_pointer_check_from(
1723            &remote_desktop_runtime_introspection(),
1724            &Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS"),
1725            &Check::ok("u 2"),
1726            &Check::ok("u 1"),
1727        );
1728
1729        assert!(!check.ok);
1730        assert!(check.detail.contains("SelectSources"));
1731    }
1732
1733    #[test]
1734    fn remote_desktop_pointer_rejects_missing_monitor_source_type() {
1735        let check = remote_desktop_pointer_check_from(
1736            &remote_desktop_runtime_introspection(),
1737            &screencast_runtime_introspection(),
1738            &Check::ok("u 2"),
1739            &Check::ok("u 2"),
1740        );
1741
1742        assert!(!check.ok);
1743        assert!(check.detail.contains("does not include monitor sources"));
1744    }
1745
1746    #[test]
1747    fn remote_desktop_portal_rejects_missing_keyboard_device_type() {
1748        let check = remote_desktop_keyboard_check_from(
1749            &remote_desktop_runtime_introspection(),
1750            &Check::ok("u 2"),
1751        );
1752
1753        assert!(!check.ok);
1754        assert!(check.detail.contains("does not include keyboard input"));
1755    }
1756
1757    #[test]
1758    fn remote_desktop_portal_accepts_runtime_keyboard_contract() {
1759        let check = remote_desktop_keyboard_check_from(
1760            &remote_desktop_runtime_introspection(),
1761            &Check::ok("u 3"),
1762        );
1763
1764        assert!(check.ok);
1765        assert!(check.detail.contains("AvailableDeviceTypes=3"));
1766    }
1767
1768    #[test]
1769    fn pointer_only_portal_remains_advertised_without_keyboard_readiness() {
1770        let platform = platform_report();
1771        let available_device_types = Check::ok("u 2");
1772        let pointer = remote_desktop_pointer_check_from(
1773            &remote_desktop_runtime_introspection(),
1774            &screencast_runtime_introspection(),
1775            &available_device_types,
1776            &Check::ok("u 1"),
1777        );
1778        let keyboard = remote_desktop_keyboard_check_from(
1779            &remote_desktop_runtime_introspection(),
1780            &available_device_types,
1781        );
1782        let portals = portal_report(pointer);
1783        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1784        let windowing = windowing_report(true, true);
1785        let input = input_report_parts(
1786            Check::fail("missing ydotool"),
1787            Check::fail("ydotoold not running"),
1788            Check::fail("no connectable ydotool socket"),
1789            Check::fail("/dev/uinput: Permission denied"),
1790        );
1791
1792        let capabilities = capability_map_with_portal_keyboard(
1793            &platform,
1794            &portals,
1795            &keyboard,
1796            &accessibility,
1797            &windowing,
1798            &input,
1799        );
1800        let readiness = readiness_report_with_portal_keyboard(
1801            &platform,
1802            &keyboard,
1803            &accessibility_report(Check::ok("bus"), Check::ok("true")),
1804            &windowing_report(true, true),
1805            &input,
1806        );
1807
1808        assert!(portals.remote_desktop.ok);
1809        assert!(capabilities.input.iter().any(|backend| backend == "portal"));
1810        assert!(!readiness.can_send_development_input);
1811    }
1812
1813    #[test]
1814    fn capabilities_require_display_to_advertise_xdotool() {
1815        let mut platform = platform_report();
1816        platform.xdg_session_type = Some("x11".to_string());
1817        platform.wayland_display = None;
1818        platform.display = None;
1819        let input = InputReport {
1820            ydotool: Check::ok("ydotool"),
1821            ydotoold: Check::ok("ydotoold"),
1822            ydotool_socket: Check::ok("connectable"),
1823            uinput: Check::fail("missing"),
1824            xdotool: Check::ok("xdotool"),
1825        };
1826
1827        let capabilities = capability_map(
1828            &platform,
1829            &portal_report(Check::fail("missing")),
1830            &accessibility_report(Check::fail("missing"), Check::fail("missing")),
1831            &windowing_report(false, false),
1832            &input,
1833        );
1834
1835        assert_eq!(capabilities.input, ["ydotool"]);
1836        assert_eq!(capabilities.preferred.input.as_deref(), Some("ydotool"));
1837    }
1838
1839    #[test]
1840    fn xdotool_diagnostics_force_precedence_matches_runtime() {
1841        let mut platform = platform_report();
1842        platform.xdg_session_type = Some("wayland".to_string());
1843        platform.display = Some(":0".to_string());
1844        let mut input = input_report(false);
1845        input.xdotool = Check::ok("xdotool");
1846
1847        assert!(should_advertise_xdotool(&platform, &input, false, true));
1848        assert!(!should_advertise_xdotool(&platform, &input, true, true));
1849    }
1850
1851    #[test]
1852    fn parses_systemd_show_environment_output() {
1853        let environment = parse_line_environment(
1854            b"DISPLAY=:0\nHYPRLAND_INSTANCE_SIGNATURE=abc\nNO_EQUALS\nYDOTOOL_SOCKET=/run/ydotoold/socket\n",
1855        );
1856
1857        assert_eq!(environment.get("DISPLAY").map(String::as_str), Some(":0"));
1858        assert_eq!(
1859            environment
1860                .get("HYPRLAND_INSTANCE_SIGNATURE")
1861                .map(String::as_str),
1862            Some("abc")
1863        );
1864        assert_eq!(
1865            environment.get("YDOTOOL_SOCKET").map(String::as_str),
1866            Some("/run/ydotoold/socket")
1867        );
1868        assert!(!environment.contains_key("NO_EQUALS"));
1869    }
1870
1871    #[test]
1872    fn readiness_requires_exact_window_focus_for_targeted_input() {
1873        let platform = platform_report();
1874        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1875        let windowing = windowing_report(true, false);
1876        let input = input_report(true);
1877
1878        let readiness = readiness_report(
1879            &platform,
1880            &portal_report(Check::fail("missing")),
1881            &accessibility,
1882            &windowing,
1883            &input,
1884        );
1885
1886        assert!(readiness.can_query_windows);
1887        assert!(!readiness.can_focus_windows);
1888        assert!(readiness
1889            .recommended_next_step
1890            .contains("exact-focus window backend"));
1891        assert!(readiness
1892            .blockers
1893            .iter()
1894            .any(|blocker| blocker.contains("Exact window activation")));
1895    }
1896
1897    #[test]
1898    fn readiness_treats_kwin_as_full_window_backend() {
1899        let platform = platform_report();
1900        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1901        let mut windowing = windowing_report(false, false);
1902        windowing.kwin = Check::ok("KWin scripting is available");
1903        windowing.can_list_windows = true;
1904        windowing.can_focus_apps = true;
1905        windowing.can_focus_windows = true;
1906        let input = input_report(true);
1907
1908        let readiness = readiness_report(
1909            &platform,
1910            &portal_report(Check::fail("missing")),
1911            &accessibility,
1912            &windowing,
1913            &input,
1914        );
1915
1916        assert!(readiness.can_query_windows);
1917        assert!(readiness.can_focus_apps);
1918        assert!(readiness.can_focus_windows);
1919        assert!(readiness.blockers.is_empty());
1920    }
1921
1922    #[test]
1923    fn readiness_message_mentions_generic_window_targeting() {
1924        let platform = platform_report();
1925        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1926        let windowing = windowing_report(true, true);
1927        let input = input_report(true);
1928
1929        let readiness = readiness_report(
1930            &platform,
1931            &portal_report(Check::fail("missing")),
1932            &accessibility,
1933            &windowing,
1934            &input,
1935        );
1936
1937        assert!(readiness.blockers.is_empty());
1938        assert!(readiness
1939            .recommended_next_step
1940            .contains("AT-SPI tree support"));
1941        assert!(readiness.recommended_next_step.contains("window targeting"));
1942        assert!(!readiness
1943            .recommended_next_step
1944            .contains("GNOME window targeting"));
1945    }
1946
1947    #[test]
1948    fn readiness_accepts_connectable_ydotool_socket_without_direct_uinput_access() {
1949        let platform = platform_report();
1950        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1951        let windowing = windowing_report(true, true);
1952        let input = input_report_parts(
1953            Check::ok("ydotool"),
1954            Check::ok("ydotoold"),
1955            Check::ok("connectable: /tmp/.ydotool_socket"),
1956            Check::fail("/dev/uinput: Permission denied"),
1957        );
1958
1959        let readiness = readiness_report(
1960            &platform,
1961            &portal_report(Check::fail("missing")),
1962            &accessibility,
1963            &windowing,
1964            &input,
1965        );
1966
1967        assert!(readiness.can_send_development_input);
1968        assert!(readiness.blockers.is_empty());
1969    }
1970
1971    #[test]
1972    fn readiness_uses_connectable_ydotool_socket_when_process_probe_fails() {
1973        let platform = platform_report();
1974        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1975        let windowing = windowing_report(true, true);
1976        let input = input_report_parts(
1977            Check::ok("ydotool"),
1978            Check::fail("ydotoold process name not found"),
1979            Check::ok("connectable: /run/user/1000/.ydotool_socket"),
1980            Check::fail("/dev/uinput: Permission denied"),
1981        );
1982        let portals = portal_report(Check::fail("missing"));
1983
1984        let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
1985        let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
1986
1987        assert!(capabilities
1988            .input
1989            .iter()
1990            .any(|backend| backend == "ydotool"));
1991        assert!(readiness.can_send_development_input);
1992    }
1993
1994    #[test]
1995    fn wayland_readiness_rejects_pointer_only_uinput_without_keyboard_backend() {
1996        let platform = platform_report();
1997        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
1998        let windowing = windowing_report(true, true);
1999        let input = input_report_parts(
2000            Check::ok("ydotool"),
2001            Check::fail("ydotoold not running"),
2002            Check::fail("no connectable ydotool socket"),
2003            Check::ok("read/write: /dev/uinput"),
2004        );
2005
2006        let readiness = readiness_report(
2007            &platform,
2008            &portal_report(Check::fail("missing")),
2009            &accessibility,
2010            &windowing,
2011            &input,
2012        );
2013
2014        assert!(!readiness.can_send_development_input);
2015        assert!(readiness
2016            .blockers
2017            .iter()
2018            .any(|blocker| blocker.contains("absolute pointer input")));
2019        assert!(readiness
2020            .recommended_next_step
2021            .contains("keyboard-capable input backend"));
2022    }
2023
2024    #[test]
2025    fn x11_readiness_rejects_pointer_only_uinput_without_keyboard_backend() {
2026        let mut platform = platform_report();
2027        platform.xdg_session_type = Some("x11".to_string());
2028        platform.wayland_display = None;
2029        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2030        let windowing = windowing_report(true, true);
2031        let input = input_report_parts(
2032            Check::fail("missing ydotool"),
2033            Check::fail("ydotoold not running"),
2034            Check::fail("no connectable ydotool socket"),
2035            Check::ok("read/write: /dev/uinput"),
2036        );
2037
2038        let readiness = readiness_report(
2039            &platform,
2040            &portal_report(Check::fail("missing")),
2041            &accessibility,
2042            &windowing,
2043            &input,
2044        );
2045
2046        assert!(!readiness.can_send_development_input);
2047    }
2048
2049    #[test]
2050    fn readiness_accepts_remote_desktop_portal_without_local_input_backend() {
2051        let platform = platform_report();
2052        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2053        let windowing = windowing_report(true, true);
2054        let input = input_report_parts(
2055            Check::fail("missing ydotool"),
2056            Check::fail("ydotoold not running"),
2057            Check::fail("no connectable ydotool socket"),
2058            Check::fail("/dev/uinput: Permission denied"),
2059        );
2060
2061        let readiness = readiness_report(
2062            &platform,
2063            &portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop")),
2064            &accessibility,
2065            &windowing,
2066            &input,
2067        );
2068
2069        assert!(readiness.can_send_development_input);
2070        assert!(readiness.blockers.is_empty());
2071    }
2072
2073    #[test]
2074    fn readiness_rejects_inaccessible_ydotool_paths() {
2075        let platform = platform_report();
2076        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2077        let windowing = windowing_report(true, true);
2078        let input = input_report_parts(
2079            Check::ok("ydotool"),
2080            Check::ok("ydotoold"),
2081            Check::fail("/tmp/.ydotool_socket: Permission denied"),
2082            Check::fail("/dev/uinput: Permission denied"),
2083        );
2084
2085        let readiness = readiness_report(
2086            &platform,
2087            &portal_report(Check::fail("missing")),
2088            &accessibility,
2089            &windowing,
2090            &input,
2091        );
2092
2093        assert!(!readiness.can_send_development_input);
2094        assert!(readiness
2095            .recommended_next_step
2096            .contains("Enable a keyboard-capable input backend"));
2097        assert!(readiness
2098            .blockers
2099            .iter()
2100            .any(|blocker| blocker.contains("Development keyboard input is unavailable")));
2101    }
2102
2103    #[test]
2104    fn capability_map_rejects_incompatible_ydotool_with_live_daemon() {
2105        let platform = platform_report();
2106        let portals = portal_report(Check::fail("missing"));
2107        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2108        let windowing = windowing_report(true, true);
2109        let input = input_report_parts(
2110            Check::fail("unsupported legacy ydotool CLI"),
2111            Check::ok("ydotoold"),
2112            Check::ok("connectable socket"),
2113            Check::fail("/dev/uinput: Permission denied"),
2114        );
2115
2116        let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);
2117        let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input);
2118
2119        assert!(!capabilities
2120            .input
2121            .iter()
2122            .any(|backend| backend == "ydotool"));
2123        assert!(!readiness.can_send_development_input);
2124    }
2125
2126    #[test]
2127    fn ydotool_socket_check_rejects_legacy_stream_socket() {
2128        let dir = std::env::temp_dir().join(format!(
2129            "computer-use-linux-diagnostics-{}",
2130            std::process::id()
2131        ));
2132        let _ = std::fs::remove_dir_all(&dir);
2133        std::fs::create_dir_all(&dir).expect("create temp diagnostics dir");
2134        let socket = dir.join("ydotool.sock");
2135        let listener =
2136            std::os::unix::net::UnixListener::bind(&socket).expect("bind temp diagnostics socket");
2137
2138        let check = socket_connect_check(&socket);
2139
2140        assert!(!check.ok, "{check:?}");
2141        drop(listener);
2142        let _ = std::fs::remove_dir_all(&dir);
2143    }
2144
2145    #[test]
2146    fn ydotool_socket_check_accepts_datagram_socket() {
2147        let dir = std::env::temp_dir().join(format!(
2148            "computer-use-linux-diagnostics-dgram-{}",
2149            std::process::id()
2150        ));
2151        let _ = std::fs::remove_dir_all(&dir);
2152        std::fs::create_dir_all(&dir).expect("create temp diagnostics dir");
2153        let socket = dir.join("ydotool.sock");
2154        let datagram =
2155            std::os::unix::net::UnixDatagram::bind(&socket).expect("bind temp datagram socket");
2156
2157        let check = socket_connect_check(&socket);
2158
2159        assert!(check.ok, "{check:?}");
2160        drop(datagram);
2161        let _ = std::fs::remove_dir_all(&dir);
2162    }
2163
2164    #[test]
2165    fn readiness_reports_cosmic_window_blocker_on_cosmic() {
2166        let mut platform = platform_report();
2167        platform.xdg_current_desktop = Some("COSMIC".to_string());
2168        let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
2169        let windowing = windowing_report(false, false);
2170        let input = input_report(true);
2171
2172        let readiness = readiness_report(
2173            &platform,
2174            &portal_report(Check::fail("missing")),
2175            &accessibility,
2176            &windowing,
2177            &input,
2178        );
2179
2180        assert!(readiness
2181            .blockers
2182            .iter()
2183            .any(|blocker| blocker.contains("COSMIC Wayland window introspection")));
2184    }
2185}