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    // Only the portal build constructs this arm; the variant stays so the
318    // model is the same three states in either build.
319    #[cfg_attr(not(feature = "portal"), allow(dead_code))]
320    Portal,
321    /// Wayland without `portal`: nothing can do this.
322    // Only the non-portal build constructs this arm; the variant stays so the
323    // model is the same three states in either build.
324    #[cfg_attr(feature = "portal", allow(dead_code))]
325    Unsupported,
326}
327
328/// Direct call into the `input::*` enigo path used for tier `activate` on
329/// Linux. Mirrors the body of [`crate::backend::off_platform_input`] on
330/// the `Activate` branch so we can route `TierName::Event` through XTest
331/// without going through a `&dyn DesktopBackend`.
332fn perform_activate_tier(
333    job: &crate::tier::InputJob,
334) -> Result<crate::tier::InputReport, ComputerError> {
335    use crate::backend::input;
336    use crate::tier::{InputKind, InputReport};
337    if job.require_background {
338        return Err(ComputerError::ExecutionFailed(
339            "--require-background refuses tier activate because it uses the foreground cursor"
340                .into(),
341        ));
342    }
343    match &job.kind {
344        InputKind::Click {
345            x,
346            y,
347            button,
348            count,
349        } => {
350            for index in 0..*count {
351                if index > 0 {
352                    std::thread::sleep(std::time::Duration::from_millis(50));
353                }
354                input::click(*x, *y, *button)?;
355            }
356        }
357        InputKind::Move { x, y } => input::move_mouse(*x, *y)?,
358        InputKind::Scroll { x, y, dx, dy } => {
359            if let (Some(x), Some(y)) = (x, y) {
360                input::move_mouse(*x, *y)?;
361            }
362            input::scroll(*dx, *dy)?;
363        }
364        InputKind::Type { text } => input::type_text(text)?,
365        InputKind::Key { strokes } => {
366            for stroke in strokes {
367                input::press_key(&stroke.modifiers, &stroke.key)?;
368            }
369        }
370        InputKind::Drag { points, button } => input::drag(points, *button)?,
371        InputKind::MouseDown { x, y, button } => input::button_down(*x, *y, *button)?,
372        InputKind::MouseUp { x, y, button } => input::button_up(*x, *y, *button)?,
373        InputKind::HoldKey { modifiers, key, ms } => input::hold_key(modifiers, key, *ms)?,
374    }
375    Ok(InputReport {
376        tier: "activate",
377        cursor_moved: true,
378        focus_restored: None,
379        warnings: Vec::new(),
380        scroll: None,
381    })
382}
383
384/// Drive the `event` tier on Linux X11 through XTest. Wayland without
385/// portals returns `unsupported` here so the dispatcher shows the user the
386/// right message instead of inventing a click.
387///
388/// The macro path emits a `Box<dyn DesktopBackend>` so we cannot borrow
389/// `self`; we open a fresh connection per dispatch. XTest is fast enough
390/// for that overhead to be invisible.
391fn perform_event_tier(job: &InputJob) -> Result<InputReport, ComputerError> {
392    match event_route() {
393        EventRoute::X11Test => x11_event_tier(job),
394        EventRoute::Portal => portal::perform_event_tier(job),
395        EventRoute::Unsupported => Err(unsupported(
396            "tier event on Wayland needs the `portal` feature; this build does not have it",
397        )),
398    }
399}
400
401/// The X11 XTest path, reached only when the session really is X11.
402fn x11_event_tier(job: &InputJob) -> Result<InputReport, ComputerError> {
403    use crate::tier::InputKind;
404    let connection = x11::connect()?;
405    let (root, _w, _h) = read_setup(&connection)?;
406    let report = match &job.kind {
407        InputKind::Move { x, y } => {
408            x11::motion_notify(
409                &connection,
410                root,
411                i16::try_from(*x).unwrap_or(i16::MAX),
412                i16::try_from(*y).unwrap_or(i16::MAX),
413            )?;
414            InputReport {
415                tier: x11::TIER_NAME,
416                cursor_moved: true,
417                focus_restored: None,
418                warnings: Vec::new(),
419                scroll: None,
420            }
421        }
422        InputKind::Click {
423            x,
424            y,
425            button,
426            count,
427        } => {
428            x11::motion_notify(
429                &connection,
430                root,
431                i16::try_from(*x).unwrap_or(i16::MAX),
432                i16::try_from(*y).unwrap_or(i16::MAX),
433            )?;
434            for index in 0..*count {
435                if index > 0 {
436                    std::thread::sleep(std::time::Duration::from_millis(50));
437                }
438                x11::button_press(
439                    &connection,
440                    root,
441                    map_mouse_button(*button),
442                    i16::try_from(*x).unwrap_or(i16::MAX),
443                    i16::try_from(*y).unwrap_or(i16::MAX),
444                )?;
445            }
446            InputReport {
447                tier: x11::TIER_NAME,
448                cursor_moved: true,
449                focus_restored: None,
450                warnings: Vec::new(),
451                scroll: None,
452            }
453        }
454        InputKind::Scroll { dx, dy, .. } => {
455            // XTest fake_input has no separate scroll motion type. Report
456            // the unsupported case so the dispatcher falls back to
457            // `activate` and the user sees the right message.
458            let _ = (dx, dy);
459            Err(ComputerError::Unsupported(
460                "scroll via XTest is not yet supported on Linux; part B adds portals".into(),
461            ))?
462        }
463        InputKind::Key { strokes } => {
464            for stroke in strokes {
465                let keycode = keycode_for_canonical(&stroke.key)?;
466                x11::key_press(&connection, root, keycode)?;
467            }
468            InputReport {
469                tier: x11::TIER_NAME,
470                cursor_moved: false,
471                focus_restored: None,
472                warnings: Vec::new(),
473                scroll: None,
474            }
475        }
476        InputKind::Type { text } => {
477            // XTest fake_input has no Type request; route through the
478            // canonical key presses we already support.
479            let _ = text;
480            Err(ComputerError::Unsupported(
481                "type via XTest is not yet supported on Linux; use `tier activate` for now".into(),
482            ))?
483        }
484        InputKind::Drag { .. } | InputKind::MouseDown { .. } | InputKind::MouseUp { .. } => {
485            return Err(ComputerError::Unsupported(
486                "tier event drag/down/up is not implemented for XTest; part B adds portals".into(),
487            ))
488        }
489        InputKind::HoldKey { .. } => {
490            return Err(ComputerError::Unsupported(
491                "tier event hold-key is not implemented for XTest; part B adds portals".into(),
492            ))
493        }
494    };
495    Ok(report)
496}
497
498fn read_setup(
499    connection: &x11rb::rust_connection::RustConnection,
500) -> Result<(x11rb::protocol::xproto::Window, u16, u16), ComputerError> {
501    // The X server keeps one `Setup` per connection and exposes a single
502    // root window per screen. P13a only consumes screen `0`'s root.
503    let setup = connection.setup();
504    let screen = setup
505        .roots
506        .first()
507        .ok_or_else(|| ComputerError::DisplayUnavailable("X server has no screens".into()))?;
508    Ok((screen.root, screen.width_in_pixels, screen.height_in_pixels))
509}
510
511fn map_mouse_button(button: crate::types::MouseButton) -> u8 {
512    match button {
513        crate::types::MouseButton::Left => 1,
514        crate::types::MouseButton::Right => 3,
515        crate::types::MouseButton::Middle => 2,
516        crate::types::MouseButton::Back => 8,
517        crate::types::MouseButton::Forward => 9,
518    }
519}
520
521/// Map a canonical key name (`super::keys::canonical_key`) to an XTest
522/// keycode. We keep a small table for the keys the agent actually emits
523/// during element actions — anything missing is reported as
524/// `unsupported` so the dispatcher falls back to `activate` where the
525/// `keys.rs` enigo mapping knows more.
526fn keycode_for_canonical(name: &str) -> Result<u8, ComputerError> {
527    let code = match name.to_ascii_lowercase().as_str() {
528        "return" | "enter" => 36,
529        "escape" | "esc" => 9,
530        "tab" => 23,
531        "space" => 65,
532        "backspace" => 22,
533        "delete" | "del" => 119,
534        "home" => 110,
535        "end" => 115,
536        "pageup" | "page_up" | "pgup" => 112,
537        "pagedown" | "page_down" | "pgdn" => 117,
538        "left" | "arrowleft" => 113,
539        "right" | "arrowright" => 114,
540        "up" | "arrowup" => 111,
541        "down" | "arrowdown" => 116,
542        other => {
543            return Err(ComputerError::Unsupported(format!(
544                "XTest keycode for `{other}` is not in the P13a table; part B adds portals"
545            )))
546        }
547    };
548    Ok(code)
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use crate::backend::DesktopBackend;
555
556    #[test]
557    fn tree_uses_atspi_symbol() {
558        // The dispatcher calls `tree(query)`. We don't actually wire to a
559        // bus here — that path is exercised on Linux CI through the
560        // integration tests that read the AT-SPI daemon's real tree. The
561        // shape check is enough for the Mac build.
562        let _tree: fn(&TreeQuery) -> Result<crate::tree::TreeReport, ComputerError> = tree;
563    }
564
565    /// Element actions are routed through `atspi::element_action` on
566    /// P13a, not stubbed to `unsupported`. The dispatcher-facing symbol
567    /// still has the right shape for the trait object; without an
568    /// AT-SPI bus on a Mac, the call returns the live
569    /// `AppNotFound`/`ExecutionFailed` rather than the old part-B
570    /// `unsupported` message.
571    #[test]
572    fn element_action_routes_to_atspi_on_linux() {
573        use crate::element::{ElementOp, ElementRequest};
574        // Symbol shape stays the same; the body now lives in
575        // `atspi::element_action`.
576        let _: fn(
577            &ElementRequest,
578        ) -> Result<crate::element::ElementOutcome, crate::element::ElementError> =
579            atspi::element_action;
580        // The function calls AT-SPI on Linux CI; without a bus here it
581        // returns an error, never a fake `AXPress`.
582        let error = LinuxDesktopBackend
583            .element_action(&ElementRequest {
584                op: ElementOp::Focus,
585                index: 0,
586                require_visible: false,
587                app: None,
588                window: None,
589                session: None,
590                tier: None,
591                allow_foreground: false,
592                require_background: false,
593                no_overlay: true,
594            })
595            .unwrap_err();
596        let code = error.error.code();
597        // No `unsupported` code — the path is real.
598        assert_ne!(code, "unsupported", "got {error:?}");
599    }
600
601    #[test]
602    fn event_route_follows_the_session_and_the_feature() {
603        let route = event_route();
604        if portal::wayland_session() {
605            // Wayland: only the portal can post events, and only when built.
606            assert_eq!(
607                route,
608                if cfg!(feature = "portal") {
609                    EventRoute::Portal
610                } else {
611                    EventRoute::Unsupported
612                }
613            );
614        } else {
615            // X11 (or no display at all) always keeps XTest.
616            assert_eq!(route, EventRoute::X11Test);
617        }
618    }
619
620    #[test]
621    fn the_event_route_is_exhaustive_so_a_new_arm_cannot_be_forgotten() {
622        let all = [
623            EventRoute::X11Test,
624            EventRoute::Portal,
625            EventRoute::Unsupported,
626        ];
627        // Each route is distinct and printable, so a caller can never get two
628        // of them to the same branch by accident.
629        assert_eq!(all.len(), 3);
630        assert_ne!(EventRoute::X11Test, EventRoute::Portal);
631        assert_ne!(EventRoute::Portal, EventRoute::Unsupported);
632    }
633
634    #[test]
635    fn doctor_uses_the_shared_shape() {
636        let report = doctor(DoctorOptions::default()).unwrap();
637        let value = serde_json::to_value(&report).unwrap();
638        assert!(value["permissions_ok"].is_boolean());
639        assert!(value["missing"].is_array());
640        assert!(value["accessibility"].is_null());
641        assert!(value["screen_capture"].is_null());
642        assert!(value["screen_recording"].is_null());
643        assert!(value["post_events"].is_null());
644        assert!(value["input_monitoring"].is_null());
645        assert!(value["input_monitor"].is_null());
646        assert!(value["fix"].is_array());
647        assert!(value["fixes"].is_array());
648        assert!(value["text"].is_string());
649        assert!(value["identity"].is_object());
650        assert!(value["session"].is_string() || value["session"].is_null());
651        assert!(value["atspi_bus"].is_boolean() || value["atspi_bus"].is_null());
652        // P13b: `portal` reports compile-time capability, never a runtime probe.
653        assert!(value["portal"].is_boolean() || value["portal"].is_null());
654        assert_eq!(value["portal"], serde_json::json!(portal::compiled()));
655        assert!(value["uiaccess"].is_null());
656        assert!(value["integrity"].is_null());
657        let note = value["note"].as_str().unwrap();
658        assert!(note.contains("P13b"), "{note}");
659        assert!(report.permissions_ok);
660        assert!(report.missing.is_empty());
661    }
662
663    /// Live AT-SPI `connect()` is exercised only on Linux CI. The unit
664    /// tests on a Mac cannot reach a session bus; we still want to assert
665    /// the function exists and is wired up.
666    #[test]
667    fn atspi_module_wires_async_through_runtime() {
668        // `build_tree` is async under the hood but stays sync for the
669        // dispatcher. Pin the symbol so a future refactor cannot silently
670        // change its signature without breaking this test.
671        let _: fn(&TreeQuery) -> Result<crate::tree::TreeReport, ComputerError> = atspi::build_tree;
672    }
673
674    /// X11 helpers exist and the tier constant is `"x11"`.
675    #[test]
676    fn x11_helpers_link() {
677        let _: fn(bool) -> bool = x11::event_capable;
678        assert_eq!(x11::TIER_NAME, "x11");
679    }
680
681    /// `tier = event` on Linux goes through `perform_event_tier`, which
682    /// uses `x11rb::protocol::xtest::fake_input`. Without an X server the
683    /// connection step fails; the error path is the documented one
684    /// (`DisplayUnavailable`), not `unsupported`. Pin the wiring so a
685    /// future refactor cannot silently route `event` through `activate`
686    /// or fall through the dispatcher.
687    #[test]
688    fn perform_event_tier_wires_xtest_fake_input() {
689        use crate::tier::{InputJob, InputKind, InputReport, TierName};
690        let job = InputJob {
691            tier: TierName::Event,
692            kind: InputKind::Move { x: 0, y: 0 },
693            app: None,
694            window: None,
695            allow_foreground: false,
696            require_background: false,
697        };
698        // The dispatcher entry point is `perform_input`; route the same
699        // way. The function signature must stay compatible with the
700        // dispatcher macro.
701        let _: fn(&InputJob) -> Result<InputReport, ComputerError> = perform_input;
702        // On a Mac (no $DISPLAY) `perform_event_tier` errors with
703        // `DisplayUnavailable`. The contract: a structured error, never a
704        // generic `unsupported` for `tier event` on a missing X server.
705        let result = perform_input(&job);
706        match result {
707            Ok(_) => {
708                // A test running against a live X server would have
709                // already verified the round-trip; we only assert the
710                // error contract on systems without an X server.
711            }
712            Err(error) => {
713                let code = error.code();
714                assert!(
715                    code == "display_unavailable" || code == "execution_failed",
716                    "event tier must fail with display_unavailable/execution_failed, got: {error:?}"
717                );
718            }
719        }
720    }
721
722    /// `tier = activate` on Linux always goes through the enigo path, on
723    /// both X11 and Wayland. `perform_activate_tier` reports `tier:
724    /// "activate"`. Without a real `DISPLAY` enigo also errors, but the
725    /// path is symmetric across sessions — pin that contract here.
726    #[test]
727    fn perform_activate_tier_is_enigo_on_linux() {
728        use crate::tier::{InputJob, InputKind, InputReport, TierName};
729        let _: fn(&InputJob) -> Result<InputReport, ComputerError> = perform_activate_tier;
730        let job = InputJob {
731            tier: TierName::Activate,
732            kind: InputKind::Move { x: 0, y: 0 },
733            app: None,
734            window: None,
735            allow_foreground: false,
736            require_background: false,
737        };
738        // Pin the TierName::Activate -> perform_activate_tier wiring
739        // through perform_input so a future refactor cannot quietly
740        // route `activate` through `xtest`.
741        let result = perform_input(&job);
742        match result {
743            Ok(report) => assert_eq!(report.tier, "activate"),
744            Err(_) => {
745                // Permission / no-display path: the test still proved
746                // that perform_input routed to the activate branch.
747            }
748        }
749    }
750
751    /// XTest tier is `unsupported` on Wayland: `is_x11_session` returns
752    /// false when `$WAYLAND_DISPLAY` is set and `--x11` is not. The
753    /// helper depends on the live `$DISPLAY` / `$WAYLAND_DISPLAY`
754    /// environment, so the unit test that pins the boolean contract is
755    /// written against the production call shape instead of an absolute
756    /// result: the symbol resolves, the type signature stays the same.
757    #[test]
758    fn x11_session_detection_compiles() {
759        let _: fn(bool, bool) -> bool = x11::is_x11_session;
760    }
761
762    /// `--x11` exposes the flag through the doctor when run on Linux.
763    #[test]
764    fn doctor_options_default_x11_is_false() {
765        let options = DoctorOptions::default();
766        assert!(!options.x11);
767    }
768}