mirror of
https://github.com/harivansh-afk/deskctl.git
synced 2026-04-15 06:04:41 +00:00
stabilize (#3)
* specs * Stabilize deskctl runtime foundation Co-authored-by: Codex <noreply@openai.com> * opsx archive --------- Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
d487a60209
commit
6dce22eaef
22 changed files with 1289 additions and 295 deletions
19
README.md
19
README.md
|
|
@ -38,6 +38,9 @@ At the moment there are no extra native build dependencies beyond a Rust toolcha
|
|||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Diagnose the environment first
|
||||
deskctl doctor
|
||||
|
||||
# See the desktop
|
||||
deskctl snapshot
|
||||
|
||||
|
|
@ -70,9 +73,21 @@ For deskctl to be fully functional on a fresh VM you still need:
|
|||
- a window manager or desktop environment that exposes standard EWMH properties such as `_NET_CLIENT_LIST_STACKING` and `_NET_ACTIVE_WINDOW`
|
||||
- an X server with the extensions needed for input simulation and screen metadata, which is standard on normal desktop X11 setups
|
||||
|
||||
## Wayland Support
|
||||
If setup fails, run:
|
||||
|
||||
Coming soon. The trait-based backend design means adding Hyprland/Wayland support is a single trait implementation with zero refactoring of the core which is good.
|
||||
```bash
|
||||
deskctl doctor
|
||||
```
|
||||
|
||||
## Contract Notes
|
||||
|
||||
- `@wN` refs are short-lived handles assigned by `snapshot` and `list-windows`
|
||||
- `--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
|
||||
|
||||
## Support Boundary
|
||||
|
||||
`deskctl` supports Linux X11 in this phase. Wayland and Hyprland are explicitly out of scope for the current runtime contract.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
|
|
|
|||
3
SKILL.md
3
SKILL.md
|
|
@ -60,6 +60,7 @@ deskctl resize-window @w1 800 600 # Resize window
|
|||
### Utilities
|
||||
|
||||
```bash
|
||||
deskctl doctor # Diagnose X11, screenshot, and daemon health
|
||||
deskctl get-screen-size # Screen resolution
|
||||
deskctl get-mouse-position # Current cursor position
|
||||
deskctl launch firefox # Launch an application
|
||||
|
|
@ -86,7 +87,7 @@ After `snapshot` or `list-windows`, windows are assigned short refs:
|
|||
- `@w1` is the topmost (usually focused) window
|
||||
- `@w2`, `@w3`, etc. follow z-order (front to back)
|
||||
- Refs reset on each `snapshot` call
|
||||
- Use `--json` to see stable `xcb_id` for programmatic tracking
|
||||
- Use `--json` to see stable `window_id` values for programmatic tracking within the current daemon session
|
||||
|
||||
## Example Agent Workflow
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
## Context
|
||||
|
||||
`deskctl` already exposes a useful X11 runtime for screenshots, input, and window management, but the current implementation mixes together concerns that need to be separated before the repo can become a stable primitive. Public window data still exposes `xcb_id`, `list-windows` is routed through screenshot-producing code, setup failures are opaque, and daemon startup behavior is not yet explicit enough for reliable reuse by higher-level tooling.
|
||||
|
||||
Phase 1 is the foundation tranche. It should make the runtime contract clean and cheap to consume without expanding into packaging, skills, or non-X11 backends.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Define a backend-neutral public window identity and selector contract.
|
||||
- Make read-only window enumeration cheap and side-effect free.
|
||||
- Add a first-run diagnostics command that explains broken environments precisely.
|
||||
- Harden daemon startup and health behavior enough for predictable CLI use.
|
||||
- Keep the Phase 1 scope implementable in one focused change.
|
||||
|
||||
**Non-Goals:**
|
||||
- Wayland support or any additional backend implementation.
|
||||
- npm distribution, crates.io publishing, or release automation changes.
|
||||
- Broad new read surface such as monitors, workspaces, clipboard, or batching.
|
||||
- Agent skills, config files, or policy/confirmation features.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Public window identity becomes `window_id`, not `xcb_id`
|
||||
|
||||
The stable contract will expose an opaque `window_id` for programmatic targeting. Backend-specific handles such as X11 window IDs stay internal to daemon/backend state.
|
||||
|
||||
Rationale:
|
||||
- This removes X11 leakage from the public interface.
|
||||
- It keeps the future backend boundary open without promising a Wayland implementation now.
|
||||
- It makes selector behavior explicit: users and agents target `@wN`, window names, or `window_id`, not backend handles.
|
||||
|
||||
Alternatives considered:
|
||||
- Keep exposing `xcb_id` and add `window_id` alongside it. Rejected because it cements the wrong contract and encourages downstream dependence on X11 internals.
|
||||
- Hide programmatic identity entirely and rely only on refs. Rejected because refs are intentionally ephemeral and not sufficient for durable automation.
|
||||
|
||||
### 2. Window enumeration and screenshot capture become separate backend operations
|
||||
|
||||
The backend API will separate:
|
||||
- window listing / state collection
|
||||
- screenshot capture
|
||||
- composed snapshot behavior
|
||||
|
||||
`list-windows` will call the cheap enumeration path directly. `snapshot` remains the convenience command that combines enumeration plus screenshot generation.
|
||||
|
||||
Rationale:
|
||||
- Read-only commands must not capture screenshots or write `/tmp` files.
|
||||
- This reduces latency and unintended side effects.
|
||||
- It clarifies which operations are safe to call frequently in agent loops.
|
||||
|
||||
Alternatives considered:
|
||||
- Keep the current `snapshot(false)` path and optimize it internally. Rejected because the coupling itself is the product problem; the API shape needs to reflect the intended behavior.
|
||||
|
||||
### 3. `deskctl doctor` runs without requiring a healthy daemon
|
||||
|
||||
`doctor` will be implemented as a CLI command that can run before daemon startup. It will probe environment prerequisites directly and optionally inspect daemon/socket state as one of the checks.
|
||||
|
||||
Expected checks:
|
||||
- `DISPLAY` present
|
||||
- X11 session expectation (`XDG_SESSION_TYPE=x11` or explicit note if missing)
|
||||
- X server connectivity
|
||||
- required extensions or equivalent runtime capabilities used by the backend
|
||||
- socket directory existence and permissions
|
||||
- basic window enumeration
|
||||
- screenshot capture viability
|
||||
|
||||
Rationale:
|
||||
- Diagnostics must work when daemon startup is the thing that is broken.
|
||||
- The command should return actionable failure messages, not “failed to connect to daemon.”
|
||||
|
||||
Alternatives considered:
|
||||
- Implement `doctor` as a normal daemon request. Rejected because it hides the startup path and cannot diagnose stale socket or spawn failures well.
|
||||
|
||||
### 4. Daemon hardening stays minimal and focused in this phase
|
||||
|
||||
Phase 1 daemon work will cover:
|
||||
- stale socket detection and cleanup on startup/connect
|
||||
- clearer startup/connect failure messages
|
||||
- a lightweight health-check request or equivalent status probe used by the client path
|
||||
|
||||
Rationale:
|
||||
- This is enough to make CLI behavior predictable without turning the spec into a full daemon observability project.
|
||||
|
||||
Alternatives considered:
|
||||
- Include idle timeout, structured logging, and full lifecycle policy now. Rejected because those are useful but not necessary to stabilize the basic runtime contract.
|
||||
|
||||
### 5. X11 remains the explicit support boundary for this change
|
||||
|
||||
The spec and implementation will define expected behavior for X11 environments only. Unsupported-session diagnostics are in scope; a second backend is not.
|
||||
|
||||
Rationale:
|
||||
- The repo needs a stable foundation more than a nominally portable abstraction.
|
||||
- Clear support boundaries are better than implying near-term Wayland support.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Breaking JSON contract for existing users] → Treat this as a deliberate pre-1.0 stabilization change, update docs/examples, and keep the new shape simple.
|
||||
- [More internal mapping state between public IDs and backend handles] → Keep one canonical mapping in daemon state and use it for selector resolution.
|
||||
- [Separate read and screenshot paths could drift] → Share the same window collection logic underneath both operations.
|
||||
- [`doctor` checks may be environment-specific] → Keep the checks narrow, tied to actual backend requirements, and report concrete pass/fail reasons.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Introduce the new public `window_id` contract and update selector resolution to accept it.
|
||||
2. Refactor backend and daemon code so `list-windows` uses a pure read path.
|
||||
3. Add `deskctl doctor` and wire it into the CLI as a daemon-independent command.
|
||||
4. Add unit and integration coverage for the new contract and cheap read behavior.
|
||||
5. Update user-facing docs and examples after implementation so the new output shape is canonical.
|
||||
|
||||
Rollback strategy:
|
||||
- This is a pre-1.0 contract cleanup, so rollback is a normal code revert rather than a compatibility shim plan.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Whether the health check should be an explicit public `daemon ping` action or an internal client/daemon probe.
|
||||
- Whether `doctor` should expose machine-readable JSON on day one or land text output first and add JSON immediately afterward in the same tranche if implementation cost is low.
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
## Why
|
||||
|
||||
`deskctl` already works as a useful X11 desktop control CLI, but the current contract is not stable enough to build packaging, skills, or broader agent workflows on top of it yet. Public output still leaks X11-specific identifiers, some read-only commands still perform screenshot capture and write files, setup failures are not self-diagnosing, and daemon lifecycle behavior needs to be more predictable before the repo is treated as a reliable primitive.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Stabilize the public desktop runtime contract around backend-neutral window identity and explicit selector semantics.
|
||||
- Separate cheap read paths from screenshot-producing paths so read-only commands do not capture or write screenshots unless explicitly requested.
|
||||
- Add a first-run `deskctl doctor` command that verifies X11 runtime prerequisites and reports exact remediation steps.
|
||||
- Harden daemon startup and health behavior enough for reliable reuse from CLI commands and future higher-level tooling.
|
||||
- Document the Phase 1 support boundary: X11 is the supported runtime today; Wayland is out of scope for this change.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `desktop-runtime`: Stable Phase 1 desktop runtime behavior covering public window identity, cheap read commands, runtime diagnostics, and foundational daemon health behavior.
|
||||
|
||||
### Modified Capabilities
|
||||
- None.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected CLI surface in `src/cli/`
|
||||
- Affected daemon request handling and state in `src/daemon/`
|
||||
- Affected backend contract and X11 implementation in `src/backend/`
|
||||
- Affected shared protocol and types in `src/core/`
|
||||
- New tests for unit behavior and X11 integration coverage
|
||||
- Follow-on docs updates for usage and troubleshooting once implementation lands
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Backend-neutral window identity
|
||||
The desktop runtime SHALL expose a backend-neutral `window_id` for each enumerated window. Public runtime responses MUST NOT require backend-specific handles such as X11 window IDs for targeting or automation.
|
||||
|
||||
#### Scenario: Listing windows returns stable public identity
|
||||
- **WHEN** a client lists windows through the runtime
|
||||
- **THEN** each window result includes a `window_id` that the client can use for later targeting
|
||||
- **AND** the result does not require the client to know the underlying X11 handle format
|
||||
|
||||
#### Scenario: Selector resolution accepts public identity
|
||||
- **WHEN** a client targets a window by `window_id`
|
||||
- **THEN** the runtime resolves the request to the correct live window
|
||||
- **AND** performs the requested action without exposing backend handles in the public contract
|
||||
|
||||
### Requirement: Read-only window enumeration is side-effect free
|
||||
The desktop runtime SHALL provide a read-only window enumeration path that does not capture screenshots and does not write screenshot artifacts unless a screenshot-producing command was explicitly requested.
|
||||
|
||||
#### Scenario: Listing windows does not write a screenshot
|
||||
- **WHEN** a client runs a read-only window listing command
|
||||
- **THEN** the runtime returns current window data
|
||||
- **AND** does not capture a screenshot
|
||||
- **AND** does not create a screenshot file as a side effect
|
||||
|
||||
#### Scenario: Snapshot remains an explicit screenshot-producing command
|
||||
- **WHEN** a client runs a snapshot command
|
||||
- **THEN** the runtime returns window data together with screenshot output
|
||||
- **AND** any screenshot artifact is created only because the snapshot command explicitly requested it
|
||||
|
||||
### Requirement: Runtime diagnostics are first-class
|
||||
The CLI SHALL provide a `doctor` command that checks runtime prerequisites for the supported X11 environment and reports actionable remediation guidance for each failed check.
|
||||
|
||||
#### Scenario: Doctor reports missing display configuration
|
||||
- **WHEN** `deskctl doctor` runs without a usable `DISPLAY`
|
||||
- **THEN** it reports that the X11 display is unavailable
|
||||
- **AND** includes a concrete remediation message describing what environment setup is required
|
||||
|
||||
#### Scenario: Doctor verifies basic runtime operations
|
||||
- **WHEN** `deskctl doctor` runs in a healthy supported environment
|
||||
- **THEN** it verifies X11 connectivity, basic window enumeration, screenshot viability, and socket path health
|
||||
- **AND** reports a successful diagnostic result for each check
|
||||
|
||||
### Requirement: Daemon startup failures are recoverable and diagnosable
|
||||
The runtime SHALL detect stale daemon socket state and surface actionable startup or connection errors instead of failing with ambiguous transport errors.
|
||||
|
||||
#### Scenario: Client encounters a stale socket
|
||||
- **WHEN** the client finds a socket path whose daemon is no longer serving requests
|
||||
- **THEN** the runtime removes or replaces the stale socket state safely
|
||||
- **AND** proceeds with a healthy daemon startup or reports a specific failure if recovery does not succeed
|
||||
|
||||
#### Scenario: Health probing distinguishes startup failure from runtime failure
|
||||
- **WHEN** a client attempts to use the runtime and the daemon cannot become healthy
|
||||
- **THEN** the returned error explains whether the failure occurred during spawn, health probing, or request handling
|
||||
- **AND** does not report the problem as a generic connection failure alone
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
## 1. Contract and protocol stabilization
|
||||
|
||||
- [x] 1.1 Define the public `window_id` contract in shared types/protocol code and remove backend-handle assumptions from public runtime responses
|
||||
- [x] 1.2 Update daemon state and selector resolution to map `window_id` and refs to internal backend handles without exposing X11-specific IDs publicly
|
||||
- [x] 1.3 Update CLI text and JSON response handling to use the new public identity consistently
|
||||
|
||||
## 2. Cheap reads and diagnostics
|
||||
|
||||
- [x] 2.1 Split backend window enumeration from screenshot capture and route `list-windows` through a read-only path with no screenshot side effects
|
||||
- [x] 2.2 Add a daemon-independent `deskctl doctor` command that probes X11 environment setup, socket health, window enumeration, and screenshot viability
|
||||
- [x] 2.3 Harden daemon startup and reconnect behavior with stale socket cleanup, health probing, and clearer failure messages
|
||||
|
||||
## 3. Validation and follow-through
|
||||
|
||||
- [x] 3.1 Add unit tests for selector parsing, public ID resolution, and read-only behavior
|
||||
- [x] 3.2 Add X11 integration coverage for `doctor`, `list-windows`, and daemon recovery behavior
|
||||
- [x] 3.3 Update user-facing docs and examples to reflect the new contract, `doctor`, and the explicit X11 support boundary
|
||||
54
openspec/specs/desktop-runtime/spec.md
Normal file
54
openspec/specs/desktop-runtime/spec.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Backend-neutral window identity
|
||||
The desktop runtime SHALL expose a backend-neutral `window_id` for each enumerated window. Public runtime responses MUST NOT require backend-specific handles such as X11 window IDs for targeting or automation.
|
||||
|
||||
#### Scenario: Listing windows returns stable public identity
|
||||
- **WHEN** a client lists windows through the runtime
|
||||
- **THEN** each window result includes a `window_id` that the client can use for later targeting
|
||||
- **AND** the result does not require the client to know the underlying X11 handle format
|
||||
|
||||
#### Scenario: Selector resolution accepts public identity
|
||||
- **WHEN** a client targets a window by `window_id`
|
||||
- **THEN** the runtime resolves the request to the correct live window
|
||||
- **AND** performs the requested action without exposing backend handles in the public contract
|
||||
|
||||
### Requirement: Read-only window enumeration is side-effect free
|
||||
The desktop runtime SHALL provide a read-only window enumeration path that does not capture screenshots and does not write screenshot artifacts unless a screenshot-producing command was explicitly requested.
|
||||
|
||||
#### Scenario: Listing windows does not write a screenshot
|
||||
- **WHEN** a client runs a read-only window listing command
|
||||
- **THEN** the runtime returns current window data
|
||||
- **AND** does not capture a screenshot
|
||||
- **AND** does not create a screenshot file as a side effect
|
||||
|
||||
#### Scenario: Snapshot remains an explicit screenshot-producing command
|
||||
- **WHEN** a client runs a snapshot command
|
||||
- **THEN** the runtime returns window data together with screenshot output
|
||||
- **AND** any screenshot artifact is created only because the snapshot command explicitly requested it
|
||||
|
||||
### Requirement: Runtime diagnostics are first-class
|
||||
The CLI SHALL provide a `doctor` command that checks runtime prerequisites for the supported X11 environment and reports actionable remediation guidance for each failed check.
|
||||
|
||||
#### Scenario: Doctor reports missing display configuration
|
||||
- **WHEN** `deskctl doctor` runs without a usable `DISPLAY`
|
||||
- **THEN** it reports that the X11 display is unavailable
|
||||
- **AND** includes a concrete remediation message describing what environment setup is required
|
||||
|
||||
#### Scenario: Doctor verifies basic runtime operations
|
||||
- **WHEN** `deskctl doctor` runs in a healthy supported environment
|
||||
- **THEN** it verifies X11 connectivity, basic window enumeration, screenshot viability, and socket path health
|
||||
- **AND** reports a successful diagnostic result for each check
|
||||
|
||||
### Requirement: Daemon startup failures are recoverable and diagnosable
|
||||
The runtime SHALL detect stale daemon socket state and surface actionable startup or connection errors instead of failing with ambiguous transport errors.
|
||||
|
||||
#### Scenario: Client encounters a stale socket
|
||||
- **WHEN** the client finds a socket path whose daemon is no longer serving requests
|
||||
- **THEN** the runtime removes or replaces the stale socket state safely
|
||||
- **AND** proceeds with a healthy daemon startup or reports a specific failure if recovery does not succeed
|
||||
|
||||
#### Scenario: Health probing distinguishes startup failure from runtime failure
|
||||
- **WHEN** a client attempts to use the runtime and the daemon cannot become healthy
|
||||
- **THEN** the returned error explains whether the failure occurred during spawn, health probing, or request handling
|
||||
- **AND** does not report the problem as a generic connection failure alone
|
||||
|
|
@ -1,25 +1,41 @@
|
|||
pub mod annotate;
|
||||
pub mod x11;
|
||||
|
||||
use crate::core::types::Snapshot;
|
||||
use anyhow::Result;
|
||||
use image::RgbaImage;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackendWindow {
|
||||
pub native_id: u32,
|
||||
pub title: String,
|
||||
pub app_name: String,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub focused: bool,
|
||||
pub minimized: bool,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub trait DesktopBackend: Send {
|
||||
/// Capture a screenshot and return a z-ordered window tree with @wN refs.
|
||||
fn snapshot(&mut self, annotate: bool) -> Result<Snapshot>;
|
||||
/// Collect z-ordered windows for read-only queries and targeting.
|
||||
fn list_windows(&mut self) -> Result<Vec<BackendWindow>>;
|
||||
|
||||
/// Focus a window by its X11 window ID.
|
||||
fn focus_window(&mut self, xcb_id: u32) -> Result<()>;
|
||||
/// Capture the current desktop image without writing it to disk.
|
||||
fn capture_screenshot(&mut self) -> Result<RgbaImage>;
|
||||
|
||||
/// Focus a window by its backend-native window handle.
|
||||
fn focus_window(&mut self, native_id: u32) -> Result<()>;
|
||||
|
||||
/// Move a window to absolute coordinates.
|
||||
fn move_window(&mut self, xcb_id: u32, x: i32, y: i32) -> Result<()>;
|
||||
fn move_window(&mut self, native_id: u32, x: i32, y: i32) -> Result<()>;
|
||||
|
||||
/// Resize a window.
|
||||
fn resize_window(&mut self, xcb_id: u32, w: u32, h: u32) -> Result<()>;
|
||||
fn resize_window(&mut self, native_id: u32, w: u32, h: u32) -> Result<()>;
|
||||
|
||||
/// Close a window gracefully.
|
||||
fn close_window(&mut self, xcb_id: u32) -> Result<()>;
|
||||
fn close_window(&mut self, native_id: u32) -> Result<()>;
|
||||
|
||||
/// Click at absolute coordinates.
|
||||
fn click(&mut self, x: i32, y: i32) -> Result<()>;
|
||||
|
|
@ -51,9 +67,6 @@ pub trait DesktopBackend: Send {
|
|||
/// Get the current mouse position.
|
||||
fn mouse_position(&self) -> Result<(i32, i32)>;
|
||||
|
||||
/// Take a screenshot and save to a path (no window tree).
|
||||
fn screenshot(&mut self, path: &str, annotate: bool) -> Result<String>;
|
||||
|
||||
/// Launch an application.
|
||||
fn launch(&self, command: &str, args: &[String]) -> Result<u32>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ use x11rb::protocol::xproto::{
|
|||
};
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
use super::annotate::annotate_screenshot;
|
||||
use crate::core::types::{Snapshot, WindowInfo};
|
||||
use crate::backend::BackendWindow;
|
||||
|
||||
struct Atoms {
|
||||
client_list_stacking: Atom,
|
||||
|
|
@ -71,10 +70,9 @@ impl X11Backend {
|
|||
Ok(windows)
|
||||
}
|
||||
|
||||
fn collect_window_infos(&self) -> Result<Vec<WindowInfo>> {
|
||||
fn collect_window_infos(&self) -> Result<Vec<BackendWindow>> {
|
||||
let active_window = self.active_window()?;
|
||||
let mut window_infos = Vec::new();
|
||||
let mut ref_counter = 1usize;
|
||||
|
||||
for window in self.stacked_windows()? {
|
||||
let title = self.window_title(window).unwrap_or_default();
|
||||
|
|
@ -89,9 +87,8 @@ impl X11Backend {
|
|||
};
|
||||
|
||||
let minimized = self.window_is_minimized(window).unwrap_or(false);
|
||||
window_infos.push(WindowInfo {
|
||||
ref_id: format!("w{ref_counter}"),
|
||||
xcb_id: window,
|
||||
window_infos.push(BackendWindow {
|
||||
native_id: window,
|
||||
title,
|
||||
app_name,
|
||||
x,
|
||||
|
|
@ -101,7 +98,6 @@ impl X11Backend {
|
|||
focused: active_window == Some(window),
|
||||
minimized,
|
||||
});
|
||||
ref_counter += 1;
|
||||
}
|
||||
|
||||
Ok(window_infos)
|
||||
|
|
@ -231,32 +227,15 @@ impl X11Backend {
|
|||
}
|
||||
|
||||
impl super::DesktopBackend for X11Backend {
|
||||
fn snapshot(&mut self, annotate: bool) -> Result<Snapshot> {
|
||||
let window_infos = self.collect_window_infos()?;
|
||||
let mut image = self.capture_root_image()?;
|
||||
|
||||
// Annotate if requested - draw bounding boxes and @wN labels
|
||||
if annotate {
|
||||
annotate_screenshot(&mut image, &window_infos);
|
||||
fn list_windows(&mut self) -> Result<Vec<BackendWindow>> {
|
||||
self.collect_window_infos()
|
||||
}
|
||||
|
||||
// Save screenshot
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let screenshot_path = format!("/tmp/deskctl-{timestamp}.png");
|
||||
image
|
||||
.save(&screenshot_path)
|
||||
.context("Failed to save screenshot")?;
|
||||
|
||||
Ok(Snapshot {
|
||||
screenshot: screenshot_path,
|
||||
windows: window_infos,
|
||||
})
|
||||
fn capture_screenshot(&mut self) -> Result<RgbaImage> {
|
||||
self.capture_root_image()
|
||||
}
|
||||
|
||||
fn focus_window(&mut self, xcb_id: u32) -> Result<()> {
|
||||
fn focus_window(&mut self, native_id: u32) -> Result<()> {
|
||||
// Use _NET_ACTIVE_WINDOW client message (avoids focus-stealing prevention)
|
||||
let net_active = self
|
||||
.conn
|
||||
|
|
@ -269,7 +248,7 @@ impl super::DesktopBackend for X11Backend {
|
|||
response_type: x11rb::protocol::xproto::CLIENT_MESSAGE_EVENT,
|
||||
format: 32,
|
||||
sequence: 0,
|
||||
window: xcb_id,
|
||||
window: native_id,
|
||||
type_: net_active,
|
||||
data: ClientMessageData::from([
|
||||
2u32, 0, 0, 0, 0, // source=2 (pager), timestamp=0, currently_active=0
|
||||
|
|
@ -288,25 +267,25 @@ impl super::DesktopBackend for X11Backend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn move_window(&mut self, xcb_id: u32, x: i32, y: i32) -> Result<()> {
|
||||
fn move_window(&mut self, native_id: u32, x: i32, y: i32) -> Result<()> {
|
||||
self.conn
|
||||
.configure_window(xcb_id, &ConfigureWindowAux::new().x(x).y(y))?;
|
||||
.configure_window(native_id, &ConfigureWindowAux::new().x(x).y(y))?;
|
||||
self.conn
|
||||
.flush()
|
||||
.context("Failed to flush X11 connection")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resize_window(&mut self, xcb_id: u32, w: u32, h: u32) -> Result<()> {
|
||||
fn resize_window(&mut self, native_id: u32, w: u32, h: u32) -> Result<()> {
|
||||
self.conn
|
||||
.configure_window(xcb_id, &ConfigureWindowAux::new().width(w).height(h))?;
|
||||
.configure_window(native_id, &ConfigureWindowAux::new().width(w).height(h))?;
|
||||
self.conn
|
||||
.flush()
|
||||
.context("Failed to flush X11 connection")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn close_window(&mut self, xcb_id: u32) -> Result<()> {
|
||||
fn close_window(&mut self, native_id: u32) -> Result<()> {
|
||||
// Use _NET_CLOSE_WINDOW for graceful close (respects WM protocols)
|
||||
let net_close = self
|
||||
.conn
|
||||
|
|
@ -319,7 +298,7 @@ impl super::DesktopBackend for X11Backend {
|
|||
response_type: x11rb::protocol::xproto::CLIENT_MESSAGE_EVENT,
|
||||
format: 32,
|
||||
sequence: 0,
|
||||
window: xcb_id,
|
||||
window: native_id,
|
||||
type_: net_close,
|
||||
data: ClientMessageData::from([
|
||||
0u32, 2, 0, 0, 0, // timestamp=0, source=2 (pager)
|
||||
|
|
@ -463,18 +442,6 @@ impl super::DesktopBackend for X11Backend {
|
|||
Ok((reply.root_x as i32, reply.root_y as i32))
|
||||
}
|
||||
|
||||
fn screenshot(&mut self, path: &str, annotate: bool) -> Result<String> {
|
||||
let mut image = self.capture_root_image()?;
|
||||
|
||||
if annotate {
|
||||
let window_infos = self.collect_window_infos()?;
|
||||
annotate_screenshot(&mut image, &window_infos);
|
||||
}
|
||||
|
||||
image.save(path).context("Failed to save screenshot")?;
|
||||
Ok(path.to_string())
|
||||
}
|
||||
|
||||
fn launch(&self, command: &str, args: &[String]) -> Result<u32> {
|
||||
let child = std::process::Command::new(command)
|
||||
.args(args)
|
||||
|
|
|
|||
|
|
@ -1,41 +1,56 @@
|
|||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
use crate::cli::GlobalOpts;
|
||||
use crate::core::doctor::{DoctorReport, run as run_doctor_report};
|
||||
use crate::core::paths::{pid_path_for_session, socket_dir, socket_path_for_session};
|
||||
use crate::core::protocol::{Request, Response};
|
||||
|
||||
fn socket_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("DESKCTL_SOCKET_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
return PathBuf::from(runtime).join("deskctl");
|
||||
}
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join(".deskctl")
|
||||
}
|
||||
|
||||
fn socket_path(opts: &GlobalOpts) -> PathBuf {
|
||||
if let Some(ref path) = opts.socket {
|
||||
return path.clone();
|
||||
}
|
||||
socket_dir().join(format!("{}.sock", opts.session))
|
||||
socket_path_for_session(&opts.session)
|
||||
}
|
||||
|
||||
fn pid_path(opts: &GlobalOpts) -> PathBuf {
|
||||
socket_dir().join(format!("{}.pid", opts.session))
|
||||
pid_path_for_session(&opts.session)
|
||||
}
|
||||
|
||||
fn try_connect(opts: &GlobalOpts) -> Option<UnixStream> {
|
||||
UnixStream::connect(socket_path(opts)).ok()
|
||||
fn connect_socket(path: &Path) -> Result<UnixStream> {
|
||||
UnixStream::connect(path).with_context(|| format!("Failed to connect to {}", path.display()))
|
||||
}
|
||||
|
||||
fn is_stale_socket_error(error: &std::io::Error) -> bool {
|
||||
matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
|
||||
)
|
||||
}
|
||||
|
||||
fn cleanup_stale_socket(opts: &GlobalOpts) -> Result<bool> {
|
||||
let path = socket_path(opts);
|
||||
if !path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
match UnixStream::connect(&path) {
|
||||
Ok(_) => Ok(false),
|
||||
Err(error) if is_stale_socket_error(&error) => {
|
||||
std::fs::remove_file(&path)
|
||||
.with_context(|| format!("Failed to remove stale socket {}", path.display()))?;
|
||||
Ok(true)
|
||||
}
|
||||
Err(error) => Err(error)
|
||||
.with_context(|| format!("Failed to inspect daemon socket {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_daemon(opts: &GlobalOpts) -> Result<()> {
|
||||
|
|
@ -51,9 +66,8 @@ fn spawn_daemon(opts: &GlobalOpts) -> Result<()> {
|
|||
.env("DESKCTL_PID_PATH", pid_path(opts))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped());
|
||||
.stderr(Stdio::null());
|
||||
|
||||
// Detach the daemon process on Unix
|
||||
unsafe {
|
||||
cmd.pre_exec(|| {
|
||||
libc::setsid();
|
||||
|
|
@ -65,82 +79,120 @@ fn spawn_daemon(opts: &GlobalOpts) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_daemon(opts: &GlobalOpts) -> Result<UnixStream> {
|
||||
// Try connecting first
|
||||
if let Some(stream) = try_connect(opts) {
|
||||
return Ok(stream);
|
||||
fn send_request_over_stream(mut stream: UnixStream, request: &Request) -> Result<Response> {
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
|
||||
|
||||
let json = serde_json::to_string(request)?;
|
||||
writeln!(stream, "{json}")?;
|
||||
stream.flush()?;
|
||||
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line)?;
|
||||
|
||||
serde_json::from_str(line.trim()).context("Failed to parse daemon response")
|
||||
}
|
||||
|
||||
fn ping_daemon(opts: &GlobalOpts) -> Result<()> {
|
||||
let response = send_request_over_stream(connect_socket(&socket_path(opts))?, &Request::new("ping"))?;
|
||||
if response.success {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!(
|
||||
"{}",
|
||||
response
|
||||
.error
|
||||
.unwrap_or_else(|| "Daemon health probe failed".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_daemon(opts: &GlobalOpts) -> Result<UnixStream> {
|
||||
if ping_daemon(opts).is_ok() {
|
||||
return connect_socket(&socket_path(opts));
|
||||
}
|
||||
|
||||
let removed_stale_socket = cleanup_stale_socket(opts)?;
|
||||
if removed_stale_socket && ping_daemon(opts).is_ok() {
|
||||
return connect_socket(&socket_path(opts));
|
||||
}
|
||||
|
||||
// Spawn daemon
|
||||
spawn_daemon(opts)?;
|
||||
|
||||
// Retry with backoff
|
||||
let max_retries = 20;
|
||||
let base_delay = Duration::from_millis(50);
|
||||
for i in 0..max_retries {
|
||||
thread::sleep(base_delay * (i + 1).min(4));
|
||||
if let Some(stream) = try_connect(opts) {
|
||||
return Ok(stream);
|
||||
for attempt in 0..max_retries {
|
||||
thread::sleep(base_delay * (attempt + 1).min(4));
|
||||
if ping_daemon(opts).is_ok() {
|
||||
return connect_socket(&socket_path(opts));
|
||||
}
|
||||
}
|
||||
|
||||
bail!(
|
||||
"Failed to connect to daemon after {} retries.\n\
|
||||
Socket path: {}",
|
||||
"Failed to start a healthy daemon after {} retries.\nSocket path: {}",
|
||||
max_retries,
|
||||
socket_path(opts).display()
|
||||
);
|
||||
}
|
||||
|
||||
pub fn send_command(opts: &GlobalOpts, request: &Request) -> Result<Response> {
|
||||
let mut stream = ensure_daemon(opts)?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
|
||||
|
||||
// Send NDJSON request
|
||||
let json = serde_json::to_string(request)?;
|
||||
writeln!(stream, "{json}")?;
|
||||
stream.flush()?;
|
||||
|
||||
// Read NDJSON response
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line)?;
|
||||
|
||||
let response: Response =
|
||||
serde_json::from_str(line.trim()).context("Failed to parse daemon response")?;
|
||||
|
||||
Ok(response)
|
||||
send_request_over_stream(ensure_daemon(opts)?, request)
|
||||
}
|
||||
|
||||
pub fn start_daemon(opts: &GlobalOpts) -> Result<()> {
|
||||
if try_connect(opts).is_some() {
|
||||
println!("Daemon already running ({})", socket_path(opts).display());
|
||||
return Ok(());
|
||||
}
|
||||
spawn_daemon(opts)?;
|
||||
// Wait briefly and verify
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
if try_connect(opts).is_some() {
|
||||
println!("Daemon started ({})", socket_path(opts).display());
|
||||
} else {
|
||||
bail!("Daemon failed to start");
|
||||
pub fn run_doctor(opts: &GlobalOpts) -> Result<()> {
|
||||
let report = run_doctor_report(&socket_path(opts));
|
||||
print_doctor_report(&report, opts.json)?;
|
||||
if !report.healthy {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop_daemon(opts: &GlobalOpts) -> Result<()> {
|
||||
match try_connect(opts) {
|
||||
Some(mut stream) => {
|
||||
let req = Request::new("shutdown");
|
||||
let json = serde_json::to_string(&req)?;
|
||||
writeln!(stream, "{json}")?;
|
||||
stream.flush()?;
|
||||
println!("Daemon stopped");
|
||||
pub fn start_daemon(opts: &GlobalOpts) -> Result<()> {
|
||||
if ping_daemon(opts).is_ok() {
|
||||
println!("Daemon already running ({})", socket_path(opts).display());
|
||||
return Ok(());
|
||||
}
|
||||
None => {
|
||||
// Try to clean up stale socket
|
||||
|
||||
if cleanup_stale_socket(opts)? {
|
||||
println!("Removed stale socket: {}", socket_path(opts).display());
|
||||
}
|
||||
|
||||
spawn_daemon(opts)?;
|
||||
|
||||
let max_retries = 20;
|
||||
for attempt in 0..max_retries {
|
||||
thread::sleep(Duration::from_millis(50 * (attempt + 1).min(4) as u64));
|
||||
if ping_daemon(opts).is_ok() {
|
||||
println!("Daemon started ({})", socket_path(opts).display());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
bail!(
|
||||
"Daemon failed to become healthy.\nSocket path: {}",
|
||||
socket_path(opts).display()
|
||||
);
|
||||
}
|
||||
|
||||
pub fn stop_daemon(opts: &GlobalOpts) -> Result<()> {
|
||||
let path = socket_path(opts);
|
||||
match UnixStream::connect(&path) {
|
||||
Ok(stream) => {
|
||||
let response = send_request_over_stream(stream, &Request::new("shutdown"))?;
|
||||
if response.success {
|
||||
println!("Daemon stopped");
|
||||
} else {
|
||||
bail!(
|
||||
"{}",
|
||||
response
|
||||
.error
|
||||
.unwrap_or_else(|| "Failed to stop daemon".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) if is_stale_socket_error(&error) => {
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)?;
|
||||
println!("Removed stale socket: {}", path.display());
|
||||
|
|
@ -148,15 +200,72 @@ pub fn stop_daemon(opts: &GlobalOpts) -> Result<()> {
|
|||
println!("Daemon not running");
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(error)
|
||||
.with_context(|| format!("Failed to inspect daemon socket {}", path.display()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn daemon_status(opts: &GlobalOpts) -> Result<()> {
|
||||
if try_connect(opts).is_some() {
|
||||
println!("Daemon running ({})", socket_path(opts).display());
|
||||
} else {
|
||||
println!("Daemon not running");
|
||||
let path = socket_path(opts);
|
||||
match ping_daemon(opts) {
|
||||
Ok(()) => println!("Daemon running ({})", path.display()),
|
||||
Err(_) if path.exists() => println!("Daemon socket exists but is unhealthy ({})", path.display()),
|
||||
Err(_) => println!("Daemon not running"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_doctor_report(report: &DoctorReport, json_output: bool) -> Result<()> {
|
||||
if json_output {
|
||||
println!("{}", serde_json::to_string_pretty(report)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"deskctl doctor: {}",
|
||||
if report.healthy { "healthy" } else { "issues found" }
|
||||
);
|
||||
for check in &report.checks {
|
||||
let status = if check.ok { "OK" } else { "FAIL" };
|
||||
println!("[{status}] {}: {}", check.name, check.details);
|
||||
if let Some(fix) = &check.fix {
|
||||
println!(" fix: {fix}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{cleanup_stale_socket, socket_path};
|
||||
use crate::cli::GlobalOpts;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn cleanup_stale_socket_removes_refused_socket_path() {
|
||||
let temp = std::env::temp_dir().join(format!(
|
||||
"deskctl-test-{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&temp).unwrap();
|
||||
let opts = GlobalOpts {
|
||||
socket: Some(temp.join("stale.sock")),
|
||||
session: "test".to_string(),
|
||||
json: false,
|
||||
};
|
||||
|
||||
let listener = std::os::unix::net::UnixListener::bind(socket_path(&opts)).unwrap();
|
||||
drop(listener);
|
||||
|
||||
assert!(cleanup_stale_socket(&opts).unwrap());
|
||||
assert!(!socket_path(&opts).exists());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&temp);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ pub enum Command {
|
|||
GetScreenSize,
|
||||
/// Get current mouse position
|
||||
GetMousePosition,
|
||||
/// Diagnose X11 runtime, screenshot, and daemon health
|
||||
Doctor,
|
||||
/// Take a screenshot without window tree
|
||||
Screenshot {
|
||||
/// Save path (default: /tmp/deskctl-{timestamp}.png)
|
||||
|
|
@ -179,6 +181,10 @@ pub fn run() -> Result<()> {
|
|||
};
|
||||
}
|
||||
|
||||
if let Command::Doctor = app.command {
|
||||
return connection::run_doctor(&app.global);
|
||||
}
|
||||
|
||||
// All other commands need a daemon connection
|
||||
let request = build_request(&app.command)?;
|
||||
let response = connection::send_command(&app.global, &request)?;
|
||||
|
|
@ -237,6 +243,7 @@ fn build_request(cmd: &Command) -> Result<Request> {
|
|||
Command::ListWindows => Request::new("list-windows"),
|
||||
Command::GetScreenSize => Request::new("get-screen-size"),
|
||||
Command::GetMousePosition => Request::new("get-mouse-position"),
|
||||
Command::Doctor => unreachable!(),
|
||||
Command::Screenshot { path, annotate } => {
|
||||
let mut req = Request::new("screenshot").with_extra("annotate", json!(annotate));
|
||||
if let Some(p) = path {
|
||||
|
|
@ -261,7 +268,7 @@ fn print_response(cmd: &Command, response: &Response) -> Result<()> {
|
|||
}
|
||||
if let Some(ref data) = response.data {
|
||||
// For snapshot, print compact text format
|
||||
if matches!(cmd, Command::Snapshot { .. }) {
|
||||
if matches!(cmd, Command::Snapshot { .. } | Command::ListWindows) {
|
||||
if let Some(screenshot) = data.get("screenshot").and_then(|v| v.as_str()) {
|
||||
println!("Screenshot: {screenshot}");
|
||||
}
|
||||
|
|
|
|||
239
src/core/doctor.rs
Normal file
239
src/core/doctor.rs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::backend::{x11::X11Backend, DesktopBackend};
|
||||
use crate::core::protocol::{Request, Response};
|
||||
use crate::core::session::detect_session;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DoctorReport {
|
||||
pub healthy: bool,
|
||||
pub checks: Vec<DoctorCheck>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DoctorCheck {
|
||||
pub name: String,
|
||||
pub ok: bool,
|
||||
pub details: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fix: Option<String>,
|
||||
}
|
||||
|
||||
pub fn run(socket_path: &Path) -> DoctorReport {
|
||||
let mut checks = Vec::new();
|
||||
|
||||
let display = std::env::var("DISPLAY").ok();
|
||||
checks.push(match display {
|
||||
Some(ref value) if !value.is_empty() => check_ok("display", format!("DISPLAY={value}")),
|
||||
_ => check_fail(
|
||||
"display",
|
||||
"DISPLAY is not set".to_string(),
|
||||
"Export DISPLAY to point at the active X11 server.".to_string(),
|
||||
),
|
||||
});
|
||||
|
||||
checks.push(match detect_session() {
|
||||
Ok(_) => check_ok("session", "X11 session detected".to_string()),
|
||||
Err(error) => check_fail(
|
||||
"session",
|
||||
error.to_string(),
|
||||
"Run deskctl inside an X11 session. Wayland is not supported in this phase."
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
|
||||
let mut backend = match X11Backend::new() {
|
||||
Ok(backend) => {
|
||||
checks.push(check_ok(
|
||||
"backend",
|
||||
"Connected to the X11 backend successfully".to_string(),
|
||||
));
|
||||
Some(backend)
|
||||
}
|
||||
Err(error) => {
|
||||
checks.push(check_fail(
|
||||
"backend",
|
||||
error.to_string(),
|
||||
"Ensure the X server is reachable and the current session can access it."
|
||||
.to_string(),
|
||||
));
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(backend) = backend.as_mut() {
|
||||
checks.push(match backend.list_windows() {
|
||||
Ok(windows) => check_ok(
|
||||
"window-enumeration",
|
||||
format!("Enumerated {} visible windows", windows.len()),
|
||||
),
|
||||
Err(error) => check_fail(
|
||||
"window-enumeration",
|
||||
error.to_string(),
|
||||
"Verify the desktop session exposes EWMH window metadata and the X11 connection is healthy."
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
|
||||
checks.push(match backend.capture_screenshot() {
|
||||
Ok(image) => check_ok(
|
||||
"screenshot",
|
||||
format!("Captured {}x{} desktop image", image.width(), image.height()),
|
||||
),
|
||||
Err(error) => check_fail(
|
||||
"screenshot",
|
||||
error.to_string(),
|
||||
"Verify the X11 session permits desktop capture on the active display."
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
checks.push(check_fail(
|
||||
"window-enumeration",
|
||||
"Skipped because backend initialization failed".to_string(),
|
||||
"Fix the X11 backend error before retrying.".to_string(),
|
||||
));
|
||||
checks.push(check_fail(
|
||||
"screenshot",
|
||||
"Skipped because backend initialization failed".to_string(),
|
||||
"Fix the X11 backend error before retrying.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
checks.push(check_socket_dir(socket_path));
|
||||
checks.push(check_daemon_socket(socket_path));
|
||||
|
||||
let healthy = checks.iter().all(|check| check.ok);
|
||||
DoctorReport { healthy, checks }
|
||||
}
|
||||
|
||||
fn check_socket_dir(socket_path: &Path) -> DoctorCheck {
|
||||
let Some(socket_dir) = socket_path.parent() else {
|
||||
return check_fail(
|
||||
"socket-dir",
|
||||
format!("Socket path {} has no parent directory", socket_path.display()),
|
||||
"Use a socket path inside a writable directory.".to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
match std::fs::create_dir_all(socket_dir) {
|
||||
Ok(()) => check_ok(
|
||||
"socket-dir",
|
||||
format!("Socket directory is ready at {}", socket_dir.display()),
|
||||
),
|
||||
Err(error) => check_fail(
|
||||
"socket-dir",
|
||||
error.to_string(),
|
||||
format!("Ensure {} exists and is writable.", socket_dir.display()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_daemon_socket(socket_path: &Path) -> DoctorCheck {
|
||||
if !socket_path.exists() {
|
||||
return check_ok(
|
||||
"daemon-socket",
|
||||
format!("No stale socket found at {}", socket_path.display()),
|
||||
);
|
||||
}
|
||||
|
||||
match ping_socket(socket_path) {
|
||||
Ok(()) => check_ok(
|
||||
"daemon-socket",
|
||||
format!("Daemon is healthy at {}", socket_path.display()),
|
||||
),
|
||||
Err(error) => check_fail(
|
||||
"daemon-socket",
|
||||
error.to_string(),
|
||||
format!(
|
||||
"Remove the stale socket at {} or run `deskctl daemon stop`.",
|
||||
socket_path.display()
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn ping_socket(socket_path: &Path) -> Result<()> {
|
||||
let mut stream = UnixStream::connect(socket_path)?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(1)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(1)))?;
|
||||
|
||||
let request = Request::new("ping");
|
||||
let json = serde_json::to_string(&request)?;
|
||||
writeln!(stream, "{json}")?;
|
||||
stream.flush()?;
|
||||
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line)?;
|
||||
let response: Response = serde_json::from_str(line.trim())?;
|
||||
|
||||
if response.success {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"{}",
|
||||
response
|
||||
.error
|
||||
.unwrap_or_else(|| "Daemon health probe failed".to_string())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn check_ok(name: &str, details: String) -> DoctorCheck {
|
||||
DoctorCheck {
|
||||
name: name.to_string(),
|
||||
ok: true,
|
||||
details,
|
||||
fix: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn check_fail(name: &str, details: String, fix: String) -> DoctorCheck {
|
||||
DoctorCheck {
|
||||
name: name.to_string(),
|
||||
ok: false,
|
||||
details,
|
||||
fix: Some(fix),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::run;
|
||||
use crate::test_support::{X11TestEnv, env_lock};
|
||||
|
||||
#[test]
|
||||
fn doctor_reports_healthy_x11_environment_under_xvfb() {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
let Some(env) = X11TestEnv::new().unwrap() else {
|
||||
eprintln!("Skipping Xvfb-dependent doctor test");
|
||||
return;
|
||||
};
|
||||
env.create_window("deskctl doctor test", "DeskctlDoctor").unwrap();
|
||||
|
||||
let socket_path = std::env::temp_dir().join("deskctl-doctor-test.sock");
|
||||
let report = run(&socket_path);
|
||||
|
||||
assert!(report.checks.iter().any(|check| check.name == "display" && check.ok));
|
||||
assert!(report.checks.iter().any(|check| check.name == "backend" && check.ok));
|
||||
assert!(
|
||||
report
|
||||
.checks
|
||||
.iter()
|
||||
.any(|check| check.name == "window-enumeration" && check.ok)
|
||||
);
|
||||
assert!(
|
||||
report
|
||||
.checks
|
||||
.iter()
|
||||
.any(|check| check.name == "screenshot" && check.ok)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
pub mod doctor;
|
||||
pub mod paths;
|
||||
pub mod protocol;
|
||||
pub mod refs;
|
||||
pub mod session;
|
||||
|
|
|
|||
29
src/core/paths.rs
Normal file
29
src/core/paths.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
pub fn socket_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("DESKCTL_SOCKET_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
return PathBuf::from(runtime).join("deskctl");
|
||||
}
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join(".deskctl")
|
||||
}
|
||||
|
||||
pub fn socket_path_for_session(session: &str) -> PathBuf {
|
||||
socket_dir().join(format!("{session}.sock"))
|
||||
}
|
||||
|
||||
pub fn pid_path_for_session(session: &str) -> PathBuf {
|
||||
socket_dir().join(format!("{session}.pid"))
|
||||
}
|
||||
|
||||
pub fn socket_path_from_env() -> Option<PathBuf> {
|
||||
std::env::var("DESKCTL_SOCKET_PATH").ok().map(PathBuf::from)
|
||||
}
|
||||
|
||||
pub fn pid_path_from_env() -> Option<PathBuf> {
|
||||
std::env::var("DESKCTL_PID_PATH").ok().map(PathBuf::from)
|
||||
}
|
||||
151
src/core/refs.rs
151
src/core/refs.rs
|
|
@ -1,10 +1,14 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::backend::BackendWindow;
|
||||
use crate::core::types::WindowInfo;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct RefEntry {
|
||||
pub xcb_id: u32,
|
||||
pub window_id: String,
|
||||
pub backend_window_id: u32,
|
||||
pub app_class: String,
|
||||
pub title: String,
|
||||
pub pid: u32,
|
||||
|
|
@ -19,58 +23,173 @@ pub struct RefEntry {
|
|||
#[derive(Debug, Default)]
|
||||
#[allow(dead_code)]
|
||||
pub struct RefMap {
|
||||
map: HashMap<String, RefEntry>,
|
||||
refs: HashMap<String, RefEntry>,
|
||||
window_id_to_ref: HashMap<String, String>,
|
||||
backend_id_to_window_id: HashMap<u32, String>,
|
||||
next_ref: usize,
|
||||
next_window: usize,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl RefMap {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
map: HashMap::new(),
|
||||
refs: HashMap::new(),
|
||||
window_id_to_ref: HashMap::new(),
|
||||
backend_id_to_window_id: HashMap::new(),
|
||||
next_ref: 1,
|
||||
next_window: 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
self.refs.clear();
|
||||
self.window_id_to_ref.clear();
|
||||
self.next_ref = 1;
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, entry: RefEntry) -> String {
|
||||
pub fn rebuild(&mut self, windows: &[BackendWindow]) -> Vec<WindowInfo> {
|
||||
self.clear();
|
||||
|
||||
let active_backend_ids = windows
|
||||
.iter()
|
||||
.map(|window| window.native_id)
|
||||
.collect::<HashSet<_>>();
|
||||
self.backend_id_to_window_id
|
||||
.retain(|backend_id, _| active_backend_ids.contains(backend_id));
|
||||
|
||||
let mut public_windows = Vec::with_capacity(windows.len());
|
||||
for window in windows {
|
||||
let ref_id = format!("w{}", self.next_ref);
|
||||
self.next_ref += 1;
|
||||
self.map.insert(ref_id.clone(), entry);
|
||||
ref_id
|
||||
|
||||
let window_id = self.window_id_for_backend(window.native_id);
|
||||
let entry = RefEntry {
|
||||
window_id: window_id.clone(),
|
||||
backend_window_id: window.native_id,
|
||||
app_class: window.app_name.clone(),
|
||||
title: window.title.clone(),
|
||||
pid: 0,
|
||||
x: window.x,
|
||||
y: window.y,
|
||||
width: window.width,
|
||||
height: window.height,
|
||||
focused: window.focused,
|
||||
minimized: window.minimized,
|
||||
};
|
||||
|
||||
self.window_id_to_ref
|
||||
.insert(window_id.clone(), ref_id.clone());
|
||||
self.refs.insert(ref_id.clone(), entry);
|
||||
public_windows.push(WindowInfo {
|
||||
ref_id,
|
||||
window_id,
|
||||
title: window.title.clone(),
|
||||
app_name: window.app_name.clone(),
|
||||
x: window.x,
|
||||
y: window.y,
|
||||
width: window.width,
|
||||
height: window.height,
|
||||
focused: window.focused,
|
||||
minimized: window.minimized,
|
||||
});
|
||||
}
|
||||
|
||||
public_windows
|
||||
}
|
||||
|
||||
fn window_id_for_backend(&mut self, backend_window_id: u32) -> String {
|
||||
if let Some(existing) = self.backend_id_to_window_id.get(&backend_window_id) {
|
||||
return existing.clone();
|
||||
}
|
||||
|
||||
let window_id = format!("win{}", self.next_window);
|
||||
self.next_window += 1;
|
||||
self.backend_id_to_window_id
|
||||
.insert(backend_window_id, window_id.clone());
|
||||
window_id
|
||||
}
|
||||
|
||||
/// Resolve a selector to a RefEntry.
|
||||
/// Accepts: "@w1", "w1", "ref=w1", or a substring match on app_class/title.
|
||||
/// 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);
|
||||
|
||||
// Try direct ref lookup
|
||||
if let Some(entry) = self.map.get(normalized) {
|
||||
if let Some(entry) = self.refs.get(normalized) {
|
||||
return Some(entry);
|
||||
}
|
||||
|
||||
// Try substring match on app_class or title (case-insensitive)
|
||||
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.map.values().find(|e| {
|
||||
e.app_class.to_lowercase().contains(&lower) || e.title.to_lowercase().contains(&lower)
|
||||
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.
|
||||
pub fn resolve_to_center(&self, selector: &str) -> Option<(i32, i32)> {
|
||||
self.resolve(selector)
|
||||
.map(|e| (e.x + e.width as i32 / 2, e.y + e.height as i32 / 2))
|
||||
.map(|entry| (entry.x + entry.width as i32 / 2, entry.y + entry.height as i32 / 2))
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl Iterator<Item = (&String, &RefEntry)> {
|
||||
self.map.iter()
|
||||
self.refs.iter()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RefMap;
|
||||
use crate::backend::BackendWindow;
|
||||
|
||||
fn sample_window(native_id: u32, title: &str) -> BackendWindow {
|
||||
BackendWindow {
|
||||
native_id,
|
||||
title: title.to_string(),
|
||||
app_name: "TestApp".to_string(),
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 300,
|
||||
height: 200,
|
||||
focused: native_id == 1,
|
||||
minimized: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_assigns_stable_window_ids_for_same_native_window() {
|
||||
let mut refs = RefMap::new();
|
||||
let first = refs.rebuild(&[sample_window(1, "First")]);
|
||||
let second = refs.rebuild(&[sample_window(1, "First Updated")]);
|
||||
|
||||
assert_eq!(first[0].window_id, second[0].window_id);
|
||||
assert_eq!(second[0].ref_id, "w1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_accepts_ref_and_window_id() {
|
||||
let mut refs = RefMap::new();
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_to_center_uses_window_geometry() {
|
||||
let mut refs = RefMap::new();
|
||||
refs.rebuild(&[sample_window(7, "Browser")]);
|
||||
|
||||
assert_eq!(refs.resolve_to_center("w1"), Some((160, 120)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,14 +15,14 @@ pub fn detect_session() -> Result<SessionType> {
|
|||
bail!(
|
||||
"No X11 session detected.\n\
|
||||
XDG_SESSION_TYPE is not set and DISPLAY is not set.\n\
|
||||
deskctl requires an X11 session. Wayland support coming in v0.2."
|
||||
deskctl requires an X11 session."
|
||||
);
|
||||
}
|
||||
}
|
||||
"wayland" => {
|
||||
bail!(
|
||||
"Wayland session detected (XDG_SESSION_TYPE=wayland).\n\
|
||||
deskctl currently supports X11 only. Wayland/Hyprland support coming in v0.2."
|
||||
deskctl currently supports X11 only."
|
||||
);
|
||||
}
|
||||
other => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ pub struct Snapshot {
|
|||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct WindowInfo {
|
||||
pub ref_id: String,
|
||||
pub xcb_id: u32,
|
||||
pub window_id: String,
|
||||
pub title: String,
|
||||
pub app_name: String,
|
||||
pub x: i32,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::state::DaemonState;
|
||||
use crate::backend::DesktopBackend;
|
||||
use crate::backend::annotate::annotate_screenshot;
|
||||
use crate::core::protocol::{Request, Response};
|
||||
use crate::core::refs::RefEntry;
|
||||
use crate::core::types::{Snapshot, WindowInfo};
|
||||
|
||||
pub async fn handle_request(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
|
||||
match request.action.as_str() {
|
||||
"ping" => Response::ok(serde_json::json!({"message": "pong"})),
|
||||
"snapshot" => handle_snapshot(request, state).await,
|
||||
"click" => handle_click(request, state).await,
|
||||
"dblclick" => handle_dblclick(request, state).await,
|
||||
|
|
@ -38,55 +41,33 @@ async fn handle_snapshot(request: &Request, state: &Arc<Mutex<DaemonState>>) ->
|
|||
.unwrap_or(false);
|
||||
|
||||
let mut state = state.lock().await;
|
||||
|
||||
match state.backend.snapshot(annotate) {
|
||||
Ok(snapshot) => {
|
||||
// Update ref map
|
||||
state.ref_map.clear();
|
||||
for win in &snapshot.windows {
|
||||
state.ref_map.insert(RefEntry {
|
||||
xcb_id: win.xcb_id,
|
||||
app_class: win.app_name.clone(),
|
||||
title: win.title.clone(),
|
||||
pid: 0, // xcap doesn't expose PID directly in snapshot
|
||||
x: win.x,
|
||||
y: win.y,
|
||||
width: win.width,
|
||||
height: win.height,
|
||||
focused: win.focused,
|
||||
minimized: win.minimized,
|
||||
});
|
||||
}
|
||||
|
||||
Response::ok(serde_json::to_value(&snapshot).unwrap_or_default())
|
||||
}
|
||||
Err(e) => Response::err(format!("Snapshot failed: {e}")),
|
||||
match capture_snapshot(&mut state, annotate, None) {
|
||||
Ok(snapshot) => Response::ok(serde_json::to_value(&snapshot).unwrap_or_default()),
|
||||
Err(error) => Response::err(format!("Snapshot failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_click(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
|
||||
let selector = match request.extra.get("selector").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
Some(selector) => selector.to_string(),
|
||||
None => return Response::err("Missing 'selector' field"),
|
||||
};
|
||||
|
||||
let mut state = state.lock().await;
|
||||
|
||||
// Try to parse as coordinates "x,y"
|
||||
if let Some((x, y)) = parse_coords(&selector) {
|
||||
return match state.backend.click(x, y) {
|
||||
Ok(()) => Response::ok(serde_json::json!({"clicked": {"x": x, "y": y}})),
|
||||
Err(e) => Response::err(format!("Click failed: {e}")),
|
||||
Err(error) => Response::err(format!("Click failed: {error}")),
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve as window ref
|
||||
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}}))
|
||||
}
|
||||
Err(e) => Response::err(format!("Click failed: {e}")),
|
||||
Err(error) => Response::err(format!("Click failed: {error}")),
|
||||
},
|
||||
None => Response::err(format!("Could not resolve selector: {selector}")),
|
||||
}
|
||||
|
|
@ -94,7 +75,7 @@ async fn handle_click(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Res
|
|||
|
||||
async fn handle_dblclick(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
|
||||
let selector = match request.extra.get("selector").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
Some(selector) => selector.to_string(),
|
||||
None => return Response::err("Missing 'selector' field"),
|
||||
};
|
||||
|
||||
|
|
@ -103,7 +84,7 @@ async fn handle_dblclick(request: &Request, state: &Arc<Mutex<DaemonState>>) ->
|
|||
if let Some((x, y)) = parse_coords(&selector) {
|
||||
return match state.backend.dblclick(x, y) {
|
||||
Ok(()) => Response::ok(serde_json::json!({"double_clicked": {"x": x, "y": y}})),
|
||||
Err(e) => Response::err(format!("Double-click failed: {e}")),
|
||||
Err(error) => Response::err(format!("Double-click failed: {error}")),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +93,7 @@ async fn handle_dblclick(request: &Request, state: &Arc<Mutex<DaemonState>>) ->
|
|||
Ok(()) => Response::ok(
|
||||
serde_json::json!({"double_clicked": {"x": x, "y": y, "ref": selector}}),
|
||||
),
|
||||
Err(e) => Response::err(format!("Double-click failed: {e}")),
|
||||
Err(error) => Response::err(format!("Double-click failed: {error}")),
|
||||
},
|
||||
None => Response::err(format!("Could not resolve selector: {selector}")),
|
||||
}
|
||||
|
|
@ -120,70 +101,66 @@ async fn handle_dblclick(request: &Request, state: &Arc<Mutex<DaemonState>>) ->
|
|||
|
||||
async fn handle_type(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
|
||||
let text = match request.extra.get("text").and_then(|v| v.as_str()) {
|
||||
Some(t) => t.to_string(),
|
||||
Some(text) => text.to_string(),
|
||||
None => return Response::err("Missing 'text' field"),
|
||||
};
|
||||
|
||||
let mut state = state.lock().await;
|
||||
|
||||
match state.backend.type_text(&text) {
|
||||
Ok(()) => Response::ok(serde_json::json!({"typed": text})),
|
||||
Err(e) => Response::err(format!("Type failed: {e}")),
|
||||
Err(error) => Response::err(format!("Type failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_press(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
|
||||
let key = match request.extra.get("key").and_then(|v| v.as_str()) {
|
||||
Some(k) => k.to_string(),
|
||||
Some(key) => key.to_string(),
|
||||
None => return Response::err("Missing 'key' field"),
|
||||
};
|
||||
|
||||
let mut state = state.lock().await;
|
||||
|
||||
match state.backend.press_key(&key) {
|
||||
Ok(()) => Response::ok(serde_json::json!({"pressed": key})),
|
||||
Err(e) => Response::err(format!("Key press failed: {e}")),
|
||||
Err(error) => Response::err(format!("Key press failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_hotkey(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
|
||||
let keys: Vec<String> = match request.extra.get("keys").and_then(|v| v.as_array()) {
|
||||
Some(arr) => arr
|
||||
Some(keys) => keys
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.filter_map(|value| value.as_str().map(|s| s.to_string()))
|
||||
.collect(),
|
||||
None => return Response::err("Missing 'keys' field"),
|
||||
};
|
||||
|
||||
let mut state = state.lock().await;
|
||||
|
||||
match state.backend.hotkey(&keys) {
|
||||
Ok(()) => Response::ok(serde_json::json!({"hotkey": keys})),
|
||||
Err(e) => Response::err(format!("Hotkey failed: {e}")),
|
||||
Err(error) => Response::err(format!("Hotkey failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_mouse_move(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
|
||||
let x = match request.extra.get("x").and_then(|v| v.as_i64()) {
|
||||
Some(v) => v as i32,
|
||||
Some(value) => value as i32,
|
||||
None => return Response::err("Missing 'x' field"),
|
||||
};
|
||||
let y = match request.extra.get("y").and_then(|v| v.as_i64()) {
|
||||
Some(v) => v as i32,
|
||||
Some(value) => value as i32,
|
||||
None => return Response::err("Missing 'y' field"),
|
||||
};
|
||||
|
||||
let mut state = state.lock().await;
|
||||
|
||||
match state.backend.mouse_move(x, y) {
|
||||
Ok(()) => Response::ok(serde_json::json!({"moved": {"x": x, "y": y}})),
|
||||
Err(e) => Response::err(format!("Mouse move failed: {e}")),
|
||||
Err(error) => Response::err(format!("Mouse move failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_mouse_scroll(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
|
||||
let amount = match request.extra.get("amount").and_then(|v| v.as_i64()) {
|
||||
Some(v) => v as i32,
|
||||
Some(value) => value as i32,
|
||||
None => return Response::err("Missing 'amount' field"),
|
||||
};
|
||||
let axis = request
|
||||
|
|
@ -194,33 +171,31 @@ async fn handle_mouse_scroll(request: &Request, state: &Arc<Mutex<DaemonState>>)
|
|||
.to_string();
|
||||
|
||||
let mut state = state.lock().await;
|
||||
|
||||
match state.backend.scroll(amount, &axis) {
|
||||
Ok(()) => Response::ok(serde_json::json!({"scrolled": {"amount": amount, "axis": axis}})),
|
||||
Err(e) => Response::err(format!("Scroll failed: {e}")),
|
||||
Err(error) => Response::err(format!("Scroll failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_mouse_drag(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
|
||||
let x1 = match request.extra.get("x1").and_then(|v| v.as_i64()) {
|
||||
Some(v) => v as i32,
|
||||
Some(value) => value as i32,
|
||||
None => return Response::err("Missing 'x1' field"),
|
||||
};
|
||||
let y1 = match request.extra.get("y1").and_then(|v| v.as_i64()) {
|
||||
Some(v) => v as i32,
|
||||
Some(value) => value as i32,
|
||||
None => return Response::err("Missing 'y1' field"),
|
||||
};
|
||||
let x2 = match request.extra.get("x2").and_then(|v| v.as_i64()) {
|
||||
Some(v) => v as i32,
|
||||
Some(value) => value as i32,
|
||||
None => return Response::err("Missing 'x2' field"),
|
||||
};
|
||||
let y2 = match request.extra.get("y2").and_then(|v| v.as_i64()) {
|
||||
Some(v) => v as i32,
|
||||
Some(value) => value as i32,
|
||||
None => return Response::err("Missing 'y2' field"),
|
||||
};
|
||||
|
||||
let mut state = state.lock().await;
|
||||
|
||||
match state.backend.drag(x1, y1, x2, y2) {
|
||||
Ok(()) => Response::ok(serde_json::json!({
|
||||
"dragged": {
|
||||
|
|
@ -228,7 +203,7 @@ async fn handle_mouse_drag(request: &Request, state: &Arc<Mutex<DaemonState>>) -
|
|||
"to": {"x": x2, "y": y2}
|
||||
}
|
||||
})),
|
||||
Err(e) => Response::err(format!("Drag failed: {e}")),
|
||||
Err(error) => Response::err(format!("Drag failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -238,20 +213,19 @@ async fn handle_window_action(
|
|||
action: &str,
|
||||
) -> Response {
|
||||
let selector = match request.extra.get("selector").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
Some(selector) => selector.to_string(),
|
||||
None => return Response::err("Missing 'selector' field"),
|
||||
};
|
||||
|
||||
let mut state = state.lock().await;
|
||||
|
||||
let entry = match state.ref_map.resolve(&selector) {
|
||||
Some(e) => e.clone(),
|
||||
Some(entry) => entry.clone(),
|
||||
None => return Response::err(format!("Could not resolve window: {selector}")),
|
||||
};
|
||||
|
||||
let result = match action {
|
||||
"focus" => state.backend.focus_window(entry.xcb_id),
|
||||
"close" => state.backend.close_window(entry.xcb_id),
|
||||
"focus" => state.backend.focus_window(entry.backend_window_id),
|
||||
"close" => state.backend.close_window(entry.backend_window_id),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
|
|
@ -259,15 +233,15 @@ async fn handle_window_action(
|
|||
Ok(()) => Response::ok(serde_json::json!({
|
||||
"action": action,
|
||||
"window": entry.title,
|
||||
"xcb_id": entry.xcb_id,
|
||||
"window_id": entry.window_id,
|
||||
})),
|
||||
Err(e) => Response::err(format!("{action} failed: {e}")),
|
||||
Err(error) => Response::err(format!("{action} failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_move_window(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
|
||||
let selector = match request.extra.get("selector").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
Some(selector) => selector.to_string(),
|
||||
None => return Response::err("Missing 'selector' field"),
|
||||
};
|
||||
let x = request.extra.get("x").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
||||
|
|
@ -275,29 +249,32 @@ async fn handle_move_window(request: &Request, state: &Arc<Mutex<DaemonState>>)
|
|||
|
||||
let mut state = state.lock().await;
|
||||
let entry = match state.ref_map.resolve(&selector) {
|
||||
Some(e) => e.clone(),
|
||||
Some(entry) => entry.clone(),
|
||||
None => return Response::err(format!("Could not resolve window: {selector}")),
|
||||
};
|
||||
|
||||
match state.backend.move_window(entry.xcb_id, x, y) {
|
||||
match state.backend.move_window(entry.backend_window_id, x, y) {
|
||||
Ok(()) => Response::ok(serde_json::json!({
|
||||
"moved": entry.title, "x": x, "y": y
|
||||
"moved": entry.title,
|
||||
"window_id": entry.window_id,
|
||||
"x": x,
|
||||
"y": y,
|
||||
})),
|
||||
Err(e) => Response::err(format!("Move failed: {e}")),
|
||||
Err(error) => Response::err(format!("Move failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_resize_window(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
|
||||
let selector = match request.extra.get("selector").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
Some(selector) => selector.to_string(),
|
||||
None => return Response::err("Missing 'selector' field"),
|
||||
};
|
||||
let w = request
|
||||
let width = request
|
||||
.extra
|
||||
.get("w")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(800) as u32;
|
||||
let h = request
|
||||
let height = request
|
||||
.extra
|
||||
.get("h")
|
||||
.and_then(|v| v.as_u64())
|
||||
|
|
@ -305,50 +282,37 @@ async fn handle_resize_window(request: &Request, state: &Arc<Mutex<DaemonState>>
|
|||
|
||||
let mut state = state.lock().await;
|
||||
let entry = match state.ref_map.resolve(&selector) {
|
||||
Some(e) => e.clone(),
|
||||
Some(entry) => entry.clone(),
|
||||
None => return Response::err(format!("Could not resolve window: {selector}")),
|
||||
};
|
||||
|
||||
match state.backend.resize_window(entry.xcb_id, w, h) {
|
||||
match state
|
||||
.backend
|
||||
.resize_window(entry.backend_window_id, width, height)
|
||||
{
|
||||
Ok(()) => Response::ok(serde_json::json!({
|
||||
"resized": entry.title, "width": w, "height": h
|
||||
"resized": entry.title,
|
||||
"window_id": entry.window_id,
|
||||
"width": width,
|
||||
"height": height,
|
||||
})),
|
||||
Err(e) => Response::err(format!("Resize failed: {e}")),
|
||||
Err(error) => Response::err(format!("Resize failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list_windows(state: &Arc<Mutex<DaemonState>>) -> Response {
|
||||
let mut state = state.lock().await;
|
||||
// Re-run snapshot without screenshot, just to get current window list
|
||||
match state.backend.snapshot(false) {
|
||||
Ok(snapshot) => {
|
||||
// Update ref map with fresh data
|
||||
state.ref_map.clear();
|
||||
for win in &snapshot.windows {
|
||||
state.ref_map.insert(RefEntry {
|
||||
xcb_id: win.xcb_id,
|
||||
app_class: win.app_name.clone(),
|
||||
title: win.title.clone(),
|
||||
pid: 0,
|
||||
x: win.x,
|
||||
y: win.y,
|
||||
width: win.width,
|
||||
height: win.height,
|
||||
focused: win.focused,
|
||||
minimized: win.minimized,
|
||||
});
|
||||
}
|
||||
Response::ok(serde_json::json!({"windows": snapshot.windows}))
|
||||
}
|
||||
Err(e) => Response::err(format!("List windows failed: {e}")),
|
||||
match refresh_windows(&mut state) {
|
||||
Ok(windows) => Response::ok(serde_json::json!({"windows": windows})),
|
||||
Err(error) => Response::err(format!("List windows failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_get_screen_size(state: &Arc<Mutex<DaemonState>>) -> Response {
|
||||
let state = state.lock().await;
|
||||
match state.backend.screen_size() {
|
||||
Ok((w, h)) => Response::ok(serde_json::json!({"width": w, "height": h})),
|
||||
Err(e) => Response::err(format!("Failed: {e}")),
|
||||
Ok((width, height)) => Response::ok(serde_json::json!({"width": width, "height": height})),
|
||||
Err(error) => Response::err(format!("Failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -356,7 +320,7 @@ async fn handle_get_mouse_position(state: &Arc<Mutex<DaemonState>>) -> Response
|
|||
let state = state.lock().await;
|
||||
match state.backend.mouse_position() {
|
||||
Ok((x, y)) => Response::ok(serde_json::json!({"x": x, "y": y})),
|
||||
Err(e) => Response::err(format!("Failed: {e}")),
|
||||
Err(error) => Response::err(format!("Failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -370,50 +334,160 @@ async fn handle_screenshot(request: &Request, state: &Arc<Mutex<DaemonState>>) -
|
|||
.extra
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
format!("/tmp/deskctl-{ts}.png")
|
||||
});
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(temp_screenshot_path);
|
||||
|
||||
let mut state = state.lock().await;
|
||||
match state.backend.screenshot(&path, annotate) {
|
||||
Ok(saved) => Response::ok(serde_json::json!({"screenshot": saved})),
|
||||
Err(e) => Response::err(format!("Screenshot failed: {e}")),
|
||||
let windows = if annotate {
|
||||
match refresh_windows(&mut state) {
|
||||
Ok(windows) => Some(windows),
|
||||
Err(error) => return Response::err(format!("Screenshot failed: {error}")),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match capture_and_save_screenshot(&mut state, &path, annotate, windows.as_deref()) {
|
||||
Ok(saved) => {
|
||||
if let Some(windows) = windows {
|
||||
Response::ok(serde_json::json!({"screenshot": saved, "windows": windows}))
|
||||
} else {
|
||||
Response::ok(serde_json::json!({"screenshot": saved}))
|
||||
}
|
||||
}
|
||||
Err(error) => Response::err(format!("Screenshot failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_launch(request: &Request, state: &Arc<Mutex<DaemonState>>) -> Response {
|
||||
let command = match request.extra.get("command").and_then(|v| v.as_str()) {
|
||||
Some(c) => c.to_string(),
|
||||
Some(command) => command.to_string(),
|
||||
None => return Response::err("Missing 'command' field"),
|
||||
};
|
||||
let args: Vec<String> = request
|
||||
.extra
|
||||
.get("args")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.map(|args| {
|
||||
args.iter()
|
||||
.filter_map(|value| value.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let state = state.lock().await;
|
||||
match state.backend.launch(&command, &args) {
|
||||
Ok(pid) => Response::ok(serde_json::json!({"pid": pid, "command": command})),
|
||||
Err(e) => Response::err(format!("Launch failed: {e}")),
|
||||
Err(error) => Response::err(format!("Launch failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_coords(s: &str) -> Option<(i32, i32)> {
|
||||
let parts: Vec<&str> = s.split(',').collect();
|
||||
if parts.len() == 2 {
|
||||
fn refresh_windows(state: &mut DaemonState) -> Result<Vec<WindowInfo>> {
|
||||
let windows = state.backend.list_windows()?;
|
||||
Ok(state.ref_map.rebuild(&windows))
|
||||
}
|
||||
|
||||
fn capture_snapshot(
|
||||
state: &mut DaemonState,
|
||||
annotate: bool,
|
||||
path: Option<String>,
|
||||
) -> Result<Snapshot> {
|
||||
let windows = refresh_windows(state)?;
|
||||
let screenshot_path = path.unwrap_or_else(temp_screenshot_path);
|
||||
let screenshot = capture_and_save_screenshot(
|
||||
state,
|
||||
&screenshot_path,
|
||||
annotate,
|
||||
Some(&windows),
|
||||
)?;
|
||||
|
||||
Ok(Snapshot { screenshot, windows })
|
||||
}
|
||||
|
||||
fn capture_and_save_screenshot(
|
||||
state: &mut DaemonState,
|
||||
path: &str,
|
||||
annotate: bool,
|
||||
windows: Option<&[WindowInfo]>,
|
||||
) -> Result<String> {
|
||||
let mut image = state.backend.capture_screenshot()?;
|
||||
if annotate {
|
||||
let windows = windows.context("Annotated screenshots require current window data")?;
|
||||
annotate_screenshot(&mut image, windows);
|
||||
}
|
||||
image
|
||||
.save(path)
|
||||
.with_context(|| format!("Failed to save screenshot to {path}"))?;
|
||||
Ok(path.to_string())
|
||||
}
|
||||
|
||||
fn temp_screenshot_path() -> String {
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
format!("/tmp/deskctl-{timestamp}.png")
|
||||
}
|
||||
|
||||
fn parse_coords(value: &str) -> Option<(i32, i32)> {
|
||||
let parts: Vec<&str> = value.split(',').collect();
|
||||
if parts.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let x = parts[0].trim().parse().ok()?;
|
||||
let y = parts[1].trim().parse().ok()?;
|
||||
Some((x, y))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::runtime::Builder;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::handle_request;
|
||||
use crate::core::protocol::Request;
|
||||
use crate::daemon::state::DaemonState;
|
||||
use crate::test_support::{X11TestEnv, deskctl_tmp_screenshot_count, env_lock};
|
||||
|
||||
#[test]
|
||||
fn list_windows_is_side_effect_free_under_xvfb() {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
let Some(env) = X11TestEnv::new().unwrap() else {
|
||||
eprintln!("Skipping Xvfb-dependent list-windows test");
|
||||
return;
|
||||
};
|
||||
env.create_window("deskctl list-windows test", "DeskctlList").unwrap();
|
||||
|
||||
let before = deskctl_tmp_screenshot_count();
|
||||
let runtime = Builder::new_current_thread().enable_all().build().unwrap();
|
||||
let state = Arc::new(Mutex::new(
|
||||
DaemonState::new(
|
||||
"test".to_string(),
|
||||
std::env::temp_dir().join("deskctl-list-windows.sock"),
|
||||
)
|
||||
.unwrap(),
|
||||
));
|
||||
|
||||
let response = runtime.block_on(handle_request(&Request::new("list-windows"), &state));
|
||||
assert!(response.success);
|
||||
|
||||
let data = response.data.unwrap();
|
||||
let windows = data
|
||||
.get("windows")
|
||||
.and_then(|value| value.as_array())
|
||||
.unwrap();
|
||||
assert!(windows.iter().any(|window| {
|
||||
window
|
||||
.get("title")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|title| title == "deskctl list-windows test")
|
||||
.unwrap_or(false)
|
||||
}));
|
||||
|
||||
let after = deskctl_tmp_screenshot_count();
|
||||
assert_eq!(before, after, "list-windows should not create screenshot artifacts");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
mod handler;
|
||||
mod state;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
|
@ -9,6 +8,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|||
use tokio::net::UnixListener;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::core::paths::{pid_path_from_env, socket_path_from_env};
|
||||
use crate::core::session;
|
||||
use state::DaemonState;
|
||||
|
||||
|
|
@ -24,11 +24,9 @@ pub fn run() -> Result<()> {
|
|||
}
|
||||
|
||||
async fn async_run() -> Result<()> {
|
||||
let socket_path = std::env::var("DESKCTL_SOCKET_PATH")
|
||||
.map(PathBuf::from)
|
||||
.context("DESKCTL_SOCKET_PATH not set")?;
|
||||
let socket_path = socket_path_from_env().context("DESKCTL_SOCKET_PATH not set")?;
|
||||
|
||||
let pid_path = std::env::var("DESKCTL_PID_PATH").map(PathBuf::from).ok();
|
||||
let pid_path = pid_path_from_env();
|
||||
|
||||
// Clean up stale socket
|
||||
if socket_path.exists() {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use crate::backend::x11::X11Backend;
|
||||
use crate::backend::{x11::X11Backend, DesktopBackend};
|
||||
use crate::core::refs::RefMap;
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
|
@ -8,12 +8,12 @@ pub struct DaemonState {
|
|||
pub session: String,
|
||||
pub socket_path: PathBuf,
|
||||
pub ref_map: RefMap,
|
||||
pub backend: X11Backend,
|
||||
pub backend: Box<dyn DesktopBackend>,
|
||||
}
|
||||
|
||||
impl DaemonState {
|
||||
pub fn new(session: String, socket_path: PathBuf) -> anyhow::Result<Self> {
|
||||
let backend = X11Backend::new()?;
|
||||
let backend: Box<dyn DesktopBackend> = Box::new(X11Backend::new()?);
|
||||
Ok(Self {
|
||||
session,
|
||||
socket_path,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ mod backend;
|
|||
mod cli;
|
||||
mod core;
|
||||
mod daemon;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
if std::env::var("DESKCTL_DAEMON").is_ok() {
|
||||
|
|
|
|||
150
src/test_support.rs
Normal file
150
src/test_support.rs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
#![cfg(all(test, target_os = "linux"))]
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use x11rb::connection::Connection;
|
||||
use x11rb::protocol::xproto::{
|
||||
AtomEnum, ConnectionExt as XprotoConnectionExt, CreateWindowAux, EventMask, PropMode,
|
||||
WindowClass,
|
||||
};
|
||||
|
||||
pub fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
pub struct X11TestEnv {
|
||||
child: Child,
|
||||
old_display: Option<String>,
|
||||
old_session_type: Option<String>,
|
||||
}
|
||||
|
||||
impl X11TestEnv {
|
||||
pub fn new() -> Result<Option<Self>> {
|
||||
if Command::new("Xvfb")
|
||||
.arg("-help")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_err()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
for display_num in 90..110 {
|
||||
let display = format!(":{display_num}");
|
||||
let lock_path = format!("/tmp/.X{display_num}-lock");
|
||||
let unix_socket = format!("/tmp/.X11-unix/X{display_num}");
|
||||
if Path::new(&lock_path).exists() || Path::new(&unix_socket).exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let child = Command::new("Xvfb")
|
||||
.arg(&display)
|
||||
.arg("-screen")
|
||||
.arg("0")
|
||||
.arg("1024x768x24")
|
||||
.arg("-nolisten")
|
||||
.arg("tcp")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.with_context(|| format!("Failed to launch Xvfb on {display}"))?;
|
||||
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
|
||||
let old_display = std::env::var("DISPLAY").ok();
|
||||
let old_session_type = std::env::var("XDG_SESSION_TYPE").ok();
|
||||
std::env::set_var("DISPLAY", &display);
|
||||
std::env::set_var("XDG_SESSION_TYPE", "x11");
|
||||
|
||||
return Ok(Some(Self {
|
||||
child,
|
||||
old_display,
|
||||
old_session_type,
|
||||
}));
|
||||
}
|
||||
|
||||
anyhow::bail!("Failed to find a free Xvfb display")
|
||||
}
|
||||
|
||||
pub fn create_window(&self, title: &str, app_class: &str) -> Result<()> {
|
||||
let (conn, screen_num) =
|
||||
x11rb::connect(None).context("Failed to connect to test Xvfb display")?;
|
||||
let screen = &conn.setup().roots[screen_num];
|
||||
let window = conn.generate_id()?;
|
||||
|
||||
conn.create_window(
|
||||
x11rb::COPY_DEPTH_FROM_PARENT,
|
||||
window,
|
||||
screen.root,
|
||||
10,
|
||||
10,
|
||||
320,
|
||||
180,
|
||||
0,
|
||||
WindowClass::INPUT_OUTPUT,
|
||||
0,
|
||||
&CreateWindowAux::new()
|
||||
.background_pixel(screen.white_pixel)
|
||||
.event_mask(EventMask::EXPOSURE),
|
||||
)?;
|
||||
conn.change_property8(
|
||||
PropMode::REPLACE,
|
||||
window,
|
||||
AtomEnum::WM_NAME,
|
||||
AtomEnum::STRING,
|
||||
title.as_bytes(),
|
||||
)?;
|
||||
let class_bytes = format!("{app_class}\0{app_class}\0");
|
||||
conn.change_property8(
|
||||
PropMode::REPLACE,
|
||||
window,
|
||||
AtomEnum::WM_CLASS,
|
||||
AtomEnum::STRING,
|
||||
class_bytes.as_bytes(),
|
||||
)?;
|
||||
conn.map_window(window)?;
|
||||
conn.flush()?;
|
||||
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for X11TestEnv {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
|
||||
match &self.old_display {
|
||||
Some(value) => std::env::set_var("DISPLAY", value),
|
||||
None => std::env::remove_var("DISPLAY"),
|
||||
}
|
||||
|
||||
match &self.old_session_type {
|
||||
Some(value) => std::env::set_var("XDG_SESSION_TYPE", value),
|
||||
None => std::env::remove_var("XDG_SESSION_TYPE"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deskctl_tmp_screenshot_count() -> usize {
|
||||
std::fs::read_dir("/tmp")
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|iter| iter.filter_map(Result::ok))
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.map(|name| name.starts_with("deskctl-") && name.ends_with(".png"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue