Files
tvctl/src/cli/mod.rs
T
44r0n7 b8a0a0ff16 feat: add HTTP API and integration coverage
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.
2026-04-15 15:40:50 -04:00

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`.",
)
})
}