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