mirror of
https://github.com/harivansh-afk/deskctl.git
synced 2026-04-15 07:04:46 +00:00
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:
parent
cc7490993a
commit
e392ba1055
11 changed files with 488 additions and 2128 deletions
1921
Cargo.lock
generated
1921
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)> {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue