Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
da3b3d4
fix(input/diagnostics): close Linux correctness gaps
nisavid Aug 18, 2026
35ef569
fix(sync): keep correctness export source-bound
nisavid Aug 18, 2026
45fbafb
feat(windowing/niri): add bounded IPC backend
nisavid Aug 18, 2026
bc31bc3
fix(input/pointer): preserve extended-button fallthrough
nisavid Aug 18, 2026
3e5fa0d
fix(windowing/kwin): authenticate script callbacks
nisavid Aug 18, 2026
5460acd
fix(windowing/gnome): report stale extension reloads
nisavid Aug 18, 2026
0ba9d5c
fix(windowing/kwin): bound script transactions
nisavid Aug 18, 2026
f6a9751
fix(diagnostics/accessibility): require complete AT-SPI readiness
nisavid Aug 18, 2026
8be5be1
fix(windowing/kwin): preserve callback registration ownership
nisavid Aug 18, 2026
32795bf
fix(windowing/gnome): fail closed on unknown extension state
nisavid Aug 18, 2026
b8afb98
perf(input/pointer): skip unsupported absolute buttons early
nisavid Aug 18, 2026
bebbdcc
fix(windowing/niri): report decorated tile bounds
nisavid Aug 18, 2026
4254542
fix(windowing): remove Niri backend
nisavid Aug 19, 2026
3d857f0
fix(gnome): bound extension setup commands
nisavid Aug 19, 2026
1aa2458
fix(input): report clamped pointer landing
nisavid Aug 19, 2026
2243d26
fix(input): report emitted CLI coordinates
nisavid Aug 19, 2026
d2521d0
test(command): reuse process exit verifier
nisavid Aug 19, 2026
6c5a7c9
refactor(input): model pointer landing explicitly
nisavid Aug 19, 2026
2d7ab1f
fix(windowing/kwin): classify Plasma 6 clients
nisavid Aug 20, 2026
39efa80
docs(changelog): note Plasma 6 client classification
nisavid Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Niri sessions now support window listing, focused-window queries, and exact
window activation through the compositor's local IPC command. Reported sizes
prefer visual tile bounds, including compositor decorations.

### Fixed
- Absolute uinput pointer axes now end at the final logical desktop pixel, so
edge coordinates are advertised and clamped consistently.
- Capability maps now advertise AT-SPI only when its bus is reachable and a
toolkit accessibility status is actually enabled.
- Buttons outside the absolute uinput device's left, middle, and right set now
fall through to a backend that can synthesize them instead of becoming left clicks.
- Temporary KWin script callbacks now accept one matching response from the
current `org.kde.KWin` bus owner, reject spoofed or replayed responses, and
time out the complete script transaction before cleaning up owned temporary
state without disturbing a colliding callback registration.
- GNOME extension setup now reports when changed files require an already-active
Shell extension to reload before its newly installed DBus methods are served,
and requires that reload when the previous extension state cannot be read.

## [0.4.9] - 2026-08-12

### Fixed
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ Validated manually on Ubuntu 25.10 (GNOME Shell 50.1, Wayland). Other compositor
| GNOME X11 | `org.gnome.Shell.Introspect`, then generic X11/EWMH | AT-SPI works; keyboard input prefers `xdotool`/XTEST so the live XKB layout resolves keys correctly. |
| KDE Plasma / KWin | temporary KWin DBus scripting | Lists and focuses windows through Plasma 5 or 6 `org.kde.KWin` scripting APIs when the session bus exposes them. |
| Hyprland | `hyprctl clients -j` and `hyprctl dispatch focuswindow` | Requires `hyprctl` in the desktop session. |
| Niri | `niri msg --json windows` and `niri msg action focus-window` | Requires `NIRI_SOCKET` and `niri` in the desktop session. Window sizes are reported without inventing unavailable global coordinates. |
| i3 | `i3-msg`; optional `xprop` for PID hydration | Lists and focuses i3 windows over the active i3 IPC socket. |
| COSMIC Wayland | `computer-use-linux-cosmic` helper | Installed automatically by `./install.sh`, `cargo install`, and npm. For custom/manual layouts, put the helper next to the main binary, on `PATH`, or point `COMPUTER_USE_LINUX_COSMIC_HELPER` at it. |
| Sway / generic wlroots | no dedicated backend yet | AT-SPI, screenshots, and global `ydotool` input can still work; exact window list/focus is currently unavailable unless another backend applies. |
Expand Down Expand Up @@ -314,7 +315,7 @@ Spawn the binary with `["mcp"]` as the argv tail. It speaks JSON-RPC over stdio

