mirror of
https://github.com/harivansh-afk/deskctl.git
synced 2026-04-15 08:03:43 +00:00
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
This commit is contained in:
parent
314a11bcba
commit
567115a6c2
4 changed files with 197 additions and 10 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -691,6 +691,7 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"x11rb",
|
||||||
"xcap",
|
"xcap",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,3 +20,4 @@ image = { version = "0.25", features = ["png"] }
|
||||||
imageproc = "0.26"
|
imageproc = "0.26"
|
||||||
ab_glyph = "0.2"
|
ab_glyph = "0.2"
|
||||||
enigo = "0.6"
|
enigo = "0.6"
|
||||||
|
x11rb = { version = "0.13", features = ["randr"] }
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,30 @@ use anyhow::{Context, Result};
|
||||||
use enigo::{
|
use enigo::{
|
||||||
Axis, Button, Coordinate, Direction, Enigo, Key, Keyboard, Mouse, Settings,
|
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 super::annotate::annotate_screenshot;
|
||||||
use crate::core::types::{Snapshot, WindowInfo};
|
use crate::core::types::{Snapshot, WindowInfo};
|
||||||
|
|
||||||
pub struct X11Backend {
|
pub struct X11Backend {
|
||||||
enigo: Enigo,
|
enigo: Enigo,
|
||||||
|
conn: RustConnection,
|
||||||
|
root: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl X11Backend {
|
impl X11Backend {
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
let enigo = Enigo::new(&Settings::default())
|
let enigo = Enigo::new(&Settings::default())
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to initialize enigo: {e}"))?;
|
.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<()> {
|
||||||
fn focus_window(&mut self, _xcb_id: u32) -> Result<()> {
|
// Use _NET_ACTIVE_WINDOW client message (avoids focus-stealing prevention)
|
||||||
anyhow::bail!("Window management not yet implemented (Phase 5)")
|
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<()> {
|
fn move_window(&mut self, xcb_id: u32, x: i32, y: i32) -> Result<()> {
|
||||||
anyhow::bail!("Window management not yet implemented (Phase 5)")
|
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<()> {
|
fn resize_window(&mut self, xcb_id: u32, w: u32, h: u32) -> Result<()> {
|
||||||
anyhow::bail!("Window management not yet implemented (Phase 5)")
|
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<()> {
|
fn close_window(&mut self, xcb_id: u32) -> Result<()> {
|
||||||
anyhow::bail!("Window management not yet implemented (Phase 5)")
|
// 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
|
// Phase 4: input simulation via enigo
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ pub async fn handle_request(
|
||||||
"mouse-move" => handle_mouse_move(request, state).await,
|
"mouse-move" => handle_mouse_move(request, state).await,
|
||||||
"mouse-scroll" => handle_mouse_scroll(request, state).await,
|
"mouse-scroll" => handle_mouse_scroll(request, state).await,
|
||||||
"mouse-drag" => handle_mouse_drag(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}")),
|
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<Mutex<DaemonState>>,
|
||||||
|
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<Mutex<DaemonState>>,
|
||||||
|
) -> 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<Mutex<DaemonState>>,
|
||||||
|
) -> 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<Mutex<DaemonState>>,
|
||||||
|
) -> 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)> {
|
fn parse_coords(s: &str) -> Option<(i32, i32)> {
|
||||||
let parts: Vec<&str> = s.split(',').collect();
|
let parts: Vec<&str> = s.split(',').collect();
|
||||||
if parts.len() == 2 {
|
if parts.len() == 2 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue