fix: improve CLI behavior from testing feedback
Stop a running daemon during uninstall, fuzzy-match cached app names, and pace remote sequences with a configurable delay so Roku secret screen sequences behave more reliably.
This commit is contained in:
+9
-1
@@ -25,6 +25,7 @@ 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;
|
||||||
|
|
||||||
/// The tvctl command-line interface.
|
/// The tvctl command-line interface.
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
@@ -182,6 +183,9 @@ pub enum RemoteCommand {
|
|||||||
Sequence {
|
Sequence {
|
||||||
/// Key names such as `home down select`.
|
/// Key names such as `home down select`.
|
||||||
keys: Vec<String>,
|
keys: Vec<String>,
|
||||||
|
/// Delay between keys in milliseconds.
|
||||||
|
#[arg(long, default_value_t = DEFAULT_REMOTE_SEQUENCE_DELAY_MS)]
|
||||||
|
delay_ms: u64,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,7 +454,7 @@ async fn handle_remote_command(cli: &Cli, command: RemoteCommand) -> Result<(),
|
|||||||
let result: ActionResult = parse_response_data(response)?;
|
let result: ActionResult = parse_response_data(response)?;
|
||||||
render(cli, &result, || result.detail.clone())
|
render(cli, &result, || result.detail.clone())
|
||||||
}
|
}
|
||||||
RemoteCommand::Sequence { keys } => {
|
RemoteCommand::Sequence { keys, delay_ms } => {
|
||||||
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 sequence`.",
|
"At least one key is required for `tvctl remote sequence`.",
|
||||||
@@ -466,6 +470,7 @@ 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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -730,6 +735,9 @@ async fn daemon_install(cli: &Cli) -> Result<(), CliError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 _ = run_systemctl(&["--user", "disable", "--now", "tvctld.service"]).await;
|
||||||
match fs::remove_file(&unit_path).await {
|
match fs::remove_file(&unit_path).await {
|
||||||
|
|||||||
+95
-7
@@ -1,4 +1,4 @@
|
|||||||
use std::{collections::BTreeMap, path::PathBuf};
|
use std::{cmp::Reverse, collections::BTreeMap, path::PathBuf};
|
||||||
|
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
@@ -72,12 +72,20 @@ 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<Option<AppInfo>> {
|
||||||
let cache = self.load_platform(platform).await?;
|
let cache = self.load_platform(platform).await?;
|
||||||
let normalized = query.to_ascii_lowercase();
|
let normalized = normalize_query(query);
|
||||||
Ok(cache.apps.into_iter().find(|app| {
|
|
||||||
app.platform_id == query
|
if let Some(app) = cache.apps.iter().find(|app| {
|
||||||
|| app.id == query
|
app.platform_id == query || app.id == query || normalize_query(&app.name) == normalized
|
||||||
|| app.name.to_ascii_lowercase() == normalized
|
}) {
|
||||||
}))
|
return Ok(Some(app.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(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))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove the persisted app cache for a platform.
|
/// Remove the persisted app cache for a platform.
|
||||||
@@ -94,6 +102,52 @@ impl AppCacheStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_query(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.filter(|character| character.is_ascii_alphanumeric())
|
||||||
|
.flat_map(char::to_lowercase)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fuzzy_match_score(query: &str, app: &AppInfo) -> Option<u8> {
|
||||||
|
if query.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = normalize_query(&app.name);
|
||||||
|
let id = normalize_query(&app.id);
|
||||||
|
let platform_id = normalize_query(&app.platform_id);
|
||||||
|
|
||||||
|
if name.starts_with(query) || id.starts_with(query) || platform_id.starts_with(query) {
|
||||||
|
return Some(3);
|
||||||
|
}
|
||||||
|
if name.contains(query) || id.contains(query) || platform_id.contains(query) {
|
||||||
|
return Some(2);
|
||||||
|
}
|
||||||
|
if is_subsequence(query, &name) {
|
||||||
|
return Some(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_subsequence(needle: &str, haystack: &str) -> bool {
|
||||||
|
let mut needle_chars = needle.chars();
|
||||||
|
let mut current = needle_chars.next();
|
||||||
|
|
||||||
|
for hay in haystack.chars() {
|
||||||
|
if current == Some(hay) {
|
||||||
|
current = needle_chars.next();
|
||||||
|
if current.is_none() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -192,4 +246,38 @@ mod tests {
|
|||||||
let loaded = store.load_platform("roku").await.expect("apps should load");
|
let loaded = store.load_platform("roku").await.expect("apps should load");
|
||||||
assert!(loaded.apps.is_empty());
|
assert!(loaded.apps.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn find_app_supports_fuzzy_name_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: "12".to_string(),
|
||||||
|
name: "Netflix".to_string(),
|
||||||
|
version: None,
|
||||||
|
platform_id: "12".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("apps should save");
|
||||||
|
|
||||||
|
let resolved = store
|
||||||
|
.find_app("roku", "jelly")
|
||||||
|
.await
|
||||||
|
.expect("lookup should work")
|
||||||
|
.expect("jellyfin should resolve");
|
||||||
|
assert_eq!(resolved.name, "Jellyfin");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ pub enum DaemonRequest {
|
|||||||
device: Option<String>,
|
device: Option<String>,
|
||||||
/// Normalized key identifiers.
|
/// Normalized key identifiers.
|
||||||
keys: Vec<TvKey>,
|
keys: Vec<TvKey>,
|
||||||
|
/// Delay between keys in milliseconds.
|
||||||
|
delay_ms: u64,
|
||||||
},
|
},
|
||||||
/// Install a dev package from a local zip path on one device.
|
/// Install a dev package from a local zip path on one device.
|
||||||
DevInstall {
|
DevInstall {
|
||||||
|
|||||||
+24
-4
@@ -25,7 +25,7 @@ use tokio::{
|
|||||||
io::{AsyncReadExt, AsyncWriteExt},
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
net::{UnixListener, UnixStream},
|
net::{UnixListener, UnixStream},
|
||||||
sync::Mutex,
|
sync::Mutex,
|
||||||
time::{self, MissedTickBehavior},
|
time::{self, MissedTickBehavior, sleep},
|
||||||
};
|
};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
@@ -596,14 +596,18 @@ async fn handle_request(
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DaemonRequest::SendSequence { device, keys } => {
|
DaemonRequest::SendSequence {
|
||||||
|
device,
|
||||||
|
keys,
|
||||||
|
delay_ms,
|
||||||
|
} => {
|
||||||
let guard = daemon.lock().await;
|
let guard = daemon.lock().await;
|
||||||
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,
|
||||||
Err(response) => return (response, false),
|
Err(response) => return (response, false),
|
||||||
};
|
};
|
||||||
let detail = format!("Sent {} key(s).", keys.len());
|
let detail = format!("Sent {} key(s) with {} ms spacing.", keys.len(), delay_ms);
|
||||||
match guard.adapters.sequence(&device, keys).await {
|
match send_key_sequence(&guard.adapters, &device, keys, delay_ms).await {
|
||||||
Ok(()) => (
|
Ok(()) => (
|
||||||
DaemonResponse::success(ActionResult { device, detail }),
|
DaemonResponse::success(ActionResult { device, detail }),
|
||||||
false,
|
false,
|
||||||
@@ -759,6 +763,22 @@ async fn handle_request(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn send_key_sequence(
|
||||||
|
adapters: &AdapterRegistry,
|
||||||
|
device: &Device,
|
||||||
|
keys: Vec<crate::adapters::TvKey>,
|
||||||
|
delay_ms: u64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut pending = keys.into_iter().peekable();
|
||||||
|
while let Some(key) = pending.next() {
|
||||||
|
adapters.key(device, key).await?;
|
||||||
|
if delay_ms > 0 && pending.peek().is_some() {
|
||||||
|
sleep(Duration::from_millis(delay_ms)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
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?;
|
||||||
|
|||||||
Reference in New Issue
Block a user