mirror of
https://github.com/harivansh-afk/deskctl.git
synced 2026-04-17 20:05:06 +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(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue