mirror of
https://github.com/harivansh-afk/deskctl.git
synced 2026-04-15 05:02:08 +00:00
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:
parent
cc8f8e548a
commit
a4cf9e32dd
12 changed files with 1323 additions and 77 deletions
80
README.md
80
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
fn capture_screenshot(&mut self) -> Result<RgbaImage>;
|
||||
|
||||
|
|
@ -69,4 +88,7 @@ pub trait DesktopBackend: Send {
|
|||
|
||||
/// Launch an application.
|
||||
fn launch(&self, command: &str, args: &[String]) -> Result<u32>;
|
||||
|
||||
/// Human-readable backend name for diagnostics and runtime queries.
|
||||
fn backend_name(&self) -> &'static str;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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> {
|
||||
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<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 {
|
||||
|
|
@ -231,6 +308,30 @@ impl super::DesktopBackend for X11Backend {
|
|||
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> {
|
||||
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<Key> {
|
||||
|
|
|
|||
|
|
@ -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<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)))?;
|
||||
|
||||
let json = serde_json::to_string(request)?;
|
||||
|
|
|
|||
219
src/cli/mod.rs
219
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<Request> {
|
|||
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}...")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,4 +58,12 @@ impl Response {
|
|||
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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
357
src/core/refs.rs
357
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<WindowInfo>,
|
||||
},
|
||||
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<Item = (&String, &RefEntry)> {
|
||||
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)]
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let state = if self.focused {
|
||||
|
|
|
|||
|
|
@ -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<Mutex<DaemonState>>) -> Response {
|
||||
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,
|
||||
"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<Mutex<DaemonState>>) -> 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<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) {
|
||||
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<Mutex<DaemonState>>) ->
|
|||
};
|
||||
|
||||
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<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) {
|
||||
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<Mutex<DaemonState>>)
|
|||
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<Mutex<DaemonState>>
|
|||
.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<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 {
|
||||
let annotate = request
|
||||
.extra
|
||||
|
|
@ -387,6 +612,97 @@ fn refresh_windows(state: &mut DaemonState) -> Result<Vec<WindowInfo>> {
|
|||
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<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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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")
|
||||
}
|
||||
|
||||
pub fn json_response(output: &Output) -> Result<serde_json::Value> {
|
||||
serde_json::from_slice(&output.stdout).context("Failed to parse JSON output from deskctl")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue