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