mirror of
https://github.com/harivansh-afk/deskctl.git
synced 2026-04-15 08:03:43 +00:00
Phase 1: project scaffold, clap CLI, self-re-exec daemon, NDJSON IPC
- Cargo.toml with clap, tokio, serde, anyhow dependencies - Entry point with env-var routing to daemon or CLI mode - Core protocol types (Request/Response NDJSON wire format) - Session detection (X11 check with DISPLAY/XDG_SESSION_TYPE) - RefMap with @wN selector resolution (direct, prefix, substring) - Snapshot/WindowInfo shared types with Display impl - clap derive CLI with all subcommands (snapshot, click, type, etc.) - Client connection: socket path resolution, daemon auto-start via self-re-exec, NDJSON send/receive with retry backoff - Tokio async daemon: Unix socket listener, accept loop, graceful shutdown via notify - DaemonState holding session info and ref map - Placeholder handler returning hardcoded snapshot response
This commit is contained in:
parent
17d4a1edeb
commit
dfaa339594
13 changed files with 1735 additions and 0 deletions
164
src/cli/connection.rs
Normal file
164
src/cli/connection.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
use crate::cli::GlobalOpts;
|
||||
use crate::core::protocol::{Request, Response};
|
||||
|
||||
fn socket_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("DESKTOP_CTL_SOCKET_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
return PathBuf::from(runtime).join("desktop-ctl");
|
||||
}
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join(".desktop-ctl")
|
||||
}
|
||||
|
||||
fn socket_path(opts: &GlobalOpts) -> PathBuf {
|
||||
if let Some(ref path) = opts.socket {
|
||||
return path.clone();
|
||||
}
|
||||
socket_dir().join(format!("{}.sock", opts.session))
|
||||
}
|
||||
|
||||
fn pid_path(opts: &GlobalOpts) -> PathBuf {
|
||||
socket_dir().join(format!("{}.pid", opts.session))
|
||||
}
|
||||
|
||||
fn try_connect(opts: &GlobalOpts) -> Option<UnixStream> {
|
||||
UnixStream::connect(socket_path(opts)).ok()
|
||||
}
|
||||
|
||||
fn spawn_daemon(opts: &GlobalOpts) -> Result<()> {
|
||||
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")?;
|
||||
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.env("DESKTOP_CTL_DAEMON", "1")
|
||||
.env("DESKTOP_CTL_SESSION", &opts.session)
|
||||
.env("DESKTOP_CTL_SOCKET_PATH", socket_path(opts))
|
||||
.env("DESKTOP_CTL_PID_PATH", pid_path(opts))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
// Detach the daemon process on Unix
|
||||
unsafe {
|
||||
cmd.pre_exec(|| {
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
cmd.spawn().context("Failed to spawn daemon")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_daemon(opts: &GlobalOpts) -> Result<UnixStream> {
|
||||
// Try connecting first
|
||||
if let Some(stream) = try_connect(opts) {
|
||||
return Ok(stream);
|
||||
}
|
||||
|
||||
// Spawn daemon
|
||||
spawn_daemon(opts)?;
|
||||
|
||||
// Retry with backoff
|
||||
let max_retries = 20;
|
||||
let base_delay = Duration::from_millis(50);
|
||||
for i in 0..max_retries {
|
||||
thread::sleep(base_delay * (i + 1).min(4));
|
||||
if let Some(stream) = try_connect(opts) {
|
||||
return Ok(stream);
|
||||
}
|
||||
}
|
||||
|
||||
bail!(
|
||||
"Failed to connect to daemon after {} retries.\n\
|
||||
Socket path: {}",
|
||||
max_retries,
|
||||
socket_path(opts).display()
|
||||
);
|
||||
}
|
||||
|
||||
pub fn send_command(opts: &GlobalOpts, request: &Request) -> Result<Response> {
|
||||
let mut stream = ensure_daemon(opts)?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
|
||||
|
||||
// Send NDJSON request
|
||||
let json = serde_json::to_string(request)?;
|
||||
writeln!(stream, "{json}")?;
|
||||
stream.flush()?;
|
||||
|
||||
// Read NDJSON response
|
||||
let mut reader = BufReader::new(&stream);
|
||||
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")?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn start_daemon(opts: &GlobalOpts) -> Result<()> {
|
||||
if try_connect(opts).is_some() {
|
||||
println!("Daemon already running ({})", socket_path(opts).display());
|
||||
return Ok(());
|
||||
}
|
||||
spawn_daemon(opts)?;
|
||||
// Wait briefly and verify
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
if try_connect(opts).is_some() {
|
||||
println!("Daemon started ({})", socket_path(opts).display());
|
||||
} else {
|
||||
bail!("Daemon failed to start");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop_daemon(opts: &GlobalOpts) -> Result<()> {
|
||||
match try_connect(opts) {
|
||||
Some(mut stream) => {
|
||||
let req = Request::new("shutdown");
|
||||
let json = serde_json::to_string(&req)?;
|
||||
writeln!(stream, "{json}")?;
|
||||
stream.flush()?;
|
||||
println!("Daemon stopped");
|
||||
}
|
||||
None => {
|
||||
// Try to clean up stale socket
|
||||
let path = socket_path(opts);
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)?;
|
||||
println!("Removed stale socket: {}", path.display());
|
||||
} else {
|
||||
println!("Daemon not running");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn daemon_status(opts: &GlobalOpts) -> Result<()> {
|
||||
if try_connect(opts).is_some() {
|
||||
println!("Daemon running ({})", socket_path(opts).display());
|
||||
} else {
|
||||
println!("Daemon not running");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
321
src/cli/mod.rs
Normal file
321
src/cli/mod.rs
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
mod connection;
|
||||
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::core::protocol::{Request, Response};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "desktop-ctl", version, about = "Desktop control CLI for AI agents")]
|
||||
pub struct App {
|
||||
#[command(flatten)]
|
||||
pub global: GlobalOpts,
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct GlobalOpts {
|
||||
/// Path to the daemon Unix socket
|
||||
#[arg(long, global = true, env = "DESKTOP_CTL_SOCKET")]
|
||||
pub socket: Option<PathBuf>,
|
||||
|
||||
/// Session name (allows multiple daemon instances)
|
||||
#[arg(long, global = true, default_value = "default")]
|
||||
pub session: String,
|
||||
|
||||
/// Output as JSON
|
||||
#[arg(long, global = true)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Command {
|
||||
/// Take a screenshot and list windows with @wN refs
|
||||
Snapshot {
|
||||
/// Draw bounding boxes and labels on the screenshot
|
||||
#[arg(long)]
|
||||
annotate: bool,
|
||||
},
|
||||
/// Click a window ref or coordinates
|
||||
Click {
|
||||
/// @w1 or x,y coordinates
|
||||
selector: String,
|
||||
},
|
||||
/// Double-click a window ref or coordinates
|
||||
Dblclick {
|
||||
/// @w1 or x,y coordinates
|
||||
selector: String,
|
||||
},
|
||||
/// Type text into the focused window
|
||||
Type {
|
||||
/// Text to type
|
||||
text: String,
|
||||
},
|
||||
/// Press a key (e.g. enter, tab, escape)
|
||||
Press {
|
||||
/// Key name
|
||||
key: String,
|
||||
},
|
||||
/// Send a hotkey combination (e.g. ctrl c)
|
||||
Hotkey {
|
||||
/// Key names (e.g. ctrl shift t)
|
||||
keys: Vec<String>,
|
||||
},
|
||||
/// Mouse operations
|
||||
#[command(subcommand)]
|
||||
Mouse(MouseCmd),
|
||||
/// Focus a window by ref or name
|
||||
Focus {
|
||||
/// @w1 or window name substring
|
||||
selector: String,
|
||||
},
|
||||
/// Close a window by ref or name
|
||||
Close {
|
||||
/// @w1 or window name substring
|
||||
selector: String,
|
||||
},
|
||||
/// Move a window
|
||||
MoveWindow {
|
||||
/// @w1 or window name substring
|
||||
selector: String,
|
||||
/// X position
|
||||
x: i32,
|
||||
/// Y position
|
||||
y: i32,
|
||||
},
|
||||
/// Resize a window
|
||||
ResizeWindow {
|
||||
/// @w1 or window name substring
|
||||
selector: String,
|
||||
/// Width
|
||||
w: u32,
|
||||
/// Height
|
||||
h: u32,
|
||||
},
|
||||
/// List all windows (same as snapshot but without screenshot)
|
||||
ListWindows,
|
||||
/// Get screen resolution
|
||||
GetScreenSize,
|
||||
/// Get current mouse position
|
||||
GetMousePosition,
|
||||
/// Take a screenshot without window tree
|
||||
Screenshot {
|
||||
/// Save path (default: /tmp/desktop-ctl-{timestamp}.png)
|
||||
path: Option<PathBuf>,
|
||||
/// Draw bounding boxes and labels
|
||||
#[arg(long)]
|
||||
annotate: bool,
|
||||
},
|
||||
/// Launch an application
|
||||
Launch {
|
||||
/// Command to run
|
||||
command: String,
|
||||
/// Arguments
|
||||
#[arg(trailing_var_arg = true)]
|
||||
args: Vec<String>,
|
||||
},
|
||||
/// Daemon management (hidden - internal use)
|
||||
#[command(hide = true)]
|
||||
Daemon(DaemonCmd),
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum MouseCmd {
|
||||
/// Move the mouse cursor
|
||||
Move {
|
||||
/// X coordinate
|
||||
x: i32,
|
||||
/// Y coordinate
|
||||
y: i32,
|
||||
},
|
||||
/// Scroll the mouse wheel
|
||||
Scroll {
|
||||
/// Amount (positive = down, negative = up)
|
||||
amount: i32,
|
||||
/// Axis: vertical or horizontal
|
||||
#[arg(long, default_value = "vertical")]
|
||||
axis: String,
|
||||
},
|
||||
/// Drag from one position to another
|
||||
Drag {
|
||||
/// Start X
|
||||
x1: i32,
|
||||
/// Start Y
|
||||
y1: i32,
|
||||
/// End X
|
||||
x2: i32,
|
||||
/// End Y
|
||||
y2: i32,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct DaemonCmd {
|
||||
#[command(subcommand)]
|
||||
pub action: DaemonAction,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum DaemonAction {
|
||||
/// Start the daemon
|
||||
Start,
|
||||
/// Stop the daemon
|
||||
Stop,
|
||||
/// Show daemon status
|
||||
Status,
|
||||
}
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
let app = App::parse();
|
||||
|
||||
// Handle daemon subcommands that don't need a running daemon
|
||||
if let Command::Daemon(ref cmd) = app.command {
|
||||
return match cmd.action {
|
||||
DaemonAction::Start => connection::start_daemon(&app.global),
|
||||
DaemonAction::Stop => connection::stop_daemon(&app.global),
|
||||
DaemonAction::Status => connection::daemon_status(&app.global),
|
||||
};
|
||||
}
|
||||
|
||||
// All other commands need a daemon connection
|
||||
let request = build_request(&app.command)?;
|
||||
let response = connection::send_command(&app.global, &request)?;
|
||||
|
||||
if app.global.json {
|
||||
println!("{}", serde_json::to_string_pretty(&response)?);
|
||||
} else {
|
||||
print_response(&app.command, &response)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
Command::Click { 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))
|
||||
}
|
||||
Command::Mouse(sub) => match sub {
|
||||
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("amount", json!(amount))
|
||||
.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))
|
||||
}
|
||||
},
|
||||
Command::Focus { selector } => {
|
||||
Request::new("focus")
|
||||
.with_extra("selector", json!(selector))
|
||||
}
|
||||
Command::Close { selector } => {
|
||||
Request::new("close")
|
||||
.with_extra("selector", json!(selector))
|
||||
}
|
||||
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("selector", json!(selector))
|
||||
.with_extra("w", json!(w))
|
||||
.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));
|
||||
if let Some(p) = path {
|
||||
req = req.with_extra("path", json!(p.to_string_lossy()));
|
||||
}
|
||||
req
|
||||
}
|
||||
Command::Launch { command, args } => {
|
||||
Request::new("launch")
|
||||
.with_extra("command", json!(command))
|
||||
.with_extra("args", json!(args))
|
||||
}
|
||||
Command::Daemon(_) => unreachable!(),
|
||||
};
|
||||
Ok(req)
|
||||
}
|
||||
|
||||
fn print_response(cmd: &Command, response: &Response) -> Result<()> {
|
||||
if !response.success {
|
||||
if let Some(ref err) = response.error {
|
||||
eprintln!("Error: {err}");
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
if let Some(ref data) = response.data {
|
||||
// For snapshot, print compact text format
|
||||
if matches!(cmd, Command::Snapshot { .. }) {
|
||||
if let Some(screenshot) = data.get("screenshot").and_then(|v| v.as_str()) {
|
||||
println!("Screenshot: {screenshot}");
|
||||
}
|
||||
if let Some(windows) = data.get("windows").and_then(|v| v.as_array()) {
|
||||
println!("Windows:");
|
||||
for w in windows {
|
||||
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 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 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);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Generic: print JSON data
|
||||
println!("{}", serde_json::to_string_pretty(data)?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
4
src/core/mod.rs
Normal file
4
src/core/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod protocol;
|
||||
pub mod refs;
|
||||
pub mod session;
|
||||
pub mod types;
|
||||
49
src/core/protocol.rs
Normal file
49
src/core/protocol.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
pub id: String,
|
||||
pub action: String,
|
||||
#[serde(flatten)]
|
||||
pub extra: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Response {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn new(action: &str) -> Self {
|
||||
Self {
|
||||
id: format!("r{}", std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_micros() % 1_000_000),
|
||||
action: action.to_string(),
|
||||
extra: Value::Object(serde_json::Map::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_extra(mut self, key: &str, value: Value) -> Self {
|
||||
if let Value::Object(ref mut map) = self.extra {
|
||||
map.insert(key.to_string(), value);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn ok(data: Value) -> Self {
|
||||
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()) }
|
||||
}
|
||||
}
|
||||
75
src/core/refs.rs
Normal file
75
src/core/refs.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
use std::collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct RefEntry {
|
||||
pub xcb_id: u32,
|
||||
pub app_class: String,
|
||||
pub title: String,
|
||||
pub pid: u32,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub focused: bool,
|
||||
pub minimized: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
#[allow(dead_code)]
|
||||
pub struct RefMap {
|
||||
map: HashMap<String, RefEntry>,
|
||||
next_ref: usize,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl RefMap {
|
||||
pub fn new() -> Self {
|
||||
Self { map: HashMap::new(), next_ref: 1 }
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
self.next_ref = 1;
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, entry: RefEntry) -> String {
|
||||
let ref_id = format!("w{}", self.next_ref);
|
||||
self.next_ref += 1;
|
||||
self.map.insert(ref_id.clone(), entry);
|
||||
ref_id
|
||||
}
|
||||
|
||||
/// Resolve a selector to a RefEntry.
|
||||
/// Accepts: "@w1", "w1", "ref=w1", or a substring match on app_class/title.
|
||||
pub fn resolve(&self, selector: &str) -> Option<&RefEntry> {
|
||||
let normalized = selector
|
||||
.strip_prefix('@')
|
||||
.or_else(|| selector.strip_prefix("ref="))
|
||||
.unwrap_or(selector);
|
||||
|
||||
// Try direct ref lookup
|
||||
if let Some(entry) = self.map.get(normalized) {
|
||||
return Some(entry);
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
/// 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)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl Iterator<Item = (&String, &RefEntry)> {
|
||||
self.map.iter()
|
||||
}
|
||||
}
|
||||
45
src/core/session.rs
Normal file
45
src/core/session.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use anyhow::{bail, Result};
|
||||
|
||||
pub enum SessionType {
|
||||
X11,
|
||||
}
|
||||
|
||||
pub fn detect_session() -> Result<SessionType> {
|
||||
let session_type = std::env::var("XDG_SESSION_TYPE").unwrap_or_default();
|
||||
|
||||
match session_type.as_str() {
|
||||
"x11" => {}
|
||||
"" => {
|
||||
// No XDG_SESSION_TYPE set - check for DISPLAY as fallback
|
||||
if std::env::var("DISPLAY").is_err() {
|
||||
bail!(
|
||||
"No X11 session detected.\n\
|
||||
XDG_SESSION_TYPE is not set and DISPLAY is not set.\n\
|
||||
desktop-ctl requires an X11 session. Wayland support coming in v0.2."
|
||||
);
|
||||
}
|
||||
}
|
||||
"wayland" => {
|
||||
bail!(
|
||||
"Wayland session detected (XDG_SESSION_TYPE=wayland).\n\
|
||||
desktop-ctl currently supports X11 only. Wayland/Hyprland support coming in v0.2."
|
||||
);
|
||||
}
|
||||
other => {
|
||||
bail!(
|
||||
"Unsupported session type: {other}\n\
|
||||
desktop-ctl currently supports X11 only."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm DISPLAY is set for X11
|
||||
if std::env::var("DISPLAY").is_err() {
|
||||
bail!(
|
||||
"X11 session detected but DISPLAY is not set.\n\
|
||||
Ensure your X server is running and DISPLAY is exported."
|
||||
);
|
||||
}
|
||||
|
||||
Ok(SessionType::X11)
|
||||
}
|
||||
55
src/core/types.rs
Normal file
55
src/core/types.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Snapshot {
|
||||
pub screenshot: String,
|
||||
pub windows: Vec<WindowInfo>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct WindowInfo {
|
||||
pub ref_id: String,
|
||||
pub xcb_id: u32,
|
||||
pub title: String,
|
||||
pub app_name: String,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub focused: bool,
|
||||
pub minimized: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WindowInfo {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let state = if self.focused {
|
||||
"focused"
|
||||
} else if self.minimized {
|
||||
"hidden"
|
||||
} else {
|
||||
"visible"
|
||||
};
|
||||
write!(
|
||||
f,
|
||||
"@{:<4} {:<30} ({:<7}) {},{} {}x{}",
|
||||
self.ref_id,
|
||||
truncate(&self.title, 30),
|
||||
state,
|
||||
self.x,
|
||||
self.y,
|
||||
self.width,
|
||||
self.height
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}...", &s[..max - 3])
|
||||
}
|
||||
}
|
||||
31
src/daemon/handler.rs
Normal file
31
src/daemon/handler.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::core::protocol::{Request, Response};
|
||||
use super::state::DaemonState;
|
||||
|
||||
pub async fn handle_request(
|
||||
request: &Request,
|
||||
_state: &Arc<Mutex<DaemonState>>,
|
||||
) -> Response {
|
||||
match request.action.as_str() {
|
||||
"snapshot" => {
|
||||
Response::ok(serde_json::json!({
|
||||
"screenshot": "/tmp/desktop-ctl-placeholder.png",
|
||||
"windows": [
|
||||
{
|
||||
"ref_id": "w1",
|
||||
"xcb_id": 0,
|
||||
"title": "Placeholder Window",
|
||||
"app_name": "placeholder",
|
||||
"x": 0, "y": 0, "width": 1920, "height": 1080,
|
||||
"focused": true, "minimized": false
|
||||
}
|
||||
]
|
||||
}))
|
||||
}
|
||||
action => {
|
||||
Response::err(format!("Unknown action: {action}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
127
src/daemon/mod.rs
Normal file
127
src/daemon/mod.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
mod handler;
|
||||
mod state;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixListener;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::core::session;
|
||||
use state::DaemonState;
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
// Validate session before starting
|
||||
session::detect_session()?;
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
runtime.block_on(async_run())
|
||||
}
|
||||
|
||||
async fn async_run() -> Result<()> {
|
||||
let socket_path = std::env::var("DESKTOP_CTL_SOCKET_PATH")
|
||||
.map(PathBuf::from)
|
||||
.context("DESKTOP_CTL_SOCKET_PATH not set")?;
|
||||
|
||||
let pid_path = std::env::var("DESKTOP_CTL_PID_PATH")
|
||||
.map(PathBuf::from)
|
||||
.ok();
|
||||
|
||||
// Clean up stale socket
|
||||
if socket_path.exists() {
|
||||
std::fs::remove_file(&socket_path)?;
|
||||
}
|
||||
|
||||
// Write PID file
|
||||
if let Some(ref pid_path) = pid_path {
|
||||
std::fs::write(pid_path, std::process::id().to_string())?;
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(&socket_path)
|
||||
.context(format!("Failed to bind socket: {}", socket_path.display()))?;
|
||||
|
||||
let session = std::env::var("DESKTOP_CTL_SESSION").unwrap_or_else(|_| "default".to_string());
|
||||
let state = Arc::new(Mutex::new(DaemonState::new(session, socket_path.clone())));
|
||||
|
||||
let shutdown = Arc::new(tokio::sync::Notify::new());
|
||||
let shutdown_clone = shutdown.clone();
|
||||
|
||||
// Accept loop
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = listener.accept() => {
|
||||
match result {
|
||||
Ok((stream, _addr)) => {
|
||||
let state = state.clone();
|
||||
let shutdown = shutdown.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream, state, shutdown).await {
|
||||
eprintln!("Connection error: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Accept error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_clone.notified() => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if socket_path.exists() {
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
}
|
||||
if let Some(ref pid_path) = pid_path {
|
||||
let _ = std::fs::remove_file(pid_path);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
stream: tokio::net::UnixStream,
|
||||
state: Arc<Mutex<DaemonState>>,
|
||||
shutdown: Arc<tokio::sync::Notify>,
|
||||
) -> Result<()> {
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
|
||||
reader.read_line(&mut line).await?;
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let request: crate::core::protocol::Request = serde_json::from_str(line)?;
|
||||
|
||||
// Handle shutdown specially - notify before writing so the accept loop
|
||||
// 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 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;
|
||||
let _ = writer.flush().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let response = handler::handle_request(&request, &state).await;
|
||||
let json = serde_json::to_string(&response)?;
|
||||
writer.write_all(format!("{json}\n").as_bytes()).await?;
|
||||
writer.flush().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
19
src/daemon/state.rs
Normal file
19
src/daemon/state.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
use std::path::PathBuf;
|
||||
use crate::core::refs::RefMap;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct DaemonState {
|
||||
pub session: String,
|
||||
pub socket_path: PathBuf,
|
||||
pub ref_map: RefMap,
|
||||
}
|
||||
|
||||
impl DaemonState {
|
||||
pub fn new(session: String, socket_path: PathBuf) -> Self {
|
||||
Self {
|
||||
session,
|
||||
socket_path,
|
||||
ref_map: RefMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/main.rs
Normal file
10
src/main.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
mod cli;
|
||||
mod core;
|
||||
mod daemon;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
if std::env::var("DESKTOP_CTL_DAEMON").is_ok() {
|
||||
return daemon::run();
|
||||
}
|
||||
cli::run()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue