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