mirror of
https://github.com/harivansh-afk/deskctl.git
synced 2026-04-18 03:00:39 +00:00
Phase 1: project scaffold, clap CLI, self-re-exec daemon, NDJSON IPC
- Cargo.toml with clap, tokio, serde, anyhow dependencies - Entry point with env-var routing to daemon or CLI mode - Core protocol types (Request/Response NDJSON wire format) - Session detection (X11 check with DISPLAY/XDG_SESSION_TYPE) - RefMap with @wN selector resolution (direct, prefix, substring) - Snapshot/WindowInfo shared types with Display impl - clap derive CLI with all subcommands (snapshot, click, type, etc.) - Client connection: socket path resolution, daemon auto-start via self-re-exec, NDJSON send/receive with retry backoff - Tokio async daemon: Unix socket listener, accept loop, graceful shutdown via notify - DaemonState holding session info and ref map - Placeholder handler returning hardcoded snapshot response
This commit is contained in:
parent
17d4a1edeb
commit
dfaa339594
13 changed files with 1735 additions and 0 deletions
4
src/core/mod.rs
Normal file
4
src/core/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod protocol;
|
||||
pub mod refs;
|
||||
pub mod session;
|
||||
pub mod types;
|
||||
49
src/core/protocol.rs
Normal file
49
src/core/protocol.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
pub id: String,
|
||||
pub action: String,
|
||||
#[serde(flatten)]
|
||||
pub extra: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Response {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn new(action: &str) -> Self {
|
||||
Self {
|
||||
id: format!("r{}", std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_micros() % 1_000_000),
|
||||
action: action.to_string(),
|
||||
extra: Value::Object(serde_json::Map::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_extra(mut self, key: &str, value: Value) -> Self {
|
||||
if let Value::Object(ref mut map) = self.extra {
|
||||
map.insert(key.to_string(), value);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn ok(data: Value) -> Self {
|
||||
Self { success: true, data: Some(data), error: None }
|
||||
}
|
||||
|
||||
pub fn err(msg: impl Into<String>) -> Self {
|
||||
Self { success: false, data: None, error: Some(msg.into()) }
|
||||
}
|
||||
}
|
||||
75
src/core/refs.rs
Normal file
75
src/core/refs.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
use std::collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct RefEntry {
|
||||
pub xcb_id: u32,
|
||||
pub app_class: String,
|
||||
pub title: String,
|
||||
pub pid: u32,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub focused: bool,
|
||||
pub minimized: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
#[allow(dead_code)]
|
||||
pub struct RefMap {
|
||||
map: HashMap<String, RefEntry>,
|
||||
next_ref: usize,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl RefMap {
|
||||
pub fn new() -> Self {
|
||||
Self { map: HashMap::new(), next_ref: 1 }
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.map.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
|
||||
}
|
||||
|
||||
/// Resolve a selector to a RefEntry.
|
||||
/// Accepts: "@w1", "w1", "ref=w1", 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) {
|
||||
return Some(entry);
|
||||
}
|
||||
|
||||
// Try substring match on app_class or title (case-insensitive)
|
||||
let lower = selector.to_lowercase();
|
||||
self.map.values().find(|e| {
|
||||
e.app_class.to_lowercase().contains(&lower)
|
||||
|| e.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)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl Iterator<Item = (&String, &RefEntry)> {
|
||||
self.map.iter()
|
||||
}
|
||||
}
|
||||
45
src/core/session.rs
Normal file
45
src/core/session.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use anyhow::{bail, Result};
|
||||
|
||||
pub enum SessionType {
|
||||
X11,
|
||||
}
|
||||
|
||||
pub fn detect_session() -> Result<SessionType> {
|
||||
let session_type = std::env::var("XDG_SESSION_TYPE").unwrap_or_default();
|
||||
|
||||
match session_type.as_str() {
|
||||
"x11" => {}
|
||||
"" => {
|
||||
// No XDG_SESSION_TYPE set - check for DISPLAY as fallback
|
||||
if std::env::var("DISPLAY").is_err() {
|
||||
bail!(
|
||||
"No X11 session detected.\n\
|
||||
XDG_SESSION_TYPE is not set and DISPLAY is not set.\n\
|
||||
desktop-ctl requires an X11 session. Wayland support coming in v0.2."
|
||||
);
|
||||
}
|
||||
}
|
||||
"wayland" => {
|
||||
bail!(
|
||||
"Wayland session detected (XDG_SESSION_TYPE=wayland).\n\
|
||||
desktop-ctl currently supports X11 only. Wayland/Hyprland support coming in v0.2."
|
||||
);
|
||||
}
|
||||
other => {
|
||||
bail!(
|
||||
"Unsupported session type: {other}\n\
|
||||
desktop-ctl currently supports X11 only."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm DISPLAY is set for X11
|
||||
if std::env::var("DISPLAY").is_err() {
|
||||
bail!(
|
||||
"X11 session detected but DISPLAY is not set.\n\
|
||||
Ensure your X server is running and DISPLAY is exported."
|
||||
);
|
||||
}
|
||||
|
||||
Ok(SessionType::X11)
|
||||
}
|
||||
55
src/core/types.rs
Normal file
55
src/core/types.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Snapshot {
|
||||
pub screenshot: String,
|
||||
pub windows: Vec<WindowInfo>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct WindowInfo {
|
||||
pub ref_id: String,
|
||||
pub xcb_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,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WindowInfo {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let state = if self.focused {
|
||||
"focused"
|
||||
} else if self.minimized {
|
||||
"hidden"
|
||||
} else {
|
||||
"visible"
|
||||
};
|
||||
write!(
|
||||
f,
|
||||
"@{:<4} {:<30} ({:<7}) {},{} {}x{}",
|
||||
self.ref_id,
|
||||
truncate(&self.title, 30),
|
||||
state,
|
||||
self.x,
|
||||
self.y,
|
||||
self.width,
|
||||
self.height
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}...", &s[..max - 3])
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue