From 7e067abf9f7f20185fe3bcf087efac27848b895a Mon Sep 17 00:00:00 2001 From: devthejo Date: Sun, 5 Jul 2026 12:57:47 +0200 Subject: [PATCH 1/5] feat(windowing): add generic X11/EWMH window backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic wmctrl/xprop backend for list/focus/activate/move/resize on any EWMH-compliant X11 window manager without a dedicated backend (Cinnamon, MATE, Xfce, Openbox, …). Registered last in the backend order so a session-native backend always wins first. Also dispatch move_window/resize_window by the resolved window's backend instead of hardcoding the GNOME Shell extension, so geometry ops work on X11 too. doctor now reports window listing/focus available on plain X11 sessions. Co-Authored-By: Claude Opus 4.8 --- src/diagnostics.rs | 2 +- src/server.rs | 36 ++-- src/windowing/backends/mod.rs | 1 + src/windowing/backends/x11.rs | 338 ++++++++++++++++++++++++++++++++++ src/windowing/mod.rs | 3 +- src/windowing/registry.rs | 41 ++++- 6 files changed, 398 insertions(+), 23 deletions(-) create mode 100644 src/windowing/backends/x11.rs diff --git a/src/diagnostics.rs b/src/diagnostics.rs index 506967b..be38996 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -585,7 +585,7 @@ fn windowing_report(platform: &PlatformReport) -> WindowingReport { } else if hyprland.ok { "A Hyprland window backend is available for list_windows, focused_window, and targeted input verification." } else { - "A GNOME window listing backend is available for list_windows, focused_window, and targeted input verification." + "A window listing backend is available for list_windows, focused_window, and targeted input verification." } } else { "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." diff --git a/src/server.rs b/src/server.rs index de87548..659862a 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1243,7 +1243,7 @@ impl ComputerUseLinux { #[tool( name = "move_window", - description = "Move a window to a new desktop position (frame top-left in desktop coordinates). Useful to recover windows that are partially off-screen. Requires the computer-use-linux GNOME Shell extension.", + description = "Move a window to a new desktop position (frame top-left in desktop coordinates). Useful to recover windows that are partially off-screen. Works through the computer-use-linux GNOME Shell extension or a generic X11/EWMH window manager (wmctrl).", annotations( read_only_hint = false, destructive_hint = false, @@ -1257,16 +1257,15 @@ impl ComputerUseLinux { ) -> Json { let received = Some(serde_json::json!(params.clone())); let target = params.target.clone().into_target(); - self.window_geometry_op(received, &target, |window_id| async move { - crate::windowing::backends::gnome::move_extension_window(window_id, params.x, params.y) - .await + self.window_geometry_op(received, &target, |window| async move { + registry::move_window(&window, params.x, params.y).await }) .await } #[tool( name = "resize_window", - description = "Resize a window to a new frame width/height in desktop pixels, unmaximizing it first if needed. Useful to fit a window fully on-screen. Requires the computer-use-linux GNOME Shell extension.", + description = "Resize a window to a new frame width/height in desktop pixels, unmaximizing it first if needed. Useful to fit a window fully on-screen. Works through the computer-use-linux GNOME Shell extension or a generic X11/EWMH window manager (wmctrl).", annotations( read_only_hint = false, destructive_hint = false, @@ -1280,13 +1279,8 @@ impl ComputerUseLinux { ) -> Json { let received = Some(serde_json::json!(params.clone())); let target = params.target.clone().into_target(); - self.window_geometry_op(received, &target, |window_id| async move { - crate::windowing::backends::gnome::resize_extension_window( - window_id, - params.width, - params.height, - ) - .await + self.window_geometry_op(received, &target, |window| async move { + registry::resize_window(&window, params.width, params.height).await }) .await } @@ -2219,7 +2213,7 @@ impl ComputerUseLinux { op: F, ) -> Json where - F: FnOnce(u64) -> Fut, + F: FnOnce(crate::windowing::WindowInfo) -> Fut, Fut: Future>, { let windows = match list_windows().await { @@ -2229,7 +2223,7 @@ impl ComputerUseLinux { return Json(WindowGeometryOutput { ok: false, implemented: true, - backend: crate::windowing::GNOME_SHELL_EXTENSION_BACKEND.to_string(), + backend: "unknown".to_string(), window: None, message: format!("Window listing failed: {error}"), permissions_hint: window_permission_hint(&error), @@ -2237,13 +2231,13 @@ impl ComputerUseLinux { }); } }; - let window_id = match resolve_window_target(&windows, target) { - Ok(window) => window.window_id, + let window = match resolve_window_target(&windows, target) { + Ok(window) => window.clone(), Err(error) => { return Json(WindowGeometryOutput { ok: false, implemented: true, - backend: crate::windowing::GNOME_SHELL_EXTENSION_BACKEND.to_string(), + backend: "unknown".to_string(), window: None, message: format!("{error:#}"), permissions_hint: None, @@ -2251,7 +2245,9 @@ impl ComputerUseLinux { }); } }; - match op(window_id).await { + let backend = window.backend.clone(); + let window_id = window.window_id; + match op(window).await { Ok(message) => { // Re-query so the caller sees the compositor-final geometry // (tiling constraints, minimum sizes, etc. may adjust it). @@ -2269,7 +2265,7 @@ impl ComputerUseLinux { Json(WindowGeometryOutput { ok: true, implemented: true, - backend: crate::windowing::GNOME_SHELL_EXTENSION_BACKEND.to_string(), + backend, window, message, permissions_hint: None, @@ -2281,7 +2277,7 @@ impl ComputerUseLinux { Json(WindowGeometryOutput { ok: false, implemented: true, - backend: crate::windowing::GNOME_SHELL_EXTENSION_BACKEND.to_string(), + backend, window: None, permissions_hint: window_permission_hint(&error), message: error, diff --git a/src/windowing/backends/mod.rs b/src/windowing/backends/mod.rs index 25cbff7..77bf33e 100644 --- a/src/windowing/backends/mod.rs +++ b/src/windowing/backends/mod.rs @@ -3,3 +3,4 @@ pub mod gnome; pub mod hyprland; pub mod i3; pub mod kwin; +pub mod x11; diff --git a/src/windowing/backends/x11.rs b/src/windowing/backends/x11.rs new file mode 100644 index 0000000..53764c8 --- /dev/null +++ b/src/windowing/backends/x11.rs @@ -0,0 +1,338 @@ +//! Generic X11 / EWMH window backend. +//! +//! Unlike the compositor-specific backends (GNOME Shell, KWin, Hyprland, i3), +//! this one talks plain [EWMH]/[ICCCM] through `wmctrl` + `xprop`, so it works +//! on any reasonably standards-compliant X11 window manager that does not have +//! a dedicated backend — Cinnamon/Muffin, MATE/Marco, Xfce/xfwm4, Openbox, etc. +//! It is intentionally registered last so a session-native backend always wins +//! when one is present. +//! +//! [EWMH]: https://specifications.freedesktop.org/wm-spec/latest/ +//! [ICCCM]: https://tronche.com/gui/x/icccm/ + +use crate::terminal::enrich_terminal_windows; +use crate::windowing::registry::BackendProbe; +use crate::windowing::types::{WindowBounds, WindowInfo}; +use anyhow::{bail, Context, Result}; +use std::env; +use std::process::Command; + +pub const X11_BACKEND: &str = "x11"; + +/// True when this looks like a plain X11 session we can drive over EWMH. +/// +/// Requires an X `DISPLAY` and either an explicit `x11` session type or the +/// absence of a Wayland display, so we never hijack XWayland under a Wayland +/// compositor (where a native backend should answer instead). +fn is_x11_session() -> bool { + if env_nonempty("DISPLAY").is_none() { + return false; + } + match env_nonempty("XDG_SESSION_TYPE").as_deref() { + Some("x11") => true, + Some("wayland") => false, + _ => env_nonempty("WAYLAND_DISPLAY").is_none(), + } +} + +pub fn probe() -> BackendProbe { + if !is_x11_session() { + return probe_fail("no X11 session (needs DISPLAY on an X11, not Wayland, session)"); + } + match wmctrl().args(["-l", "-p", "-G", "-x"]).output() { + Ok(output) if output.status.success() => BackendProbe { + id: X11_BACKEND, + ok: true, + can_list_windows: true, + can_focus_apps: true, + can_focus_windows: true, + detail: "wmctrl listed X11/EWMH windows".to_string(), + }, + Ok(output) => probe_fail(&format!( + "wmctrl -l failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )), + Err(error) => probe_fail(&format!("wmctrl unavailable: {error}")), + } +} + +fn probe_fail(detail: &str) -> BackendProbe { + BackendProbe { + id: X11_BACKEND, + ok: false, + can_list_windows: false, + can_focus_apps: false, + can_focus_windows: false, + detail: detail.to_string(), + } +} + +pub fn list_windows() -> Result> { + let output = wmctrl() + .args(["-l", "-p", "-G", "-x"]) + .output() + .context("failed to run wmctrl -l -p -G -x")?; + if !output.status.success() { + bail!( + "wmctrl -l -p -G -x failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let active_id = active_window_id(); + let mut windows = parse_wmctrl_windows(&String::from_utf8_lossy(&output.stdout), active_id); + enrich_terminal_windows(&mut windows); + Ok(windows) +} + +pub fn activate_window(window_id: u64) -> Result<()> { + let id = window_id_arg(window_id); + run_wmctrl(&["-i", "-a", id.as_str()], "activate window", window_id) +} + +pub fn move_window(window_id: u64, x: i32, y: i32) -> Result { + unmaximize(window_id)?; + let id = window_id_arg(window_id); + // wmctrl -e format is `gravity,x,y,width,height`; -1 keeps the current size. + let geometry = format!("0,{x},{y},-1,-1"); + run_wmctrl( + &["-i", "-r", id.as_str(), "-e", geometry.as_str()], + "move window", + window_id, + )?; + Ok(format!("Moved window to ({x}, {y}) via X11/EWMH (wmctrl).")) +} + +pub fn resize_window(window_id: u64, width: i32, height: i32) -> Result { + unmaximize(window_id)?; + let id = window_id_arg(window_id); + let geometry = format!("0,-1,-1,{width},{height}"); + run_wmctrl( + &["-i", "-r", id.as_str(), "-e", geometry.as_str()], + "resize window", + window_id, + )?; + Ok(format!( + "Resized window to {width}x{height} via X11/EWMH (wmctrl)." + )) +} + +/// EWMH move/resize only take effect on unmaximized windows, so drop the +/// maximized state first (mirrors the GNOME extension backend behaviour). +fn unmaximize(window_id: u64) -> Result<()> { + let id = window_id_arg(window_id); + run_wmctrl( + &[ + "-i", + "-r", + id.as_str(), + "-b", + "remove,maximized_vert,maximized_horz", + ], + "unmaximize window", + window_id, + ) +} + +fn run_wmctrl(args: &[&str], action: &str, window_id: u64) -> Result<()> { + let output = wmctrl() + .args(args) + .output() + .with_context(|| format!("failed to run wmctrl to {action} 0x{window_id:x}"))?; + if !output.status.success() { + bail!( + "wmctrl failed to {action} 0x{window_id:x}: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +fn window_id_arg(window_id: u64) -> String { + format!("0x{window_id:08x}") +} + +fn wmctrl() -> Command { + Command::new("wmctrl") +} + +fn active_window_id() -> Option { + let output = Command::new("xprop") + .args(["-root", "-notype", "_NET_ACTIVE_WINDOW"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + parse_active_window_id(&String::from_utf8_lossy(&output.stdout)) +} + +/// Parse the window id out of an `xprop _NET_ACTIVE_WINDOW` line, e.g. +/// `_NET_ACTIVE_WINDOW: window id # 0x7000004`. Returns `None` for `0x0`. +pub(crate) fn parse_active_window_id(xprop_output: &str) -> Option { + let after = xprop_output.split("0x").nth(1)?; + let hex: String = after + .chars() + .take_while(|character| character.is_ascii_hexdigit()) + .collect(); + let id = u64::from_str_radix(&hex, 16).ok()?; + (id != 0).then_some(id) +} + +/// Parse `wmctrl -l -p -G -x` output into window records. +/// +/// Each line is: `id desktop pid x y w h wm_class client_machine title...`, +/// where `title` is the free-form remainder (may contain spaces). +pub(crate) fn parse_wmctrl_windows(list_output: &str, active_id: Option) -> Vec { + let mut windows: Vec = list_output + .lines() + .filter_map(|line| parse_wmctrl_line(line, active_id)) + .collect(); + windows.sort_by_key(|window| window.window_id); + windows +} + +fn parse_wmctrl_line(line: &str, active_id: Option) -> Option { + let mut rest = line; + let id_field = next_field(&mut rest)?; + let desktop_field = next_field(&mut rest)?; + let pid_field = next_field(&mut rest)?; + let x_field = next_field(&mut rest)?; + let y_field = next_field(&mut rest)?; + let width_field = next_field(&mut rest)?; + let height_field = next_field(&mut rest)?; + let class_field = next_field(&mut rest)?; + let _client_machine = next_field(&mut rest); + let title = rest.trim(); + + let window_id = u64::from_str_radix(id_field.trim_start_matches("0x"), 16).ok()?; + let desktop = desktop_field.parse::().ok()?; + let pid = pid_field.parse::().ok().filter(|pid| *pid != 0); + let x = x_field.parse::().ok()?; + let y = y_field.parse::().ok()?; + let width = width_field.parse::().ok()?; + let height = height_field.parse::().ok()?; + + let (app_id, wm_class) = split_wm_class(class_field); + + Some(WindowInfo { + window_id, + title: clean(title), + app_id, + wm_class, + pid, + bounds: Some(WindowBounds { + x: Some(x), + y: Some(y), + width, + height, + }), + workspace: (desktop >= 0).then_some(desktop), + focused: active_id == Some(window_id), + hidden: false, + client_type: Some("x11".to_string()), + backend: X11_BACKEND.to_string(), + terminal: None, + }) +} + +/// `wmctrl -x` prints `WM_CLASS` as `instance.Class`. Map `instance` to +/// `app_id` and `Class` to `wm_class`, mirroring the i3 backend. +fn split_wm_class(value: &str) -> (Option, Option) { + match value.split_once('.') { + Some((instance, class)) => (clean(instance), clean(class)), + None => (clean(value), clean(value)), + } +} + +/// Consume the next whitespace-delimited field, advancing `rest` past it. +fn next_field<'a>(rest: &mut &'a str) -> Option<&'a str> { + *rest = rest.trim_start(); + if rest.is_empty() { + return None; + } + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + let (field, tail) = rest.split_at(end); + *rest = tail; + Some(field) +} + +fn clean(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty() && value != "N/A").then(|| value.to_string()) +} + +fn env_nonempty(name: &str) -> Option { + env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_active_window_id_from_xprop() { + assert_eq!( + parse_active_window_id("_NET_ACTIVE_WINDOW: window id # 0x7000004\n"), + Some(0x7000004) + ); + assert_eq!( + parse_active_window_id("_NET_ACTIVE_WINDOW(WINDOW): window id # 0x03a00017\n"), + Some(0x03a00017) + ); + assert_eq!( + parse_active_window_id("_NET_ACTIVE_WINDOW: window id # 0x0\n"), + None + ); + } + + #[test] + fn parses_wmctrl_windows_as_window_info() { + // Real `wmctrl -l -p -G -x` lines: id desktop pid x y w h class host title. + let output = "\ +0x03000003 0 3843 0 0 5120 1400 nemo-desktop.Nemo-desktop rog nemo-desktop +0x03a00017 2 4564 -40 -40 2600 1440 Navigator.firefox rog Nouvel onglet — Mozilla Firefox +0x05c00007 0 26606 0 72 2560 1368 terminator.Terminator rog jo@rog: ~ +0x07000004 -1 0 10 10 400 300 claude-desktop.claude-desktop rog Claude +"; + let windows = parse_wmctrl_windows(output, Some(0x07000004)); + + assert_eq!(windows.len(), 4); + + let firefox = windows + .iter() + .find(|window| window.window_id == 0x03a00017) + .unwrap(); + assert_eq!(firefox.app_id.as_deref(), Some("Navigator")); + assert_eq!(firefox.wm_class.as_deref(), Some("firefox")); + assert_eq!(firefox.pid, Some(4564)); + assert_eq!(firefox.title.as_deref(), Some("Nouvel onglet — Mozilla Firefox")); + assert_eq!(firefox.workspace, Some(2)); + let bounds = firefox.bounds.as_ref().unwrap(); + assert_eq!((bounds.x, bounds.y, bounds.width, bounds.height), (Some(-40), Some(-40), 2600, 1440)); + assert_eq!(firefox.client_type.as_deref(), Some("x11")); + assert_eq!(firefox.backend, X11_BACKEND); + assert!(!firefox.focused); + + // Active window is flagged; sticky desktop (-1) has no workspace; pid 0 -> None. + let claude = windows + .iter() + .find(|window| window.window_id == 0x07000004) + .unwrap(); + assert!(claude.focused); + assert_eq!(claude.workspace, None); + assert_eq!(claude.pid, None); + } + + #[test] + fn parses_title_with_multiple_spaces_and_empty_title() { + let output = "0x00000001 0 100 0 0 800 600 term.Term rog\n"; + let windows = parse_wmctrl_windows(output, None); + assert_eq!(windows.len(), 1); + assert_eq!(windows[0].title, None); + assert_eq!(windows[0].wm_class.as_deref(), Some("Term")); + } +} diff --git a/src/windowing/mod.rs b/src/windowing/mod.rs index b2f38de..c8cf0de 100644 --- a/src/windowing/mod.rs +++ b/src/windowing/mod.rs @@ -6,7 +6,7 @@ pub mod types; #[allow(unused_imports)] pub use registry::{ COSMIC_WAYLAND_BACKEND, GNOME_SHELL_EXTENSION_BACKEND, GNOME_SHELL_INTROSPECT_BACKEND, - HYPRLAND_BACKEND, I3_BACKEND, KWIN_BACKEND, WINDOW_PERMISSION_HINT, + HYPRLAND_BACKEND, I3_BACKEND, KWIN_BACKEND, WINDOW_PERMISSION_HINT, X11_BACKEND, }; #[allow(unused_imports)] pub use target::{ @@ -55,6 +55,7 @@ mod tests { KWIN_BACKEND, HYPRLAND_BACKEND, I3_BACKEND, + X11_BACKEND, ] ); } diff --git a/src/windowing/registry.rs b/src/windowing/registry.rs index 6f4d9c3..1b9c0a1 100644 --- a/src/windowing/registry.rs +++ b/src/windowing/registry.rs @@ -1,4 +1,4 @@ -use crate::windowing::backends::{cosmic, gnome, hyprland, i3, kwin}; +use crate::windowing::backends::{cosmic, gnome, hyprland, i3, kwin, x11}; use crate::windowing::types::WindowInfo; use anyhow::{anyhow, Result}; @@ -7,6 +7,7 @@ pub use gnome::{GNOME_SHELL_EXTENSION_BACKEND, GNOME_SHELL_INTROSPECT_BACKEND}; pub use hyprland::HYPRLAND_BACKEND; pub use i3::I3_BACKEND; pub use kwin::KWIN_BACKEND; +pub use x11::X11_BACKEND; pub const WINDOW_PERMISSION_HINT: &str = "Computer Use could not access a supported window list backend. Targeted window input requires session-bus access plus GNOME Shell Introspect, the computer-use-linux GNOME Shell extension, the COSMIC Wayland helper, KWin/Plasma DBus scripting, Hyprland hyprctl, or i3-msg. On GNOME, run setup_window_targeting to install the extension backend."; @@ -37,6 +38,7 @@ enum BackendKind { Kwin, Hyprland, I3, + X11, } const BACKEND_ORDER: &[BackendKind] = &[ @@ -46,6 +48,8 @@ const BACKEND_ORDER: &[BackendKind] = &[ BackendKind::Kwin, BackendKind::Hyprland, BackendKind::I3, + // Generic X11/EWMH: last, so a session-native backend always wins first. + BackendKind::X11, ]; const DESCRIPTORS: &[BackendDescriptor] = &[ @@ -91,6 +95,13 @@ const DESCRIPTORS: &[BackendDescriptor] = &[ missing_hint: "On i3, ensure i3-msg can reach the active i3 IPC socket.", can_exact_focus: true, }, + BackendDescriptor { + id: X11_BACKEND, + failure_label: "X11/EWMH", + list_note: "Window list came from X11/EWMH (wmctrl). Terminal windows may include best-effort PTY and active-process context when the process tree is readable.", + missing_hint: "On other X11 window managers (Cinnamon, MATE, Xfce, Openbox…), ensure wmctrl and xprop are installed.", + can_exact_focus: true, + }, ]; pub fn descriptors() -> &'static [BackendDescriptor] { @@ -153,6 +164,7 @@ async fn list_windows_for(backend: BackendKind) -> Result> { BackendKind::Kwin => kwin::list_windows().await, BackendKind::Hyprland => hyprland::list_windows(), BackendKind::I3 => i3::list_windows(), + BackendKind::X11 => x11::list_windows(), } } @@ -176,12 +188,37 @@ pub async fn activate_window(window: &WindowInfo) -> Result<()> { KWIN_BACKEND => kwin::activate_window(window.window_id).await, HYPRLAND_BACKEND => hyprland::activate_window(window.window_id), I3_BACKEND => i3::activate_window(window.window_id), + X11_BACKEND => x11::activate_window(window.window_id), backend => Err(anyhow!( "Unsupported window backend for activation: {backend}" )), } } +pub async fn move_window(window: &WindowInfo, x: i32, y: i32) -> Result { + match window.backend.as_str() { + GNOME_SHELL_EXTENSION_BACKEND => { + gnome::move_extension_window(window.window_id, x, y).await + } + X11_BACKEND => x11::move_window(window.window_id, x, y), + backend => Err(anyhow!( + "Window backend {backend} cannot move windows; move_window needs the computer-use-linux GNOME Shell extension or a generic X11/EWMH session." + )), + } +} + +pub async fn resize_window(window: &WindowInfo, width: i32, height: i32) -> Result { + match window.backend.as_str() { + GNOME_SHELL_EXTENSION_BACKEND => { + gnome::resize_extension_window(window.window_id, width, height).await + } + X11_BACKEND => x11::resize_window(window.window_id, width, height), + backend => Err(anyhow!( + "Window backend {backend} cannot resize windows; resize_window needs the computer-use-linux GNOME Shell extension or a generic X11/EWMH session." + )), + } +} + pub fn focused_window_override() -> Option { cosmic::focused_window().ok().flatten() } @@ -194,6 +231,7 @@ pub fn probe_backends() -> Vec { kwin::probe(), hyprland::probe(), i3::probe(), + x11::probe(), ] } @@ -206,6 +244,7 @@ impl BackendKind { BackendKind::Kwin => KWIN_BACKEND, BackendKind::Hyprland => HYPRLAND_BACKEND, BackendKind::I3 => I3_BACKEND, + BackendKind::X11 => X11_BACKEND, } } From 75a3e658986fe0dae585f268277c60948f8ce02b Mon Sep 17 00:00:00 2001 From: devthejo Date: Sun, 5 Jul 2026 13:17:43 +0200 Subject: [PATCH 2/5] fix(windowing): harden X11 backend per review - list_windows: bail on non-X11 sessions so it never returns XWayland-only windows when a Wayland session falls through to this backend. - move_window: map a -1 x/y target to -2, since wmctrl -e reads -1 as "preserve current value" and would otherwise drop the move. - clean: compare "N/A" case-insensitively. Co-Authored-By: Claude Opus 4.8 --- src/windowing/backends/x11.rs | 41 ++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/windowing/backends/x11.rs b/src/windowing/backends/x11.rs index 53764c8..69c5027 100644 --- a/src/windowing/backends/x11.rs +++ b/src/windowing/backends/x11.rs @@ -68,6 +68,12 @@ fn probe_fail(detail: &str) -> BackendProbe { } pub fn list_windows() -> Result> { + // Guard the session too, not just probe(): registry::list_windows() tries + // each backend directly, so without this a Wayland session with no native + // backend would fall through here and return XWayland-only windows. + if !is_x11_session() { + bail!("not an X11 session (needs DISPLAY on an X11, not Wayland, session)"); + } let output = wmctrl() .args(["-l", "-p", "-G", "-x"]) .output() @@ -93,8 +99,10 @@ pub fn activate_window(window_id: u64) -> Result<()> { pub fn move_window(window_id: u64, x: i32, y: i32) -> Result { unmaximize(window_id)?; let id = window_id_arg(window_id); - // wmctrl -e format is `gravity,x,y,width,height`; -1 keeps the current size. - let geometry = format!("0,{x},{y},-1,-1"); + // wmctrl -e is `gravity,x,y,width,height`; the trailing -1,-1 keep the size. + // wmctrl also reads -1 in the x/y fields as "preserve current position", so a + // literal -1 target would be dropped — nudge it to -2 so the move still lands. + let geometry = format!("0,{},{},-1,-1", wmctrl_move_coord(x), wmctrl_move_coord(y)); run_wmctrl( &["-i", "-r", id.as_str(), "-e", geometry.as_str()], "move window", @@ -103,6 +111,17 @@ pub fn move_window(window_id: u64, x: i32, y: i32) -> Result { Ok(format!("Moved window to ({x}, {y}) via X11/EWMH (wmctrl).")) } +/// `wmctrl -e` treats -1 in any field as "keep current value", so a literal -1 +/// coordinate is silently ignored. Map it to -2 so the window actually moves +/// (a 1px difference at the screen edge is harmless). +fn wmctrl_move_coord(value: i32) -> i32 { + if value == -1 { + -2 + } else { + value + } +} + pub fn resize_window(window_id: u64, width: i32, height: i32) -> Result { unmaximize(window_id)?; let id = window_id_arg(window_id); @@ -259,7 +278,7 @@ fn next_field<'a>(rest: &mut &'a str) -> Option<&'a str> { fn clean(value: &str) -> Option { let value = value.trim(); - (!value.is_empty() && value != "N/A").then(|| value.to_string()) + (!value.is_empty() && !value.eq_ignore_ascii_case("N/A")).then(|| value.to_string()) } fn env_nonempty(name: &str) -> Option { @@ -327,6 +346,22 @@ mod tests { assert_eq!(claude.pid, None); } + #[test] + fn move_coord_avoids_wmctrl_preserve_sentinel() { + assert_eq!(wmctrl_move_coord(-1), -2); + assert_eq!(wmctrl_move_coord(0), 0); + assert_eq!(wmctrl_move_coord(-40), -40); + assert_eq!(wmctrl_move_coord(1920), 1920); + } + + #[test] + fn clean_drops_na_case_insensitively_and_blanks() { + assert_eq!(clean("N/A"), None); + assert_eq!(clean("n/a"), None); + assert_eq!(clean(" "), None); + assert_eq!(clean(" Firefox "), Some("Firefox".to_string())); + } + #[test] fn parses_title_with_multiple_spaces_and_empty_title() { let output = "0x00000001 0 100 0 0 800 600 term.Term rog\n"; From d087b261c8ba9de11a2d7c844fd36c346e4e2ceb Mon Sep 17 00:00:00 2001 From: devthejo Date: Sun, 5 Jul 2026 14:42:28 +0200 Subject: [PATCH 3/5] fix(windowing): gate X11 focus caps on xprop; list x11 in capability map probe(): the focused flag (and thus focused_window/activate verification) comes from _NET_ACTIVE_WINDOW via xprop, so advertise can_focus_apps/can_focus_windows only when xprop is on PATH. Listing still works with wmctrl alone. diagnostics: capabilities.window_control hard-coded the named backend fields and omitted i3 and x11, so it was empty on X11-only sessions even though the registry uses the x11 backend. Read i3/x11 from windowing.backends (tried last). Co-Authored-By: Claude Opus 4.8 --- src/diagnostics.rs | 11 ++++++++++- src/windowing/backends/x11.rs | 36 +++++++++++++++++++++++++++-------- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/diagnostics.rs b/src/diagnostics.rs index be38996..2ab3b62 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -1,6 +1,6 @@ use crate::windowing::registry::{ self, COSMIC_WAYLAND_BACKEND, GNOME_SHELL_EXTENSION_BACKEND, GNOME_SHELL_INTROSPECT_BACKEND, - HYPRLAND_BACKEND, KWIN_BACKEND, + HYPRLAND_BACKEND, I3_BACKEND, KWIN_BACKEND, X11_BACKEND, }; use schemars::JsonSchema; use serde::Serialize; @@ -240,6 +240,15 @@ fn capability_map( if windowing.cosmic_helper.ok { window_backends.push("cosmic".to_string()); } + // i3 and the generic X11/EWMH backend have no dedicated WindowingReport + // field; read them from the probe map (tried last) so the capability list + // matches the backends the registry will actually use. + if windowing.backends.get(I3_BACKEND).is_some_and(|check| check.ok) { + window_backends.push(I3_BACKEND.to_string()); + } + if windowing.backends.get(X11_BACKEND).is_some_and(|check| check.ok) { + window_backends.push(X11_BACKEND.to_string()); + } let mut accessibility_backends = Vec::new(); if accessibility.at_spi_enabled.ok || accessibility.toolkit_accessibility.ok { diff --git a/src/windowing/backends/x11.rs b/src/windowing/backends/x11.rs index 69c5027..29a8956 100644 --- a/src/windowing/backends/x11.rs +++ b/src/windowing/backends/x11.rs @@ -40,14 +40,25 @@ pub fn probe() -> BackendProbe { return probe_fail("no X11 session (needs DISPLAY on an X11, not Wayland, session)"); } match wmctrl().args(["-l", "-p", "-G", "-x"]).output() { - Ok(output) if output.status.success() => BackendProbe { - id: X11_BACKEND, - ok: true, - can_list_windows: true, - can_focus_apps: true, - can_focus_windows: true, - detail: "wmctrl listed X11/EWMH windows".to_string(), - }, + Ok(output) if output.status.success() => { + // Listing only needs wmctrl, but the `focused` flag (and therefore + // focused_window() and activate_window's focus verification) comes + // from `_NET_ACTIVE_WINDOW` read via xprop. Without xprop we can list + // but cannot verify focus, so don't advertise focus capabilities. + let can_focus = command_on_path("xprop"); + BackendProbe { + id: X11_BACKEND, + ok: true, + can_list_windows: true, + can_focus_apps: can_focus, + can_focus_windows: can_focus, + detail: if can_focus { + "wmctrl listed X11/EWMH windows".to_string() + } else { + "wmctrl listed X11/EWMH windows; xprop missing, so focused-window verification is unavailable".to_string() + }, + } + } Ok(output) => probe_fail(&format!( "wmctrl -l failed: {}", String::from_utf8_lossy(&output.stderr).trim() @@ -288,6 +299,15 @@ fn env_nonempty(name: &str) -> Option { .filter(|value| !value.is_empty()) } +/// True if `cmd` is an executable found on `$PATH`. Used to gate focus +/// capabilities on `xprop` without spawning it (xprop with no args would block +/// reading a window interactively). +fn command_on_path(cmd: &str) -> bool { + env::var_os("PATH").is_some_and(|paths| { + env::split_paths(&paths).any(|dir| dir.join(cmd).is_file()) + }) +} + #[cfg(test)] mod tests { use super::*; From 81c5e818d23ea09e1de8c4fb34c6b9d43f67c99a Mon Sep 17 00:00:00 2001 From: devthejo Date: Sun, 5 Jul 2026 15:27:18 +0200 Subject: [PATCH 4/5] fix(windowing): reject non-positive X11 resize; fix doctor note when focus unavailable resize_window: reject width<=0 || height<=0 before wmctrl -e, where a non-positive value is the "preserve current value" sentinel and would report success while leaving a dimension unchanged. windowing note: when a backend can list windows but cannot verify focus (e.g. wmctrl present but xprop missing on X11, so can_focus_windows=false), say listing-only instead of claiming focused_window and targeted-input verification are available. Co-Authored-By: Claude Opus 4.8 --- src/diagnostics.rs | 4 +++- src/windowing/backends/x11.rs | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/diagnostics.rs b/src/diagnostics.rs index 2ab3b62..f0bb944 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -587,7 +587,9 @@ fn windowing_report(platform: &PlatformReport) -> WindowingReport { let can_focus_apps = probes.iter().any(|probe| probe.can_focus_apps); let can_focus_windows = probes.iter().any(|probe| probe.can_focus_windows); let note = if can_list_windows { - if cosmic_helper.ok && is_cosmic_wayland_platform(platform) { + if !can_focus_windows { + "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)." + } else if cosmic_helper.ok && is_cosmic_wayland_platform(platform) { "A COSMIC Wayland window backend is available for list_windows, focused_window, and targeted input verification." } else if kwin.ok { "A KWin/Plasma window backend is available for list_windows, focused_window, and targeted input verification." diff --git a/src/windowing/backends/x11.rs b/src/windowing/backends/x11.rs index 29a8956..3034a2a 100644 --- a/src/windowing/backends/x11.rs +++ b/src/windowing/backends/x11.rs @@ -134,6 +134,12 @@ fn wmctrl_move_coord(value: i32) -> i32 { } pub fn resize_window(window_id: u64, width: i32, height: i32) -> Result { + // wmctrl -e reads -1 (and rejects <= 0) as "preserve current value" per + // field, so a non-positive size would silently leave a dimension unchanged + // while reporting success. Reject it up front. + if width <= 0 || height <= 0 { + bail!("resize requires positive width and height (got {width}x{height})"); + } unmaximize(window_id)?; let id = window_id_arg(window_id); let geometry = format!("0,-1,-1,{width},{height}"); From 3f2db12759b9cb7cf420931aa3f47518b9a4fe65 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Sun, 5 Jul 2026 23:50:19 +0300 Subject: [PATCH 5/5] style: format X11 backend changes --- src/diagnostics.rs | 12 ++++++++++-- src/windowing/backends/x11.rs | 15 ++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/diagnostics.rs b/src/diagnostics.rs index f0bb944..ac5061b 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -243,10 +243,18 @@ fn capability_map( // i3 and the generic X11/EWMH backend have no dedicated WindowingReport // field; read them from the probe map (tried last) so the capability list // matches the backends the registry will actually use. - if windowing.backends.get(I3_BACKEND).is_some_and(|check| check.ok) { + if windowing + .backends + .get(I3_BACKEND) + .is_some_and(|check| check.ok) + { window_backends.push(I3_BACKEND.to_string()); } - if windowing.backends.get(X11_BACKEND).is_some_and(|check| check.ok) { + if windowing + .backends + .get(X11_BACKEND) + .is_some_and(|check| check.ok) + { window_backends.push(X11_BACKEND.to_string()); } diff --git a/src/windowing/backends/x11.rs b/src/windowing/backends/x11.rs index 3034a2a..a4e201a 100644 --- a/src/windowing/backends/x11.rs +++ b/src/windowing/backends/x11.rs @@ -309,9 +309,8 @@ fn env_nonempty(name: &str) -> Option { /// capabilities on `xprop` without spawning it (xprop with no args would block /// reading a window interactively). fn command_on_path(cmd: &str) -> bool { - env::var_os("PATH").is_some_and(|paths| { - env::split_paths(&paths).any(|dir| dir.join(cmd).is_file()) - }) + env::var_os("PATH") + .is_some_and(|paths| env::split_paths(&paths).any(|dir| dir.join(cmd).is_file())) } #[cfg(test)] @@ -354,10 +353,16 @@ mod tests { assert_eq!(firefox.app_id.as_deref(), Some("Navigator")); assert_eq!(firefox.wm_class.as_deref(), Some("firefox")); assert_eq!(firefox.pid, Some(4564)); - assert_eq!(firefox.title.as_deref(), Some("Nouvel onglet — Mozilla Firefox")); + assert_eq!( + firefox.title.as_deref(), + Some("Nouvel onglet — Mozilla Firefox") + ); assert_eq!(firefox.workspace, Some(2)); let bounds = firefox.bounds.as_ref().unwrap(); - assert_eq!((bounds.x, bounds.y, bounds.width, bounds.height), (Some(-40), Some(-40), 2600, 1440)); + assert_eq!( + (bounds.x, bounds.y, bounds.width, bounds.height), + (Some(-40), Some(-40), 2600, 1440) + ); assert_eq!(firefox.client_type.as_deref(), Some("x11")); assert_eq!(firefox.backend, X11_BACKEND); assert!(!firefox.focused);