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

164
src/cli/connection.rs Normal file
View file

@ -0,0 +1,164 @@
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::os::unix::process::CommandExt;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;
use anyhow::{bail, Context, Result};
use crate::cli::GlobalOpts;
use crate::core::protocol::{Request, Response};
fn socket_dir() -> PathBuf {
if let Ok(dir) = std::env::var("DESKTOP_CTL_SOCKET_DIR") {
return PathBuf::from(dir);
}
if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") {
return PathBuf::from(runtime).join("desktop-ctl");
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".desktop-ctl")
}
fn socket_path(opts: &GlobalOpts) -> PathBuf {
if let Some(ref path) = opts.socket {
return path.clone();
}
socket_dir().join(format!("{}.sock", opts.session))
}
fn pid_path(opts: &GlobalOpts) -> PathBuf {
socket_dir().join(format!("{}.pid", opts.session))
}
fn try_connect(opts: &GlobalOpts) -> Option<UnixStream> {
UnixStream::connect(socket_path(opts)).ok()
}
fn spawn_daemon(opts: &GlobalOpts) -> Result<()> {
let exe = std::env::current_exe()
.context("Failed to determine executable path")?;
let sock_dir = socket_dir();
std::fs::create_dir_all(&sock_dir)
.context("Failed to create socket directory")?;
let mut cmd = Command::new(exe);
cmd.env("DESKTOP_CTL_DAEMON", "1")
.env("DESKTOP_CTL_SESSION", &opts.session)
.env("DESKTOP_CTL_SOCKET_PATH", socket_path(opts))
.env("DESKTOP_CTL_PID_PATH", pid_path(opts))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped());
// Detach the daemon process on Unix
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
cmd.spawn().context("Failed to spawn daemon")?;
Ok(())
}
fn ensure_daemon(opts: &GlobalOpts) -> Result<UnixStream> {
// Try connecting first
if let Some(stream) = try_connect(opts) {
return Ok(stream);
}
// 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);
}
}
bail!(
"Failed to connect to daemon after {} retries.\n\
Socket 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)
}
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");
}
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");
}
None => {
// Try to clean up stale socket
let path = socket_path(opts);
if path.exists() {
std::fs::remove_file(&path)?;
println!("Removed stale socket: {}", path.display());
} else {
println!("Daemon not running");
}
}
}
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");
}
Ok(())
}

321
src/cli/mod.rs Normal file
View file

