diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index f8c0d4a..ccdc188 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -19,7 +19,7 @@ script and control smart TVs through a stable, brand-agnostic API. ## Project Status -**Phase:** Milestone 4 in progress. Daemon core is complete; CLI coverage is expanding beyond daemon/device/app-cache operations. +**Phase:** Milestone 5 ready. Daemon core and CLI are complete for v1 Roku control, including config management and systemd user-service install/uninstall. Next work is HTTP API parity. **Platform v1:** Roku only (via ECP HTTP API) **Language:** Rust **Crate type:** Binary (single binary distribution target) @@ -307,6 +307,8 @@ default = "" [dev] enabled = true +roku_username = "" +roku_password = "" ``` --- @@ -338,8 +340,6 @@ enabled = true ## What Has NOT Been Started - HTTP route handlers and request validation -- CLI command handling for remote/state/dev/config flows -- Daemon install/uninstall helpers and systemd unit generation - CI/CD configuration - Release/packaging diff --git a/README.md b/README.md index a509ea8..f37fc87 100644 --- a/README.md +++ b/README.md @@ -371,6 +371,8 @@ default = "living-room" [dev] enabled = true +roku_username = "" +roku_password = "" ``` ### Config Keys @@ -387,6 +389,8 @@ enabled = true | `discovery.timeout_secs` | `5` | Per-device timeout | | `devices.default` | `""` | Default device name or UUID | | `dev.enabled` | `true` | Enable dev tooling commands | +| `dev.roku_username` | `""` | Optional Roku developer username override | +| `dev.roku_password` | `""` | Optional Roku developer password override | --- diff --git a/ROADMAP.md b/ROADMAP.md index 9468b18..b500fa3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,15 +7,14 @@ ## Current Focus -**Milestone 4 — CLI** -Daemon core is complete. Continue broadening CLI coverage against the running daemon. +**Milestone 5 — HTTP API** +CLI coverage is complete. Begin exposing the same surface over loopback HTTP. --- ## In Progress -- Milestone 4 is underway with working `daemon`, `device`, and app-cache commands over the Unix socket -- Remaining CLI work is remote/state/dev/config coverage plus daemon install/uninstall helpers +- Milestone 5 has not started yet; CLI validation and polish are complete enough to move on --- @@ -77,26 +76,26 @@ _Goal: All tvctl commands work against a running daemon._ - [x] 2026-04-14 — CLI entry point and dispatch (`src/cli/mod.rs`) - [x] 2026-04-14 — Unix socket client (send commands, receive responses) -- [ ] `tvctl daemon` commands - - `start` `stop` `restart` `status` - - `install` (generate systemd user unit) - - `uninstall` -- [ ] `tvctl device` commands - - `list` `discover` `add` `select` `info` `remove` +- [x] 2026-04-14 `tvctl daemon` commands + - [x] `start` `stop` `restart` `status` + - [x] `install` (generate systemd user unit) + - [x] `uninstall` +- [x] 2026-04-14 `tvctl device` commands + - [x] `list` `discover` `add` `select` `info` `remove` - [ ] `tvctl app` commands - - `list` `launch` `stop` `refresh` + - [x] 2026-04-14 `list` `launch` `stop` `refresh` - [ ] `tvctl remote` commands - - `key` `sequence` -- [ ] `tvctl state` -- [ ] `tvctl dev` commands - - `install` `reload` `logs` -- [ ] `tvctl config` commands - - `list` `get` `set` `reset` `reload` -- [ ] Global flags: `--device` `--json` `--help` `--version` -- [ ] Full help text on every command (see AGENT.md definition of done) -- [ ] Full help output on bare `tvctl` -- [ ] Friendly error messages with hints on every failure path -- [ ] `--json` output verified on every command + - [x] 2026-04-14 `key` `sequence` +- [x] 2026-04-14 `tvctl state` +- [x] 2026-04-14 `tvctl dev` commands + - [x] `install` `reload` `logs` +- [x] 2026-04-14 `tvctl config` commands + - [x] `list` `get` `set` `reset` `reload` +- [x] 2026-04-14 Global flags: `--device` `--json` `--help` `--version` +- [x] 2026-04-14 Full help text on every command (see AGENT.md definition of done) +- [x] 2026-04-14 Full help output on bare `tvctl` +- [x] 2026-04-14 Friendly error messages with hints on every failure path +- [x] 2026-04-14 `--json` output verified on every command --- @@ -162,6 +161,9 @@ out of scope until Milestone 6 is complete and stable. - [x] 2026-04-14 — Add daemon config loading, runtime paths, persisted registry/cache stores, and discovery foundations - [x] 2026-04-14 — Add daemon Unix socket IPC plus working `daemon` and `device` lifecycle/discovery commands - [x] 2026-04-14 — Finish Milestone 3 with registry CRUD, periodic discovery, manual add, and app-cache refresh plus live daemon validation +- [x] 2026-04-14 — Add daemon-backed app control, remote input, state queries, and dev workflows with live Roku CLI validation +- [x] 2026-04-14 — Add config list/get/set/reset/reload plus systemd user-service install/uninstall commands +- [x] 2026-04-14 — Finish Milestone 4 with help, JSON-mode, config socket-reload, and secret-redaction polish --- diff --git a/src/adapters/roku/mod.rs b/src/adapters/roku/mod.rs index 1eb5856..ad338b7 100644 --- a/src/adapters/roku/mod.rs +++ b/src/adapters/roku/mod.rs @@ -41,6 +41,8 @@ pub struct RokuAdapter { request_timeout: Duration, discovery_timeout: Duration, dev_log_window: Duration, + dev_username: Option, + dev_password: Option, } impl RokuAdapter { @@ -51,6 +53,8 @@ impl RokuAdapter { request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), discovery_timeout: Duration::from_secs(DEFAULT_DISCOVERY_TIMEOUT_SECS), dev_log_window: Duration::from_secs(DEFAULT_DEV_LOG_WINDOW_SECS), + dev_username: None, + dev_password: None, } } @@ -61,6 +65,20 @@ impl RokuAdapter { request_timeout, discovery_timeout, dev_log_window: Duration::from_secs(DEFAULT_DEV_LOG_WINDOW_SECS), + dev_username: None, + dev_password: None, + } + } + + /// Create a Roku adapter with explicit developer-mode credentials. + pub fn with_dev_credentials( + dev_username: Option, + dev_password: Option, + ) -> Self { + Self { + dev_username, + dev_password, + ..Self::new() } } @@ -198,11 +216,22 @@ impl RokuAdapter { } async fn developer_credentials(&self) -> Result<(String, String)> { - let username = - env::var("TVCTL_ROKU_DEV_USERNAME").unwrap_or_else(|_| "rokudev".to_string()); - let password = env::var("TVCTL_ROKU_DEV_PASSWORD").map_err(|_| { - TvError::Config("missing TVCTL_ROKU_DEV_PASSWORD for Roku developer mode".to_string()) - })?; + let username = self + .dev_username + .clone() + .filter(|value| !value.is_empty()) + .or_else(|| env::var("TVCTL_ROKU_DEV_USERNAME").ok()) + .unwrap_or_else(|| "rokudev".to_string()); + let password = self + .dev_password + .clone() + .filter(|value| !value.is_empty()) + .or_else(|| env::var("TVCTL_ROKU_DEV_PASSWORD").ok()) + .ok_or_else(|| { + TvError::Config( + "missing TVCTL_ROKU_DEV_PASSWORD for Roku developer mode".to_string(), + ) + })?; Ok((username, password)) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 1b74553..8426a76 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,9 +1,10 @@ -use std::{net::IpAddr, path::PathBuf, process::Stdio, time::Duration}; +use std::{collections::BTreeMap, net::IpAddr, path::PathBuf, process::Stdio, time::Duration}; use clap::{Args, CommandFactory, Parser, Subcommand}; use serde::Serialize; use thiserror::Error; use tokio::{ + fs, io::{AsyncReadExt, AsyncWriteExt}, net::UnixStream, process::Command as TokioCommand, @@ -11,13 +12,13 @@ use tokio::{ }; use crate::{ - adapters::{AppInfo, Device}, + adapters::{AppInfo, Device, DeviceState, TvKey}, daemon::{ self, - config::{RuntimePaths, TvctlConfig}, + config::{RuntimePaths, TvctlConfig, default_config_path, systemd_unit_path}, ipc::{ - AppListResult, AppRefreshResult, DaemonRequest, DaemonResponse, DaemonStatus, - DiscoveryResult, + ActionResult, AppListResult, AppRefreshResult, ConfigReloadResult, DaemonRequest, + DaemonResponse, DaemonStatus, DevLogsResult, DiscoveryResult, StateResult, }, }, }; @@ -65,13 +66,22 @@ pub enum Command { command: AppCommand, }, /// Send remote control input. - Remote, + Remote { + #[command(subcommand)] + command: RemoteCommand, + }, /// Query device state. State, /// Use developer-oriented TV features. - Dev, + Dev { + #[command(subcommand)] + command: DevCommand, + }, /// Inspect and modify tvctl configuration. - Config, + Config { + #[command(subcommand)] + command: ConfigCommand, + }, /// Internal daemon entry point used by `tvctl daemon start`. #[command(hide = true, name = "__daemon_serve")] InternalDaemonServe, @@ -88,6 +98,10 @@ pub enum DaemonCommand { Restart, /// Show whether the daemon is running. Status, + /// Generate and enable a systemd user service. + Install, + /// Disable and remove the systemd user service. + Uninstall, } /// Discover and inspect known devices. @@ -141,6 +155,13 @@ pub struct DeviceAddArgs { pub enum AppCommand { /// List cached apps for the selected or default device platform. List, + /// Launch an app by name, normalized id, or platform id. + Launch { + /// App name, normalized id, or platform id. + app: String, + }, + /// Stop the active app on the selected or default device. + Stop, /// Refresh the cached app list from the selected or default device. Refresh { /// Clear the platform cache before reloading from the device. @@ -149,6 +170,58 @@ pub enum AppCommand { }, } +/// Remote control commands. +#[derive(Debug, Clone, Subcommand)] +pub enum RemoteCommand { + /// Send a single normalized key. + Key { + /// Key name such as `home`, `down`, or `literal:abc`. + key: String, + }, + /// Send multiple normalized keys in order. + Sequence { + /// Key names such as `home down select`. + keys: Vec, + }, +} + +/// Developer-oriented commands. +#[derive(Debug, Clone, Subcommand)] +pub enum DevCommand { + /// Install a dev zip on the selected or default device. + Install { + /// Local filesystem path to the zip package. + zip: PathBuf, + }, + /// Reload the active dev package. + Reload, + /// Fetch recent developer logs. + Logs, +} + +/// Config management commands. +#[derive(Debug, Clone, Subcommand)] +pub enum ConfigCommand { + /// Show all config values. + List, + /// Read one config key. + Get { + /// Stable dotted key such as `daemon.socket`. + key: String, + }, + /// Set one config key. + Set { + /// Stable dotted key such as `discovery.interval_secs`. + key: String, + /// New value for the key. + value: String, + }, + /// Reset config to defaults. + Reset, + /// Reload config into the running daemon. + Reload, +} + /// A user-facing CLI error with a suggested next action. #[derive(Debug, Error)] #[error("{message}\nHint: {hint}")] @@ -168,6 +241,33 @@ impl CliError { } } +#[derive(Debug, Clone, Serialize)] +struct ConfigEntryResult { + key: String, + value: String, +} + +#[derive(Debug, Clone, Serialize)] +struct ConfigListResult { + values: BTreeMap, +} + +#[derive(Debug, Clone, Serialize)] +struct ConfigMutationResult { + key: Option, + value: Option, + config_path: String, + daemon_reloaded: bool, + restart_required: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct ServiceResult { + unit_file: String, + enabled: bool, + running: bool, +} + /// Parse the CLI and execute the selected command. pub async fn run() -> Result<(), CliError> { let cli = Cli::parse(); @@ -190,22 +290,10 @@ pub async fn run() -> Result<(), CliError> { Command::Daemon { command } => handle_daemon_command(&cli, command).await, Command::Device { command } => handle_device_command(&cli, command).await, Command::App { command } => handle_app_command(&cli, command).await, - Command::Remote => Err(CliError::new( - "Remote commands are not wired to the daemon yet.", - "Continue Milestone 4 after the daemon protocol is in place.", - )), - Command::State => Err(CliError::new( - "State queries are not wired to the daemon yet.", - "Continue Milestone 4 after the daemon protocol is in place.", - )), - Command::Dev => Err(CliError::new( - "Developer commands are not wired to the daemon yet.", - "Continue Milestone 4 after the daemon protocol is in place.", - )), - Command::Config => Err(CliError::new( - "Config commands are not wired to the daemon yet.", - "Continue Milestone 4 after the daemon protocol is in place.", - )), + Command::Remote { command } => handle_remote_command(&cli, command).await, + Command::State => handle_state_command(&cli).await, + Command::Dev { command } => handle_dev_command(&cli, command).await, + Command::Config { command } => handle_config_command(&cli, command).await, } } @@ -220,6 +308,8 @@ async fn handle_daemon_command(cli: &Cli, command: DaemonCommand) -> Result<(), daemon_start(cli).await } DaemonCommand::Status => daemon_status(cli).await, + DaemonCommand::Install => daemon_install(cli).await, + DaemonCommand::Uninstall => daemon_uninstall(cli).await, } } @@ -308,6 +398,29 @@ async fn handle_app_command(cli: &Cli, command: AppCommand) -> Result<(), CliErr render_app_list(&result.apps, &result.platform) }) } + AppCommand::Launch { app } => { + let response = send_request( + load_socket_path().await?, + &DaemonRequest::LaunchApp { + device: cli.device.clone(), + app, + }, + ) + .await?; + let result: ActionResult = parse_response_data(response)?; + render(cli, &result, || result.detail.clone()) + } + AppCommand::Stop => { + let response = send_request( + load_socket_path().await?, + &DaemonRequest::StopApp { + device: cli.device.clone(), + }, + ) + .await?; + let result: ActionResult = parse_response_data(response)?; + render(cli, &result, || result.detail.clone()) + } AppCommand::Refresh { clear } => { let response = send_request( load_socket_path().await?, @@ -323,6 +436,187 @@ 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 { key } => { + let response = send_request( + load_socket_path().await?, + &DaemonRequest::SendKey { + device: cli.device.clone(), + key: parse_tv_key(&key)?, + }, + ) + .await?; + let result: ActionResult = parse_response_data(response)?; + render(cli, &result, || result.detail.clone()) + } + RemoteCommand::Sequence { keys } => { + if keys.is_empty() { + return Err(CliError::new( + "At least one key is required for `tvctl remote sequence`.", + "Pass one or more keys such as `home down select`.", + )); + } + let parsed = keys + .iter() + .map(|key| parse_tv_key(key)) + .collect::, _>>()?; + let response = send_request( + load_socket_path().await?, + &DaemonRequest::SendSequence { + device: cli.device.clone(), + keys: parsed, + }, + ) + .await?; + let result: ActionResult = parse_response_data(response)?; + render(cli, &result, || result.detail.clone()) + } + } +} + +async fn handle_state_command(cli: &Cli) -> Result<(), CliError> { + let response = send_request( + load_socket_path().await?, + &DaemonRequest::GetState { + device: cli.device.clone(), + }, + ) + .await?; + let result: StateResult = parse_response_data(response)?; + render(cli, &result, || render_state(&result.device, &result.state)) +} + +async fn handle_dev_command(cli: &Cli, command: DevCommand) -> Result<(), CliError> { + match command { + DevCommand::Install { zip } => { + let response = send_request( + load_socket_path().await?, + &DaemonRequest::DevInstall { + device: cli.device.clone(), + zip_path: zip.display().to_string(), + }, + ) + .await?; + let result: ActionResult = parse_response_data(response)?; + render(cli, &result, || result.detail.clone()) + } + DevCommand::Reload => { + let response = send_request( + load_socket_path().await?, + &DaemonRequest::DevReload { + device: cli.device.clone(), + }, + ) + .await?; + let result: ActionResult = parse_response_data(response)?; + render(cli, &result, || result.detail.clone()) + } + DevCommand::Logs => { + let response = send_request( + load_socket_path().await?, + &DaemonRequest::DevLogs { + device: cli.device.clone(), + }, + ) + .await?; + let result: DevLogsResult = parse_response_data(response)?; + render(cli, &result, || render_dev_logs(&result)) + } + } +} + +async fn handle_config_command(cli: &Cli, command: ConfigCommand) -> Result<(), CliError> { + match command { + ConfigCommand::List => { + let config = load_config().await?; + let values = config + .entries() + .into_iter() + .map(|(key, value)| (key.to_string(), redact_config_value(key, value))) + .collect::>(); + let result = ConfigListResult { values }; + render(cli, &result, || render_config_list(&result.values)) + } + ConfigCommand::Get { key } => { + let config = load_config().await?; + let value = config.get_value(&key).ok_or_else(|| { + CliError::new( + format!("Unknown config key '{key}'."), + "Run `tvctl config list` to see the supported keys.", + ) + })?; + let result = ConfigEntryResult { + value: redact_config_value(&key, value), + key, + }; + render(cli, &result, || result.value.clone()) + } + 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 result = ConfigMutationResult { + value: Some(redact_config_value(&key, value)), + key: Some(key), + config_path: path.display().to_string(), + daemon_reloaded: reload.is_some(), + restart_required: reload + .map(|result| result.restart_required) + .unwrap_or_default(), + }; + render(cli, &result, || { + render_config_mutation("Saved config value.", &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 result = ConfigMutationResult { + key: None, + value: None, + config_path: path.display().to_string(), + daemon_reloaded: reload.is_some(), + restart_required: reload + .map(|result| result.restart_required) + .unwrap_or_default(), + }; + render(cli, &result, || { + render_config_mutation("Reset config to defaults.", &result) + }) + } + ConfigCommand::Reload => { + let result = sanitize_config_reload_result(reload_daemon_config().await?); + render(cli, &result, || render_config_reload(&result)) + } + } +} + async fn daemon_start(cli: &Cli) -> Result<(), CliError> { if let Some(status) = daemon_status_payload().await { return render(cli, &status, || { @@ -393,20 +687,122 @@ async fn daemon_status(cli: &Cli) -> Result<(), CliError> { Ok(()) } +async fn daemon_install(cli: &Cli) -> Result<(), CliError> { + 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( + format!("Failed to create the systemd user unit directory: {error}"), + "Check write permissions for ~/.config/systemd/user.", + ) + })?; + } + + fs::write(&unit_path, render_systemd_unit(&exe)) + .await + .map_err(|error| { + CliError::new( + format!("Failed to write the user service file: {error}"), + "Check write permissions for ~/.config/systemd/user.", + ) + })?; + + run_systemctl(&["--user", "daemon-reload"]).await?; + run_systemctl(&["--user", "enable", "--now", "tvctld.service"]).await?; + + let result = ServiceResult { + unit_file: unit_path.display().to_string(), + enabled: true, + running: true, + }; + render(cli, &result, || { + format!( + "Installed and started tvctld user service at {}.", + result.unit_file + ) + }) +} + +async fn daemon_uninstall(cli: &Cli) -> Result<(), CliError> { + let unit_path = systemd_unit_path(); + let _ = run_systemctl(&["--user", "disable", "--now", "tvctld.service"]).await; + match fs::remove_file(&unit_path).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(CliError::new( + format!("Failed to remove the user service file: {error}"), + "Check write permissions for ~/.config/systemd/user.", + )); + } + } + run_systemctl(&["--user", "daemon-reload"]).await?; + + let result = ServiceResult { + unit_file: unit_path.display().to_string(), + enabled: false, + running: false, + }; + render(cli, &result, || { + format!("Removed tvctld user service from {}.", result.unit_file) + }) +} + async fn daemon_status_payload() -> Option { let socket = load_socket_path().await.ok()?; let response = send_request(socket, &DaemonRequest::Ping).await.ok()?; parse_response_data(response).ok() } -async fn load_socket_path() -> Result { - let config = TvctlConfig::load().await.map_err(|error| { +async fn load_config() -> Result { + TvctlConfig::load().await.map_err(|error| { CliError::new( format!("Failed to load tvctl configuration: {error}"), "Inspect ~/.config/tvctl/config.toml for invalid TOML.", ) - })?; - let fallback = RuntimePaths::detect().socket_file; + }) +} + +async fn reload_daemon_config() -> Result { + let socket = daemon_status_payload() + .await + .map(|status| PathBuf::from(status.socket)) + .ok_or_else(|| { + CliError::new( + "tvctld is not running, so config cannot be hot-reloaded.", + "Start the daemon first or let the new config apply on the next start.", + ) + })?; + let response = send_request(socket, &DaemonRequest::ReloadConfig).await?; + parse_response_data(response) +} + +async fn maybe_reload_daemon_config( + daemon_socket: Option, +) -> Result, CliError> { + let Some(socket) = daemon_socket else { + return Ok(None); + }; + let response = send_request(socket, &DaemonRequest::ReloadConfig).await?; + parse_response_data(response).map(Some) +} + +async fn load_socket_path() -> Result { + let config = load_config().await?; + let runtime_paths = RuntimePaths::detect(); + if let Ok(active) = fs::read_to_string(&runtime_paths.active_socket_file).await { + let active = PathBuf::from(active.trim()); + if !active.as_os_str().is_empty() && active.exists() { + return Ok(active); + } + } + let fallback = runtime_paths.socket_file; let configured = PathBuf::from(config.daemon.socket); Ok(if configured.as_os_str().is_empty() { fallback @@ -415,6 +811,34 @@ 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.", + ) + })?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let detail = if stderr.is_empty() { + format!("systemctl exited with status {}", output.status) + } else { + stderr + }; + Err(CliError::new( + format!("systemctl failed: {detail}"), + "Check whether the user systemd instance is running and retry.", + )) +} + async fn send_request( socket_path: PathBuf, request: &DaemonRequest, @@ -581,6 +1005,153 @@ fn render_app_refresh(result: &AppRefreshResult) -> String { ) } +fn render_state(device: &Device, state: &DeviceState) -> String { + let active_app = state + .active_app + .as_ref() + .map(|app| app.name.as_str()) + .unwrap_or("none"); + let volume = state + .volume + .as_ref() + .map(|volume| { + format!( + "{}{}", + volume.level, + if volume.muted { " (muted)" } else { "" } + ) + }) + .unwrap_or_else(|| "unknown".to_string()); + [ + format!("Device: {}", device.name), + format!("Power: {:?}", state.power), + format!("Active App: {active_app}"), + format!("Volume: {volume}"), + format!("Timestamp: {}", state.timestamp), + ] + .join("\n") +} + +fn render_dev_logs(result: &DevLogsResult) -> String { + if result.lines.is_empty() { + return format!( + "No recent developer logs were returned from {}.", + result.device.name + ); + } + result.lines.join("\n") +} + +fn redact_config_value(key: &str, value: String) -> String { + if is_secret_config_key(key) && !value.is_empty() { + return "".to_string(); + } + value +} + +fn sanitize_config_reload_result(mut result: ConfigReloadResult) -> ConfigReloadResult { + if !result.config.dev.roku_password.is_empty() { + result.config.dev.roku_password = "".to_string(); + } + result +} + +fn render_config_list(values: &BTreeMap) -> String { + values + .iter() + .map(|(key, value)| format!("{key} = {value}")) + .collect::>() + .join("\n") +} + +fn render_config_mutation(prefix: &str, result: &ConfigMutationResult) -> String { + let mut lines = vec![ + prefix.to_string(), + format!("Config path: {}", result.config_path), + ]; + if let (Some(key), Some(value)) = (&result.key, &result.value) { + lines.push(format!("{key} = {value}")); + } + if result.daemon_reloaded { + lines.push("Running daemon reloaded config.".to_string()); + } else { + lines.push("Daemon not running; changes will apply on next start.".to_string()); + } + if !result.restart_required.is_empty() { + lines.push(format!( + "Restart required for: {}", + result.restart_required.join(", ") + )); + } + lines.join("\n") +} + +fn render_config_reload(result: &ConfigReloadResult) -> String { + if result.restart_required.is_empty() { + return "Reloaded config into the running daemon.".to_string(); + } + format!( + "Reloaded config into the running daemon.\nRestart required for: {}", + result.restart_required.join(", ") + ) +} + fn default_marker(device: &Device) -> &'static str { if device.is_default { " (default)" } else { "" } } + +fn render_systemd_unit(exe: &std::path::Path) -> String { + format!( + "[Unit]\nDescription=tvctl daemon\nAfter=network.target\n\n[Service]\nType=simple\nExecStart={} __daemon_serve\nRestart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=default.target\n", + exe.display() + ) +} + +fn is_secret_config_key(key: &str) -> bool { + matches!(key, "dev.roku_password") +} + +fn parse_tv_key(input: &str) -> Result { + if let Some(literal) = input.strip_prefix("literal:") { + return Ok(TvKey::Literal(literal.to_string())); + } + + match input.to_ascii_lowercase().as_str() { + "home" => Ok(TvKey::Home), + "back" => Ok(TvKey::Back), + "up" => Ok(TvKey::Up), + "down" => Ok(TvKey::Down), + "left" => Ok(TvKey::Left), + "right" => Ok(TvKey::Right), + "select" => Ok(TvKey::Select), + "play" => Ok(TvKey::Play), + "pause" => Ok(TvKey::Pause), + "play-pause" => Ok(TvKey::PlayPause), + "stop" => Ok(TvKey::Stop), + "rewind" => Ok(TvKey::Rewind), + "fast-forward" => Ok(TvKey::FastForward), + "replay" => Ok(TvKey::Replay), + "skip" => Ok(TvKey::Skip), + "channel-up" => Ok(TvKey::ChannelUp), + "channel-down" => Ok(TvKey::ChannelDown), + "volume-up" => Ok(TvKey::VolumeUp), + "volume-down" => Ok(TvKey::VolumeDown), + "mute" => Ok(TvKey::Mute), + "power" => Ok(TvKey::Power), + "power-on" => Ok(TvKey::PowerOn), + "power-off" => Ok(TvKey::PowerOff), + "input-hdmi1" => Ok(TvKey::InputHdmi1), + "input-hdmi2" => Ok(TvKey::InputHdmi2), + "input-hdmi3" => Ok(TvKey::InputHdmi3), + "input-hdmi4" => Ok(TvKey::InputHdmi4), + "input-av" => Ok(TvKey::InputAv), + "input-tuner" => Ok(TvKey::InputTuner), + "search" => Ok(TvKey::Search), + "info" => Ok(TvKey::Info), + "options" => Ok(TvKey::Options), + _ => Err(CliError::new( + format!("Unknown key '{input}'."), + "Use a normalized key like `home`, `down`, `volume-up`, or `literal:text`.", + )), + } +} diff --git a/src/daemon/config.rs b/src/daemon/config.rs index 257786c..447e672 100644 --- a/src/daemon/config.rs +++ b/src/daemon/config.rs @@ -1,8 +1,10 @@ use std::{ + collections::BTreeMap, env, path::{Path, PathBuf}, }; +use anyhow::bail; use serde::{Deserialize, Serialize}; use tokio::fs; @@ -55,6 +57,58 @@ impl TvctlConfig { fs::write(path, contents).await?; Ok(()) } + + /// Return the config as stable flattened key/value pairs. + pub fn entries(&self) -> BTreeMap<&'static str, String> { + BTreeMap::from([ + ("daemon.socket", self.daemon.socket.clone()), + ("daemon.http_enabled", self.daemon.http_enabled.to_string()), + ("daemon.http_port", self.daemon.http_port.to_string()), + ("daemon.http_host", self.daemon.http_host.clone()), + ("daemon.log_level", self.daemon.log_level.clone()), + ( + "discovery.auto_discover", + self.discovery.auto_discover.to_string(), + ), + ( + "discovery.interval_secs", + self.discovery.interval_secs.to_string(), + ), + ( + "discovery.timeout_secs", + self.discovery.timeout_secs.to_string(), + ), + ("devices.default", self.devices.default.clone()), + ("dev.enabled", self.dev.enabled.to_string()), + ("dev.roku_username", self.dev.roku_username.clone()), + ("dev.roku_password", self.dev.roku_password.clone()), + ]) + } + + /// Return one flattened config value by stable key. + pub fn get_value(&self, key: &str) -> Option { + self.entries().remove(key) + } + + /// Set one flattened config value by stable key. + pub fn set_value(&mut self, key: &str, value: &str) -> anyhow::Result<()> { + match key { + "daemon.socket" => self.daemon.socket = value.to_string(), + "daemon.http_enabled" => self.daemon.http_enabled = parse_bool(key, value)?, + "daemon.http_port" => self.daemon.http_port = parse_value(key, value)?, + "daemon.http_host" => self.daemon.http_host = value.to_string(), + "daemon.log_level" => self.daemon.log_level = value.to_string(), + "discovery.auto_discover" => self.discovery.auto_discover = parse_bool(key, value)?, + "discovery.interval_secs" => self.discovery.interval_secs = parse_value(key, value)?, + "discovery.timeout_secs" => self.discovery.timeout_secs = parse_value(key, value)?, + "devices.default" => self.devices.default = value.to_string(), + "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(), + other => bail!("unknown config key '{other}'"), + } + Ok(()) + } } /// Runtime daemon settings. @@ -121,11 +175,19 @@ pub struct DeviceConfig { pub struct DevConfig { /// Whether developer tooling is enabled. pub enabled: bool, + /// Optional Roku developer username override. + pub roku_username: String, + /// Optional Roku developer password override. + pub roku_password: String, } impl Default for DevConfig { fn default() -> Self { - Self { enabled: true } + Self { + enabled: true, + roku_username: String::new(), + roku_password: String::new(), + } } } @@ -140,6 +202,8 @@ pub struct RuntimePaths { pub devices_file: PathBuf, /// The platform app cache directory. pub cache_dir: PathBuf, + /// The file containing the currently active daemon socket path. + pub active_socket_file: PathBuf, /// The runtime socket path. pub socket_file: PathBuf, } @@ -153,6 +217,7 @@ impl RuntimePaths { Self { config_file, devices_file: data_dir.join("devices.json"), + active_socket_file: data_dir.join("active_socket"), socket_file: default_socket_path(), data_dir, cache_dir, @@ -173,6 +238,19 @@ pub fn default_config_dir() -> PathBuf { home_dir().join(".config/tvctl") } +/// Return the user-level systemd unit directory path. +pub fn systemd_user_dir() -> PathBuf { + if let Ok(path) = env::var("XDG_CONFIG_HOME") { + return PathBuf::from(path).join("systemd/user"); + } + home_dir().join(".config/systemd/user") +} + +/// Return the canonical tvctld user service unit path. +pub fn systemd_unit_path() -> PathBuf { + systemd_user_dir().join("tvctld.service") +} + /// Return the default data directory path. pub fn default_data_dir() -> PathBuf { if let Ok(path) = env::var("XDG_DATA_HOME") { @@ -200,6 +278,20 @@ fn current_uid() -> u32 { unsafe { libc::geteuid() } } +fn parse_bool(key: &str, value: &str) -> anyhow::Result { + parse_value(key, value) +} + +fn parse_value(key: &str, value: &str) -> anyhow::Result +where + T: std::str::FromStr, + T::Err: std::fmt::Display, +{ + value + .parse::() + .map_err(|error| anyhow::anyhow!("invalid value '{value}' for {key}: {error}")) +} + #[cfg(test)] mod tests { use super::*; @@ -221,6 +313,11 @@ mod tests { devices: DeviceConfig { default: "living-room".to_string(), }, + dev: DevConfig { + enabled: true, + roku_username: "rokudev".to_string(), + roku_password: "secret".to_string(), + }, ..TvctlConfig::default() }; config diff --git a/src/daemon/ipc.rs b/src/daemon/ipc.rs index 3bc8d45..13435c0 100644 --- a/src/daemon/ipc.rs +++ b/src/daemon/ipc.rs @@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::net::IpAddr; -use crate::adapters::{AppInfo, Device}; +use crate::adapters::{AppInfo, Device, DeviceState, TvKey}; +use crate::daemon::config::TvctlConfig; /// A request sent from the CLI to the daemon over the Unix socket. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -56,6 +57,56 @@ pub enum DaemonRequest { /// Whether to clear the platform cache before reloading from the device. clear: bool, }, + /// Launch an app on one device. + LaunchApp { + /// Optional UUID or friendly name. + device: Option, + /// App name, normalized id, or platform id. + app: String, + }, + /// Stop the currently running app on one device. + StopApp { + /// Optional UUID or friendly name. + device: Option, + }, + /// Fetch the current state for one device. + GetState { + /// Optional UUID or friendly name. + device: Option, + }, + /// Send one normalized key to one device. + SendKey { + /// Optional UUID or friendly name. + device: Option, + /// Normalized key identifier. + key: TvKey, + }, + /// Send a normalized key sequence to one device. + SendSequence { + /// Optional UUID or friendly name. + device: Option, + /// Normalized key identifiers. + keys: Vec, + }, + /// Install a dev package from a local zip path on one device. + DevInstall { + /// Optional UUID or friendly name. + device: Option, + /// Local filesystem path to the zip package. + zip_path: String, + }, + /// Reload the active dev package on one device. + DevReload { + /// Optional UUID or friendly name. + device: Option, + }, + /// Fetch developer logs from one device. + DevLogs { + /// Optional UUID or friendly name. + device: Option, + }, + /// Reload config from disk into the running daemon. + ReloadConfig, } /// A standard daemon response envelope for IPC. @@ -148,3 +199,39 @@ pub struct AppRefreshResult { /// The total number of cached apps after merge/replace. pub cached_count: usize, } + +/// A simple action result for one device-targeted command. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ActionResult { + /// The device the action ran against. + pub device: Device, + /// A human-readable description of what happened. + pub detail: String, +} + +/// State results returned by the daemon. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StateResult { + /// The device the state belongs to. + pub device: Device, + /// The live state snapshot. + pub state: DeviceState, +} + +/// Developer log results returned by the daemon. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DevLogsResult { + /// The device the logs came from. + pub device: Device, + /// Recent developer log lines. + pub lines: Vec, +} + +/// Config reload results returned by the daemon. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ConfigReloadResult { + /// The newly loaded config snapshot. + pub config: TvctlConfig, + /// Keys that still require a daemon restart to fully apply. + pub restart_required: Vec, +} diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 5bf803d..4002cb4 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -15,7 +15,8 @@ use cache::AppCacheStore; use config::{RuntimePaths, TvctlConfig}; use discovery::DiscoveryService; use ipc::{ - AppListResult, AppRefreshResult, DaemonRequest, DaemonResponse, DaemonStatus, DiscoveryResult, + ActionResult, AppListResult, AppRefreshResult, ConfigReloadResult, DaemonRequest, + DaemonResponse, DaemonStatus, DevLogsResult, DiscoveryResult, StateResult, }; use registry::{AdapterRegistry, DeviceRegistry}; use state::StateCache; @@ -60,7 +61,7 @@ impl Daemon { if !config.daemon.socket.is_empty() { paths.socket_file = PathBuf::from(&config.daemon.socket); } - let adapters = AdapterRegistry::default(); + let adapters = AdapterRegistry::from_config(&config); let mut registry = DeviceRegistry::load(paths.devices_file.clone()).await?; if !config.devices.default.is_empty() { let _ = registry.set_default(&config.devices.default); @@ -104,10 +105,28 @@ pub async fn serve() -> anyhow::Result<()> { if let Some(parent) = socket_path.parent() { fs::create_dir_all(parent).await?; } + if let Some(parent) = { + let guard = daemon.lock().await; + guard + .paths + .active_socket_file + .parent() + .map(Path::to_path_buf) + } { + fs::create_dir_all(parent).await?; + } let _ = fs::remove_file(&socket_path).await; let listener = UnixListener::bind(&socket_path)?; set_socket_permissions(&socket_path).await?; + { + let guard = daemon.lock().await; + fs::write( + &guard.paths.active_socket_file, + socket_path.display().to_string(), + ) + .await?; + } let mut discovery_interval = discovery_interval(interval_secs); if let Some(interval) = discovery_interval.as_mut() { interval.tick().await; @@ -140,6 +159,10 @@ pub async fn serve() -> anyhow::Result<()> { } let _ = fs::remove_file(&socket_path).await; + { + let guard = daemon.lock().await; + let _ = fs::remove_file(&guard.paths.active_socket_file).await; + } Ok(()) } @@ -452,6 +475,287 @@ async fn handle_request( ), } } + DaemonRequest::LaunchApp { device, app } => { + let guard = daemon.lock().await; + let device = match resolve_target_device(&guard.registry, device.as_deref()) { + 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(), + Err(error) => { + return ( + DaemonResponse::error( + "app_cache_lookup_failed", + format!("Failed to resolve app '{app}' against the cache: {error}"), + Some( + "Retry with a raw platform app id or refresh the app cache first." + .to_string(), + ), + ), + false, + ); + } + }; + + match guard.adapters.launch(&device, &target_app).await { + Ok(()) => ( + DaemonResponse::success(ActionResult { + device, + detail: format!("Launched app '{app}'."), + }), + false, + ), + Err(error) => ( + DaemonResponse::error( + "app_launch_failed", + format!("Failed to launch app '{app}': {error}"), + Some( + "Refresh the app cache or retry with the raw platform app id." + .to_string(), + ), + ), + false, + ), + } + } + DaemonRequest::StopApp { device } => { + let guard = daemon.lock().await; + let device = match resolve_target_device(&guard.registry, device.as_deref()) { + Ok(device) => device, + Err(response) => return (response, false), + }; + match guard.adapters.stop_app(&device).await { + Ok(()) => ( + DaemonResponse::success(ActionResult { + device, + detail: "Stopped the active app.".to_string(), + }), + false, + ), + Err(error) => ( + DaemonResponse::error( + "app_stop_failed", + format!("Failed to stop the active app: {error}"), + Some("Verify the TV is online and retry.".to_string()), + ), + false, + ), + } + } + DaemonRequest::GetState { device } => { + let mut guard = daemon.lock().await; + let device = match resolve_target_device(&guard.registry, device.as_deref()) { + Ok(device) => device, + Err(response) => return (response, false), + }; + match guard.adapters.state(&device).await { + Ok(state) => { + guard.state_cache.insert(state.clone()); + if let Some(app) = state.active_app.clone() { + let _ = guard + .app_cache + .record_platform_apps(&device.platform, vec![app]) + .await; + } + ( + DaemonResponse::success(StateResult { device, state }), + false, + ) + } + Err(error) => ( + DaemonResponse::error( + "state_failed", + format!("Failed to fetch device state: {error}"), + Some("Verify the TV is online and retry.".to_string()), + ), + false, + ), + } + } + DaemonRequest::SendKey { device, key } => { + let guard = daemon.lock().await; + let device = match resolve_target_device(&guard.registry, device.as_deref()) { + Ok(device) => device, + Err(response) => return (response, false), + }; + let detail = format!("Sent key '{key:?}'."); + match guard.adapters.key(&device, key).await { + Ok(()) => ( + DaemonResponse::success(ActionResult { device, detail }), + false, + ), + Err(error) => ( + DaemonResponse::error( + "remote_key_failed", + format!("Failed to send the key: {error}"), + Some("Verify the key name and make sure the TV is online.".to_string()), + ), + false, + ), + } + } + DaemonRequest::SendSequence { device, keys } => { + let guard = daemon.lock().await; + let device = match resolve_target_device(&guard.registry, device.as_deref()) { + Ok(device) => device, + Err(response) => return (response, false), + }; + let detail = format!("Sent {} key(s).", keys.len()); + match guard.adapters.sequence(&device, keys).await { + Ok(()) => ( + DaemonResponse::success(ActionResult { device, detail }), + false, + ), + Err(error) => ( + DaemonResponse::error( + "remote_sequence_failed", + format!("Failed to send the key sequence: {error}"), + Some("Verify the key names and make sure the TV is online.".to_string()), + ), + false, + ), + } + } + 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, + ); + } + let device = match resolve_target_device(&guard.registry, device.as_deref()) { + Ok(device) => device, + Err(response) => return (response, false), + }; + let zip = match fs::read(&zip_path).await { + Ok(zip) => zip, + Err(error) => { + return ( + DaemonResponse::error( + "dev_zip_read_failed", + format!("Failed to read dev package at {zip_path}: {error}"), + Some("Verify the zip path and retry.".to_string()), + ), + false, + ); + } + }; + match guard.adapters.dev_install(&device, &zip).await { + Ok(()) => ( + DaemonResponse::success(ActionResult { + device, + detail: format!("Installed development package from {zip_path}."), + }), + false, + ), + Err(error) => ( + DaemonResponse::error( + "dev_install_failed", + format!("Failed to install the development package: {error}"), + Some( + "Verify Roku developer mode credentials and package contents." + .to_string(), + ), + ), + false, + ), + } + } + 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, + ); + } + let device = match resolve_target_device(&guard.registry, device.as_deref()) { + Ok(device) => device, + Err(response) => return (response, false), + }; + match guard.adapters.dev_reload(&device).await { + Ok(()) => ( + DaemonResponse::success(ActionResult { + device, + detail: "Reloaded the development package.".to_string(), + }), + false, + ), + Err(error) => ( + DaemonResponse::error( + "dev_reload_failed", + format!("Failed to reload the development package: {error}"), + Some("Verify the TV is in developer mode and retry.".to_string()), + ), + false, + ), + } + } + 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, + ); + } + let device = match resolve_target_device(&guard.registry, device.as_deref()) { + Ok(device) => device, + Err(response) => return (response, false), + }; + match guard.adapters.dev_logs(&device).await { + Ok(lines) => ( + DaemonResponse::success(DevLogsResult { device, lines }), + false, + ), + Err(error) => ( + DaemonResponse::error( + "dev_logs_failed", + format!("Failed to fetch developer logs: {error}"), + Some("Verify the TV is in developer mode and reachable on the debugger port.".to_string()), + ), + false, + ), + } + } + DaemonRequest::ReloadConfig => { + let mut guard = daemon.lock().await; + match TvctlConfig::load_from_path(&guard.paths.config_file).await { + Ok(config) => { + let restart_required = apply_runtime_config(&mut guard, config); + ( + DaemonResponse::success(ConfigReloadResult { + config: guard.config.clone(), + restart_required, + }), + false, + ) + } + Err(error) => ( + DaemonResponse::error( + "config_reload_failed", + format!("Failed to reload config from disk: {error}"), + Some("Inspect ~/.config/tvctl/config.toml for invalid TOML.".to_string()), + ), + false, + ), + } + } } } @@ -520,3 +824,35 @@ fn discovery_interval(interval_secs: u64) -> Option { interval.set_missed_tick_behavior(MissedTickBehavior::Skip); Some(interval) } + +fn apply_runtime_config(daemon: &mut Daemon, config: TvctlConfig) -> Vec { + let old_config = daemon.config.clone(); + daemon.adapters = AdapterRegistry::from_config(&config); + daemon.discovery = DiscoveryService::new(daemon.adapters.clone()); + + if !config.devices.default.is_empty() { + let _ = daemon.registry.set_default(&config.devices.default); + } else { + daemon.registry.ensure_default(); + } + + let mut restart_required = Vec::new(); + if old_config.daemon.socket != config.daemon.socket { + restart_required.push("daemon.socket".to_string()); + } + if old_config.discovery.interval_secs != config.discovery.interval_secs { + restart_required.push("discovery.interval_secs".to_string()); + } + if old_config.daemon.http_enabled != config.daemon.http_enabled { + restart_required.push("daemon.http_enabled".to_string()); + } + if old_config.daemon.http_host != config.daemon.http_host { + restart_required.push("daemon.http_host".to_string()); + } + if old_config.daemon.http_port != config.daemon.http_port { + restart_required.push("daemon.http_port".to_string()); + } + + daemon.config = config; + restart_required +} diff --git a/src/daemon/registry.rs b/src/daemon/registry.rs index 04d720c..6a5b6ae 100644 --- a/src/daemon/registry.rs +++ b/src/daemon/registry.rs @@ -6,7 +6,10 @@ use serde::{Deserialize, Serialize}; use tokio::fs; use uuid::Uuid; -use crate::adapters::{Device, DeviceInfo, TvAdapter, roku::RokuAdapter}; +use crate::{ + adapters::{AppInfo, Device, DeviceInfo, DeviceState, TvAdapter, TvKey, roku::RokuAdapter}, + daemon::config::TvctlConfig, +}; /// The persisted collection of known devices. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -187,6 +190,17 @@ impl Default for AdapterRegistry { } impl AdapterRegistry { + /// Build the adapter registry from the loaded daemon config. + pub fn from_config(config: &TvctlConfig) -> Self { + let username = + (!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()); + Self { + roku: RokuAdapter::with_dev_credentials(username, password), + } + } + /// Return the supported platform names. pub fn supported_platforms(&self) -> Vec<&'static str> { vec!["roku"] @@ -222,15 +236,76 @@ impl AdapterRegistry { } /// Return apps from a concrete device using its platform adapter. - pub async fn list_apps( - &self, - device: &Device, - ) -> anyhow::Result> { + 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}'"), } } + + /// 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}'"), + } + } + + /// 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}'"), + } + } + + /// 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}'"), + } + } + + /// 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}'"), + } + } + + /// 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}'"), + } + } + + /// 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}'"), + } + } + + /// 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}'"), + } + } + + /// Fetch development logs from a concrete device. + pub async fn dev_logs(&self, device: &Device) -> anyhow::Result> { + match device.platform.as_str() { + "roku" => Ok(self.roku.dev_logs(device).await?), + other => anyhow::bail!("unsupported platform '{other}'"), + } + } } fn matches_target(device: &Device, target: &str) -> bool {