Skip to content
161 changes: 141 additions & 20 deletions libs/cua-driver/rust/crates/platform-linux/src/health_report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,10 +304,34 @@ async fn check_wayland_backend() -> CheckEntry {
);
}
};
let remote_desktop_portal_reachable = if crate::wayland::PORTAL_LIBEI_ENABLED {
tokio::task::spawn_blocking(probe_portal_remote_desktop)
.await
.ok()
.and_then(|r| r.ok())
.unwrap_or(false)
} else {
false
};
classify_wayland_backend(
&snap,
crate::wayland::PORTAL_LIBEI_ENABLED,
remote_desktop_portal_reachable,
)
}

fn classify_wayland_backend(
snap: &crate::wayland::WaylandManagers,
portal_libei_enabled: bool,
remote_desktop_portal_reachable: bool,
) -> CheckEntry {
let msg = format!(
"foreign-toplevel={ftl}, screencopy={cap}, virtual-pointer={vp}, wl_shm={shm}",
"foreign-toplevel={ftl}, screencopy={cap}, ext-image-copy={ext_cap}, \
ext-output-source={ext_src}, virtual-pointer={vp}, wl_shm={shm}",
ftl = snap.foreign_toplevel,
cap = snap.screencopy,
ext_cap = snap.ext_image_copy_capture,
ext_src = snap.ext_output_image_capture_source,
vp = snap.virtual_pointer,
shm = snap.wl_shm,
);
Expand All @@ -317,15 +341,37 @@ async fn check_wayland_backend() -> CheckEntry {
format!("All wlroots manager globals advertised ({msg})."),
);
}
// Input-injection backend check (#1982). A non-wlroots compositor
// (KWin/Plasma, Mutter/GNOME) advertises no zwlr_virtual_pointer; on those
// the ONLY working input path is libei via xdg-desktop-portal. If this
// binary was built without `portal-libei` (the published tarball is — see
// #1967), input injection has no backend and silently no-ops: the agent
// cursor renders but clicks/keys are never delivered, while list_windows
// and capture still work. Report that explicitly instead of the misleading
// "input may fall back" partial-pass below.
if !snap.virtual_pointer && !crate::wayland::PORTAL_LIBEI_ENABLED {
if !snap.virtual_pointer {
if remote_desktop_portal_reachable {
return CheckEntry::pass(
NAME_WAYLAND_BACKEND,
format!(
"No wlroots virtual-pointer advertised ({msg}), but this \
portal/libei build can reach the RemoteDesktop portal \
(proxy reachability only — the full create_session → \
select_devices → start → connect_to_eis handshake is NOT \
exercised here, to avoid a consent prompt on every doctor \
run, so this is not a guarantee that injection succeeds); \
input attempts the full xdg-desktop-portal + EIS handshake \
on commands for non-wlroots compositors such as GNOME/Mutter \
and KDE/KWin."
),
);
}
if portal_libei_enabled {
return CheckEntry::fail(
NAME_WAYLAND_BACKEND,
format!(
"No wlroots virtual-pointer advertised ({msg}) and the \
portal/libei RemoteDesktop backend is compiled in but not \
reachable on this session; clicks and key presses have no \
native Wayland input backend."
),
"Ensure xdg-desktop-portal and a desktop backend such as \
xdg-desktop-portal-gnome or xdg-desktop-portal-kde are running \
on the session bus, or run under XWayland.",
);
}
return CheckEntry::fail(
NAME_WAYLAND_BACKEND,
format!(
Expand All @@ -340,27 +386,27 @@ async fn check_wayland_backend() -> CheckEntry {
compositor (sway, labwc, hyprland) where zwlr_virtual_pointer exists.",
);
}
// Partial-pass: list_windows + capture both work, but virtual-pointer
// input is missing. Require `wl_shm` here too — `check_screen_capture_capability`
// gates on both `screencopy && wl_shm`, so excluding `wl_shm` from the
// partial-pass verdict would let the matrices disagree on degenerate
// compositors that omit it.
// Partial-pass: list_windows + capture both work, but some optional
// wlroots globals are absent. Require `wl_shm` here too —
// `check_screen_capture_capability` gates on both `screencopy && wl_shm`,
// so excluding `wl_shm` from the partial-pass verdict would let the
// matrices disagree on degenerate compositors that omit it.
if snap.foreign_toplevel && snap.screencopy && snap.wl_shm {
return CheckEntry::pass(
NAME_WAYLAND_BACKEND,
format!(
"Core wlroots manager globals available; some optional globals missing ({msg}). \
Input may fall back where virtual-pointer is absent."
"Core wlroots manager globals available; some optional globals missing ({msg})."
),
);
}
CheckEntry::fail(
NAME_WAYLAND_BACKEND,
format!(
"Compositor does not advertise the wlroots manager globals cua-driver \
needs ({msg})."
"Compositor does not advertise a complete native Wayland backend \
set ({msg})."
),
"Use a wlroots-based compositor (sway, labwc, hyprland) or run under XWayland.",
"Use a wlroots-based compositor (sway, labwc, hyprland), a portal/libei \
build on GNOME/KDE, or run under XWayland.",
)
}

Expand Down Expand Up @@ -471,6 +517,45 @@ fn probe_portal_screenshot() -> anyhow::Result<bool> {
Ok(false)
}

#[cfg(target_os = "linux")]
fn probe_portal_remote_desktop() -> anyhow::Result<bool> {
#[cfg(feature = "portal-libei")]
{
use ashpd::desktop::remote_desktop::RemoteDesktop;

let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| anyhow::anyhow!("failed to build tokio runtime for RemoteDesktop probe: {e}"))?;

rt.block_on(async {
match RemoteDesktop::new().await {
Ok(_) => Ok(true),
Err(e) => {
let msg = format!("{e}");
if msg.contains("ServiceUnknown")
|| msg.contains("NameHasNoOwner")
|| msg.contains("NotFound")
{
Ok(false)
} else {
Err(anyhow::anyhow!("portal RemoteDesktop probe failed: {e}"))
}
}
}
})
}
#[cfg(not(feature = "portal-libei"))]
{
Ok(false)
}
}

#[cfg(not(target_os = "linux"))]
fn probe_portal_remote_desktop() -> anyhow::Result<bool> {
Ok(false)
}

/// Stub of `wayland::WaylandManagers` so off-Linux builds compile. Always
/// reports nothing advertised — non-Linux code paths never call this.
#[cfg(not(target_os = "linux"))]
Expand Down Expand Up @@ -541,6 +626,42 @@ mod tests {
}
}

#[test]
fn wayland_backend_passes_on_non_wlroots_when_portal_libei_backend_is_reachable() {
let snap = crate::wayland::WaylandManagers {
foreign_toplevel: false,
screencopy: false,
ext_image_copy_capture: false,
ext_output_image_capture_source: false,
virtual_pointer: false,
wl_shm: true,
};

let entry = classify_wayland_backend(&snap, true, true);

assert_eq!(entry.status, CheckStatus::Pass);
assert!(entry.message.contains("portal/libei"), "{}", entry.message);
assert!(entry.message.contains("RemoteDesktop"), "{}", entry.message);
}

#[test]
fn wayland_backend_fails_on_non_wlroots_when_portal_libei_backend_is_unreachable() {
let snap = crate::wayland::WaylandManagers {
foreign_toplevel: false,
screencopy: false,
ext_image_copy_capture: false,
ext_output_image_capture_source: false,
virtual_pointer: false,
wl_shm: true,
};

let entry = classify_wayland_backend(&snap, true, false);

assert_eq!(entry.status, CheckStatus::Fail);
assert!(entry.message.contains("portal/libei"), "{}", entry.message);
assert!(entry.hint.as_deref().unwrap_or("").contains("xdg-desktop-portal"));
}

#[tokio::test]
async fn invoke_full_run_produces_linux_check_set() {
let provider = Arc::new(LinuxHealthProvider);
Expand Down
34 changes: 17 additions & 17 deletions libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1573,7 +1573,7 @@ impl Tool for ClickTool {
// `get_window_state` frames are screen-space, and `window_local_to_screen`
// — an X11 `translate_coordinates` call — can't run with DISPLAY unset),
// so use them directly. On X11 the coords are window-local; translate.
let glide_target = if crate::wayland::is_wayland() {
let glide_target = if crate::wayland::wayland_input_enabled() {
Some((x, y))
} else {
tokio::task::spawn_blocking(move || window_local_to_screen(xid, x, y))
Expand All @@ -1596,7 +1596,7 @@ impl Tool for ClickTool {
// then restore prior active. Mirrors macOS/Windows.
let delivery = crate::input::delivery::DeliveryMode::from_args(&args);
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<&'static str> {
if crate::wayland::is_wayland() {
if crate::wayland::wayland_input_enabled() {
// Vision/pixel click on native Wayland. Mutter drops synthetic
// virtual-pointer events (the `wayland::click` warp doesn't land),
// so for a plain left single click resolve the screen pixel to the
Expand Down Expand Up @@ -1831,7 +1831,7 @@ impl Tool for TypeTextTool {
// Native Wayland: keys go to the *focused* surface (no pid/window
// targeting in the protocol). Type via the virtual-keyboard tool; pair
// with a prior `click`/`activate` to focus the intended window.
if crate::wayland::is_wayland() {
if crate::wayland::wayland_input_enabled() {
let text_len = text.chars().count();
let text_w = text.clone();
let result =
Expand Down Expand Up @@ -2144,7 +2144,7 @@ impl Tool for PressKeyTool {
}

// Native Wayland: send the key to the focused surface via virtual-keyboard.
if crate::wayland::is_wayland() {
if crate::wayland::wayland_input_enabled() {
let key_w = key.clone();
let result = tokio::task::spawn_blocking(move || crate::wayland::press_key(&key_w)).await;
return match result {
Expand Down Expand Up @@ -2283,7 +2283,7 @@ impl Tool for HotkeyTool {
let deliver_fg = delivery.is_foreground() && !px_focused;

let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
if crate::wayland::is_wayland() {
if crate::wayland::wayland_input_enabled() {
// Native Wayland: route the modifier combo through wtype's
// -M/-k/-m sequence — the closest equivalent to the X11
// state-mask path. window_id is irrelevant once focused.
Expand Down Expand Up @@ -2471,7 +2471,7 @@ impl Tool for ScrollTool {
let cursor_id_for_task = cursor_id.clone();
let delivery = crate::input::delivery::DeliveryMode::from_args(&args);
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
if crate::wayland::is_wayland() {
if crate::wayland::wayland_input_enabled() {
return crate::wayland::scroll(xid, &direction_for_wayland, amount_u32);
}
// foreground: activate the window, then scroll, then restore — for
Expand Down Expand Up @@ -2611,7 +2611,7 @@ impl Tool for DoubleClickTool {
let lyi = ly as i32;
let cursor_id_for_task = cursor_id.clone();
let click_result = tokio::task::spawn_blocking(move || {
if crate::wayland::is_wayland() {
if crate::wayland::wayland_input_enabled() {
return crate::wayland::click(xid, lxi, lyi, 2, 1);
}
x11_pixel_click_no_focus_steal(&cursor_id_for_task, xid, lxi, lyi, 1, 2)
Expand Down Expand Up @@ -2652,7 +2652,7 @@ impl Tool for DoubleClickTool {
// `get_window_state` frames are screen-space, and `window_local_to_screen`
// — an X11 `translate_coordinates` call — can't run with DISPLAY unset),
// so use them directly. On X11 the coords are window-local; translate.
let glide_target = if crate::wayland::is_wayland() {
let glide_target = if crate::wayland::wayland_input_enabled() {
Some((x, y))
} else {
tokio::task::spawn_blocking(move || window_local_to_screen(xid, x, y))
Expand All @@ -2671,7 +2671,7 @@ impl Tool for DoubleClickTool {
let cursor_id_for_task = cursor_id.clone();
let delivery = crate::input::delivery::DeliveryMode::from_args(&args);
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
if crate::wayland::is_wayland() {
if crate::wayland::wayland_input_enabled() {
return crate::wayland::click(xid, xi, yi, 2, 1);
}
if delivery.is_foreground() {
Expand Down Expand Up @@ -2786,7 +2786,7 @@ impl Tool for RightClickTool {
let lyi = ly as i32;
let cursor_id_for_task = cursor_id.clone();
let click_result = tokio::task::spawn_blocking(move || {
if crate::wayland::is_wayland() {
if crate::wayland::wayland_input_enabled() {
return crate::wayland::click(xid, lxi, lyi, 1, 3);
}
x11_pixel_click_no_focus_steal(&cursor_id_for_task, xid, lxi, lyi, 3, 1)
Expand Down Expand Up @@ -2827,7 +2827,7 @@ impl Tool for RightClickTool {
// `get_window_state` frames are screen-space, and `window_local_to_screen`
// — an X11 `translate_coordinates` call — can't run with DISPLAY unset),
// so use them directly. On X11 the coords are window-local; translate.
let glide_target = if crate::wayland::is_wayland() {
let glide_target = if crate::wayland::wayland_input_enabled() {
Some((x, y))
} else {
tokio::task::spawn_blocking(move || window_local_to_screen(xid, x, y))
Expand All @@ -2846,7 +2846,7 @@ impl Tool for RightClickTool {
let cursor_id_for_task = cursor_id.clone();
let delivery = crate::input::delivery::DeliveryMode::from_args(&args);
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
if crate::wayland::is_wayland() {
if crate::wayland::wayland_input_enabled() {
return crate::wayland::click(xid, xi, yi, 1, 3);
}
if delivery.is_foreground() {
Expand Down Expand Up @@ -2969,9 +2969,9 @@ impl Tool for DragTool {
);

// Native Wayland: emit press + interpolated motion + release as one
// virtual-pointer sequence (output-relative coords). Returns early so
// we don't fall into the X11 XSendEvent loop below.
if crate::wayland::is_wayland() {
// virtual-pointer (wlroots) or libei (GNOME/KDE) sequence, output-relative
// coords. Returns early so we don't fall into the X11 XSendEvent loop below.
if crate::wayland::wayland_input_enabled() {
let (fxi, fyi) = (from_x.round() as i32, from_y.round() as i32);
let (txi, tyi) = (to_x.round() as i32, to_y.round() as i32);
let steps_u32 = steps as u32;
Expand Down Expand Up @@ -3965,7 +3965,7 @@ impl Tool for MoveCursorTool {
// Off-thread because the wayland-client roundtrip is blocking. Best-effort
// — overlay update + registry write already succeeded; surface a warning
// only if the warp itself failed.
let real_warp_note = if crate::wayland::is_wayland() {
let real_warp_note = if crate::wayland::wayland_input_enabled() {
let xi = x.round() as i32;
let yi = y.round() as i32;
match tokio::task::spawn_blocking(move || crate::wayland::move_cursor_absolute(window_id, xi, yi)).await {
Expand Down Expand Up @@ -4732,7 +4732,7 @@ impl Tool for TypeTextCharsTool {
};
let text_len = text.chars().count();
let result = tokio::task::spawn_blocking(move || {
if crate::wayland::is_wayland() {
if crate::wayland::wayland_input_enabled() {
// Per-char `wtype` loop with the requested delay — mirrors the
// X11 XSendEvent per-char path. Sleeping here is fine because
// we're inside spawn_blocking.
Expand Down
Loading