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