Skip to main content

computer_use_linux/
diagnostics.rs

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