mirror of
https://github.com/harivansh-afk/deskctl.git
synced 2026-04-17 17:03:25 +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
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)
|
||||
}
|
||||
155
src/core/refs.rs
155
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 {
|
||||
let ref_id = format!("w{}", self.next_ref);
|
||||
self.next_ref += 1;
|
||||
self.map.insert(ref_id.clone(), entry);
|
||||
ref_id
|
||||
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;
|
||||
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue