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