Files
tvctl/src/daemon/config.rs
T
44r0n7 795aa2f713 refactor: harden internal daemon entrypoint and cleanup observations
Remove the internal daemon subcommand from the public CLI surface,
start the daemon via an internal env trigger, and ensure generated
completions/help never expose internal entrypoints.

Also finish the pending observation cleanups and docs updates,
including config/key deduplication, registry matching cleanup, and
remaining roadmap/project map staleness fixes.
2026-04-18 11:55:18 -04:00

389 lines
13 KiB
Rust

use std::{
collections::BTreeMap,
env,
path::{Path, PathBuf},
};
use anyhow::bail;
use serde::{Deserialize, Serialize};
use tokio::fs;
use tracing_subscriber::EnvFilter;
/// The complete daemon configuration loaded from TOML.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct TvctlConfig {
/// Runtime daemon settings.
pub daemon: DaemonConfig,
/// Discovery scan behavior.
pub discovery: DiscoveryConfig,
/// Default-device settings.
pub devices: DeviceConfig,
/// Remote input behavior.
pub remote: RemoteConfig,
/// Developer tooling toggles.
pub dev: DevConfig,
}
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(())
}
/// Return the config as stable flattened key/value pairs.
pub fn entries(&self) -> BTreeMap<&'static str, String> {
BTreeMap::from([
("daemon.socket", self.daemon.socket.clone()),
("daemon.http_enabled", self.daemon.http_enabled.to_string()),
("daemon.http_port", self.daemon.http_port.to_string()),
("daemon.http_host", self.daemon.http_host.clone()),
("daemon.log_level", self.daemon.log_level.clone()),
(
"discovery.auto_discover",
self.discovery.auto_discover.to_string(),
),
(
"discovery.interval_secs",
self.discovery.interval_secs.to_string(),
),
(
"discovery.timeout_secs",
self.discovery.timeout_secs.to_string(),
),
("devices.default", self.devices.default.clone()),
("remote.roku_key_mode", self.remote.roku_key_mode.clone()),
(
"remote.roku_press_duration_ms",
self.remote.roku_press_duration_ms.to_string(),
),
("dev.enabled", self.dev.enabled.to_string()),
("dev.roku_username", self.dev.roku_username.clone()),
("dev.roku_password", self.dev.roku_password.clone()),
])
}
/// Return one flattened config value by stable key.
pub fn get_value(&self, key: &str) -> Option<String> {
match key {
"daemon.socket" => Some(self.daemon.socket.clone()),
"daemon.http_enabled" => Some(self.daemon.http_enabled.to_string()),
"daemon.http_port" => Some(self.daemon.http_port.to_string()),
"daemon.http_host" => Some(self.daemon.http_host.clone()),
"daemon.log_level" => Some(self.daemon.log_level.clone()),
"discovery.auto_discover" => Some(self.discovery.auto_discover.to_string()),
"discovery.interval_secs" => Some(self.discovery.interval_secs.to_string()),
"discovery.timeout_secs" => Some(self.discovery.timeout_secs.to_string()),
"devices.default" => Some(self.devices.default.clone()),
"remote.roku_key_mode" => Some(self.remote.roku_key_mode.clone()),
"remote.roku_press_duration_ms" => Some(self.remote.roku_press_duration_ms.to_string()),
"dev.enabled" => Some(self.dev.enabled.to_string()),
"dev.roku_username" => Some(self.dev.roku_username.clone()),
"dev.roku_password" => Some(self.dev.roku_password.clone()),
_ => None,
}
}
/// Set one flattened config value by stable key.
pub fn set_value(&mut self, key: &str, value: &str) -> anyhow::Result<()> {
match key {
"daemon.socket" => self.daemon.socket = value.to_string(),
"daemon.http_enabled" => self.daemon.http_enabled = parse_value(key, value)?,
"daemon.http_port" => self.daemon.http_port = parse_value(key, value)?,
"daemon.http_host" => self.daemon.http_host = value.to_string(),
"daemon.log_level" => {
validate_log_level(value)?;
self.daemon.log_level = value.to_string();
}
"discovery.auto_discover" => self.discovery.auto_discover = parse_value(key, value)?,
"discovery.interval_secs" => self.discovery.interval_secs = parse_value(key, value)?,
"discovery.timeout_secs" => self.discovery.timeout_secs = parse_value(key, value)?,
"devices.default" => self.devices.default = value.to_string(),
"remote.roku_key_mode" => self.remote.roku_key_mode = value.to_string(),
"remote.roku_press_duration_ms" => {
self.remote.roku_press_duration_ms = parse_value(key, value)?
}
"dev.enabled" => self.dev.enabled = parse_value(key, value)?,
"dev.roku_username" => self.dev.roku_username = value.to_string(),
"dev.roku_password" => self.dev.roku_password = value.to_string(),
other => bail!("unknown config key '{other}'"),
}
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,
}
/// Remote input behavior.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct RemoteConfig {
/// Roku key delivery mode: `keypress` or `keydown_up`.
pub roku_key_mode: String,
/// How long a Roku key stays pressed before `keyup`, in milliseconds.
pub roku_press_duration_ms: u64,
}
impl Default for RemoteConfig {
fn default() -> Self {
Self {
roku_key_mode: "keypress".to_string(),
roku_press_duration_ms: 75,
}
}
}
/// Developer tooling toggles.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct DevConfig {
/// Whether developer tooling is enabled.
pub enabled: bool,
/// Optional Roku developer username override.
pub roku_username: String,
/// Optional Roku developer password override.
pub roku_password: String,
}
impl Default for DevConfig {
fn default() -> Self {
Self {
enabled: true,
roku_username: String::new(),
roku_password: String::new(),
}
}
}
/// 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 file containing the currently active daemon socket path.
pub active_socket_file: 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"),
active_socket_file: data_dir.join("active_socket"),
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 user-level systemd unit directory path.
pub fn systemd_user_dir() -> PathBuf {
if let Ok(path) = env::var("XDG_CONFIG_HOME") {
return PathBuf::from(path).join("systemd/user");
}
home_dir().join(".config/systemd/user")
}
/// Return the canonical tvctld user service unit path.
pub fn systemd_unit_path() -> PathBuf {
systemd_user_dir().join("tvctld.service")
}
/// 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() }
}
fn parse_value<T>(key: &str, value: &str) -> anyhow::Result<T>
where
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
value
.parse::<T>()
.map_err(|error| anyhow::anyhow!("invalid value '{value}' for {key}: {error}"))
}
fn validate_log_level(value: &str) -> anyhow::Result<()> {
EnvFilter::try_new(value).map_err(|error| {
anyhow::anyhow!("invalid value '{value}' for daemon.log_level: {error}")
})?;
Ok(())
}
#[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(),
},
dev: DevConfig {
enabled: true,
roku_username: "rokudev".to_string(),
roku_password: "secret".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);
}
#[test]
fn rejects_invalid_log_level_values() {
let mut config = TvctlConfig::default();
let error = config
.set_value("daemon.log_level", "[")
.expect_err("invalid log level should fail");
assert!(
error
.to_string()
.contains("invalid value '[' for daemon.log_level"),
"unexpected error: {error}"
);
}
}