feat: add milestone 6 CLI polish
Add shell completion generation, improve first-run device messaging, and include HTTP endpoint details in daemon startup output.
This commit is contained in:
+44
-49
@@ -18,7 +18,8 @@ use tokio::{
|
||||
use crate::{
|
||||
adapters::{Device, TvKey, parse_normalized_tv_key},
|
||||
daemon::{
|
||||
SharedDaemon, config::TvctlConfig,
|
||||
SharedDaemon,
|
||||
config::TvctlConfig,
|
||||
ipc::{
|
||||
AppListResult, ConfigReloadResult, DaemonRequest, DaemonResponse, DaemonStatus,
|
||||
DevLogsResult, DiscoveryResult, StateResult,
|
||||
@@ -115,13 +116,7 @@ async fn delete_device(Path(id): Path<String>, State(daemon): State<SharedDaemon
|
||||
}
|
||||
|
||||
async fn get_state(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
|
||||
execute_json::<StateResult>(
|
||||
daemon,
|
||||
DaemonRequest::GetState {
|
||||
device: Some(id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
execute_json::<StateResult>(daemon, DaemonRequest::GetState { device: Some(id) }).await
|
||||
}
|
||||
|
||||
async fn list_apps(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
|
||||
@@ -151,13 +146,7 @@ async fn launch_app(
|
||||
}
|
||||
|
||||
async fn stop_app(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
|
||||
execute_json_value(
|
||||
daemon,
|
||||
DaemonRequest::StopApp {
|
||||
device: Some(id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
execute_json_value(daemon, DaemonRequest::StopApp { device: Some(id) }).await
|
||||
}
|
||||
|
||||
async fn refresh_apps(
|
||||
@@ -182,7 +171,7 @@ async fn send_key(
|
||||
) -> Response {
|
||||
let key = match parse_key(&body.key) {
|
||||
Ok(key) => key,
|
||||
Err(response) => return response,
|
||||
Err(response) => return *response,
|
||||
};
|
||||
execute_json_value(
|
||||
daemon,
|
||||
@@ -203,7 +192,7 @@ async fn send_sequence(
|
||||
for key in body.keys {
|
||||
match parse_key(&key) {
|
||||
Ok(key) => parsed.push(key),
|
||||
Err(response) => return response,
|
||||
Err(response) => return *response,
|
||||
}
|
||||
}
|
||||
execute_json_value(
|
||||
@@ -277,23 +266,11 @@ async fn dev_install(
|
||||
}
|
||||
|
||||
async fn dev_reload(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
|
||||
execute_json_value(
|
||||
daemon,
|
||||
DaemonRequest::DevReload {
|
||||
device: Some(id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
execute_json_value(daemon, DaemonRequest::DevReload { device: Some(id) }).await
|
||||
}
|
||||
|
||||
async fn dev_logs(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
|
||||
execute_json::<DevLogsResult>(
|
||||
daemon,
|
||||
DaemonRequest::DevLogs {
|
||||
device: Some(id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
execute_json::<DevLogsResult>(daemon, DaemonRequest::DevLogs { device: Some(id) }).await
|
||||
}
|
||||
|
||||
async fn daemon_status(State(daemon): State<SharedDaemon>) -> Response {
|
||||
@@ -342,7 +319,9 @@ async fn patch_config(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_config_value",
|
||||
format!("Config value for '{key}' must be a string, number, boolean, or null."),
|
||||
Some("Use flat key/value JSON such as {\"daemon.http_port\": 7272}.".to_string()),
|
||||
Some(
|
||||
"Use flat key/value JSON such as {\"daemon.http_port\": 7272}.".to_string(),
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -365,8 +344,7 @@ async fn patch_config(
|
||||
);
|
||||
}
|
||||
|
||||
let response = execute_json_value(daemon, DaemonRequest::ReloadConfig).await;
|
||||
response
|
||||
execute_json_value(daemon, DaemonRequest::ReloadConfig).await
|
||||
}
|
||||
|
||||
async fn reload_config(State(daemon): State<SharedDaemon>) -> Response {
|
||||
@@ -395,7 +373,12 @@ where
|
||||
T: Serialize + DeserializeOwned,
|
||||
{
|
||||
if let Some(error) = response.error {
|
||||
return api_error(status_for_error(&error.code), error.code, error.message, error.hint);
|
||||
return api_error(
|
||||
status_for_error(&error.code),
|
||||
error.code,
|
||||
error.message,
|
||||
error.hint,
|
||||
);
|
||||
}
|
||||
|
||||
let data = response.data.unwrap_or(Value::Null);
|
||||
@@ -437,14 +420,17 @@ fn api_error(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn parse_key(input: &str) -> Result<TvKey, Response> {
|
||||
fn parse_key(input: &str) -> Result<TvKey, Box<Response>> {
|
||||
parse_normalized_tv_key(input).map_err(|_| {
|
||||
api_error(
|
||||
Box::new(api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_key",
|
||||
format!("Unknown key '{input}'."),
|
||||
Some("Use a normalized key like `home`, `down`, `volume-up`, or `literal:text`.".to_string()),
|
||||
)
|
||||
Some(
|
||||
"Use a normalized key like `home`, `down`, `volume-up`, or `literal:text`."
|
||||
.to_string(),
|
||||
),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -486,8 +472,14 @@ async fn send_daemon_request(
|
||||
api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"daemon_socket_unreachable",
|
||||
format!("Unable to reach tvctld at {}: {error}", socket_path.display()),
|
||||
Some("Check whether the daemon socket is writable and the daemon is running.".to_string()),
|
||||
format!(
|
||||
"Unable to reach tvctld at {}: {error}",
|
||||
socket_path.display()
|
||||
),
|
||||
Some(
|
||||
"Check whether the daemon socket is writable and the daemon is running."
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -517,14 +509,17 @@ async fn send_daemon_request(
|
||||
})?;
|
||||
|
||||
let mut response_bytes = Vec::new();
|
||||
stream.read_to_end(&mut response_bytes).await.map_err(|error| {
|
||||
api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"daemon_read_failed",
|
||||
format!("Failed to read the daemon response: {error}"),
|
||||
Some("Retry the request after restarting the daemon.".to_string()),
|
||||
)
|
||||
})?;
|
||||
stream
|
||||
.read_to_end(&mut response_bytes)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"daemon_read_failed",
|
||||
format!("Failed to read the daemon response: {error}"),
|
||||
Some("Retry the request after restarting the daemon.".to_string()),
|
||||
)
|
||||
})?;
|
||||
|
||||
serde_json::from_slice::<DaemonResponse>(&response_bytes).map_err(|error| {
|
||||
api_error(
|
||||
|
||||
+68
-15
@@ -1,6 +1,7 @@
|
||||
use std::{collections::BTreeMap, net::IpAddr, path::PathBuf, process::Stdio, time::Duration};
|
||||
|
||||
use clap::{Args, CommandFactory, Parser, Subcommand};
|
||||
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
|
||||
use clap_complete::{Generator, Shell, generate};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
use tokio::{
|
||||
@@ -97,11 +98,23 @@ pub enum Command {
|
||||
#[command(subcommand)]
|
||||
command: ConfigCommand,
|
||||
},
|
||||
/// Generate shell completion scripts.
|
||||
Completion {
|
||||
#[arg(value_enum)]
|
||||
shell: CompletionShell,
|
||||
},
|
||||
/// Internal daemon entry point used by `tvctl daemon start`.
|
||||
#[command(hide = true, name = "__daemon_serve")]
|
||||
InternalDaemonServe,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub enum CompletionShell {
|
||||
Bash,
|
||||
Zsh,
|
||||
Fish,
|
||||
}
|
||||
|
||||
/// Manage the tvctld lifecycle.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum DaemonCommand {
|
||||
@@ -320,9 +333,29 @@ pub async fn run() -> Result<(), CliError> {
|
||||
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,
|
||||
Command::Completion { shell } => handle_completion_command(shell),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_completion_command(shell: CompletionShell) -> Result<(), CliError> {
|
||||
let mut command = Cli::command();
|
||||
match shell {
|
||||
CompletionShell::Bash => print_completions(Shell::Bash, &mut command),
|
||||
CompletionShell::Zsh => print_completions(Shell::Zsh, &mut command),
|
||||
CompletionShell::Fish => print_completions(Shell::Fish, &mut command),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_completions<G: Generator>(generator: G, command: &mut clap::Command) {
|
||||
generate(
|
||||
generator,
|
||||
command,
|
||||
command.get_name().to_string(),
|
||||
&mut std::io::stdout(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn handle_daemon_command(cli: &Cli, command: DaemonCommand) -> Result<(), CliError> {
|
||||
match command {
|
||||
DaemonCommand::Start => daemon_start(cli).await,
|
||||
@@ -626,10 +659,7 @@ async fn daemon_start(cli: &Cli) -> Result<(), CliError> {
|
||||
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
|
||||
)
|
||||
render_daemon_started("tvctld user service is already running.", &status)
|
||||
});
|
||||
}
|
||||
stop_ad_hoc_daemon(&status).await?;
|
||||
@@ -638,13 +668,13 @@ async fn daemon_start(cli: &Cli) -> Result<(), CliError> {
|
||||
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)
|
||||
render_daemon_started("Started tvctld user service.", &status)
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(status) = daemon_status_payload().await {
|
||||
return render(cli, &status, || {
|
||||
format!("tvctld is already running on {}", status.socket)
|
||||
render_daemon_started("tvctld is already running.", &status)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -670,7 +700,7 @@ async fn daemon_start(cli: &Cli) -> Result<(), CliError> {
|
||||
|
||||
let status = wait_for_daemon_ready().await?;
|
||||
render(cli, &status, || {
|
||||
format!("tvctld started on {}", status.socket)
|
||||
render_daemon_started("tvctld started.", &status)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -725,15 +755,15 @@ async fn daemon_stop(cli: &Cli) -> Result<(), CliError> {
|
||||
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?;
|
||||
}
|
||||
if let Some(status) = daemon_status_payload().await
|
||||
&& !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)
|
||||
render_daemon_started("Restarted tvctld user service.", &status)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1166,7 +1196,11 @@ fn render_action_response(cli: &Cli, response: DaemonResponse) -> Result<(), Cli
|
||||
|
||||
fn render_device_list(devices: &[Device]) -> String {
|
||||
if devices.is_empty() {
|
||||
return "No devices are registered yet.".to_string();
|
||||
return [
|
||||
"No devices are registered yet.".to_string(),
|
||||
"Run `tvctl device discover` to scan the network or `tvctl device add --platform roku --address <ip>` to add one manually.".to_string(),
|
||||
]
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
devices
|
||||
@@ -1187,7 +1221,11 @@ fn render_device_list(devices: &[Device]) -> String {
|
||||
|
||||
fn render_discovery_result(devices: &[Device]) -> String {
|
||||
if devices.is_empty() {
|
||||
return "Discovery completed, but no devices were found.".to_string();
|
||||
return [
|
||||
"Discovery completed, but no devices were found.".to_string(),
|
||||
"Make sure the TV is powered on and reachable, then retry or add it manually with `tvctl device add --platform roku --address <ip>`.".to_string(),
|
||||
]
|
||||
.join("\n");
|
||||
}
|
||||
format!(
|
||||
"Discovered {} device(s).\n{}",
|
||||
@@ -1300,6 +1338,21 @@ fn render_daemon_status(
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_daemon_started(prefix: &str, status: &DaemonStatus) -> String {
|
||||
let http = if status.http_enabled {
|
||||
format!("http://{}:{}/v1", status.http_host, status.http_port)
|
||||
} else {
|
||||
"disabled".to_string()
|
||||
};
|
||||
|
||||
[
|
||||
prefix.to_string(),
|
||||
format!("Socket: {}", status.socket),
|
||||
format!("HTTP: {http}"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn render_service_install(result: &ServiceInstallResult) -> String {
|
||||
if result.already_installed {
|
||||
let running = if result.running { "yes" } else { "no" };
|
||||
|
||||
+1
-13
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::fs;
|
||||
|
||||
/// The complete daemon configuration loaded from TOML.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct TvctlConfig {
|
||||
/// Runtime daemon settings.
|
||||
@@ -24,18 +24,6 @@ pub struct TvctlConfig {
|
||||
pub dev: DevConfig,
|
||||
}
|
||||
|
||||
impl Default for TvctlConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
daemon: DaemonConfig::default(),
|
||||
discovery: DiscoveryConfig::default(),
|
||||
devices: DeviceConfig::default(),
|
||||
remote: RemoteConfig::default(),
|
||||
dev: DevConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TvctlConfig {
|
||||
/// Load configuration from the default XDG path or return defaults when absent.
|
||||
pub async fn load() -> anyhow::Result<Self> {
|
||||
|
||||
+12
-12
@@ -183,7 +183,9 @@ async fn set_socket_permissions(path: &Path) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_http_server_if_enabled(daemon: SharedDaemon) -> anyhow::Result<Option<JoinHandle<()>>> {
|
||||
async fn start_http_server_if_enabled(
|
||||
daemon: SharedDaemon,
|
||||
) -> anyhow::Result<Option<JoinHandle<()>>> {
|
||||
let (enabled, host, port) = {
|
||||
let guard = daemon.lock().await;
|
||||
(
|
||||
@@ -469,17 +471,15 @@ pub(crate) async fn execute_request(
|
||||
Ok(device) => device,
|
||||
Err(response) => return (response, false),
|
||||
};
|
||||
if clear {
|
||||
if let Err(error) = guard.app_cache.clear_platform(&device.platform).await {
|
||||
return (
|
||||
DaemonResponse::error(
|
||||
"app_cache_clear_failed",
|
||||
format!("Failed to clear the cached app list: {error}"),
|
||||
Some("Check permissions for ~/.local/share/tvctl/cache.".to_string()),
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
if clear && let Err(error) = guard.app_cache.clear_platform(&device.platform).await {
|
||||
return (
|
||||
DaemonResponse::error(
|
||||
"app_cache_clear_failed",
|
||||
format!("Failed to clear the cached app list: {error}"),
|
||||
Some("Check permissions for ~/.local/share/tvctl/cache.".to_string()),
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
match guard.adapters.list_apps(&device).await {
|
||||
Ok(apps) => match guard
|
||||
|
||||
@@ -179,19 +179,11 @@ impl Default for DeviceRegistry {
|
||||
}
|
||||
|
||||
/// A registry of platform adapters available to the daemon.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AdapterRegistry {
|
||||
roku: RokuAdapter,
|
||||
}
|
||||
|
||||
impl Default for AdapterRegistry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
roku: RokuAdapter::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AdapterRegistry {
|
||||
/// Build the adapter registry from the loaded daemon config.
|
||||
pub fn from_config(config: &TvctlConfig) -> Self {
|
||||
|
||||
Reference in New Issue
Block a user