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:
+122
-11
@@ -4,6 +4,14 @@ use tokio::fs;
|
||||
|
||||
use crate::adapters::AppInfo;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AppResolution {
|
||||
Exact(AppInfo),
|
||||
Fuzzy(AppInfo),
|
||||
Ambiguous(Vec<AppInfo>),
|
||||
None,
|
||||
}
|
||||
|
||||
/// A platform-level cache of app metadata discovered from live devices.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AppCache {
|
||||
@@ -70,22 +78,46 @@ impl AppCacheStore {
|
||||
}
|
||||
|
||||
/// Resolve an app by case-insensitive name or exact ID from the persisted platform cache.
|
||||
pub async fn find_app(&self, platform: &str, query: &str) -> anyhow::Result<Option<AppInfo>> {
|
||||
pub async fn find_app(&self, platform: &str, query: &str) -> anyhow::Result<AppResolution> {
|
||||
let cache = self.load_platform(platform).await?;
|
||||
let normalized = normalize_query(query);
|
||||
|
||||
if let Some(app) = cache.apps.iter().find(|app| {
|
||||
app.platform_id == query || app.id == query || normalize_query(&app.name) == normalized
|
||||
}) {
|
||||
return Ok(Some(app.clone()));
|
||||
return Ok(AppResolution::Exact(app.clone()));
|
||||
}
|
||||
|
||||
Ok(cache
|
||||
let mut matches = cache
|
||||
.apps
|
||||
.into_iter()
|
||||
.filter_map(|app| fuzzy_match_score(&normalized, &app).map(|score| (score, app)))
|
||||
.max_by_key(|(score, app)| (*score, Reverse(app.name.len())))
|
||||
.map(|(_, app)| app))
|
||||
.collect::<Vec<_>>();
|
||||
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.
|
||||
@@ -120,6 +152,9 @@ fn fuzzy_match_score(query: &str, app: &AppInfo) -> Option<u8> {
|
||||
let platform_id = normalize_query(&app.platform_id);
|
||||
|
||||
if name.starts_with(query) || id.starts_with(query) || platform_id.starts_with(query) {
|
||||
return Some(4);
|
||||
}
|
||||
if name.ends_with(query) || id.ends_with(query) || platform_id.ends_with(query) {
|
||||
return Some(3);
|
||||
}
|
||||
if name.contains(query) || id.contains(query) || platform_id.contains(query) {
|
||||
@@ -182,9 +217,11 @@ mod tests {
|
||||
let resolved = store
|
||||
.find_app("roku", "youtube")
|
||||
.await
|
||||
.expect("app lookup should work")
|
||||
.expect("youtube should exist");
|
||||
assert_eq!(resolved.platform_id, "837");
|
||||
.expect("app lookup should work");
|
||||
match resolved {
|
||||
AppResolution::Exact(app) => assert_eq!(app.platform_id, "837"),
|
||||
other => panic!("expected exact youtube match, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -276,8 +313,82 @@ mod tests {
|
||||
let resolved = store
|
||||
.find_app("roku", "jelly")
|
||||
.await
|
||||
.expect("lookup should work")
|
||||
.expect("jellyfin should resolve");
|
||||
assert_eq!(resolved.name, "Jellyfin");
|
||||
.expect("lookup should work");
|
||||
match resolved {
|
||||
AppResolution::Fuzzy(app) => assert_eq!(app.name, "Jellyfin"),
|
||||
other => panic!("expected fuzzy jellyfin match, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_app_rejects_ambiguous_fuzzy_matches() {
|
||||
let temp_dir = tempfile::tempdir().expect("temp dir should exist");
|
||||
let store = AppCacheStore::new(temp_dir.path().join("cache"));
|
||||
|
||||
store
|
||||
.record_platform_apps(
|
||||
"roku",
|
||||
vec![
|
||||
AppInfo {
|
||||
id: "592369".to_string(),
|
||||
name: "Jellyfin".to_string(),
|
||||
version: None,
|
||||
platform_id: "592369".to_string(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "111".to_string(),
|
||||
name: "Frndly TV".to_string(),
|
||||
version: None,
|
||||
platform_id: "111".to_string(),
|
||||
},
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("apps should save");
|
||||
|
||||
let resolved = store
|
||||
.find_app("roku", "f")
|
||||
.await
|
||||
.expect("lookup should work");
|
||||
match resolved {
|
||||
AppResolution::Ambiguous(apps) => assert_eq!(apps.len(), 2),
|
||||
other => panic!("expected ambiguous match, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_app_prefers_suffix_match_when_unique() {
|
||||
let temp_dir = tempfile::tempdir().expect("temp dir should exist");
|
||||
let store = AppCacheStore::new(temp_dir.path().join("cache"));
|
||||
|
||||
store
|
||||
.record_platform_apps(
|
||||
"roku",
|
||||
vec![
|
||||
AppInfo {
|
||||
id: "592369".to_string(),
|
||||
name: "Jellyfin".to_string(),
|
||||
version: None,
|
||||
platform_id: "592369".to_string(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "123132".to_string(),
|
||||
name: "Xfinity Stream".to_string(),
|
||||
version: None,
|
||||
platform_id: "123132".to_string(),
|
||||
},
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("apps should save");
|
||||
|
||||
let resolved = store
|
||||
.find_app("roku", "fin")
|
||||
.await
|
||||
.expect("lookup should work");
|
||||
match resolved {
|
||||
AppResolution::Fuzzy(app) => assert_eq!(app.name, "Jellyfin"),
|
||||
other => panic!("expected jellyfin suffix match, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ pub struct TvctlConfig {
|
||||
pub discovery: DiscoveryConfig,
|
||||
/// Default-device settings.
|
||||
pub devices: DeviceConfig,
|
||||
/// Remote input behavior.
|
||||
pub remote: RemoteConfig,
|
||||
/// Developer tooling toggles.
|
||||
pub dev: DevConfig,
|
||||
}
|
||||
@@ -28,6 +30,7 @@ impl Default for TvctlConfig {
|
||||
daemon: DaemonConfig::default(),
|
||||
discovery: DiscoveryConfig::default(),
|
||||
devices: DeviceConfig::default(),
|
||||
remote: RemoteConfig::default(),
|
||||
dev: DevConfig::default(),
|
||||
}
|
||||
}
|
||||
@@ -79,6 +82,11 @@ impl TvctlConfig {
|
||||
self.discovery.timeout_secs.to_string(),
|
||||
),
|
||||
("devices.default", self.devices.default.clone()),
|
||||
("remote.roku_key_mode", self.remote.roku_key_mode.clone()),
|
||||
(
|
||||
"remote.roku_press_duration_ms",
|
||||
self.remote.roku_press_duration_ms.to_string(),
|
||||
),
|
||||
("dev.enabled", self.dev.enabled.to_string()),
|
||||
("dev.roku_username", self.dev.roku_username.clone()),
|
||||
("dev.roku_password", self.dev.roku_password.clone()),
|
||||
@@ -102,6 +110,10 @@ impl TvctlConfig {
|
||||
"discovery.interval_secs" => self.discovery.interval_secs = parse_value(key, value)?,
|
||||
"discovery.timeout_secs" => self.discovery.timeout_secs = parse_value(key, value)?,
|
||||
"devices.default" => self.devices.default = value.to_string(),
|
||||
"remote.roku_key_mode" => self.remote.roku_key_mode = value.to_string(),
|
||||
"remote.roku_press_duration_ms" => {
|
||||
self.remote.roku_press_duration_ms = parse_value(key, value)?
|
||||
}
|
||||
"dev.enabled" => self.dev.enabled = parse_bool(key, value)?,
|
||||
"dev.roku_username" => self.dev.roku_username = value.to_string(),
|
||||
"dev.roku_password" => self.dev.roku_password = value.to_string(),
|
||||
@@ -169,6 +181,25 @@ pub struct DeviceConfig {
|
||||
pub default: String,
|
||||
}
|
||||
|
||||
/// Remote input behavior.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct RemoteConfig {
|
||||
/// Roku key delivery mode: `keypress` or `keydown_up`.
|
||||
pub roku_key_mode: String,
|
||||
/// How long a Roku key stays pressed before `keyup`, in milliseconds.
|
||||
pub roku_press_duration_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for RemoteConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
roku_key_mode: "keypress".to_string(),
|
||||
roku_press_duration_ms: 75,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Developer tooling toggles.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
|
||||
+113
-55
@@ -11,7 +11,7 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use cache::AppCacheStore;
|
||||
use cache::{AppCacheStore, AppResolution};
|
||||
use config::{RuntimePaths, TvctlConfig};
|
||||
use discovery::DiscoveryService;
|
||||
use ipc::{
|
||||
@@ -32,7 +32,7 @@ use tracing::warn;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use crate::adapters::Device;
|
||||
use crate::adapters::{Device, TvKey};
|
||||
|
||||
/// The long-lived tvctld process.
|
||||
#[derive(Debug)]
|
||||
@@ -487,9 +487,34 @@ async fn handle_request(
|
||||
Ok(device) => device,
|
||||
Err(response) => return (response, false),
|
||||
};
|
||||
let target_app = match guard.app_cache.find_app(&device.platform, &app).await {
|
||||
Ok(Some(cached)) => cached.platform_id,
|
||||
Ok(None) => app.clone(),
|
||||
let (target_app, launch_label) = match guard
|
||||
.app_cache
|
||||
.find_app(&device.platform, &app)
|
||||
.await
|
||||
{
|
||||
Ok(AppResolution::Exact(cached)) | Ok(AppResolution::Fuzzy(cached)) => (
|
||||
cached.platform_id.clone(),
|
||||
format!("{} [{}]", cached.name, cached.platform_id),
|
||||
),
|
||||
Ok(AppResolution::Ambiguous(matches)) => {
|
||||
let options = matches
|
||||
.into_iter()
|
||||
.map(|candidate| format!("{} [{}]", candidate.name, candidate.platform_id))
|
||||
.collect::<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) => {
|
||||
return (
|
||||
DaemonResponse::error(
|
||||
@@ -507,10 +532,7 @@ async fn handle_request(
|
||||
|
||||
match guard.adapters.launch(&device, &target_app).await {
|
||||
Ok(()) => (
|
||||
DaemonResponse::success(ActionResult {
|
||||
device,
|
||||
detail: format!("Launched app '{app}'."),
|
||||
}),
|
||||
action_success(device, format!("Launched {launch_label}.")),
|
||||
false,
|
||||
),
|
||||
Err(error) => (
|
||||
@@ -534,10 +556,10 @@ async fn handle_request(
|
||||
};
|
||||
match guard.adapters.stop_app(&device).await {
|
||||
Ok(()) => (
|
||||
DaemonResponse::success(ActionResult {
|
||||
device,
|
||||
detail: "Stopped the active app.".to_string(),
|
||||
}),
|
||||
action_success(
|
||||
device.clone(),
|
||||
format!("Stopped the active app on {}.", device.name),
|
||||
),
|
||||
false,
|
||||
),
|
||||
Err(error) => (
|
||||
@@ -586,12 +608,9 @@ async fn handle_request(
|
||||
Ok(device) => device,
|
||||
Err(response) => return (response, false),
|
||||
};
|
||||
let detail = format!("Sent key '{key:?}'.");
|
||||
let detail = format!("Sent key '{}' to {}.", format_tv_key(&key), device.name);
|
||||
match guard.adapters.key(&device, key).await {
|
||||
Ok(()) => (
|
||||
DaemonResponse::success(ActionResult { device, detail }),
|
||||
false,
|
||||
),
|
||||
Ok(()) => (action_success(device, detail), false),
|
||||
Err(error) => (
|
||||
DaemonResponse::error(
|
||||
"remote_key_failed",
|
||||
@@ -612,12 +631,14 @@ async fn handle_request(
|
||||
Ok(device) => device,
|
||||
Err(response) => return (response, false),
|
||||
};
|
||||
let detail = format!("Sent {} key(s) with {} ms spacing.", keys.len(), delay_ms);
|
||||
let detail = format!(
|
||||
"Sent {} key(s) to {} with {} ms spacing.",
|
||||
keys.len(),
|
||||
device.name,
|
||||
delay_ms
|
||||
);
|
||||
match send_key_sequence(&guard.adapters, &device, keys, delay_ms).await {
|
||||
Ok(()) => (
|
||||
DaemonResponse::success(ActionResult { device, detail }),
|
||||
false,
|
||||
),
|
||||
Ok(()) => (action_success(device, detail), false),
|
||||
Err(error) => (
|
||||
DaemonResponse::error(
|
||||
"remote_sequence_failed",
|
||||
@@ -631,14 +652,7 @@ async fn handle_request(
|
||||
DaemonRequest::DevInstall { device, zip_path } => {
|
||||
let guard = daemon.lock().await;
|
||||
if !guard.config.dev.enabled {
|
||||
return (
|
||||
DaemonResponse::error(
|
||||
"dev_disabled",
|
||||
"Developer commands are disabled in the tvctl config.",
|
||||
Some("Set [dev].enabled = true or use `tvctl config` once that surface exists.".to_string()),
|
||||
),
|
||||
false,
|
||||
);
|
||||
return (dev_disabled_response(), false);
|
||||
}
|
||||
let device = match resolve_target_device(&guard.registry, device.as_deref()) {
|
||||
Ok(device) => device,
|
||||
@@ -659,10 +673,13 @@ async fn handle_request(
|
||||
};
|
||||
match guard.adapters.dev_install(&device, &zip).await {
|
||||
Ok(()) => (
|
||||
DaemonResponse::success(ActionResult {
|
||||
device,
|
||||
detail: format!("Installed development package from {zip_path}."),
|
||||
}),
|
||||
action_success(
|
||||
device.clone(),
|
||||
format!(
|
||||
"Installed development package from {zip_path} on {}.",
|
||||
device.name
|
||||
),
|
||||
),
|
||||
false,
|
||||
),
|
||||
Err(error) => (
|
||||
@@ -681,14 +698,7 @@ async fn handle_request(
|
||||
DaemonRequest::DevReload { device } => {
|
||||
let guard = daemon.lock().await;
|
||||
if !guard.config.dev.enabled {
|
||||
return (
|
||||
DaemonResponse::error(
|
||||
"dev_disabled",
|
||||
"Developer commands are disabled in the tvctl config.",
|
||||
Some("Set [dev].enabled = true or use `tvctl config` once that surface exists.".to_string()),
|
||||
),
|
||||
false,
|
||||
);
|
||||
return (dev_disabled_response(), false);
|
||||
}
|
||||
let device = match resolve_target_device(&guard.registry, device.as_deref()) {
|
||||
Ok(device) => device,
|
||||
@@ -696,10 +706,10 @@ async fn handle_request(
|
||||
};
|
||||
match guard.adapters.dev_reload(&device).await {
|
||||
Ok(()) => (
|
||||
DaemonResponse::success(ActionResult {
|
||||
device,
|
||||
detail: "Reloaded the development package.".to_string(),
|
||||
}),
|
||||
action_success(
|
||||
device.clone(),
|
||||
format!("Reloaded the development package on {}.", device.name),
|
||||
),
|
||||
false,
|
||||
),
|
||||
Err(error) => (
|
||||
@@ -715,14 +725,7 @@ async fn handle_request(
|
||||
DaemonRequest::DevLogs { device } => {
|
||||
let guard = daemon.lock().await;
|
||||
if !guard.config.dev.enabled {
|
||||
return (
|
||||
DaemonResponse::error(
|
||||
"dev_disabled",
|
||||
"Developer commands are disabled in the tvctl config.",
|
||||
Some("Set [dev].enabled = true or use `tvctl config` once that surface exists.".to_string()),
|
||||
),
|
||||
false,
|
||||
);
|
||||
return (dev_disabled_response(), false);
|
||||
}
|
||||
let device = match resolve_target_device(&guard.registry, device.as_deref()) {
|
||||
Ok(device) => device,
|
||||
@@ -785,6 +788,44 @@ async fn send_key_sequence(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_tv_key(key: &TvKey) -> String {
|
||||
match key {
|
||||
TvKey::Home => "home".to_string(),
|
||||
TvKey::Back => "back".to_string(),
|
||||
TvKey::Up => "up".to_string(),
|
||||
TvKey::Down => "down".to_string(),
|
||||
TvKey::Left => "left".to_string(),
|
||||
TvKey::Right => "right".to_string(),
|
||||
TvKey::Select => "select".to_string(),
|
||||
TvKey::Play => "play".to_string(),
|
||||
TvKey::Pause => "pause".to_string(),
|
||||
TvKey::PlayPause => "play-pause".to_string(),
|
||||
TvKey::Stop => "stop".to_string(),
|
||||
TvKey::Rewind => "rewind".to_string(),
|
||||
TvKey::FastForward => "fast-forward".to_string(),
|
||||
TvKey::Replay => "replay".to_string(),
|
||||
TvKey::Skip => "skip".to_string(),
|
||||
TvKey::ChannelUp => "channel-up".to_string(),
|
||||
TvKey::ChannelDown => "channel-down".to_string(),
|
||||
TvKey::VolumeUp => "volume-up".to_string(),
|
||||
TvKey::VolumeDown => "volume-down".to_string(),
|
||||
TvKey::Mute => "mute".to_string(),
|
||||
TvKey::Power => "power".to_string(),
|
||||
TvKey::PowerOn => "power-on".to_string(),
|
||||
TvKey::PowerOff => "power-off".to_string(),
|
||||
TvKey::InputHdmi1 => "input-hdmi1".to_string(),
|
||||
TvKey::InputHdmi2 => "input-hdmi2".to_string(),
|
||||
TvKey::InputHdmi3 => "input-hdmi3".to_string(),
|
||||
TvKey::InputHdmi4 => "input-hdmi4".to_string(),
|
||||
TvKey::InputAv => "input-av".to_string(),
|
||||
TvKey::InputTuner => "input-tuner".to_string(),
|
||||
TvKey::Search => "search".to_string(),
|
||||
TvKey::Info => "info".to_string(),
|
||||
TvKey::Options => "options".to_string(),
|
||||
TvKey::Literal(text) => format!("literal:{text}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_discovery(daemon: &mut Daemon) -> anyhow::Result<Vec<Device>> {
|
||||
let discovery = daemon.discovery.clone();
|
||||
let devices = discovery.discover_all(&mut daemon.registry).await?;
|
||||
@@ -819,6 +860,23 @@ async fn sync_registry_config(daemon: &mut Daemon) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn action_success(device: Device, detail: impl Into<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(
|
||||
registry: &DeviceRegistry,
|
||||
target: Option<&str>,
|
||||
|
||||
+65
-46
@@ -7,7 +7,10 @@ use tokio::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
adapters::{AppInfo, Device, DeviceInfo, DeviceState, TvAdapter, TvKey, roku::RokuAdapter},
|
||||
adapters::{
|
||||
AppInfo, Device, DeviceInfo, DeviceState, TvAdapter, TvKey,
|
||||
roku::{RokuAdapter, RokuKeyMode},
|
||||
},
|
||||
daemon::config::TvctlConfig,
|
||||
};
|
||||
|
||||
@@ -196,8 +199,12 @@ impl AdapterRegistry {
|
||||
(!config.dev.roku_username.is_empty()).then(|| config.dev.roku_username.clone());
|
||||
let password =
|
||||
(!config.dev.roku_password.is_empty()).then(|| config.dev.roku_password.clone());
|
||||
let key_mode = RokuKeyMode::from_config(
|
||||
&config.remote.roku_key_mode,
|
||||
config.remote.roku_press_duration_ms,
|
||||
);
|
||||
Self {
|
||||
roku: RokuAdapter::with_dev_credentials(username, password),
|
||||
roku: RokuAdapter::with_config(username, password, key_mode),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,10 +215,8 @@ impl AdapterRegistry {
|
||||
|
||||
/// Discover candidate devices for one platform.
|
||||
pub async fn discover(&self, platform: &str) -> anyhow::Result<Vec<DeviceInfo>> {
|
||||
match platform {
|
||||
"roku" => Ok(self.roku.discover().await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
self.with_platform(platform, |roku| Box::pin(roku.discover()))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Return true when a platform is supported.
|
||||
@@ -226,83 +231,97 @@ impl AdapterRegistry {
|
||||
address: IpAddr,
|
||||
port: Option<u16>,
|
||||
) -> anyhow::Result<DeviceInfo> {
|
||||
match platform {
|
||||
"roku" => Ok(self
|
||||
.roku
|
||||
.probe_device(address, port.unwrap_or(8060))
|
||||
.await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
self.with_platform(platform, |roku| {
|
||||
Box::pin(roku.probe_device(address, port.unwrap_or(8060)))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Return apps from a concrete device using its platform adapter.
|
||||
pub async fn list_apps(&self, device: &Device) -> anyhow::Result<Vec<AppInfo>> {
|
||||
match device.platform.as_str() {
|
||||
"roku" => Ok(self.roku.list_apps(device).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
self.with_device(device, |roku, device| Box::pin(roku.list_apps(device)))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetch the current state for a concrete device.
|
||||
pub async fn state(&self, device: &Device) -> anyhow::Result<DeviceState> {
|
||||
match device.platform.as_str() {
|
||||
"roku" => Ok(self.roku.state(device).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
self.with_device(device, |roku, device| Box::pin(roku.state(device)))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Launch an app on a concrete device.
|
||||
pub async fn launch(&self, device: &Device, app: &str) -> anyhow::Result<()> {
|
||||
match device.platform.as_str() {
|
||||
"roku" => Ok(self.roku.launch(device, app).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
let app = app.to_string();
|
||||
self.with_device(device, move |roku, device| {
|
||||
Box::pin(async move { roku.launch(device, &app).await })
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Stop the currently running app on a concrete device.
|
||||
pub async fn stop_app(&self, device: &Device) -> anyhow::Result<()> {
|
||||
match device.platform.as_str() {
|
||||
"roku" => Ok(self.roku.stop_app(device).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
self.with_device(device, |roku, device| Box::pin(roku.stop_app(device)))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send a single normalized key to a concrete device.
|
||||
pub async fn key(&self, device: &Device, key: TvKey) -> anyhow::Result<()> {
|
||||
match device.platform.as_str() {
|
||||
"roku" => Ok(self.roku.key(device, key).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
self.with_device(device, |roku, device| Box::pin(roku.key(device, key)))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send a normalized key sequence to a concrete device.
|
||||
pub async fn sequence(&self, device: &Device, keys: Vec<TvKey>) -> anyhow::Result<()> {
|
||||
match device.platform.as_str() {
|
||||
"roku" => Ok(self.roku.sequence(device, keys).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
self.with_device(device, |roku, device| Box::pin(roku.sequence(device, keys)))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Install a development package on a concrete device.
|
||||
pub async fn dev_install(&self, device: &Device, zip: &[u8]) -> anyhow::Result<()> {
|
||||
match device.platform.as_str() {
|
||||
"roku" => Ok(self.roku.dev_install(device, zip).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
let zip = zip.to_vec();
|
||||
self.with_device(device, move |roku, device| {
|
||||
Box::pin(async move { roku.dev_install(device, &zip).await })
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Reload the active development package on a concrete device.
|
||||
pub async fn dev_reload(&self, device: &Device) -> anyhow::Result<()> {
|
||||
match device.platform.as_str() {
|
||||
"roku" => Ok(self.roku.dev_reload(device).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
self.with_device(device, |roku, device| Box::pin(roku.dev_reload(device)))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetch development logs from a concrete device.
|
||||
pub async fn dev_logs(&self, device: &Device) -> anyhow::Result<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() {
|
||||
"roku" => Ok(self.roku.dev_logs(device).await?),
|
||||
"roku" => Ok(call(&self.roku, device).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user