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