Rewrite X11 backend to drop xcap

Use x11rb directly for screenshot capture and window metadata so the Linux build no longer drags in Wayland build dependencies.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Harivansh Rathi 2026-03-25 12:28:23 -04:00
parent cc7490993a
commit e392ba1055
11 changed files with 488 additions and 2128 deletions

1921
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -15,7 +15,6 @@ anyhow = "1"
dirs = "6"
libc = "0.2"
uuid = { version = "1", features = ["v4"] }
xcap = "0.8"
image = { version = "0.25", features = ["png"] }
imageproc = "0.26"
ab_glyph = "0.2"

View file

@ -45,11 +45,7 @@ pub fn annotate_screenshot(image: &mut RgbaImage, windows: &[WindowInfo]) {
if w > 0 && h > 0 {
draw_hollow_rect_mut(image, Rect::at(x, y).of_size(w, h), color);
if w > 2 && h > 2 {
draw_hollow_rect_mut(
image,
Rect::at(x + 1, y + 1).of_size(w - 2, h - 2),
color,
);
draw_hollow_rect_mut(image, Rect::at(x + 1, y + 1).of_size(w - 2, h - 2), color);
}
}
@ -67,6 +63,14 @@ pub fn annotate_screenshot(image: &mut RgbaImage, windows: &[WindowInfo]) {
);
// Label text
draw_text_mut(image, LABEL_FG, label_x + 3, label_y + 2, scale, &font, &label);
draw_text_mut(
image,
LABEL_FG,
label_x + 3,
label_y + 2,
scale,
&font,
&label,
);
}
}

View file

@ -1,8 +1,8 @@
pub mod annotate;
pub mod x11;
use anyhow::Result;
use crate::core::types::Snapshot;
use anyhow::Result;
#[allow(dead_code)]
pub trait DesktopBackend: Send {

View file

@ -1,86 +1,240 @@
use anyhow::{Context, Result};
use enigo::{
Axis, Button, Coordinate, Direction, Enigo, Key, Keyboard, Mouse, Settings,
};
use enigo::{Axis, Button, Coordinate, Direction, Enigo, Key, Keyboard, Mouse, Settings};
use image::RgbaImage;
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{
ClientMessageEvent, ConfigureWindowAux, ConnectionExt as XprotoConnectionExt,
EventMask,
Atom, AtomEnum, ClientMessageData, ClientMessageEvent, ConfigureWindowAux,
ConnectionExt as XprotoConnectionExt, EventMask, GetPropertyReply, ImageFormat, ImageOrder,
Window,
};
use x11rb::rust_connection::RustConnection;
use super::annotate::annotate_screenshot;
use crate::core::types::{Snapshot, WindowInfo};
struct Atoms {
client_list_stacking: Atom,
active_window: Atom,
net_wm_name: Atom,
utf8_string: Atom,
wm_name: Atom,
wm_class: Atom,
net_wm_state: Atom,
net_wm_state_hidden: Atom,
}
pub struct X11Backend {
enigo: Enigo,
conn: RustConnection,
root: u32,
root: Window,
atoms: Atoms,
}
impl X11Backend {
pub fn new() -> Result<Self> {
let enigo = Enigo::new(&Settings::default())
.map_err(|e| anyhow::anyhow!("Failed to initialize enigo: {e}"))?;
let (conn, screen_num) = x11rb::connect(None)
.context("Failed to connect to X11 server")?;
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 })
}
let atoms = Atoms::new(&conn)?;
Ok(Self {
enigo,
conn,
root,
atoms,
})
}
impl super::DesktopBackend for X11Backend {
fn snapshot(&mut self, annotate: bool) -> Result<Snapshot> {
// Get z-ordered window list via xcap (topmost first internally)
let windows = xcap::Window::all().context("Failed to enumerate windows")?;
fn stacked_windows(&self) -> Result<Vec<Window>> {
let mut windows = self
.get_property_u32(
self.root,
self.atoms.client_list_stacking,
AtomEnum::WINDOW.into(),
1024,
)?
.into_iter()
.map(|id| id as Window)
.collect::<Vec<_>>();
// Get primary monitor for screenshot
let monitors = xcap::Monitor::all().context("Failed to enumerate monitors")?;
let monitor = monitors.into_iter().next().context("No monitor found")?;
if windows.is_empty() {
windows = self
.conn
.query_tree(self.root)?
.reply()
.context("Failed to query root window tree")?
.children;
}
let mut image = monitor
.capture_image()
.context("Failed to capture screenshot")?;
// EWMH exposes bottom-to-top stacking order. Reverse it so @w1 is the topmost window.
windows.reverse();
Ok(windows)
}
// Build window info list
fn collect_window_infos(&self) -> Result<Vec<WindowInfo>> {
let active_window = self.active_window()?;
let mut window_infos = Vec::new();
let mut ref_counter = 1usize;
for win in &windows {
// Each xcap method returns XCapResult<T> - skip windows where metadata fails
let title = win.title().unwrap_or_default();
let app_name = win.app_name().unwrap_or_default();
// Skip windows with empty titles and app names (desktop, panels, etc.)
for window in self.stacked_windows()? {
let title = self.window_title(window).unwrap_or_default();
let app_name = self.window_app_name(window).unwrap_or_default();
if title.is_empty() && app_name.is_empty() {
continue;
}
let xcb_id = win.id().unwrap_or(0);
let x = win.x().unwrap_or(0);
let y = win.y().unwrap_or(0);
let width = win.width().unwrap_or(0);
let height = win.height().unwrap_or(0);
let focused = win.is_focused().unwrap_or(false);
let minimized = win.is_minimized().unwrap_or(false);
let ref_id = format!("w{ref_counter}");
ref_counter += 1;
let (x, y, width, height) = match self.window_geometry(window) {
Ok(geometry) => geometry,
Err(_) => continue,
};
let minimized = self.window_is_minimized(window).unwrap_or(false);
window_infos.push(WindowInfo {
ref_id,
xcb_id,
ref_id: format!("w{ref_counter}"),
xcb_id: window,
title,
app_name,
x,
y,
width,
height,
focused,
focused: active_window == Some(window),
minimized,
});
ref_counter += 1;
}
Ok(window_infos)
}
fn capture_root_image(&self) -> Result<RgbaImage> {
let (width, height) = self.root_geometry()?;
let reply = self
.conn
.get_image(
ImageFormat::Z_PIXMAP,
self.root,
0,
0,
width as u16,
height as u16,
u32::MAX,
)?
.reply()
.context("Failed to capture root window image")?;
rgba_from_image_reply(self.conn.setup(), width, height, &reply)
}
fn root_geometry(&self) -> Result<(u32, u32)> {
let geometry = self
.conn
.get_geometry(self.root)?
.reply()
.context("Failed to get root geometry")?;
Ok((geometry.width.into(), geometry.height.into()))
}
fn window_geometry(&self, window: Window) -> Result<(i32, i32, u32, u32)> {
let geometry = self
.conn
.get_geometry(window)?
.reply()
.context("Failed to get window geometry")?;
let translated = self
.conn
.translate_coordinates(window, self.root, 0, 0)?
.reply()
.context("Failed to translate window coordinates to root")?;
Ok((
i32::from(translated.dst_x),
i32::from(translated.dst_y),
geometry.width.into(),
geometry.height.into(),
))
}
fn active_window(&self) -> Result<Option<Window>> {
Ok(self
.get_property_u32(
self.root,
self.atoms.active_window,
AtomEnum::WINDOW.into(),
1,
)?
.into_iter()
.next()
.map(|id| id as Window))
}
fn window_title(&self, window: Window) -> Result<String> {
let title =
self.read_text_property(window, self.atoms.net_wm_name, self.atoms.utf8_string)?;
if !title.is_empty() {
return Ok(title);
}
self.read_text_property(window, self.atoms.wm_name, AtomEnum::ANY.into())
}
fn window_app_name(&self, window: Window) -> Result<String> {
let wm_class =
self.read_text_property(window, self.atoms.wm_class, AtomEnum::STRING.into())?;
let mut parts = wm_class.split('\0').filter(|part| !part.is_empty());
Ok(parts
.nth(1)
.or_else(|| parts.next())
.unwrap_or("")
.to_string())
}
fn window_is_minimized(&self, window: Window) -> Result<bool> {
let states =
self.get_property_u32(window, self.atoms.net_wm_state, AtomEnum::ATOM.into(), 32)?;
Ok(states.contains(&self.atoms.net_wm_state_hidden))
}
fn read_text_property(&self, window: Window, property: Atom, type_: Atom) -> Result<String> {
let reply = self.get_property(window, property, type_, 1024)?;
Ok(String::from_utf8_lossy(&reply.value)
.trim_end_matches('\0')
.to_string())
}
fn get_property_u32(
&self,
window: Window,
property: Atom,
type_: Atom,
long_length: u32,
) -> Result<Vec<u32>> {
let reply = self.get_property(window, property, type_, long_length)?;
Ok(reply
.value32()
.map(|iter| iter.collect::<Vec<_>>())
.unwrap_or_default())
}
fn get_property(
&self,
window: Window,
property: Atom,
type_: Atom,
long_length: u32,
) -> Result<GetPropertyReply> {
self.conn
.get_property(false, window, property, type_, 0, long_length)?
.reply()
.with_context(|| format!("Failed to read property {property} from window {window}"))
}
}
impl super::DesktopBackend for X11Backend {
fn snapshot(&mut self, annotate: bool) -> Result<Snapshot> {
let window_infos = self.collect_window_infos()?;
let mut image = self.capture_root_image()?;
// Annotate if requested - draw bounding boxes and @wN labels
if annotate {
annotate_screenshot(&mut image, &window_infos);
@ -117,7 +271,7 @@ impl super::DesktopBackend for X11Backend {
sequence: 0,
window: xcb_id,
type_: net_active,
data: x11rb::protocol::xproto::ClientMessageData::from([
data: ClientMessageData::from([
2u32, 0, 0, 0, 0, // source=2 (pager), timestamp=0, currently_active=0
]),
};
@ -128,21 +282,27 @@ impl super::DesktopBackend for X11Backend {
EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY,
event,
)?;
self.conn.flush().context("Failed to flush X11 connection")?;
self.conn
.flush()
.context("Failed to flush X11 connection")?;
Ok(())
}
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")?;
self.conn
.flush()
.context("Failed to flush X11 connection")?;
Ok(())
}
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")?;
self.conn
.flush()
.context("Failed to flush X11 connection")?;
Ok(())
}
@ -161,7 +321,7 @@ impl super::DesktopBackend for X11Backend {
sequence: 0,
window: xcb_id,
type_: net_close,
data: x11rb::protocol::xproto::ClientMessageData::from([
data: ClientMessageData::from([
0u32, 2, 0, 0, 0, // timestamp=0, source=2 (pager)
]),
};
@ -172,7 +332,9 @@ impl super::DesktopBackend for X11Backend {
EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY,
event,
)?;
self.conn.flush().context("Failed to flush X11 connection")?;
self.conn
.flush()
.context("Failed to flush X11 connection")?;
Ok(())
}
@ -289,11 +451,7 @@ impl super::DesktopBackend for X11Backend {
}
fn screen_size(&self) -> Result<(u32, u32)> {
let monitors = xcap::Monitor::all().context("Failed to enumerate monitors")?;
let monitor = monitors.into_iter().next().context("No monitor found")?;
let w = monitor.width().context("Failed to get monitor width")?;
let h = monitor.height().context("Failed to get monitor height")?;
Ok((w, h))
self.root_geometry()
}
fn mouse_position(&self) -> Result<(i32, i32)> {
@ -306,37 +464,10 @@ impl super::DesktopBackend for X11Backend {
}
fn screenshot(&mut self, path: &str, annotate: bool) -> Result<String> {
let monitors = xcap::Monitor::all().context("Failed to enumerate monitors")?;
let monitor = monitors.into_iter().next().context("No monitor found")?;
let mut image = monitor
.capture_image()
.context("Failed to capture screenshot")?;
let mut image = self.capture_root_image()?;
if annotate {
let windows = xcap::Window::all().unwrap_or_default();
let mut window_infos = Vec::new();
let mut ref_counter = 1usize;
for win in &windows {
let title = win.title().unwrap_or_default();
let app_name = win.app_name().unwrap_or_default();
if title.is_empty() && app_name.is_empty() {
continue;
}
window_infos.push(crate::core::types::WindowInfo {
ref_id: format!("w{ref_counter}"),
xcb_id: win.id().unwrap_or(0),
title,
app_name,
x: win.x().unwrap_or(0),
y: win.y().unwrap_or(0),
width: win.width().unwrap_or(0),
height: win.height().unwrap_or(0),
focused: win.is_focused().unwrap_or(false),
minimized: win.is_minimized().unwrap_or(false),
});
ref_counter += 1;
}
let window_infos = self.collect_window_infos()?;
annotate_screenshot(&mut image, &window_infos);
}
@ -404,3 +535,121 @@ fn parse_key(name: &str) -> Result<Key> {
other => anyhow::bail!("Unknown key: {other}"),
}
}
impl Atoms {
fn new(conn: &RustConnection) -> Result<Self> {
Ok(Self {
client_list_stacking: intern_atom(conn, b"_NET_CLIENT_LIST_STACKING")?,
active_window: intern_atom(conn, b"_NET_ACTIVE_WINDOW")?,
net_wm_name: intern_atom(conn, b"_NET_WM_NAME")?,
utf8_string: intern_atom(conn, b"UTF8_STRING")?,
wm_name: intern_atom(conn, b"WM_NAME")?,
wm_class: intern_atom(conn, b"WM_CLASS")?,
net_wm_state: intern_atom(conn, b"_NET_WM_STATE")?,
net_wm_state_hidden: intern_atom(conn, b"_NET_WM_STATE_HIDDEN")?,
})
}
}
fn intern_atom(conn: &RustConnection, name: &[u8]) -> Result<Atom> {
conn.intern_atom(false, name)?
.reply()
.with_context(|| format!("Failed to intern atom {}", String::from_utf8_lossy(name)))
.map(|reply| reply.atom)
}
fn rgba_from_image_reply(
setup: &x11rb::protocol::xproto::Setup,
width: u32,
height: u32,
reply: &x11rb::protocol::xproto::GetImageReply,
) -> Result<RgbaImage> {
let pixmap_format = setup
.pixmap_formats
.iter()
.find(|format| format.depth == reply.depth)
.context("Failed to find pixmap format for captured image depth")?;
let bits_per_pixel = u32::from(pixmap_format.bits_per_pixel);
let bit_order = setup.bitmap_format_bit_order;
let bytes = reply.data.as_slice();
let get_pixel_rgba = match reply.depth {
8 => pixel8_rgba,
16 => pixel16_rgba,
24 | 32 => pixel24_32_rgba,
depth => anyhow::bail!("Unsupported X11 image depth: {depth}"),
};
let mut rgba = vec![0u8; (width * height * 4) as usize];
for y in 0..height {
for x in 0..width {
let index = ((y * width + x) * 4) as usize;
let (r, g, b, a) = get_pixel_rgba(bytes, x, y, width, bits_per_pixel, bit_order);
rgba[index] = r;
rgba[index + 1] = g;
rgba[index + 2] = b;
rgba[index + 3] = a;
}
}
RgbaImage::from_raw(width, height, rgba)
.context("Failed to convert captured X11 image into RGBA buffer")
}
fn pixel8_rgba(
bytes: &[u8],
x: u32,
y: u32,
width: u32,
bits_per_pixel: u32,
bit_order: ImageOrder,
) -> (u8, u8, u8, u8) {
let index = ((y * width + x) * bits_per_pixel / 8) as usize;
let pixel = if bit_order == ImageOrder::LSB_FIRST {
bytes[index]
} else {
bytes[index] & (7 << 4) | (bytes[index] >> 4)
};
let r = (pixel >> 6) as f32 / 3.0 * 255.0;
let g = ((pixel >> 2) & 7) as f32 / 7.0 * 255.0;
let b = (pixel & 3) as f32 / 3.0 * 255.0;
(r as u8, g as u8, b as u8, 255)
}
fn pixel16_rgba(
bytes: &[u8],
x: u32,
y: u32,
width: u32,
bits_per_pixel: u32,
bit_order: ImageOrder,
) -> (u8, u8, u8, u8) {
let index = ((y * width + x) * bits_per_pixel / 8) as usize;
let pixel = if bit_order == ImageOrder::LSB_FIRST {
u16::from(bytes[index]) | (u16::from(bytes[index + 1]) << 8)
} else {
(u16::from(bytes[index]) << 8) | u16::from(bytes[index + 1])
};
let r = (pixel >> 11) as f32 / 31.0 * 255.0;
let g = ((pixel >> 5) & 63) as f32 / 63.0 * 255.0;
let b = (pixel & 31) as f32 / 31.0 * 255.0;
(r as u8, g as u8, b as u8, 255)
}
fn pixel24_32_rgba(
bytes: &[u8],
x: u32,
y: u32,
width: u32,
bits_per_pixel: u32,
bit_order: ImageOrder,
) -> (u8, u8, u8, u8) {
let index = ((y * width + x) * bits_per_pixel / 8) as usize;
if bit_order == ImageOrder::LSB_FIRST {
(bytes[index + 2], bytes[index + 1], bytes[index], 255)
} else {
(bytes[index], bytes[index + 1], bytes[index + 2], 255)
}
}

View file

@ -39,12 +39,10 @@ fn try_connect(opts: &GlobalOpts) -> Option<UnixStream> {
}
fn spawn_daemon(opts: &GlobalOpts) -> Result<()> {
let exe = std::env::current_exe()
.context("Failed to determine executable path")?;
let exe = std::env::current_exe().context("Failed to determine executable path")?;
let sock_dir = socket_dir();
std::fs::create_dir_all(&sock_dir)
.context("Failed to create socket directory")?;
std::fs::create_dir_all(&sock_dir).context("Failed to create socket directory")?;
let mut cmd = Command::new(exe);
cmd.env("DESKCTL_DAEMON", "1")
@ -109,8 +107,8 @@ pub fn send_command(opts: &GlobalOpts, request: &Request) -> Result<Response> {
let mut line = String::new();
reader.read_line(&mut line)?;
let response: Response = serde_json::from_str(line.trim())
.context("Failed to parse daemon response")?;
let response: Response =
serde_json::from_str(line.trim()).context("Failed to parse daemon response")?;
Ok(response)
}

View file

@ -1,8 +1,8 @@
mod connection;
use anyhow::Result;
use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
use anyhow::Result;
use crate::core::protocol::{Request, Response};
@ -196,84 +196,57 @@ fn build_request(cmd: &Command) -> Result<Request> {
use serde_json::json;
let req = match cmd {
Command::Snapshot { annotate } => {
Request::new("snapshot")
.with_extra("annotate", json!(annotate))
Request::new("snapshot").with_extra("annotate", json!(annotate))
}
Command::Click { selector } => {
Request::new("click")
.with_extra("selector", json!(selector))
Request::new("click").with_extra("selector", json!(selector))
}
Command::Dblclick { selector } => {
Request::new("dblclick")
.with_extra("selector", json!(selector))
}
Command::Type { text } => {
Request::new("type")
.with_extra("text", json!(text))
}
Command::Press { key } => {
Request::new("press")
.with_extra("key", json!(key))
}
Command::Hotkey { keys } => {
Request::new("hotkey")
.with_extra("keys", json!(keys))
Request::new("dblclick").with_extra("selector", json!(selector))
}
Command::Type { text } => Request::new("type").with_extra("text", json!(text)),
Command::Press { key } => Request::new("press").with_extra("key", json!(key)),
Command::Hotkey { keys } => Request::new("hotkey").with_extra("keys", json!(keys)),
Command::Mouse(sub) => match sub {
MouseCmd::Move { x, y } => {
Request::new("mouse-move")
MouseCmd::Move { x, y } => Request::new("mouse-move")
.with_extra("x", json!(x))
.with_extra("y", json!(y))
}
MouseCmd::Scroll { amount, axis } => {
Request::new("mouse-scroll")
.with_extra("y", json!(y)),
MouseCmd::Scroll { amount, axis } => Request::new("mouse-scroll")
.with_extra("amount", json!(amount))
.with_extra("axis", json!(axis))
}
MouseCmd::Drag { x1, y1, x2, y2 } => {
Request::new("mouse-drag")
.with_extra("axis", json!(axis)),
MouseCmd::Drag { x1, y1, x2, y2 } => Request::new("mouse-drag")
.with_extra("x1", json!(x1))
.with_extra("y1", json!(y1))
.with_extra("x2", json!(x2))
.with_extra("y2", json!(y2))
}
.with_extra("y2", json!(y2)),
},
Command::Focus { selector } => {
Request::new("focus")
.with_extra("selector", json!(selector))
Request::new("focus").with_extra("selector", json!(selector))
}
Command::Close { selector } => {
Request::new("close")
.with_extra("selector", json!(selector))
Request::new("close").with_extra("selector", json!(selector))
}
Command::MoveWindow { selector, x, y } => {
Request::new("move-window")
Command::MoveWindow { selector, x, y } => Request::new("move-window")
.with_extra("selector", json!(selector))
.with_extra("x", json!(x))
.with_extra("y", json!(y))
}
Command::ResizeWindow { selector, w, h } => {
Request::new("resize-window")
.with_extra("y", json!(y)),
Command::ResizeWindow { selector, w, h } => Request::new("resize-window")
.with_extra("selector", json!(selector))
.with_extra("w", json!(w))
.with_extra("h", json!(h))
}
.with_extra("h", json!(h)),
Command::ListWindows => Request::new("list-windows"),
Command::GetScreenSize => Request::new("get-screen-size"),
Command::GetMousePosition => Request::new("get-mouse-position"),
Command::Screenshot { path, annotate } => {
let mut req = Request::new("screenshot")
.with_extra("annotate", json!(annotate));
let mut req = Request::new("screenshot").with_extra("annotate", json!(annotate));
if let Some(p) = path {
req = req.with_extra("path", json!(p.to_string_lossy()));
}
req
}
Command::Launch { command, args } => {
Request::new("launch")
Command::Launch { command, args } => Request::new("launch")
.with_extra("command", json!(command))
.with_extra("args", json!(args))
}
.with_extra("args", json!(args)),
Command::Daemon(_) => unreachable!(),
};
Ok(req)
@ -298,18 +271,30 @@ fn print_response(cmd: &Command, response: &Response) -> Result<()> {
let ref_id = w.get("ref_id").and_then(|v| v.as_str()).unwrap_or("?");
let title = w.get("title").and_then(|v| v.as_str()).unwrap_or("");
let focused = w.get("focused").and_then(|v| v.as_bool()).unwrap_or(false);
let minimized = w.get("minimized").and_then(|v| v.as_bool()).unwrap_or(false);
let minimized = w
.get("minimized")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let x = w.get("x").and_then(|v| v.as_i64()).unwrap_or(0);
let y = w.get("y").and_then(|v| v.as_i64()).unwrap_or(0);
let width = w.get("width").and_then(|v| v.as_u64()).unwrap_or(0);
let height = w.get("height").and_then(|v| v.as_u64()).unwrap_or(0);
let state = if focused { "focused" } else if minimized { "hidden" } else { "visible" };
let state = if focused {
"focused"
} else if minimized {
"hidden"
} else {
"visible"
};
let display_title = if title.len() > 30 {
format!("{}...", &title[..27])
} else {
title.to_string()
};
println!("@{:<4} {:<30} ({:<7}) {},{} {}x{}", ref_id, display_title, state, x, y, width, height);
println!(
"@{:<4} {:<30} ({:<7}) {},{} {}x{}",
ref_id, display_title, state, x, y, width, height
);
}
}
} else {

View file

@ -21,10 +21,14 @@ pub struct Response {
impl Request {
pub fn new(action: &str) -> Self {
Self {
id: format!("r{}", std::time::SystemTime::now()
id: format!(
"r{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_micros() % 1_000_000),
.as_micros()
% 1_000_000
),
action: action.to_string(),
extra: Value::Object(serde_json::Map::new()),
}
@ -40,10 +44,18 @@ impl Request {
impl Response {
pub fn ok(data: Value) -> Self {
Self { success: true, data: Some(data), error: None }
Self {
success: true,
data: Some(data),
error: None,
}
}
pub fn err(msg: impl Into<String>) -> Self {
Self { success: false, data: None, error: Some(msg.into()) }
Self {
success: false,
data: None,
error: Some(msg.into()),
}
}
}

View file

@ -1,5 +1,5 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)]
@ -26,7 +26,10 @@ pub struct RefMap {
#[allow(dead_code)]
impl RefMap {
pub fn new() -> Self {
Self { map: HashMap::new(), next_ref: 1 }
Self {
map: HashMap::new(),
next_ref: 1,
}
}
pub fn clear(&mut self) {
@ -57,16 +60,14 @@ impl RefMap {
// Try substring match on app_class or title (case-insensitive)
let lower = selector.to_lowercase();
self.map.values().find(|e| {
e.app_class.to_lowercase().contains(&lower)
|| e.title.to_lowercase().contains(&lower)
e.app_class.to_lowercase().contains(&lower) || e.title.to_lowercase().contains(&lower)
})
}
/// Resolve a selector to the center coordinates of the window.
pub fn resolve_to_center(&self, selector: &str) -> Option<(i32, i32)> {
self.resolve(selector).map(|e| {
(e.x + e.width as i32 / 2, e.y + e.height as i32 / 2)
})
self.resolve(selector)
.map(|e| (e.x + e.width as i32 / 2, e.y + e.height as i32 / 2))
}
pub fn entries(&self) -> impl Iterator<Item = (&String, &RefEntry)> {

View file

@ -1,15 +1,12 @@
use std::sync::Arc;
use tokio::sync::Mutex;
use super::state::DaemonState;
use crate::backend::DesktopBackend;
use crate::core::protocol::{Request, Response};
use crate::core::refs::RefEntry;
use super::state::DaemonState;
pub async fn handle_request(
request: &Request,
state: &Arc<Mutex<DaemonState>>,
) -> Response {
pub async fn handle_request(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
match request.action.as_str() {
"snapshot" => handle_snapshot(request, state).await,
"click" => handle_click(request, state).await,
@ -33,10 +30,7 @@ pub async fn handle_request(
}
}
async fn handle_snapshot(
request: &Request,
state: &Arc<Mutex<DaemonState>>,
) -> Response {
async fn handle_snapshot(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
let annotate = request
.extra
.get("annotate")
@ -70,10 +64,7 @@ async fn handle_snapshot(
}
}
async fn handle_click(
request: &Request,
state: &Arc<Mutex<DaemonState>>,
) -> Response {
async fn handle_click(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"),
@ -92,19 +83,16 @@ async fn handle_click(
// Resolve as window ref
match state.ref_map.resolve_to_center(&selector) {
Some((x, y)) => match state.backend.click(x, y) {
Ok(()) => Response::ok(
serde_json::json!({"clicked": {"x": x, "y": y, "ref": selector}}),
),
Ok(()) => {
Response::ok(serde_json::json!({"clicked": {"x": x, "y": y, "ref": selector}}))
}
Err(e) => Response::err(format!("Click failed: {e}")),
},
None => Response::err(format!("Could not resolve selector: {selector}")),
}
}
async fn handle_dblclick(
request: &Request,
state: &Arc<Mutex<DaemonState>>,
) -> Response {
async fn handle_dblclick(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"),
@ -130,10 +118,7 @@ async fn handle_dblclick(
}
}
async fn handle_type(
request: &Request,
state: &Arc<Mutex<DaemonState>>,
) -> Response {
async fn handle_type(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
let text = match request.extra.get("text").and_then(|v| v.as_str()) {
Some(t) => t.to_string(),
None => return Response::err("Missing 'text' field"),
@ -147,10 +132,7 @@ async fn handle_type(
}
}
async fn handle_press(
request: &Request,
state: &Arc<Mutex<DaemonState>>,
) -> Response {
async fn handle_press(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
let key = match request.extra.get("key").and_then(|v| v.as_str()) {
Some(k) => k.to_string(),
None => return Response::err("Missing 'key' field"),
@ -164,10 +146,7 @@ async fn handle_press(
}
}
async fn handle_hotkey(
request: &Request,
state: &Arc<Mutex<DaemonState>>,
) -> Response {
async fn handle_hotkey(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
let keys: Vec<String> = match request.extra.get("keys").and_then(|v| v.as_array()) {
Some(arr) => arr
.iter()
@ -184,10 +163,7 @@ async fn handle_hotkey(
}
}
async fn handle_mouse_move(
request: &Request,
state: &Arc<Mutex<DaemonState>>,
) -> Response {
async fn handle_mouse_move(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
let x = match request.extra.get("x").and_then(|v| v.as_i64()) {
Some(v) => v as i32,
None => return Response::err("Missing 'x' field"),
@ -205,10 +181,7 @@ async fn handle_mouse_move(
}
}
async fn handle_mouse_scroll(
request: &Request,
state: &Arc<Mutex<DaemonState>>,
) -> Response {
async fn handle_mouse_scroll(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
let amount = match request.extra.get("amount").and_then(|v| v.as_i64()) {
Some(v) => v as i32,
None => return Response::err("Missing 'amount' field"),
@ -223,17 +196,12 @@ async fn handle_mouse_scroll(
let mut state = state.lock().await;
match state.backend.scroll(amount, &axis) {
Ok(()) => {
Response::ok(serde_json::json!({"scrolled": {"amount": amount, "axis": axis}}))
}
Ok(()) => Response::ok(serde_json::json!({"scrolled": {"amount": amount, "axis": axis}})),
Err(e) => Response::err(format!("Scroll failed: {e}")),
}
}
async fn handle_mouse_drag(
request: &Request,
state: &Arc<Mutex<DaemonState>>,
) -> Response {
async fn handle_mouse_drag(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
let x1 = match request.extra.get("x1").and_then(|v| v.as_i64()) {
Some(v) => v as i32,
None => return Response::err("Missing 'x1' field"),
@ -297,10 +265,7 @@ async fn handle_window_action(
}
}
async fn handle_move_window(
request: &Request,
state: &Arc<Mutex<DaemonState>>,
) -> Response {
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"),
@ -322,16 +287,21 @@ async fn handle_move_window(
}
}
async fn handle_resize_window(
request: &Request,
state: &Arc<Mutex<DaemonState>>,
) -> Response {
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 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) {
@ -347,9 +317,7 @@ async fn handle_resize_window(
}
}
async fn handle_list_windows(
state: &Arc<Mutex<DaemonState>>,
) -> Response {
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) {
@ -392,10 +360,7 @@ async fn handle_get_mouse_position(state: &Arc<Mutex<DaemonState>>) -> Response
}
}
async fn handle_screenshot(
request: &Request,
state: &Arc<Mutex<DaemonState>>,
) -> Response {
async fn handle_screenshot(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
let annotate = request
.extra
.get("annotate")
@ -420,10 +385,7 @@ async fn handle_screenshot(
}
}
async fn handle_launch(
request: &Request,
state: &Arc<Mutex<DaemonState>>,
) -> Response {
async fn handle_launch(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
let command = match request.extra.get("command").and_then(|v| v.as_str()) {
Some(c) => c.to_string(),
None => return Response::err("Missing 'command' field"),

View file

@ -28,9 +28,7 @@ async fn async_run() -> Result<()> {
.map(PathBuf::from)
.context("DESKCTL_SOCKET_PATH not set")?;
let pid_path = std::env::var("DESKCTL_PID_PATH")
.map(PathBuf::from)
.ok();
let pid_path = std::env::var("DESKCTL_PID_PATH").map(PathBuf::from).ok();
// Clean up stale socket
if socket_path.exists() {
@ -48,7 +46,7 @@ async fn async_run() -> Result<()> {
let session = std::env::var("DESKCTL_SESSION").unwrap_or_else(|_| "default".to_string());
let state = Arc::new(Mutex::new(
DaemonState::new(session, socket_path.clone())
.context("Failed to initialize daemon state")?
.context("Failed to initialize daemon state")?,
));
let shutdown = Arc::new(tokio::sync::Notify::new());
@ -111,9 +109,8 @@ async fn handle_connection(
// exits even if the client has already closed the connection.
if request.action == "shutdown" {
shutdown.notify_one();
let response = crate::core::protocol::Response::ok(
serde_json::json!({"message": "Shutting down"})
);
let response =
crate::core::protocol::Response::ok(serde_json::json!({"message": "Shutting down"}));
let json = serde_json::to_string(&response)?;
// Ignore write errors - client may have already closed the connection.
let _ = writer.write_all(format!("{json}\n").as_bytes()).await;