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, } /// 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, /// Emit JSON output suitable for scripting. #[arg(long, global = true)] pub json: bool, /// The resource-oriented command to execute. #[command(subcommand)] pub command: Option, } /// 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, /// Optional user-assigned friendly name. #[arg(long)] pub name: Option, } /// 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, /// 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, hint: impl Into) -> 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, } #[derive(Debug, Clone, Serialize)] struct ConfigMutationResult { key: Option, value: Option, config_path: String, daemon_reloaded: bool, restart_required: Vec, } #[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 = 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::, _>>()?; 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::>(); 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 { 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 { 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 { 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 { let output = run_systemctl_output(args).await?; Ok(output.status.success()) } async fn read_systemd_main_pid() -> Result, 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::().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::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 { 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, ) -> Result, 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, 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 { 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 { 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 { 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::(&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(response: DaemonResponse) -> Result 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(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::>() .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::>() .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 "".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 = "".to_string(); } result } fn render_config_list(values: &BTreeMap) -> String { values .iter() .map(|(key, value)| format!("{key} = {value}")) .collect::>() .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 { 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`.", ) }) }