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:
44r0n7
2026-04-14 10:19:14 -04:00
parent 642fa716d1
commit 29e53d16b0
14 changed files with 2176 additions and 46 deletions
+548 -9
View File
@@ -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 { "" }
}