refactor: harden internal daemon entrypoint and cleanup observations

Remove the internal daemon subcommand from the public CLI surface,
start the daemon via an internal env trigger, and ensure generated
completions/help never expose internal entrypoints.

Also finish the pending observation cleanups and docs updates,
including config/key deduplication, registry matching cleanup, and
remaining roadmap/project map staleness fixes.
This commit is contained in:
44r0n7
2026-04-18 11:55:18 -04:00
parent 274844b558
commit 795aa2f713
13 changed files with 131 additions and 129 deletions
+41
View File
@@ -174,6 +174,47 @@ pub enum TvKey {
Literal(String),
}
impl TvKey {
/// Return the normalized key name used by CLI and API payloads.
pub fn normalized_name(&self) -> String {
match self {
TvKey::Home => "home".to_string(),
TvKey::Back => "back".to_string(),
TvKey::Up => "up".to_string(),
TvKey::Down => "down".to_string(),
TvKey::Left => "left".to_string(),
TvKey::Right => "right".to_string(),
TvKey::Select => "select".to_string(),
TvKey::Play => "play".to_string(),
TvKey::Pause => "pause".to_string(),
TvKey::PlayPause => "play-pause".to_string(),
TvKey::Stop => "stop".to_string(),
TvKey::Rewind => "rewind".to_string(),
TvKey::FastForward => "fast-forward".to_string(),
TvKey::Replay => "replay".to_string(),
TvKey::Skip => "skip".to_string(),
TvKey::ChannelUp => "channel-up".to_string(),
TvKey::ChannelDown => "channel-down".to_string(),
TvKey::VolumeUp => "volume-up".to_string(),
TvKey::VolumeDown => "volume-down".to_string(),
TvKey::Mute => "mute".to_string(),
TvKey::Power => "power".to_string(),
TvKey::PowerOn => "power-on".to_string(),
TvKey::PowerOff => "power-off".to_string(),
TvKey::InputHdmi1 => "input-hdmi1".to_string(),
TvKey::InputHdmi2 => "input-hdmi2".to_string(),
TvKey::InputHdmi3 => "input-hdmi3".to_string(),
TvKey::InputHdmi4 => "input-hdmi4".to_string(),
TvKey::InputAv => "input-av".to_string(),
TvKey::InputTuner => "input-tuner".to_string(),
TvKey::Search => "search".to_string(),
TvKey::Info => "info".to_string(),
TvKey::Options => "options".to_string(),
TvKey::Literal(text) => format!("literal:{text}"),
}
}
}
/// A structured error produced by adapter implementations.
#[derive(Debug, Error)]
pub enum TvError {
+2
View File
@@ -762,6 +762,8 @@ fn roku_key_paths(key: &TvKey) -> Result<Vec<String>> {
.map(|character| format!("Lit_{}", urlencoding::encode(&character.to_string())))
.collect());
}
// These keys are part of the normalized key model but Roku ECP does not
// provide documented equivalents for them.
TvKey::Stop => "stop",
TvKey::Skip => "skip",
TvKey::Power => "power",
+1 -1
View File
@@ -154,7 +154,7 @@ async fn refresh_apps(
daemon,
DaemonRequest::RefreshApps {
device: Some(id),
clear: body.map(|value| value.clear).unwrap_or(false),
clear: body.is_some_and(|value| value.clear),
},
)
.await
+45 -14
View File
@@ -103,9 +103,6 @@ pub enum Command {
#[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)]
@@ -323,9 +320,6 @@ pub async fn run() -> Result<(), CliError> {
};
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,
@@ -348,12 +342,15 @@ fn handle_completion_command(shell: CompletionShell) -> Result<(), CliError> {
}
fn print_completions<G: Generator>(generator: G, command: &mut clap::Command) {
generate(
generator,
command,
command.get_name().to_string(),
&mut std::io::stdout(),
);
write_completions(generator, command, &mut std::io::stdout());
}
fn write_completions<G: Generator, W: std::io::Write>(
generator: G,
command: &mut clap::Command,
writer: &mut W,
) {
generate(generator, command, command.get_name().to_string(), writer);
}
fn build_cli_command() -> clap::Command {
@@ -690,7 +687,7 @@ async fn daemon_start(cli: &Cli) -> Result<(), CliError> {
})?;
TokioCommand::new(exe)
.arg("__daemon_serve")
.env(daemon::INTERNAL_DAEMON_ENV, "1")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
@@ -1456,7 +1453,8 @@ fn default_marker(device: &Device) -> &'static str {
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",
"[Unit]\nDescription=tvctl daemon\nAfter=network.target\n\n[Service]\nType=simple\nEnvironment={}=1\nExecStart={}\nRestart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=default.target\n",
daemon::INTERNAL_DAEMON_ENV,
exe.display()
)
}
@@ -1473,3 +1471,36 @@ fn parse_tv_key(input: &str) -> Result<TvKey, CliError> {
)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn completion_output(shell: Shell) -> String {
let mut command = build_cli_command();
let mut output = Vec::new();
write_completions(shell, &mut command, &mut output);
String::from_utf8(output).expect("completion output should be utf-8")
}
#[test]
fn completions_do_not_expose_internal_daemon_entrypoint() {
for shell in [Shell::Bash, Shell::Zsh, Shell::Fish] {
let output = completion_output(shell);
assert!(
!output.contains("__daemon_serve"),
"completion for {shell:?} should not include internal daemon entrypoint"
);
}
}
#[test]
fn help_does_not_expose_internal_daemon_entrypoint() {
let mut command = build_cli_command();
let help = command.render_help().to_string();
assert!(
!help.contains("__daemon_serve"),
"help output should not include internal daemon entrypoint"
);
}
}
+20 -8
View File
@@ -84,21 +84,37 @@ impl TvctlConfig {
/// Return one flattened config value by stable key.
pub fn get_value(&self, key: &str) -> Option<String> {
self.entries().remove(key)
match key {
"daemon.socket" => Some(self.daemon.socket.clone()),
"daemon.http_enabled" => Some(self.daemon.http_enabled.to_string()),
"daemon.http_port" => Some(self.daemon.http_port.to_string()),
"daemon.http_host" => Some(self.daemon.http_host.clone()),
"daemon.log_level" => Some(self.daemon.log_level.clone()),
"discovery.auto_discover" => Some(self.discovery.auto_discover.to_string()),
"discovery.interval_secs" => Some(self.discovery.interval_secs.to_string()),
"discovery.timeout_secs" => Some(self.discovery.timeout_secs.to_string()),
"devices.default" => Some(self.devices.default.clone()),
"remote.roku_key_mode" => Some(self.remote.roku_key_mode.clone()),
"remote.roku_press_duration_ms" => Some(self.remote.roku_press_duration_ms.to_string()),
"dev.enabled" => Some(self.dev.enabled.to_string()),
"dev.roku_username" => Some(self.dev.roku_username.clone()),
"dev.roku_password" => Some(self.dev.roku_password.clone()),
_ => None,
}
}
/// Set one flattened config value by stable key.
pub fn set_value(&mut self, key: &str, value: &str) -> anyhow::Result<()> {
match key {
"daemon.socket" => self.daemon.socket = value.to_string(),
"daemon.http_enabled" => self.daemon.http_enabled = parse_bool(key, value)?,
"daemon.http_enabled" => self.daemon.http_enabled = parse_value(key, value)?,
"daemon.http_port" => self.daemon.http_port = parse_value(key, value)?,
"daemon.http_host" => self.daemon.http_host = value.to_string(),
"daemon.log_level" => {
validate_log_level(value)?;
self.daemon.log_level = value.to_string();
}
"discovery.auto_discover" => self.discovery.auto_discover = parse_bool(key, value)?,
"discovery.auto_discover" => self.discovery.auto_discover = parse_value(key, value)?,
"discovery.interval_secs" => self.discovery.interval_secs = parse_value(key, value)?,
"discovery.timeout_secs" => self.discovery.timeout_secs = parse_value(key, value)?,
"devices.default" => self.devices.default = value.to_string(),
@@ -106,7 +122,7 @@ impl TvctlConfig {
"remote.roku_press_duration_ms" => {
self.remote.roku_press_duration_ms = parse_value(key, value)?
}
"dev.enabled" => self.dev.enabled = parse_bool(key, value)?,
"dev.enabled" => self.dev.enabled = parse_value(key, value)?,
"dev.roku_username" => self.dev.roku_username = value.to_string(),
"dev.roku_password" => self.dev.roku_password = value.to_string(),
other => bail!("unknown config key '{other}'"),
@@ -301,10 +317,6 @@ fn current_uid() -> u32 {
unsafe { libc::geteuid() }
}
fn parse_bool(key: &str, value: &str) -> anyhow::Result<bool> {
parse_value(key, value)
}
fn parse_value<T>(key: &str, value: &str) -> anyhow::Result<T>
where
T: std::str::FromStr,
+3 -40
View File
@@ -34,11 +34,12 @@ use tracing::warn;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use crate::adapters::{Device, TvKey};
use crate::adapters::Device;
use crate::api;
use crate::logging;
pub type SharedDaemon = Arc<Mutex<Daemon>>;
pub const INTERNAL_DAEMON_ENV: &str = "TVCTL_INTERNAL_DAEMON";
/// The long-lived tvctld process.
#[derive(Debug)]
@@ -644,7 +645,7 @@ pub(crate) async fn execute_request(
Ok(device) => device,
Err(response) => return (response, false),
};
let detail = format!("Sent key '{}' to {}.", format_tv_key(&key), device.name);
let detail = format!("Sent key '{}' to {}.", key.normalized_name(), device.name);
match guard.adapters.key(&device, key).await {
Ok(()) => (action_success(device, detail), false),
Err(error) => (
@@ -834,44 +835,6 @@ async fn send_key_sequence(
Ok(())
}
fn format_tv_key(key: &TvKey) -> String {
match key {
TvKey::Home => "home".to_string(),
TvKey::Back => "back".to_string(),
TvKey::Up => "up".to_string(),
TvKey::Down => "down".to_string(),
TvKey::Left => "left".to_string(),
TvKey::Right => "right".to_string(),
TvKey::Select => "select".to_string(),
TvKey::Play => "play".to_string(),
TvKey::Pause => "pause".to_string(),
TvKey::PlayPause => "play-pause".to_string(),
TvKey::Stop => "stop".to_string(),
TvKey::Rewind => "rewind".to_string(),
TvKey::FastForward => "fast-forward".to_string(),
TvKey::Replay => "replay".to_string(),
TvKey::Skip => "skip".to_string(),
TvKey::ChannelUp => "channel-up".to_string(),
TvKey::ChannelDown => "channel-down".to_string(),
TvKey::VolumeUp => "volume-up".to_string(),
TvKey::VolumeDown => "volume-down".to_string(),
TvKey::Mute => "mute".to_string(),
TvKey::Power => "power".to_string(),
TvKey::PowerOn => "power-on".to_string(),
TvKey::PowerOff => "power-off".to_string(),
TvKey::InputHdmi1 => "input-hdmi1".to_string(),
TvKey::InputHdmi2 => "input-hdmi2".to_string(),
TvKey::InputHdmi3 => "input-hdmi3".to_string(),
TvKey::InputHdmi4 => "input-hdmi4".to_string(),
TvKey::InputAv => "input-av".to_string(),
TvKey::InputTuner => "input-tuner".to_string(),
TvKey::Search => "search".to_string(),
TvKey::Info => "info".to_string(),
TvKey::Options => "options".to_string(),
TvKey::Literal(text) => format!("literal:{text}"),
}
}
async fn run_discovery(daemon: &mut Daemon) -> anyhow::Result<Vec<Device>> {
let discovery = daemon.discovery.clone();
let devices = discovery.discover_all(&mut daemon.registry).await?;
+2 -11
View File
@@ -97,10 +97,8 @@ impl DeviceRegistry {
/// Remove a device by UUID or case-insensitive name.
pub fn remove(&mut self, target: &str) -> Option<Device> {
let index = self
.devices
.iter()
.position(|device| matches_target(device, target))?;
let id = self.find(target)?.id;
let index = self.devices.iter().position(|device| device.id == id)?;
let removed = self.devices.remove(index);
self.ensure_default();
Some(removed)
@@ -319,13 +317,6 @@ impl AdapterRegistry {
}
}
fn matches_target(device: &Device, target: &str) -> bool {
let target_uuid = Uuid::parse_str(target).ok();
let normalized = target.to_ascii_lowercase();
target_uuid.map(|uuid| device.id == uuid).unwrap_or(false)
|| device.name.to_ascii_lowercase() == normalized
}
#[cfg(test)]
mod tests {
use super::*;
+10 -1
View File
@@ -6,7 +6,16 @@ async fn main() {
std::process::exit(1);
}
if let Err(error) = tvctl::cli::run().await {
let result = if std::env::var(tvctl::daemon::INTERNAL_DAEMON_ENV)
.map(|value| value == "1")
.unwrap_or(false)
{
tvctl::daemon::serve().await
} else {
tvctl::cli::run().await.map_err(anyhow::Error::from)
};
if let Err(error) = result {
eprintln!("{error}");
std::process::exit(1);
}