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

31
src/daemon/handler.rs Normal file
View file

@ -0,0 +1,31 @@
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::core::protocol::{Request, Response};
use super::state::DaemonState;
pub async fn handle_request(
request: &Request,
_state: &Arc<Mutex<DaemonState>>,
) -> Response {
match request.action.as_str() {
"snapshot" => {
Response::ok(serde_json::json!({
"screenshot": "/tmp/desktop-ctl-placeholder.png",
"windows": [
{
"ref_id": "w1",
"xcb_id": 0,
"title": "Placeholder Window",
"app_name": "placeholder",
"x": 0, "y": 0, "width": 1920, "height": 1080,
"focused": true, "minimized": false
}
]
}))
}
action => {
Response::err(format!("Unknown action: {action}"))
}
}
}

127
src/daemon/mod.rs Normal file
View file

@ -0,0 +1,127 @@
mod handler;
mod state;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixListener;
use tokio::sync::Mutex;
use crate::core::session;
use state::DaemonState;
pub fn run() -> Result<()> {
// Validate session before starting
session::detect_session()?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
runtime.block_on(async_run())
}
async fn async_run() -> Result<()> {
let socket_path = std::env::var("DESKTOP_CTL_SOCKET_PATH")
.map(PathBuf::from)
.context("DESKTOP_CTL_SOCKET_PATH not set")?;
let pid_path = std::env::var("DESKTOP_CTL_PID_PATH")
.map(PathBuf::from)
.ok();
// Clean up stale socket
if socket_path.exists() {
std::fs::remove_file(&socket_path)?;
}
// Write PID file
if let Some(ref pid_path) = pid_path {
std::fs::write(pid_path, std::process::id().to_string())?;
}
let listener = UnixListener::bind(&socket_path)
.context(format!("Failed to bind socket: {}", socket_path.display()))?;
let session = std::env::var("DESKTOP_CTL_SESSION").unwrap_or_else(|_| "default".to_string());
let state = Arc::new(Mutex::new(DaemonState::new(session, socket_path.clone())));
let shutdown = Arc::new(tokio::sync::Notify::new());
let shutdown_clone = shutdown.clone();
// Accept loop
loop {
tokio::select! {
result = listener.accept() => {
match result {
Ok((stream, _addr)) => {
let state = state.clone();
let shutdown = shutdown.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection(stream, state, shutdown).await {
eprintln!("Connection error: {e}");
}
});
}
Err(e) => {
eprintln!("Accept error: {e}");
}
}
}
_ = shutdown_clone.notified() => {
break;
}
}
}
// Cleanup
if socket_path.exists() {
let _ = std::fs::remove_file(&socket_path);
}
if let Some(ref pid_path) = pid_path {
let _ = std::fs::remove_file(pid_path);
}
Ok(())
}
async fn handle_connection(
stream: tokio::net::UnixStream,
state: Arc<Mutex<DaemonState>>,
shutdown: Arc<tokio::sync::Notify>,
) -> Result<()> {
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
let mut line = String::new();
reader.read_line(&mut line).await?;
let line = line.trim();
if line.is_empty() {
return Ok(());
}
let request: crate::core::protocol::Request = serde_json::from_str(line)?;
// Handle shutdown specially - notify before writing so the accept loop
// exits even if the client has already closed the connection.
if request.action == "shutdown" {
shutdown.notify_one();
let response = crate::core::protocol::Response::ok(
serde_json::json!({"message": "Shutting down"})
);
let json = serde_json::to_string(&response)?;
// Ignore write errors - client may have already closed the connection.
let _ = writer.write_all(format!("{json}\n").as_bytes()).await;
let _ = writer.flush().await;
return Ok(());
}
let response = handler::handle_request(&request, &state).await;
let json = serde_json::to_string(&response)?;
writer.write_all(format!("{json}\n").as_bytes()).await?;
writer.flush().await?;
Ok(())
}

19
src/daemon/state.rs Normal file
View file

@ -0,0 +1,19 @@
use std::path::PathBuf;
use crate::core::refs::RefMap;
#[allow(dead_code)]
pub struct DaemonState {
pub session: String,
pub socket_path: PathBuf,
pub ref_map: RefMap,
}
impl DaemonState {
pub fn new(session: String, socket_path: PathBuf) -> Self {
Self {
session,
socket_path,
ref_map: RefMap::new(),
}
}
}