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