From 45620b1ab51bec24299a20ee05ef1737d60cfe79 Mon Sep 17 00:00:00 2001 From: 44r0n7 <44r0n7@users.noreply.git.44r0n.cc> Date: Wed, 15 Apr 2026 15:25:49 -0400 Subject: [PATCH] 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. --- PROJECT_MAP.md | 2 +- README.md | 27 +- ROADMAP.md | 1 + src/adapters/roku/mod.rs | 49 +++- src/cli/mod.rs | 517 ++++++++++++++++++++++++++++++--------- src/daemon/cache.rs | 133 +++++++++- src/daemon/config.rs | 31 +++ src/daemon/mod.rs | 168 ++++++++----- src/daemon/registry.rs | 111 +++++---- 9 files changed, 802 insertions(+), 237 deletions(-) diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 85f10d9..132aaf8 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -221,7 +221,7 @@ tvctl │ ├── stop │ └── refresh ├── remote -│ └── key [key...] +│ └── send [key...] ├── state ├── dev │ ├── install diff --git a/README.md b/README.md index 7734b57..46b62e2 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,13 @@ includes a timestamp so callers know how fresh the data is. Cleared on daemon re tvctl [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: ``` @@ -131,12 +138,13 @@ Global flags available on every command: ### 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 stop Stop the daemon -tvctl daemon restart Restart the daemon +tvctl daemon start Start the daemon or installed user service +tvctl daemon stop Stop the daemon or installed user service +tvctl daemon restart Restart the daemon or installed user service tvctl daemon status Show daemon status tvctl daemon install Generate and enable 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 --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 Send input to the TV. ``` -tvctl remote key [key...] Send one or more keypresses +tvctl remote send [key...] Send one or more keypresses ``` **Available keys:** @@ -426,10 +438,11 @@ roku_password = "" # Install (once binary releases exist) # cargo install tvctl -# Start the daemon +# Start the daemon ad hoc tvctl daemon start # Or install as a systemd user service +# After install, daemon start/stop/restart manage the service tvctl daemon install # Discover TVs on your network @@ -442,7 +455,7 @@ tvctl device select "Living Room" tvctl app launch netflix # Send a keypress -tvctl remote key home +tvctl remote send home # Query state tvctl state diff --git a/ROADMAP.md b/ROADMAP.md index b500fa3..a46b11f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | 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-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 | diff --git a/src/adapters/roku/mod.rs b/src/adapters/roku/mod.rs index ad338b7..2e0f4b8 100644 --- a/src/adapters/roku/mod.rs +++ b/src/adapters/roku/mod.rs @@ -33,6 +33,24 @@ const DEFAULT_DISCOVERY_TIMEOUT_SECS: u64 = 3; const DEFAULT_DEV_LOG_WINDOW_SECS: u64 = 3; const ROKU_DEV_WEB_PORT: u16 = 80; 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. #[derive(Debug, Clone)] @@ -43,6 +61,7 @@ pub struct RokuAdapter { dev_log_window: Duration, dev_username: Option, dev_password: Option, + key_mode: RokuKeyMode, } impl RokuAdapter { @@ -55,6 +74,7 @@ impl RokuAdapter { dev_log_window: Duration::from_secs(DEFAULT_DEV_LOG_WINDOW_SECS), dev_username: 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_username: 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, + dev_password: Option, + key_mode: RokuKeyMode, + ) -> Self { + Self { + dev_username, + dev_password, + key_mode, + ..Self::new() + } + } + async fn get_text(&self, url: Url) -> Result { let response = self .client @@ -188,6 +223,17 @@ impl RokuAdapter { 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 { let url = Self::join_url(base_url, "query/device-info")?; let xml = self.get_text(url).await?; @@ -488,8 +534,7 @@ impl TvAdapter for RokuAdapter { async fn key(&self, device: &Device, key: TvKey) -> Result<()> { for path in roku_key_paths(&key)? { - self.device_post(device, &format!("keypress/{path}")) - .await?; + self.send_key_path(device, &path).await?; } Ok(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index cc52f41..96f9406 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -26,6 +26,20 @@ use crate::{ const DAEMON_START_WAIT_ATTEMPTS: usize = 20; const DAEMON_START_WAIT_INTERVAL: Duration = Duration::from_millis(250); 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, +} /// The tvctl command-line interface. #[derive(Debug, Parser)] @@ -91,13 +105,13 @@ pub enum Command { /// Manage the tvctld lifecycle. #[derive(Debug, Clone, Subcommand)] pub enum DaemonCommand { - /// Start the background daemon process. + /// Start the background daemon or installed user service. Start, - /// Stop the running daemon process. + /// Stop the running daemon or installed user service. Stop, - /// Restart the running daemon process. + /// Restart the running daemon or installed user service. Restart, - /// Show whether the daemon is running. + /// Show daemon and user-service status. Status, /// Generate and enable a systemd user service. Install, @@ -174,13 +188,13 @@ pub enum AppCommand { /// Remote control commands. #[derive(Debug, Clone, Subcommand)] pub enum RemoteCommand { - /// Send a single normalized key. - Key { + /// Send one or more normalized keys. + Send { /// One or more key names such as `home`, `down`, or `literal:abc`. keys: Vec, - /// 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)] - delay_ms: u64, + delay: u64, }, } @@ -261,10 +275,23 @@ struct ConfigMutationResult { } #[derive(Debug, Clone, Serialize)] -struct ServiceResult { +struct ServiceInstallResult { unit_file: String, + installed: bool, enabled: 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. @@ -300,12 +327,7 @@ async fn handle_daemon_command(cli: &Cli, command: DaemonCommand) -> Result<(), 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::Restart => daemon_restart(cli).await, DaemonCommand::Status => daemon_status(cli).await, DaemonCommand::Install => daemon_install(cli).await, DaemonCommand::Uninstall => daemon_uninstall(cli).await, @@ -406,8 +428,7 @@ async fn handle_app_command(cli: &Cli, command: AppCommand) -> Result<(), CliErr }, ) .await?; - let result: ActionResult = parse_response_data(response)?; - render(cli, &result, || result.detail.clone()) + render_action_response(cli, response) } AppCommand::Stop => { let response = send_request( @@ -417,8 +438,7 @@ async fn handle_app_command(cli: &Cli, command: AppCommand) -> Result<(), CliErr }, ) .await?; - let result: ActionResult = parse_response_data(response)?; - render(cli, &result, || result.detail.clone()) + render_action_response(cli, response) } AppCommand::Refresh { clear } => { 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> { match command { - RemoteCommand::Key { keys, delay_ms } => { + RemoteCommand::Send { keys, delay } => { if keys.is_empty() { 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`.", )); } @@ -453,8 +473,7 @@ async fn handle_remote_command(cli: &Cli, command: RemoteCommand) -> Result<(), }, ) .await?; - let result: ActionResult = parse_response_data(response)?; - render(cli, &result, || result.detail.clone()) + render_action_response(cli, response) } else { let parsed = keys .iter() @@ -465,12 +484,11 @@ async fn handle_remote_command(cli: &Cli, command: RemoteCommand) -> Result<(), &DaemonRequest::SendSequence { device: cli.device.clone(), keys: parsed, - delay_ms, + delay_ms: delay, }, ) .await?; - let result: ActionResult = parse_response_data(response)?; - render(cli, &result, || result.detail.clone()) + render_action_response(cli, response) } } } @@ -499,8 +517,7 @@ async fn handle_dev_command(cli: &Cli, command: DevCommand) -> Result<(), CliErr }, ) .await?; - let result: ActionResult = parse_response_data(response)?; - render(cli, &result, || result.detail.clone()) + render_action_response(cli, response) } DevCommand::Reload => { let response = send_request( @@ -510,8 +527,7 @@ async fn handle_dev_command(cli: &Cli, command: DevCommand) -> Result<(), CliErr }, ) .await?; - let result: ActionResult = parse_response_data(response)?; - render(cli, &result, || result.detail.clone()) + render_action_response(cli, response) } DevCommand::Logs => { let response = send_request( @@ -555,23 +571,15 @@ async fn handle_config_command(cli: &Cli, command: ConfigCommand) -> Result<(), } ConfigCommand::Set { key, value } => { let path = default_config_path(); - let daemon_socket = daemon_status_payload() - .await - .map(|status| PathBuf::from(status.socket)); - let mut config = load_config().await?; - config.set_value(&key, &value).map_err(|error| { - CliError::new( - format!("Failed to set config value: {error}"), - "Run `tvctl config list` to confirm the key and expected value type.", - ) - })?; - 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.", - ) - })?; - let reload = maybe_reload_daemon_config(daemon_socket).await?; + let reload = save_config_with_reload(path.clone(), |config| { + config.set_value(&key, &value).map_err(|error| { + CliError::new( + format!("Failed to set config value: {error}"), + "Run `tvctl config list` to confirm the key and expected value type.", + ) + }) + }) + .await?; let result = ConfigMutationResult { value: Some(redact_config_value(&key, value)), key: Some(key), @@ -587,17 +595,11 @@ async fn handle_config_command(cli: &Cli, command: ConfigCommand) -> Result<(), } ConfigCommand::Reset => { let path = default_config_path(); - let daemon_socket = daemon_status_payload() - .await - .map(|status| PathBuf::from(status.socket)); - let config = TvctlConfig::default(); - config.save_to_path(&path).await.map_err(|error| { - 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 reload = save_config_with_reload(path.clone(), |config| { + *config = TvctlConfig::default(); + Ok(()) + }) + .await?; let result = ConfigMutationResult { key: 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> { + 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 { return render(cli, &status, || { 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 { - 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`.", - )) + let status = wait_for_daemon_ready().await?; + render(cli, &status, || { + format!("tvctld started on {}", status.socket) + }) } 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()) + let service = systemd_service_status().await?; + if service.installed { + let mut stopped_service = false; + 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> { + let service = systemd_service_status().await?; if let Some(status) = daemon_status_payload().await { + let mode = daemon_mode_for_status(&service, &status); return render(cli, &status, || { - 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"); - format!( - "tvctld is running.\nPID: {}\nSocket: {}\nHTTP: {}\nKnown Devices: {}\nDefault Device: {}", - status.pid, status.socket, http, status.device_count, default_device - ) + render_daemon_status(&service, &status, mode) }); } if cli.json { let status = serde_json::json!({ "running": false, + "service_installed": service.installed, + "service_active": service.active, }); return render(cli, &status, || "tvctld is not running.".to_string()); } - println!("tvctld is not running."); + if service.installed { + println!("tvctld user service is installed but not running."); + } else { + println!("tvctld is not running."); + } Ok(()) } 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| { CliError::new( format!("Unable to locate the tvctl binary: {error}"), "Run the command from an installed or built tvctl executable.", ) })?; - let unit_path = systemd_unit_path(); if let Some(parent) = unit_path.parent() { fs::create_dir_all(parent).await.map_err(|error| { CliError::new( @@ -721,29 +812,36 @@ async fn daemon_install(cli: &Cli) -> Result<(), CliError> { })?; 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(), + installed: true, enabled: true, running: true, + already_installed: false, }; - render(cli, &result, || { - format!( - "Installed and started tvctld user service at {}.", - result.unit_file - ) - }) + render(cli, &result, || render_service_install(&result)) } 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 _ = 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 { - Ok(()) => {} + Ok(()) => removed_unit = true, Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => { 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(), + installed: false, enabled: false, running: false, + removed_unit, + stopped_service, + stopped_ad_hoc, }; - render(cli, &result, || { - format!("Removed tvctld user service from {}.", result.unit_file) - }) + render(cli, &result, || render_service_uninstall(&result)) } async fn daemon_status_payload() -> Option { @@ -770,6 +870,95 @@ async fn daemon_status_payload() -> Option { parse_response_data(response).ok() } +async fn wait_for_daemon_ready() -> Result { + 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 { + 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 { + let output = run_systemctl_output(args).await?; + Ok(output.status.success()) +} + +async fn read_systemd_main_pid() -> Result, 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::().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::load().await.map_err(|error| { CliError::new( @@ -803,6 +992,24 @@ async fn maybe_reload_daemon_config( parse_response_data(response).map(Some) } +async fn save_config_with_reload( + path: PathBuf, + mutate: impl FnOnce(&mut TvctlConfig) -> Result<(), CliError>, +) -> Result, 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 { let config = load_config().await?; let runtime_paths = RuntimePaths::detect(); @@ -822,16 +1029,7 @@ async fn load_socket_path() -> Result { } async fn run_systemctl(args: &[&str]) -> Result<(), CliError> { - let output = 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.", - ) - })?; + let output = run_systemctl_output(args).await?; if output.status.success() { return Ok(()); @@ -849,6 +1047,19 @@ async fn run_systemctl(args: &[&str]) -> Result<(), CliError> { )) } +async fn run_systemctl_output(args: &[&str]) -> Result { + 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( socket_path: PathBuf, request: &DaemonRequest, @@ -948,6 +1159,11 @@ where 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 { if devices.is_empty() { return "No devices are registered yet.".to_string(); @@ -1052,6 +1268,77 @@ fn render_dev_logs(result: &DevLogsResult) -> String { 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 { if is_secret_config_key(key) && !value.is_empty() { return "".to_string(); diff --git a/src/daemon/cache.rs b/src/daemon/cache.rs index fc1e8cf..2bd7f2c 100644 --- a/src/daemon/cache.rs +++ b/src/daemon/cache.rs @@ -4,6 +4,14 @@ use tokio::fs; use crate::adapters::AppInfo; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppResolution { + Exact(AppInfo), + Fuzzy(AppInfo), + Ambiguous(Vec), + None, +} + /// A platform-level cache of app metadata discovered from live devices. #[derive(Debug, Clone, Default)] pub struct AppCache { @@ -70,22 +78,46 @@ impl AppCacheStore { } /// 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> { + pub async fn find_app(&self, platform: &str, query: &str) -> anyhow::Result { let cache = self.load_platform(platform).await?; let normalized = normalize_query(query); if let Some(app) = cache.apps.iter().find(|app| { 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 .into_iter() .filter_map(|app| fuzzy_match_score(&normalized, &app).map(|score| (score, app))) - .max_by_key(|(score, app)| (*score, Reverse(app.name.len()))) - .map(|(_, app)| app)) + .collect::>(); + 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::>(); + + 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. @@ -120,6 +152,9 @@ fn fuzzy_match_score(query: &str, app: &AppInfo) -> Option { let platform_id = normalize_query(&app.platform_id); 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); } if name.contains(query) || id.contains(query) || platform_id.contains(query) { @@ -182,9 +217,11 @@ mod tests { let resolved = store .find_app("roku", "youtube") .await - .expect("app lookup should work") - .expect("youtube should exist"); - assert_eq!(resolved.platform_id, "837"); + .expect("app lookup should work"); + match resolved { + AppResolution::Exact(app) => assert_eq!(app.platform_id, "837"), + other => panic!("expected exact youtube match, got {other:?}"), + } } #[tokio::test] @@ -276,8 +313,82 @@ mod tests { let resolved = store .find_app("roku", "jelly") .await - .expect("lookup should work") - .expect("jellyfin should resolve"); - assert_eq!(resolved.name, "Jellyfin"); + .expect("lookup should work"); + match resolved { + 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:?}"), + } } } diff --git a/src/daemon/config.rs b/src/daemon/config.rs index 447e672..18a5884 100644 --- a/src/daemon/config.rs +++ b/src/daemon/config.rs @@ -18,6 +18,8 @@ pub struct TvctlConfig { pub discovery: DiscoveryConfig, /// Default-device settings. pub devices: DeviceConfig, + /// Remote input behavior. + pub remote: RemoteConfig, /// Developer tooling toggles. pub dev: DevConfig, } @@ -28,6 +30,7 @@ impl Default for TvctlConfig { daemon: DaemonConfig::default(), discovery: DiscoveryConfig::default(), devices: DeviceConfig::default(), + remote: RemoteConfig::default(), dev: DevConfig::default(), } } @@ -79,6 +82,11 @@ impl TvctlConfig { self.discovery.timeout_secs.to_string(), ), ("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.roku_username", self.dev.roku_username.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.timeout_secs" => self.discovery.timeout_secs = parse_value(key, value)?, "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.roku_username" => self.dev.roku_username = value.to_string(), "dev.roku_password" => self.dev.roku_password = value.to_string(), @@ -169,6 +181,25 @@ pub struct DeviceConfig { 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. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index de1f4c7..701ed78 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -11,7 +11,7 @@ use std::{ time::Duration, }; -use cache::AppCacheStore; +use cache::{AppCacheStore, AppResolution}; use config::{RuntimePaths, TvctlConfig}; use discovery::DiscoveryService; use ipc::{ @@ -32,7 +32,7 @@ use tracing::warn; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; -use crate::adapters::Device; +use crate::adapters::{Device, TvKey}; /// The long-lived tvctld process. #[derive(Debug)] @@ -487,9 +487,34 @@ async fn handle_request( Ok(device) => device, Err(response) => return (response, false), }; - let target_app = match guard.app_cache.find_app(&device.platform, &app).await { - Ok(Some(cached)) => cached.platform_id, - Ok(None) => app.clone(), + let (target_app, launch_label) = match guard + .app_cache + .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::>() + .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) => { return ( DaemonResponse::error( @@ -507,10 +532,7 @@ async fn handle_request( match guard.adapters.launch(&device, &target_app).await { Ok(()) => ( - DaemonResponse::success(ActionResult { - device, - detail: format!("Launched app '{app}'."), - }), + action_success(device, format!("Launched {launch_label}.")), false, ), Err(error) => ( @@ -534,10 +556,10 @@ async fn handle_request( }; match guard.adapters.stop_app(&device).await { Ok(()) => ( - DaemonResponse::success(ActionResult { - device, - detail: "Stopped the active app.".to_string(), - }), + action_success( + device.clone(), + format!("Stopped the active app on {}.", device.name), + ), false, ), Err(error) => ( @@ -586,12 +608,9 @@ async fn handle_request( Ok(device) => device, 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 { - Ok(()) => ( - DaemonResponse::success(ActionResult { device, detail }), - false, - ), + Ok(()) => (action_success(device, detail), false), Err(error) => ( DaemonResponse::error( "remote_key_failed", @@ -612,12 +631,14 @@ async fn handle_request( Ok(device) => device, 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 { - Ok(()) => ( - DaemonResponse::success(ActionResult { device, detail }), - false, - ), + Ok(()) => (action_success(device, detail), false), Err(error) => ( DaemonResponse::error( "remote_sequence_failed", @@ -631,14 +652,7 @@ async fn handle_request( DaemonRequest::DevInstall { device, zip_path } => { let guard = daemon.lock().await; if !guard.config.dev.enabled { - return ( - 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, - ); + return (dev_disabled_response(), false); } let device = match resolve_target_device(&guard.registry, device.as_deref()) { Ok(device) => device, @@ -659,10 +673,13 @@ async fn handle_request( }; match guard.adapters.dev_install(&device, &zip).await { Ok(()) => ( - DaemonResponse::success(ActionResult { - device, - detail: format!("Installed development package from {zip_path}."), - }), + action_success( + device.clone(), + format!( + "Installed development package from {zip_path} on {}.", + device.name + ), + ), false, ), Err(error) => ( @@ -681,14 +698,7 @@ async fn handle_request( DaemonRequest::DevReload { device } => { let guard = daemon.lock().await; if !guard.config.dev.enabled { - return ( - 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, - ); + return (dev_disabled_response(), false); } let device = match resolve_target_device(&guard.registry, device.as_deref()) { Ok(device) => device, @@ -696,10 +706,10 @@ async fn handle_request( }; match guard.adapters.dev_reload(&device).await { Ok(()) => ( - DaemonResponse::success(ActionResult { - device, - detail: "Reloaded the development package.".to_string(), - }), + action_success( + device.clone(), + format!("Reloaded the development package on {}.", device.name), + ), false, ), Err(error) => ( @@ -715,14 +725,7 @@ async fn handle_request( DaemonRequest::DevLogs { device } => { let guard = daemon.lock().await; if !guard.config.dev.enabled { - return ( - 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, - ); + return (dev_disabled_response(), false); } let device = match resolve_target_device(&guard.registry, device.as_deref()) { Ok(device) => device, @@ -785,6 +788,44 @@ 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> { let discovery = daemon.discovery.clone(); let devices = discovery.discover_all(&mut daemon.registry).await?; @@ -819,6 +860,23 @@ async fn sync_registry_config(daemon: &mut Daemon) -> Result<(), String> { Ok(()) } +fn action_success(device: Device, detail: impl Into) -> 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( registry: &DeviceRegistry, target: Option<&str>, diff --git a/src/daemon/registry.rs b/src/daemon/registry.rs index 6a5b6ae..755bf95 100644 --- a/src/daemon/registry.rs +++ b/src/daemon/registry.rs @@ -7,7 +7,10 @@ use tokio::fs; use uuid::Uuid; use crate::{ - adapters::{AppInfo, Device, DeviceInfo, DeviceState, TvAdapter, TvKey, roku::RokuAdapter}, + adapters::{ + AppInfo, Device, DeviceInfo, DeviceState, TvAdapter, TvKey, + roku::{RokuAdapter, RokuKeyMode}, + }, daemon::config::TvctlConfig, }; @@ -196,8 +199,12 @@ impl AdapterRegistry { (!config.dev.roku_username.is_empty()).then(|| config.dev.roku_username.clone()); let password = (!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 { - 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. pub async fn discover(&self, platform: &str) -> anyhow::Result> { - match platform { - "roku" => Ok(self.roku.discover().await?), - other => anyhow::bail!("unsupported platform '{other}'"), - } + self.with_platform(platform, |roku| Box::pin(roku.discover())) + .await } /// Return true when a platform is supported. @@ -226,83 +231,97 @@ impl AdapterRegistry { address: IpAddr, port: Option, ) -> anyhow::Result { - match platform { - "roku" => Ok(self - .roku - .probe_device(address, port.unwrap_or(8060)) - .await?), - other => anyhow::bail!("unsupported platform '{other}'"), - } + self.with_platform(platform, |roku| { + Box::pin(roku.probe_device(address, port.unwrap_or(8060))) + }) + .await } /// Return apps from a concrete device using its platform adapter. pub async fn list_apps(&self, device: &Device) -> anyhow::Result> { - match device.platform.as_str() { - "roku" => Ok(self.roku.list_apps(device).await?), - other => anyhow::bail!("unsupported platform '{other}'"), - } + self.with_device(device, |roku, device| Box::pin(roku.list_apps(device))) + .await } /// Fetch the current state for a concrete device. pub async fn state(&self, device: &Device) -> anyhow::Result { - match device.platform.as_str() { - "roku" => Ok(self.roku.state(device).await?), - other => anyhow::bail!("unsupported platform '{other}'"), - } + self.with_device(device, |roku, device| Box::pin(roku.state(device))) + .await } /// Launch an app on a concrete device. pub async fn launch(&self, device: &Device, app: &str) -> anyhow::Result<()> { - match device.platform.as_str() { - "roku" => Ok(self.roku.launch(device, app).await?), - other => anyhow::bail!("unsupported platform '{other}'"), - } + let app = app.to_string(); + self.with_device(device, move |roku, device| { + Box::pin(async move { roku.launch(device, &app).await }) + }) + .await } /// Stop the currently running app on a concrete device. pub async fn stop_app(&self, device: &Device) -> anyhow::Result<()> { - match device.platform.as_str() { - "roku" => Ok(self.roku.stop_app(device).await?), - other => anyhow::bail!("unsupported platform '{other}'"), - } + self.with_device(device, |roku, device| Box::pin(roku.stop_app(device))) + .await } /// Send a single normalized key to a concrete device. pub async fn key(&self, device: &Device, key: TvKey) -> anyhow::Result<()> { - match device.platform.as_str() { - "roku" => Ok(self.roku.key(device, key).await?), - other => anyhow::bail!("unsupported platform '{other}'"), - } + self.with_device(device, |roku, device| Box::pin(roku.key(device, key))) + .await } /// Send a normalized key sequence to a concrete device. pub async fn sequence(&self, device: &Device, keys: Vec) -> anyhow::Result<()> { - match device.platform.as_str() { - "roku" => Ok(self.roku.sequence(device, keys).await?), - other => anyhow::bail!("unsupported platform '{other}'"), - } + self.with_device(device, |roku, device| Box::pin(roku.sequence(device, keys))) + .await } /// Install a development package on a concrete device. pub async fn dev_install(&self, device: &Device, zip: &[u8]) -> anyhow::Result<()> { - match device.platform.as_str() { - "roku" => Ok(self.roku.dev_install(device, zip).await?), - other => anyhow::bail!("unsupported platform '{other}'"), - } + let zip = zip.to_vec(); + self.with_device(device, move |roku, device| { + Box::pin(async move { roku.dev_install(device, &zip).await }) + }) + .await } /// Reload the active development package on a concrete device. pub async fn dev_reload(&self, device: &Device) -> anyhow::Result<()> { - match device.platform.as_str() { - "roku" => Ok(self.roku.dev_reload(device).await?), - other => anyhow::bail!("unsupported platform '{other}'"), - } + self.with_device(device, |roku, device| Box::pin(roku.dev_reload(device))) + .await } /// Fetch development logs from a concrete device. pub async fn dev_logs(&self, device: &Device) -> anyhow::Result> { + self.with_device(device, |roku, device| Box::pin(roku.dev_logs(device))) + .await + } + + async fn with_platform(&self, platform: &str, call: F) -> anyhow::Result + where + F: for<'a> FnOnce( + &'a RokuAdapter, + ) -> std::pin::Pin< + Box> + 'a>, + >, + { + match platform { + "roku" => Ok(call(&self.roku).await?), + other => anyhow::bail!("unsupported platform '{other}'"), + } + } + + async fn with_device(&self, device: &Device, call: F) -> anyhow::Result + where + F: for<'a> FnOnce( + &'a RokuAdapter, + &'a Device, + ) -> std::pin::Pin< + Box> + 'a>, + >, + { 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}'"), } }