From 3b1a2fb1668bf8f620799bde34a6b6f7b6dd32e7 Mon Sep 17 00:00:00 2001 From: Albert Armea Date: Wed, 31 Dec 2025 22:33:44 -0500 Subject: [PATCH] `shepherdd` shouldn't require root to run --- config.example.toml | 11 +- crates/shepherd-config/src/policy.rs | 14 +-- crates/shepherd-config/src/schema.rs | 6 +- crates/shepherd-hud/README.md | 2 +- crates/shepherd-hud/src/app.rs | 6 +- crates/shepherd-hud/src/main.rs | 10 +- crates/shepherd-hud/src/volume.rs | 15 +-- crates/shepherd-launcher-ui/README.md | 4 +- crates/shepherd-launcher-ui/src/main.rs | 10 +- crates/shepherd-util/README.md | 14 +++ crates/shepherd-util/src/lib.rs | 3 + crates/shepherd-util/src/paths.rs | 148 ++++++++++++++++++++++++ crates/shepherdd/README.md | 4 +- 13 files changed, 208 insertions(+), 39 deletions(-) create mode 100644 crates/shepherd-util/src/paths.rs diff --git a/config.example.toml b/config.example.toml index b9b0fbc..3be1ade 100644 --- a/config.example.toml +++ b/config.example.toml @@ -4,10 +4,13 @@ config_version = 1 [service] -# Uncomment to customize paths -# socket_path = "/run/shepherdd/shepherdd.sock" -# log_dir = "/var/log/shepherdd" -# data_dir = "/var/lib/shepherdd" +# Uncomment to customize paths (defaults use XDG directories for user-writable access) +# socket_path defaults to $XDG_RUNTIME_DIR/shepherdd/shepherdd.sock +# log_dir defaults to $XDG_STATE_HOME/shepherdd or ~/.local/state/shepherdd +# data_dir defaults to $XDG_DATA_HOME/shepherdd or ~/.local/share/shepherdd +# socket_path = "/run/user/1000/shepherdd/shepherdd.sock" +# log_dir = "~/.local/state/shepherdd" +# data_dir = "~/.local/share/shepherdd" # Default max run duration if not specified per entry (1 hour) # Set to 0 for unlimited (no time limit) diff --git a/crates/shepherd-config/src/policy.rs b/crates/shepherd-config/src/policy.rs index 841b95b..4579908 100644 --- a/crates/shepherd-config/src/policy.rs +++ b/crates/shepherd-config/src/policy.rs @@ -3,7 +3,7 @@ use crate::schema::{RawConfig, RawEntry, RawEntryKind, RawVolumeConfig, RawServiceConfig, RawWarningThreshold}; use crate::validation::{parse_days, parse_time}; use shepherd_api::{EntryKind, WarningSeverity, WarningThreshold}; -use shepherd_util::{DaysOfWeek, EntryId, TimeWindow, WallClock}; +use shepherd_util::{DaysOfWeek, EntryId, TimeWindow, WallClock, default_data_dir, default_log_dir, socket_path_without_env}; use std::path::PathBuf; use std::time::Duration; @@ -84,13 +84,13 @@ impl ServiceConfig { Self { socket_path: raw .socket_path - .unwrap_or_else(|| PathBuf::from("/run/shepherdd/shepherdd.sock")), + .unwrap_or_else(socket_path_without_env), log_dir: raw .log_dir - .unwrap_or_else(|| PathBuf::from("/var/log/shepherdd")), + .unwrap_or_else(default_log_dir), data_dir: raw .data_dir - .unwrap_or_else(|| PathBuf::from("/var/lib/shepherdd")), + .unwrap_or_else(default_data_dir), } } } @@ -98,9 +98,9 @@ impl ServiceConfig { impl Default for ServiceConfig { fn default() -> Self { Self { - socket_path: PathBuf::from("/run/shepherdd/shepherdd.sock"), - log_dir: PathBuf::from("/var/log/shepherdd"), - data_dir: PathBuf::from("/var/lib/shepherdd"), + socket_path: socket_path_without_env(), + log_dir: default_log_dir(), + data_dir: default_data_dir(), } } } diff --git a/crates/shepherd-config/src/schema.rs b/crates/shepherd-config/src/schema.rs index c26da68..99b939f 100644 --- a/crates/shepherd-config/src/schema.rs +++ b/crates/shepherd-config/src/schema.rs @@ -22,13 +22,13 @@ pub struct RawConfig { /// Service-level settings #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct RawServiceConfig { - /// IPC socket path (default: /run/shepherdd/shepherdd.sock) + /// IPC socket path (default: $XDG_RUNTIME_DIR/shepherdd/shepherdd.sock) pub socket_path: Option, - /// Log directory + /// Log directory (default: $XDG_STATE_HOME/shepherdd) pub log_dir: Option, - /// Data directory for store + /// Data directory for store (default: $XDG_DATA_HOME/shepherdd) pub data_dir: Option, /// Default warning thresholds (can be overridden per entry) diff --git a/crates/shepherd-hud/README.md b/crates/shepherd-hud/README.md index 9e10045..d4be986 100644 --- a/crates/shepherd-hud/README.md +++ b/crates/shepherd-hud/README.md @@ -56,7 +56,7 @@ shepherd-hud --anchor top --height 48 | Option | Default | Description | |--------|---------|-------------| -| `-s, --socket` | `/run/shepherdd/shepherdd.sock` | Service socket path | +| `-s, --socket` | `$XDG_RUNTIME_DIR/shepherdd/shepherdd.sock` | Service socket path | | `-l, --log-level` | `info` | Log verbosity | | `-a, --anchor` | `top` | Screen edge (`top` or `bottom`) | | `--height` | `48` | HUD bar height in pixels | diff --git a/crates/shepherd-hud/src/app.rs b/crates/shepherd-hud/src/app.rs index 170f389..173129b 100644 --- a/crates/shepherd-hud/src/app.rs +++ b/crates/shepherd-hud/src/app.rs @@ -11,6 +11,7 @@ use gtk4::prelude::*; use gtk4_layer_shell::{Edge, Layer, LayerShell}; use shepherd_api::Command; use shepherd_ipc::IpcClient; +use shepherd_util::default_socket_path; use std::path::PathBuf; use std::sync::mpsc; use std::time::Duration; @@ -329,12 +330,11 @@ fn build_hud_content(state: SharedState) -> gtk4::Box { if let Some(session_id) = session_state.session_id() { tracing::info!("Requesting end session for {}", session_id); // Send StopCurrent command to shepherdd - let socket_path = std::env::var("SHEPHERD_SOCKET") - .unwrap_or_else(|_| "./dev-runtime/shepherd.sock".to_string()); + let socket_path = default_socket_path(); std::thread::spawn(move || { let rt = Runtime::new().expect("Failed to create runtime"); rt.block_on(async { - match IpcClient::connect(std::path::PathBuf::from(&socket_path)).await { + match IpcClient::connect(&socket_path).await { Ok(mut client) => { let cmd = Command::StopCurrent { mode: shepherd_api::StopMode::Graceful, diff --git a/crates/shepherd-hud/src/main.rs b/crates/shepherd-hud/src/main.rs index e0aeb96..b401672 100644 --- a/crates/shepherd-hud/src/main.rs +++ b/crates/shepherd-hud/src/main.rs @@ -11,6 +11,7 @@ mod volume; use anyhow::Result; use clap::Parser; +use shepherd_util::default_socket_path; use std::path::PathBuf; use tracing_subscriber::EnvFilter; @@ -20,8 +21,8 @@ use tracing_subscriber::EnvFilter; #[command(about = "GTK4 layer-shell HUD for shepherdd", long_about = None)] struct Args { /// Socket path for shepherdd connection (or set SHEPHERD_SOCKET env var) - #[arg(short, long, env = "SHEPHERD_SOCKET", default_value = "/run/shepherdd/shepherdd.sock")] - socket: PathBuf, + #[arg(short, long, env = "SHEPHERD_SOCKET")] + socket: Option, /// Log level #[arg(short, long, default_value = "info")] @@ -49,8 +50,11 @@ fn main() -> Result<()> { tracing::info!("Starting Shepherd HUD"); + // Determine socket path with fallback to default + let socket_path = args.socket.unwrap_or_else(default_socket_path); + // Run GTK application - let application = app::HudApp::new(args.socket, args.anchor, args.height); + let application = app::HudApp::new(socket_path, args.anchor, args.height); let exit_code = application.run(); std::process::exit(exit_code); diff --git a/crates/shepherd-hud/src/volume.rs b/crates/shepherd-hud/src/volume.rs index b54692f..1ec1828 100644 --- a/crates/shepherd-hud/src/volume.rs +++ b/crates/shepherd-hud/src/volume.rs @@ -5,19 +5,12 @@ use shepherd_api::{Command, ResponsePayload, VolumeInfo}; use shepherd_ipc::IpcClient; -use std::path::PathBuf; +use shepherd_util::default_socket_path; use tokio::runtime::Runtime; -/// Get the default socket path from environment or fallback -fn get_socket_path() -> PathBuf { - std::env::var("SHEPHERD_SOCKET") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("./dev-runtime/shepherd.sock")) -} - /// Get current volume status from shepherdd pub fn get_volume_status() -> Option { - let socket_path = get_socket_path(); + let socket_path = default_socket_path(); let rt = match Runtime::new() { Ok(rt) => rt, @@ -54,7 +47,7 @@ pub fn get_volume_status() -> Option { /// Toggle mute state via shepherdd pub fn toggle_mute() -> anyhow::Result<()> { - let socket_path = get_socket_path(); + let socket_path = default_socket_path(); let rt = Runtime::new()?; @@ -77,7 +70,7 @@ pub fn toggle_mute() -> anyhow::Result<()> { /// Set volume to a specific percentage via shepherdd pub fn set_volume(percent: u8) -> anyhow::Result<()> { - let socket_path = get_socket_path(); + let socket_path = default_socket_path(); let rt = Runtime::new()?; diff --git a/crates/shepherd-launcher-ui/README.md b/crates/shepherd-launcher-ui/README.md index f0e389b..a18a98b 100644 --- a/crates/shepherd-launcher-ui/README.md +++ b/crates/shepherd-launcher-ui/README.md @@ -49,14 +49,14 @@ This is what users see when no session is active—the "home screen" of the envi shepherd-launcher # With custom socket path -shepherd-launcher --socket /run/shepherdd/shepherdd.sock +shepherd-launcher --socket /custom/path/shepherdd.sock ``` ### Command-Line Options | Option | Default | Description | |--------|---------|-------------| -| `-s, --socket` | `/run/shepherdd/shepherdd.sock` | Service socket path | +| `-s, --socket` | `$XDG_RUNTIME_DIR/shepherdd/shepherdd.sock` | Service socket path | | `-l, --log-level` | `info` | Log verbosity | ## Grid Behavior diff --git a/crates/shepherd-launcher-ui/src/main.rs b/crates/shepherd-launcher-ui/src/main.rs index 5b66603..054a3cf 100644 --- a/crates/shepherd-launcher-ui/src/main.rs +++ b/crates/shepherd-launcher-ui/src/main.rs @@ -11,6 +11,7 @@ mod tile; use anyhow::Result; use clap::Parser; +use shepherd_util::default_socket_path; use std::path::PathBuf; use tracing_subscriber::EnvFilter; @@ -20,8 +21,8 @@ use tracing_subscriber::EnvFilter; #[command(about = "GTK4 launcher UI for shepherdd", long_about = None)] struct Args { /// Socket path for shepherdd connection (or set SHEPHERD_SOCKET env var) - #[arg(short, long, env = "SHEPHERD_SOCKET", default_value = "/run/shepherdd/shepherdd.sock")] - socket: PathBuf, + #[arg(short, long, env = "SHEPHERD_SOCKET")] + socket: Option, /// Log level #[arg(short, long, default_value = "info")] @@ -41,8 +42,11 @@ fn main() -> Result<()> { tracing::info!("Starting Shepherd Launcher UI"); + // Determine socket path with fallback to default + let socket_path = args.socket.unwrap_or_else(default_socket_path); + // Run GTK application - let application = app::LauncherApp::new(args.socket); + let application = app::LauncherApp::new(socket_path); let exit_code = application.run(); std::process::exit(exit_code); diff --git a/crates/shepherd-util/README.md b/crates/shepherd-util/README.md index 16878c4..fd08dd4 100644 --- a/crates/shepherd-util/README.md +++ b/crates/shepherd-util/README.md @@ -10,6 +10,7 @@ This crate provides common utilities and types used across all Shepherd crates, - **Time utilities** - Monotonic time handling and duration helpers - **Error types** - Common error definitions - **Rate limiting** - Helpers for command rate limiting +- **Default paths** - XDG-compliant default paths for socket, data, and log directories ## Purpose @@ -56,6 +57,19 @@ if limiter.check(&client_id) { } ``` +### Default Paths + +```rust +use shepherd_util::{default_socket_path, default_data_dir, default_log_dir}; + +// Get XDG-compliant paths (no root required) +let socket = default_socket_path(); // $XDG_RUNTIME_DIR/shepherdd/shepherdd.sock +let data = default_data_dir(); // $XDG_DATA_HOME/shepherdd or ~/.local/share/shepherdd +let logs = default_log_dir(); // $XDG_STATE_HOME/shepherdd or ~/.local/state/shepherdd +``` + +Environment variables `SHEPHERD_SOCKET` and `SHEPHERD_DATA_DIR` can override the defaults. + ## Design Philosophy - **No platform-specific code** - Pure Rust, works everywhere diff --git a/crates/shepherd-util/src/lib.rs b/crates/shepherd-util/src/lib.rs index 91b00ac..f821418 100644 --- a/crates/shepherd-util/src/lib.rs +++ b/crates/shepherd-util/src/lib.rs @@ -5,13 +5,16 @@ //! - Time utilities (monotonic time, duration helpers) //! - Error types //! - Rate limiting helpers +//! - Default paths for socket, data, and log directories mod error; mod ids; +mod paths; mod rate_limit; mod time; pub use error::*; pub use ids::*; +pub use paths::*; pub use rate_limit::*; pub use time::*; diff --git a/crates/shepherd-util/src/paths.rs b/crates/shepherd-util/src/paths.rs new file mode 100644 index 0000000..6e26cf0 --- /dev/null +++ b/crates/shepherd-util/src/paths.rs @@ -0,0 +1,148 @@ +//! Default paths for shepherdd components +//! +//! Provides centralized path defaults that all crates can use. +//! Paths are user-writable by default (no root required): +//! - Socket: `$XDG_RUNTIME_DIR/shepherdd/shepherdd.sock` or `/tmp/shepherdd-$USER/shepherdd.sock` +//! - Data: `$XDG_DATA_HOME/shepherdd` or `~/.local/share/shepherdd` +//! - Logs: `$XDG_STATE_HOME/shepherdd` or `~/.local/state/shepherdd` + +use std::path::PathBuf; + +/// Environment variable for overriding the socket path +pub const SHEPHERD_SOCKET_ENV: &str = "SHEPHERD_SOCKET"; + +/// Environment variable for overriding the data directory +pub const SHEPHERD_DATA_DIR_ENV: &str = "SHEPHERD_DATA_DIR"; + +/// Socket filename within the socket directory +const SOCKET_FILENAME: &str = "shepherdd.sock"; + +/// Application subdirectory name +const APP_DIR: &str = "shepherdd"; + +/// Get the default socket path. +/// +/// Order of precedence: +/// 1. `$SHEPHERD_SOCKET` environment variable (if set) +/// 2. `$XDG_RUNTIME_DIR/shepherdd/shepherdd.sock` (if XDG_RUNTIME_DIR is set) +/// 3. `/tmp/shepherdd-$USER/shepherdd.sock` (fallback) +pub fn default_socket_path() -> PathBuf { + // Check environment override first + if let Ok(path) = std::env::var(SHEPHERD_SOCKET_ENV) { + return PathBuf::from(path); + } + + socket_path_without_env() +} + +/// Get the socket path without checking SHEPHERD_SOCKET env var. +/// Used for default values in configs where the env var is checked separately. +pub fn socket_path_without_env() -> PathBuf { + // Try XDG_RUNTIME_DIR first (typically /run/user/) + if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { + return PathBuf::from(runtime_dir).join(APP_DIR).join(SOCKET_FILENAME); + } + + // Fallback to /tmp with username + let username = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()); + PathBuf::from(format!("/tmp/{}-{}", APP_DIR, username)).join(SOCKET_FILENAME) +} + +/// Get the default data directory. +/// +/// Order of precedence: +/// 1. `$SHEPHERD_DATA_DIR` environment variable (if set) +/// 2. `$XDG_DATA_HOME/shepherdd` (if XDG_DATA_HOME is set) +/// 3. `~/.local/share/shepherdd` (fallback) +pub fn default_data_dir() -> PathBuf { + // Check environment override first + if let Ok(path) = std::env::var(SHEPHERD_DATA_DIR_ENV) { + return PathBuf::from(path); + } + + data_dir_without_env() +} + +/// Get the data directory without checking SHEPHERD_DATA_DIR env var. +/// Used for default values in configs where the env var is checked separately. +pub fn data_dir_without_env() -> PathBuf { + // Try XDG_DATA_HOME first + if let Ok(data_home) = std::env::var("XDG_DATA_HOME") { + return PathBuf::from(data_home).join(APP_DIR); + } + + // Fallback to ~/.local/share/shepherdd + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join(".local") + .join("share") + .join(APP_DIR); + } + + // Last resort + PathBuf::from("/tmp").join(APP_DIR).join("data") +} + +/// Get the default log directory. +/// +/// Order of precedence: +/// 1. `$XDG_STATE_HOME/shepherdd` (if XDG_STATE_HOME is set) +/// 2. `~/.local/state/shepherdd` (fallback) +pub fn default_log_dir() -> PathBuf { + // Try XDG_STATE_HOME first + if let Ok(state_home) = std::env::var("XDG_STATE_HOME") { + return PathBuf::from(state_home).join(APP_DIR); + } + + // Fallback to ~/.local/state/shepherdd + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join(".local") + .join("state") + .join(APP_DIR); + } + + // Last resort + PathBuf::from("/tmp").join(APP_DIR).join("logs") +} + +/// Get the parent directory of the socket (for creating it) +pub fn socket_dir() -> PathBuf { + let socket_path = socket_path_without_env(); + socket_path.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| { + // Should never happen with our paths, but just in case + PathBuf::from("/tmp").join(APP_DIR) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn socket_path_contains_shepherdd() { + // The socket path should always contain "shepherdd" regardless of environment + let path = socket_path_without_env(); + assert!(path.to_string_lossy().contains("shepherdd")); + assert!(path.to_string_lossy().contains(".sock")); + } + + #[test] + fn data_dir_contains_shepherdd() { + let path = data_dir_without_env(); + assert!(path.to_string_lossy().contains("shepherdd")); + } + + #[test] + fn log_dir_contains_shepherdd() { + let path = default_log_dir(); + assert!(path.to_string_lossy().contains("shepherdd")); + } + + #[test] + fn socket_dir_is_parent_of_socket_path() { + let socket = socket_path_without_env(); + let dir = socket_dir(); + assert_eq!(socket.parent().unwrap(), dir); + } +} diff --git a/crates/shepherdd/README.md b/crates/shepherdd/README.md index 7ac0d3c..b861e1c 100644 --- a/crates/shepherdd/README.md +++ b/crates/shepherdd/README.md @@ -74,8 +74,8 @@ shepherdd --log-level debug | Variable | Description | |----------|-------------| -| `SHEPHERD_SOCKET` | Override socket path | -| `SHEPHERD_DATA_DIR` | Override data directory | +| `SHEPHERD_SOCKET` | Override socket path (default: `$XDG_RUNTIME_DIR/shepherdd/shepherdd.sock`) | +| `SHEPHERD_DATA_DIR` | Override data directory (default: `$XDG_DATA_HOME/shepherdd`) | | `RUST_LOG` | Tracing filter (e.g., `shepherdd=debug`) | ## Main Loop