From a4cf9e32dd227205b2bc312f107daf12f4d3b381 Mon Sep 17 00:00:00 2001 From: Hari <73809867+harivansh-afk@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:11:30 -0400 Subject: [PATCH] grouped runtime reads and waits selector modes (#5) - grouped runtime reads and waits selector modes - Fix wait command client timeouts and test failures --- README.md | 80 ++++++++- skills/SKILL.md | 34 +++- src/backend/mod.rs | 22 +++ src/backend/x11.rs | 107 +++++++++++- src/cli/connection.rs | 17 +- src/cli/mod.rs | 219 ++++++++++++++++++++++++- src/core/protocol.rs | 8 + src/core/refs.rs | 357 +++++++++++++++++++++++++++++++++++----- src/core/types.rs | 43 ++++- src/daemon/handler.rs | 372 +++++++++++++++++++++++++++++++++++++++--- tests/support/mod.rs | 11 ++ tests/x11_runtime.rs | 130 ++++++++++++++- 12 files changed, 1323 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 2438381..387f3e9 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,22 @@ deskctl doctor # See the desktop deskctl snapshot +# Query focused runtime state +deskctl get active-window +deskctl get monitors + # Click a window deskctl click @w1 # Type text deskctl type "hello world" -# Focus by name -deskctl focus "firefox" +# Wait for a window or focus transition +deskctl wait window --selector 'title=Firefox' --timeout 10 +deskctl wait focus --selector 'class=firefox' --timeout 5 + +# Focus by explicit selector +deskctl focus 'title=Firefox' ``` ## Architecture @@ -93,6 +101,74 @@ deskctl doctor - `--json` output includes a stable `window_id` for programmatic targeting within the current daemon session - `list-windows` is a cheap read-only operation and does not capture or write a screenshot +## Read and Wait Surface + +The grouped runtime reads are: + +```bash +deskctl get active-window +deskctl get monitors +deskctl get version +deskctl get systeminfo +``` + +The grouped runtime waits are: + +```bash +deskctl wait window --selector 'title=Firefox' --timeout 10 +deskctl wait focus --selector 'id=win3' --timeout 5 +``` + +Successful `get active-window`, `wait window`, and `wait focus` responses return a `window` payload with: +- `ref_id` +- `window_id` +- `title` +- `app_name` +- geometry (`x`, `y`, `width`, `height`) +- state flags (`focused`, `minimized`) + +`get monitors` returns: +- `count` +- `monitors[]` with geometry and primary/automatic flags + +`get version` returns: +- `version` +- `backend` + +`get systeminfo` stays runtime-scoped and returns: +- `backend` +- `display` +- `session_type` +- `session` +- `socket_path` +- `screen` +- `monitor_count` +- `monitors` + +Wait timeout and selector failures are structured in `--json` mode so agents can recover without string parsing. + +## Selector Contract + +Explicit selector modes: + +```bash +ref=w1 +id=win1 +title=Firefox +class=firefox +focused +``` + +Legacy refs remain supported: + +```bash +@w1 +w1 +win1 +``` + +Bare selectors such as `firefox` are still supported as fuzzy substring matches, but they now fail on ambiguity and return candidate windows instead of silently picking the first match. + ## Support Boundary `deskctl` supports Linux X11 in this phase. Wayland and Hyprland are explicitly out of scope for the current runtime contract. diff --git a/skills/SKILL.md b/skills/SKILL.md index 50a8f00..3b1733d 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -11,8 +11,9 @@ Desktop control CLI for AI agents on Linux X11. Provides a unified interface for ## Core Workflow 1. **Snapshot** to see the desktop and get window refs -2. **Act** using refs or coordinates (click, type, focus) -3. **Repeat** as needed +2. **Query / wait** using grouped `get` and `wait` commands +3. **Act** using refs, explicit selectors, or coordinates +4. **Repeat** as needed ## Quick Reference @@ -24,6 +25,12 @@ deskctl snapshot --annotate # Screenshot with bounding boxes and labels deskctl snapshot --json # Structured JSON output deskctl list-windows # Window tree without screenshot deskctl screenshot /tmp/s.png # Screenshot only (no window tree) +deskctl get active-window # Currently focused window +deskctl get monitors # Monitor geometry +deskctl get version # deskctl version + backend +deskctl get systeminfo # Runtime-scoped diagnostics +deskctl wait window --selector 'title=Firefox' --timeout 10 +deskctl wait focus --selector 'class=firefox' --timeout 5 ``` ### Click and Type @@ -51,7 +58,9 @@ deskctl mouse drag 100 100 500 500 # Drag from (100,100) to (500,500) ```bash deskctl focus @w2 # Focus window by ref -deskctl focus "firefox" # Focus window by name (substring match) +deskctl focus 'title=Firefox' # Focus by explicit title selector +deskctl focus 'class=firefox' # Focus by explicit class selector +deskctl focus "firefox" # Fuzzy substring match (fails on ambiguity) deskctl close @w3 # Close window gracefully deskctl move-window @w1 100 200 # Move window to position deskctl resize-window @w1 800 600 # Resize window @@ -89,14 +98,29 @@ After `snapshot` or `list-windows`, windows are assigned short refs: - Refs reset on each `snapshot` call - Use `--json` to see stable `window_id` values for programmatic tracking within the current daemon session +## Selector Contract + +Prefer explicit selectors when an agent needs deterministic targeting: + +```bash +ref=w1 +id=win1 +title=Firefox +class=firefox +focused +``` + +Bare selectors such as `firefox` still work as fuzzy substring matches, but they now fail with candidate windows if multiple matches exist. + ## Example Agent Workflow ```bash # 1. See what's on screen deskctl snapshot --annotate -# 2. Focus the browser -deskctl focus "firefox" +# 2. Wait for the browser and focus it deterministically +deskctl wait window --selector 'class=firefox' --timeout 10 +deskctl focus 'class=firefox' # 3. Navigate to a URL deskctl hotkey ctrl l diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 53ea405..ea4df7f 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -17,11 +17,30 @@ pub struct BackendWindow { pub minimized: bool, } +#[derive(Debug, Clone)] +pub struct BackendMonitor { + pub name: String, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub width_mm: u32, + pub height_mm: u32, + pub primary: bool, + pub automatic: bool, +} + #[allow(dead_code)] pub trait DesktopBackend: Send { /// Collect z-ordered windows for read-only queries and targeting. fn list_windows(&mut self) -> Result>; + /// Get the currently focused window, if one is known. + fn active_window(&mut self) -> Result>; + + /// Collect monitor geometry and metadata. + fn list_monitors(&self) -> Result>; + /// Capture the current desktop image without writing it to disk. fn capture_screenshot(&mut self) -> Result; @@ -69,4 +88,7 @@ pub trait DesktopBackend: Send { /// Launch an application. fn launch(&self, command: &str, args: &[String]) -> Result; + + /// Human-readable backend name for diagnostics and runtime queries. + fn backend_name(&self) -> &'static str; } diff --git a/src/backend/x11.rs b/src/backend/x11.rs index 721b96d..7b1b396 100644 --- a/src/backend/x11.rs +++ b/src/backend/x11.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use enigo::{Axis, Button, Coordinate, Direction, Enigo, Key, Keyboard, Mouse, Settings}; use image::RgbaImage; use x11rb::connection::Connection; +use x11rb::protocol::randr::ConnectionExt as RandrConnectionExt; use x11rb::protocol::xproto::{ Atom, AtomEnum, ClientMessageData, ClientMessageEvent, ConfigureWindowAux, ConnectionExt as XprotoConnectionExt, EventMask, GetPropertyReply, ImageFormat, ImageOrder, @@ -9,7 +10,7 @@ use x11rb::protocol::xproto::{ }; use x11rb::rust_connection::RustConnection; -use crate::backend::BackendWindow; +use crate::backend::{BackendMonitor, BackendWindow}; struct Atoms { client_list_stacking: Atom, @@ -103,6 +104,74 @@ impl X11Backend { Ok(window_infos) } + fn active_window_info(&self) -> Result> { + let Some(active_window) = self.active_window()? else { + return Ok(None); + }; + + let title = self.window_title(active_window).unwrap_or_default(); + let app_name = self.window_app_name(active_window).unwrap_or_default(); + if title.is_empty() && app_name.is_empty() { + return Ok(None); + } + + let (x, y, width, height) = self.window_geometry(active_window)?; + let minimized = self.window_is_minimized(active_window).unwrap_or(false); + Ok(Some(BackendWindow { + native_id: active_window, + title, + app_name, + x, + y, + width, + height, + focused: true, + minimized, + })) + } + + fn collect_monitors(&self) -> Result> { + let reply = self + .conn + .randr_get_monitors(self.root, true)? + .reply() + .context("Failed to query RANDR monitors")?; + + let mut monitors = Vec::with_capacity(reply.monitors.len()); + for (index, monitor) in reply.monitors.into_iter().enumerate() { + monitors.push(BackendMonitor { + name: self + .atom_name(monitor.name) + .unwrap_or_else(|_| format!("monitor{}", index + 1)), + x: i32::from(monitor.x), + y: i32::from(monitor.y), + width: u32::from(monitor.width), + height: u32::from(monitor.height), + width_mm: monitor.width_in_millimeters, + height_mm: monitor.height_in_millimeters, + primary: monitor.primary, + automatic: monitor.automatic, + }); + } + + if monitors.is_empty() { + let (width, height) = self.root_geometry()?; + monitors.push(BackendMonitor { + name: "screen".to_string(), + x: 0, + y: 0, + width, + height, + width_mm: 0, + height_mm: 0, + primary: true, + automatic: true, + }); + } + + Ok(monitors) + } + fn capture_root_image(&self) -> Result { let (width, height) = self.root_geometry()?; let reply = self @@ -224,6 +293,14 @@ impl X11Backend { .reply() .with_context(|| format!("Failed to read property {property} from window {window}")) } + + fn atom_name(&self, atom: Atom) -> Result { + self.conn + .get_atom_name(atom)? + .reply() + .map(|reply| String::from_utf8_lossy(&reply.name).to_string()) + .with_context(|| format!("Failed to read atom name for {atom}")) + } } impl super::DesktopBackend for X11Backend { @@ -231,6 +308,30 @@ impl super::DesktopBackend for X11Backend { self.collect_window_infos() } + fn active_window(&mut self) -> Result> { + self.active_window_info() + } + + fn list_monitors(&self) -> Result> { + match self.collect_monitors() { + Ok(monitors) => Ok(monitors), + Err(_) => { + let (width, height) = self.root_geometry()?; + Ok(vec![BackendMonitor { + name: "screen".to_string(), + x: 0, + y: 0, + width, + height, + width_mm: 0, + height_mm: 0, + primary: true, + automatic: true, + }]) + } + } + } + fn capture_screenshot(&mut self) -> Result { self.capture_root_image() } @@ -452,6 +553,10 @@ impl super::DesktopBackend for X11Backend { .with_context(|| format!("Failed to launch: {command}"))?; Ok(child.id()) } + + fn backend_name(&self) -> &'static str { + "x11" + } } fn parse_key(name: &str) -> Result { diff --git a/src/cli/connection.rs b/src/cli/connection.rs index 840e637..1b7b0b2 100644 --- a/src/cli/connection.rs +++ b/src/cli/connection.rs @@ -79,8 +79,23 @@ fn spawn_daemon(opts: &GlobalOpts) -> Result<()> { Ok(()) } +fn request_read_timeout(request: &Request) -> Duration { + let default_timeout = Duration::from_secs(30); + match request.action.as_str() { + "wait-window" | "wait-focus" => { + let wait_timeout = request + .extra + .get("timeout_ms") + .and_then(|value| value.as_u64()) + .unwrap_or(10_000); + Duration::from_millis(wait_timeout.saturating_add(5_000)) + } + _ => default_timeout, + } +} + fn send_request_over_stream(mut stream: UnixStream, request: &Request) -> Result { - stream.set_read_timeout(Some(Duration::from_secs(30)))?; + stream.set_read_timeout(Some(request_read_timeout(request)))?; stream.set_write_timeout(Some(Duration::from_secs(5)))?; let json = serde_json::to_string(request)?; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index d4003ff..ccd5b28 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -102,6 +102,12 @@ pub enum Command { GetMousePosition, /// Diagnose X11 runtime, screenshot, and daemon health Doctor, + /// Query runtime state + #[command(subcommand)] + Get(GetCmd), + /// Wait for runtime state transitions + #[command(subcommand)] + Wait(WaitCmd), /// Take a screenshot without window tree Screenshot { /// Save path (default: /tmp/deskctl-{timestamp}.png) @@ -169,6 +175,57 @@ pub enum DaemonAction { Status, } +const GET_ACTIVE_WINDOW_EXAMPLES: &str = + "Examples:\n deskctl get active-window\n deskctl --json get active-window"; +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 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"; + +#[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(); @@ -188,9 +245,13 @@ pub fn run() -> Result<()> { // 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)?; } @@ -244,6 +305,22 @@ fn build_request(cmd: &Command) -> Result { 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 { @@ -264,6 +341,32 @@ fn print_response(cmd: &Command, response: &Response) -> Result<()> { if let Some(ref err) = response.error { eprintln!("Error: {err}"); } + if let Some(ref data) = response.data { + if let Some(kind) = data.get("kind").and_then(|value| value.as_str()) { + match kind { + "selector_ambiguous" => { + if let Some(candidates) = data.get("candidates").and_then(|v| v.as_array()) + { + eprintln!("Candidates:"); + for candidate in candidates { + print_window_to_stderr(candidate); + } + } + } + "timeout" => { + if let Some(selector) = data.get("selector").and_then(|v| v.as_str()) { + let wait = data.get("wait").and_then(|v| v.as_str()).unwrap_or("wait"); + let timeout_ms = + data.get("timeout_ms").and_then(|v| v.as_u64()).unwrap_or(0); + eprintln!( + "Timed out after {timeout_ms}ms waiting for {wait} selector {selector}" + ); + } + } + _ => {} + } + } + } std::process::exit(1); } if let Some(ref data) = response.data { @@ -293,17 +396,61 @@ fn print_response(cmd: &Command, response: &Response) -> Result<()> { } else { "visible" }; - let display_title = if title.len() > 30 { - format!("{}...", &title[..27]) - } else { - title.to_string() - }; + let display_title = truncate_display(title, 30); println!( "@{:<4} {:<30} ({:<7}) {},{} {}x{}", ref_id, display_title, state, x, y, width, height ); } } + } else if matches!( + cmd, + Command::Get(GetCmd::ActiveWindow) + | Command::Wait(WaitCmd::Window(_)) + | Command::Wait(WaitCmd::Focus(_)) + ) { + if let Some(window) = data.get("window") { + print_window(window); + if let Some(elapsed_ms) = data.get("elapsed_ms").and_then(|v| v.as_u64()) { + println!("Elapsed: {elapsed_ms}ms"); + } + } else { + println!("{}", serde_json::to_string_pretty(data)?); + } + } else if matches!(cmd, Command::Get(GetCmd::Monitors)) { + if let Some(monitors) = data.get("monitors").and_then(|v| v.as_array()) { + for monitor in monitors { + let name = monitor + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("monitor"); + let x = monitor.get("x").and_then(|v| v.as_i64()).unwrap_or(0); + let y = monitor.get("y").and_then(|v| v.as_i64()).unwrap_or(0); + let width = monitor.get("width").and_then(|v| v.as_u64()).unwrap_or(0); + let height = monitor.get("height").and_then(|v| v.as_u64()).unwrap_or(0); + let primary = monitor + .get("primary") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let primary_suffix = if primary { " primary" } else { "" }; + println!( + "{name:<16} {},{} {}x{}{primary_suffix}", + x, y, width, height + ); + } + } + } else if matches!(cmd, Command::Get(GetCmd::Version)) { + let version = data + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let backend = data + .get("backend") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + println!("deskctl {version} ({backend})"); + } else if matches!(cmd, Command::Get(GetCmd::Systeminfo)) { + println!("{}", serde_json::to_string_pretty(data)?); } else { // Generic: print JSON data println!("{}", serde_json::to_string_pretty(data)?); @@ -311,3 +458,65 @@ fn print_response(cmd: &Command, response: &Response) -> Result<()> { } Ok(()) } + +fn print_window(window: &serde_json::Value) { + print_window_line(window, false); +} + +fn print_window_to_stderr(window: &serde_json::Value) { + print_window_line(window, true); +} + +fn print_window_line(window: &serde_json::Value, stderr: bool) { + let ref_id = window.get("ref_id").and_then(|v| v.as_str()).unwrap_or("?"); + let window_id = window + .get("window_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let title = window.get("title").and_then(|v| v.as_str()).unwrap_or(""); + let focused = window + .get("focused") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let minimized = window + .get("minimized") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let x = window.get("x").and_then(|v| v.as_i64()).unwrap_or(0); + let y = window.get("y").and_then(|v| v.as_i64()).unwrap_or(0); + let width = window.get("width").and_then(|v| v.as_u64()).unwrap_or(0); + let height = window.get("height").and_then(|v| v.as_u64()).unwrap_or(0); + let state = if focused { + "focused" + } else if minimized { + "hidden" + } else { + "visible" + }; + let line = format!( + "@{:<4} {:<30} ({:<7}) {},{} {}x{} [{}]", + ref_id, + truncate_display(title, 30), + state, + x, + y, + width, + height, + window_id + ); + if stderr { + eprintln!("{line}"); + } else { + println!("{line}"); + } +} + +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}...") +} diff --git a/src/core/protocol.rs b/src/core/protocol.rs index c0ead03..8feb87e 100644 --- a/src/core/protocol.rs +++ b/src/core/protocol.rs @@ -58,4 +58,12 @@ impl Response { error: Some(msg.into()), } } + + pub fn err_with_data(msg: impl Into, data: Value) -> Self { + Self { + success: false, + data: Some(data), + error: Some(msg.into()), + } + } } diff --git a/src/core/refs.rs b/src/core/refs.rs index 6185ebf..34e1ba7 100644 --- a/src/core/refs.rs +++ b/src/core/refs.rs @@ -7,6 +7,7 @@ use crate::core::types::WindowInfo; #[derive(Debug, Clone, Serialize, Deserialize)] #[allow(dead_code)] pub struct RefEntry { + pub ref_id: String, pub window_id: String, pub backend_window_id: u32, pub app_class: String, @@ -30,6 +31,35 @@ pub struct RefMap { next_window: usize, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SelectorQuery { + Ref(String), + WindowId(String), + Title(String), + Class(String), + Focused, + Fuzzy(String), +} + +#[derive(Debug, Clone)] +pub enum ResolveResult { + Match(RefEntry), + NotFound { + selector: String, + mode: &'static str, + }, + Ambiguous { + selector: String, + mode: &'static str, + candidates: Vec, + }, + Invalid { + selector: String, + mode: &'static str, + message: String, + }, +} + #[allow(dead_code)] impl RefMap { pub fn new() -> Self { @@ -65,6 +95,7 @@ impl RefMap { let window_id = self.window_id_for_backend(window.native_id); let entry = RefEntry { + ref_id: ref_id.clone(), window_id: window_id.clone(), backend_window_id: window.native_id, app_class: window.app_name.clone(), @@ -110,48 +141,205 @@ impl RefMap { window_id } - /// Resolve a selector to a RefEntry. - /// Accepts: "@w1", "w1", "ref=w1", "win1", "id=win1", 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); - - if let Some(entry) = self.refs.get(normalized) { - return Some(entry); - } - - let window_id = selector.strip_prefix("id=").unwrap_or(normalized); - if let Some(ref_id) = self.window_id_to_ref.get(window_id) { - return self.refs.get(ref_id); - } - - let lower = selector.to_lowercase(); - self.refs.values().find(|entry| { - entry.app_class.to_lowercase().contains(&lower) - || entry.title.to_lowercase().contains(&lower) - }) + pub fn resolve(&self, selector: &str) -> ResolveResult { + self.resolve_query(SelectorQuery::parse(selector), selector) } /// 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(|entry| { - ( - entry.x + entry.width as i32 / 2, - entry.y + entry.height as i32 / 2, - ) - }) + pub fn resolve_to_center(&self, selector: &str) -> ResolveResult { + self.resolve(selector) } pub fn entries(&self) -> impl Iterator { self.refs.iter() } + + fn resolve_query(&self, query: SelectorQuery, selector: &str) -> ResolveResult { + match query { + SelectorQuery::Ref(ref_id) => self + .refs + .get(&ref_id) + .cloned() + .map(ResolveResult::Match) + .unwrap_or_else(|| ResolveResult::NotFound { + selector: selector.to_string(), + mode: "ref", + }), + SelectorQuery::WindowId(window_id) => self + .window_id_to_ref + .get(&window_id) + .and_then(|ref_id| self.refs.get(ref_id)) + .cloned() + .map(ResolveResult::Match) + .unwrap_or_else(|| ResolveResult::NotFound { + selector: selector.to_string(), + mode: "id", + }), + SelectorQuery::Focused => self.resolve_candidates( + selector, + "focused", + self.refs + .values() + .filter(|entry| entry.focused) + .cloned() + .collect(), + ), + SelectorQuery::Title(title) => { + if title.is_empty() { + return ResolveResult::Invalid { + selector: selector.to_string(), + mode: "title", + message: "title selectors must not be empty".to_string(), + }; + } + self.resolve_candidates( + selector, + "title", + self.refs + .values() + .filter(|entry| entry.title.eq_ignore_ascii_case(&title)) + .cloned() + .collect(), + ) + } + SelectorQuery::Class(app_class) => { + if app_class.is_empty() { + return ResolveResult::Invalid { + selector: selector.to_string(), + mode: "class", + message: "class selectors must not be empty".to_string(), + }; + } + self.resolve_candidates( + selector, + "class", + self.refs + .values() + .filter(|entry| entry.app_class.eq_ignore_ascii_case(&app_class)) + .cloned() + .collect(), + ) + } + SelectorQuery::Fuzzy(value) => { + if let Some(entry) = self.refs.get(&value).cloned() { + return ResolveResult::Match(entry); + } + + if let Some(entry) = self + .window_id_to_ref + .get(&value) + .and_then(|ref_id| self.refs.get(ref_id)) + .cloned() + { + return ResolveResult::Match(entry); + } + + let lower = value.to_lowercase(); + self.resolve_candidates( + selector, + "fuzzy", + self.refs + .values() + .filter(|entry| { + entry.app_class.to_lowercase().contains(&lower) + || entry.title.to_lowercase().contains(&lower) + }) + .cloned() + .collect(), + ) + } + } + } + + fn resolve_candidates( + &self, + selector: &str, + mode: &'static str, + mut candidates: Vec, + ) -> ResolveResult { + candidates.sort_by(|left, right| left.ref_id.cmp(&right.ref_id)); + match candidates.len() { + 0 => ResolveResult::NotFound { + selector: selector.to_string(), + mode, + }, + 1 => ResolveResult::Match(candidates.remove(0)), + _ => ResolveResult::Ambiguous { + selector: selector.to_string(), + mode, + candidates: candidates + .into_iter() + .map(|entry| entry.to_window_info()) + .collect(), + }, + } + } +} + +impl SelectorQuery { + pub fn parse(selector: &str) -> Self { + if let Some(value) = selector.strip_prefix('@') { + return Self::Ref(value.to_string()); + } + if let Some(value) = selector.strip_prefix("ref=") { + return Self::Ref(value.to_string()); + } + if let Some(value) = selector.strip_prefix("id=") { + return Self::WindowId(value.to_string()); + } + if let Some(value) = selector.strip_prefix("title=") { + return Self::Title(value.to_string()); + } + if let Some(value) = selector.strip_prefix("class=") { + return Self::Class(value.to_string()); + } + if selector == "focused" { + return Self::Focused; + } + Self::Fuzzy(selector.to_string()) + } + + pub fn needs_live_refresh(&self) -> bool { + !matches!(self, Self::Ref(_)) + } +} + +impl RefEntry { + pub fn center(&self) -> (i32, i32) { + ( + self.x + self.width as i32 / 2, + self.y + self.height as i32 / 2, + ) + } + + pub fn to_window_info(&self) -> WindowInfo { + WindowInfo { + ref_id: self.ref_id.clone(), + window_id: self.window_id.clone(), + title: self.title.clone(), + app_name: self.app_class.clone(), + x: self.x, + y: self.y, + width: self.width, + height: self.height, + focused: self.focused, + minimized: self.minimized, + } + } +} + +impl ResolveResult { + pub fn matched_entry(&self) -> Option<&RefEntry> { + match self { + Self::Match(entry) => Some(entry), + _ => None, + } + } } #[cfg(test)] mod tests { - use super::RefMap; + use super::{RefMap, ResolveResult, SelectorQuery}; use crate::backend::BackendWindow; fn sample_window(native_id: u32, title: &str) -> BackendWindow { @@ -184,12 +372,18 @@ mod tests { let public = refs.rebuild(&[sample_window(42, "Editor")]); let window_id = public[0].window_id.clone(); - assert_eq!(refs.resolve("@w1").unwrap().window_id, window_id); - assert_eq!(refs.resolve(&window_id).unwrap().backend_window_id, 42); - assert_eq!( - refs.resolve(&format!("id={window_id}")).unwrap().title, - "Editor" - ); + match refs.resolve("@w1") { + ResolveResult::Match(entry) => assert_eq!(entry.window_id, window_id), + other => panic!("unexpected resolve result: {other:?}"), + } + match refs.resolve(&window_id) { + ResolveResult::Match(entry) => assert_eq!(entry.backend_window_id, 42), + other => panic!("unexpected resolve result: {other:?}"), + } + match refs.resolve(&format!("id={window_id}")) { + ResolveResult::Match(entry) => assert_eq!(entry.title, "Editor"), + other => panic!("unexpected resolve result: {other:?}"), + } } #[test] @@ -197,6 +391,95 @@ mod tests { let mut refs = RefMap::new(); refs.rebuild(&[sample_window(7, "Browser")]); - assert_eq!(refs.resolve_to_center("w1"), Some((160, 120))); + match refs.resolve_to_center("w1") { + ResolveResult::Match(entry) => assert_eq!(entry.center(), (160, 120)), + other => panic!("unexpected resolve result: {other:?}"), + } + } + + #[test] + fn selector_query_parses_explicit_modes() { + assert_eq!( + SelectorQuery::parse("@w1"), + SelectorQuery::Ref("w1".to_string()) + ); + assert_eq!( + SelectorQuery::parse("ref=w2"), + SelectorQuery::Ref("w2".to_string()) + ); + assert_eq!( + SelectorQuery::parse("id=win4"), + SelectorQuery::WindowId("win4".to_string()) + ); + assert_eq!( + SelectorQuery::parse("title=Firefox"), + SelectorQuery::Title("Firefox".to_string()) + ); + assert_eq!( + SelectorQuery::parse("class=Navigator"), + SelectorQuery::Class("Navigator".to_string()) + ); + assert_eq!(SelectorQuery::parse("focused"), SelectorQuery::Focused); + } + + #[test] + fn resolve_supports_exact_title_class_and_focused_modes() { + let mut refs = RefMap::new(); + refs.rebuild(&[ + sample_window(1, "Browser"), + BackendWindow { + native_id: 2, + title: "Editor".to_string(), + app_name: "Code".to_string(), + x: 0, + y: 0, + width: 10, + height: 10, + focused: false, + minimized: false, + }, + ]); + + match refs.resolve("focused") { + ResolveResult::Match(entry) => assert_eq!(entry.title, "Browser"), + other => panic!("unexpected resolve result: {other:?}"), + } + match refs.resolve("title=Editor") { + ResolveResult::Match(entry) => assert_eq!(entry.app_class, "Code"), + other => panic!("unexpected resolve result: {other:?}"), + } + match refs.resolve("class=code") { + ResolveResult::Match(entry) => assert_eq!(entry.title, "Editor"), + other => panic!("unexpected resolve result: {other:?}"), + } + } + + #[test] + fn fuzzy_resolution_fails_with_candidates_when_ambiguous() { + let mut refs = RefMap::new(); + refs.rebuild(&[ + sample_window(1, "Firefox"), + BackendWindow { + native_id: 2, + title: "Firefox Settings".to_string(), + app_name: "Firefox".to_string(), + x: 0, + y: 0, + width: 10, + height: 10, + focused: false, + minimized: false, + }, + ]); + + match refs.resolve("firefox") { + ResolveResult::Ambiguous { + mode, candidates, .. + } => { + assert_eq!(mode, "fuzzy"); + assert_eq!(candidates.len(), 2); + } + other => panic!("unexpected resolve result: {other:?}"), + } } } diff --git a/src/core/types.rs b/src/core/types.rs index 569fe37..845a4c0 100644 --- a/src/core/types.rs +++ b/src/core/types.rs @@ -8,7 +8,7 @@ pub struct Snapshot { } #[allow(dead_code)] -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct WindowInfo { pub ref_id: String, pub window_id: String, @@ -22,6 +22,47 @@ pub struct WindowInfo { pub minimized: bool, } +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MonitorInfo { + pub name: String, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub width_mm: u32, + pub height_mm: u32, + pub primary: bool, + pub automatic: bool, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScreenSize { + pub width: u32, + pub height: u32, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VersionInfo { + pub version: String, + pub backend: String, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemInfo { + pub backend: String, + pub display: Option, + pub session_type: Option, + pub session: String, + pub socket_path: String, + pub screen: ScreenSize, + pub monitor_count: usize, + pub monitors: Vec, +} + impl std::fmt::Display for WindowInfo { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let state = if self.focused { diff --git a/src/daemon/handler.rs b/src/daemon/handler.rs index 21f5e76..c0f4f1d 100644 --- a/src/daemon/handler.rs +++ b/src/daemon/handler.rs @@ -2,11 +2,13 @@ use std::sync::Arc; use anyhow::{Context, Result}; use tokio::sync::Mutex; +use tokio::time::{sleep, Duration, Instant}; use super::state::DaemonState; use crate::backend::annotate::annotate_screenshot; use crate::core::protocol::{Request, Response}; -use crate::core::types::{Snapshot, WindowInfo}; +use crate::core::refs::{ResolveResult, SelectorQuery}; +use crate::core::types::{MonitorInfo, ScreenSize, Snapshot, SystemInfo, VersionInfo, WindowInfo}; pub async fn handle_request(request: &Request, state: &Arc>) -> Response { match request.action.as_str() { @@ -27,6 +29,12 @@ pub async fn handle_request(request: &Request, state: &Arc>) "list-windows" => handle_list_windows(state).await, "get-screen-size" => handle_get_screen_size(state).await, "get-mouse-position" => handle_get_mouse_position(state).await, + "get-active-window" => handle_get_active_window(state).await, + "get-monitors" => handle_get_monitors(state).await, + "get-version" => handle_get_version(state).await, + "get-systeminfo" => handle_get_systeminfo(state).await, + "wait-window" => handle_wait(request, state, WaitKind::Window).await, + "wait-focus" => handle_wait(request, state, WaitKind::Focus).await, "screenshot" => handle_screenshot(request, state).await, "launch" => handle_launch(request, state).await, action => Response::err(format!("Unknown action: {action}")), @@ -54,6 +62,7 @@ async fn handle_click(request: &Request, state: &Arc>) -> Res }; let mut state = state.lock().await; + let selector_query = SelectorQuery::parse(&selector); if let Some((x, y)) = parse_coords(&selector) { return match state.backend.click(x, y) { @@ -62,14 +71,23 @@ async fn handle_click(request: &Request, state: &Arc>) -> Res }; } + if selector_query.needs_live_refresh() { + if let Err(error) = refresh_windows(&mut state) { + return Response::err(format!("Click failed: {error}")); + } + } + 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}})) + ResolveResult::Match(entry) => { + let (x, y) = entry.center(); + match state.backend.click(x, y) { + Ok(()) => Response::ok( + serde_json::json!({"clicked": {"x": x, "y": y, "selector": selector, "window_id": entry.window_id}}), + ), + Err(error) => Response::err(format!("Click failed: {error}")), } - Err(error) => Response::err(format!("Click failed: {error}")), - }, - None => Response::err(format!("Could not resolve selector: {selector}")), + } + outcome => selector_failure_response(outcome), } } @@ -80,6 +98,7 @@ async fn handle_dblclick(request: &Request, state: &Arc>) -> }; let mut state = state.lock().await; + let selector_query = SelectorQuery::parse(&selector); if let Some((x, y)) = parse_coords(&selector) { return match state.backend.dblclick(x, y) { @@ -88,14 +107,23 @@ async fn handle_dblclick(request: &Request, state: &Arc>) -> }; } + if selector_query.needs_live_refresh() { + if let Err(error) = refresh_windows(&mut state) { + return Response::err(format!("Double-click failed: {error}")); + } + } + match state.ref_map.resolve_to_center(&selector) { - Some((x, y)) => match state.backend.dblclick(x, y) { - Ok(()) => Response::ok( - serde_json::json!({"double_clicked": {"x": x, "y": y, "ref": selector}}), - ), - Err(error) => Response::err(format!("Double-click failed: {error}")), - }, - None => Response::err(format!("Could not resolve selector: {selector}")), + ResolveResult::Match(entry) => { + let (x, y) = entry.center(); + match state.backend.dblclick(x, y) { + Ok(()) => Response::ok( + serde_json::json!({"double_clicked": {"x": x, "y": y, "selector": selector, "window_id": entry.window_id}}), + ), + Err(error) => Response::err(format!("Double-click failed: {error}")), + } + } + outcome => selector_failure_response(outcome), } } @@ -218,9 +246,15 @@ async fn handle_window_action( }; let mut state = state.lock().await; + let selector_query = SelectorQuery::parse(&selector); + if selector_query.needs_live_refresh() { + if let Err(error) = refresh_windows(&mut state) { + return Response::err(format!("{action} failed: {error}")); + } + } let entry = match state.ref_map.resolve(&selector) { - Some(entry) => entry.clone(), - None => return Response::err(format!("Could not resolve window: {selector}")), + ResolveResult::Match(entry) => entry, + outcome => return selector_failure_response(outcome), }; let result = match action { @@ -248,9 +282,15 @@ async fn handle_move_window(request: &Request, state: &Arc>) let y = request.extra.get("y").and_then(|v| v.as_i64()).unwrap_or(0) as i32; let mut state = state.lock().await; + let selector_query = SelectorQuery::parse(&selector); + if selector_query.needs_live_refresh() { + if let Err(error) = refresh_windows(&mut state) { + return Response::err(format!("Move failed: {error}")); + } + } let entry = match state.ref_map.resolve(&selector) { - Some(entry) => entry.clone(), - None => return Response::err(format!("Could not resolve window: {selector}")), + ResolveResult::Match(entry) => entry, + outcome => return selector_failure_response(outcome), }; match state.backend.move_window(entry.backend_window_id, x, y) { @@ -281,9 +321,15 @@ async fn handle_resize_window(request: &Request, state: &Arc> .unwrap_or(600) as u32; let mut state = state.lock().await; + let selector_query = SelectorQuery::parse(&selector); + if selector_query.needs_live_refresh() { + if let Err(error) = refresh_windows(&mut state) { + return Response::err(format!("Resize failed: {error}")); + } + } let entry = match state.ref_map.resolve(&selector) { - Some(entry) => entry.clone(), - None => return Response::err(format!("Could not resolve window: {selector}")), + ResolveResult::Match(entry) => entry, + outcome => return selector_failure_response(outcome), }; match state @@ -324,6 +370,185 @@ async fn handle_get_mouse_position(state: &Arc>) -> Response } } +async fn handle_get_active_window(state: &Arc>) -> Response { + let mut state = state.lock().await; + let active_backend_window = match state.backend.active_window() { + Ok(window) => window, + Err(error) => return Response::err(format!("Failed: {error}")), + }; + + let windows = match refresh_windows(&mut state) { + Ok(windows) => windows, + Err(error) => return Response::err(format!("Failed: {error}")), + }; + + let active_window = if let Some(active_backend_window) = active_backend_window { + state + .ref_map + .entries() + .find_map(|(_, entry)| { + (entry.backend_window_id == active_backend_window.native_id) + .then(|| entry.to_window_info()) + }) + .or_else(|| windows.iter().find(|window| window.focused).cloned()) + } else { + windows.iter().find(|window| window.focused).cloned() + }; + + if let Some(window) = active_window { + Response::ok(serde_json::json!({"window": window})) + } else { + Response::err_with_data( + "No focused window is available", + serde_json::json!({"kind": "not_found", "mode": "focused"}), + ) + } +} + +async fn handle_get_monitors(state: &Arc>) -> Response { + let state = state.lock().await; + match state.backend.list_monitors() { + Ok(monitors) => { + let monitors: Vec = monitors.into_iter().map(Into::into).collect(); + Response::ok(serde_json::json!({ + "count": monitors.len(), + "monitors": monitors, + })) + } + Err(error) => Response::err(format!("Failed: {error}")), + } +} + +async fn handle_get_version(state: &Arc>) -> Response { + let state = state.lock().await; + let info = VersionInfo { + version: env!("CARGO_PKG_VERSION").to_string(), + backend: state.backend.backend_name().to_string(), + }; + Response::ok(serde_json::to_value(info).unwrap_or_default()) +} + +async fn handle_get_systeminfo(state: &Arc>) -> Response { + let state = state.lock().await; + let screen = match state.backend.screen_size() { + Ok((width, height)) => ScreenSize { width, height }, + Err(error) => return Response::err(format!("Failed: {error}")), + }; + let monitors = match state.backend.list_monitors() { + Ok(monitors) => monitors.into_iter().map(Into::into).collect::>(), + Err(error) => return Response::err(format!("Failed: {error}")), + }; + + let info = SystemInfo { + backend: state.backend.backend_name().to_string(), + display: std::env::var("DISPLAY") + .ok() + .filter(|value| !value.is_empty()), + session_type: std::env::var("XDG_SESSION_TYPE") + .ok() + .filter(|value| !value.is_empty()), + session: state.session.clone(), + socket_path: state.socket_path.display().to_string(), + screen, + monitor_count: monitors.len(), + monitors, + }; + + Response::ok(serde_json::to_value(info).unwrap_or_default()) +} + +async fn handle_wait( + request: &Request, + state: &Arc>, + wait_kind: WaitKind, +) -> Response { + let selector = match request.extra.get("selector").and_then(|v| v.as_str()) { + Some(selector) => selector.to_string(), + None => return Response::err("Missing 'selector' field"), + }; + let timeout_ms = request + .extra + .get("timeout_ms") + .and_then(|v| v.as_u64()) + .unwrap_or(10_000); + let poll_ms = request + .extra + .get("poll_ms") + .and_then(|v| v.as_u64()) + .unwrap_or(250); + + let start = Instant::now(); + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + let mut last_observation: serde_json::Value; + + loop { + let outcome = { + let mut state = state.lock().await; + if let Err(error) = refresh_windows(&mut state) { + return Response::err(format!("Wait failed: {error}")); + } + observe_wait(&state, &selector, wait_kind) + }; + + match outcome { + WaitObservation::Satisfied(window) => { + let elapsed_ms = start.elapsed().as_millis() as u64; + return Response::ok(serde_json::json!({ + "wait": wait_kind.as_str(), + "selector": selector, + "elapsed_ms": elapsed_ms, + "window": window, + })); + } + WaitObservation::Retry { observation } => { + last_observation = observation; + } + WaitObservation::Failure(response) => return response, + } + + if Instant::now() >= deadline { + return Response::err_with_data( + format!( + "Timed out waiting for {} to match selector: {}", + wait_kind.as_str(), + selector + ), + serde_json::json!({ + "kind": "timeout", + "wait": wait_kind.as_str(), + "selector": selector, + "timeout_ms": timeout_ms, + "poll_ms": poll_ms, + "last_observation": last_observation, + }), + ); + } + + sleep(Duration::from_millis(poll_ms)).await; + } +} + +#[derive(Clone, Copy)] +enum WaitKind { + Window, + Focus, +} + +impl WaitKind { + fn as_str(self) -> &'static str { + match self { + Self::Window => "window", + Self::Focus => "focus", + } + } +} + +enum WaitObservation { + Satisfied(WindowInfo), + Retry { observation: serde_json::Value }, + Failure(Response), +} + async fn handle_screenshot(request: &Request, state: &Arc>) -> Response { let annotate = request .extra @@ -387,6 +612,97 @@ fn refresh_windows(state: &mut DaemonState) -> Result> { Ok(state.ref_map.rebuild(&windows)) } +fn selector_failure_response(result: ResolveResult) -> Response { + match result { + ResolveResult::NotFound { selector, mode } => Response::err_with_data( + format!("Could not resolve selector: {selector}"), + serde_json::json!({ + "kind": "selector_not_found", + "selector": selector, + "mode": mode, + }), + ), + ResolveResult::Ambiguous { + selector, + mode, + candidates, + } => Response::err_with_data( + format!("Selector is ambiguous: {selector}"), + serde_json::json!({ + "kind": "selector_ambiguous", + "selector": selector, + "mode": mode, + "candidates": candidates, + }), + ), + ResolveResult::Invalid { + selector, + mode, + message, + } => Response::err_with_data( + format!("Invalid selector '{selector}': {message}"), + serde_json::json!({ + "kind": "selector_invalid", + "selector": selector, + "mode": mode, + "message": message, + }), + ), + ResolveResult::Match(_) => unreachable!(), + } +} + +fn observe_wait(state: &DaemonState, selector: &str, wait_kind: WaitKind) -> WaitObservation { + match state.ref_map.resolve(selector) { + ResolveResult::Match(entry) => { + let window = entry.to_window_info(); + match wait_kind { + WaitKind::Window => WaitObservation::Satisfied(window), + WaitKind::Focus if window.focused => WaitObservation::Satisfied(window), + WaitKind::Focus => WaitObservation::Retry { + observation: serde_json::json!({ + "kind": "window_not_focused", + "window": window, + }), + }, + } + } + ResolveResult::NotFound { selector, mode } => WaitObservation::Retry { + observation: serde_json::json!({ + "kind": "selector_not_found", + "selector": selector, + "mode": mode, + }), + }, + ResolveResult::Ambiguous { + selector, + mode, + candidates, + } => WaitObservation::Failure(Response::err_with_data( + format!("Selector is ambiguous: {selector}"), + serde_json::json!({ + "kind": "selector_ambiguous", + "selector": selector, + "mode": mode, + "candidates": candidates, + }), + )), + ResolveResult::Invalid { + selector, + mode, + message, + } => WaitObservation::Failure(Response::err_with_data( + format!("Invalid selector '{selector}': {message}"), + serde_json::json!({ + "kind": "selector_invalid", + "selector": selector, + "mode": mode, + "message": message, + }), + )), + } +} + fn capture_snapshot( state: &mut DaemonState, annotate: bool, @@ -438,3 +754,19 @@ fn parse_coords(value: &str) -> Option<(i32, i32)> { let y = parts[1].trim().parse().ok()?; Some((x, y)) } + +impl From for MonitorInfo { + fn from(value: crate::backend::BackendMonitor) -> Self { + Self { + name: value.name, + x: value.x, + y: value.y, + width: value.width, + height: value.height, + width_mm: value.width_mm, + height_mm: value.height_mm, + primary: value.primary, + automatic: value.automatic, + } + } +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs index d8b93a1..5c6f0be 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -21,6 +21,13 @@ pub fn env_lock() -> &'static Mutex<()> { LOCK.get_or_init(|| Mutex::new(())) } +pub fn env_lock_guard() -> std::sync::MutexGuard<'static, ()> { + match env_lock().lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + pub struct SessionEnvGuard { old_session_type: Option, } @@ -218,3 +225,7 @@ pub fn successful_json_response(output: Output) -> Result { serde_json::from_slice(&output.stdout).context("Failed to parse JSON output from deskctl") } + +pub fn json_response(output: &Output) -> Result { + serde_json::from_slice(&output.stdout).context("Failed to parse JSON output from deskctl") +} diff --git a/tests/x11_runtime.rs b/tests/x11_runtime.rs index ef09411..2aac58c 100644 --- a/tests/x11_runtime.rs +++ b/tests/x11_runtime.rs @@ -8,13 +8,13 @@ use deskctl::core::doctor; use deskctl::core::protocol::Request; use self::support::{ - deskctl_tmp_screenshot_count, env_lock, successful_json_response, FixtureWindow, - SessionEnvGuard, TestSession, + deskctl_tmp_screenshot_count, env_lock_guard, json_response, successful_json_response, + FixtureWindow, SessionEnvGuard, TestSession, }; #[test] fn doctor_reports_healthy_x11_environment() -> Result<()> { - let _guard = env_lock().lock().unwrap(); + let _guard = env_lock_guard(); let Some(_env) = SessionEnvGuard::prepare() else { eprintln!("Skipping X11 integration test because DISPLAY is not set"); return Ok(()); @@ -46,7 +46,7 @@ fn doctor_reports_healthy_x11_environment() -> Result<()> { #[test] fn list_windows_is_side_effect_free() -> Result<()> { - let _guard = env_lock().lock().unwrap(); + let _guard = env_lock_guard(); let Some(_env) = SessionEnvGuard::prepare() else { eprintln!("Skipping X11 integration test because DISPLAY is not set"); return Ok(()); @@ -84,7 +84,7 @@ fn list_windows_is_side_effect_free() -> Result<()> { #[test] fn daemon_start_recovers_from_stale_socket() -> Result<()> { - let _guard = env_lock().lock().unwrap(); + let _guard = env_lock_guard(); let Some(_env) = SessionEnvGuard::prepare() else { eprintln!("Skipping X11 integration test because DISPLAY is not set"); return Ok(()); @@ -113,3 +113,123 @@ fn daemon_start_recovers_from_stale_socket() -> Result<()> { Ok(()) } + +#[test] +fn wait_window_returns_matched_window_payload() -> Result<()> { + let _guard = env_lock_guard(); + let Some(_env) = SessionEnvGuard::prepare() else { + eprintln!("Skipping X11 integration test because DISPLAY is not set"); + return Ok(()); + }; + + let title = "deskctl wait window test"; + let _window = FixtureWindow::create(title, "DeskctlWait")?; + let session = TestSession::new("wait-window-success")?; + let response = successful_json_response(session.run_cli([ + "--json", + "wait", + "window", + "--selector", + &format!("title={title}"), + "--timeout", + "1", + "--poll-ms", + "50", + ])?)?; + + let window = response + .get("data") + .and_then(|data| data.get("window")) + .expect("wait window should return a matched window"); + assert_eq!( + window.get("title").and_then(|value| value.as_str()), + Some(title) + ); + assert_eq!( + response + .get("data") + .and_then(|data| data.get("wait")) + .and_then(|value| value.as_str()), + Some("window") + ); + + Ok(()) +} + +#[test] +fn ambiguous_fuzzy_selector_returns_candidates() -> Result<()> { + let _guard = env_lock_guard(); + let Some(_env) = SessionEnvGuard::prepare() else { + eprintln!("Skipping X11 integration test because DISPLAY is not set"); + return Ok(()); + }; + + let _window_one = FixtureWindow::create("deskctl ambiguity alpha", "DeskctlAmbiguous")?; + let _window_two = FixtureWindow::create("deskctl ambiguity beta", "DeskctlAmbiguous")?; + let session = TestSession::new("selector-ambiguity")?; + let output = session.run_cli(["--json", "focus", "ambiguity"])?; + let response = json_response(&output)?; + + assert!(!output.status.success()); + assert_eq!( + response.get("success").and_then(|value| value.as_bool()), + Some(false) + ); + assert_eq!( + response + .get("data") + .and_then(|data| data.get("kind")) + .and_then(|value| value.as_str()), + Some("selector_ambiguous") + ); + assert!(response + .get("data") + .and_then(|data| data.get("candidates")) + .and_then(|value| value.as_array()) + .map(|candidates| candidates.len() >= 2) + .unwrap_or(false)); + + Ok(()) +} + +#[test] +fn wait_focus_timeout_is_structured() -> Result<()> { + let _guard = env_lock_guard(); + let Some(_env) = SessionEnvGuard::prepare() else { + eprintln!("Skipping X11 integration test because DISPLAY is not set"); + return Ok(()); + }; + + let session = TestSession::new("wait-focus-timeout")?; + let output = session.run_cli([ + "--json", + "wait", + "focus", + "--selector", + "title=missing-window-for-wait-focus", + "--timeout", + "1", + "--poll-ms", + "50", + ])?; + let response = json_response(&output)?; + + assert!(!output.status.success()); + assert_eq!( + response + .get("data") + .and_then(|data| data.get("kind")) + .and_then(|value| value.as_str()), + Some("timeout") + ); + assert_eq!( + response + .get("data") + .and_then(|data| data.get("last_observation")) + .and_then(|value| value.get("kind")) + .and_then(|value| value.as_str()), + Some("selector_not_found") + ); + + Ok(()) +}