@ -0,0 +1,321 @@
mod connection;
use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
use anyhow::Result;
use crate::core::protocol::{Request, Response};
#[derive(Parser)]
#[command(name = "desktop-ctl", version, about = "Desktop control CLI for AI agents")]
pub struct App {
#[command(flatten)]
pub global: GlobalOpts,
#[command(subcommand)]
pub command: Command,
}
#[derive(Args)]
pub struct GlobalOpts {
/// Path to the daemon Unix socket
#[arg(long, global = true, env = "DESKTOP_CTL_SOCKET")]
pub socket: Option<PathBuf>,
/// Session name (allows multiple daemon instances)
#[arg(long, global = true, default_value = "default")]
pub session: String,
/// Output as JSON
#[arg(long, global = true)]
pub json: bool,
}
#[derive(Subcommand)]
pub enum Command {
/// Take a screenshot and list windows with @wN refs
Snapshot {
/// Draw bounding boxes and labels on the screenshot
#[arg(long)]
annotate: bool,
},
/// Click a window ref or coordinates
Click {
/// @w1 or x,y coordinates
selector: String,
},
/// Double-click a window ref or coordinates
Dblclick {
/// @w1 or x,y coordinates
selector: String,
},
/// Type text into the focused window
Type {
/// Text to type
text: String,
},
/// Press a key (e.g. enter, tab, escape)
Press {
/// Key name
key: String,
},
/// Send a hotkey combination (e.g. ctrl c)
Hotkey {
/// Key names (e.g. ctrl shift t)
keys: Vec<String>,
},
/// Mouse operations
#[command(subcommand)]
Mouse(MouseCmd),
/// Focus a window by ref or name
Focus {
/// @w1 or window name substring
selector: String,
},
/// Close a window by ref or name
Close {
/// @w1 or window name substring
selector: String,
},
/// Move a window
MoveWindow {
/// @w1 or window name substring
selector: String,
/// X position
x: i32,
/// Y position
y: i32,
},
/// Resize a window
ResizeWindow {
/// @w1 or window name substring
selector: String,
/// Width
w: u32,
/// Height
h: u32,
},
/// List all windows (same as snapshot but without screenshot)
ListWindows,
/// Get screen resolution
GetScreenSize,
/// Get current mouse position
GetMousePosition,
/// Take a screenshot without window tree
Screenshot {
/// Save path (default: /tmp/desktop-ctl-{timestamp}.png)
path: Option<PathBuf>,
/// Draw bounding boxes and labels
#[arg(long)]
annotate: bool,
},
/// Launch an application
Launch {
/// Command to run
command: String,
/// Arguments
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
/// Daemon management (hidden - internal use)
#[command(hide = true)]
Daemon(DaemonCmd),
}
#[derive(Subcommand)]
pub enum MouseCmd {
/// Move the mouse cursor
Move {
/// X coordinate
x: i32,
/// Y coordinate
y: i32,
},
/// Scroll the mouse wheel
Scroll {
/// Amount (positive = down, negative = up)
amount: i32,
/// Axis: vertical or horizontal
#[arg(long, default_value = "vertical")]
axis: String,
},
/// Drag from one position to another
Drag {
/// Start X
x1: i32,
/// Start Y
y1: i32,
/// End X
x2: i32,
/// End Y
y2: i32,
},
}
#[derive(Args)]
pub struct DaemonCmd {
#[command(subcommand)]
pub action: DaemonAction,
}
#[derive(Subcommand)]
pub enum DaemonAction {
/// Start the daemon
Start,
/// Stop the daemon
Stop,
/// Show daemon status
Status,
}
pub fn run() -> Result<()> {
let app = App::parse();
// Handle daemon subcommands that don't need a running daemon
if let Command::Daemon(ref cmd) = app.command {
return match cmd.action {
DaemonAction::Start => connection::start_daemon(&app.global),
DaemonAction::Stop => connection::stop_daemon(&app.global),
DaemonAction::Status => connection::daemon_status(&app.global),
};
}
// All other commands need a daemon connection
let request = build_request(&app.command)?;
let response = connection::send_command(&app.global, &request)?;
if app.global.json {
println!("{}", serde_json::to_string_pretty(&response)?);
} else {
print_response(&app.command, &response)?;
}
Ok(())
}
fn build_request(cmd: &Command) -> Result<Request> {
use serde_json::json;
let req = match cmd {
Command::Snapshot { annotate } => {
Request::new("snapshot")
.with_extra("annotate", json!(annotate))
}
Command::Click { selector } => {
Request::new("click")
.with_extra("selector", json!(selector))
}
Command::Dblclick { selector } => {
Request::new("dblclick")
.with_extra("selector", json!(selector))
}
Command::Type { text } => {
Request::new("type")
.with_extra("text", json!(text))
}
Command::Press { key } => {
Request::new("press")
.with_extra("key", json!(key))
}
Command::Hotkey { keys } => {
Request::new("hotkey")
.with_extra("keys", json!(keys))
}
Command::Mouse(sub) => match sub {
MouseCmd::Move { x, y } => {
Request::new("mouse-move")
.with_extra("x", json!(x))
.with_extra("y", json!(y))
}
MouseCmd::Scroll { amount, axis } => {
Request::new("mouse-scroll")
.with_extra("amount", json!(amount))
.with_extra("axis", json!(axis))
}
MouseCmd::Drag { x1, y1, x2, y2 } => {
Request::new("mouse-drag")
.with_extra("x1", json!(x1))
.with_extra("y1", json!(y1))
.with_extra("x2", json!(x2))
.with_extra("y2", json!(y2))
}
},
Command::Focus { selector } => {
Request::new("focus")
.with_extra("selector", json!(selector))
}
Command::Close { selector } => {
Request::new("close")
.with_extra("selector", json!(selector))
}
Command::MoveWindow { selector, x, y } => {
Request::new("move-window")
.with_extra("selector", json!(selector))
.with_extra("x", json!(x))
.with_extra("y", json!(y))
}
Command::ResizeWindow { selector, w, h } => {
Request::new("resize-window")
.with_extra("selector", json!(selector))
.with_extra("w", json!(w))
.with_extra("h", json!(h))
}
Command::ListWindows => Request::new("list-windows"),
Command::GetScreenSize => Request::new("get-screen-size"),
Command::GetMousePosition => Request::new("get-mouse-position"),
Command::Screenshot { path, annotate } => {
let mut req = Request::new("screenshot")
.with_extra("annotate", json!(annotate));
if let Some(p) = path {
req = req.with_extra("path", json!(p.to_string_lossy()));
}
req
}
Command::Launch { command, args } => {
Request::new("launch")
.with_extra("command", json!(command))
.with_extra("args", json!(args))
}
Command::Daemon(_) => unreachable!(),
};
Ok(req)
}
fn print_response(cmd: &Command, response: &Response) -> Result<()> {
if !response.success {
if let Some(ref err) = response.error {
eprintln!("Error: {err}");
}
std::process::exit(1);
}
if let Some(ref data) = response.data {
// For snapshot, print compact text format
if matches!(cmd, Command::Snapshot { .. }) {
if let Some(screenshot) = data.get("screenshot").and_then(|v| v.as_str()) {
println!("Screenshot: {screenshot}");
}
if let Some(windows) = data.get("windows").and_then(|v| v.as_array()) {
println!("Windows:");
for w in windows {
let ref_id = w.get("ref_id").and_then(|v| v.as_str()).unwrap_or("?");
let title = w.get("title").and_then(|v| v.as_str()).unwrap_or("");
let focused = w.get("focused").and_then(|v| v.as_bool()).unwrap_or(false);
let minimized = w.get("minimized").and_then(|v| v.as_bool()).unwrap_or(false);
let x = w.get("x").and_then(|v| v.as_i64()).unwrap_or(0);
let y = w.get("y").and_then(|v| v.as_i64()).unwrap_or(0);
let width = w.get("width").and_then(|v| v.as_u64()).unwrap_or(0);
let height = w.get("height").and_then(|v| v.as_u64()).unwrap_or(0);
let state = if focused { "focused" } else if minimized { "hidden" } else { "visible" };
let display_title = if title.len() > 30 {
format!("{}...", &title[..27])
} else {
title.to_string()
};
println!("@{:<4} {:<30} ({:<7}) {},{} {}x{}", ref_id, display_title, state, x, y, width, height);
}
}
} else {
// Generic: print JSON data
println!("{}", serde_json::to_string_pretty(data)?);
}
}
Ok(())
}

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])
}
}

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(),
}
}
}

10
src/main.rs Normal file
View file

@ -0,0 +1,10 @@
mod cli;
mod core;
mod daemon;
fn main() -> anyhow::Result<()> {
if std::env::var("DESKTOP_CTL_DAEMON").is_ok() {
return daemon::run();
}
cli::run()
}