Skip to main content

computer_use_linux/
diagnostics.rs

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