feat: complete daemon core milestone
Finish Milestone 3 with persisted config, socket IPC, registry CRUD, periodic discovery, manual add, and app-cache refresh support.
This commit is contained in:
+548
-9
@@ -1,4 +1,29 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::{net::IpAddr, path::PathBuf, process::Stdio, time::Duration};
|
||||
|
||||
use clap::{Args, CommandFactory, Parser, Subcommand};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::UnixStream,
|
||||
process::Command as TokioCommand,
|
||||
time::sleep,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
adapters::{AppInfo, Device},
|
||||
daemon::{
|
||||
self,
|
||||
config::{RuntimePaths, TvctlConfig},
|
||||
ipc::{
|
||||
AppListResult, AppRefreshResult, DaemonRequest, DaemonResponse, DaemonStatus,
|
||||
DiscoveryResult,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const DAEMON_START_WAIT_ATTEMPTS: usize = 20;
|
||||
const DAEMON_START_WAIT_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
/// The tvctl command-line interface.
|
||||
#[derive(Debug, Parser)]
|
||||
@@ -22,14 +47,23 @@ pub struct Cli {
|
||||
}
|
||||
|
||||
/// The top-level resource namespaces exposed by tvctl.
|
||||
#[derive(Debug, Subcommand)]
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum Command {
|
||||
/// Manage the background daemon.
|
||||
Daemon,
|
||||
Daemon {
|
||||
#[command(subcommand)]
|
||||
command: DaemonCommand,
|
||||
},
|
||||
/// Discover and manage devices.
|
||||
Device,
|
||||
/// List, launch, and stop applications.
|
||||
App,
|
||||
Device {
|
||||
#[command(subcommand)]
|
||||
command: DeviceCommand,
|
||||
},
|
||||
/// List and refresh application metadata.
|
||||
App {
|
||||
#[command(subcommand)]
|
||||
command: AppCommand,
|
||||
},
|
||||
/// Send remote control input.
|
||||
Remote,
|
||||
/// Query device state.
|
||||
@@ -38,10 +72,515 @@ pub enum Command {
|
||||
Dev,
|
||||
/// Inspect and modify tvctl configuration.
|
||||
Config,
|
||||
/// Internal daemon entry point used by `tvctl daemon start`.
|
||||
#[command(hide = true, name = "__daemon_serve")]
|
||||
InternalDaemonServe,
|
||||
}
|
||||
|
||||
/// Parse the CLI and return successfully for the repository scaffold.
|
||||
pub async fn run() -> anyhow::Result<()> {
|
||||
let _ = Cli::parse();
|
||||
/// Manage the tvctld lifecycle.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum DaemonCommand {
|
||||
/// Start the background daemon process.
|
||||
Start,
|
||||
/// Stop the running daemon process.
|
||||
Stop,
|
||||
/// Restart the running daemon process.
|
||||
Restart,
|
||||
/// Show whether the daemon is running.
|
||||
Status,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// 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,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 => 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.",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
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 => {
|
||||
if daemon_status_payload().await.is_some() {
|
||||
daemon_stop(cli).await?;
|
||||
}
|
||||
daemon_start(cli).await
|
||||
}
|
||||
DaemonCommand::Status => daemon_status(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::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 daemon_start(cli: &Cli) -> Result<(), CliError> {
|
||||
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.",
|
||||
)
|
||||
})?;
|
||||
|
||||
for _ in 0..DAEMON_START_WAIT_ATTEMPTS {
|
||||
if let Some(status) = daemon_status_payload().await {
|
||||
return render(cli, &status, || {
|
||||
format!("tvctld started on {}", status.socket)
|
||||
});
|
||||
}
|
||||
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 daemon_stop(cli: &Cli) -> Result<(), CliError> {
|
||||
let socket = load_socket_path().await?;
|
||||
let response = send_request(socket, &DaemonRequest::Shutdown).await?;
|
||||
let data: serde_json::Value = parse_response_data(response)?;
|
||||
render(cli, &data, || "tvctld stopped.".to_string())
|
||||
}
|
||||
|
||||
async fn daemon_status(cli: &Cli) -> Result<(), CliError> {
|
||||
if let Some(status) = daemon_status_payload().await {
|
||||
return render(cli, &status, || {
|
||||
format!(
|
||||
"tvctld is running on {} with {} known device(s).",
|
||||
status.socket, status.device_count
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
if cli.json {
|
||||
let status = serde_json::json!({
|
||||
"running": false,
|
||||
});
|
||||
return render(cli, &status, || "tvctld is not running.".to_string());
|
||||
}
|
||||
|
||||
println!("tvctld is not running.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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| {
|
||||
CliError::new(
|
||||
format!("Failed to load tvctl configuration: {error}"),
|
||||
"Inspect ~/.config/tvctl/config.toml for invalid TOML.",
|
||||
)
|
||||
})?;
|
||||
let fallback = RuntimePaths::detect().socket_file;
|
||||
let configured = PathBuf::from(config.daemon.socket);
|
||||
Ok(if configured.as_os_str().is_empty() {
|
||||
fallback
|
||||
} else {
|
||||
configured
|
||||
})
|
||||
}
|
||||
|
||||
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_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 default_marker(device: &Device) -> &'static str {
|
||||
if device.is_default { " (default)" } else { "" }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user