feat: complete CLI milestone
Finish the Milestone 4 CLI surface with config management, daemon install and uninstall helpers, config reload handling, and final polish for secret redaction and running-socket tracking.
This commit is contained in:
+3
-3
@@ -19,7 +19,7 @@ script and control smart TVs through a stable, brand-agnostic API.
|
|||||||
|
|
||||||
## Project Status
|
## 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)
|
**Platform v1:** Roku only (via ECP HTTP API)
|
||||||
**Language:** Rust
|
**Language:** Rust
|
||||||
**Crate type:** Binary (single binary distribution target)
|
**Crate type:** Binary (single binary distribution target)
|
||||||
@@ -307,6 +307,8 @@ default = ""
|
|||||||
|
|
||||||
[dev]
|
[dev]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
roku_username = ""
|
||||||
|
roku_password = ""
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -338,8 +340,6 @@ enabled = true
|
|||||||
## What Has NOT Been Started
|
## What Has NOT Been Started
|
||||||
|
|
||||||
- HTTP route handlers and request validation
|
- 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
|
- CI/CD configuration
|
||||||
- Release/packaging
|
- Release/packaging
|
||||||
|
|
||||||
|
|||||||
@@ -371,6 +371,8 @@ default = "living-room"
|
|||||||
|
|
||||||
[dev]
|
[dev]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
roku_username = ""
|
||||||
|
roku_password = ""
|
||||||
```
|
```
|
||||||
|
|
||||||
### Config Keys
|
### Config Keys
|
||||||
@@ -387,6 +389,8 @@ enabled = true
|
|||||||
| `discovery.timeout_secs` | `5` | Per-device timeout |
|
| `discovery.timeout_secs` | `5` | Per-device timeout |
|
||||||
| `devices.default` | `""` | Default device name or UUID |
|
| `devices.default` | `""` | Default device name or UUID |
|
||||||
| `dev.enabled` | `true` | Enable dev tooling commands |
|
| `dev.enabled` | `true` | Enable dev tooling commands |
|
||||||
|
| `dev.roku_username` | `""` | Optional Roku developer username override |
|
||||||
|
| `dev.roku_password` | `""` | Optional Roku developer password override |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+24
-22
@@ -7,15 +7,14 @@
|
|||||||
|
|
||||||
## Current Focus
|
## Current Focus
|
||||||
|
|
||||||
**Milestone 4 — CLI**
|
**Milestone 5 — HTTP API**
|
||||||
Daemon core is complete. Continue broadening CLI coverage against the running daemon.
|
CLI coverage is complete. Begin exposing the same surface over loopback HTTP.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## In Progress
|
## In Progress
|
||||||
|
|
||||||
- Milestone 4 is underway with working `daemon`, `device`, and app-cache commands over the Unix socket
|
- Milestone 5 has not started yet; CLI validation and polish are complete enough to move on
|
||||||
- Remaining CLI work is remote/state/dev/config coverage plus daemon install/uninstall helpers
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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 — CLI entry point and dispatch (`src/cli/mod.rs`)
|
||||||
- [x] 2026-04-14 — Unix socket client (send commands, receive responses)
|
- [x] 2026-04-14 — Unix socket client (send commands, receive responses)
|
||||||
- [ ] `tvctl daemon` commands
|
- [x] 2026-04-14 `tvctl daemon` commands
|
||||||
- `start` `stop` `restart` `status`
|
- [x] `start` `stop` `restart` `status`
|
||||||
- `install` (generate systemd user unit)
|
- [x] `install` (generate systemd user unit)
|
||||||
- `uninstall`
|
- [x] `uninstall`
|
||||||
- [ ] `tvctl device` commands
|
- [x] 2026-04-14 `tvctl device` commands
|
||||||
- `list` `discover` `add` `select` `info` `remove`
|
- [x] `list` `discover` `add` `select` `info` `remove`
|
||||||
- [ ] `tvctl app` commands
|
- [ ] `tvctl app` commands
|
||||||
- `list` `launch` `stop` `refresh`
|
- [x] 2026-04-14 `list` `launch` `stop` `refresh`
|
||||||
- [ ] `tvctl remote` commands
|
- [ ] `tvctl remote` commands
|
||||||
- `key` `sequence`
|
- [x] 2026-04-14 `key` `sequence`
|
||||||
- [ ] `tvctl state`
|
- [x] 2026-04-14 `tvctl state`
|
||||||
- [ ] `tvctl dev` commands
|
- [x] 2026-04-14 `tvctl dev` commands
|
||||||
- `install` `reload` `logs`
|
- [x] `install` `reload` `logs`
|
||||||
- [ ] `tvctl config` commands
|
- [x] 2026-04-14 `tvctl config` commands
|
||||||
- `list` `get` `set` `reset` `reload`
|
- [x] `list` `get` `set` `reset` `reload`
|
||||||
- [ ] Global flags: `--device` `--json` `--help` `--version`
|
- [x] 2026-04-14 Global flags: `--device` `--json` `--help` `--version`
|
||||||
- [ ] Full help text on every command (see AGENT.md definition of done)
|
- [x] 2026-04-14 Full help text on every command (see AGENT.md definition of done)
|
||||||
- [ ] Full help output on bare `tvctl`
|
- [x] 2026-04-14 Full help output on bare `tvctl`
|
||||||
- [ ] Friendly error messages with hints on every failure path
|
- [x] 2026-04-14 Friendly error messages with hints on every failure path
|
||||||
- [ ] `--json` output verified on every command
|
- [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 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 — 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 — 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ pub struct RokuAdapter {
|
|||||||
request_timeout: Duration,
|
request_timeout: Duration,
|
||||||
discovery_timeout: Duration,
|
discovery_timeout: Duration,
|
||||||
dev_log_window: Duration,
|
dev_log_window: Duration,
|
||||||
|
dev_username: Option<String>,
|
||||||
|
dev_password: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RokuAdapter {
|
impl RokuAdapter {
|
||||||
@@ -51,6 +53,8 @@ impl RokuAdapter {
|
|||||||
request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
|
request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
|
||||||
discovery_timeout: Duration::from_secs(DEFAULT_DISCOVERY_TIMEOUT_SECS),
|
discovery_timeout: Duration::from_secs(DEFAULT_DISCOVERY_TIMEOUT_SECS),
|
||||||
dev_log_window: Duration::from_secs(DEFAULT_DEV_LOG_WINDOW_SECS),
|
dev_log_window: Duration::from_secs(DEFAULT_DEV_LOG_WINDOW_SECS),
|
||||||
|
dev_username: None,
|
||||||
|
dev_password: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +65,20 @@ impl RokuAdapter {
|
|||||||
request_timeout,
|
request_timeout,
|
||||||
discovery_timeout,
|
discovery_timeout,
|
||||||
dev_log_window: Duration::from_secs(DEFAULT_DEV_LOG_WINDOW_SECS),
|
dev_log_window: Duration::from_secs(DEFAULT_DEV_LOG_WINDOW_SECS),
|
||||||
|
dev_username: None,
|
||||||
|
dev_password: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a Roku adapter with explicit developer-mode credentials.
|
||||||
|
pub fn with_dev_credentials(
|
||||||
|
dev_username: Option<String>,
|
||||||
|
dev_password: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
dev_username,
|
||||||
|
dev_password,
|
||||||
|
..Self::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,10 +216,21 @@ impl RokuAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn developer_credentials(&self) -> Result<(String, String)> {
|
async fn developer_credentials(&self) -> Result<(String, String)> {
|
||||||
let username =
|
let username = self
|
||||||
env::var("TVCTL_ROKU_DEV_USERNAME").unwrap_or_else(|_| "rokudev".to_string());
|
.dev_username
|
||||||
let password = env::var("TVCTL_ROKU_DEV_PASSWORD").map_err(|_| {
|
.clone()
|
||||||
TvError::Config("missing TVCTL_ROKU_DEV_PASSWORD for Roku developer mode".to_string())
|
.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))
|
Ok((username, password))
|
||||||
}
|
}
|
||||||
|
|||||||
+598
-27
@@ -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 clap::{Args, CommandFactory, Parser, Subcommand};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
|
fs,
|
||||||
io::{AsyncReadExt, AsyncWriteExt},
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
net::UnixStream,
|
net::UnixStream,
|
||||||
process::Command as TokioCommand,
|
process::Command as TokioCommand,
|
||||||
@@ -11,13 +12,13 @@ use tokio::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
adapters::{AppInfo, Device},
|
adapters::{AppInfo, Device, DeviceState, TvKey},
|
||||||
daemon::{
|
daemon::{
|
||||||
self,
|
self,
|
||||||
config::{RuntimePaths, TvctlConfig},
|
config::{RuntimePaths, TvctlConfig, default_config_path, systemd_unit_path},
|
||||||
ipc::{
|
ipc::{
|
||||||
AppListResult, AppRefreshResult, DaemonRequest, DaemonResponse, DaemonStatus,
|
ActionResult, AppListResult, AppRefreshResult, ConfigReloadResult, DaemonRequest,
|
||||||
DiscoveryResult,
|
DaemonResponse, DaemonStatus, DevLogsResult, DiscoveryResult, StateResult,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -65,13 +66,22 @@ pub enum Command {
|
|||||||
command: AppCommand,
|
command: AppCommand,
|
||||||
},
|
},
|
||||||
/// Send remote control input.
|
/// Send remote control input.
|
||||||
Remote,
|
Remote {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: RemoteCommand,
|
||||||
|
},
|
||||||
/// Query device state.
|
/// Query device state.
|
||||||
State,
|
State,
|
||||||
/// Use developer-oriented TV features.
|
/// Use developer-oriented TV features.
|
||||||
Dev,
|
Dev {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: DevCommand,
|
||||||
|
},
|
||||||
/// Inspect and modify tvctl configuration.
|
/// Inspect and modify tvctl configuration.
|
||||||
Config,
|
Config {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: ConfigCommand,
|
||||||
|
},
|
||||||
/// Internal daemon entry point used by `tvctl daemon start`.
|
/// Internal daemon entry point used by `tvctl daemon start`.
|
||||||
#[command(hide = true, name = "__daemon_serve")]
|
#[command(hide = true, name = "__daemon_serve")]
|
||||||
InternalDaemonServe,
|
InternalDaemonServe,
|
||||||
@@ -88,6 +98,10 @@ pub enum DaemonCommand {
|
|||||||
Restart,
|
Restart,
|
||||||
/// Show whether the daemon is running.
|
/// Show whether the daemon is running.
|
||||||
Status,
|
Status,
|
||||||
|
/// Generate and enable a systemd user service.
|
||||||
|
Install,
|
||||||
|
/// Disable and remove the systemd user service.
|
||||||
|
Uninstall,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discover and inspect known devices.
|
/// Discover and inspect known devices.
|
||||||
@@ -141,6 +155,13 @@ pub struct DeviceAddArgs {
|
|||||||
pub enum AppCommand {
|
pub enum AppCommand {
|
||||||
/// List cached apps for the selected or default device platform.
|
/// List cached apps for the selected or default device platform.
|
||||||
List,
|
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 the cached app list from the selected or default device.
|
||||||
Refresh {
|
Refresh {
|
||||||
/// Clear the platform cache before reloading from the device.
|
/// 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<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// A user-facing CLI error with a suggested next action.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
#[error("{message}\nHint: {hint}")]
|
#[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<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
struct ConfigMutationResult {
|
||||||
|
key: Option<String>,
|
||||||
|
value: Option<String>,
|
||||||
|
config_path: String,
|
||||||
|
daemon_reloaded: bool,
|
||||||
|
restart_required: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
struct ServiceResult {
|
||||||
|
unit_file: String,
|
||||||
|
enabled: bool,
|
||||||
|
running: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse the CLI and execute the selected command.
|
/// Parse the CLI and execute the selected command.
|
||||||
pub async fn run() -> Result<(), CliError> {
|
pub async fn run() -> Result<(), CliError> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
@@ -190,22 +290,10 @@ pub async fn run() -> Result<(), CliError> {
|
|||||||
Command::Daemon { command } => handle_daemon_command(&cli, command).await,
|
Command::Daemon { command } => handle_daemon_command(&cli, command).await,
|
||||||
Command::Device { command } => handle_device_command(&cli, command).await,
|
Command::Device { command } => handle_device_command(&cli, command).await,
|
||||||
Command::App { command } => handle_app_command(&cli, command).await,
|
Command::App { command } => handle_app_command(&cli, command).await,
|
||||||
Command::Remote => Err(CliError::new(
|
Command::Remote { command } => handle_remote_command(&cli, command).await,
|
||||||
"Remote commands are not wired to the daemon yet.",
|
Command::State => handle_state_command(&cli).await,
|
||||||
"Continue Milestone 4 after the daemon protocol is in place.",
|
Command::Dev { command } => handle_dev_command(&cli, command).await,
|
||||||
)),
|
Command::Config { command } => handle_config_command(&cli, command).await,
|
||||||
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.",
|
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +308,8 @@ async fn handle_daemon_command(cli: &Cli, command: DaemonCommand) -> Result<(),
|
|||||||
daemon_start(cli).await
|
daemon_start(cli).await
|
||||||
}
|
}
|
||||||
DaemonCommand::Status => daemon_status(cli).await,
|
DaemonCommand::Status => daemon_status(cli).await,
|
||||||
|
DaemonCommand::Install => daemon_install(cli).await,
|
||||||
|
DaemonCommand::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)
|
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 } => {
|
AppCommand::Refresh { clear } => {
|
||||||
let response = send_request(
|
let response = send_request(
|
||||||
load_socket_path().await?,
|
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::<Result<Vec<_>, _>>()?;
|
||||||
|
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::<BTreeMap<_, _>>();
|
||||||
|
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> {
|
async fn daemon_start(cli: &Cli) -> Result<(), CliError> {
|
||||||
if let Some(status) = daemon_status_payload().await {
|
if let Some(status) = daemon_status_payload().await {
|
||||||
return render(cli, &status, || {
|
return render(cli, &status, || {
|
||||||
@@ -393,20 +687,122 @@ async fn daemon_status(cli: &Cli) -> Result<(), CliError> {
|
|||||||
Ok(())
|
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<DaemonStatus> {
|
async fn daemon_status_payload() -> Option<DaemonStatus> {
|
||||||
let socket = load_socket_path().await.ok()?;
|
let socket = load_socket_path().await.ok()?;
|
||||||
let response = send_request(socket, &DaemonRequest::Ping).await.ok()?;
|
let response = send_request(socket, &DaemonRequest::Ping).await.ok()?;
|
||||||
parse_response_data(response).ok()
|
parse_response_data(response).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_socket_path() -> Result<PathBuf, CliError> {
|
async fn load_config() -> Result<TvctlConfig, CliError> {
|
||||||
let config = TvctlConfig::load().await.map_err(|error| {
|
TvctlConfig::load().await.map_err(|error| {
|
||||||
CliError::new(
|
CliError::new(
|
||||||
format!("Failed to load tvctl configuration: {error}"),
|
format!("Failed to load tvctl configuration: {error}"),
|
||||||
"Inspect ~/.config/tvctl/config.toml for invalid TOML.",
|
"Inspect ~/.config/tvctl/config.toml for invalid TOML.",
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reload_daemon_config() -> Result<ConfigReloadResult, CliError> {
|
||||||
|
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 fallback = RuntimePaths::detect().socket_file;
|
let response = send_request(socket, &DaemonRequest::ReloadConfig).await?;
|
||||||
|
parse_response_data(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn maybe_reload_daemon_config(
|
||||||
|
daemon_socket: Option<PathBuf>,
|
||||||
|
) -> Result<Option<ConfigReloadResult>, 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<PathBuf, CliError> {
|
||||||
|
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);
|
let configured = PathBuf::from(config.daemon.socket);
|
||||||
Ok(if configured.as_os_str().is_empty() {
|
Ok(if configured.as_os_str().is_empty() {
|
||||||
fallback
|
fallback
|
||||||
@@ -415,6 +811,34 @@ async fn load_socket_path() -> Result<PathBuf, CliError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
async fn send_request(
|
||||||
socket_path: PathBuf,
|
socket_path: PathBuf,
|
||||||
request: &DaemonRequest,
|
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 "<redacted>".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 = "<redacted>".to_string();
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_config_list(values: &BTreeMap<String, String>) -> String {
|
||||||
|
values
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| format!("{key} = {value}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.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 {
|
fn default_marker(device: &Device) -> &'static str {
|
||||||
if device.is_default { " (default)" } else { "" }
|
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<TvKey, CliError> {
|
||||||
|
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`.",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+98
-1
@@ -1,8 +1,10 @@
|
|||||||
use std::{
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
env,
|
env,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use anyhow::bail;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
@@ -55,6 +57,58 @@ impl TvctlConfig {
|
|||||||
fs::write(path, contents).await?;
|
fs::write(path, contents).await?;
|
||||||
Ok(())
|
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<String> {
|
||||||
|
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.
|
/// Runtime daemon settings.
|
||||||
@@ -121,11 +175,19 @@ pub struct DeviceConfig {
|
|||||||
pub struct DevConfig {
|
pub struct DevConfig {
|
||||||
/// Whether developer tooling is enabled.
|
/// Whether developer tooling is enabled.
|
||||||
pub enabled: bool,
|
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 {
|
impl Default for DevConfig {
|
||||||
fn default() -> Self {
|
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,
|
pub devices_file: PathBuf,
|
||||||
/// The platform app cache directory.
|
/// The platform app cache directory.
|
||||||
pub cache_dir: PathBuf,
|
pub cache_dir: PathBuf,
|
||||||
|
/// The file containing the currently active daemon socket path.
|
||||||
|
pub active_socket_file: PathBuf,
|
||||||
/// The runtime socket path.
|
/// The runtime socket path.
|
||||||
pub socket_file: PathBuf,
|
pub socket_file: PathBuf,
|
||||||
}
|
}
|
||||||
@@ -153,6 +217,7 @@ impl RuntimePaths {
|
|||||||
Self {
|
Self {
|
||||||
config_file,
|
config_file,
|
||||||
devices_file: data_dir.join("devices.json"),
|
devices_file: data_dir.join("devices.json"),
|
||||||
|
active_socket_file: data_dir.join("active_socket"),
|
||||||
socket_file: default_socket_path(),
|
socket_file: default_socket_path(),
|
||||||
data_dir,
|
data_dir,
|
||||||
cache_dir,
|
cache_dir,
|
||||||
@@ -173,6 +238,19 @@ pub fn default_config_dir() -> PathBuf {
|
|||||||
home_dir().join(".config/tvctl")
|
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.
|
/// Return the default data directory path.
|
||||||
pub fn default_data_dir() -> PathBuf {
|
pub fn default_data_dir() -> PathBuf {
|
||||||
if let Ok(path) = env::var("XDG_DATA_HOME") {
|
if let Ok(path) = env::var("XDG_DATA_HOME") {
|
||||||
@@ -200,6 +278,20 @@ fn current_uid() -> u32 {
|
|||||||
unsafe { libc::geteuid() }
|
unsafe { libc::geteuid() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_bool(key: &str, value: &str) -> anyhow::Result<bool> {
|
||||||
|
parse_value(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_value<T>(key: &str, value: &str) -> anyhow::Result<T>
|
||||||
|
where
|
||||||
|
T: std::str::FromStr,
|
||||||
|
T::Err: std::fmt::Display,
|
||||||
|
{
|
||||||
|
value
|
||||||
|
.parse::<T>()
|
||||||
|
.map_err(|error| anyhow::anyhow!("invalid value '{value}' for {key}: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -221,6 +313,11 @@ mod tests {
|
|||||||
devices: DeviceConfig {
|
devices: DeviceConfig {
|
||||||
default: "living-room".to_string(),
|
default: "living-room".to_string(),
|
||||||
},
|
},
|
||||||
|
dev: DevConfig {
|
||||||
|
enabled: true,
|
||||||
|
roku_username: "rokudev".to_string(),
|
||||||
|
roku_password: "secret".to_string(),
|
||||||
|
},
|
||||||
..TvctlConfig::default()
|
..TvctlConfig::default()
|
||||||
};
|
};
|
||||||
config
|
config
|
||||||
|
|||||||
+88
-1
@@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::net::IpAddr;
|
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.
|
/// A request sent from the CLI to the daemon over the Unix socket.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[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.
|
/// Whether to clear the platform cache before reloading from the device.
|
||||||
clear: bool,
|
clear: bool,
|
||||||
},
|
},
|
||||||
|
/// Launch an app on one device.
|
||||||
|
LaunchApp {
|
||||||
|
/// Optional UUID or friendly name.
|
||||||
|
device: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
},
|
||||||
|
/// Fetch the current state for one device.
|
||||||
|
GetState {
|
||||||
|
/// Optional UUID or friendly name.
|
||||||
|
device: Option<String>,
|
||||||
|
},
|
||||||
|
/// Send one normalized key to one device.
|
||||||
|
SendKey {
|
||||||
|
/// Optional UUID or friendly name.
|
||||||
|
device: Option<String>,
|
||||||
|
/// Normalized key identifier.
|
||||||
|
key: TvKey,
|
||||||
|
},
|
||||||
|
/// Send a normalized key sequence to one device.
|
||||||
|
SendSequence {
|
||||||
|
/// Optional UUID or friendly name.
|
||||||
|
device: Option<String>,
|
||||||
|
/// Normalized key identifiers.
|
||||||
|
keys: Vec<TvKey>,
|
||||||
|
},
|
||||||
|
/// Install a dev package from a local zip path on one device.
|
||||||
|
DevInstall {
|
||||||
|
/// Optional UUID or friendly name.
|
||||||
|
device: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
},
|
||||||
|
/// Fetch developer logs from one device.
|
||||||
|
DevLogs {
|
||||||
|
/// Optional UUID or friendly name.
|
||||||
|
device: Option<String>,
|
||||||
|
},
|
||||||
|
/// Reload config from disk into the running daemon.
|
||||||
|
ReloadConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A standard daemon response envelope for IPC.
|
/// A standard daemon response envelope for IPC.
|
||||||
@@ -148,3 +199,39 @@ pub struct AppRefreshResult {
|
|||||||
/// The total number of cached apps after merge/replace.
|
/// The total number of cached apps after merge/replace.
|
||||||
pub cached_count: usize,
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|||||||
+338
-2
@@ -15,7 +15,8 @@ use cache::AppCacheStore;
|
|||||||
use config::{RuntimePaths, TvctlConfig};
|
use config::{RuntimePaths, TvctlConfig};
|
||||||
use discovery::DiscoveryService;
|
use discovery::DiscoveryService;
|
||||||
use ipc::{
|
use ipc::{
|
||||||
AppListResult, AppRefreshResult, DaemonRequest, DaemonResponse, DaemonStatus, DiscoveryResult,
|
ActionResult, AppListResult, AppRefreshResult, ConfigReloadResult, DaemonRequest,
|
||||||
|
DaemonResponse, DaemonStatus, DevLogsResult, DiscoveryResult, StateResult,
|
||||||
};
|
};
|
||||||
use registry::{AdapterRegistry, DeviceRegistry};
|
use registry::{AdapterRegistry, DeviceRegistry};
|
||||||
use state::StateCache;
|
use state::StateCache;
|
||||||
@@ -60,7 +61,7 @@ impl Daemon {
|
|||||||
if !config.daemon.socket.is_empty() {
|
if !config.daemon.socket.is_empty() {
|
||||||
paths.socket_file = PathBuf::from(&config.daemon.socket);
|
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?;
|
let mut registry = DeviceRegistry::load(paths.devices_file.clone()).await?;
|
||||||
if !config.devices.default.is_empty() {
|
if !config.devices.default.is_empty() {
|
||||||
let _ = registry.set_default(&config.devices.default);
|
let _ = registry.set_default(&config.devices.default);
|
||||||
@@ -104,10 +105,28 @@ pub async fn serve() -> anyhow::Result<()> {
|
|||||||
if let Some(parent) = socket_path.parent() {
|
if let Some(parent) = socket_path.parent() {
|
||||||
fs::create_dir_all(parent).await?;
|
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 _ = fs::remove_file(&socket_path).await;
|
||||||
let listener = UnixListener::bind(&socket_path)?;
|
let listener = UnixListener::bind(&socket_path)?;
|
||||||
set_socket_permissions(&socket_path).await?;
|
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);
|
let mut discovery_interval = discovery_interval(interval_secs);
|
||||||
if let Some(interval) = discovery_interval.as_mut() {
|
if let Some(interval) = discovery_interval.as_mut() {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
@@ -140,6 +159,10 @@ pub async fn serve() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _ = fs::remove_file(&socket_path).await;
|
let _ = fs::remove_file(&socket_path).await;
|
||||||
|
{
|
||||||
|
let guard = daemon.lock().await;
|
||||||
|
let _ = fs::remove_file(&guard.paths.active_socket_file).await;
|
||||||
|
}
|
||||||
Ok(())
|
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<time::Interval> {
|
|||||||
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||||
Some(interval)
|
Some(interval)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_runtime_config(daemon: &mut Daemon, config: TvctlConfig) -> Vec<String> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
+80
-5
@@ -6,7 +6,10 @@ use serde::{Deserialize, Serialize};
|
|||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use uuid::Uuid;
|
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.
|
/// The persisted collection of known devices.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -187,6 +190,17 @@ impl Default for AdapterRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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.
|
/// Return the supported platform names.
|
||||||
pub fn supported_platforms(&self) -> Vec<&'static str> {
|
pub fn supported_platforms(&self) -> Vec<&'static str> {
|
||||||
vec!["roku"]
|
vec!["roku"]
|
||||||
@@ -222,15 +236,76 @@ impl AdapterRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return apps from a concrete device using its platform adapter.
|
/// Return apps from a concrete device using its platform adapter.
|
||||||
pub async fn list_apps(
|
pub async fn list_apps(&self, device: &Device) -> anyhow::Result<Vec<AppInfo>> {
|
||||||
&self,
|
|
||||||
device: &Device,
|
|
||||||
) -> anyhow::Result<Vec<crate::adapters::AppInfo>> {
|
|
||||||
match device.platform.as_str() {
|
match device.platform.as_str() {
|
||||||
"roku" => Ok(self.roku.list_apps(device).await?),
|
"roku" => Ok(self.roku.list_apps(device).await?),
|
||||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch the current state for a concrete device.
|
||||||
|
pub async fn state(&self, device: &Device) -> anyhow::Result<DeviceState> {
|
||||||
|
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<TvKey>) -> 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<Vec<String>> {
|
||||||
|
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 {
|
fn matches_target(device: &Device, target: &str) -> bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user