mirror of
https://github.com/harivansh-afk/deskctl.git
synced 2026-04-15 13:03:43 +00:00
1096 lines
37 KiB
Rust
1096 lines
37 KiB
Rust
pub mod connection;
|
|
|
|
use anyhow::Result;
|
|
use clap::{Args, Parser, Subcommand};
|
|
use std::path::PathBuf;
|
|
|
|
use crate::core::protocol::{Request, Response};
|
|
|
|
#[derive(Parser)]
|
|
#[command(name = "deskctl", 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 = "DESKCTL_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
|
|
#[command(after_help = SNAPSHOT_EXAMPLES)]
|
|
Snapshot {
|
|
/// Draw bounding boxes and labels on the screenshot
|
|
#[arg(long)]
|
|
annotate: bool,
|
|
},
|
|
/// Click a window ref or coordinates
|
|
#[command(after_help = CLICK_EXAMPLES)]
|
|
Click {
|
|
/// Selector (ref=w1, id=win1, title=Firefox, class=firefox, focused) or x,y coordinates
|
|
selector: String,
|
|
},
|
|
/// Double-click a window ref or coordinates
|
|
#[command(after_help = DBLCLICK_EXAMPLES)]
|
|
Dblclick {
|
|
/// Selector (ref=w1, id=win1, title=Firefox, class=firefox, focused) or x,y coordinates
|
|
selector: String,
|
|
},
|
|
/// Type text into the focused window
|
|
#[command(after_help = TYPE_EXAMPLES)]
|
|
Type {
|
|
/// Text to type
|
|
text: String,
|
|
},
|
|
/// Press a key (e.g. enter, tab, escape)
|
|
#[command(after_help = PRESS_EXAMPLES)]
|
|
Press {
|
|
/// Key name
|
|
key: String,
|
|
},
|
|
/// Send a hotkey combination (e.g. ctrl c)
|
|
#[command(after_help = HOTKEY_EXAMPLES)]
|
|
Hotkey {
|
|
/// Key names (e.g. ctrl shift t)
|
|
keys: Vec<String>,
|
|
},
|
|
/// Mouse operations
|
|
#[command(subcommand)]
|
|
Mouse(MouseCmd),
|
|
/// Focus a window by ref or name
|
|
#[command(after_help = FOCUS_EXAMPLES)]
|
|
Focus {
|
|
/// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
|
|
selector: String,
|
|
},
|
|
/// Close a window by ref or name
|
|
#[command(after_help = CLOSE_EXAMPLES)]
|
|
Close {
|
|
/// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
|
|
selector: String,
|
|
},
|
|
/// Move a window
|
|
#[command(after_help = MOVE_WINDOW_EXAMPLES)]
|
|
MoveWindow {
|
|
/// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
|
|
selector: String,
|
|
/// X position
|
|
x: i32,
|
|
/// Y position
|
|
y: i32,
|
|
},
|
|
/// Resize a window
|
|
#[command(after_help = RESIZE_WINDOW_EXAMPLES)]
|
|
ResizeWindow {
|
|
/// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
|
|
selector: String,
|
|
/// Width
|
|
w: u32,
|
|
/// Height
|
|
h: u32,
|
|
},
|
|
/// List all windows (same as snapshot but without screenshot)
|
|
#[command(after_help = LIST_WINDOWS_EXAMPLES)]
|
|
ListWindows,
|
|
/// Get screen resolution
|
|
#[command(after_help = GET_SCREEN_SIZE_EXAMPLES)]
|
|
GetScreenSize,
|
|
/// Get current mouse position
|
|
#[command(after_help = GET_MOUSE_POSITION_EXAMPLES)]
|
|
GetMousePosition,
|
|
/// Diagnose X11 runtime, screenshot, and daemon health
|
|
#[command(after_help = DOCTOR_EXAMPLES)]
|
|
Doctor,
|
|
/// Query runtime state
|
|
#[command(subcommand)]
|
|
Get(GetCmd),
|
|
/// Wait for runtime state transitions
|
|
#[command(subcommand)]
|
|
Wait(WaitCmd),
|
|
/// Take a screenshot without window tree
|
|
#[command(after_help = SCREENSHOT_EXAMPLES)]
|
|
Screenshot {
|
|
/// Save path (default: /tmp/deskctl-{timestamp}.png)
|
|
path: Option<PathBuf>,
|
|
/// Draw bounding boxes and labels
|
|
#[arg(long)]
|
|
annotate: bool,
|
|
},
|
|
/// Launch an application
|
|
#[command(after_help = LAUNCH_EXAMPLES)]
|
|
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
|
|
#[command(after_help = MOUSE_MOVE_EXAMPLES)]
|
|
Move {
|
|
/// X coordinate
|
|
x: i32,
|
|
/// Y coordinate
|
|
y: i32,
|
|
},
|
|
/// Scroll the mouse wheel
|
|
#[command(after_help = MOUSE_SCROLL_EXAMPLES)]
|
|
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
|
|
#[command(after_help = MOUSE_DRAG_EXAMPLES)]
|
|
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,
|
|
}
|
|
|
|
const GET_ACTIVE_WINDOW_EXAMPLES: &str =
|
|
"Examples:\n deskctl get active-window\n deskctl --json get active-window";
|
|
const SNAPSHOT_EXAMPLES: &str =
|
|
"Examples:\n deskctl snapshot\n deskctl snapshot --annotate\n deskctl --json snapshot --annotate";
|
|
const LIST_WINDOWS_EXAMPLES: &str =
|
|
"Examples:\n deskctl list-windows\n deskctl --json list-windows";
|
|
const CLICK_EXAMPLES: &str =
|
|
"Examples:\n deskctl click @w1\n deskctl click 'title=Firefox'\n deskctl click 500,300";
|
|
const DBLCLICK_EXAMPLES: &str =
|
|
"Examples:\n deskctl dblclick @w2\n deskctl dblclick 'class=firefox'\n deskctl dblclick 500,300";
|
|
const TYPE_EXAMPLES: &str =
|
|
"Examples:\n deskctl type \"hello world\"\n deskctl type \"https://example.com\"";
|
|
const PRESS_EXAMPLES: &str = "Examples:\n deskctl press enter\n deskctl press escape";
|
|
const HOTKEY_EXAMPLES: &str = "Examples:\n deskctl hotkey ctrl l\n deskctl hotkey ctrl shift t";
|
|
const FOCUS_EXAMPLES: &str =
|
|
"Examples:\n deskctl focus @w1\n deskctl focus 'title=Firefox'\n deskctl focus focused";
|
|
const CLOSE_EXAMPLES: &str =
|
|
"Examples:\n deskctl close @w3\n deskctl close 'id=win2'\n deskctl close 'class=firefox'";
|
|
const MOVE_WINDOW_EXAMPLES: &str =
|
|
"Examples:\n deskctl move-window @w1 100 200\n deskctl move-window 'title=Firefox' 0 0";
|
|
const RESIZE_WINDOW_EXAMPLES: &str =
|
|
"Examples:\n deskctl resize-window @w1 1280 720\n deskctl resize-window 'id=win2' 800 600";
|
|
const GET_MONITORS_EXAMPLES: &str =
|
|
"Examples:\n deskctl get monitors\n deskctl --json get monitors";
|
|
const GET_VERSION_EXAMPLES: &str = "Examples:\n deskctl get version\n deskctl --json get version";
|
|
const GET_SYSTEMINFO_EXAMPLES: &str =
|
|
"Examples:\n deskctl get systeminfo\n deskctl --json get systeminfo";
|
|
const GET_SCREEN_SIZE_EXAMPLES: &str =
|
|
"Examples:\n deskctl get-screen-size\n deskctl --json get-screen-size";
|
|
const GET_MOUSE_POSITION_EXAMPLES: &str =
|
|
"Examples:\n deskctl get-mouse-position\n deskctl --json get-mouse-position";
|
|
const DOCTOR_EXAMPLES: &str = "Examples:\n deskctl doctor\n deskctl --json doctor";
|
|
const WAIT_WINDOW_EXAMPLES: &str = "Examples:\n deskctl wait window --selector 'title=Firefox' --timeout 10\n deskctl --json wait window --selector 'class=firefox' --poll-ms 100";
|
|
const WAIT_FOCUS_EXAMPLES: &str = "Examples:\n deskctl wait focus --selector 'id=win3' --timeout 5\n deskctl wait focus --selector focused --poll-ms 200";
|
|
const SCREENSHOT_EXAMPLES: &str =
|
|
"Examples:\n deskctl screenshot\n deskctl screenshot /tmp/screen.png\n deskctl screenshot --annotate";
|
|
const LAUNCH_EXAMPLES: &str =
|
|
"Examples:\n deskctl launch firefox\n deskctl launch code -- --new-window";
|
|
const MOUSE_MOVE_EXAMPLES: &str =
|
|
"Examples:\n deskctl mouse move 500 300\n deskctl mouse move 0 0";
|
|
const MOUSE_SCROLL_EXAMPLES: &str =
|
|
"Examples:\n deskctl mouse scroll 3\n deskctl mouse scroll -3 --axis vertical";
|
|
const MOUSE_DRAG_EXAMPLES: &str = "Examples:\n deskctl mouse drag 100 100 500 500";
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum GetCmd {
|
|
/// Show the currently focused window
|
|
#[command(after_help = GET_ACTIVE_WINDOW_EXAMPLES)]
|
|
ActiveWindow,
|
|
/// List current monitor geometry and metadata
|
|
#[command(after_help = GET_MONITORS_EXAMPLES)]
|
|
Monitors,
|
|
/// Show deskctl version and backend information
|
|
#[command(after_help = GET_VERSION_EXAMPLES)]
|
|
Version,
|
|
/// Show runtime-focused diagnostic information
|
|
#[command(after_help = GET_SYSTEMINFO_EXAMPLES)]
|
|
Systeminfo,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum WaitCmd {
|
|
/// Wait until a window matching the selector exists
|
|
#[command(after_help = WAIT_WINDOW_EXAMPLES)]
|
|
Window(WaitSelectorOpts),
|
|
/// Wait until the selector resolves to a focused window
|
|
#[command(after_help = WAIT_FOCUS_EXAMPLES)]
|
|
Focus(WaitSelectorOpts),
|
|
}
|
|
|
|
#[derive(Args)]
|
|
pub struct WaitSelectorOpts {
|
|
/// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
|
|
#[arg(long)]
|
|
pub selector: String,
|
|
|
|
/// Timeout in seconds
|
|
#[arg(long, default_value_t = 10)]
|
|
pub timeout: u64,
|
|
|
|
/// Poll interval in milliseconds
|
|
#[arg(long = "poll-ms", default_value_t = 250)]
|
|
pub poll_ms: u64,
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
if let Command::Doctor = app.command {
|
|
return connection::run_doctor(&app.global);
|
|
}
|
|
|
|
// All other commands need a daemon connection
|
|
let request = build_request(&app.command)?;
|
|
let response = connection::send_command(&app.global, &request)?;
|
|
let success = response.success;
|
|
|
|
if app.global.json {
|
|
println!("{}", serde_json::to_string_pretty(&response)?);
|
|
if !success {
|
|
std::process::exit(1);
|
|
}
|
|
} 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::Doctor => unreachable!(),
|
|
Command::Get(sub) => match sub {
|
|
GetCmd::ActiveWindow => Request::new("get-active-window"),
|
|
GetCmd::Monitors => Request::new("get-monitors"),
|
|
GetCmd::Version => Request::new("get-version"),
|
|
GetCmd::Systeminfo => Request::new("get-systeminfo"),
|
|
},
|
|
Command::Wait(sub) => match sub {
|
|
WaitCmd::Window(opts) => Request::new("wait-window")
|
|
.with_extra("selector", json!(opts.selector))
|
|
.with_extra("timeout_ms", json!(opts.timeout * 1000))
|
|
.with_extra("poll_ms", json!(opts.poll_ms)),
|
|
WaitCmd::Focus(opts) => Request::new("wait-focus")
|
|
.with_extra("selector", json!(opts.selector))
|
|
.with_extra("timeout_ms", json!(opts.timeout * 1000))
|
|
.with_extra("poll_ms", json!(opts.poll_ms)),
|
|
},
|
|
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 {
|
|
for line in render_error_lines(response) {
|
|
eprintln!("{line}");
|
|
}
|
|
std::process::exit(1);
|
|
}
|
|
for line in render_success_lines(cmd, response.data.as_ref())? {
|
|
println!("{line}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn render_success_lines(cmd: &Command, data: Option<&serde_json::Value>) -> Result<Vec<String>> {
|
|
let Some(data) = data else {
|
|
return Ok(vec!["ok".to_string()]);
|
|
};
|
|
|
|
let lines = match cmd {
|
|
Command::Snapshot { .. } | Command::ListWindows => render_window_listing(data),
|
|
Command::Get(GetCmd::ActiveWindow)
|
|
| Command::Wait(WaitCmd::Window(_))
|
|
| Command::Wait(WaitCmd::Focus(_)) => render_window_wait_or_read(data),
|
|
Command::Get(GetCmd::Monitors) => render_monitor_listing(data),
|
|
Command::Get(GetCmd::Version) => vec![render_version_line(data)],
|
|
Command::Get(GetCmd::Systeminfo) => render_systeminfo_lines(data),
|
|
Command::GetScreenSize => vec![render_screen_size_line(data)],
|
|
Command::GetMousePosition => vec![render_mouse_position_line(data)],
|
|
Command::Screenshot { annotate, .. } => render_screenshot_lines(data, *annotate),
|
|
Command::Click { .. } => vec![render_click_line(data, false)],
|
|
Command::Dblclick { .. } => vec![render_click_line(data, true)],
|
|
Command::Type { .. } => vec![render_type_line(data)],
|
|
Command::Press { .. } => vec![render_press_line(data)],
|
|
Command::Hotkey { .. } => vec![render_hotkey_line(data)],
|
|
Command::Mouse(sub) => vec![render_mouse_line(sub, data)],
|
|
Command::Focus { .. } => vec![render_window_action_line("Focused", data)],
|
|
Command::Close { .. } => vec![render_window_action_line("Closed", data)],
|
|
Command::MoveWindow { .. } => vec![render_move_window_line(data)],
|
|
Command::ResizeWindow { .. } => vec![render_resize_window_line(data)],
|
|
Command::Launch { .. } => vec![render_launch_line(data)],
|
|
Command::Doctor | Command::Daemon(_) => vec![serde_json::to_string_pretty(data)?],
|
|
};
|
|
|
|
Ok(lines)
|
|
}
|
|
|
|
fn render_error_lines(response: &Response) -> Vec<String> {
|
|
let mut lines = Vec::new();
|
|
if let Some(err) = &response.error {
|
|
lines.push(format!("Error: {err}"));
|
|
}
|
|
|
|
let Some(data) = response.data.as_ref() else {
|
|
return lines;
|
|
};
|
|
|
|
let Some(kind) = data.get("kind").and_then(|value| value.as_str()) else {
|
|
return lines;
|
|
};
|
|
|
|
match kind {
|
|
"selector_not_found" => {
|
|
let selector = data
|
|
.get("selector")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("unknown");
|
|
let mode = data
|
|
.get("mode")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("unknown");
|
|
lines.push(format!("Selector: {selector} (mode: {mode})"));
|
|
}
|
|
"selector_invalid" => {
|
|
let selector = data
|
|
.get("selector")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("unknown");
|
|
let mode = data
|
|
.get("mode")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("unknown");
|
|
lines.push(format!("Selector: {selector} (mode: {mode})"));
|
|
if let Some(message) = data.get("message").and_then(|value| value.as_str()) {
|
|
lines.push(format!("Reason: {message}"));
|
|
}
|
|
}
|
|
"selector_ambiguous" => {
|
|
let selector = data
|
|
.get("selector")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("unknown");
|
|
let mode = data
|
|
.get("mode")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("unknown");
|
|
lines.push(format!("Selector: {selector} (mode: {mode})"));
|
|
if let Some(candidates) = data.get("candidates").and_then(|value| value.as_array()) {
|
|
lines.push("Candidates:".to_string());
|
|
for candidate in candidates {
|
|
lines.push(window_line(candidate));
|
|
}
|
|
}
|
|
}
|
|
"timeout" => {
|
|
let selector = data
|
|
.get("selector")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("unknown");
|
|
let wait = data
|
|
.get("wait")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("wait");
|
|
let timeout_ms = data
|
|
.get("timeout_ms")
|
|
.and_then(|value| value.as_u64())
|
|
.unwrap_or(0);
|
|
lines.push(format!(
|
|
"Timed out after {timeout_ms}ms waiting for {wait} selector {selector}"
|
|
));
|
|
if let Some(observation) = data.get("last_observation") {
|
|
lines.extend(render_last_observation_lines(observation));
|
|
}
|
|
}
|
|
"not_found" => {
|
|
if data
|
|
.get("mode")
|
|
.and_then(|value| value.as_str())
|
|
.is_some_and(|mode| mode == "focused")
|
|
{
|
|
lines.push("No focused window is available.".to_string());
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
lines
|
|
}
|
|
|
|
fn render_last_observation_lines(observation: &serde_json::Value) -> Vec<String> {
|
|
let mut lines = Vec::new();
|
|
let Some(kind) = observation.get("kind").and_then(|value| value.as_str()) else {
|
|
return lines;
|
|
};
|
|
|
|
match kind {
|
|
"window_not_focused" => {
|
|
lines.push(
|
|
"Last observation: matching window exists but is not focused yet.".to_string(),
|
|
);
|
|
if let Some(window) = observation.get("window") {
|
|
lines.push(window_line(window));
|
|
}
|
|
}
|
|
"selector_not_found" => {
|
|
let selector = observation
|
|
.get("selector")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("unknown");
|
|
let mode = observation
|
|
.get("mode")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("unknown");
|
|
lines.push(format!(
|
|
"Last observation: no window matched selector {selector} (mode: {mode})"
|
|
));
|
|
}
|
|
_ => {
|
|
lines.push(format!(
|
|
"Last observation: {}",
|
|
serde_json::to_string(observation).unwrap_or_else(|_| kind.to_string())
|
|
));
|
|
}
|
|
}
|
|
|
|
lines
|
|
}
|
|
|
|
fn render_window_listing(data: &serde_json::Value) -> Vec<String> {
|
|
let mut lines = Vec::new();
|
|
if let Some(screenshot) = data.get("screenshot").and_then(|value| value.as_str()) {
|
|
lines.push(format!("Screenshot: {screenshot}"));
|
|
}
|
|
if let Some(windows) = data.get("windows").and_then(|value| value.as_array()) {
|
|
lines.push(format!("Windows: {}", windows.len()));
|
|
for window in windows {
|
|
lines.push(window_line(window));
|
|
}
|
|
}
|
|
lines
|
|
}
|
|
|
|
fn render_window_wait_or_read(data: &serde_json::Value) -> Vec<String> {
|
|
let mut lines = Vec::new();
|
|
if let Some(window) = data.get("window") {
|
|
lines.push(window_line(window));
|
|
}
|
|
if let Some(elapsed_ms) = data.get("elapsed_ms").and_then(|value| value.as_u64()) {
|
|
lines.push(format!("Elapsed: {elapsed_ms}ms"));
|
|
}
|
|
lines
|
|
}
|
|
|
|
fn render_monitor_listing(data: &serde_json::Value) -> Vec<String> {
|
|
let mut lines = Vec::new();
|
|
if let Some(count) = data.get("count").and_then(|value| value.as_u64()) {
|
|
lines.push(format!("Monitors: {count}"));
|
|
}
|
|
if let Some(monitors) = data.get("monitors").and_then(|value| value.as_array()) {
|
|
for monitor in monitors {
|
|
let name = monitor
|
|
.get("name")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("monitor");
|
|
let x = monitor
|
|
.get("x")
|
|
.and_then(|value| value.as_i64())
|
|
.unwrap_or(0);
|
|
let y = monitor
|
|
.get("y")
|
|
.and_then(|value| value.as_i64())
|
|
.unwrap_or(0);
|
|
let width = monitor
|
|
.get("width")
|
|
.and_then(|value| value.as_u64())
|
|
.unwrap_or(0);
|
|
let height = monitor
|
|
.get("height")
|
|
.and_then(|value| value.as_u64())
|
|
.unwrap_or(0);
|
|
let primary = monitor
|
|
.get("primary")
|
|
.and_then(|value| value.as_bool())
|
|
.unwrap_or(false);
|
|
let automatic = monitor
|
|
.get("automatic")
|
|
.and_then(|value| value.as_bool())
|
|
.unwrap_or(false);
|
|
let mut flags = Vec::new();
|
|
if primary {
|
|
flags.push("primary");
|
|
}
|
|
if automatic {
|
|
flags.push("automatic");
|
|
}
|
|
let suffix = if flags.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" [{}]", flags.join(", "))
|
|
};
|
|
lines.push(format!("{name:<16} {x},{y} {width}x{height}{suffix}"));
|
|
}
|
|
}
|
|
lines
|
|
}
|
|
|
|
fn render_version_line(data: &serde_json::Value) -> String {
|
|
let version = data
|
|
.get("version")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("unknown");
|
|
let backend = data
|
|
.get("backend")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("unknown");
|
|
format!("deskctl {version} ({backend})")
|
|
}
|
|
|
|
fn render_systeminfo_lines(data: &serde_json::Value) -> Vec<String> {
|
|
let mut lines = Vec::new();
|
|
let backend = data
|
|
.get("backend")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("unknown");
|
|
lines.push(format!("Backend: {backend}"));
|
|
if let Some(display) = data.get("display").and_then(|value| value.as_str()) {
|
|
lines.push(format!("Display: {display}"));
|
|
}
|
|
if let Some(session_type) = data.get("session_type").and_then(|value| value.as_str()) {
|
|
lines.push(format!("Session type: {session_type}"));
|
|
}
|
|
if let Some(session) = data.get("session").and_then(|value| value.as_str()) {
|
|
lines.push(format!("Session: {session}"));
|
|
}
|
|
if let Some(socket_path) = data.get("socket_path").and_then(|value| value.as_str()) {
|
|
lines.push(format!("Socket: {socket_path}"));
|
|
}
|
|
if let Some(screen) = data.get("screen") {
|
|
lines.push(format!("Screen: {}", screen_dimensions(screen)));
|
|
}
|
|
if let Some(count) = data.get("monitor_count").and_then(|value| value.as_u64()) {
|
|
lines.push(format!("Monitor count: {count}"));
|
|
}
|
|
if let Some(monitors) = data.get("monitors").and_then(|value| value.as_array()) {
|
|
for monitor in monitors {
|
|
lines.push(format!(
|
|
" {}",
|
|
render_monitor_listing(&serde_json::json!({"monitors": [monitor]}))[0]
|
|
));
|
|
}
|
|
}
|
|
lines
|
|
}
|
|
|
|
fn render_screen_size_line(data: &serde_json::Value) -> String {
|
|
format!("Screen: {}", screen_dimensions(data))
|
|
}
|
|
|
|
fn render_mouse_position_line(data: &serde_json::Value) -> String {
|
|
let x = data.get("x").and_then(|value| value.as_i64()).unwrap_or(0);
|
|
let y = data.get("y").and_then(|value| value.as_i64()).unwrap_or(0);
|
|
format!("Pointer: {x},{y}")
|
|
}
|
|
|
|
fn render_screenshot_lines(data: &serde_json::Value, annotate: bool) -> Vec<String> {
|
|
let mut lines = Vec::new();
|
|
if let Some(screenshot) = data.get("screenshot").and_then(|value| value.as_str()) {
|
|
lines.push(format!("Screenshot: {screenshot}"));
|
|
}
|
|
if annotate {
|
|
if let Some(windows) = data.get("windows").and_then(|value| value.as_array()) {
|
|
lines.push(format!("Annotated windows: {}", windows.len()));
|
|
for window in windows {
|
|
lines.push(window_line(window));
|
|
}
|
|
}
|
|
}
|
|
lines
|
|
}
|
|
|
|
fn render_click_line(data: &serde_json::Value, double: bool) -> String {
|
|
let action = if double { "Double-clicked" } else { "Clicked" };
|
|
let key = if double { "double_clicked" } else { "clicked" };
|
|
let x = data
|
|
.get(key)
|
|
.and_then(|value| value.get("x"))
|
|
.and_then(|value| value.as_i64())
|
|
.unwrap_or(0);
|
|
let y = data
|
|
.get(key)
|
|
.and_then(|value| value.get("y"))
|
|
.and_then(|value| value.as_i64())
|
|
.unwrap_or(0);
|
|
match target_summary(data) {
|
|
Some(target) => format!("{action} {x},{y} on {target}"),
|
|
None => format!("{action} {x},{y}"),
|
|
}
|
|
}
|
|
|
|
fn render_type_line(data: &serde_json::Value) -> String {
|
|
let typed = data
|
|
.get("typed")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("");
|
|
format!("Typed: {}", quoted_summary(typed, 60))
|
|
}
|
|
|
|
fn render_press_line(data: &serde_json::Value) -> String {
|
|
let key = data
|
|
.get("pressed")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("unknown");
|
|
format!("Pressed: {key}")
|
|
}
|
|
|
|
fn render_hotkey_line(data: &serde_json::Value) -> String {
|
|
let keys = data
|
|
.get("hotkey")
|
|
.and_then(|value| value.as_array())
|
|
.map(|items| {
|
|
items
|
|
.iter()
|
|
.filter_map(|value| value.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("+")
|
|
})
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
format!("Hotkey: {keys}")
|
|
}
|
|
|
|
fn render_mouse_line(sub: &MouseCmd, data: &serde_json::Value) -> String {
|
|
match sub {
|
|
MouseCmd::Move { .. } => {
|
|
let x = data
|
|
.get("moved")
|
|
.and_then(|value| value.get("x"))
|
|
.and_then(|value| value.as_i64())
|
|
.unwrap_or(0);
|
|
let y = data
|
|
.get("moved")
|
|
.and_then(|value| value.get("y"))
|
|
.and_then(|value| value.as_i64())
|
|
.unwrap_or(0);
|
|
format!("Moved pointer to {x},{y}")
|
|
}
|
|
MouseCmd::Scroll { .. } => {
|
|
let amount = data
|
|
.get("scrolled")
|
|
.and_then(|value| value.get("amount"))
|
|
.and_then(|value| value.as_i64())
|
|
.unwrap_or(0);
|
|
let axis = data
|
|
.get("scrolled")
|
|
.and_then(|value| value.get("axis"))
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("vertical");
|
|
format!("Scrolled {axis} by {amount}")
|
|
}
|
|
MouseCmd::Drag { .. } => {
|
|
let x1 = data
|
|
.get("dragged")
|
|
.and_then(|value| value.get("from"))
|
|
.and_then(|value| value.get("x"))
|
|
.and_then(|value| value.as_i64())
|
|
.unwrap_or(0);
|
|
let y1 = data
|
|
.get("dragged")
|
|
.and_then(|value| value.get("from"))
|
|
.and_then(|value| value.get("y"))
|
|
.and_then(|value| value.as_i64())
|
|
.unwrap_or(0);
|
|
let x2 = data
|
|
.get("dragged")
|
|
.and_then(|value| value.get("to"))
|
|
.and_then(|value| value.get("x"))
|
|
.and_then(|value| value.as_i64())
|
|
.unwrap_or(0);
|
|
let y2 = data
|
|
.get("dragged")
|
|
.and_then(|value| value.get("to"))
|
|
.and_then(|value| value.get("y"))
|
|
.and_then(|value| value.as_i64())
|
|
.unwrap_or(0);
|
|
format!("Dragged {x1},{y1} -> {x2},{y2}")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_window_action_line(action: &str, data: &serde_json::Value) -> String {
|
|
match target_summary(data) {
|
|
Some(target) => format!("{action} {target}"),
|
|
None => action.to_string(),
|
|
}
|
|
}
|
|
|
|
fn render_move_window_line(data: &serde_json::Value) -> String {
|
|
let x = data.get("x").and_then(|value| value.as_i64()).unwrap_or(0);
|
|
let y = data.get("y").and_then(|value| value.as_i64()).unwrap_or(0);
|
|
match target_summary(data) {
|
|
Some(target) => format!("Moved {target} to {x},{y}"),
|
|
None => format!("Moved window to {x},{y}"),
|
|
}
|
|
}
|
|
|
|
fn render_resize_window_line(data: &serde_json::Value) -> String {
|
|
let width = data
|
|
.get("width")
|
|
.and_then(|value| value.as_u64())
|
|
.unwrap_or(0);
|
|
let height = data
|
|
.get("height")
|
|
.and_then(|value| value.as_u64())
|
|
.unwrap_or(0);
|
|
match target_summary(data) {
|
|
Some(target) => format!("Resized {target} to {width}x{height}"),
|
|
None => format!("Resized window to {width}x{height}"),
|
|
}
|
|
}
|
|
|
|
fn render_launch_line(data: &serde_json::Value) -> String {
|
|
let command = data
|
|
.get("command")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("command");
|
|
let pid = data
|
|
.get("pid")
|
|
.and_then(|value| value.as_u64())
|
|
.map(|value| value.to_string())
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
format!("Launched {command} (pid {pid})")
|
|
}
|
|
|
|
fn window_line(window: &serde_json::Value) -> String {
|
|
let ref_id = window
|
|
.get("ref_id")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("?");
|
|
let window_id = window
|
|
.get("window_id")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("unknown");
|
|
let title = window
|
|
.get("title")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("");
|
|
let focused = window
|
|
.get("focused")
|
|
.and_then(|value| value.as_bool())
|
|
.unwrap_or(false);
|
|
let minimized = window
|
|
.get("minimized")
|
|
.and_then(|value| value.as_bool())
|
|
.unwrap_or(false);
|
|
let x = window
|
|
.get("x")
|
|
.and_then(|value| value.as_i64())
|
|
.unwrap_or(0);
|
|
let y = window
|
|
.get("y")
|
|
.and_then(|value| value.as_i64())
|
|
.unwrap_or(0);
|
|
let width = window
|
|
.get("width")
|
|
.and_then(|value| value.as_u64())
|
|
.unwrap_or(0);
|
|
let height = window
|
|
.get("height")
|
|
.and_then(|value| value.as_u64())
|
|
.unwrap_or(0);
|
|
let state = if focused {
|
|
"focused"
|
|
} else if minimized {
|
|
"hidden"
|
|
} else {
|
|
"visible"
|
|
};
|
|
format!(
|
|
"@{ref_id:<4} {:<30} ({state:<7}) {x},{y} {width}x{height} [{window_id}]",
|
|
truncate_display(title, 30)
|
|
)
|
|
}
|
|
|
|
fn target_summary(data: &serde_json::Value) -> Option<String> {
|
|
let ref_id = data.get("ref_id").and_then(|value| value.as_str());
|
|
let window_id = data.get("window_id").and_then(|value| value.as_str());
|
|
let title = data
|
|
.get("title")
|
|
.or_else(|| data.get("window"))
|
|
.and_then(|value| value.as_str());
|
|
|
|
match (ref_id, window_id, title) {
|
|
(Some(ref_id), Some(window_id), Some(title)) => Some(format!(
|
|
"@{ref_id} [{window_id}] {}",
|
|
quoted_summary(title, 40)
|
|
)),
|
|
(None, Some(window_id), Some(title)) => {
|
|
Some(format!("[{window_id}] {}", quoted_summary(title, 40)))
|
|
}
|
|
(Some(ref_id), Some(window_id), None) => Some(format!("@{ref_id} [{window_id}]")),
|
|
(None, Some(window_id), None) => Some(format!("[{window_id}]")),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn quoted_summary(value: &str, max_chars: usize) -> String {
|
|
format!("\"{}\"", truncate_display(value, max_chars))
|
|
}
|
|
|
|
fn screen_dimensions(data: &serde_json::Value) -> String {
|
|
let width = data
|
|
.get("width")
|
|
.and_then(|value| value.as_u64())
|
|
.unwrap_or(0);
|
|
let height = data
|
|
.get("height")
|
|
.and_then(|value| value.as_u64())
|
|
.unwrap_or(0);
|
|
format!("{width}x{height}")
|
|
}
|
|
|
|
fn truncate_display(value: &str, max_chars: usize) -> String {
|
|
let char_count = value.chars().count();
|
|
if char_count <= max_chars {
|
|
return value.to_string();
|
|
}
|
|
|
|
let truncated: String = value.chars().take(max_chars.saturating_sub(3)).collect();
|
|
format!("{truncated}...")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
render_error_lines, render_screen_size_line, render_success_lines, target_summary,
|
|
truncate_display, App, Command, Response,
|
|
};
|
|
use clap::CommandFactory;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn help_examples_include_snapshot_examples() {
|
|
let help = App::command()
|
|
.find_subcommand_mut("snapshot")
|
|
.expect("snapshot subcommand must exist")
|
|
.render_long_help()
|
|
.to_string();
|
|
assert!(help.contains("deskctl snapshot --annotate"));
|
|
}
|
|
|
|
#[test]
|
|
fn window_listing_text_includes_window_ids() {
|
|
let lines = render_success_lines(
|
|
&Command::ListWindows,
|
|
Some(&json!({
|
|
"windows": [{
|
|
"ref_id": "w1",
|
|
"window_id": "win1",
|
|
"title": "Firefox",
|
|
"app_name": "firefox",
|
|
"x": 0,
|
|
"y": 0,
|
|
"width": 1280,
|
|
"height": 720,
|
|
"focused": true,
|
|
"minimized": false
|
|
}]
|
|
})),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(lines[0], "Windows: 1");
|
|
assert!(lines[1].contains("[win1]"));
|
|
assert!(lines[1].contains("@w1"));
|
|
}
|
|
|
|
#[test]
|
|
fn action_text_includes_target_identity() {
|
|
let lines = render_success_lines(
|
|
&Command::Focus {
|
|
selector: "title=Firefox".to_string(),
|
|
},
|
|
Some(&json!({
|
|
"action": "focus",
|
|
"window": "Firefox",
|
|
"title": "Firefox",
|
|
"ref_id": "w2",
|
|
"window_id": "win7"
|
|
})),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(lines, vec!["Focused @w2 [win7] \"Firefox\""]);
|
|
}
|
|
|
|
#[test]
|
|
fn timeout_errors_render_last_observation() {
|
|
let lines = render_error_lines(&Response::err_with_data(
|
|
"Timed out waiting for focus to match selector: title=Firefox",
|
|
json!({
|
|
"kind": "timeout",
|
|
"wait": "focus",
|
|
"selector": "title=Firefox",
|
|
"timeout_ms": 1000,
|
|
"last_observation": {
|
|
"kind": "window_not_focused",
|
|
"window": {
|
|
"ref_id": "w1",
|
|
"window_id": "win1",
|
|
"title": "Firefox",
|
|
"app_name": "firefox",
|
|
"x": 0,
|
|
"y": 0,
|
|
"width": 1280,
|
|
"height": 720,
|
|
"focused": false,
|
|
"minimized": false
|
|
}
|
|
}
|
|
}),
|
|
));
|
|
|
|
assert!(lines
|
|
.iter()
|
|
.any(|line| line
|
|
.contains("Timed out after 1000ms waiting for focus selector title=Firefox")));
|
|
assert!(lines
|
|
.iter()
|
|
.any(|line| line.contains("matching window exists but is not focused yet")));
|
|
assert!(lines.iter().any(|line| line.contains("[win1]")));
|
|
}
|
|
|
|
#[test]
|
|
fn screen_size_text_is_compact() {
|
|
assert_eq!(
|
|
render_screen_size_line(&json!({"width": 1440, "height": 900})),
|
|
"Screen: 1440x900"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn target_summary_prefers_ref_and_window_id() {
|
|
let summary = target_summary(&json!({
|
|
"ref_id": "w1",
|
|
"window_id": "win1",
|
|
"title": "Firefox"
|
|
}));
|
|
assert_eq!(summary.as_deref(), Some("@w1 [win1] \"Firefox\""));
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_display_is_char_safe() {
|
|
let input = format!("fire{}fox", '\u{00E9}');
|
|
assert_eq!(truncate_display(&input, 7), "fire...");
|
|
}
|
|
}
|