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