Skip to main content

computer_use/backend/linux/
mod.rs

1//! Linux desktop backend.
2//!
3//! P13a wires two real subsystems in:
4//!
5//! - **AT-SPI** (`atspi.rs`) — the accessibility tree, doctor probe,
6//!   redaction contract, and element actions. Live tree building through
7//!   `AccessibilityConnection` with a per-call budget. `press`,
8//!   `focus`, `set_value`, `select_text`, `scroll_to`, and `type` run
9//!   through `Action::do_action`, `Value::set_current_value`,
10//!   `EditableText::set_text_contents`, `Text::set_selection`, and
11//!   `Component::scroll_to` / `Component::grab_focus`; the same
12//!   `crate::element::gate` revalidation macOS / Windows use; secure
13//!   `Role::PasswordText` nodes refuse `set_value`, `select_text`, and
14//!   `type --element`.
15//! - **XDG portals** (`portal.rs`) — `screencast` capture and
16//!   `remote_desktop` input, behind the `portal` feature. Wayland never hands
17//!   a client pixels and never lets it move the pointer, so both go through a
18//!   compositor dialog the user has to click; the agent cannot click it.
19//! - **X11 XTest** (`x11.rs`) — `fake_input` for the `event` input tier on
20//!   plain X11 (no Wayland). On Wayland without the `portal` feature the
21//!   tier is `unsupported`; tier `activate` is enigo everywhere, mirroring
22//!   the rest of the project.
23//!
24//! `xdotool`, when installed, supplies the frontmost window title for the
25//! `frontmost` probe — same as before P13a.
26
27#![cfg(target_os = "linux")]
28// `x11rb` re-exports wire types marked `unsafe_op_in_unsafe_fn`; the
29// `allow-unsafe-code` feature keeps that ergonomic.
30#![allow(unsafe_op_in_unsafe_fn)]
31
32pub mod atspi;
33pub mod portal;
34pub mod runtime;
35pub mod x11;
36
37use std::time::Duration;
38
39use crate::doctor::{self, DoctorInput, DoctorOptions};
40use crate::element::{ElementError, ElementOutcome, ElementRequest};
41use crate::error::ComputerError;
42use crate::target::{AppInfo, DoctorReport, LaunchResult, QuitResult, WindowList};
43use crate::tier::{InputJob, InputReport, TierName};
44use crate::tree::TreeQuery;
45use crate::types::FrontmostInfo;
46
47use super::command::{run_timeout, stderr_snippet};
48use super::unsupported;
49use x11rb::connection::Connection as X11Connection;
50
51/// Linux desktop backend. Methods are implemented in this module.
52#[derive(Debug, Default, Clone, Copy)]
53pub struct LinuxDesktopBackend;
54
55pub(crate) fn frontmost() -> FrontmostInfo {
56    match run_timeout(
57        "xdotool",
58        &["getactivewindow", "getwindowname"],
59        Duration::from_secs(2),
60    ) {
61        Ok(output) if output.status.success() => {
62            let window = String::from_utf8_lossy(&output.stdout).trim().to_string();
63            let app = window_class();
64            FrontmostInfo {
65                app,
66                window: if window.is_empty() { None } else { Some(window) },
67                note: None,
68            }
69        }
70        Ok(output) => FrontmostInfo {
71            note: Some(format!(
72                "xdotool failed: {}",
73                stderr_snippet(&output.stderr)
74            )),
75            ..FrontmostInfo::default()
76        },
77        Err(error) => FrontmostInfo {
78            note: Some(format!(
79                "frontmost window unavailable ({error}). On X11, install xdotool; Wayland often hides the active window from this build"
80            )),
81            ..FrontmostInfo::default()
82        },
83    }
84}
85
86fn window_class() -> Option<String> {
87    let output = run_timeout(
88        "xdotool",
89        &["getactivewindow", "getwindowclassname"],
90        Duration::from_secs(2),
91    )
92    .ok()?;
93    if !output.status.success() {
94        return None;
95    }
96    let class = String::from_utf8_lossy(&output.stdout).trim().to_string();
97    if class.is_empty() {
98        None
99    } else {
100        Some(class)
101    }
102}
103
104pub(crate) fn inspect_accessibility(app_name: Option<&str>) -> Result<String, ComputerError> {
105    let query = TreeQuery {
106        app: app_name.map(str::to_owned),
107        window: None,
108        full: true,
109        session: None,
110    };
111    let report = atspi::build_tree(&query)?;
112    Ok(report.full_text)
113}
114
115pub(crate) fn list_apps(_all: bool) -> Result<Vec<AppInfo>, ComputerError> {
116    Err(unsupported("list_apps"))
117}
118
119pub(crate) fn list_windows() -> Result<WindowList, ComputerError> {
120    Err(unsupported("list_windows"))
121}
122
123pub(crate) fn launch(_app: &str) -> Result<LaunchResult, ComputerError> {
124    Err(unsupported("launch"))
125}
126
127pub(crate) fn activate(_app: &str, _window: Option<&str>) -> Result<LaunchResult, ComputerError> {
128    Err(unsupported("activate"))
129}
130
131pub(crate) fn quit(_app: &str, _force: bool) -> Result<QuitResult, ComputerError> {
132    Err(unsupported("quit"))
133}
134
135pub(crate) fn tree(query: &TreeQuery) -> Result<crate::tree::TreeReport, ComputerError> {
136    atspi::build_tree(query)
137}
138
139pub(crate) fn end_session(session: Option<&str>) -> Result<Vec<i32>, ComputerError> {
140    clear_cached_restores(session);
141    Ok(Vec::new())
142}
143
144fn clear_cached_restores(session: Option<&str>) {
145    if let Ok(pending) = crate::cache::pending_restores(session) {
146        let pids: Vec<i32> = pending.into_iter().map(|(pid, _)| pid).collect();
147        if !pids.is_empty() {
148            let _ = crate::cache::clear_restores(session, &pids);
149        }
150    }
151}
152
153/// Element actions route through AT-SPI: `press`, `focus`, `set_value`,
154/// `select_text`, `scroll_to`, and `type` map to the matching
155/// `org.a11y.atspi.*` interfaces (`Action::do_action`,
156/// `Component::grab_focus`, `EditableText::set_text_contents`,
157/// `Value::set_current_value`, `Text::set_selection`/`add_selection`,
158/// `Component::scroll_to`). Secure `Role::PasswordText` nodes refuse
159/// writes with `policy_denied`. A missing interface returns
160/// `execution_failed` — we never invent a click. See
161/// [`atspi::element_action`] for the per-call timeout and the
162/// `crate::element::gate` revalidation contract.
163pub(crate) fn element_action(request: &ElementRequest) -> Result<ElementOutcome, ElementError> {
164    atspi::element_action(request)
165}
166
167pub(crate) fn doctor(options: DoctorOptions) -> Result<DoctorReport, ComputerError> {
168    let mut notes =
169        vec!["P13b: AT-SPI tree + Linux actions + XTest event tier + XDG portals".to_string()];
170    let display = env_nonempty("DISPLAY");
171    let wayland = env_nonempty("WAYLAND_DISPLAY");
172    let session = doctor::session_kind(display.as_deref(), wayland.as_deref());
173    if session.is_none() {
174        notes.push("no DISPLAY or WAYLAND_DISPLAY".to_string());
175    }
176    let (atspi_bus, atspi_note) = atspi_presence();
177    if let Some(note) = atspi_note {
178        notes.push(note);
179    }
180    if x11::event_capable(options.x11) {
181        notes.push(format!(
182            "XTest event tier reachable on {session:?} (synchronous X11, not per-window)",
183        ));
184    }
185    match event_route() {
186        EventRoute::X11Test => notes.push("event tier: X11 XTest".to_string()),
187        EventRoute::Portal => notes.push(
188            "event tier: XDG RemoteDesktop portal (opens a system dialog the user must click)"
189                .to_string(),
190        ),
191        EventRoute::Unsupported => notes.push(format!(
192            "event tier is unsupported on {session:?} (Wayland): built without the `portal` feature"
193        )),
194    }
195    if portal::compiled() {
196        notes.push(
197            "portal feature compiled: ScreenCast capture and RemoteDesktop input are available; \
198             both open a system dialog the agent cannot click for you"
199                .to_string(),
200        );
201    }
202    let report = doctor::assemble(DoctorInput {
203        path: doctor::current_executable(),
204        session: session.map(str::to_string),
205        atspi_bus,
206        portal: Some(portal::compiled()),
207        notes,
208        prompt_ignored: options.prompt,
209        open_settings_ignored: options.open_settings,
210        ..DoctorInput::default()
211    });
212    Ok(report)
213}
214
215fn env_nonempty(name: &str) -> Option<String> {
216    std::env::var(name)
217        .ok()
218        .filter(|value| !value.trim().is_empty())
219}
220
221fn atspi_presence() -> (Option<bool>, Option<String>) {
222    let bus = env_nonempty("AT_SPI_BUS");
223    let runtime = env_nonempty("XDG_RUNTIME_DIR");
224    if bus.is_none() && runtime.is_none() {
225        return (
226            None,
227            Some(
228                "AT_SPI_BUS and XDG_RUNTIME_DIR are unset; bus was not probed (P13a: install at-spi2-core)"
229                    .to_string(),
230            ),
231        );
232    }
233    let present =
234        doctor::atspi_bus_present(bus.as_deref(), runtime.as_deref().map(std::path::Path::new));
235    let note = if present {
236        None
237    } else {
238        Some("AT-SPI bus address was not found; install at-spi2-core + at-spi2-atk".to_string())
239    };
240    (Some(present), note)
241}
242
243/// Bridge for `DoctorOptions::x11`: the `--x11` CLI flag flows through the
244/// dispatcher; tests want to bypass env-detection, so we expose a probe.
245#[allow(dead_code)]
246pub(crate) fn x11_event_capable(use_x11_override: bool) -> bool {
247    x11::event_capable(use_x11_override)
248}
249
250/// Tier name to report back when we dispatched via XTest. Exposed here so
251/// the dispatcher can render the tier tag in its JSON output.
252#[allow(dead_code)]
253pub(crate) fn x11_tier_name() -> &'static str {
254    x11::TIER_NAME
255}
256
257/// Linux `perform_input`. `tier event` on a plain X11 session routes
258/// through `x11rb::protocol::xtest::fake_input`; everything else falls
259/// back to the regular `input::*` enigo path on the same dispatch. Wayland
260/// without `portal` returns `unsupported` for `tier event`; the tier
261/// planner routes the request through `activate` instead, never this
262/// function.
263pub(crate) fn perform_input(job: &InputJob) -> Result<InputReport, ComputerError> {
264    match job.tier {
265        TierName::Event => perform_event_tier(job),
266        // Synthetic (skylight) is intentionally absent on Linux; the tier
267        // planner hides it. AX clicks are routed through `activate` on
268        // Linux because pure-AT-SPI background clicks need a session bus.
269        TierName::Synthetic => Err(unsupported("tier synthetic")),
270        TierName::Ax => Err(unsupported("tier ax for coordinate and keyboard input")),
271        TierName::Activate => perform_activate_tier(job),
272    }
273}
274
275/// Capture one window on Linux.
276///
277/// With the `portal` feature this goes through the XDG ScreenCast dialog;
278/// without it, and on any portal failure that is not a user refusal, it crops
279/// the desktop image the way P13a did.
280pub(crate) fn capture_window(
281    target: &crate::backend::WindowTarget,
282) -> Result<crate::types::RawCapture, ComputerError> {
283    portal::capture_window(target)
284}
285
286/// Where the `event` tier should be sent on Linux.
287///
288/// X11 keeps XTest. Wayland has no such API, so the RemoteDesktop portal is
289/// the only route — and only when `portal` is compiled in. Anything else is
290/// [`ComputerError::Unsupported`], which lets the planner fall through to
291/// `activate` honestly instead of pretending the click landed.
292#[cfg(feature = "portal")]
293fn event_route() -> EventRoute {
294    if portal::wayland_session() {
295        EventRoute::Portal
296    } else {
297        EventRoute::X11Test
298    }
299}
300
301/// No `portal` feature: only X11 can host the event tier.
302#[cfg(not(feature = "portal"))]
303fn event_route() -> EventRoute {
304    if portal::wayland_session() {
305        EventRoute::Unsupported
306    } else {
307        EventRoute::X11Test
308    }
309}
310
311/// Which backend the `event` tier uses on this session.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313enum EventRoute {
314    /// Plain X11: XTest.
315    X11Test,
316    /// Wayland with `portal` compiled in: the RemoteDesktop portal.
317    Portal,
318    /// Wayland without `portal`: nothing can do this.
319    // Only the non-portal build constructs this arm; the variant stays so the
320    // model is the same three states in either build.
321    #[cfg_attr(feature = "portal", allow(dead_code))]
322    Unsupported,
323}
324
325/// Direct call into the `input::*` enigo path used for tier `activate` on
326/// Linux. Mirrors the body of [`crate::backend::off_platform_input`] on
327/// the `Activate` branch so we can route `TierName::Event` through XTest
328/// without going through a `&dyn DesktopBackend`.
329fn perform_activate_tier(
330    job: &crate::tier::InputJob,
331) -> Result<crate::tier::InputReport, ComputerError> {
332    use crate::backend::input;
333    use crate::tier::{InputKind, InputReport};
334    if job.require_background {
335        return Err(ComputerError::ExecutionFailed(
336            "--require-background refuses tier activate because it uses the foreground cursor"
337                .into(),
338        ));
339    }
340    match &job.kind {
341        InputKind::Click {
342            x,
343            y,
344            button,
345            count,
346        } => {
347            for index in 0..*count {
348                if index > 0 {
349                    std::thread::sleep(std::time::Duration::from_millis(50));
350                }
351                input::click(*x, *y, *button)?;
352            }
353        }
354        InputKind::Move { x, y } => input::move_mouse(*x, *y)?,
355        InputKind::Scroll { x, y, dx, dy } => {
356            if let (Some(x), Some(y)) = (x, y) {
357                input::move_mouse(*x, *y)?;
358            }
359            input::scroll(*dx, *dy)?;
360        }
361        InputKind::Type { text } => input::type_text(text)?,
362        InputKind::Key { strokes } => {
363            for stroke in strokes {
364                input::press_key(&stroke.modifiers, &stroke.key)?;
365            }
366        }
367        InputKind::Drag { points, button } => input::drag(points, *button)?,
368        InputKind::MouseDown { x, y, button } => input::button_down(*x, *y, *button)?,
369        InputKind::MouseUp { x, y, button } => input::button_up(*x, *y, *button)?,
370        InputKind::HoldKey { modifiers, key, ms } => input::hold_key(modifiers, key, *ms)?,
371    }
372    Ok(InputReport {
373        tier: "activate",
374        cursor_moved: true,
375        focus_restored: None,
376        warnings: Vec::new(),
377        scroll: None,
378    })
379}
380
381/// Drive the `event` tier on Linux X11 through XTest. Wayland without
382/// portals returns `unsupported` here so the dispatcher shows the user the
383/// right message instead of inventing a click.
384///
385/// The macro path emits a `Box<dyn DesktopBackend>` so we cannot borrow
386/// `self`; we open a fresh connection per dispatch. XTest is fast enough
387/// for that overhead to be invisible.
388fn perform_event_tier(job: &InputJob) -> Result<InputReport, ComputerError> {
389    match event_route() {
390        EventRoute::X11Test => x11_event_tier(job),
391        #[cfg(feature = "portal")]
392        EventRoute::Portal => portal::perform_event_tier(job),
393        EventRoute::Unsupported => Err(unsupported(
394            "tier event on Wayland needs the `portal` feature; this build does not have it",
395        )),
396    }
397}
398
399/// The X11 XTest path, reached only when the session really is X11.
400fn x11_event_tier(job: &InputJob) -> Result<InputReport, ComputerError> {
401    use crate::tier::InputKind;
402    let connection = x11::connect()?;
403    let (root, _w, _h) = read_setup(&connection)?;
404    let report = match &job.kind {
405        InputKind::Move { x, y } => {
406            x11::motion_notify(
407                &connection,
408                root,
409                i16::try_from(*x).unwrap_or(i16::MAX),
410                i16::try_from(*y).unwrap_or(i16::MAX),
411            )?;
412            InputReport {
413                tier: x11::TIER_NAME,
414                cursor_moved: true,
415                focus_restored: None,
416                warnings: Vec::new(),
417                scroll: None,
418            }
419        }
420        InputKind::Click {
421            x,
422            y,
423            button,
424            count,
425        } => {
426            x11::motion_notify(
427                &connection,
428                root,
429                i16::try_from(*x).unwrap_or(i16::MAX),
430                i16::try_from(*y).unwrap_or(i16::MAX),
431            )?;
432            for index in 0..*count {
433                if index > 0 {
434                    std::thread::sleep(std::time::Duration::from_millis(50));
435                }
436                x11::button_press(
437                    &connection,
438                    root,
439                    map_mouse_button(*button),
440                    i16::try_from(*x).unwrap_or(i16::MAX),
441                    i16::try_from(*y).unwrap_or(i16::MAX),
442                )?;
443            }
444            InputReport {
445                tier: x11::TIER_NAME,
446                cursor_moved: true,
447                focus_restored: None,
448                warnings: Vec::new(),
449                scroll: None,
450            }
451        }
452        InputKind::Scroll { dx, dy, .. } => {
453            // XTest fake_input has no separate scroll motion type. Report
454            // the unsupported case so the dispatcher falls back to
455            // `activate` and the user sees the right message.
456            let _ = (dx, dy);
457            Err(ComputerError::Unsupported(
458                "scroll via XTest is not yet supported on Linux; part B adds portals".into(),
459            ))?
460        }
461        InputKind::Key { strokes } => {
462            for stroke in strokes {
463                let keycode = keycode_for_canonical(&stroke.key)?;
464                x11::key_press(&connection, root, keycode)?;
465            }
466            InputReport {
467                tier: x11::TIER_NAME,
468                cursor_moved: false,
469                focus_restored: None,
470                warnings: Vec::new(),
471                scroll: None,
472            }
473        }
474        InputKind::Type { text } => {
475            // XTest fake_input has no Type request; route through the
476            // canonical key presses we already support.
477            let _ = text;
478            Err(ComputerError::Unsupported(
479                "type via XTest is not yet supported on Linux; use `tier activate` for now".into(),
480            ))?
481        }
482        InputKind::Drag { .. } | InputKind::MouseDown { .. } | InputKind::MouseUp { .. } => {
483            return Err(ComputerError::Unsupported(
484                "tier event drag/down/up is not implemented for XTest; part B adds portals".into(),
485            ))
486        }
487        InputKind::HoldKey { .. } => {
488            return Err(ComputerError::Unsupported(
489                "tier event hold-key is not implemented for XTest; part B adds portals".into(),
490            ))
491        }
492    };
493    Ok(report)
494}
495
496fn read_setup(
497    connection: &x11rb::rust_connection::RustConnection,
498) -> Result<(x11rb::protocol::xproto::Window, u16, u16), ComputerError> {
499    // The X server keeps one `Setup` per connection and exposes a single
500    // root window per screen. P13a only consumes screen `0`'s root.
501    let setup = connection.setup();
502    let screen = setup
503        .roots
504        .first()
505        .ok_or_else(|| ComputerError::DisplayUnavailable("X server has no screens".into()))?;
506    Ok((screen.root, screen.width_in_pixels, screen.height_in_pixels))
507}
508
509fn map_mouse_button(button: crate::types::MouseButton) -> u8 {
510    match button {
511        crate::types::MouseButton::Left => 1,
512        crate::types::MouseButton::Right => 3,
513        crate::types::MouseButton::Middle => 2,
514        crate::types::MouseButton::Back => 8,
515        crate::types::MouseButton::Forward => 9,
516    }
517}
518
519/// Map a canonical key name (`super::keys::canonical_key`) to an XTest
520/// keycode. We keep a small table for the keys the agent actually emits
521/// during element actions — anything missing is reported as
522/// `unsupported` so the dispatcher falls back to `activate` where the
523/// `keys.rs` enigo mapping knows more.
524fn keycode_for_canonical(name: &str) -> Result<u8, ComputerError> {
525    let code = match name.to_ascii_lowercase().as_str() {
526        "return" | "enter" => 36,
527        "escape" | "esc" => 9,
528        "tab" => 23,
529        "space" => 65,
530        "backspace" => 22,
531        "delete" | "del" => 119,
532        "home" => 110,
533        "end" => 115,
534        "pageup" | "page_up" | "pgup" => 112,
535        "pagedown" | "page_down" | "pgdn" => 117,
536        "left" | "arrowleft" => 113,
537        "right" | "arrowright" => 114,
538        "up" | "arrowup" => 111,
539        "down" | "arrowdown" => 116,
540        other => {
541            return Err(ComputerError::Unsupported(format!(
542                "XTest keycode for `{other}` is not in the P13a table; part B adds portals"
543            )))
544        }
545    };
546    Ok(code)
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use crate::backend::DesktopBackend;
553
554    #[test]
555    fn tree_uses_atspi_symbol() {
556        // The dispatcher calls `tree(query)`. We don't actually wire to a
557        // bus here — that path is exercised on Linux CI through the
558        // integration tests that read the AT-SPI daemon's real tree. The
559        // shape check is enough for the Mac build.
560        let _tree: fn(&TreeQuery) -> Result<crate::tree::TreeReport, ComputerError> = tree;
561    }
562
563    /// Element actions are routed through `atspi::element_action` on
564    /// P13a, not stubbed to `unsupported`. The dispatcher-facing symbol
565    /// still has the right shape for the trait object; without an
566    /// AT-SPI bus on a Mac, the call returns the live
567    /// `AppNotFound`/`ExecutionFailed` rather than the old part-B
568    /// `unsupported` message.
569    #[test]
570    fn element_action_routes_to_atspi_on_linux() {
571        use crate::element::{ElementOp, ElementRequest};
572        // Symbol shape stays the same; the body now lives in
573        // `atspi::element_action`.
574        let _: fn(
575            &ElementRequest,
576        ) -> Result<crate::element::ElementOutcome, crate::element::ElementError> =
577            atspi::element_action;
578        // The function calls AT-SPI on Linux CI; without a bus here it
579        // returns an error, never a fake `AXPress`.
580        let error = LinuxDesktopBackend
581            .element_action(&ElementRequest {
582                op: ElementOp::Focus,
583                index: 0,
584                require_visible: false,
585                app: None,
586                window: None,
587                session: None,
588                tier: None,
589                allow_foreground: false,
590                require_background: false,
591                no_overlay: true,
592            })
593            .unwrap_err();
594        let code = error.error.code();
595        // No `unsupported` code — the path is real.
596        assert_ne!(code, "unsupported", "got {error:?}");
597    }
598
599    #[test]
600    fn event_route_follows_the_session_and_the_feature() {
601        let route = event_route();
602        if portal::wayland_session() {
603            // Wayland: only the portal can post events, and only when built.
604            assert_eq!(
605                route,
606                if cfg!(feature = "portal") {
607                    EventRoute::Portal
608                } else {
609                    EventRoute::Unsupported
610                }
611            );
612        } else {
613            // X11 (or no display at all) always keeps XTest.
614            assert_eq!(route, EventRoute::X11Test);
615        }
616    }
617
618    #[test]
619    fn the_event_route_is_exhaustive_so_a_new_arm_cannot_be_forgotten() {
620        let all = [
621            EventRoute::X11Test,
622            EventRoute::Portal,
623            EventRoute::Unsupported,
624        ];
625        // Each route is distinct and printable, so a caller can never get two
626        // of them to the same branch by accident.
627        assert_eq!(all.len(), 3);
628        assert_ne!(EventRoute::X11Test, EventRoute::Portal);
629        assert_ne!(EventRoute::Portal, EventRoute::Unsupported);
630    }
631
632    #[test]
633    fn doctor_uses_the_shared_shape() {
634        let report = doctor(DoctorOptions::default()).unwrap();
635        let value = serde_json::to_value(&report).unwrap();
636        assert!(value["permissions_ok"].is_boolean());
637        assert!(value["missing"].is_array());
638        assert!(value["accessibility"].is_null());
639        assert!(value["screen_capture"].is_null());
640        assert!(value["screen_recording"].is_null());
641        assert!(value["post_events"].is_null());
642        assert!(value["input_monitoring"].is_null());
643        assert!(value["input_monitor"].is_null());
644        assert!(value["fix"].is_array());
645        assert!(value["fixes"].is_array());
646        assert!(value["text"].is_string());
647        assert!(value["identity"].is_object());
648        assert!(value["session"].is_string() || value["session"].is_null());
649        assert!(value["atspi_bus"].is_boolean() || value["atspi_bus"].is_null());
650        // P13b: `portal` reports compile-time capability, never a runtime probe.
651        assert!(value["portal"].is_boolean() || value["portal"].is_null());
652        assert_eq!(value["portal"], serde_json::json!(portal::compiled()));
653        assert!(value["uiaccess"].is_null());
654        assert!(value["integrity"].is_null());
655        let note = value["note"].as_str().unwrap();
656        assert!(note.contains("P13b"), "{note}");
657        assert!(report.permissions_ok);
658        assert!(report.missing.is_empty());
659    }
660
661    /// Live AT-SPI `connect()` is exercised only on Linux CI. The unit
662    /// tests on a Mac cannot reach a session bus; we still want to assert
663    /// the function exists and is wired up.
664    #[test]
665    fn atspi_module_wires_async_through_runtime() {
666        // `build_tree` is async under the hood but stays sync for the
667        // dispatcher. Pin the symbol so a future refactor cannot silently
668        // change its signature without breaking this test.
669        let _: fn(&TreeQuery) -> Result<crate::tree::TreeReport, ComputerError> = atspi::build_tree;
670    }
671
672    /// X11 helpers exist and the tier constant is `"x11"`.
673    #[test]
674    fn x11_helpers_link() {
675        let _: fn(bool) -> bool = x11::event_capable;
676        assert_eq!(x11::TIER_NAME, "x11");
677    }
678
679    /// `tier = event` on Linux goes through `perform_event_tier`, which
680    /// uses `x11rb::protocol::xtest::fake_input`. Without an X server the
681    /// connection step fails; the error path is the documented one
682    /// (`DisplayUnavailable`), not `unsupported`. Pin the wiring so a
683    /// future refactor cannot silently route `event` through `activate`
684    /// or fall through the dispatcher.
685    #[test]
686    fn perform_event_tier_wires_xtest_fake_input() {
687        use crate::tier::{InputJob, InputKind, InputReport, TierName};
688        let job = InputJob {
689            tier: TierName::Event,
690            kind: InputKind::Move { x: 0, y: 0 },
691            app: None,
692            window: None,
693            allow_foreground: false,
694            require_background: false,
695        };
696        // The dispatcher entry point is `perform_input`; route the same
697        // way. The function signature must stay compatible with the
698        // dispatcher macro.
699        let _: fn(&InputJob) -> Result<InputReport, ComputerError> = perform_input;
700        // On a Mac (no $DISPLAY) `perform_event_tier` errors with
701        // `DisplayUnavailable`. The contract: a structured error, never a
702        // generic `unsupported` for `tier event` on a missing X server.
703        let result = perform_input(&job);
704        match result {
705            Ok(_) => {
706                // A test running against a live X server would have
707                // already verified the round-trip; we only assert the
708                // error contract on systems without an X server.
709            }
710            Err(error) => {
711                let code = error.code();
712                assert!(
713                    code == "display_unavailable" || code == "execution_failed",
714                    "event tier must fail with display_unavailable/execution_failed, got: {error:?}"
715                );
716            }
717        }
718    }
719
720    /// `tier = activate` on Linux always goes through the enigo path, on
721    /// both X11 and Wayland. `perform_activate_tier` reports `tier:
722    /// "activate"`. Without a real `DISPLAY` enigo also errors, but the
723    /// path is symmetric across sessions — pin that contract here.
724    #[test]
725    fn perform_activate_tier_is_enigo_on_linux() {
726        use crate::tier::{InputJob, InputKind, InputReport, TierName};
727        let _: fn(&InputJob) -> Result<InputReport, ComputerError> = perform_activate_tier;
728        let job = InputJob {
729            tier: TierName::Activate,
730            kind: InputKind::Move { x: 0, y: 0 },
731            app: None,
732            window: None,
733            allow_foreground: false,
734            require_background: false,
735        };
736        // Pin the TierName::Activate -> perform_activate_tier wiring
737        // through perform_input so a future refactor cannot quietly
738        // route `activate` through `xtest`.
739        let result = perform_input(&job);
740        match result {
741            Ok(report) => assert_eq!(report.tier, "activate"),
742            Err(_) => {
743                // Permission / no-display path: the test still proved
744                // that perform_input routed to the activate branch.
745            }
746        }
747    }
748
749    /// XTest tier is `unsupported` on Wayland: `is_x11_session` returns
750    /// false when `$WAYLAND_DISPLAY` is set and `--x11` is not. The
751    /// helper depends on the live `$DISPLAY` / `$WAYLAND_DISPLAY`
752    /// environment, so the unit test that pins the boolean contract is
753    /// written against the production call shape instead of an absolute
754    /// result: the symbol resolves, the type signature stays the same.
755    #[test]
756    fn x11_session_detection_compiles() {
757        let _: fn(bool, bool) -> bool = x11::is_x11_session;
758    }
759
760    /// `--x11` exposes the flag through the doctor when run on Linux.
761    #[test]
762    fn doctor_options_default_x11_is_false() {
763        let options = DoctorOptions::default();
764        assert!(!options.x11);
765    }
766}