grouped runtime reads and waits selector modes (#5)

- grouped runtime reads and waits
selector modes
- Fix wait command client timeouts and test failures
This commit is contained in:
Hari 2026-03-25 21:11:30 -04:00 committed by GitHub
parent cc8f8e548a
commit a4cf9e32dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1323 additions and 77 deletions

View file

@ -44,14 +44,22 @@ deskctl doctor
# See the desktop # See the desktop
deskctl snapshot deskctl snapshot
# Query focused runtime state
deskctl get active-window
deskctl get monitors
# Click a window # Click a window
deskctl click @w1 deskctl click @w1
# Type text # Type text
deskctl type "hello world" deskctl type "hello world"
# Focus by name # Wait for a window or focus transition
deskctl focus "firefox" 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 ## Architecture
@ -93,6 +101,74 @@ deskctl doctor
- `--json` output includes a stable `window_id` for programmatic targeting within the current daemon session - `--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 - `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 ## Support Boundary
`deskctl` supports Linux X11 in this phase. Wayland and Hyprland are explicitly out of scope for the current runtime contract. `deskctl` supports Linux X11 in this phase. Wayland and Hyprland are explicitly out of scope for the current runtime contract.

View file

@ -11,8 +11,9 @@ Desktop control CLI for AI agents on Linux X11. Provides a unified interface for
## Core Workflow ## Core Workflow
1. **Snapshot** to see the desktop and get window refs 1. **Snapshot** to see the desktop and get window refs
2. **Act** using refs or coordinates (click, type, focus) 2. **Query / wait** using grouped `get` and `wait` commands
3. **Repeat** as needed 3. **Act** using refs, explicit selectors, or coordinates
4. **Repeat** as needed
## Quick Reference ## Quick Reference
@ -24,6 +25,12 @@ deskctl snapshot --annotate # Screenshot with bounding boxes and labels
deskctl snapshot --json # Structured JSON output deskctl snapshot --json # Structured JSON output
deskctl list-windows # Window tree without screenshot deskctl list-windows # Window tree without screenshot
deskctl screenshot /tmp/s.png # Screenshot only (no window tree) 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 ### Click and Type
@ -51,7 +58,9 @@ deskctl mouse drag 100 100 500 500 # Drag from (100,100) to (500,500)
```bash ```bash
deskctl focus @w2 # Focus window by ref 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 close @w3 # Close window gracefully
deskctl move-window @w1 100 200 # Move window to position deskctl move-window @w1 100 200 # Move window to position
deskctl resize-window @w1 800 600 # Resize window 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 - Refs reset on each `snapshot` call
- Use `--json` to see stable `window_id` values for programmatic tracking within the current daemon session - 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 ## Example Agent Workflow
```bash ```bash
# 1. See what's on screen # 1. See what's on screen
deskctl snapshot --annotate deskctl snapshot --annotate
# 2. Focus the browser # 2. Wait for the browser and focus it deterministically
deskctl focus "firefox" deskctl wait window --selector 'class=firefox' --timeout 10
deskctl focus 'class=firefox'
# 3. Navigate to a URL # 3. Navigate to a URL
deskctl hotkey ctrl l deskctl hotkey ctrl l

View file

@ -17,11 +17,30 @@ pub struct BackendWindow {
pub minimized: bool, 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)] #[allow(dead_code)]
pub trait DesktopBackend: Send { pub trait DesktopBackend: Send {
/// Collect z-ordered windows for read-only queries and targeting. /// Collect z-ordered windows for read-only queries and targeting.
fn list_windows(&mut self) -> Result<Vec<BackendWindow>>; fn list_windows(&mut self) -> Result<Vec<BackendWindow>>;
/// Get the currently focused window, if one is known.
fn active_window(&mut self) -> Result<Option<BackendWindow>>;
/// Collect monitor geometry and metadata.
fn list_monitors(&self) -> Result<Vec<BackendMonitor>>;
/// Capture the current desktop image without writing it to disk. /// Capture the current desktop image without writing it to disk.
fn capture_screenshot(&mut self) -> Result<RgbaImage>; fn capture_screenshot(&mut self) -> Result<RgbaImage>;
@ -69,4 +88,7 @@ pub trait DesktopBackend: Send {
/// Launch an application. /// Launch an application.
fn launch(&self, command: &str, args: &[String]) -> Result<u32>; fn launch(&self, command: &str, args: &[String]) -> Result<u32>;
/// Human-readable backend name for diagnostics and runtime queries.
fn backend_name(&self) -> &'static str;
} }

View file

@ -2,6 +2,7 @@ use anyhow::{Context, Result};
use enigo::{Axis, Button, Coordinate, Direction, Enigo, Key, Keyboard, Mouse, Settings}; use enigo::{Axis, Button, Coordinate, Direction, Enigo, Key, Keyboard, Mouse, Settings};
use image::RgbaImage; use image::RgbaImage;
use x11rb::connection::Connection; use x11rb::connection::Connection;
use x11rb::protocol::randr::ConnectionExt as RandrConnectionExt;
use x11rb::protocol::xproto::{ use x11rb::protocol::xproto::{
Atom, AtomEnum, ClientMessageData, ClientMessageEvent, ConfigureWindowAux, Atom, AtomEnum, ClientMessageData, ClientMessageEvent, ConfigureWindowAux,
ConnectionExt as XprotoConnectionExt, EventMask, GetPropertyReply, ImageFormat, ImageOrder, ConnectionExt as XprotoConnectionExt, EventMask, GetPropertyReply, ImageFormat, ImageOrder,
@ -9,7 +10,7 @@ use x11rb::protocol::xproto::{
}; };
use x11rb::rust_connection::RustConnection; use x11rb::rust_connection::RustConnection;
use crate::backend::BackendWindow; use crate::backend::{BackendMonitor, BackendWindow};
struct Atoms { struct Atoms {
client_list_stacking: Atom, client_list_stacking: Atom,
@ -103,6 +104,74 @@ impl X11Backend {
Ok(window_infos) Ok(window_infos)
} }
fn active_window_info(&self) -> Result<Option<BackendWindow>> {
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<Vec<BackendMonitor>> {
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<RgbaImage> { fn capture_root_image(&self) -> Result<RgbaImage> {
let (width, height) = self.root_geometry()?; let (width, height) = self.root_geometry()?;
let reply = self let reply = self
@ -224,6 +293,14 @@ impl X11Backend {
.reply() .reply()
.with_context(|| format!("Failed to read property {property} from window {window}")) .with_context(|| format!("Failed to read property {property} from window {window}"))
} }
fn atom_name(&self, atom: Atom) -> Result<String> {
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 { impl super::DesktopBackend for X11Backend {
@ -231,6 +308,30 @@ impl super::DesktopBackend for X11Backend {
self.collect_window_infos() self.collect_window_infos()
} }
fn active_window(&mut self) -> Result<Option<BackendWindow>> {
self.active_window_info()
}
fn list_monitors(&self) -> Result<Vec<BackendMonitor>> {
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<RgbaImage> { fn capture_screenshot(&mut self) -> Result<RgbaImage> {
self.capture_root_image() self.capture_root_image()
} }
@ -452,6 +553,10 @@ impl super::DesktopBackend for X11Backend {
.with_context(|| format!("Failed to launch: {command}"))?; .with_context(|| format!("Failed to launch: {command}"))?;
Ok(child.id()) Ok(child.id())
} }
fn backend_name(&self) -> &'static str {
"x11"
}
} }
fn parse_key(name: &str) -> Result<Key> { fn parse_key(name: &str) -> Result<Key> {

View file

@ -79,8 +79,23 @@ fn spawn_daemon(opts: &GlobalOpts) -> Result<()> {
Ok(()) 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<Response> { fn send_request_over_stream(mut stream: UnixStream, request: &Request) -> Result<Response> {
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)))?; stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let json = serde_json::to_string(request)?; let json = serde_json::to_string(request)?;

View file

@ -102,6 +102,12 @@ pub enum Command {
GetMousePosition, GetMousePosition,
/// Diagnose X11 runtime, screenshot, and daemon health /// Diagnose X11 runtime, screenshot, and daemon health
Doctor, Doctor,
/// Query runtime state
#[command(subcommand)]
Get(GetCmd),
/// Wait for runtime state transitions
#[command(subcommand)]
Wait(WaitCmd),
/// Take a screenshot without window tree /// Take a screenshot without window tree
Screenshot { Screenshot {
/// Save path (default: /tmp/deskctl-{timestamp}.png) /// Save path (default: /tmp/deskctl-{timestamp}.png)
@ -169,6 +175,57 @@ pub enum DaemonAction {
Status, 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<()> { pub fn run() -> Result<()> {
let app = App::parse(); let app = App::parse();
@ -188,9 +245,13 @@ pub fn run() -> Result<()> {
// All other commands need a daemon connection // All other commands need a daemon connection
let request = build_request(&app.command)?; let request = build_request(&app.command)?;
let response = connection::send_command(&app.global, &request)?; let response = connection::send_command(&app.global, &request)?;
let success = response.success;
if app.global.json { if app.global.json {
println!("{}", serde_json::to_string_pretty(&response)?); println!("{}", serde_json::to_string_pretty(&response)?);
if !success {
std::process::exit(1);
}
} else { } else {
print_response(&app.command, &response)?; print_response(&app.command, &response)?;
} }
@ -244,6 +305,22 @@ fn build_request(cmd: &Command) -> Result<Request> {
Command::GetScreenSize => Request::new("get-screen-size"), Command::GetScreenSize => Request::new("get-screen-size"),
Command::GetMousePosition => Request::new("get-mouse-position"), Command::GetMousePosition => Request::new("get-mouse-position"),
Command::Doctor => unreachable!(), 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 } => { Command::Screenshot { path, annotate } => {
let mut req = Request::new("screenshot").with_extra("annotate", json!(annotate)); let mut req = Request::new("screenshot").with_extra("annotate", json!(annotate));
if let Some(p) = path { if let Some(p) = path {
@ -264,6 +341,32 @@ fn print_response(cmd: &Command, response: &Response) -> Result<()> {
if let Some(ref err) = response.error { if let Some(ref err) = response.error {
eprintln!("Error: {err}"); 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); std::process::exit(1);
} }
if let Some(ref data) = response.data { if let Some(ref data) = response.data {
@ -293,17 +396,61 @@ fn print_response(cmd: &Command, response: &Response) -> Result<()> {
} else { } else {
"visible" "visible"
}; };
let display_title = if title.len() > 30 { let display_title = truncate_display(title, 30);
format!("{}...", &title[..27])
} else {
title.to_string()
};
println!( println!(
"@{:<4} {:<30} ({:<7}) {},{} {}x{}", "@{:<4} {:<30} ({:<7}) {},{} {}x{}",
ref_id, display_title, state, x, y, width, height 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 { } else {
// Generic: print JSON data // Generic: print JSON data
println!("{}", serde_json::to_string_pretty(data)?); println!("{}", serde_json::to_string_pretty(data)?);
@ -311,3 +458,65 @@ fn print_response(cmd: &Command, response: &Response) -> Result<()> {
} }
Ok(()) 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}...")
}

View file

@ -58,4 +58,12 @@ impl Response {
error: Some(msg.into()), error: Some(msg.into()),
} }
} }
pub fn err_with_data(msg: impl Into<String>, data: Value) -> Self {
Self {
success: false,
data: Some(data),
error: Some(msg.into()),
}
}
} }

View file

@ -7,6 +7,7 @@ use crate::core::types::WindowInfo;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct RefEntry { pub struct RefEntry {
pub ref_id: String,
pub window_id: String, pub window_id: String,
pub backend_window_id: u32, pub backend_window_id: u32,
pub app_class: String, pub app_class: String,
@ -30,6 +31,35 @@ pub struct RefMap {
next_window: usize, 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<WindowInfo>,
},
Invalid {
selector: String,
mode: &'static str,
message: String,
},
}
#[allow(dead_code)] #[allow(dead_code)]
impl RefMap { impl RefMap {
pub fn new() -> Self { pub fn new() -> Self {
@ -65,6 +95,7 @@ impl RefMap {
let window_id = self.window_id_for_backend(window.native_id); let window_id = self.window_id_for_backend(window.native_id);
let entry = RefEntry { let entry = RefEntry {
ref_id: ref_id.clone(),
window_id: window_id.clone(), window_id: window_id.clone(),
backend_window_id: window.native_id, backend_window_id: window.native_id,
app_class: window.app_name.clone(), app_class: window.app_name.clone(),
@ -110,48 +141,205 @@ impl RefMap {
window_id window_id
} }
/// Resolve a selector to a RefEntry. pub fn resolve(&self, selector: &str) -> ResolveResult {
/// Accepts: "@w1", "w1", "ref=w1", "win1", "id=win1", or a substring match on app_class/title. self.resolve_query(SelectorQuery::parse(selector), selector)
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)
})
} }
/// Resolve a selector to the center coordinates of the window. /// Resolve a selector to the center coordinates of the window.
pub fn resolve_to_center(&self, selector: &str) -> Option<(i32, i32)> { pub fn resolve_to_center(&self, selector: &str) -> ResolveResult {
self.resolve(selector).map(|entry| { self.resolve(selector)
(
entry.x + entry.width as i32 / 2,
entry.y + entry.height as i32 / 2,
)
})
} }
pub fn entries(&self) -> impl Iterator<Item = (&String, &RefEntry)> { pub fn entries(&self) -> impl Iterator<Item = (&String, &RefEntry)> {
self.refs.iter() 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<RefEntry>,
) -> 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)] #[cfg(test)]
mod tests { mod tests {
use super::RefMap; use super::{RefMap, ResolveResult, SelectorQuery};
use crate::backend::BackendWindow; use crate::backend::BackendWindow;
fn sample_window(native_id: u32, title: &str) -> 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 public = refs.rebuild(&[sample_window(42, "Editor")]);
let window_id = public[0].window_id.clone(); let window_id = public[0].window_id.clone();
assert_eq!(refs.resolve("@w1").unwrap().window_id, window_id); match refs.resolve("@w1") {
assert_eq!(refs.resolve(&window_id).unwrap().backend_window_id, 42); ResolveResult::Match(entry) => assert_eq!(entry.window_id, window_id),
assert_eq!( other => panic!("unexpected resolve result: {other:?}"),
refs.resolve(&format!("id={window_id}")).unwrap().title, }
"Editor" 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] #[test]
@ -197,6 +391,95 @@ mod tests {
let mut refs = RefMap::new(); let mut refs = RefMap::new();
refs.rebuild(&[sample_window(7, "Browser")]); 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:?}"),
}
} }
} }

View file

@ -8,7 +8,7 @@ pub struct Snapshot {
} }
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindowInfo { pub struct WindowInfo {
pub ref_id: String, pub ref_id: String,
pub window_id: String, pub window_id: String,
@ -22,6 +22,47 @@ pub struct WindowInfo {
pub minimized: bool, 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<String>,
pub session_type: Option<String>,
pub session: String,
pub socket_path: String,
pub screen: ScreenSize,
pub monitor_count: usize,
pub monitors: Vec<MonitorInfo>,
}
impl std::fmt::Display for WindowInfo { impl std::fmt::Display for WindowInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let state = if self.focused { let state = if self.focused {

View file

@ -2,11 +2,13 @@ use std::sync::Arc;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::time::{sleep, Duration, Instant};
use super::state::DaemonState; use super::state::DaemonState;
use crate::backend::annotate::annotate_screenshot; use crate::backend::annotate::annotate_screenshot;
use crate::core::protocol::{Request, Response}; 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<Mutex<DaemonState>>) -> Response { pub async fn handle_request(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
match request.action.as_str() { match request.action.as_str() {
@ -27,6 +29,12 @@ pub async fn handle_request(request: &Request, state: &Arc<Mutex<DaemonState>>)
"list-windows" => handle_list_windows(state).await, "list-windows" => handle_list_windows(state).await,
"get-screen-size" => handle_get_screen_size(state).await, "get-screen-size" => handle_get_screen_size(state).await,
"get-mouse-position" => handle_get_mouse_position(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, "screenshot" => handle_screenshot(request, state).await,
"launch" => handle_launch(request, state).await, "launch" => handle_launch(request, state).await,
action => Response::err(format!("Unknown action: {action}")), action => Response::err(format!("Unknown action: {action}")),
@ -54,6 +62,7 @@ async fn handle_click(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Res
}; };
let mut state = state.lock().await; let mut state = state.lock().await;
let selector_query = SelectorQuery::parse(&selector);
if let Some((x, y)) = parse_coords(&selector) { if let Some((x, y)) = parse_coords(&selector) {
return match state.backend.click(x, y) { return match state.backend.click(x, y) {
@ -62,14 +71,23 @@ async fn handle_click(request: &Request, state: &Arc<Mutex<DaemonState>>) -> 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) { match state.ref_map.resolve_to_center(&selector) {
Some((x, y)) => match state.backend.click(x, y) { ResolveResult::Match(entry) => {
Ok(()) => { let (x, y) = entry.center();
Response::ok(serde_json::json!({"clicked": {"x": x, "y": y, "ref": selector}})) 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}")), }
}, outcome => selector_failure_response(outcome),
None => Response::err(format!("Could not resolve selector: {selector}")),
} }
} }
@ -80,6 +98,7 @@ async fn handle_dblclick(request: &Request, state: &Arc<Mutex<DaemonState>>) ->
}; };
let mut state = state.lock().await; let mut state = state.lock().await;
let selector_query = SelectorQuery::parse(&selector);
if let Some((x, y)) = parse_coords(&selector) { if let Some((x, y)) = parse_coords(&selector) {
return match state.backend.dblclick(x, y) { return match state.backend.dblclick(x, y) {
@ -88,14 +107,23 @@ async fn handle_dblclick(request: &Request, state: &Arc<Mutex<DaemonState>>) ->
}; };
} }
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) { match state.ref_map.resolve_to_center(&selector) {
Some((x, y)) => match state.backend.dblclick(x, y) { ResolveResult::Match(entry) => {
Ok(()) => Response::ok( let (x, y) = entry.center();
serde_json::json!({"double_clicked": {"x": x, "y": y, "ref": selector}}), match state.backend.dblclick(x, y) {
), Ok(()) => Response::ok(
Err(error) => Response::err(format!("Double-click failed: {error}")), serde_json::json!({"double_clicked": {"x": x, "y": y, "selector": selector, "window_id": entry.window_id}}),
}, ),
None => Response::err(format!("Could not resolve selector: {selector}")), 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 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) { let entry = match state.ref_map.resolve(&selector) {
Some(entry) => entry.clone(), ResolveResult::Match(entry) => entry,
None => return Response::err(format!("Could not resolve window: {selector}")), outcome => return selector_failure_response(outcome),
}; };
let result = match action { let result = match action {
@ -248,9 +282,15 @@ async fn handle_move_window(request: &Request, state: &Arc<Mutex<DaemonState>>)
let y = request.extra.get("y").and_then(|v| v.as_i64()).unwrap_or(0) as i32; let y = request.extra.get("y").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
let mut state = state.lock().await; 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) { let entry = match state.ref_map.resolve(&selector) {
Some(entry) => entry.clone(), ResolveResult::Match(entry) => entry,
None => return Response::err(format!("Could not resolve window: {selector}")), outcome => return selector_failure_response(outcome),
}; };
match state.backend.move_window(entry.backend_window_id, x, y) { match state.backend.move_window(entry.backend_window_id, x, y) {
@ -281,9 +321,15 @@ async fn handle_resize_window(request: &Request, state: &Arc<Mutex<DaemonState>>
.unwrap_or(600) as u32; .unwrap_or(600) as u32;
let mut state = state.lock().await; 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) { let entry = match state.ref_map.resolve(&selector) {
Some(entry) => entry.clone(), ResolveResult::Match(entry) => entry,
None => return Response::err(format!("Could not resolve window: {selector}")), outcome => return selector_failure_response(outcome),
}; };
match state match state
@ -324,6 +370,185 @@ async fn handle_get_mouse_position(state: &Arc<Mutex<DaemonState>>) -> Response
} }
} }
async fn handle_get_active_window(state: &Arc<Mutex<DaemonState>>) -> 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<Mutex<DaemonState>>) -> Response {
let state = state.lock().await;
match state.backend.list_monitors() {
Ok(monitors) => {
let monitors: Vec<MonitorInfo> = 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<Mutex<DaemonState>>) -> 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<Mutex<DaemonState>>) -> 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::<Vec<_>>(),
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<Mutex<DaemonState>>,
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<Mutex<DaemonState>>) -> Response { async fn handle_screenshot(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
let annotate = request let annotate = request
.extra .extra
@ -387,6 +612,97 @@ fn refresh_windows(state: &mut DaemonState) -> Result<Vec<WindowInfo>> {
Ok(state.ref_map.rebuild(&windows)) 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( fn capture_snapshot(
state: &mut DaemonState, state: &mut DaemonState,
annotate: bool, annotate: bool,
@ -438,3 +754,19 @@ fn parse_coords(value: &str) -> Option<(i32, i32)> {
let y = parts[1].trim().parse().ok()?; let y = parts[1].trim().parse().ok()?;
Some((x, y)) Some((x, y))
} }
impl From<crate::backend::BackendMonitor> 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,
}
}
}

View file

@ -21,6 +21,13 @@ pub fn env_lock() -> &'static Mutex<()> {
LOCK.get_or_init(|| Mutex::new(())) 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 { pub struct SessionEnvGuard {
old_session_type: Option<String>, old_session_type: Option<String>,
} }
@ -218,3 +225,7 @@ pub fn successful_json_response(output: Output) -> Result<serde_json::Value> {
serde_json::from_slice(&output.stdout).context("Failed to parse JSON output from deskctl") serde_json::from_slice(&output.stdout).context("Failed to parse JSON output from deskctl")
} }
pub fn json_response(output: &Output) -> Result<serde_json::Value> {
serde_json::from_slice(&output.stdout).context("Failed to parse JSON output from deskctl")
}

View file

@ -8,13 +8,13 @@ use deskctl::core::doctor;
use deskctl::core::protocol::Request; use deskctl::core::protocol::Request;
use self::support::{ use self::support::{
deskctl_tmp_screenshot_count, env_lock, successful_json_response, FixtureWindow, deskctl_tmp_screenshot_count, env_lock_guard, json_response, successful_json_response,
SessionEnvGuard, TestSession, FixtureWindow, SessionEnvGuard, TestSession,
}; };
#[test] #[test]
fn doctor_reports_healthy_x11_environment() -> Result<()> { fn doctor_reports_healthy_x11_environment() -> Result<()> {
let _guard = env_lock().lock().unwrap(); let _guard = env_lock_guard();
let Some(_env) = SessionEnvGuard::prepare() else { let Some(_env) = SessionEnvGuard::prepare() else {
eprintln!("Skipping X11 integration test because DISPLAY is not set"); eprintln!("Skipping X11 integration test because DISPLAY is not set");
return Ok(()); return Ok(());
@ -46,7 +46,7 @@ fn doctor_reports_healthy_x11_environment() -> Result<()> {
#[test] #[test]
fn list_windows_is_side_effect_free() -> Result<()> { 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 { let Some(_env) = SessionEnvGuard::prepare() else {
eprintln!("Skipping X11 integration test because DISPLAY is not set"); eprintln!("Skipping X11 integration test because DISPLAY is not set");
return Ok(()); return Ok(());
@ -84,7 +84,7 @@ fn list_windows_is_side_effect_free() -> Result<()> {
#[test] #[test]
fn daemon_start_recovers_from_stale_socket() -> Result<()> { 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 { let Some(_env) = SessionEnvGuard::prepare() else {
eprintln!("Skipping X11 integration test because DISPLAY is not set"); eprintln!("Skipping X11 integration test because DISPLAY is not set");
return Ok(()); return Ok(());
@ -113,3 +113,123 @@ fn daemon_start_recovers_from_stale_socket() -> Result<()> {
Ok(()) 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(())
}