refactor: clean up daemon and CLI duplication

Reduce repeated adapter dispatch, CLI action rendering, and config save
flows while keeping the current Roku behavior and docs aligned with the
known secret-menu limitations.
This commit is contained in:
44r0n7
2026-04-15 15:25:49 -04:00
parent 0095462216
commit 45620b1ab5
9 changed files with 802 additions and 237 deletions
+1 -1
View File
@@ -221,7 +221,7 @@ tvctl
│ ├── stop │ ├── stop
│ └── refresh │ └── refresh
├── remote ├── remote
│ └── key <key> [key...] │ └── send <key> [key...]
├── state ├── state
├── dev ├── dev
│ ├── install <zip> │ ├── install <zip>
+20 -7
View File
@@ -120,6 +120,13 @@ includes a timestamp so callers know how fresh the data is. Cleared on daemon re
tvctl <resource> <verb> [args] [flags] tvctl <resource> <verb> [args] [flags]
``` ```
## Known Issues
- Some Roku secret-menu sequences sent through `tvctl remote send` do not reliably open the expected menu, even when the same sequence works on the physical Roku remote.
- Current Roku input delivery uses ECP `keypress/...` requests only. Roku's official ECP docs also document `keydown/...` and `keyup/...`, which likely need investigation for higher-fidelity secret-menu automation.
- Normal navigation and app control work as expected, but Roku secret-menu automation should currently be treated as experimental.
- Tracking issue: [#1 Investigate Roku secret-menu sequence reliability over ECP](https://git.44r0n.cc/44r0n7/tvctl/issues/1)
Global flags available on every command: Global flags available on every command:
``` ```
@@ -131,12 +138,13 @@ Global flags available on every command:
### daemon ### daemon
Manage the tvctld background service. Manage the tvctld background daemon. When the user service is installed, these
commands manage the systemd user service instead of an ad hoc background process.
``` ```
tvctl daemon start Start the daemon tvctl daemon start Start the daemon or installed user service
tvctl daemon stop Stop the daemon tvctl daemon stop Stop the daemon or installed user service
tvctl daemon restart Restart the daemon tvctl daemon restart Restart the daemon or installed user service
tvctl daemon status Show daemon status tvctl daemon status Show daemon status
tvctl daemon install Generate and enable systemd user service tvctl daemon install Generate and enable systemd user service
tvctl daemon uninstall Remove systemd user service tvctl daemon uninstall Remove systemd user service
@@ -166,12 +174,16 @@ tvctl app stop Stop the current app
tvctl app refresh Refresh app cache from TV tvctl app refresh Refresh app cache from TV
``` ```
`tvctl app refresh --clear` clears the persisted cache for the current platform
before reloading it from the selected device. Use it when cached app names or IDs
look stale, or when removed apps are still showing up in the cache.
### remote ### remote
Send input to the TV. Send input to the TV.
``` ```
tvctl remote key <key> [key...] Send one or more keypresses tvctl remote send <key> [key...] Send one or more keypresses
``` ```
**Available keys:** **Available keys:**
@@ -426,10 +438,11 @@ roku_password = ""
# Install (once binary releases exist) # Install (once binary releases exist)
# cargo install tvctl # cargo install tvctl
# Start the daemon # Start the daemon ad hoc
tvctl daemon start tvctl daemon start
# Or install as a systemd user service # Or install as a systemd user service
# After install, daemon start/stop/restart manage the service
tvctl daemon install tvctl daemon install
# Discover TVs on your network # Discover TVs on your network
@@ -442,7 +455,7 @@ tvctl device select "Living Room"
tvctl app launch netflix tvctl app launch netflix
# Send a keypress # Send a keypress
tvctl remote key home tvctl remote send home
# Query state # Query state
tvctl state tvctl state
+1
View File
@@ -179,3 +179,4 @@ so future agents understand why things are the way they are.
| 2026-04-14 | No pre-populated app database | Organic cache from live TV data is more accurate and zero-maintenance | | 2026-04-14 | No pre-populated app database | Organic cache from live TV data is more accurate and zero-maintenance |
| 2026-04-14 | Unix socket for CLI, HTTP for tool builders | Clean security boundary, loopback-only by default | | 2026-04-14 | Unix socket for CLI, HTTP for tool builders | Clean security boundary, loopback-only by default |
| 2026-04-14 | User-level systemd service | No root required, correct ownership model | | 2026-04-14 | User-level systemd service | No root required, correct ownership model |
| 2026-04-15 | Future remote input options should use a generalized adapter capability model | If multiple adapters need press/release or other delivery semantics, model it as adapter capabilities instead of hard-coding Roku-only behavior |
+47 -2
View File
@@ -33,6 +33,24 @@ const DEFAULT_DISCOVERY_TIMEOUT_SECS: u64 = 3;
const DEFAULT_DEV_LOG_WINDOW_SECS: u64 = 3; const DEFAULT_DEV_LOG_WINDOW_SECS: u64 = 3;
const ROKU_DEV_WEB_PORT: u16 = 80; const ROKU_DEV_WEB_PORT: u16 = 80;
const ROKU_DEV_DEBUG_PORT: u16 = 8085; const ROKU_DEV_DEBUG_PORT: u16 = 8085;
const DEFAULT_KEY_PRESS_DURATION_MS: u64 = 75;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RokuKeyMode {
KeyPress,
KeyDownUp { press_duration: Duration },
}
impl RokuKeyMode {
pub fn from_config(mode: &str, press_duration_ms: u64) -> Self {
match mode.trim().to_ascii_lowercase().as_str() {
"keydown_up" | "keydownup" | "keyevents" | "keyevent" => Self::KeyDownUp {
press_duration: Duration::from_millis(press_duration_ms.max(1)),
},
_ => Self::KeyPress,
}
}
}
/// A Roku ECP adapter backed by SSDP and HTTP requests. /// A Roku ECP adapter backed by SSDP and HTTP requests.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -43,6 +61,7 @@ pub struct RokuAdapter {
dev_log_window: Duration, dev_log_window: Duration,
dev_username: Option<String>, dev_username: Option<String>,
dev_password: Option<String>, dev_password: Option<String>,
key_mode: RokuKeyMode,
} }
impl RokuAdapter { impl RokuAdapter {
@@ -55,6 +74,7 @@ impl RokuAdapter {
dev_log_window: Duration::from_secs(DEFAULT_DEV_LOG_WINDOW_SECS), dev_log_window: Duration::from_secs(DEFAULT_DEV_LOG_WINDOW_SECS),
dev_username: None, dev_username: None,
dev_password: None, dev_password: None,
key_mode: RokuKeyMode::KeyPress,
} }
} }
@@ -67,6 +87,7 @@ impl RokuAdapter {
dev_log_window: Duration::from_secs(DEFAULT_DEV_LOG_WINDOW_SECS), dev_log_window: Duration::from_secs(DEFAULT_DEV_LOG_WINDOW_SECS),
dev_username: None, dev_username: None,
dev_password: None, dev_password: None,
key_mode: RokuKeyMode::KeyPress,
} }
} }
@@ -82,6 +103,20 @@ impl RokuAdapter {
} }
} }
/// Create a Roku adapter with explicit developer-mode credentials and key delivery mode.
pub fn with_config(
dev_username: Option<String>,
dev_password: Option<String>,
key_mode: RokuKeyMode,
) -> Self {
Self {
dev_username,
dev_password,
key_mode,
..Self::new()
}
}
async fn get_text(&self, url: Url) -> Result<String> { async fn get_text(&self, url: Url) -> Result<String> {
let response = self let response = self
.client .client
@@ -188,6 +223,17 @@ impl RokuAdapter {
self.post_empty(url).await self.post_empty(url).await
} }
async fn send_key_path(&self, device: &Device, path: &str) -> Result<()> {
match &self.key_mode {
RokuKeyMode::KeyPress => self.device_post(device, &format!("keypress/{path}")).await,
RokuKeyMode::KeyDownUp { press_duration } => {
self.device_post(device, &format!("keydown/{path}")).await?;
tokio::time::sleep(*press_duration).await;
self.device_post(device, &format!("keyup/{path}")).await
}
}
}
async fn fetch_device_info(&self, base_url: &Url) -> Result<RokuDeviceInfo> { async fn fetch_device_info(&self, base_url: &Url) -> Result<RokuDeviceInfo> {
let url = Self::join_url(base_url, "query/device-info")?; let url = Self::join_url(base_url, "query/device-info")?;
let xml = self.get_text(url).await?; let xml = self.get_text(url).await?;
@@ -488,8 +534,7 @@ impl TvAdapter for RokuAdapter {
async fn key(&self, device: &Device, key: TvKey) -> Result<()> { async fn key(&self, device: &Device, key: TvKey) -> Result<()> {
for path in roku_key_paths(&key)? { for path in roku_key_paths(&key)? {
self.device_post(device, &format!("keypress/{path}")) self.send_key_path(device, &path).await?;
.await?;
} }
Ok(()) Ok(())
} }
+395 -108
View File
@@ -26,6 +26,20 @@ use crate::{
const DAEMON_START_WAIT_ATTEMPTS: usize = 20; const DAEMON_START_WAIT_ATTEMPTS: usize = 20;
const DAEMON_START_WAIT_INTERVAL: Duration = Duration::from_millis(250); const DAEMON_START_WAIT_INTERVAL: Duration = Duration::from_millis(250);
const DEFAULT_REMOTE_SEQUENCE_DELAY_MS: u64 = 200; 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<u32>,
}
/// The tvctl command-line interface. /// The tvctl command-line interface.
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
@@ -91,13 +105,13 @@ pub enum Command {
/// Manage the tvctld lifecycle. /// Manage the tvctld lifecycle.
#[derive(Debug, Clone, Subcommand)] #[derive(Debug, Clone, Subcommand)]
pub enum DaemonCommand { pub enum DaemonCommand {
/// Start the background daemon process. /// Start the background daemon or installed user service.
Start, Start,
/// Stop the running daemon process. /// Stop the running daemon or installed user service.
Stop, Stop,
/// Restart the running daemon process. /// Restart the running daemon or installed user service.
Restart, Restart,
/// Show whether the daemon is running. /// Show daemon and user-service status.
Status, Status,
/// Generate and enable a systemd user service. /// Generate and enable a systemd user service.
Install, Install,
@@ -174,13 +188,13 @@ pub enum AppCommand {
/// Remote control commands. /// Remote control commands.
#[derive(Debug, Clone, Subcommand)] #[derive(Debug, Clone, Subcommand)]
pub enum RemoteCommand { pub enum RemoteCommand {
/// Send a single normalized key. /// Send one or more normalized keys.
Key { Send {
/// One or more key names such as `home`, `down`, or `literal:abc`. /// One or more key names such as `home`, `down`, or `literal:abc`.
keys: Vec<String>, keys: Vec<String>,
/// Delay between keys in milliseconds when sending more than one key. /// Delay between keys when sending more than one key, in milliseconds.
#[arg(long, default_value_t = DEFAULT_REMOTE_SEQUENCE_DELAY_MS)] #[arg(long, default_value_t = DEFAULT_REMOTE_SEQUENCE_DELAY_MS)]
delay_ms: u64, delay: u64,
}, },
} }
@@ -261,10 +275,23 @@ struct ConfigMutationResult {
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
struct ServiceResult { struct ServiceInstallResult {
unit_file: String, unit_file: String,
installed: bool,
enabled: bool, enabled: bool,
running: 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. /// Parse the CLI and execute the selected command.
@@ -300,12 +327,7 @@ async fn handle_daemon_command(cli: &Cli, command: DaemonCommand) -> Result<(),
match command { match command {
DaemonCommand::Start => daemon_start(cli).await, DaemonCommand::Start => daemon_start(cli).await,
DaemonCommand::Stop => daemon_stop(cli).await, DaemonCommand::Stop => daemon_stop(cli).await,
DaemonCommand::Restart => { DaemonCommand::Restart => daemon_restart(cli).await,
if daemon_status_payload().await.is_some() {
daemon_stop(cli).await?;
}
daemon_start(cli).await
}
DaemonCommand::Status => daemon_status(cli).await, DaemonCommand::Status => daemon_status(cli).await,
DaemonCommand::Install => daemon_install(cli).await, DaemonCommand::Install => daemon_install(cli).await,
DaemonCommand::Uninstall => daemon_uninstall(cli).await, DaemonCommand::Uninstall => daemon_uninstall(cli).await,
@@ -406,8 +428,7 @@ async fn handle_app_command(cli: &Cli, command: AppCommand) -> Result<(), CliErr
}, },
) )
.await?; .await?;
let result: ActionResult = parse_response_data(response)?; render_action_response(cli, response)
render(cli, &result, || result.detail.clone())
} }
AppCommand::Stop => { AppCommand::Stop => {
let response = send_request( let response = send_request(
@@ -417,8 +438,7 @@ async fn handle_app_command(cli: &Cli, command: AppCommand) -> Result<(), CliErr
}, },
) )
.await?; .await?;
let result: ActionResult = parse_response_data(response)?; render_action_response(cli, response)
render(cli, &result, || result.detail.clone())
} }
AppCommand::Refresh { clear } => { AppCommand::Refresh { clear } => {
let response = send_request( let response = send_request(
@@ -437,10 +457,10 @@ async fn handle_app_command(cli: &Cli, command: AppCommand) -> Result<(), CliErr
async fn handle_remote_command(cli: &Cli, command: RemoteCommand) -> Result<(), CliError> { async fn handle_remote_command(cli: &Cli, command: RemoteCommand) -> Result<(), CliError> {
match command { match command {
RemoteCommand::Key { keys, delay_ms } => { RemoteCommand::Send { keys, delay } => {
if keys.is_empty() { if keys.is_empty() {
return Err(CliError::new( return Err(CliError::new(
"At least one key is required for `tvctl remote key`.", "At least one key is required for `tvctl remote send`.",
"Pass one or more keys such as `home` or `home down select`.", "Pass one or more keys such as `home` or `home down select`.",
)); ));
} }
@@ -453,8 +473,7 @@ async fn handle_remote_command(cli: &Cli, command: RemoteCommand) -> Result<(),
}, },
) )
.await?; .await?;
let result: ActionResult = parse_response_data(response)?; render_action_response(cli, response)
render(cli, &result, || result.detail.clone())
} else { } else {
let parsed = keys let parsed = keys
.iter() .iter()
@@ -465,12 +484,11 @@ async fn handle_remote_command(cli: &Cli, command: RemoteCommand) -> Result<(),
&DaemonRequest::SendSequence { &DaemonRequest::SendSequence {
device: cli.device.clone(), device: cli.device.clone(),
keys: parsed, keys: parsed,
delay_ms, delay_ms: delay,
}, },
) )
.await?; .await?;
let result: ActionResult = parse_response_data(response)?; render_action_response(cli, response)
render(cli, &result, || result.detail.clone())
} }
} }
} }
@@ -499,8 +517,7 @@ async fn handle_dev_command(cli: &Cli, command: DevCommand) -> Result<(), CliErr
}, },
) )
.await?; .await?;
let result: ActionResult = parse_response_data(response)?; render_action_response(cli, response)
render(cli, &result, || result.detail.clone())
} }
DevCommand::Reload => { DevCommand::Reload => {
let response = send_request( let response = send_request(
@@ -510,8 +527,7 @@ async fn handle_dev_command(cli: &Cli, command: DevCommand) -> Result<(), CliErr
}, },
) )
.await?; .await?;
let result: ActionResult = parse_response_data(response)?; render_action_response(cli, response)
render(cli, &result, || result.detail.clone())
} }
DevCommand::Logs => { DevCommand::Logs => {
let response = send_request( let response = send_request(
@@ -555,23 +571,15 @@ async fn handle_config_command(cli: &Cli, command: ConfigCommand) -> Result<(),
} }
ConfigCommand::Set { key, value } => { ConfigCommand::Set { key, value } => {
let path = default_config_path(); let path = default_config_path();
let daemon_socket = daemon_status_payload() let reload = save_config_with_reload(path.clone(), |config| {
.await
.map(|status| PathBuf::from(status.socket));
let mut config = load_config().await?;
config.set_value(&key, &value).map_err(|error| { config.set_value(&key, &value).map_err(|error| {
CliError::new( CliError::new(
format!("Failed to set config value: {error}"), format!("Failed to set config value: {error}"),
"Run `tvctl config list` to confirm the key and expected value type.", "Run `tvctl config list` to confirm the key and expected value type.",
) )
})?; })
config.save_to_path(&path).await.map_err(|error| { })
CliError::new( .await?;
format!("Failed to save config: {error}"),
"Check write permissions for ~/.config/tvctl/config.toml.",
)
})?;
let reload = maybe_reload_daemon_config(daemon_socket).await?;
let result = ConfigMutationResult { let result = ConfigMutationResult {
value: Some(redact_config_value(&key, value)), value: Some(redact_config_value(&key, value)),
key: Some(key), key: Some(key),
@@ -587,17 +595,11 @@ async fn handle_config_command(cli: &Cli, command: ConfigCommand) -> Result<(),
} }
ConfigCommand::Reset => { ConfigCommand::Reset => {
let path = default_config_path(); let path = default_config_path();
let daemon_socket = daemon_status_payload() let reload = save_config_with_reload(path.clone(), |config| {
.await *config = TvctlConfig::default();
.map(|status| PathBuf::from(status.socket)); Ok(())
let config = TvctlConfig::default(); })
config.save_to_path(&path).await.map_err(|error| { .await?;
CliError::new(
format!("Failed to reset config: {error}"),
"Check write permissions for ~/.config/tvctl/config.toml.",
)
})?;
let reload = maybe_reload_daemon_config(daemon_socket).await?;
let result = ConfigMutationResult { let result = ConfigMutationResult {
key: None, key: None,
value: None, value: None,
@@ -619,6 +621,27 @@ async fn handle_config_command(cli: &Cli, command: ConfigCommand) -> Result<(),
} }
async fn daemon_start(cli: &Cli) -> Result<(), CliError> { 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 { if let Some(status) = daemon_status_payload().await {
return render(cli, &status, || { return render(cli, &status, || {
format!("tvctld is already running on {}", status.socket) format!("tvctld is already running on {}", status.socket)
@@ -645,63 +668,131 @@ async fn daemon_start(cli: &Cli) -> Result<(), CliError> {
) )
})?; })?;
for _ in 0..DAEMON_START_WAIT_ATTEMPTS { let status = wait_for_daemon_ready().await?;
if let Some(status) = daemon_status_payload().await { render(cli, &status, || {
return render(cli, &status, || {
format!("tvctld started on {}", status.socket) 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> { async fn daemon_stop(cli: &Cli) -> Result<(), CliError> {
let socket = load_socket_path().await?; let service = systemd_service_status().await?;
let response = send_request(socket, &DaemonRequest::Shutdown).await?; if service.installed {
let data: serde_json::Value = parse_response_data(response)?; let mut stopped_service = false;
render(cli, &data, || "tvctld stopped.".to_string()) 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> { async fn daemon_status(cli: &Cli) -> Result<(), CliError> {
let service = systemd_service_status().await?;
if let Some(status) = daemon_status_payload().await { if let Some(status) = daemon_status_payload().await {
let mode = daemon_mode_for_status(&service, &status);
return render(cli, &status, || { return render(cli, &status, || {
let http = if status.http_enabled { render_daemon_status(&service, &status, mode)
format!("{}:{}", status.http_host, status.http_port)
} else {
"disabled".to_string()
};
let default_device = status.default_device.as_deref().unwrap_or("none");
format!(
"tvctld is running.\nPID: {}\nSocket: {}\nHTTP: {}\nKnown Devices: {}\nDefault Device: {}",
status.pid, status.socket, http, status.device_count, default_device
)
}); });
} }
if cli.json { if cli.json {
let status = serde_json::json!({ let status = serde_json::json!({
"running": false, "running": false,
"service_installed": service.installed,
"service_active": service.active,
}); });
return render(cli, &status, || "tvctld is not running.".to_string()); 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."); println!("tvctld is not running.");
}
Ok(()) Ok(())
} }
async fn daemon_install(cli: &Cli) -> Result<(), CliError> { 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| { let exe = std::env::current_exe().map_err(|error| {
CliError::new( CliError::new(
format!("Unable to locate the tvctl binary: {error}"), format!("Unable to locate the tvctl binary: {error}"),
"Run the command from an installed or built tvctl executable.", "Run the command from an installed or built tvctl executable.",
) )
})?; })?;
let unit_path = systemd_unit_path();
if let Some(parent) = unit_path.parent() { if let Some(parent) = unit_path.parent() {
fs::create_dir_all(parent).await.map_err(|error| { fs::create_dir_all(parent).await.map_err(|error| {
CliError::new( CliError::new(
@@ -721,29 +812,36 @@ async fn daemon_install(cli: &Cli) -> Result<(), CliError> {
})?; })?;
run_systemctl(&["--user", "daemon-reload"]).await?; run_systemctl(&["--user", "daemon-reload"]).await?;
run_systemctl(&["--user", "enable", "--now", "tvctld.service"]).await?; run_systemctl(&["--user", "enable", "--now", TVCTLD_SYSTEMD_UNIT]).await?;
wait_for_daemon_ready().await?;
let result = ServiceResult { let result = ServiceInstallResult {
unit_file: unit_path.display().to_string(), unit_file: unit_path.display().to_string(),
installed: true,
enabled: true, enabled: true,
running: true, running: true,
already_installed: false,
}; };
render(cli, &result, || { render(cli, &result, || render_service_install(&result))
format!(
"Installed and started tvctld user service at {}.",
result.unit_file
)
})
} }
async fn daemon_uninstall(cli: &Cli) -> Result<(), CliError> { async fn daemon_uninstall(cli: &Cli) -> Result<(), CliError> {
if daemon_status_payload().await.is_some() {
daemon_stop(cli).await?;
}
let unit_path = systemd_unit_path(); let unit_path = systemd_unit_path();
let _ = run_systemctl(&["--user", "disable", "--now", "tvctld.service"]).await; 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 { match fs::remove_file(&unit_path).await {
Ok(()) => {} Ok(()) => removed_unit = true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => { Err(error) => {
return Err(CliError::new( return Err(CliError::new(
@@ -752,16 +850,18 @@ async fn daemon_uninstall(cli: &Cli) -> Result<(), CliError> {
)); ));
} }
} }
run_systemctl(&["--user", "daemon-reload"]).await?; let _ = run_systemctl(&["--user", "daemon-reload"]).await;
let result = ServiceResult { let result = ServiceUninstallResult {
unit_file: unit_path.display().to_string(), unit_file: unit_path.display().to_string(),
installed: false,
enabled: false, enabled: false,
running: false, running: false,
removed_unit,
stopped_service,
stopped_ad_hoc,
}; };
render(cli, &result, || { render(cli, &result, || render_service_uninstall(&result))
format!("Removed tvctld user service from {}.", result.unit_file)
})
} }
async fn daemon_status_payload() -> Option<DaemonStatus> { async fn daemon_status_payload() -> Option<DaemonStatus> {
@@ -770,6 +870,95 @@ async fn daemon_status_payload() -> Option<DaemonStatus> {
parse_response_data(response).ok() parse_response_data(response).ok()
} }
async fn wait_for_daemon_ready() -> Result<DaemonStatus, CliError> {
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<SystemdServiceStatus, CliError> {
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<bool, CliError> {
let output = run_systemctl_output(args).await?;
Ok(output.status.success())
}
async fn read_systemd_main_pid() -> Result<Option<u32>, 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::<u32>().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, CliError> { async fn load_config() -> Result<TvctlConfig, CliError> {
TvctlConfig::load().await.map_err(|error| { TvctlConfig::load().await.map_err(|error| {
CliError::new( CliError::new(
@@ -803,6 +992,24 @@ async fn maybe_reload_daemon_config(
parse_response_data(response).map(Some) parse_response_data(response).map(Some)
} }
async fn save_config_with_reload(
path: PathBuf,
mutate: impl FnOnce(&mut TvctlConfig) -> Result<(), CliError>,
) -> Result<Option<ConfigReloadResult>, 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<PathBuf, CliError> { async fn load_socket_path() -> Result<PathBuf, CliError> {
let config = load_config().await?; let config = load_config().await?;
let runtime_paths = RuntimePaths::detect(); let runtime_paths = RuntimePaths::detect();
@@ -822,16 +1029,7 @@ async fn load_socket_path() -> Result<PathBuf, CliError> {
} }
async fn run_systemctl(args: &[&str]) -> Result<(), CliError> { async fn run_systemctl(args: &[&str]) -> Result<(), CliError> {
let output = TokioCommand::new("systemctl") let output = run_systemctl_output(args).await?;
.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.",
)
})?;
if output.status.success() { if output.status.success() {
return Ok(()); return Ok(());
@@ -849,6 +1047,19 @@ async fn run_systemctl(args: &[&str]) -> Result<(), CliError> {
)) ))
} }
async fn run_systemctl_output(args: &[&str]) -> Result<std::process::Output, CliError> {
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( async fn send_request(
socket_path: PathBuf, socket_path: PathBuf,
request: &DaemonRequest, request: &DaemonRequest,
@@ -948,6 +1159,11 @@ where
Ok(()) 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 { fn render_device_list(devices: &[Device]) -> String {
if devices.is_empty() { if devices.is_empty() {
return "No devices are registered yet.".to_string(); return "No devices are registered yet.".to_string();
@@ -1052,6 +1268,77 @@ fn render_dev_logs(result: &DevLogsResult) -> String {
result.lines.join("\n") 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 { fn redact_config_value(key: &str, value: String) -> String {
if is_secret_config_key(key) && !value.is_empty() { if is_secret_config_key(key) && !value.is_empty() {
return "<redacted>".to_string(); return "<redacted>".to_string();
+122 -11
View File
@@ -4,6 +4,14 @@ use tokio::fs;
use crate::adapters::AppInfo; use crate::adapters::AppInfo;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppResolution {
Exact(AppInfo),
Fuzzy(AppInfo),
Ambiguous(Vec<AppInfo>),
None,
}
/// A platform-level cache of app metadata discovered from live devices. /// A platform-level cache of app metadata discovered from live devices.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct AppCache { pub struct AppCache {
@@ -70,22 +78,46 @@ impl AppCacheStore {
} }
/// Resolve an app by case-insensitive name or exact ID from the persisted platform cache. /// Resolve an app by case-insensitive name or exact ID from the persisted platform cache.
pub async fn find_app(&self, platform: &str, query: &str) -> anyhow::Result<Option<AppInfo>> { pub async fn find_app(&self, platform: &str, query: &str) -> anyhow::Result<AppResolution> {
let cache = self.load_platform(platform).await?; let cache = self.load_platform(platform).await?;
let normalized = normalize_query(query); let normalized = normalize_query(query);
if let Some(app) = cache.apps.iter().find(|app| { if let Some(app) = cache.apps.iter().find(|app| {
app.platform_id == query || app.id == query || normalize_query(&app.name) == normalized app.platform_id == query || app.id == query || normalize_query(&app.name) == normalized
}) { }) {
return Ok(Some(app.clone())); return Ok(AppResolution::Exact(app.clone()));
} }
Ok(cache let mut matches = cache
.apps .apps
.into_iter() .into_iter()
.filter_map(|app| fuzzy_match_score(&normalized, &app).map(|score| (score, app))) .filter_map(|app| fuzzy_match_score(&normalized, &app).map(|score| (score, app)))
.max_by_key(|(score, app)| (*score, Reverse(app.name.len()))) .collect::<Vec<_>>();
.map(|(_, app)| app)) if matches.is_empty() {
return Ok(AppResolution::None);
}
matches.sort_by_key(|(score, app)| (Reverse(*score), app.name.len(), app.name.clone()));
let best_score = matches[0].0;
let winners = matches
.iter()
.filter(|(score, _)| *score == best_score)
.map(|(_, app)| app.clone())
.collect::<Vec<_>>();
if normalized.len() < 3 && matches.len() > 1 {
return Ok(AppResolution::Ambiguous(
matches.into_iter().map(|(_, app)| app).collect(),
));
}
if winners.len() == 1 {
return Ok(AppResolution::Fuzzy(
winners.into_iter().next().expect("one winner"),
));
}
Ok(AppResolution::Ambiguous(winners))
} }
/// Remove the persisted app cache for a platform. /// Remove the persisted app cache for a platform.
@@ -120,6 +152,9 @@ fn fuzzy_match_score(query: &str, app: &AppInfo) -> Option<u8> {
let platform_id = normalize_query(&app.platform_id); let platform_id = normalize_query(&app.platform_id);
if name.starts_with(query) || id.starts_with(query) || platform_id.starts_with(query) { if name.starts_with(query) || id.starts_with(query) || platform_id.starts_with(query) {
return Some(4);
}
if name.ends_with(query) || id.ends_with(query) || platform_id.ends_with(query) {
return Some(3); return Some(3);
} }
if name.contains(query) || id.contains(query) || platform_id.contains(query) { if name.contains(query) || id.contains(query) || platform_id.contains(query) {
@@ -182,9 +217,11 @@ mod tests {
let resolved = store let resolved = store
.find_app("roku", "youtube") .find_app("roku", "youtube")
.await .await
.expect("app lookup should work") .expect("app lookup should work");
.expect("youtube should exist"); match resolved {
assert_eq!(resolved.platform_id, "837"); AppResolution::Exact(app) => assert_eq!(app.platform_id, "837"),
other => panic!("expected exact youtube match, got {other:?}"),
}
} }
#[tokio::test] #[tokio::test]
@@ -276,8 +313,82 @@ mod tests {
let resolved = store let resolved = store
.find_app("roku", "jelly") .find_app("roku", "jelly")
.await .await
.expect("lookup should work") .expect("lookup should work");
.expect("jellyfin should resolve"); match resolved {
assert_eq!(resolved.name, "Jellyfin"); AppResolution::Fuzzy(app) => assert_eq!(app.name, "Jellyfin"),
other => panic!("expected fuzzy jellyfin match, got {other:?}"),
}
}
#[tokio::test]
async fn find_app_rejects_ambiguous_fuzzy_matches() {
let temp_dir = tempfile::tempdir().expect("temp dir should exist");
let store = AppCacheStore::new(temp_dir.path().join("cache"));
store
.record_platform_apps(
"roku",
vec![
AppInfo {
id: "592369".to_string(),
name: "Jellyfin".to_string(),
version: None,
platform_id: "592369".to_string(),
},
AppInfo {
id: "111".to_string(),
name: "Frndly TV".to_string(),
version: None,
platform_id: "111".to_string(),
},
],
)
.await
.expect("apps should save");
let resolved = store
.find_app("roku", "f")
.await
.expect("lookup should work");
match resolved {
AppResolution::Ambiguous(apps) => assert_eq!(apps.len(), 2),
other => panic!("expected ambiguous match, got {other:?}"),
}
}
#[tokio::test]
async fn find_app_prefers_suffix_match_when_unique() {
let temp_dir = tempfile::tempdir().expect("temp dir should exist");
let store = AppCacheStore::new(temp_dir.path().join("cache"));
store
.record_platform_apps(
"roku",
vec![
AppInfo {
id: "592369".to_string(),
name: "Jellyfin".to_string(),
version: None,
platform_id: "592369".to_string(),
},
AppInfo {
id: "123132".to_string(),
name: "Xfinity Stream".to_string(),
version: None,
platform_id: "123132".to_string(),
},
],
)
.await
.expect("apps should save");
let resolved = store
.find_app("roku", "fin")
.await
.expect("lookup should work");
match resolved {
AppResolution::Fuzzy(app) => assert_eq!(app.name, "Jellyfin"),
other => panic!("expected jellyfin suffix match, got {other:?}"),
}
} }
} }
+31
View File
@@ -18,6 +18,8 @@ pub struct TvctlConfig {
pub discovery: DiscoveryConfig, pub discovery: DiscoveryConfig,
/// Default-device settings. /// Default-device settings.
pub devices: DeviceConfig, pub devices: DeviceConfig,
/// Remote input behavior.
pub remote: RemoteConfig,
/// Developer tooling toggles. /// Developer tooling toggles.
pub dev: DevConfig, pub dev: DevConfig,
} }
@@ -28,6 +30,7 @@ impl Default for TvctlConfig {
daemon: DaemonConfig::default(), daemon: DaemonConfig::default(),
discovery: DiscoveryConfig::default(), discovery: DiscoveryConfig::default(),
devices: DeviceConfig::default(), devices: DeviceConfig::default(),
remote: RemoteConfig::default(),
dev: DevConfig::default(), dev: DevConfig::default(),
} }
} }
@@ -79,6 +82,11 @@ impl TvctlConfig {
self.discovery.timeout_secs.to_string(), self.discovery.timeout_secs.to_string(),
), ),
("devices.default", self.devices.default.clone()), ("devices.default", self.devices.default.clone()),
("remote.roku_key_mode", self.remote.roku_key_mode.clone()),
(
"remote.roku_press_duration_ms",
self.remote.roku_press_duration_ms.to_string(),
),
("dev.enabled", self.dev.enabled.to_string()), ("dev.enabled", self.dev.enabled.to_string()),
("dev.roku_username", self.dev.roku_username.clone()), ("dev.roku_username", self.dev.roku_username.clone()),
("dev.roku_password", self.dev.roku_password.clone()), ("dev.roku_password", self.dev.roku_password.clone()),
@@ -102,6 +110,10 @@ impl TvctlConfig {
"discovery.interval_secs" => self.discovery.interval_secs = 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)?, "discovery.timeout_secs" => self.discovery.timeout_secs = parse_value(key, value)?,
"devices.default" => self.devices.default = value.to_string(), "devices.default" => self.devices.default = value.to_string(),
"remote.roku_key_mode" => self.remote.roku_key_mode = value.to_string(),
"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_bool(key, value)?,
"dev.roku_username" => self.dev.roku_username = value.to_string(), "dev.roku_username" => self.dev.roku_username = value.to_string(),
"dev.roku_password" => self.dev.roku_password = value.to_string(), "dev.roku_password" => self.dev.roku_password = value.to_string(),
@@ -169,6 +181,25 @@ pub struct DeviceConfig {
pub default: String, pub default: String,
} }
/// Remote input behavior.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct RemoteConfig {
/// Roku key delivery mode: `keypress` or `keydown_up`.
pub roku_key_mode: String,
/// How long a Roku key stays pressed before `keyup`, in milliseconds.
pub roku_press_duration_ms: u64,
}
impl Default for RemoteConfig {
fn default() -> Self {
Self {
roku_key_mode: "keypress".to_string(),
roku_press_duration_ms: 75,
}
}
}
/// Developer tooling toggles. /// Developer tooling toggles.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)] #[serde(default)]
+113 -55
View File
@@ -11,7 +11,7 @@ use std::{
time::Duration, time::Duration,
}; };
use cache::AppCacheStore; use cache::{AppCacheStore, AppResolution};
use config::{RuntimePaths, TvctlConfig}; use config::{RuntimePaths, TvctlConfig};
use discovery::DiscoveryService; use discovery::DiscoveryService;
use ipc::{ use ipc::{
@@ -32,7 +32,7 @@ use tracing::warn;
#[cfg(unix)] #[cfg(unix)]
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use crate::adapters::Device; use crate::adapters::{Device, TvKey};
/// The long-lived tvctld process. /// The long-lived tvctld process.
#[derive(Debug)] #[derive(Debug)]
@@ -487,9 +487,34 @@ async fn handle_request(
Ok(device) => device, Ok(device) => device,
Err(response) => return (response, false), Err(response) => return (response, false),
}; };
let target_app = match guard.app_cache.find_app(&device.platform, &app).await { let (target_app, launch_label) = match guard
Ok(Some(cached)) => cached.platform_id, .app_cache
Ok(None) => app.clone(), .find_app(&device.platform, &app)
.await
{
Ok(AppResolution::Exact(cached)) | Ok(AppResolution::Fuzzy(cached)) => (
cached.platform_id.clone(),
format!("{} [{}]", cached.name, cached.platform_id),
),
Ok(AppResolution::Ambiguous(matches)) => {
let options = matches
.into_iter()
.map(|candidate| format!("{} [{}]", candidate.name, candidate.platform_id))
.collect::<Vec<_>>()
.join(", ");
return (
DaemonResponse::error(
"app_launch_ambiguous",
format!("App query '{app}' matches multiple cached apps: {options}"),
Some(
"Use a longer app name, refresh the app cache, or retry with the raw platform app id."
.to_string(),
),
),
false,
);
}
Ok(AppResolution::None) => (app.clone(), app.clone()),
Err(error) => { Err(error) => {
return ( return (
DaemonResponse::error( DaemonResponse::error(
@@ -507,10 +532,7 @@ async fn handle_request(
match guard.adapters.launch(&device, &target_app).await { match guard.adapters.launch(&device, &target_app).await {
Ok(()) => ( Ok(()) => (
DaemonResponse::success(ActionResult { action_success(device, format!("Launched {launch_label}.")),
device,
detail: format!("Launched app '{app}'."),
}),
false, false,
), ),
Err(error) => ( Err(error) => (
@@ -534,10 +556,10 @@ async fn handle_request(
}; };
match guard.adapters.stop_app(&device).await { match guard.adapters.stop_app(&device).await {
Ok(()) => ( Ok(()) => (
DaemonResponse::success(ActionResult { action_success(
device, device.clone(),
detail: "Stopped the active app.".to_string(), format!("Stopped the active app on {}.", device.name),
}), ),
false, false,
), ),
Err(error) => ( Err(error) => (
@@ -586,12 +608,9 @@ async fn handle_request(
Ok(device) => device, Ok(device) => device,
Err(response) => return (response, false), Err(response) => return (response, false),
}; };
let detail = format!("Sent key '{key:?}'."); let detail = format!("Sent key '{}' to {}.", format_tv_key(&key), device.name);
match guard.adapters.key(&device, key).await { match guard.adapters.key(&device, key).await {
Ok(()) => ( Ok(()) => (action_success(device, detail), false),
DaemonResponse::success(ActionResult { device, detail }),
false,
),
Err(error) => ( Err(error) => (
DaemonResponse::error( DaemonResponse::error(
"remote_key_failed", "remote_key_failed",
@@ -612,12 +631,14 @@ async fn handle_request(
Ok(device) => device, Ok(device) => device,
Err(response) => return (response, false), Err(response) => return (response, false),
}; };
let detail = format!("Sent {} key(s) with {} ms spacing.", keys.len(), delay_ms); let detail = format!(
"Sent {} key(s) to {} with {} ms spacing.",
keys.len(),
device.name,
delay_ms
);
match send_key_sequence(&guard.adapters, &device, keys, delay_ms).await { match send_key_sequence(&guard.adapters, &device, keys, delay_ms).await {
Ok(()) => ( Ok(()) => (action_success(device, detail), false),
DaemonResponse::success(ActionResult { device, detail }),
false,
),
Err(error) => ( Err(error) => (
DaemonResponse::error( DaemonResponse::error(
"remote_sequence_failed", "remote_sequence_failed",
@@ -631,14 +652,7 @@ async fn handle_request(
DaemonRequest::DevInstall { device, zip_path } => { DaemonRequest::DevInstall { device, zip_path } => {
let guard = daemon.lock().await; let guard = daemon.lock().await;
if !guard.config.dev.enabled { if !guard.config.dev.enabled {
return ( return (dev_disabled_response(), false);
DaemonResponse::error(
"dev_disabled",
"Developer commands are disabled in the tvctl config.",
Some("Set [dev].enabled = true or use `tvctl config` once that surface exists.".to_string()),
),
false,
);
} }
let device = match resolve_target_device(&guard.registry, device.as_deref()) { let device = match resolve_target_device(&guard.registry, device.as_deref()) {
Ok(device) => device, Ok(device) => device,
@@ -659,10 +673,13 @@ async fn handle_request(
}; };
match guard.adapters.dev_install(&device, &zip).await { match guard.adapters.dev_install(&device, &zip).await {
Ok(()) => ( Ok(()) => (
DaemonResponse::success(ActionResult { action_success(
device, device.clone(),
detail: format!("Installed development package from {zip_path}."), format!(
}), "Installed development package from {zip_path} on {}.",
device.name
),
),
false, false,
), ),
Err(error) => ( Err(error) => (
@@ -681,14 +698,7 @@ async fn handle_request(
DaemonRequest::DevReload { device } => { DaemonRequest::DevReload { device } => {
let guard = daemon.lock().await; let guard = daemon.lock().await;
if !guard.config.dev.enabled { if !guard.config.dev.enabled {
return ( return (dev_disabled_response(), false);
DaemonResponse::error(
"dev_disabled",
"Developer commands are disabled in the tvctl config.",
Some("Set [dev].enabled = true or use `tvctl config` once that surface exists.".to_string()),
),
false,
);
} }
let device = match resolve_target_device(&guard.registry, device.as_deref()) { let device = match resolve_target_device(&guard.registry, device.as_deref()) {
Ok(device) => device, Ok(device) => device,
@@ -696,10 +706,10 @@ async fn handle_request(
}; };
match guard.adapters.dev_reload(&device).await { match guard.adapters.dev_reload(&device).await {
Ok(()) => ( Ok(()) => (
DaemonResponse::success(ActionResult { action_success(
device, device.clone(),
detail: "Reloaded the development package.".to_string(), format!("Reloaded the development package on {}.", device.name),
}), ),
false, false,
), ),
Err(error) => ( Err(error) => (
@@ -715,14 +725,7 @@ async fn handle_request(
DaemonRequest::DevLogs { device } => { DaemonRequest::DevLogs { device } => {
let guard = daemon.lock().await; let guard = daemon.lock().await;
if !guard.config.dev.enabled { if !guard.config.dev.enabled {
return ( return (dev_disabled_response(), false);
DaemonResponse::error(
"dev_disabled",
"Developer commands are disabled in the tvctl config.",
Some("Set [dev].enabled = true or use `tvctl config` once that surface exists.".to_string()),
),
false,
);
} }
let device = match resolve_target_device(&guard.registry, device.as_deref()) { let device = match resolve_target_device(&guard.registry, device.as_deref()) {
Ok(device) => device, Ok(device) => device,
@@ -785,6 +788,44 @@ async fn send_key_sequence(
Ok(()) 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>> { async fn run_discovery(daemon: &mut Daemon) -> anyhow::Result<Vec<Device>> {
let discovery = daemon.discovery.clone(); let discovery = daemon.discovery.clone();
let devices = discovery.discover_all(&mut daemon.registry).await?; let devices = discovery.discover_all(&mut daemon.registry).await?;
@@ -819,6 +860,23 @@ async fn sync_registry_config(daemon: &mut Daemon) -> Result<(), String> {
Ok(()) Ok(())
} }
fn action_success(device: Device, detail: impl Into<String>) -> DaemonResponse {
DaemonResponse::success(ActionResult {
device,
detail: detail.into(),
})
}
fn dev_disabled_response() -> DaemonResponse {
DaemonResponse::error(
"dev_disabled",
"Developer commands are disabled in the tvctl config.",
Some(
"Set [dev].enabled = true or use `tvctl config` once that surface exists.".to_string(),
),
)
}
fn resolve_target_device( fn resolve_target_device(
registry: &DeviceRegistry, registry: &DeviceRegistry,
target: Option<&str>, target: Option<&str>,
+65 -46
View File
@@ -7,7 +7,10 @@ use tokio::fs;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
adapters::{AppInfo, Device, DeviceInfo, DeviceState, TvAdapter, TvKey, roku::RokuAdapter}, adapters::{
AppInfo, Device, DeviceInfo, DeviceState, TvAdapter, TvKey,
roku::{RokuAdapter, RokuKeyMode},
},
daemon::config::TvctlConfig, daemon::config::TvctlConfig,
}; };
@@ -196,8 +199,12 @@ impl AdapterRegistry {
(!config.dev.roku_username.is_empty()).then(|| config.dev.roku_username.clone()); (!config.dev.roku_username.is_empty()).then(|| config.dev.roku_username.clone());
let password = let password =
(!config.dev.roku_password.is_empty()).then(|| config.dev.roku_password.clone()); (!config.dev.roku_password.is_empty()).then(|| config.dev.roku_password.clone());
let key_mode = RokuKeyMode::from_config(
&config.remote.roku_key_mode,
config.remote.roku_press_duration_ms,
);
Self { Self {
roku: RokuAdapter::with_dev_credentials(username, password), roku: RokuAdapter::with_config(username, password, key_mode),
} }
} }
@@ -208,10 +215,8 @@ impl AdapterRegistry {
/// Discover candidate devices for one platform. /// Discover candidate devices for one platform.
pub async fn discover(&self, platform: &str) -> anyhow::Result<Vec<DeviceInfo>> { pub async fn discover(&self, platform: &str) -> anyhow::Result<Vec<DeviceInfo>> {
match platform { self.with_platform(platform, |roku| Box::pin(roku.discover()))
"roku" => Ok(self.roku.discover().await?), .await
other => anyhow::bail!("unsupported platform '{other}'"),
}
} }
/// Return true when a platform is supported. /// Return true when a platform is supported.
@@ -226,83 +231,97 @@ impl AdapterRegistry {
address: IpAddr, address: IpAddr,
port: Option<u16>, port: Option<u16>,
) -> anyhow::Result<DeviceInfo> { ) -> anyhow::Result<DeviceInfo> {
match platform { self.with_platform(platform, |roku| {
"roku" => Ok(self Box::pin(roku.probe_device(address, port.unwrap_or(8060)))
.roku })
.probe_device(address, port.unwrap_or(8060)) .await
.await?),
other => anyhow::bail!("unsupported platform '{other}'"),
}
} }
/// Return apps from a concrete device using its platform adapter. /// Return apps from a concrete device using its platform adapter.
pub async fn list_apps(&self, device: &Device) -> anyhow::Result<Vec<AppInfo>> { pub async fn list_apps(&self, device: &Device) -> anyhow::Result<Vec<AppInfo>> {
match device.platform.as_str() { self.with_device(device, |roku, device| Box::pin(roku.list_apps(device)))
"roku" => Ok(self.roku.list_apps(device).await?), .await
other => anyhow::bail!("unsupported platform '{other}'"),
}
} }
/// Fetch the current state for a concrete device. /// Fetch the current state for a concrete device.
pub async fn state(&self, device: &Device) -> anyhow::Result<DeviceState> { pub async fn state(&self, device: &Device) -> anyhow::Result<DeviceState> {
match device.platform.as_str() { self.with_device(device, |roku, device| Box::pin(roku.state(device)))
"roku" => Ok(self.roku.state(device).await?), .await
other => anyhow::bail!("unsupported platform '{other}'"),
}
} }
/// Launch an app on a concrete device. /// Launch an app on a concrete device.
pub async fn launch(&self, device: &Device, app: &str) -> anyhow::Result<()> { pub async fn launch(&self, device: &Device, app: &str) -> anyhow::Result<()> {
match device.platform.as_str() { let app = app.to_string();
"roku" => Ok(self.roku.launch(device, app).await?), self.with_device(device, move |roku, device| {
other => anyhow::bail!("unsupported platform '{other}'"), Box::pin(async move { roku.launch(device, &app).await })
} })
.await
} }
/// Stop the currently running app on a concrete device. /// Stop the currently running app on a concrete device.
pub async fn stop_app(&self, device: &Device) -> anyhow::Result<()> { pub async fn stop_app(&self, device: &Device) -> anyhow::Result<()> {
match device.platform.as_str() { self.with_device(device, |roku, device| Box::pin(roku.stop_app(device)))
"roku" => Ok(self.roku.stop_app(device).await?), .await
other => anyhow::bail!("unsupported platform '{other}'"),
}
} }
/// Send a single normalized key to a concrete device. /// Send a single normalized key to a concrete device.
pub async fn key(&self, device: &Device, key: TvKey) -> anyhow::Result<()> { pub async fn key(&self, device: &Device, key: TvKey) -> anyhow::Result<()> {
match device.platform.as_str() { self.with_device(device, |roku, device| Box::pin(roku.key(device, key)))
"roku" => Ok(self.roku.key(device, key).await?), .await
other => anyhow::bail!("unsupported platform '{other}'"),
}
} }
/// Send a normalized key sequence to a concrete device. /// Send a normalized key sequence to a concrete device.
pub async fn sequence(&self, device: &Device, keys: Vec<TvKey>) -> anyhow::Result<()> { pub async fn sequence(&self, device: &Device, keys: Vec<TvKey>) -> anyhow::Result<()> {
match device.platform.as_str() { self.with_device(device, |roku, device| Box::pin(roku.sequence(device, keys)))
"roku" => Ok(self.roku.sequence(device, keys).await?), .await
other => anyhow::bail!("unsupported platform '{other}'"),
}
} }
/// Install a development package on a concrete device. /// Install a development package on a concrete device.
pub async fn dev_install(&self, device: &Device, zip: &[u8]) -> anyhow::Result<()> { pub async fn dev_install(&self, device: &Device, zip: &[u8]) -> anyhow::Result<()> {
match device.platform.as_str() { let zip = zip.to_vec();
"roku" => Ok(self.roku.dev_install(device, zip).await?), self.with_device(device, move |roku, device| {
other => anyhow::bail!("unsupported platform '{other}'"), Box::pin(async move { roku.dev_install(device, &zip).await })
} })
.await
} }
/// Reload the active development package on a concrete device. /// Reload the active development package on a concrete device.
pub async fn dev_reload(&self, device: &Device) -> anyhow::Result<()> { pub async fn dev_reload(&self, device: &Device) -> anyhow::Result<()> {
match device.platform.as_str() { self.with_device(device, |roku, device| Box::pin(roku.dev_reload(device)))
"roku" => Ok(self.roku.dev_reload(device).await?), .await
other => anyhow::bail!("unsupported platform '{other}'"),
}
} }
/// Fetch development logs from a concrete device. /// Fetch development logs from a concrete device.
pub async fn dev_logs(&self, device: &Device) -> anyhow::Result<Vec<String>> { pub async fn dev_logs(&self, device: &Device) -> anyhow::Result<Vec<String>> {
self.with_device(device, |roku, device| Box::pin(roku.dev_logs(device)))
.await
}
async fn with_platform<T, F>(&self, platform: &str, call: F) -> anyhow::Result<T>
where
F: for<'a> FnOnce(
&'a RokuAdapter,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = crate::adapters::Result<T>> + 'a>,
>,
{
match platform {
"roku" => Ok(call(&self.roku).await?),
other => anyhow::bail!("unsupported platform '{other}'"),
}
}
async fn with_device<T, F>(&self, device: &Device, call: F) -> anyhow::Result<T>
where
F: for<'a> FnOnce(
&'a RokuAdapter,
&'a Device,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = crate::adapters::Result<T>> + 'a>,
>,
{
match device.platform.as_str() { match device.platform.as_str() {
"roku" => Ok(self.roku.dev_logs(device).await?), "roku" => Ok(call(&self.roku, device).await?),
other => anyhow::bail!("unsupported platform '{other}'"), other => anyhow::bail!("unsupported platform '{other}'"),
} }
} }