You may need to restart toolkit-using apps for the change to take effect.

3. **If `windowing.can_list_windows = false`** — inspect `doctor.windowing.backends`. On GNOME Wayland, run `computer-use-linux setup-window-targeting` (or call `setup_window_targeting`) to install the bundled `computer-use-linux@avifenesh.dev` Shell extension, then log out and back in so GNOME Shell loads it. On KDE, Hyprland, i3, COSMIC, or generic X11, install or expose the matching compositor tool/helper shown in the backend details.
3. **If `windowing.can_list_windows = false`** — inspect `doctor.windowing.backends`. On GNOME Wayland, run `computer-use-linux setup-window-targeting` (or call `setup_window_targeting`) to install the bundled `computer-use-linux@avifenesh.dev` Shell extension, then log out and back in so GNOME Shell loads it. On KDE, Hyprland, Niri, i3, COSMIC, or generic X11, install or expose the matching compositor tool/helper shown in the backend details.

4. **Grant the screencast portal on first screenshot.** The first time `get_app_state` or any screenshot subcommand runs, GNOME will pop a portal dialog asking to share the screen. Accept once and tick "remember" to make it sticky for the session.

Expand Down Expand Up @@ -363,7 +364,7 @@ files.
- **MCP transport** — [`rmcp`](https://crates.io/crates/rmcp) with the `transport-io` feature; stdio framing, no network.
- **Input fallback** — on X11, keyboard input prefers `xdotool`/XTEST and falls back only when xdotool cannot launch. On Wayland, when the remote-desktop portal isn't available or the host wants deterministic injection, the binary uses a compatible ydotool 1.0.3+ CLI and `ydotoold` socket, which writes to `/dev/uinput`. `install.sh` can configure `ydotoold`; the `setup` command only enables the GNOME AT-SPI bridge.
- **Native X11 coordinate clicks** — eligible native X11 sessions use one supervised `xdotool mousemove -- X Y click --repeat N BUTTON` command for left, middle, and right clicks; ydotool is used only when xdotool cannot launch, while a launched nonzero xdotool command is reported as an error without replay. `COMPUTER_USE_LINUX_FORCE_YDOTOOL_POINTER=1` skips this xdotool path.
- **Window registry** — `list_windows`, `focused_window`, `activate_window`, `press_key`, and `type_text` share a backend registry. It tries GNOME extension, GNOME Introspect, COSMIC helper, KWin scripting, Hyprland `hyprctl`, i3 IPC, and generic X11/EWMH in that order, skipping empty or failed backends so another compositor backend can answer.
- **Window registry** — `list_windows`, `focused_window`, `activate_window`, `press_key`, and `type_text` share a backend registry. It tries GNOME extension, GNOME Introspect, COSMIC helper, KWin scripting, Hyprland `hyprctl`, Niri IPC, i3 IPC, and generic X11/EWMH in that order, skipping empty or failed backends so another compositor backend can answer.
- **GNOME extension fallback** — recent GNOME builds deny `org.gnome.Shell.Introspect.GetWindows` to non-blessed clients. The bundled Shell extension exposes window data and exact activation under `dev.avifenesh.ComputerUseLinux.WindowControl`.
- **COSMIC helper** — `computer-use-linux-cosmic` talks to COSMIC toplevel protocols and is resolved from `COMPUTER_USE_LINUX_COSMIC_HELPER`, next to the running binary, or from `PATH`.
- **Terminal enrichment** — `list_windows` cross-references each terminal window with its controlling TTY and the foreground process on that TTY, so `type_text` / `press_key` can target "the terminal where `pytest` is running" without the host ever knowing the window id.
Expand Down Expand Up @@ -391,7 +392,7 @@ If you're running this on a shared workstation, set `ydotoold`'s socket permissi
- **`input.ydotool.ok = false` with an unsupported CLI message** — install ydotool 1.0.3 or newer. A running daemon or socket alone is not enough; `doctor` verifies the raw key, wheel, stdin typing, and absolute-movement command family before advertising the backend.
- **`input.uinput.ok = false`** — `/dev/uinput` isn't accessible to your user. Fix: add yourself to the `input` group (`sudo usermod -aG input $USER`) and re-login. On distros that ship `uinput` as a kernel module without auto-loading it, add `uinput` to `/etc/modules-load.d/`. Direct uinput supplies absolute pointer input only, so `doctor` also requires a keyboard-capable portal, xdotool, or ydotool backend.
- **Portal calls hang or time out** — `xdg-desktop-portal` or its backend (`-gnome`, `-gtk`, `-kde`, `-wlr`) crashed. Fix: check `journalctl --user -u xdg-desktop-portal -u xdg-desktop-portal-gnome --since '5 min ago'` and restart the relevant unit.
- **KWin / Hyprland / i3 / COSMIC / X11 windowing is unavailable** — check `doctor.windowing.backends`. KWin needs session-bus scripting; Hyprland needs `hyprctl`; i3 needs `i3-msg` and its IPC socket; generic X11 needs `wmctrl` and `xprop`. COSMIC needs `computer-use-linux-cosmic`, which the standard installers provide automatically; if you copied binaries by hand, copy the helper too or set `COMPUTER_USE_LINUX_COSMIC_HELPER`.
- **KWin / Hyprland / Niri / i3 / COSMIC / X11 windowing is unavailable** — check `doctor.windowing.backends`. KWin needs session-bus scripting; Hyprland needs `hyprctl`; Niri needs `NIRI_SOCKET` and `niri msg`; i3 needs `i3-msg` and its IPC socket; generic X11 needs `wmctrl` and `xprop`. COSMIC needs `computer-use-linux-cosmic`, which the standard installers provide automatically; if you copied binaries by hand, copy the helper too or set `COMPUTER_USE_LINUX_COSMIC_HELPER`.
- **Screenshots return black frames on multi-monitor setups** — known portal / compositor edge case. Use `get_app_state` with `include_screenshot: false` and rely on AT-SPI until the portal backend is healthy.
- **`type_text` types into the wrong window** — pass an explicit target (`window_id`, `pid`, `wm_class`, `title`, or for terminals `tty` / `terminal_pid` / `terminal_command` / `terminal_cwd`). Without a target, input goes to whatever window currently has compositor focus.

Expand Down
94 changes: 77 additions & 17 deletions src/abs_pointer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,46 @@ use evdev::{
PropType, UinputAbsSetup,
};

#[derive(Clone, Copy)]
struct AbsPointerGeometry {
max_x: i32,
max_y: i32,
}

impl AbsPointerGeometry {
fn from_dimensions(width: i32, height: i32) -> Self {
Self {
max_x: width.max(1).saturating_sub(1),
max_y: height.max(1).saturating_sub(1),
}
}

fn axis_maxima(self) -> (i32, i32) {
(self.max_x, self.max_y)
}

fn clamp_coordinates(self, x: i32, y: i32) -> (i32, i32) {
(x.clamp(0, self.max_x), y.clamp(0, self.max_y))
}
}

pub struct AbsPointer {
device: VirtualDevice,
width: i32,
height: i32,
geometry: AbsPointerGeometry,
}

impl AbsPointer {
/// Create the absolute pointer sized to the logical desktop `width`×`height`
/// (the portal screenshot dimensions). Blocks ~`settle` ms so libinput picks
/// the device up before the first event.
pub fn create(width: i32, height: i32) -> Result<Self> {
let width = width.max(1);
let height = height.max(1);
let geometry = AbsPointerGeometry::from_dimensions(width, height);
let (max_x, max_y) = geometry.axis_maxima();
// value, min, max, fuzz, flat, resolution. resolution=1 unit/px.
let abs_x =
UinputAbsSetup::new(AbsoluteAxisCode::ABS_X, AbsInfo::new(0, 0, width, 0, 0, 1));
UinputAbsSetup::new(AbsoluteAxisCode::ABS_X, AbsInfo::new(0, 0, max_x, 0, 0, 1));
let abs_y =
UinputAbsSetup::new(AbsoluteAxisCode::ABS_Y, AbsInfo::new(0, 0, height, 0, 0, 1));
UinputAbsSetup::new(AbsoluteAxisCode::ABS_Y, AbsInfo::new(0, 0, max_y, 0, 0, 1));
let keys =
AttributeSet::from_iter([KeyCode::BTN_LEFT, KeyCode::BTN_RIGHT, KeyCode::BTN_MIDDLE]);
// INPUT_PROP_DIRECT marks the device as a direct (absolute) pointer so
Expand All @@ -59,17 +81,12 @@ impl AbsPointer {
// Give udev/libinput time to enumerate the new device.
sleep(Duration::from_millis(500));

Ok(Self {
device,
width,
height,
})
Ok(Self { device, geometry })
}

/// Move the pointer to absolute logical coordinates `(x, y)`.
pub fn move_to(&mut self, x: i32, y: i32) -> Result<()> {
let x = x.clamp(0, self.width);
let y = y.clamp(0, self.height);
let (x, y) = self.geometry.clamp_coordinates(x, y);
self.device
.emit(&[
InputEvent::new_now(EventType::ABSOLUTE.0, AbsoluteAxisCode::ABS_X.0, x),
Expand Down Expand Up @@ -125,11 +142,12 @@ pub enum PointerButton {
}

impl PointerButton {
pub fn from_name(name: Option<&str>) -> Self {
pub fn from_name(name: Option<&str>) -> Option<Self> {
match name.unwrap_or("left").to_ascii_lowercase().as_str() {
"right" => Self::Right,
"middle" => Self::Middle,
_ => Self::Left,
"left" => Some(Self::Left),
"right" => Some(Self::Right),
"middle" => Some(Self::Middle),
_ => None,
}
}

Expand All @@ -141,3 +159,45 @@ impl PointerButton {
}
}
}

#[cfg(test)]
mod tests {
use super::{AbsPointerGeometry, PointerButton};

#[test]
fn axis_range_ends_at_last_desktop_pixel() {
let geometry = AbsPointerGeometry::from_dimensions(1920, 1080);

assert_eq!(geometry.axis_maxima(), (1919, 1079));
}

#[test]
fn pointer_coordinates_clamp_to_last_desktop_pixel() {
let geometry = AbsPointerGeometry::from_dimensions(1920, 1080);

assert_eq!(geometry.clamp_coordinates(1920, 1080), (1919, 1079));
}

#[test]
fn unsupported_buttons_fall_through_to_other_backends() {
assert!(matches!(
PointerButton::from_name(None),
Some(PointerButton::Left)
));
assert!(matches!(
PointerButton::from_name(Some("right")),
Some(PointerButton::Right)
));
assert!(matches!(
PointerButton::from_name(Some("middle")),
Some(PointerButton::Middle)
));

for button in ["side", "extra", "forward", "back"] {
assert!(
PointerButton::from_name(Some(button)).is_none(),
"{button} must fall through instead of becoming a left click"
);
}
}
}
69 changes: 63 additions & 6 deletions src/diagnostics.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::windowing::registry::{
self, COSMIC_WAYLAND_BACKEND, GNOME_SHELL_EXTENSION_BACKEND, GNOME_SHELL_INTROSPECT_BACKEND,
HYPRLAND_BACKEND, I3_BACKEND, KWIN_BACKEND, X11_BACKEND,
HYPRLAND_BACKEND, I3_BACKEND, KWIN_BACKEND, NIRI_BACKEND, X11_BACKEND,
};
use crate::ydotool;
use schemars::JsonSchema;
Expand All @@ -19,6 +19,7 @@ const DESKTOP_ENV_KEYS: &[&str] = &[
"DESKTOP_SESSION",
"DISPLAY",
"HYPRLAND_INSTANCE_SIGNATURE",
"NIRI_SOCKET",
"XAUTHORITY",
"YDOTOOL_SOCKET",
"XDG_SESSION_DESKTOP",
Expand Down Expand Up @@ -327,9 +328,16 @@ fn capability_map_with_portal_keyboard(
if windowing.hyprland.ok {
window_backends.push("hyprland".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.
// Niri, i3, and the generic X11/EWMH backend have no dedicated
// WindowingReport field; read them from the probe map so the capability
// list matches the registry order.
if windowing
.backends
.get(NIRI_BACKEND)
.is_some_and(|check| check.ok)
{
window_backends.push(NIRI_BACKEND.to_string());
}
if windowing
.backends
.get(I3_BACKEND)
Expand All @@ -342,7 +350,7 @@ fn capability_map_with_portal_keyboard(
}

let mut accessibility_backends = Vec::new();
if accessibility.at_spi_enabled.ok || accessibility.toolkit_accessibility.ok {
if can_build_accessibility_tree(accessibility) {
accessibility_backends.push("at_spi".to_string());
}

Expand Down Expand Up @@ -725,7 +733,7 @@ fn windowing_report(platform: &PlatformReport) -> WindowingReport {
"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."
"Window listing is unavailable or denied. Computer Use can still use screenshots, AT-SPI, and global ydotool input, but targeted window input cannot be verified. On GNOME, run setup_window_targeting to install the optional GNOME Shell extension backend. On COSMIC, ensure the bundled COSMIC helper is present and can connect to the session. On KDE/Plasma, ensure KWin exposes org.kde.KWin scripting on the session bus. On Hyprland, ensure hyprctl is available in the session. On Niri, ensure NIRI_SOCKET is available and niri msg can reach the active compositor."
}
.to_string();

Expand Down Expand Up @@ -1460,6 +1468,26 @@ mod tests {
assert!(can_build_accessibility_tree(&report));
}

#[test]
fn capability_map_advertises_only_a_buildable_accessibility_tree() {
let platform = platform_report();
let portals = portal_report(Check::fail("missing"));
let windowing = windowing_report(false, false);
let input = input_report(false);

for accessibility in [
accessibility_report(Check::fail("permission denied"), Check::ok("true")),
accessibility_report(
Check::ok("('unix:path=/run/user/1000/at-spi/bus',)"),
Check::ok("false"),
),
] {
let capabilities =
capability_map(&platform, &portals, &accessibility, &windowing, &input);
assert!(capabilities.accessibility.is_empty());
}
}

#[test]
fn parses_parent_pid_from_proc_status() {
let status = "Name:\ttest\nPid:\t42\nPPid:\t7\n";
Expand Down Expand Up @@ -1487,6 +1515,11 @@ mod tests {
assert!(DESKTOP_ENV_KEYS.contains(&"XAUTHORITY"));
}

#[test]
fn desktop_env_hydration_includes_niri_socket() {
assert!(DESKTOP_ENV_KEYS.contains(&"NIRI_SOCKET"));
}

#[test]
fn desktop_env_hydration_preserves_explicit_native_x11() {
let current_env = HashMap::from([
Expand Down Expand Up @@ -1919,6 +1952,30 @@ mod tests {
assert!(readiness.blockers.is_empty());
}

#[test]
fn capability_map_reports_niri_window_control() {
let platform = platform_report();
let portals = portal_report(Check::fail("missing"));
let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true"));
let mut windowing = windowing_report(false, false);
windowing.backends.insert(
crate::windowing::registry::NIRI_BACKEND.to_string(),
Check::ok("niri msg returned windows"),
);
let input = input_report(false);

let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input);

assert_eq!(
capabilities.window_control,
vec![crate::windowing::registry::NIRI_BACKEND]
);
assert_eq!(
capabilities.preferred.window_control.as_deref(),
Some(crate::windowing::registry::NIRI_BACKEND)
);
}

#[test]
fn readiness_message_mentions_generic_window_targeting() {
let platform = platform_report();
Expand Down
Loading
Loading