Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 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
147 changes: 120 additions & 27 deletions src/abs_pointer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,60 @@ use evdev::{
PropType, UinputAbsSetup,
};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[must_use = "pointer input may be clamped; inspect requested and emitted coordinates"]
pub(crate) struct PointerLanding {
pub(crate) requested: (i32, i32),
pub(crate) emitted: (i32, i32),
}

#[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))
}

fn landing_for(self, x: i32, y: i32) -> PointerLanding {
PointerLanding {
requested: (x, y),
emitted: self.clamp_coordinates(x, 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,29 +95,32 @@ 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);
/// Move the pointer to absolute logical coordinates `(x, y)` and report
/// both the requested point and the values emitted after edge clamping.
pub fn move_to(&mut self, x: i32, y: i32) -> Result<PointerLanding> {
let landing = self.geometry.landing_for(x, y);
let (emitted_x, emitted_y) = landing.emitted;
self.device
.emit(&[
InputEvent::new_now(EventType::ABSOLUTE.0, AbsoluteAxisCode::ABS_X.0, x),
InputEvent::new_now(EventType::ABSOLUTE.0, AbsoluteAxisCode::ABS_Y.0, y),
InputEvent::new_now(EventType::ABSOLUTE.0, AbsoluteAxisCode::ABS_X.0, emitted_x),
InputEvent::new_now(EventType::ABSOLUTE.0, AbsoluteAxisCode::ABS_Y.0, emitted_y),
])
.context("failed to emit absolute motion")?;
Ok(())
Ok(landing)
}

/// Move to `(x, y)` then press+release `button` `count` times.
pub fn click(&mut self, x: i32, y: i32, button: PointerButton, count: u32) -> Result<()> {
self.move_to(x, y)?;
pub fn click(
&mut self,
x: i32,
y: i32,
button: PointerButton,
count: u32,
) -> Result<PointerLanding> {
let landing = self.move_to(x, y)?;
sleep(Duration::from_millis(30));
let code = button.key_code();
for _ in 0..count.max(1) {
Expand All @@ -92,7 +131,7 @@ impl AbsPointer {
.emit(&[InputEvent::new_now(EventType::KEY.0, code, 0)])?;
sleep(Duration::from_millis(40));
}
Ok(())
Ok(landing)
}

/// Press at `(start)`, move to `(end)`, release — a drag with `button`.
Expand All @@ -103,12 +142,14 @@ impl AbsPointer {
button: PointerButton,
) -> Result<()> {
let code = button.key_code();
self.move_to(start.0, start.1)?;
// Drag currently reports backend success only; retain the landing
// values explicitly so their intentional omission stays visible.
let _start_landing = self.move_to(start.0, start.1)?;
sleep(Duration::from_millis(30));
self.device
.emit(&[InputEvent::new_now(EventType::KEY.0, code, 1)])?;
sleep(Duration::from_millis(40));
self.move_to(end.0, end.1)?;
let _end_landing = self.move_to(end.0, end.1)?;
sleep(Duration::from_millis(40));
self.device
.emit(&[InputEvent::new_now(EventType::KEY.0, code, 0)])?;
Expand All @@ -125,11 +166,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 +183,54 @@ 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_landing_preserves_the_request_and_emitted_coordinates() {
let geometry = AbsPointerGeometry::from_dimensions(1920, 1080);

for (requested, emitted) in [
((640, 480), (640, 480)),
((1920, 1080), (1919, 1079)),
((-1, -1), (0, 0)),
((i32::MAX, i32::MAX), (1919, 1079)),
] {
let landing = geometry.landing_for(requested.0, requested.1);
assert_eq!(landing.requested, requested);
assert_eq!(landing.emitted, emitted);
}
}

#[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"
);
}
}
}
49 changes: 44 additions & 5 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,8 @@ pub(crate) async fn run_from_env() -> Result<()> {
let cap = screenshot::capture_screenshot_raw().await?;
eprintln!("desktop logical size: {}x{}", cap.width, cap.height);
let mut p = abs_pointer::AbsPointer::create(cap.width as i32, cap.height as i32)?;
p.click(x, y, abs_pointer::PointerButton::Left, 1)?;
println!(
"{}",
serde_json::json!({"ok": true, "x": x, "y": y, "w": cap.width, "h": cap.height})
);
let landing = p.click(x, y, abs_pointer::PointerButton::Left, 1)?;
println!("{}", abs_test_report(landing, (cap.width, cap.height)));
Ok(())
}
Some("screenshot") => {
Expand Down Expand Up @@ -145,8 +142,50 @@ pub(crate) async fn run_from_env() -> Result<()> {
}
}

fn abs_test_report(
landing: abs_pointer::PointerLanding,
dimensions: (u32, u32),
) -> serde_json::Value {
serde_json::json!({
"ok": true,
"requested_x": landing.requested.0,
"requested_y": landing.requested.1,
"x": landing.emitted.0,
"y": landing.emitted.1,
"w": dimensions.0,
"h": dimensions.1
})
}

fn print_help() {
println!(
"computer-use-linux\n\nUsage:\n computer-use-linux mcp\n computer-use-linux doctor\n computer-use-linux setup\n computer-use-linux setup-window-targeting\n computer-use-linux apps\n computer-use-linux state [APP_NAME]\n computer-use-linux screenshot\n computer-use-linux windows"
);
}

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

#[test]
fn abs_test_report_distinguishes_requested_and_emitted_coordinates() {
assert_eq!(
abs_test_report(
abs_pointer::PointerLanding {
requested: (1920, 1080),
emitted: (1919, 1079),
},
(1920, 1080)
),
serde_json::json!({
"ok": true,
"requested_x": 1920,
"requested_y": 1080,
"x": 1919,
"y": 1079,
"w": 1920,
"h": 1080
})
);
}
}
39 changes: 39 additions & 0 deletions src/command_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ pub(crate) async fn output_with_stdin(
output_with_input(command, action, timeout, Some(input)).await
}

pub(crate) fn output_blocking(command: &mut StdCommand, action: &str) -> Result<Output> {
output_blocking_with_timeout(command, action, COMMAND_TIMEOUT)
}

pub(crate) fn output_blocking_with_timeout(
command: &mut StdCommand,
action: &str,
Expand Down Expand Up @@ -600,6 +604,41 @@ mod tests {
assert!(output.stderr.len() >= 200_000);
}

#[tokio::test]
async fn default_blocking_timeout_kills_the_process_group() {
let leader_path = temporary_pid_path("blocking-default-leader");
let descendant_path = temporary_pid_path("blocking-default-descendant");
let mut command = StdCommand::new("sh");
command.args([
"-c",
&format!(
"printf %s $$ > '{}'; sleep 60 & printf %s $! > '{}'; wait",
leader_path.display(),
descendant_path.display()
),
]);
let started = Instant::now();

let error = output_blocking(&mut command, "run blocking process tree").unwrap_err();

assert!(error
.to_string()
.contains("timed out after 2000 ms while trying to run blocking process tree"));
assert!(started.elapsed() < Duration::from_secs(4));
let leader = fs::read_to_string(&leader_path)
.expect("leader should record its pid")
.parse()
.expect("leader pid should be numeric");
let descendant = fs::read_to_string(&descendant_path)
.expect("descendant should record its pid")
.parse()
.expect("descendant pid should be numeric");
wait_for_process_exit(leader).await;
wait_for_process_exit(descendant).await;
let _ = fs::remove_file(leader_path);
let _ = fs::remove_file(descendant_path);
}

fn temporary_pid_path(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"computer-use-linux-command-runner-{label}-{}-{}.pid",
Expand Down
Loading
Loading