feat: complete daemon core milestone
Finish Milestone 3 with persisted config, socket IPC, registry CRUD, periodic discovery, manual add, and app-cache refresh support.
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
use std::{
|
||||
env,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::fs;
|
||||
|
||||
/// The complete daemon configuration loaded from TOML.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct TvctlConfig {
|
||||
/// Runtime daemon settings.
|
||||
pub daemon: DaemonConfig,
|
||||
/// Discovery scan behavior.
|
||||
pub discovery: DiscoveryConfig,
|
||||
/// Default-device settings.
|
||||
pub devices: DeviceConfig,
|
||||
/// Developer tooling toggles.
|
||||
pub dev: DevConfig,
|
||||
}
|
||||
|
||||
impl Default for TvctlConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
daemon: DaemonConfig::default(),
|
||||
discovery: DiscoveryConfig::default(),
|
||||
devices: DeviceConfig::default(),
|
||||
dev: DevConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TvctlConfig {
|
||||
/// Load configuration from the default XDG path or return defaults when absent.
|
||||
pub async fn load() -> anyhow::Result<Self> {
|
||||
Self::load_from_path(&default_config_path()).await
|
||||
}
|
||||
|
||||
/// Load configuration from a specific path or return defaults when absent.
|
||||
pub async fn load_from_path(path: &Path) -> anyhow::Result<Self> {
|
||||
match fs::read_to_string(path).await {
|
||||
Ok(contents) => Ok(toml::from_str::<Self>(&contents)?),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist configuration to a specific path, creating parent directories first.
|
||||
pub async fn save_to_path(&self, path: &Path) -> anyhow::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let contents = toml::to_string_pretty(self)?;
|
||||
fs::write(path, contents).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime daemon settings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct DaemonConfig {
|
||||
/// Unix socket path for CLI communication.
|
||||
pub socket: String,
|
||||
/// Whether the HTTP API is enabled.
|
||||
pub http_enabled: bool,
|
||||
/// Loopback HTTP port.
|
||||
pub http_port: u16,
|
||||
/// Loopback host or bind address.
|
||||
pub http_host: String,
|
||||
/// Logging level.
|
||||
pub log_level: String,
|
||||
}
|
||||
|
||||
impl Default for DaemonConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
socket: default_socket_path().to_string_lossy().into_owned(),
|
||||
http_enabled: true,
|
||||
http_port: 7272,
|
||||
http_host: "127.0.0.1".to_string(),
|
||||
log_level: "info".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Discovery scan behavior.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct DiscoveryConfig {
|
||||
/// Whether discovery runs automatically on daemon start.
|
||||
pub auto_discover: bool,
|
||||
/// How often discovery repeats, in seconds.
|
||||
pub interval_secs: u64,
|
||||
/// Per-device timeout in seconds.
|
||||
pub timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for DiscoveryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_discover: true,
|
||||
interval_secs: 300,
|
||||
timeout_secs: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default-device settings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct DeviceConfig {
|
||||
/// The default device name or UUID.
|
||||
pub default: String,
|
||||
}
|
||||
|
||||
/// Developer tooling toggles.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct DevConfig {
|
||||
/// Whether developer tooling is enabled.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for DevConfig {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical runtime paths used by the daemon.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimePaths {
|
||||
/// The configuration file path.
|
||||
pub config_file: PathBuf,
|
||||
/// The data directory root.
|
||||
pub data_dir: PathBuf,
|
||||
/// The device registry path.
|
||||
pub devices_file: PathBuf,
|
||||
/// The platform app cache directory.
|
||||
pub cache_dir: PathBuf,
|
||||
/// The runtime socket path.
|
||||
pub socket_file: PathBuf,
|
||||
}
|
||||
|
||||
impl RuntimePaths {
|
||||
/// Build the canonical path set from XDG defaults.
|
||||
pub fn detect() -> Self {
|
||||
let config_file = default_config_path();
|
||||
let data_dir = default_data_dir();
|
||||
let cache_dir = data_dir.join("cache");
|
||||
Self {
|
||||
config_file,
|
||||
devices_file: data_dir.join("devices.json"),
|
||||
socket_file: default_socket_path(),
|
||||
data_dir,
|
||||
cache_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the default config file path.
|
||||
pub fn default_config_path() -> PathBuf {
|
||||
default_config_dir().join("config.toml")
|
||||
}
|
||||
|
||||
/// Return the default config directory path.
|
||||
pub fn default_config_dir() -> PathBuf {
|
||||
if let Ok(path) = env::var("XDG_CONFIG_HOME") {
|
||||
return PathBuf::from(path).join("tvctl");
|
||||
}
|
||||
home_dir().join(".config/tvctl")
|
||||
}
|
||||
|
||||
/// Return the default data directory path.
|
||||
pub fn default_data_dir() -> PathBuf {
|
||||
if let Ok(path) = env::var("XDG_DATA_HOME") {
|
||||
return PathBuf::from(path).join("tvctl");
|
||||
}
|
||||
home_dir().join(".local/share/tvctl")
|
||||
}
|
||||
|
||||
/// Return the default runtime socket path.
|
||||
pub fn default_socket_path() -> PathBuf {
|
||||
let runtime_dir = env::var("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from(format!("/run/user/{}", current_uid())));
|
||||
runtime_dir.join("tvctl.sock")
|
||||
}
|
||||
|
||||
fn home_dir() -> PathBuf {
|
||||
env::var("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("/tmp"))
|
||||
}
|
||||
|
||||
fn current_uid() -> u32 {
|
||||
// SAFETY: geteuid reads process state and has no side effects.
|
||||
unsafe { libc::geteuid() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_config_uses_defaults() {
|
||||
let temp_dir = tempfile::tempdir().expect("temp dir should exist");
|
||||
let config = TvctlConfig::load_from_path(&temp_dir.path().join("missing.toml"))
|
||||
.await
|
||||
.expect("default config should load");
|
||||
assert_eq!(config, TvctlConfig::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_round_trips() {
|
||||
let temp_dir = tempfile::tempdir().expect("temp dir should exist");
|
||||
let path = temp_dir.path().join("config.toml");
|
||||
let config = TvctlConfig {
|
||||
devices: DeviceConfig {
|
||||
default: "living-room".to_string(),
|
||||
},
|
||||
..TvctlConfig::default()
|
||||
};
|
||||
config
|
||||
.save_to_path(&path)
|
||||
.await
|
||||
.expect("config should save");
|
||||
let loaded = TvctlConfig::load_from_path(&path)
|
||||
.await
|
||||
.expect("config should load");
|
||||
assert_eq!(loaded, config);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user