b8a0a0ff16
Expose the daemon request surface over /v1 with Axum, reuse shared key parsing between CLI and HTTP, and add an isolated end-to-end HTTP test that boots a real daemon process with temp XDG paths.
1419 lines
44 KiB
Rust
1419 lines
44 KiB
Rust
use std::{collections::BTreeMap, net::IpAddr, path::PathBuf, process::Stdio, time::Duration};
|
|
|
|
use clap::{Args, CommandFactory, Parser, Subcommand};
|
|
use serde::Serialize;
|
|
use thiserror::Error;
|
|
use tokio::{
|
|
fs,
|
|
io::{AsyncReadExt, AsyncWriteExt},
|
|
net::UnixStream,
|
|
process::Command as TokioCommand,
|
|
time::sleep,
|
|
};
|
|
|
|
use crate::{
|
|
adapters::{AppInfo, Device, DeviceState, TvKey, parse_normalized_tv_key},
|
|
daemon::{
|
|
self,
|
|
config::{RuntimePaths, TvctlConfig, default_config_path, systemd_unit_path},
|
|
ipc::{
|
|
ActionResult, AppListResult, AppRefreshResult, ConfigReloadResult, DaemonRequest,
|
|
DaemonResponse, DaemonStatus, DevLogsResult, DiscoveryResult, StateResult,
|
|
},
|
|
},
|
|
};
|
|
|
|
const DAEMON_START_WAIT_ATTEMPTS: usize = 20;
|
|
const DAEMON_START_WAIT_INTERVAL: Duration = Duration::from_millis(250);
|
|
const DEFAULT_REMOTE_SEQUENCE_DELAY_MS: u64 = 200;
|
|
const TVCTLD_SYSTEMD_UNIT: &str = "tvctld.service";
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum DaemonMode {
|
|
AdHoc,
|
|
Systemd,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct SystemdServiceStatus {
|
|
installed: bool,
|
|
active: bool,
|
|
main_pid: Option<u32>,
|
|
}
|
|
|
|
/// The tvctl command-line interface.
|
|
#[derive(Debug, Parser)]
|
|
#[command(
|
|
name = "tvctl",
|
|
version,
|
|
about = "A local-first daemon and CLI for controlling smart TVs."
|
|
)]
|
|
pub struct Cli {
|
|
/// Target a specific device by friendly name or UUID.
|
|
#[arg(long, global = true)]
|
|
pub device: Option<String>,
|
|
|
|
/// Emit JSON output suitable for scripting.
|
|
#[arg(long, global = true)]
|
|
pub json: bool,
|
|
|
|
/// The resource-oriented command to execute.
|
|
#[command(subcommand)]
|
|
pub command: Option<Command>,
|
|
}
|
|
|
|
/// The top-level resource namespaces exposed by tvctl.
|
|
#[derive(Debug, Clone, Subcommand)]
|
|
pub enum Command {
|
|
/// Manage the background daemon.
|
|
Daemon {
|
|
#[command(subcommand)]
|
|
command: DaemonCommand,
|
|
},
|
|
/// Discover and manage devices.
|
|
Device {
|
|
#[command(subcommand)]
|
|
command: DeviceCommand,
|
|
},
|
|
/// List and refresh application metadata.
|
|
App {
|
|
#[command(subcommand)]
|
|
command: AppCommand,
|
|
},
|
|
/// Send remote control input.
|
|
Remote {
|
|
#[command(subcommand)]
|
|
command: RemoteCommand,
|
|
},
|
|
/// Query device state.
|
|
State,
|
|
/// Use developer-oriented TV features.
|
|
Dev {
|
|
#[command(subcommand)]
|
|
command: DevCommand,
|
|
},
|
|
/// Inspect and modify tvctl configuration.
|
|
Config {
|
|
#[command(subcommand)]
|
|
command: ConfigCommand,
|
|
},
|
|
/// Internal daemon entry point used by `tvctl daemon start`.
|
|
#[command(hide = true, name = "__daemon_serve")]
|
|
InternalDaemonServe,
|
|
}
|
|
|
|
/// Manage the tvctld lifecycle.
|
|
#[derive(Debug, Clone, Subcommand)]
|
|
pub enum DaemonCommand {
|
|
/// Start the background daemon or installed user service.
|
|
Start,
|
|
/// Stop the running daemon or installed user service.
|
|
Stop,
|
|
/// Restart the running daemon or installed user service.
|
|
Restart,
|
|
/// Show daemon and user-service status.
|
|
Status,
|
|
/// Generate and enable a systemd user service.
|
|
Install,
|
|
/// Disable and remove the systemd user service.
|
|
Uninstall,
|
|
}
|
|
|
|
/// Discover and inspect known devices.
|
|
#[derive(Debug, Clone, Subcommand)]
|
|
pub enum DeviceCommand {
|
|
/// List devices currently known to the daemon.
|
|
List,
|
|
/// Trigger a fresh discovery scan.
|
|
Discover,
|
|
/// Manually add a device by probing its address.
|
|
Add(DeviceAddArgs),
|
|
/// Show one known device by name or UUID.
|
|
Info {
|
|
/// The friendly name or UUID to inspect.
|
|
target: String,
|
|
},
|
|
/// Remove one known device by name or UUID.
|
|
Remove {
|
|
/// The friendly name or UUID to remove.
|
|
target: String,
|
|
},
|
|
/// Mark one known device as the default target.
|
|
Select {
|
|
/// The friendly name or UUID to make default.
|
|
target: String,
|
|
},
|
|
}
|
|
|
|
/// Arguments for `tvctl device add`.
|
|
#[derive(Debug, Clone, Args)]
|
|
pub struct DeviceAddArgs {
|
|
/// The normalized platform identifier, currently `roku`.
|
|
#[arg(long)]
|
|
pub platform: String,
|
|
|
|
/// The device IP address.
|
|
#[arg(long)]
|
|
pub address: IpAddr,
|
|
|
|
/// Optional platform port override.
|
|
#[arg(long)]
|
|
pub port: Option<u16>,
|
|
|
|
/// Optional user-assigned friendly name.
|
|
#[arg(long)]
|
|
pub name: Option<String>,
|
|
}
|
|
|
|
/// App-cache commands.
|
|
#[derive(Debug, Clone, Subcommand)]
|
|
pub enum AppCommand {
|
|
/// List cached apps for the selected or default device platform.
|
|
List,
|
|
/// Launch an app by name, normalized id, or platform id.
|
|
Launch {
|
|
/// App name, normalized id, or platform id.
|
|
app: String,
|
|
},
|
|
/// Stop the active app on the selected or default device.
|
|
Stop,
|
|
/// Refresh the cached app list from the selected or default device.
|
|
Refresh {
|
|
/// Clear the platform cache before reloading from the device.
|
|
#[arg(long)]
|
|
clear: bool,
|
|
},
|
|
}
|
|
|
|
/// Remote control commands.
|
|
#[derive(Debug, Clone, Subcommand)]
|
|
pub enum RemoteCommand {
|
|
/// Send one or more normalized keys.
|
|
Send {
|
|
/// One or more key names such as `home`, `down`, or `literal:abc`.
|
|
keys: Vec<String>,
|
|
/// Delay between keys when sending more than one key, in milliseconds.
|
|
#[arg(long, default_value_t = DEFAULT_REMOTE_SEQUENCE_DELAY_MS)]
|
|
delay: u64,
|
|
},
|
|
}
|
|
|
|
/// Developer-oriented commands.
|
|
#[derive(Debug, Clone, Subcommand)]
|
|
pub enum DevCommand {
|
|
/// Install a dev zip on the selected or default device.
|
|
Install {
|
|
/// Local filesystem path to the zip package.
|
|
zip: PathBuf,
|
|
},
|
|
/// Reload the active dev package.
|
|
Reload,
|
|
/// Fetch recent developer logs.
|
|
Logs,
|
|
}
|
|
|
|
/// Config management commands.
|
|
#[derive(Debug, Clone, Subcommand)]
|
|
pub enum ConfigCommand {
|
|
/// Show all config values.
|
|
List,
|
|
/// Read one config key.
|
|
Get {
|
|
/// Stable dotted key such as `daemon.socket`.
|
|
key: String,
|
|
},
|
|
/// Set one config key.
|
|
Set {
|
|
/// Stable dotted key such as `discovery.interval_secs`.
|
|
key: String,
|
|
/// New value for the key.
|
|
value: String,
|
|
},
|
|
/// Reset config to defaults.
|
|
Reset,
|
|
/// Reload config into the running daemon.
|
|
Reload,
|
|
}
|
|
|
|
/// A user-facing CLI error with a suggested next action.
|
|
#[derive(Debug, Error)]
|
|
#[error("{message}\nHint: {hint}")]
|
|
pub struct CliError {
|
|
/// The human-readable error message.
|
|
pub message: String,
|
|
/// The suggested next action.
|
|
pub hint: String,
|
|
}
|
|
|
|
impl CliError {
|
|
fn new(message: impl Into<String>, hint: impl Into<String>) -> Self {
|
|
Self {
|
|
message: message.into(),
|
|
hint: hint.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
struct ConfigEntryResult {
|
|
key: String,
|
|
value: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
struct ConfigListResult {
|
|
values: BTreeMap<String, String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
struct ConfigMutationResult {
|
|
key: Option<String>,
|
|
value: Option<String>,
|
|
config_path: String,
|
|
daemon_reloaded: bool,
|
|
restart_required: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
struct ServiceInstallResult {
|
|
unit_file: String,
|
|
installed: bool,
|
|
enabled: bool,
|
|
running: bool,
|
|
already_installed: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
struct ServiceUninstallResult {
|
|
unit_file: String,
|
|
installed: bool,
|
|
enabled: bool,
|
|
running: bool,
|
|
removed_unit: bool,
|
|
stopped_service: bool,
|
|
stopped_ad_hoc: bool,
|
|
}
|
|
|
|
/// Parse the CLI and execute the selected command.
|
|
pub async fn run() -> Result<(), CliError> {
|
|
let cli = Cli::parse();
|
|
let Some(command) = cli.command.clone() else {
|
|
let mut cmd = Cli::command();
|
|
cmd.print_long_help().map_err(|error| {
|
|
CliError::new(
|
|
format!("Failed to render CLI help: {error}"),
|
|
"Retry the command or inspect the terminal output.",
|
|
)
|
|
})?;
|
|
println!();
|
|
return Ok(());
|
|
};
|
|
|
|
match command {
|
|
Command::InternalDaemonServe => daemon::serve().await.map_err(|error| {
|
|
CliError::new(error.to_string(), "Inspect the daemon logs and retry.")
|
|
}),
|
|
Command::Daemon { command } => handle_daemon_command(&cli, command).await,
|
|
Command::Device { command } => handle_device_command(&cli, command).await,
|
|
Command::App { command } => handle_app_command(&cli, command).await,
|
|
Command::Remote { command } => handle_remote_command(&cli, command).await,
|
|
Command::State => handle_state_command(&cli).await,
|
|
Command::Dev { command } => handle_dev_command(&cli, command).await,
|
|
Command::Config { command } => handle_config_command(&cli, command).await,
|
|
}
|
|
}
|
|
|
|
async fn handle_daemon_command(cli: &Cli, command: DaemonCommand) -> Result<(), CliError> {
|
|
match command {
|
|
DaemonCommand::Start => daemon_start(cli).await,
|
|
DaemonCommand::Stop => daemon_stop(cli).await,
|
|
DaemonCommand::Restart => daemon_restart(cli).await,
|
|
DaemonCommand::Status => daemon_status(cli).await,
|
|
DaemonCommand::Install => daemon_install(cli).await,
|
|
DaemonCommand::Uninstall => daemon_uninstall(cli).await,
|
|
}
|
|
}
|
|
|
|
async fn handle_device_command(cli: &Cli, command: DeviceCommand) -> Result<(), CliError> {
|
|
match command {
|
|
DeviceCommand::List => {
|
|
let response =
|
|
send_request(load_socket_path().await?, &DaemonRequest::ListDevices).await?;
|
|
let devices: Vec<Device> = parse_response_data(response)?;
|
|
render(cli, &devices, || render_device_list(&devices))
|
|
}
|
|
DeviceCommand::Discover => {
|
|
let response =
|
|
send_request(load_socket_path().await?, &DaemonRequest::Discover).await?;
|
|
let result: DiscoveryResult = parse_response_data(response)?;
|
|
render(cli, &result, || render_discovery_result(&result.devices))
|
|
}
|
|
DeviceCommand::Add(args) => {
|
|
let response = send_request(
|
|
load_socket_path().await?,
|
|
&DaemonRequest::AddDevice {
|
|
platform: args.platform,
|
|
address: args.address,
|
|
port: args.port,
|
|
name: args.name,
|
|
},
|
|
)
|
|
.await?;
|
|
let device: Device = parse_response_data(response)?;
|
|
render(cli, &device, || {
|
|
format!(
|
|
"Added {} [{}] {}:{}{}",
|
|
device.name,
|
|
device.platform,
|
|
device.address,
|
|
device.port,
|
|
default_marker(&device)
|
|
)
|
|
})
|
|
}
|
|
DeviceCommand::Info { target } => {
|
|
let response = send_request(
|
|
load_socket_path().await?,
|
|
&DaemonRequest::GetDevice { target },
|
|
)
|
|
.await?;
|
|
let device: Device = parse_response_data(response)?;
|
|
render(cli, &device, || render_device_info(&device))
|
|
}
|
|
DeviceCommand::Remove { target } => {
|
|
let response = send_request(
|
|
load_socket_path().await?,
|
|
&DaemonRequest::RemoveDevice { target },
|
|
)
|
|
.await?;
|
|
let device: Device = parse_response_data(response)?;
|
|
render(cli, &device, || format!("Removed {}.", device.name))
|
|
}
|
|
DeviceCommand::Select { target } => {
|
|
let response = send_request(
|
|
load_socket_path().await?,
|
|
&DaemonRequest::SelectDevice { target },
|
|
)
|
|
.await?;
|
|
let device: Device = parse_response_data(response)?;
|
|
render(cli, &device, || {
|
|
format!("Default device set to {}.", device.name)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_app_command(cli: &Cli, command: AppCommand) -> Result<(), CliError> {
|
|
match command {
|
|
AppCommand::List => {
|
|
let response = send_request(
|
|
load_socket_path().await?,
|
|
&DaemonRequest::ListApps {
|
|
device: cli.device.clone(),
|
|
platform: None,
|
|
},
|
|
)
|
|
.await?;
|
|
let result: AppListResult = parse_response_data(response)?;
|
|
render(cli, &result, || {
|
|
render_app_list(&result.apps, &result.platform)
|
|
})
|
|
}
|
|
AppCommand::Launch { app } => {
|
|
let response = send_request(
|
|
load_socket_path().await?,
|
|
&DaemonRequest::LaunchApp {
|
|
device: cli.device.clone(),
|
|
app,
|
|
},
|
|
)
|
|
.await?;
|
|
render_action_response(cli, response)
|
|
}
|
|
AppCommand::Stop => {
|
|
let response = send_request(
|
|
load_socket_path().await?,
|
|
&DaemonRequest::StopApp {
|
|
device: cli.device.clone(),
|
|
},
|
|
)
|
|
.await?;
|
|
render_action_response(cli, response)
|
|
}
|
|
AppCommand::Refresh { clear } => {
|
|
let response = send_request(
|
|
load_socket_path().await?,
|
|
&DaemonRequest::RefreshApps {
|
|
device: cli.device.clone(),
|
|
clear,
|
|
},
|
|
)
|
|
.await?;
|
|
let result: AppRefreshResult = parse_response_data(response)?;
|
|
render(cli, &result, || render_app_refresh(&result))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_remote_command(cli: &Cli, command: RemoteCommand) -> Result<(), CliError> {
|
|
match command {
|
|
RemoteCommand::Send { keys, delay } => {
|
|
if keys.is_empty() {
|
|
return Err(CliError::new(
|
|
"At least one key is required for `tvctl remote send`.",
|
|
"Pass one or more keys such as `home` or `home down select`.",
|
|
));
|
|
}
|
|
if keys.len() == 1 {
|
|
let response = send_request(
|
|
load_socket_path().await?,
|
|
&DaemonRequest::SendKey {
|
|
device: cli.device.clone(),
|
|
key: parse_tv_key(&keys[0])?,
|
|
},
|
|
)
|
|
.await?;
|
|
render_action_response(cli, response)
|
|
} else {
|
|
let parsed = keys
|
|
.iter()
|
|
.map(|key| parse_tv_key(key))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
let response = send_request(
|
|
load_socket_path().await?,
|
|
&DaemonRequest::SendSequence {
|
|
device: cli.device.clone(),
|
|
keys: parsed,
|
|
delay_ms: delay,
|
|
},
|
|
)
|
|
.await?;
|
|
render_action_response(cli, response)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_state_command(cli: &Cli) -> Result<(), CliError> {
|
|
let response = send_request(
|
|
load_socket_path().await?,
|
|
&DaemonRequest::GetState {
|
|
device: cli.device.clone(),
|
|
},
|
|
)
|
|
.await?;
|
|
let result: StateResult = parse_response_data(response)?;
|
|
render(cli, &result, || render_state(&result.device, &result.state))
|
|
}
|
|
|
|
async fn handle_dev_command(cli: &Cli, command: DevCommand) -> Result<(), CliError> {
|
|
match command {
|
|
DevCommand::Install { zip } => {
|
|
let response = send_request(
|
|
load_socket_path().await?,
|
|
&DaemonRequest::DevInstall {
|
|
device: cli.device.clone(),
|
|
zip_path: zip.display().to_string(),
|
|
},
|
|
)
|
|
.await?;
|
|
render_action_response(cli, response)
|
|
}
|
|
DevCommand::Reload => {
|
|
let response = send_request(
|
|
load_socket_path().await?,
|
|
&DaemonRequest::DevReload {
|
|
device: cli.device.clone(),
|
|
},
|
|
)
|
|
.await?;
|
|
render_action_response(cli, response)
|
|
}
|
|
DevCommand::Logs => {
|
|
let response = send_request(
|
|
load_socket_path().await?,
|
|
&DaemonRequest::DevLogs {
|
|
device: cli.device.clone(),
|
|
},
|
|
)
|
|
.await?;
|
|
let result: DevLogsResult = parse_response_data(response)?;
|
|
render(cli, &result, || render_dev_logs(&result))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_config_command(cli: &Cli, command: ConfigCommand) -> Result<(), CliError> {
|
|
match command {
|
|
ConfigCommand::List => {
|
|
let config = load_config().await?;
|
|
let values = config
|
|
.entries()
|
|
.into_iter()
|
|
.map(|(key, value)| (key.to_string(), redact_config_value(key, value)))
|
|
.collect::<BTreeMap<_, _>>();
|
|
let result = ConfigListResult { values };
|
|
render(cli, &result, || render_config_list(&result.values))
|
|
}
|
|
ConfigCommand::Get { key } => {
|
|
let config = load_config().await?;
|
|
let value = config.get_value(&key).ok_or_else(|| {
|
|
CliError::new(
|
|
format!("Unknown config key '{key}'."),
|
|
"Run `tvctl config list` to see the supported keys.",
|
|
)
|
|
})?;
|
|
let result = ConfigEntryResult {
|
|
value: redact_config_value(&key, value),
|
|
key,
|
|
};
|
|
render(cli, &result, || result.value.clone())
|
|
}
|
|
ConfigCommand::Set { key, value } => {
|
|
let path = default_config_path();
|
|
let reload = save_config_with_reload(path.clone(), |config| {
|
|
config.set_value(&key, &value).map_err(|error| {
|
|
CliError::new(
|
|
format!("Failed to set config value: {error}"),
|
|
"Run `tvctl config list` to confirm the key and expected value type.",
|
|
)
|
|
})
|
|
})
|
|
.await?;
|
|
let result = ConfigMutationResult {
|
|
value: Some(redact_config_value(&key, value)),
|
|
key: Some(key),
|
|
config_path: path.display().to_string(),
|
|
daemon_reloaded: reload.is_some(),
|
|
restart_required: reload
|
|
.map(|result| result.restart_required)
|
|
.unwrap_or_default(),
|
|
};
|
|
render(cli, &result, || {
|
|
render_config_mutation("Saved config value.", &result)
|
|
})
|
|
}
|
|
ConfigCommand::Reset => {
|
|
let path = default_config_path();
|
|
let reload = save_config_with_reload(path.clone(), |config| {
|
|
*config = TvctlConfig::default();
|
|
Ok(())
|
|
})
|
|
.await?;
|
|
let result = ConfigMutationResult {
|
|
key: None,
|
|
value: None,
|
|
config_path: path.display().to_string(),
|
|
daemon_reloaded: reload.is_some(),
|
|
restart_required: reload
|
|
.map(|result| result.restart_required)
|
|
.unwrap_or_default(),
|
|
};
|
|
render(cli, &result, || {
|
|
render_config_mutation("Reset config to defaults.", &result)
|
|
})
|
|
}
|
|
ConfigCommand::Reload => {
|
|
let result = sanitize_config_reload_result(reload_daemon_config().await?);
|
|
render(cli, &result, || render_config_reload(&result))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn daemon_start(cli: &Cli) -> Result<(), CliError> {
|
|
let service = systemd_service_status().await?;
|
|
if service.installed {
|
|
if let Some(status) = daemon_status_payload().await {
|
|
if service_owns_daemon(&service, &status) {
|
|
return render(cli, &status, || {
|
|
format!(
|
|
"tvctld user service is already running on {}",
|
|
status.socket
|
|
)
|
|
});
|
|
}
|
|
stop_ad_hoc_daemon(&status).await?;
|
|
}
|
|
|
|
run_systemctl(&["--user", "start", TVCTLD_SYSTEMD_UNIT]).await?;
|
|
let status = wait_for_daemon_ready().await?;
|
|
return render(cli, &status, || {
|
|
format!("Started tvctld user service on {}", status.socket)
|
|
});
|
|
}
|
|
|
|
if let Some(status) = daemon_status_payload().await {
|
|
return render(cli, &status, || {
|
|
format!("tvctld is already running on {}", status.socket)
|
|
});
|
|
}
|
|
|
|
let exe = std::env::current_exe().map_err(|error| {
|
|
CliError::new(
|
|
format!("Unable to locate the tvctl binary: {error}"),
|
|
"Run the command from an installed or built tvctl executable.",
|
|
)
|
|
})?;
|
|
|
|
TokioCommand::new(exe)
|
|
.arg("__daemon_serve")
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.map_err(|error| {
|
|
CliError::new(
|
|
format!("Failed to launch tvctld: {error}"),
|
|
"Check filesystem permissions and try again.",
|
|
)
|
|
})?;
|
|
|
|
let status = wait_for_daemon_ready().await?;
|
|
render(cli, &status, || {
|
|
format!("tvctld started on {}", status.socket)
|
|
})
|
|
}
|
|
|
|
async fn daemon_stop(cli: &Cli) -> Result<(), CliError> {
|
|
let service = systemd_service_status().await?;
|
|
if service.installed {
|
|
let mut stopped_service = false;
|
|
if service.active {
|
|
run_systemctl(&["--user", "stop", TVCTLD_SYSTEMD_UNIT]).await?;
|
|
wait_for_daemon_stopped().await?;
|
|
stopped_service = true;
|
|
}
|
|
|
|
let mut stopped_ad_hoc = false;
|
|
if let Some(status) = daemon_status_payload().await {
|
|
stop_ad_hoc_daemon(&status).await?;
|
|
stopped_ad_hoc = true;
|
|
}
|
|
|
|
if !stopped_service && !stopped_ad_hoc {
|
|
return Err(CliError::new(
|
|
"tvctld is not running.",
|
|
"Start the daemon first or use `tvctl daemon status` to inspect the service.",
|
|
));
|
|
}
|
|
|
|
let detail = match (stopped_service, stopped_ad_hoc) {
|
|
(true, true) => "Stopped tvctld user service and cleared a conflicting ad hoc daemon.",
|
|
(true, false) => "Stopped tvctld user service.",
|
|
(false, true) => {
|
|
"Stopped a conflicting ad hoc tvctld while the user service remains installed."
|
|
}
|
|
(false, false) => unreachable!(),
|
|
};
|
|
return render(cli, &serde_json::json!({ "stopped": true }), || {
|
|
detail.to_string()
|
|
});
|
|
}
|
|
|
|
let status = daemon_status_payload().await.ok_or_else(|| {
|
|
CliError::new(
|
|
"tvctld is not running.",
|
|
"Start the daemon first or use `tvctl daemon status` to inspect it.",
|
|
)
|
|
})?;
|
|
stop_ad_hoc_daemon(&status).await?;
|
|
render(cli, &serde_json::json!({ "stopped": true }), || {
|
|
"tvctld stopped.".to_string()
|
|
})
|
|
}
|
|
|
|
async fn daemon_restart(cli: &Cli) -> Result<(), CliError> {
|
|
let service = systemd_service_status().await?;
|
|
if service.installed {
|
|
if let Some(status) = daemon_status_payload().await {
|
|
if !service_owns_daemon(&service, &status) {
|
|
stop_ad_hoc_daemon(&status).await?;
|
|
}
|
|
}
|
|
run_systemctl(&["--user", "restart", TVCTLD_SYSTEMD_UNIT]).await?;
|
|
let status = wait_for_daemon_ready().await?;
|
|
return render(cli, &status, || {
|
|
format!("Restarted tvctld user service on {}", status.socket)
|
|
});
|
|
}
|
|
|
|
if daemon_status_payload().await.is_some() {
|
|
daemon_stop(cli).await?;
|
|
}
|
|
daemon_start(cli).await
|
|
}
|
|
|
|
async fn daemon_status(cli: &Cli) -> Result<(), CliError> {
|
|
let service = systemd_service_status().await?;
|
|
if let Some(status) = daemon_status_payload().await {
|
|
let mode = daemon_mode_for_status(&service, &status);
|
|
return render(cli, &status, || {
|
|
render_daemon_status(&service, &status, mode)
|
|
});
|
|
}
|
|
|
|
if cli.json {
|
|
let status = serde_json::json!({
|
|
"running": false,
|
|
"service_installed": service.installed,
|
|
"service_active": service.active,
|
|
});
|
|
return render(cli, &status, || "tvctld is not running.".to_string());
|
|
}
|
|
|
|
if service.installed {
|
|
println!("tvctld user service is installed but not running.");
|
|
} else {
|
|
println!("tvctld is not running.");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn daemon_install(cli: &Cli) -> Result<(), CliError> {
|
|
let unit_path = systemd_unit_path();
|
|
let existing_service = systemd_service_status().await?;
|
|
if existing_service.installed {
|
|
let result = ServiceInstallResult {
|
|
unit_file: unit_path.display().to_string(),
|
|
installed: true,
|
|
enabled: true,
|
|
running: existing_service.active,
|
|
already_installed: true,
|
|
};
|
|
return render(cli, &result, || render_service_install(&result));
|
|
}
|
|
|
|
if let Some(status) = daemon_status_payload().await {
|
|
stop_ad_hoc_daemon(&status).await?;
|
|
}
|
|
|
|
let exe = std::env::current_exe().map_err(|error| {
|
|
CliError::new(
|
|
format!("Unable to locate the tvctl binary: {error}"),
|
|
"Run the command from an installed or built tvctl executable.",
|
|
)
|
|
})?;
|
|
if let Some(parent) = unit_path.parent() {
|
|
fs::create_dir_all(parent).await.map_err(|error| {
|
|
CliError::new(
|
|
format!("Failed to create the systemd user unit directory: {error}"),
|
|
"Check write permissions for ~/.config/systemd/user.",
|
|
)
|
|
})?;
|
|
}
|
|
|
|
fs::write(&unit_path, render_systemd_unit(&exe))
|
|
.await
|
|
.map_err(|error| {
|
|
CliError::new(
|
|
format!("Failed to write the user service file: {error}"),
|
|
"Check write permissions for ~/.config/systemd/user.",
|
|
)
|
|
})?;
|
|
|
|
run_systemctl(&["--user", "daemon-reload"]).await?;
|
|
run_systemctl(&["--user", "enable", "--now", TVCTLD_SYSTEMD_UNIT]).await?;
|
|
wait_for_daemon_ready().await?;
|
|
|
|
let result = ServiceInstallResult {
|
|
unit_file: unit_path.display().to_string(),
|
|
installed: true,
|
|
enabled: true,
|
|
running: true,
|
|
already_installed: false,
|
|
};
|
|
render(cli, &result, || render_service_install(&result))
|
|
}
|
|
|
|
async fn daemon_uninstall(cli: &Cli) -> Result<(), CliError> {
|
|
let unit_path = systemd_unit_path();
|
|
let service = systemd_service_status().await?;
|
|
let mut stopped_service = false;
|
|
if service.installed || service.active {
|
|
let _ = run_systemctl(&["--user", "disable", "--now", TVCTLD_SYSTEMD_UNIT]).await;
|
|
let _ = wait_for_daemon_stopped().await;
|
|
stopped_service = service.active;
|
|
}
|
|
let mut stopped_ad_hoc = false;
|
|
if let Some(status) = daemon_status_payload().await {
|
|
stop_ad_hoc_daemon(&status).await?;
|
|
stopped_ad_hoc = true;
|
|
}
|
|
let mut removed_unit = false;
|
|
match fs::remove_file(&unit_path).await {
|
|
Ok(()) => removed_unit = true,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(error) => {
|
|
return Err(CliError::new(
|
|
format!("Failed to remove the user service file: {error}"),
|
|
"Check write permissions for ~/.config/systemd/user.",
|
|
));
|
|
}
|
|
}
|
|
let _ = run_systemctl(&["--user", "daemon-reload"]).await;
|
|
|
|
let result = ServiceUninstallResult {
|
|
unit_file: unit_path.display().to_string(),
|
|
installed: false,
|
|
enabled: false,
|
|
running: false,
|
|
removed_unit,
|
|
stopped_service,
|
|
stopped_ad_hoc,
|
|
};
|
|
render(cli, &result, || render_service_uninstall(&result))
|
|
}
|
|
|
|
async fn daemon_status_payload() -> Option<DaemonStatus> {
|
|
let socket = load_socket_path().await.ok()?;
|
|
let response = send_request(socket, &DaemonRequest::Ping).await.ok()?;
|
|
parse_response_data(response).ok()
|
|
}
|
|
|
|
async fn wait_for_daemon_ready() -> Result<DaemonStatus, CliError> {
|
|
for _ in 0..DAEMON_START_WAIT_ATTEMPTS {
|
|
if let Some(status) = daemon_status_payload().await {
|
|
return Ok(status);
|
|
}
|
|
sleep(DAEMON_START_WAIT_INTERVAL).await;
|
|
}
|
|
|
|
Err(CliError::new(
|
|
"tvctld did not become ready in time.",
|
|
"Check whether the socket path is writable and retry `tvctl daemon start`.",
|
|
))
|
|
}
|
|
|
|
async fn wait_for_daemon_stopped() -> Result<(), CliError> {
|
|
for _ in 0..DAEMON_START_WAIT_ATTEMPTS {
|
|
if daemon_status_payload().await.is_none() {
|
|
return Ok(());
|
|
}
|
|
sleep(DAEMON_START_WAIT_INTERVAL).await;
|
|
}
|
|
|
|
Err(CliError::new(
|
|
"tvctld did not stop in time.",
|
|
"Retry `tvctl daemon stop` or inspect the running process.",
|
|
))
|
|
}
|
|
|
|
async fn stop_ad_hoc_daemon(status: &DaemonStatus) -> Result<(), CliError> {
|
|
let response = send_request(PathBuf::from(&status.socket), &DaemonRequest::Shutdown).await?;
|
|
let _: serde_json::Value = parse_response_data(response)?;
|
|
wait_for_daemon_stopped().await
|
|
}
|
|
|
|
async fn systemd_service_status() -> Result<SystemdServiceStatus, CliError> {
|
|
let installed = fs::metadata(systemd_unit_path()).await.is_ok();
|
|
if !installed {
|
|
return Ok(SystemdServiceStatus {
|
|
installed: false,
|
|
active: false,
|
|
main_pid: None,
|
|
});
|
|
}
|
|
|
|
let active =
|
|
systemctl_success(&["--user", "is-active", "--quiet", TVCTLD_SYSTEMD_UNIT]).await?;
|
|
let main_pid = read_systemd_main_pid().await?;
|
|
Ok(SystemdServiceStatus {
|
|
installed,
|
|
active,
|
|
main_pid,
|
|
})
|
|
}
|
|
|
|
async fn systemctl_success(args: &[&str]) -> Result<bool, CliError> {
|
|
let output = run_systemctl_output(args).await?;
|
|
Ok(output.status.success())
|
|
}
|
|
|
|
async fn read_systemd_main_pid() -> Result<Option<u32>, CliError> {
|
|
let output = run_systemctl_output(&[
|
|
"--user",
|
|
"show",
|
|
TVCTLD_SYSTEMD_UNIT,
|
|
"--property=MainPID",
|
|
"--value",
|
|
])
|
|
.await?;
|
|
if !output.status.success() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let pid = stdout.trim().parse::<u32>().ok().filter(|pid| *pid > 0);
|
|
Ok(pid)
|
|
}
|
|
|
|
fn service_owns_daemon(service: &SystemdServiceStatus, status: &DaemonStatus) -> bool {
|
|
service.active && service.main_pid == Some(status.pid)
|
|
}
|
|
|
|
fn daemon_mode_for_status(service: &SystemdServiceStatus, status: &DaemonStatus) -> DaemonMode {
|
|
if service_owns_daemon(service, status) {
|
|
DaemonMode::Systemd
|
|
} else {
|
|
DaemonMode::AdHoc
|
|
}
|
|
}
|
|
|
|
async fn load_config() -> Result<TvctlConfig, CliError> {
|
|
TvctlConfig::load().await.map_err(|error| {
|
|
CliError::new(
|
|
format!("Failed to load tvctl configuration: {error}"),
|
|
"Inspect ~/.config/tvctl/config.toml for invalid TOML.",
|
|
)
|
|
})
|
|
}
|
|
|
|
async fn reload_daemon_config() -> Result<ConfigReloadResult, CliError> {
|
|
let socket = daemon_status_payload()
|
|
.await
|
|
.map(|status| PathBuf::from(status.socket))
|
|
.ok_or_else(|| {
|
|
CliError::new(
|
|
"tvctld is not running, so config cannot be hot-reloaded.",
|
|
"Start the daemon first or let the new config apply on the next start.",
|
|
)
|
|
})?;
|
|
let response = send_request(socket, &DaemonRequest::ReloadConfig).await?;
|
|
parse_response_data(response)
|
|
}
|
|
|
|
async fn maybe_reload_daemon_config(
|
|
daemon_socket: Option<PathBuf>,
|
|
) -> Result<Option<ConfigReloadResult>, CliError> {
|
|
let Some(socket) = daemon_socket else {
|
|
return Ok(None);
|
|
};
|
|
let response = send_request(socket, &DaemonRequest::ReloadConfig).await?;
|
|
parse_response_data(response).map(Some)
|
|
}
|
|
|
|
async fn save_config_with_reload(
|
|
path: PathBuf,
|
|
mutate: impl FnOnce(&mut TvctlConfig) -> Result<(), CliError>,
|
|
) -> Result<Option<ConfigReloadResult>, CliError> {
|
|
let daemon_socket = daemon_status_payload()
|
|
.await
|
|
.map(|status| PathBuf::from(status.socket));
|
|
let mut config = load_config().await?;
|
|
mutate(&mut config)?;
|
|
config.save_to_path(&path).await.map_err(|error| {
|
|
CliError::new(
|
|
format!("Failed to save config: {error}"),
|
|
"Check write permissions for ~/.config/tvctl/config.toml.",
|
|
)
|
|
})?;
|
|
maybe_reload_daemon_config(daemon_socket).await
|
|
}
|
|
|
|
async fn load_socket_path() -> Result<PathBuf, CliError> {
|
|
let config = load_config().await?;
|
|
let runtime_paths = RuntimePaths::detect();
|
|
if let Ok(active) = fs::read_to_string(&runtime_paths.active_socket_file).await {
|
|
let active = PathBuf::from(active.trim());
|
|
if !active.as_os_str().is_empty() && active.exists() {
|
|
return Ok(active);
|
|
}
|
|
}
|
|
let fallback = runtime_paths.socket_file;
|
|
let configured = PathBuf::from(config.daemon.socket);
|
|
Ok(if configured.as_os_str().is_empty() {
|
|
fallback
|
|
} else {
|
|
configured
|
|
})
|
|
}
|
|
|
|
async fn run_systemctl(args: &[&str]) -> Result<(), CliError> {
|
|
let output = run_systemctl_output(args).await?;
|
|
|
|
if output.status.success() {
|
|
return Ok(());
|
|
}
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
let detail = if stderr.is_empty() {
|
|
format!("systemctl exited with status {}", output.status)
|
|
} else {
|
|
stderr
|
|
};
|
|
Err(CliError::new(
|
|
format!("systemctl failed: {detail}"),
|
|
"Check whether the user systemd instance is running and retry.",
|
|
))
|
|
}
|
|
|
|
async fn run_systemctl_output(args: &[&str]) -> Result<std::process::Output, CliError> {
|
|
TokioCommand::new("systemctl")
|
|
.args(args)
|
|
.output()
|
|
.await
|
|
.map_err(|error| {
|
|
CliError::new(
|
|
format!("Failed to run systemctl: {error}"),
|
|
"Make sure systemd user services are available in this session.",
|
|
)
|
|
})
|
|
}
|
|
|
|
async fn send_request(
|
|
socket_path: PathBuf,
|
|
request: &DaemonRequest,
|
|
) -> Result<DaemonResponse, CliError> {
|
|
let mut stream = UnixStream::connect(&socket_path).await.map_err(|error| {
|
|
CliError::new(
|
|
format!(
|
|
"Unable to reach tvctld at {}: {error}",
|
|
socket_path.display()
|
|
),
|
|
"Run `tvctl daemon start` first.",
|
|
)
|
|
})?;
|
|
|
|
let bytes = serde_json::to_vec(request).map_err(|error| {
|
|
CliError::new(
|
|
format!("Failed to serialize daemon request: {error}"),
|
|
"Inspect the CLI build and retry.",
|
|
)
|
|
})?;
|
|
stream.write_all(&bytes).await.map_err(|error| {
|
|
CliError::new(
|
|
format!("Failed to write request to tvctld: {error}"),
|
|
"Check the daemon socket permissions and retry.",
|
|
)
|
|
})?;
|
|
stream.shutdown().await.map_err(|error| {
|
|
CliError::new(
|
|
format!("Failed to finish the daemon request: {error}"),
|
|
"Retry the command after restarting the daemon.",
|
|
)
|
|
})?;
|
|
|
|
let mut response_bytes = Vec::new();
|
|
stream
|
|
.read_to_end(&mut response_bytes)
|
|
.await
|
|
.map_err(|error| {
|
|
CliError::new(
|
|
format!("Failed to read the daemon response: {error}"),
|
|
"Retry the command after restarting the daemon.",
|
|
)
|
|
})?;
|
|
|
|
let response = serde_json::from_slice::<DaemonResponse>(&response_bytes).map_err(|error| {
|
|
CliError::new(
|
|
format!("Failed to decode the daemon response: {error}"),
|
|
"Ensure the CLI and daemon are from the same build.",
|
|
)
|
|
})?;
|
|
|
|
if let Some(error) = &response.error {
|
|
return Err(CliError::new(
|
|
error.message.clone(),
|
|
error.hint.clone().unwrap_or_else(|| {
|
|
"Retry the command after checking the daemon state.".to_string()
|
|
}),
|
|
));
|
|
}
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
fn parse_response_data<T>(response: DaemonResponse) -> Result<T, CliError>
|
|
where
|
|
T: serde::de::DeserializeOwned,
|
|
{
|
|
let data = response.data.ok_or_else(|| {
|
|
CliError::new(
|
|
"The daemon response did not include data.",
|
|
"Ensure the CLI and daemon are from the same build.",
|
|
)
|
|
})?;
|
|
serde_json::from_value(data).map_err(|error| {
|
|
CliError::new(
|
|
format!("Failed to decode daemon payload: {error}"),
|
|
"Ensure the CLI and daemon are from the same build.",
|
|
)
|
|
})
|
|
}
|
|
|
|
fn render<T>(cli: &Cli, data: &T, human: impl FnOnce() -> String) -> Result<(), CliError>
|
|
where
|
|
T: Serialize,
|
|
{
|
|
if cli.json {
|
|
let json = serde_json::to_string_pretty(data).map_err(|error| {
|
|
CliError::new(
|
|
format!("Failed to encode JSON output: {error}"),
|
|
"Retry the command or inspect the output type.",
|
|
)
|
|
})?;
|
|
println!("{json}");
|
|
} else {
|
|
println!("{}", human());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn render_action_response(cli: &Cli, response: DaemonResponse) -> Result<(), CliError> {
|
|
let result: ActionResult = parse_response_data(response)?;
|
|
render(cli, &result, || result.detail.clone())
|
|
}
|
|
|
|
fn render_device_list(devices: &[Device]) -> String {
|
|
if devices.is_empty() {
|
|
return "No devices are registered yet.".to_string();
|
|
}
|
|
|
|
devices
|
|
.iter()
|
|
.map(|device| {
|
|
format!(
|
|
"{} [{}] {}:{}{}",
|
|
device.name,
|
|
device.platform,
|
|
device.address,
|
|
device.port,
|
|
default_marker(device)
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn render_discovery_result(devices: &[Device]) -> String {
|
|
if devices.is_empty() {
|
|
return "Discovery completed, but no devices were found.".to_string();
|
|
}
|
|
format!(
|
|
"Discovered {} device(s).\n{}",
|
|
devices.len(),
|
|
render_device_list(devices)
|
|
)
|
|
}
|
|
|
|
fn render_device_info(device: &Device) -> String {
|
|
[
|
|
format!("Name: {}", device.name),
|
|
format!("Original Name: {}", device.original_name),
|
|
format!("UUID: {}", device.id),
|
|
format!("Platform: {}", device.platform),
|
|
format!("Address: {}:{}", device.address, device.port),
|
|
format!("Default: {}", device.is_default),
|
|
format!("Discovered At: {}", device.discovered_at),
|
|
format!("Last Seen: {}", device.last_seen),
|
|
]
|
|
.join("\n")
|
|
}
|
|
|
|
fn render_app_list(apps: &[AppInfo], platform: &str) -> String {
|
|
if apps.is_empty() {
|
|
return format!("No cached apps are known yet for platform {platform}.");
|
|
}
|
|
|
|
format!(
|
|
"Cached apps for {platform}:\n{}",
|
|
apps.iter()
|
|
.map(|app| format!("{} [{}]", app.name, app.platform_id))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
)
|
|
}
|
|
|
|
fn render_app_refresh(result: &AppRefreshResult) -> String {
|
|
format!(
|
|
"Refreshed {} app(s) from {}. {} cached app(s) are now known for {}.",
|
|
result.refreshed_count, result.device.name, result.cached_count, result.platform
|
|
)
|
|
}
|
|
|
|
fn render_state(device: &Device, state: &DeviceState) -> String {
|
|
let active_app = state
|
|
.active_app
|
|
.as_ref()
|
|
.map(|app| app.name.as_str())
|
|
.unwrap_or("none");
|
|
let volume = state
|
|
.volume
|
|
.as_ref()
|
|
.map(|volume| {
|
|
format!(
|
|
"{}{}",
|
|
volume.level,
|
|
if volume.muted { " (muted)" } else { "" }
|
|
)
|
|
})
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
[
|
|
format!("Device: {}", device.name),
|
|
format!("Power: {:?}", state.power),
|
|
format!("Active App: {active_app}"),
|
|
format!("Volume: {volume}"),
|
|
format!("Timestamp: {}", state.timestamp),
|
|
]
|
|
.join("\n")
|
|
}
|
|
|
|
fn render_dev_logs(result: &DevLogsResult) -> String {
|
|
if result.lines.is_empty() {
|
|
return format!(
|
|
"No recent developer logs were returned from {}.",
|
|
result.device.name
|
|
);
|
|
}
|
|
result.lines.join("\n")
|
|
}
|
|
|
|
fn render_daemon_status(
|
|
service: &SystemdServiceStatus,
|
|
status: &DaemonStatus,
|
|
mode: DaemonMode,
|
|
) -> String {
|
|
let http = if status.http_enabled {
|
|
format!("{}:{}", status.http_host, status.http_port)
|
|
} else {
|
|
"disabled".to_string()
|
|
};
|
|
let default_device = status.default_device.as_deref().unwrap_or("none");
|
|
let mode_label = match mode {
|
|
DaemonMode::AdHoc => "ad hoc",
|
|
DaemonMode::Systemd => "systemd user service",
|
|
};
|
|
let mut lines = vec![
|
|
format!("tvctld is running ({mode_label})."),
|
|
format!("PID: {}", status.pid),
|
|
format!("Socket: {}", status.socket),
|
|
format!("HTTP: {http}"),
|
|
format!("Known Devices: {}", status.device_count),
|
|
format!("Default Device: {default_device}"),
|
|
];
|
|
if service.installed {
|
|
lines.push(format!(
|
|
"Service Installed: yes ({})",
|
|
if service.active { "active" } else { "inactive" }
|
|
));
|
|
}
|
|
lines.join("\n")
|
|
}
|
|
|
|
fn render_service_install(result: &ServiceInstallResult) -> String {
|
|
if result.already_installed {
|
|
let running = if result.running { "yes" } else { "no" };
|
|
return [
|
|
"tvctld user service is already installed.".to_string(),
|
|
format!("Unit File: {}", result.unit_file),
|
|
"Enabled: yes".to_string(),
|
|
format!("Running: {running}"),
|
|
]
|
|
.join("\n");
|
|
}
|
|
|
|
[
|
|
"Installed and started tvctld user service.".to_string(),
|
|
format!("Unit File: {}", result.unit_file),
|
|
"Enabled: yes".to_string(),
|
|
"Running: yes".to_string(),
|
|
"Use `tvctl daemon start|stop|restart` to manage the service.".to_string(),
|
|
]
|
|
.join("\n")
|
|
}
|
|
|
|
fn render_service_uninstall(result: &ServiceUninstallResult) -> String {
|
|
let mut lines = Vec::new();
|
|
if result.removed_unit {
|
|
lines.push("Removed tvctld user service.".to_string());
|
|
lines.push(format!("Unit File: {}", result.unit_file));
|
|
} else {
|
|
lines.push("tvctld user service was not installed.".to_string());
|
|
}
|
|
if result.stopped_service {
|
|
lines.push("Stopped the running tvctld user service first.".to_string());
|
|
}
|
|
if result.stopped_ad_hoc {
|
|
lines.push("Stopped a running ad hoc tvctld daemon.".to_string());
|
|
}
|
|
lines.join("\n")
|
|
}
|
|
|
|
fn redact_config_value(key: &str, value: String) -> String {
|
|
if is_secret_config_key(key) && !value.is_empty() {
|
|
return "<redacted>".to_string();
|
|
}
|
|
value
|
|
}
|
|
|
|
fn sanitize_config_reload_result(mut result: ConfigReloadResult) -> ConfigReloadResult {
|
|
if !result.config.dev.roku_password.is_empty() {
|
|
result.config.dev.roku_password = "<redacted>".to_string();
|
|
}
|
|
result
|
|
}
|
|
|
|
fn render_config_list(values: &BTreeMap<String, String>) -> String {
|
|
values
|
|
.iter()
|
|
.map(|(key, value)| format!("{key} = {value}"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn render_config_mutation(prefix: &str, result: &ConfigMutationResult) -> String {
|
|
let mut lines = vec![
|
|
prefix.to_string(),
|
|
format!("Config path: {}", result.config_path),
|
|
];
|
|
if let (Some(key), Some(value)) = (&result.key, &result.value) {
|
|
lines.push(format!("{key} = {value}"));
|
|
}
|
|
if result.daemon_reloaded {
|
|
lines.push("Running daemon reloaded config.".to_string());
|
|
} else {
|
|
lines.push("Daemon not running; changes will apply on next start.".to_string());
|
|
}
|
|
if !result.restart_required.is_empty() {
|
|
lines.push(format!(
|
|
"Restart required for: {}",
|
|
result.restart_required.join(", ")
|
|
));
|
|
}
|
|
lines.join("\n")
|
|
}
|
|
|
|
fn render_config_reload(result: &ConfigReloadResult) -> String {
|
|
if result.restart_required.is_empty() {
|
|
return "Reloaded config into the running daemon.".to_string();
|
|
}
|
|
format!(
|
|
"Reloaded config into the running daemon.\nRestart required for: {}",
|
|
result.restart_required.join(", ")
|
|
)
|
|
}
|
|
|
|
fn default_marker(device: &Device) -> &'static str {
|
|
if device.is_default { " (default)" } else { "" }
|
|
}
|
|
|
|
fn render_systemd_unit(exe: &std::path::Path) -> String {
|
|
format!(
|
|
"[Unit]\nDescription=tvctl daemon\nAfter=network.target\n\n[Service]\nType=simple\nExecStart={} __daemon_serve\nRestart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=default.target\n",
|
|
exe.display()
|
|
)
|
|
}
|
|
|
|
fn is_secret_config_key(key: &str) -> bool {
|
|
matches!(key, "dev.roku_password")
|
|
}
|
|
|
|
fn parse_tv_key(input: &str) -> Result<TvKey, CliError> {
|
|
parse_normalized_tv_key(input).map_err(|_| {
|
|
CliError::new(
|
|
format!("Unknown key '{input}'."),
|
|
"Use a normalized key like `home`, `down`, `volume-up`, or `literal:text`.",
|
|
)
|
|
})
|
|
}
|