From 567115a6c29cd2d2e2f45f918cb4701de138ef8a Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Tue, 24 Mar 2026 21:36:56 -0400 Subject: [PATCH] Phase 5: window management via x11rb - Add x11rb 0.13 dependency with randr feature - RustConnection and root window in X11Backend - Focus window via _NET_ACTIVE_WINDOW client message - Close window via _NET_CLOSE_WINDOW client message - Move/resize via configure_window - Handler dispatchers for focus, close, move-window, resize-window - list-windows command re-runs snapshot for fresh window tree --- Cargo.lock | 1 + Cargo.toml | 1 + src/backend/x11.rs | 88 +++++++++++++++++++++++++++---- src/daemon/handler.rs | 117 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1cd7282..a52f7ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -691,6 +691,7 @@ dependencies = [ "serde_json", "tokio", "uuid", + "x11rb", "xcap", ] diff --git a/Cargo.toml b/Cargo.toml index 3947a75..9ee6ff4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,3 +20,4 @@ image = { version = "0.25", features = ["png"] } imageproc = "0.26" ab_glyph = "0.2" enigo = "0.6" +x11rb = { version = "0.13", features = ["randr"] } diff --git a/src/backend/x11.rs b/src/backend/x11.rs index 0ee3e04..d4513fd 100644 --- a/src/backend/x11.rs +++ b/src/backend/x11.rs @@ -2,19 +2,30 @@ use anyhow::{Context, Result}; use enigo::{ Axis, Button, Coordinate, Direction, Enigo, Key, Keyboard, Mouse, Settings, }; +use x11rb::connection::Connection; +use x11rb::protocol::xproto::{ + ClientMessageEvent, ConfigureWindowAux, ConnectionExt as XprotoConnectionExt, + EventMask, +}; +use x11rb::rust_connection::RustConnection; use super::annotate::annotate_screenshot; use crate::core::types::{Snapshot, WindowInfo}; pub struct X11Backend { enigo: Enigo, + conn: RustConnection, + root: u32, } impl X11Backend { pub fn new() -> Result { let enigo = Enigo::new(&Settings::default()) .map_err(|e| anyhow::anyhow!("Failed to initialize enigo: {e}"))?; - Ok(Self { enigo }) + let (conn, screen_num) = x11rb::connect(None) + .context("Failed to connect to X11 server")?; + let root = conn.setup().roots[screen_num].root; + Ok(Self { enigo, conn, root }) } } @@ -91,21 +102,78 @@ impl super::DesktopBackend for X11Backend { }) } - // Phase 5: window management (stub) - fn focus_window(&mut self, _xcb_id: u32) -> Result<()> { - anyhow::bail!("Window management not yet implemented (Phase 5)") + fn focus_window(&mut self, xcb_id: u32) -> Result<()> { + // Use _NET_ACTIVE_WINDOW client message (avoids focus-stealing prevention) + let net_active = self + .conn + .intern_atom(false, b"_NET_ACTIVE_WINDOW")? + .reply() + .context("Failed to intern _NET_ACTIVE_WINDOW atom")? + .atom; + + let event = ClientMessageEvent { + response_type: x11rb::protocol::xproto::CLIENT_MESSAGE_EVENT, + format: 32, + sequence: 0, + window: xcb_id, + type_: net_active, + data: x11rb::protocol::xproto::ClientMessageData::from([ + 2u32, 0, 0, 0, 0, // source=2 (pager), timestamp=0, currently_active=0 + ]), + }; + + self.conn.send_event( + false, + self.root, + EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, + event, + )?; + self.conn.flush().context("Failed to flush X11 connection")?; + Ok(()) } - fn move_window(&mut self, _xcb_id: u32, _x: i32, _y: i32) -> Result<()> { - anyhow::bail!("Window management not yet implemented (Phase 5)") + fn move_window(&mut self, xcb_id: u32, x: i32, y: i32) -> Result<()> { + self.conn + .configure_window(xcb_id, &ConfigureWindowAux::new().x(x).y(y))?; + self.conn.flush().context("Failed to flush X11 connection")?; + Ok(()) } - fn resize_window(&mut self, _xcb_id: u32, _w: u32, _h: u32) -> Result<()> { - anyhow::bail!("Window management not yet implemented (Phase 5)") + fn resize_window(&mut self, xcb_id: u32, w: u32, h: u32) -> Result<()> { + self.conn + .configure_window(xcb_id, &ConfigureWindowAux::new().width(w).height(h))?; + self.conn.flush().context("Failed to flush X11 connection")?; + Ok(()) } - fn close_window(&mut self, _xcb_id: u32) -> Result<()> { - anyhow::bail!("Window management not yet implemented (Phase 5)") + fn close_window(&mut self, xcb_id: u32) -> Result<()> { + // Use _NET_CLOSE_WINDOW for graceful close (respects WM protocols) + let net_close = self + .conn + .intern_atom(false, b"_NET_CLOSE_WINDOW")? + .reply() + .context("Failed to intern _NET_CLOSE_WINDOW atom")? + .atom; + + let event = ClientMessageEvent { + response_type: x11rb::protocol::xproto::CLIENT_MESSAGE_EVENT, + format: 32, + sequence: 0, + window: xcb_id, + type_: net_close, + data: x11rb::protocol::xproto::ClientMessageData::from([ + 0u32, 2, 0, 0, 0, // timestamp=0, source=2 (pager) + ]), + }; + + self.conn.send_event( + false, + self.root, + EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, + event, + )?; + self.conn.flush().context("Failed to flush X11 connection")?; + Ok(()) } // Phase 4: input simulation via enigo diff --git a/src/daemon/handler.rs b/src/daemon/handler.rs index 2749f6d..80cded1 100644 --- a/src/daemon/handler.rs +++ b/src/daemon/handler.rs @@ -20,6 +20,11 @@ pub async fn handle_request( "mouse-move" => handle_mouse_move(request, state).await, "mouse-scroll" => handle_mouse_scroll(request, state).await, "mouse-drag" => handle_mouse_drag(request, state).await, + "focus" => handle_window_action(request, state, "focus").await, + "close" => handle_window_action(request, state, "close").await, + "move-window" => handle_move_window(request, state).await, + "resize-window" => handle_resize_window(request, state).await, + "list-windows" => handle_list_windows(state).await, action => Response::err(format!("Unknown action: {action}")), } } @@ -255,6 +260,118 @@ async fn handle_mouse_drag( } } +async fn handle_window_action( + request: &Request, + state: &Arc>, + action: &str, +) -> Response { + let selector = match request.extra.get("selector").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return Response::err("Missing 'selector' field"), + }; + + let mut state = state.lock().await; + + let entry = match state.ref_map.resolve(&selector) { + Some(e) => e.clone(), + None => return Response::err(format!("Could not resolve window: {selector}")), + }; + + let result = match action { + "focus" => state.backend.focus_window(entry.xcb_id), + "close" => state.backend.close_window(entry.xcb_id), + _ => unreachable!(), + }; + + match result { + Ok(()) => Response::ok(serde_json::json!({ + "action": action, + "window": entry.title, + "xcb_id": entry.xcb_id, + })), + Err(e) => Response::err(format!("{action} failed: {e}")), + } +} + +async fn handle_move_window( + request: &Request, + state: &Arc>, +) -> Response { + let selector = match request.extra.get("selector").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return Response::err("Missing 'selector' field"), + }; + let x = request.extra.get("x").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + let y = request.extra.get("y").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + + let mut state = state.lock().await; + let entry = match state.ref_map.resolve(&selector) { + Some(e) => e.clone(), + None => return Response::err(format!("Could not resolve window: {selector}")), + }; + + match state.backend.move_window(entry.xcb_id, x, y) { + Ok(()) => Response::ok(serde_json::json!({ + "moved": entry.title, "x": x, "y": y + })), + Err(e) => Response::err(format!("Move failed: {e}")), + } +} + +async fn handle_resize_window( + request: &Request, + state: &Arc>, +) -> Response { + let selector = match request.extra.get("selector").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return Response::err("Missing 'selector' field"), + }; + let w = request.extra.get("w").and_then(|v| v.as_u64()).unwrap_or(800) as u32; + let h = request.extra.get("h").and_then(|v| v.as_u64()).unwrap_or(600) as u32; + + let mut state = state.lock().await; + let entry = match state.ref_map.resolve(&selector) { + Some(e) => e.clone(), + None => return Response::err(format!("Could not resolve window: {selector}")), + }; + + match state.backend.resize_window(entry.xcb_id, w, h) { + Ok(()) => Response::ok(serde_json::json!({ + "resized": entry.title, "width": w, "height": h + })), + Err(e) => Response::err(format!("Resize failed: {e}")), + } +} + +async fn handle_list_windows( + state: &Arc>, +) -> Response { + let mut state = state.lock().await; + // Re-run snapshot without screenshot, just to get current window list + match state.backend.snapshot(false) { + Ok(snapshot) => { + // Update ref map with fresh data + state.ref_map.clear(); + for win in &snapshot.windows { + state.ref_map.insert(RefEntry { + xcb_id: win.xcb_id, + app_class: win.app_name.clone(), + title: win.title.clone(), + pid: 0, + x: win.x, + y: win.y, + width: win.width, + height: win.height, + focused: win.focused, + minimized: win.minimized, + }); + } + Response::ok(serde_json::json!({"windows": snapshot.windows})) + } + Err(e) => Response::err(format!("List windows failed: {e}")), + } +} + fn parse_coords(s: &str) -> Option<(i32, i32)> { let parts: Vec<&str> = s.split(',').collect(); if parts.len() == 2 {