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:
+599
-28
@@ -1,9 +1,10 @@
|
||||
use std::{net::IpAddr, path::PathBuf, process::Stdio, time::Duration};
|
||||
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,
|
||||
@@ -11,13 +12,13 @@ use tokio::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
adapters::{AppInfo, Device},
|
||||
adapters::{AppInfo, Device, DeviceState, TvKey},
|
||||
daemon::{
|
||||
self,
|
||||
config::{RuntimePaths, TvctlConfig},
|
||||
config::{RuntimePaths, TvctlConfig, default_config_path, systemd_unit_path},
|
||||
ipc::{
|
||||
AppListResult, AppRefreshResult, DaemonRequest, DaemonResponse, DaemonStatus,
|
||||
DiscoveryResult,
|
||||
ActionResult, AppListResult, AppRefreshResult, ConfigReloadResult, DaemonRequest,
|
||||
DaemonResponse, DaemonStatus, DevLogsResult, DiscoveryResult, StateResult,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -65,13 +66,22 @@ pub enum Command {
|
||||
command: AppCommand,
|
||||
},
|
||||
/// Send remote control input.
|
||||
Remote,
|
||||
Remote {
|
||||
#[command(subcommand)]
|
||||
command: RemoteCommand,
|
||||
},
|
||||
/// Query device state.
|
||||
State,
|
||||
/// Use developer-oriented TV features.
|
||||
Dev,
|
||||
Dev {
|
||||
#[command(subcommand)]
|
||||
command: DevCommand,
|
||||
},
|
||||
/// Inspect and modify tvctl configuration.
|
||||
Config,
|
||||
Config {
|
||||
#[command(subcommand)]
|
||||
command: ConfigCommand,
|
||||
},
|
||||
/// Internal daemon entry point used by `tvctl daemon start`.
|
||||
#[command(hide = true, name = "__daemon_serve")]
|
||||
InternalDaemonServe,
|
||||
@@ -88,6 +98,10 @@ pub enum DaemonCommand {
|
||||
Restart,
|
||||
/// Show whether the daemon is running.
|
||||
Status,
|
||||
/// Generate and enable a systemd user service.
|
||||
Install,
|
||||
/// Disable and remove the systemd user service.
|
||||
Uninstall,
|
||||
}
|
||||
|
||||
/// Discover and inspect known devices.
|
||||
@@ -141,6 +155,13 @@ pub struct DeviceAddArgs {
|
||||
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.
|
||||
@@ -149,6 +170,58 @@ pub enum AppCommand {
|
||||
},
|
||||
}
|
||||
|
||||
/// Remote control commands.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum RemoteCommand {
|
||||
/// Send a single normalized key.
|
||||
Key {
|
||||
/// Key name such as `home`, `down`, or `literal:abc`.
|
||||
key: String,
|
||||
},
|
||||
/// Send multiple normalized keys in order.
|
||||
Sequence {
|
||||
/// Key names such as `home down select`.
|
||||
keys: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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}")]
|
||||
@@ -168,6 +241,33 @@ impl CliError {
|
||||
}
|
||||
}
|
||||
|
||||
#[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 ServiceResult {
|
||||
unit_file: String,
|
||||
enabled: bool,
|
||||
running: bool,
|
||||
}
|
||||
|
||||
/// Parse the CLI and execute the selected command.
|
||||
pub async fn run() -> Result<(), CliError> {
|
||||
let cli = Cli::parse();
|
||||
@@ -190,22 +290,10 @@ pub async fn run() -> Result<(), CliError> {
|
||||
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 => Err(CliError::new(
|
||||
"Remote commands are not wired to the daemon yet.",
|
||||
"Continue Milestone 4 after the daemon protocol is in place.",
|
||||
)),
|
||||
Command::State => Err(CliError::new(
|
||||
"State queries are not wired to the daemon yet.",
|
||||
"Continue Milestone 4 after the daemon protocol is in place.",
|
||||
)),
|
||||
Command::Dev => Err(CliError::new(
|
||||
"Developer commands are not wired to the daemon yet.",
|
||||
"Continue Milestone 4 after the daemon protocol is in place.",
|
||||
)),
|
||||
Command::Config => Err(CliError::new(
|
||||
"Config commands are not wired to the daemon yet.",
|
||||
"Continue Milestone 4 after the daemon protocol is in place.",
|
||||
)),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +308,8 @@ async fn handle_daemon_command(cli: &Cli, command: DaemonCommand) -> Result<(),
|
||||
daemon_start(cli).await
|
||||
}
|
||||
DaemonCommand::Status => daemon_status(cli).await,
|
||||
DaemonCommand::Install => daemon_install(cli).await,
|
||||
DaemonCommand::Uninstall => daemon_uninstall(cli).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,6 +398,29 @@ async fn handle_app_command(cli: &Cli, command: AppCommand) -> Result<(), CliErr
|
||||
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?;
|
||||
let result: ActionResult = parse_response_data(response)?;
|
||||
render(cli, &result, || result.detail.clone())
|
||||
}
|
||||
AppCommand::Stop => {
|
||||
let response = send_request(
|
||||
load_socket_path().await?,
|
||||
&DaemonRequest::StopApp {
|
||||
device: cli.device.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let result: ActionResult = parse_response_data(response)?;
|
||||
render(cli, &result, || result.detail.clone())
|
||||
}
|
||||
AppCommand::Refresh { clear } => {
|
||||
let response = send_request(
|
||||
load_socket_path().await?,
|
||||
@@ -323,6 +436,187 @@ async fn handle_app_command(cli: &Cli, command: AppCommand) -> Result<(), CliErr
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_remote_command(cli: &Cli, command: RemoteCommand) -> Result<(), CliError> {
|
||||
match command {
|
||||
RemoteCommand::Key { key } => {
|
||||
let response = send_request(
|
||||
load_socket_path().await?,
|
||||
&DaemonRequest::SendKey {
|
||||
device: cli.device.clone(),
|
||||
key: parse_tv_key(&key)?,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let result: ActionResult = parse_response_data(response)?;
|
||||
render(cli, &result, || result.detail.clone())
|
||||
}
|
||||
RemoteCommand::Sequence { keys } => {
|
||||
if keys.is_empty() {
|
||||
return Err(CliError::new(
|
||||
"At least one key is required for `tvctl remote sequence`.",
|
||||
"Pass one or more keys such as `home down select`.",
|
||||
));
|
||||
}
|
||||
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,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let result: ActionResult = parse_response_data(response)?;
|
||||
render(cli, &result, || result.detail.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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?;
|
||||
let result: ActionResult = parse_response_data(response)?;
|
||||
render(cli, &result, || result.detail.clone())
|
||||
}
|
||||
DevCommand::Reload => {
|
||||
let response = send_request(
|
||||
load_socket_path().await?,
|
||||
&DaemonRequest::DevReload {
|
||||
device: cli.device.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let result: ActionResult = parse_response_data(response)?;
|
||||
render(cli, &result, || result.detail.clone())
|
||||
}
|
||||
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 daemon_socket = daemon_status_payload()
|
||||
.await
|
||||
.map(|status| PathBuf::from(status.socket));
|
||||
let mut config = load_config().await?;
|
||||
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.",
|
||||
)
|
||||
})?;
|
||||
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.",
|
||||
)
|
||||
})?;
|
||||
let reload = maybe_reload_daemon_config(daemon_socket).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 daemon_socket = daemon_status_payload()
|
||||
.await
|
||||
.map(|status| PathBuf::from(status.socket));
|
||||
let config = TvctlConfig::default();
|
||||
config.save_to_path(&path).await.map_err(|error| {
|
||||
CliError::new(
|
||||
format!("Failed to reset config: {error}"),
|
||||
"Check write permissions for ~/.config/tvctl/config.toml.",
|
||||
)
|
||||
})?;
|
||||
let reload = maybe_reload_daemon_config(daemon_socket).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> {
|
||||
if let Some(status) = daemon_status_payload().await {
|
||||
return render(cli, &status, || {
|
||||
@@ -393,20 +687,122 @@ async fn daemon_status(cli: &Cli) -> Result<(), CliError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn daemon_install(cli: &Cli) -> Result<(), CliError> {
|
||||
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.",
|
||||
)
|
||||
})?;
|
||||
let unit_path = systemd_unit_path();
|
||||
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.service"]).await?;
|
||||
|
||||
let result = ServiceResult {
|
||||
unit_file: unit_path.display().to_string(),
|
||||
enabled: true,
|
||||
running: true,
|
||||
};
|
||||
render(cli, &result, || {
|
||||
format!(
|
||||
"Installed and started tvctld user service at {}.",
|
||||
result.unit_file
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn daemon_uninstall(cli: &Cli) -> Result<(), CliError> {
|
||||
let unit_path = systemd_unit_path();
|
||||
let _ = run_systemctl(&["--user", "disable", "--now", "tvctld.service"]).await;
|
||||
match fs::remove_file(&unit_path).await {
|
||||
Ok(()) => {}
|
||||
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.",
|
||||
));
|
||||
}
|
||||
}
|
||||
run_systemctl(&["--user", "daemon-reload"]).await?;
|
||||
|
||||
let result = ServiceResult {
|
||||
unit_file: unit_path.display().to_string(),
|
||||
enabled: false,
|
||||
running: false,
|
||||
};
|
||||
render(cli, &result, || {
|
||||
format!("Removed tvctld user service from {}.", result.unit_file)
|
||||
})
|
||||
}
|
||||
|
||||
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 load_socket_path() -> Result<PathBuf, CliError> {
|
||||
let config = TvctlConfig::load().await.map_err(|error| {
|
||||
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.",
|
||||
)
|
||||
})?;
|
||||
let fallback = RuntimePaths::detect().socket_file;
|
||||
})
|
||||
}
|
||||
|
||||
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 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
|
||||
@@ -415,6 +811,34 @@ async fn load_socket_path() -> Result<PathBuf, CliError> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_systemctl(args: &[&str]) -> Result<(), CliError> {
|
||||
let output = 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.",
|
||||
)
|
||||
})?;
|
||||
|
||||
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 send_request(
|
||||
socket_path: PathBuf,
|
||||
request: &DaemonRequest,
|
||||
@@ -581,6 +1005,153 @@ fn render_app_refresh(result: &AppRefreshResult) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
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 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> {
|
||||
if let Some(literal) = input.strip_prefix("literal:") {
|
||||
return Ok(TvKey::Literal(literal.to_string()));
|
||||
}
|
||||
|
||||
match input.to_ascii_lowercase().as_str() {
|
||||
"home" => Ok(TvKey::Home),
|
||||
"back" => Ok(TvKey::Back),
|
||||
"up" => Ok(TvKey::Up),
|
||||
"down" => Ok(TvKey::Down),
|
||||
"left" => Ok(TvKey::Left),
|
||||
"right" => Ok(TvKey::Right),
|
||||
"select" => Ok(TvKey::Select),
|
||||
"play" => Ok(TvKey::Play),
|
||||
"pause" => Ok(TvKey::Pause),
|
||||
"play-pause" => Ok(TvKey::PlayPause),
|
||||
"stop" => Ok(TvKey::Stop),
|
||||
"rewind" => Ok(TvKey::Rewind),
|
||||
"fast-forward" => Ok(TvKey::FastForward),
|
||||
"replay" => Ok(TvKey::Replay),
|
||||
"skip" => Ok(TvKey::Skip),
|
||||
"channel-up" => Ok(TvKey::ChannelUp),
|
||||
"channel-down" => Ok(TvKey::ChannelDown),
|
||||
"volume-up" => Ok(TvKey::VolumeUp),
|
||||
"volume-down" => Ok(TvKey::VolumeDown),
|
||||
"mute" => Ok(TvKey::Mute),
|
||||
"power" => Ok(TvKey::Power),
|
||||
"power-on" => Ok(TvKey::PowerOn),
|
||||
"power-off" => Ok(TvKey::PowerOff),
|
||||
"input-hdmi1" => Ok(TvKey::InputHdmi1),
|
||||
"input-hdmi2" => Ok(TvKey::InputHdmi2),
|
||||
"input-hdmi3" => Ok(TvKey::InputHdmi3),
|
||||
"input-hdmi4" => Ok(TvKey::InputHdmi4),
|
||||
"input-av" => Ok(TvKey::InputAv),
|
||||
"input-tuner" => Ok(TvKey::InputTuner),
|
||||
"search" => Ok(TvKey::Search),
|
||||
"info" => Ok(TvKey::Info),
|
||||
"options" => Ok(TvKey::Options),
|
||||
_ => Err(CliError::new(
|
||||
format!("Unknown key '{input}'."),
|
||||
"Use a normalized key like `home`, `down`, `volume-up`, or `literal:text`.",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user