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:
Harivansh Rathi 2026-03-24 21:19:18 -04:00
parent 17d4a1edeb
commit dfaa339594
13 changed files with 1735 additions and 0 deletions

4
src/core/mod.rs Normal file
View file

@ -0,0 +1,4 @@
pub mod protocol;
pub mod refs;
pub mod session;
pub mod types;

49
src/core/protocol.rs Normal file
View 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
View 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
View 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
View 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])
}
}