feat: complete CLI milestone
Finish the Milestone 4 CLI surface with config management, daemon install and uninstall helpers, config reload handling, and final polish for secret redaction and running-socket tracking.
This commit is contained in:
+98
-1
@@ -1,8 +1,10 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
env,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::bail;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::fs;
|
||||
|
||||
@@ -55,6 +57,58 @@ impl TvctlConfig {
|
||||
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()),
|
||||
("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> {
|
||||
self.entries().remove(key)
|
||||
}
|
||||
|
||||
/// 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_bool(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" => self.daemon.log_level = value.to_string(),
|
||||
"discovery.auto_discover" => self.discovery.auto_discover = parse_bool(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(),
|
||||
"dev.enabled" => self.dev.enabled = parse_bool(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.
|
||||
@@ -121,11 +175,19 @@ pub struct DeviceConfig {
|
||||
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 }
|
||||
Self {
|
||||
enabled: true,
|
||||
roku_username: String::new(),
|
||||
roku_password: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +202,8 @@ pub struct RuntimePaths {
|
||||
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,
|
||||
}
|
||||
@@ -153,6 +217,7 @@ impl RuntimePaths {
|
||||
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,
|
||||
@@ -173,6 +238,19 @@ pub fn default_config_dir() -> PathBuf {
|
||||
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") {
|
||||
@@ -200,6 +278,20 @@ fn current_uid() -> u32 {
|
||||
unsafe { libc::geteuid() }
|
||||
}
|
||||
|
||||
fn parse_bool(key: &str, value: &str) -> anyhow::Result<bool> {
|
||||
parse_value(key, value)
|
||||
}
|
||||
|
||||
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}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -221,6 +313,11 @@ mod tests {
|
||||
devices: DeviceConfig {
|
||||
default: "living-room".to_string(),
|
||||
},
|
||||
dev: DevConfig {
|
||||
enabled: true,
|
||||
roku_username: "rokudev".to_string(),
|
||||
roku_password: "secret".to_string(),
|
||||
},
|
||||
..TvctlConfig::default()
|
||||
};
|
||||
config
|
||||
|
||||
+88
-1
@@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::adapters::{AppInfo, Device};
|
||||
use crate::adapters::{AppInfo, Device, DeviceState, TvKey};
|
||||
use crate::daemon::config::TvctlConfig;
|
||||
|
||||
/// A request sent from the CLI to the daemon over the Unix socket.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -56,6 +57,56 @@ pub enum DaemonRequest {
|
||||
/// Whether to clear the platform cache before reloading from the device.
|
||||
clear: bool,
|
||||
},
|
||||
/// Launch an app on one device.
|
||||
LaunchApp {
|
||||
/// Optional UUID or friendly name.
|
||||
device: Option<String>,
|
||||
/// App name, normalized id, or platform id.
|
||||
app: String,
|
||||
},
|
||||
/// Stop the currently running app on one device.
|
||||
StopApp {
|
||||
/// Optional UUID or friendly name.
|
||||
device: Option<String>,
|
||||
},
|
||||
/// Fetch the current state for one device.
|
||||
GetState {
|
||||
/// Optional UUID or friendly name.
|
||||
device: Option<String>,
|
||||
},
|
||||
/// Send one normalized key to one device.
|
||||
SendKey {
|
||||
/// Optional UUID or friendly name.
|
||||
device: Option<String>,
|
||||
/// Normalized key identifier.
|
||||
key: TvKey,
|
||||
},
|
||||
/// Send a normalized key sequence to one device.
|
||||
SendSequence {
|
||||
/// Optional UUID or friendly name.
|
||||
device: Option<String>,
|
||||
/// Normalized key identifiers.
|
||||
keys: Vec<TvKey>,
|
||||
},
|
||||
/// Install a dev package from a local zip path on one device.
|
||||
DevInstall {
|
||||
/// Optional UUID or friendly name.
|
||||
device: Option<String>,
|
||||
/// Local filesystem path to the zip package.
|
||||
zip_path: String,
|
||||
},
|
||||
/// Reload the active dev package on one device.
|
||||
DevReload {
|
||||
/// Optional UUID or friendly name.
|
||||
device: Option<String>,
|
||||
},
|
||||
/// Fetch developer logs from one device.
|
||||
DevLogs {
|
||||
/// Optional UUID or friendly name.
|
||||
device: Option<String>,
|
||||
},
|
||||
/// Reload config from disk into the running daemon.
|
||||
ReloadConfig,
|
||||
}
|
||||
|
||||
/// A standard daemon response envelope for IPC.
|
||||
@@ -148,3 +199,39 @@ pub struct AppRefreshResult {
|
||||
/// The total number of cached apps after merge/replace.
|
||||
pub cached_count: usize,
|
||||
}
|
||||
|
||||
/// A simple action result for one device-targeted command.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ActionResult {
|
||||
/// The device the action ran against.
|
||||
pub device: Device,
|
||||
/// A human-readable description of what happened.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// State results returned by the daemon.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct StateResult {
|
||||
/// The device the state belongs to.
|
||||
pub device: Device,
|
||||
/// The live state snapshot.
|
||||
pub state: DeviceState,
|
||||
}
|
||||
|
||||
/// Developer log results returned by the daemon.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DevLogsResult {
|
||||
/// The device the logs came from.
|
||||
pub device: Device,
|
||||
/// Recent developer log lines.
|
||||
pub lines: Vec<String>,
|
||||
}
|
||||
|
||||
/// Config reload results returned by the daemon.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ConfigReloadResult {
|
||||
/// The newly loaded config snapshot.
|
||||
pub config: TvctlConfig,
|
||||
/// Keys that still require a daemon restart to fully apply.
|
||||
pub restart_required: Vec<String>,
|
||||
}
|
||||
|
||||
+338
-2
@@ -15,7 +15,8 @@ use cache::AppCacheStore;
|
||||
use config::{RuntimePaths, TvctlConfig};
|
||||
use discovery::DiscoveryService;
|
||||
use ipc::{
|
||||
AppListResult, AppRefreshResult, DaemonRequest, DaemonResponse, DaemonStatus, DiscoveryResult,
|
||||
ActionResult, AppListResult, AppRefreshResult, ConfigReloadResult, DaemonRequest,
|
||||
DaemonResponse, DaemonStatus, DevLogsResult, DiscoveryResult, StateResult,
|
||||
};
|
||||
use registry::{AdapterRegistry, DeviceRegistry};
|
||||
use state::StateCache;
|
||||
@@ -60,7 +61,7 @@ impl Daemon {
|
||||
if !config.daemon.socket.is_empty() {
|
||||
paths.socket_file = PathBuf::from(&config.daemon.socket);
|
||||
}
|
||||
let adapters = AdapterRegistry::default();
|
||||
let adapters = AdapterRegistry::from_config(&config);
|
||||
let mut registry = DeviceRegistry::load(paths.devices_file.clone()).await?;
|
||||
if !config.devices.default.is_empty() {
|
||||
let _ = registry.set_default(&config.devices.default);
|
||||
@@ -104,10 +105,28 @@ pub async fn serve() -> anyhow::Result<()> {
|
||||
if let Some(parent) = socket_path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
if let Some(parent) = {
|
||||
let guard = daemon.lock().await;
|
||||
guard
|
||||
.paths
|
||||
.active_socket_file
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
} {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
let _ = fs::remove_file(&socket_path).await;
|
||||
let listener = UnixListener::bind(&socket_path)?;
|
||||
set_socket_permissions(&socket_path).await?;
|
||||
{
|
||||
let guard = daemon.lock().await;
|
||||
fs::write(
|
||||
&guard.paths.active_socket_file,
|
||||
socket_path.display().to_string(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let mut discovery_interval = discovery_interval(interval_secs);
|
||||
if let Some(interval) = discovery_interval.as_mut() {
|
||||
interval.tick().await;
|
||||
@@ -140,6 +159,10 @@ pub async fn serve() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
let _ = fs::remove_file(&socket_path).await;
|
||||
{
|
||||
let guard = daemon.lock().await;
|
||||
let _ = fs::remove_file(&guard.paths.active_socket_file).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -452,6 +475,287 @@ async fn handle_request(
|
||||
),
|
||||
}
|
||||
}
|
||||
DaemonRequest::LaunchApp { device, app } => {
|
||||
let guard = daemon.lock().await;
|
||||
let device = match resolve_target_device(&guard.registry, device.as_deref()) {
|
||||
Ok(device) => device,
|
||||
Err(response) => return (response, false),
|
||||
};
|
||||
let target_app = match guard.app_cache.find_app(&device.platform, &app).await {
|
||||
Ok(Some(cached)) => cached.platform_id,
|
||||
Ok(None) => app.clone(),
|
||||
Err(error) => {
|
||||
return (
|
||||
DaemonResponse::error(
|
||||
"app_cache_lookup_failed",
|
||||
format!("Failed to resolve app '{app}' against the cache: {error}"),
|
||||
Some(
|
||||
"Retry with a raw platform app id or refresh the app cache first."
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
match guard.adapters.launch(&device, &target_app).await {
|
||||
Ok(()) => (
|
||||
DaemonResponse::success(ActionResult {
|
||||
device,
|
||||
detail: format!("Launched app '{app}'."),
|
||||
}),
|
||||
false,
|
||||
),
|
||||
Err(error) => (
|
||||
DaemonResponse::error(
|
||||
"app_launch_failed",
|
||||
format!("Failed to launch app '{app}': {error}"),
|
||||
Some(
|
||||
"Refresh the app cache or retry with the raw platform app id."
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
false,
|
||||
),
|
||||
}
|
||||
}
|
||||
DaemonRequest::StopApp { device } => {
|
||||
let guard = daemon.lock().await;
|
||||
let device = match resolve_target_device(&guard.registry, device.as_deref()) {
|
||||
Ok(device) => device,
|
||||
Err(response) => return (response, false),
|
||||
};
|
||||
match guard.adapters.stop_app(&device).await {
|
||||
Ok(()) => (
|
||||
DaemonResponse::success(ActionResult {
|
||||
device,
|
||||
detail: "Stopped the active app.".to_string(),
|
||||
}),
|
||||
false,
|
||||
),
|
||||
Err(error) => (
|
||||
DaemonResponse::error(
|
||||
"app_stop_failed",
|
||||
format!("Failed to stop the active app: {error}"),
|
||||
Some("Verify the TV is online and retry.".to_string()),
|
||||
),
|
||||
false,
|
||||
),
|
||||
}
|
||||
}
|
||||
DaemonRequest::GetState { device } => {
|
||||
let mut guard = daemon.lock().await;
|
||||
let device = match resolve_target_device(&guard.registry, device.as_deref()) {
|
||||
Ok(device) => device,
|
||||
Err(response) => return (response, false),
|
||||
};
|
||||
match guard.adapters.state(&device).await {
|
||||
Ok(state) => {
|
||||
guard.state_cache.insert(state.clone());
|
||||
if let Some(app) = state.active_app.clone() {
|
||||
let _ = guard
|
||||
.app_cache
|
||||
.record_platform_apps(&device.platform, vec![app])
|
||||
.await;
|
||||
}
|
||||
(
|
||||
DaemonResponse::success(StateResult { device, state }),
|
||||
false,
|
||||
)
|
||||
}
|
||||
Err(error) => (
|
||||
DaemonResponse::error(
|
||||
"state_failed",
|
||||
format!("Failed to fetch device state: {error}"),
|
||||
Some("Verify the TV is online and retry.".to_string()),
|
||||
),
|
||||
false,
|
||||
),
|
||||
}
|
||||
}
|
||||
DaemonRequest::SendKey { device, key } => {
|
||||
let guard = daemon.lock().await;
|
||||
let device = match resolve_target_device(&guard.registry, device.as_deref()) {
|
||||
Ok(device) => device,
|
||||
Err(response) => return (response, false),
|
||||
};
|
||||
let detail = format!("Sent key '{key:?}'.");
|
||||
match guard.adapters.key(&device, key).await {
|
||||
Ok(()) => (
|
||||
DaemonResponse::success(ActionResult { device, detail }),
|
||||
false,
|
||||
),
|
||||
Err(error) => (
|
||||
DaemonResponse::error(
|
||||
"remote_key_failed",
|
||||
format!("Failed to send the key: {error}"),
|
||||
Some("Verify the key name and make sure the TV is online.".to_string()),
|
||||
),
|
||||
false,
|
||||
),
|
||||
}
|
||||
}
|
||||
DaemonRequest::SendSequence { device, keys } => {
|
||||
let guard = daemon.lock().await;
|
||||
let device = match resolve_target_device(&guard.registry, device.as_deref()) {
|
||||
Ok(device) => device,
|
||||
Err(response) => return (response, false),
|
||||
};
|
||||
let detail = format!("Sent {} key(s).", keys.len());
|
||||
match guard.adapters.sequence(&device, keys).await {
|
||||
Ok(()) => (
|
||||
DaemonResponse::success(ActionResult { device, detail }),
|
||||
false,
|
||||
),
|
||||
Err(error) => (
|
||||
DaemonResponse::error(
|
||||
"remote_sequence_failed",
|
||||
format!("Failed to send the key sequence: {error}"),
|
||||
Some("Verify the key names and make sure the TV is online.".to_string()),
|
||||
),
|
||||
false,
|
||||
),
|
||||
}
|
||||
}
|
||||
DaemonRequest::DevInstall { device, zip_path } => {
|
||||
let guard = daemon.lock().await;
|
||||
if !guard.config.dev.enabled {
|
||||
return (
|
||||
DaemonResponse::error(
|
||||
"dev_disabled",
|
||||
"Developer commands are disabled in the tvctl config.",
|
||||
Some("Set [dev].enabled = true or use `tvctl config` once that surface exists.".to_string()),
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
let device = match resolve_target_device(&guard.registry, device.as_deref()) {
|
||||
Ok(device) => device,
|
||||
Err(response) => return (response, false),
|
||||
};
|
||||
let zip = match fs::read(&zip_path).await {
|
||||
Ok(zip) => zip,
|
||||
Err(error) => {
|
||||
return (
|
||||
DaemonResponse::error(
|
||||
"dev_zip_read_failed",
|
||||
format!("Failed to read dev package at {zip_path}: {error}"),
|
||||
Some("Verify the zip path and retry.".to_string()),
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
};
|
||||
match guard.adapters.dev_install(&device, &zip).await {
|
||||
Ok(()) => (
|
||||
DaemonResponse::success(ActionResult {
|
||||
device,
|
||||
detail: format!("Installed development package from {zip_path}."),
|
||||
}),
|
||||
false,
|
||||
),
|
||||
Err(error) => (
|
||||
DaemonResponse::error(
|
||||
"dev_install_failed",
|
||||
format!("Failed to install the development package: {error}"),
|
||||
Some(
|
||||
"Verify Roku developer mode credentials and package contents."
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
false,
|
||||
),
|
||||
}
|
||||
}
|
||||
DaemonRequest::DevReload { device } => {
|
||||
let guard = daemon.lock().await;
|
||||
if !guard.config.dev.enabled {
|
||||
return (
|
||||
DaemonResponse::error(
|
||||
"dev_disabled",
|
||||
"Developer commands are disabled in the tvctl config.",
|
||||
Some("Set [dev].enabled = true or use `tvctl config` once that surface exists.".to_string()),
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
let device = match resolve_target_device(&guard.registry, device.as_deref()) {
|
||||
Ok(device) => device,
|
||||
Err(response) => return (response, false),
|
||||
};
|
||||
match guard.adapters.dev_reload(&device).await {
|
||||
Ok(()) => (
|
||||
DaemonResponse::success(ActionResult {
|
||||
device,
|
||||
detail: "Reloaded the development package.".to_string(),
|
||||
}),
|
||||
false,
|
||||
),
|
||||
Err(error) => (
|
||||
DaemonResponse::error(
|
||||
"dev_reload_failed",
|
||||
format!("Failed to reload the development package: {error}"),
|
||||
Some("Verify the TV is in developer mode and retry.".to_string()),
|
||||
),
|
||||
false,
|
||||
),
|
||||
}
|
||||
}
|
||||
DaemonRequest::DevLogs { device } => {
|
||||
let guard = daemon.lock().await;
|
||||
if !guard.config.dev.enabled {
|
||||
return (
|
||||
DaemonResponse::error(
|
||||
"dev_disabled",
|
||||
"Developer commands are disabled in the tvctl config.",
|
||||
Some("Set [dev].enabled = true or use `tvctl config` once that surface exists.".to_string()),
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
let device = match resolve_target_device(&guard.registry, device.as_deref()) {
|
||||
Ok(device) => device,
|
||||
Err(response) => return (response, false),
|
||||
};
|
||||
match guard.adapters.dev_logs(&device).await {
|
||||
Ok(lines) => (
|
||||
DaemonResponse::success(DevLogsResult { device, lines }),
|
||||
false,
|
||||
),
|
||||
Err(error) => (
|
||||
DaemonResponse::error(
|
||||
"dev_logs_failed",
|
||||
format!("Failed to fetch developer logs: {error}"),
|
||||
Some("Verify the TV is in developer mode and reachable on the debugger port.".to_string()),
|
||||
),
|
||||
false,
|
||||
),
|
||||
}
|
||||
}
|
||||
DaemonRequest::ReloadConfig => {
|
||||
let mut guard = daemon.lock().await;
|
||||
match TvctlConfig::load_from_path(&guard.paths.config_file).await {
|
||||
Ok(config) => {
|
||||
let restart_required = apply_runtime_config(&mut guard, config);
|
||||
(
|
||||
DaemonResponse::success(ConfigReloadResult {
|
||||
config: guard.config.clone(),
|
||||
restart_required,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
}
|
||||
Err(error) => (
|
||||
DaemonResponse::error(
|
||||
"config_reload_failed",
|
||||
format!("Failed to reload config from disk: {error}"),
|
||||
Some("Inspect ~/.config/tvctl/config.toml for invalid TOML.".to_string()),
|
||||
),
|
||||
false,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,3 +824,35 @@ fn discovery_interval(interval_secs: u64) -> Option<time::Interval> {
|
||||
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
Some(interval)
|
||||
}
|
||||
|
||||
fn apply_runtime_config(daemon: &mut Daemon, config: TvctlConfig) -> Vec<String> {
|
||||
let old_config = daemon.config.clone();
|
||||
daemon.adapters = AdapterRegistry::from_config(&config);
|
||||
daemon.discovery = DiscoveryService::new(daemon.adapters.clone());
|
||||
|
||||
if !config.devices.default.is_empty() {
|
||||
let _ = daemon.registry.set_default(&config.devices.default);
|
||||
} else {
|
||||
daemon.registry.ensure_default();
|
||||
}
|
||||
|
||||
let mut restart_required = Vec::new();
|
||||
if old_config.daemon.socket != config.daemon.socket {
|
||||
restart_required.push("daemon.socket".to_string());
|
||||
}
|
||||
if old_config.discovery.interval_secs != config.discovery.interval_secs {
|
||||
restart_required.push("discovery.interval_secs".to_string());
|
||||
}
|
||||
if old_config.daemon.http_enabled != config.daemon.http_enabled {
|
||||
restart_required.push("daemon.http_enabled".to_string());
|
||||
}
|
||||
if old_config.daemon.http_host != config.daemon.http_host {
|
||||
restart_required.push("daemon.http_host".to_string());
|
||||
}
|
||||
if old_config.daemon.http_port != config.daemon.http_port {
|
||||
restart_required.push("daemon.http_port".to_string());
|
||||
}
|
||||
|
||||
daemon.config = config;
|
||||
restart_required
|
||||
}
|
||||
|
||||
+80
-5
@@ -6,7 +6,10 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::adapters::{Device, DeviceInfo, TvAdapter, roku::RokuAdapter};
|
||||
use crate::{
|
||||
adapters::{AppInfo, Device, DeviceInfo, DeviceState, TvAdapter, TvKey, roku::RokuAdapter},
|
||||
daemon::config::TvctlConfig,
|
||||
};
|
||||
|
||||
/// The persisted collection of known devices.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -187,6 +190,17 @@ impl Default for AdapterRegistry {
|
||||
}
|
||||
|
||||
impl AdapterRegistry {
|
||||
/// Build the adapter registry from the loaded daemon config.
|
||||
pub fn from_config(config: &TvctlConfig) -> Self {
|
||||
let username =
|
||||
(!config.dev.roku_username.is_empty()).then(|| config.dev.roku_username.clone());
|
||||
let password =
|
||||
(!config.dev.roku_password.is_empty()).then(|| config.dev.roku_password.clone());
|
||||
Self {
|
||||
roku: RokuAdapter::with_dev_credentials(username, password),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the supported platform names.
|
||||
pub fn supported_platforms(&self) -> Vec<&'static str> {
|
||||
vec!["roku"]
|
||||
@@ -222,15 +236,76 @@ impl AdapterRegistry {
|
||||
}
|
||||
|
||||
/// Return apps from a concrete device using its platform adapter.
|
||||
pub async fn list_apps(
|
||||
&self,
|
||||
device: &Device,
|
||||
) -> anyhow::Result<Vec<crate::adapters::AppInfo>> {
|
||||
pub async fn list_apps(&self, device: &Device) -> anyhow::Result<Vec<AppInfo>> {
|
||||
match device.platform.as_str() {
|
||||
"roku" => Ok(self.roku.list_apps(device).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the current state for a concrete device.
|
||||
pub async fn state(&self, device: &Device) -> anyhow::Result<DeviceState> {
|
||||
match device.platform.as_str() {
|
||||
"roku" => Ok(self.roku.state(device).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Launch an app on a concrete device.
|
||||
pub async fn launch(&self, device: &Device, app: &str) -> anyhow::Result<()> {
|
||||
match device.platform.as_str() {
|
||||
"roku" => Ok(self.roku.launch(device, app).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the currently running app on a concrete device.
|
||||
pub async fn stop_app(&self, device: &Device) -> anyhow::Result<()> {
|
||||
match device.platform.as_str() {
|
||||
"roku" => Ok(self.roku.stop_app(device).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a single normalized key to a concrete device.
|
||||
pub async fn key(&self, device: &Device, key: TvKey) -> anyhow::Result<()> {
|
||||
match device.platform.as_str() {
|
||||
"roku" => Ok(self.roku.key(device, key).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a normalized key sequence to a concrete device.
|
||||
pub async fn sequence(&self, device: &Device, keys: Vec<TvKey>) -> anyhow::Result<()> {
|
||||
match device.platform.as_str() {
|
||||
"roku" => Ok(self.roku.sequence(device, keys).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a development package on a concrete device.
|
||||
pub async fn dev_install(&self, device: &Device, zip: &[u8]) -> anyhow::Result<()> {
|
||||
match device.platform.as_str() {
|
||||
"roku" => Ok(self.roku.dev_install(device, zip).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reload the active development package on a concrete device.
|
||||
pub async fn dev_reload(&self, device: &Device) -> anyhow::Result<()> {
|
||||
match device.platform.as_str() {
|
||||
"roku" => Ok(self.roku.dev_reload(device).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch development logs from a concrete device.
|
||||
pub async fn dev_logs(&self, device: &Device) -> anyhow::Result<Vec<String>> {
|
||||
match device.platform.as_str() {
|
||||
"roku" => Ok(self.roku.dev_logs(device).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_target(device: &Device, target: &str) -> bool {
|
||||
|
||||
Reference in New Issue
Block a user