Skip to main content

computer_use_linux/
diagnostics.rs

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