Files
tvctl/src/daemon/registry.rs
T
44r0n7 795aa2f713 refactor: harden internal daemon entrypoint and cleanup observations
Remove the internal daemon subcommand from the public CLI surface,
start the daemon via an internal env trigger, and ensure generated
completions/help never expose internal entrypoints.

Also finish the pending observation cleanups and docs updates,
including config/key deduplication, registry matching cleanup, and
remaining roadmap/project map staleness fixes.
2026-04-18 11:55:18 -04:00

474 lines
16 KiB
Rust

use std::{net::IpAddr, path::PathBuf};
use anyhow::Context;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tokio::fs;
use uuid::Uuid;
use crate::{
adapters::{
AppInfo, Device, DeviceInfo, DeviceState, TvAdapter, TvKey,
roku::{RokuAdapter, RokuKeyMode},
},
daemon::config::TvctlConfig,
};
/// The persisted collection of known devices.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceRegistry {
path: PathBuf,
/// All devices currently remembered by the daemon.
pub devices: Vec<Device>,
}
impl DeviceRegistry {
/// Load the registry from disk or return an empty registry when absent.
pub async fn load(path: PathBuf) -> anyhow::Result<Self> {
let devices = match fs::read_to_string(&path).await {
Ok(contents) => serde_json::from_str::<Vec<Device>>(&contents).with_context(|| {
format!("failed to parse device registry at {}", path.display())
})?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(error.into()),
};
Ok(Self { path, devices })
}
/// Persist the current registry to disk.
pub async fn save(&self) -> anyhow::Result<()> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).await?;
}
let contents = serde_json::to_string_pretty(&self.devices)?;
fs::write(&self.path, contents).await?;
Ok(())
}
/// Return all known devices.
pub fn list(&self) -> &[Device] {
&self.devices
}
/// Upsert discovered devices, preserving UUIDs for known entries.
pub fn merge_discovered(&mut self, discovered: Vec<DeviceInfo>) -> Vec<Device> {
discovered
.into_iter()
.map(|info| self.upsert_device(info, None))
.collect()
}
/// Add or update a manually specified device after it has been probed.
pub fn merge_manual(&mut self, info: DeviceInfo, name: Option<String>) -> Device {
self.upsert_device(info, name)
}
/// Set the default device by UUID or name.
pub fn set_default(&mut self, target: &str) -> Option<Device> {
let selected = self.find(target)?.id;
let mut selected_device = None;
for device in &mut self.devices {
let is_match = device.id == selected;
device.is_default = is_match;
if is_match {
selected_device = Some(device.clone());
}
}
selected_device
}
/// Find a device by UUID or case-insensitive name.
pub fn find(&self, target: &str) -> Option<&Device> {
let target_uuid = Uuid::parse_str(target).ok();
let normalized = target.to_ascii_lowercase();
self.devices.iter().find(|device| {
target_uuid.map(|uuid| device.id == uuid).unwrap_or(false)
|| device.name.to_ascii_lowercase() == normalized
})
}
/// Return the current default device, if any.
pub fn default_device(&self) -> Option<&Device> {
self.devices.iter().find(|device| device.is_default)
}
/// Remove a device by UUID or case-insensitive name.
pub fn remove(&mut self, target: &str) -> Option<Device> {
let id = self.find(target)?.id;
let index = self.devices.iter().position(|device| device.id == id)?;
let removed = self.devices.remove(index);
self.ensure_default();
Some(removed)
}
/// Ensure the registry's default marker is valid and singular.
pub fn ensure_default(&mut self) {
if self.devices.is_empty() {
return;
}
let Some(default_index) = self.devices.iter().position(|device| device.is_default) else {
self.devices[0].is_default = true;
return;
};
for (index, device) in self.devices.iter_mut().enumerate() {
device.is_default = index == default_index;
}
}
fn upsert_device(&mut self, info: DeviceInfo, name: Option<String>) -> Device {
let DeviceInfo {
name: original_name,
platform,
address,
port,
} = info;
let now = Utc::now();
if let Some(device) = self.find_platform_address_mut(&platform, address) {
device.port = port;
device.original_name = original_name.clone();
if let Some(name) = name {
device.name = name;
}
device.last_seen = now;
return device.clone();
}
let is_default = self.devices.is_empty();
let device = Device {
id: Uuid::new_v4(),
name: name.unwrap_or_else(|| original_name.clone()),
original_name,
platform,
address,
port,
is_default,
discovered_at: now,
last_seen: now,
};
self.devices.push(device.clone());
self.ensure_default();
device
}
fn find_platform_address_mut(
&mut self,
platform: &str,
address: IpAddr,
) -> Option<&mut Device> {
self.devices
.iter_mut()
.find(|device| device.platform == platform && device.address == address)
}
}
impl Default for DeviceRegistry {
fn default() -> Self {
Self {
path: PathBuf::from("devices.json"),
devices: Vec::new(),
}
}
}
/// A registry of platform adapters available to the daemon.
#[derive(Debug, Clone, Default)]
pub struct AdapterRegistry {
roku: RokuAdapter,
}
impl AdapterRegistry {
/// Build the adapter registry from the loaded daemon config.
pub fn from_config(config: &TvctlConfig) -> Self {
let username =
(!config.dev.roku_username.is_empty()).then(|| config.dev.roku_username.clone());
let password =
(!config.dev.roku_password.is_empty()).then(|| config.dev.roku_password.clone());
let key_mode = RokuKeyMode::from_config(
&config.remote.roku_key_mode,
config.remote.roku_press_duration_ms,
);
Self {
roku: RokuAdapter::with_config(username, password, key_mode),
}
}
/// Return the supported platform names.
pub fn supported_platforms(&self) -> Vec<&'static str> {
vec!["roku"]
}
/// Discover candidate devices for one platform.
pub async fn discover(&self, platform: &str) -> anyhow::Result<Vec<DeviceInfo>> {
self.with_platform(platform, |roku| Box::pin(roku.discover()))
.await
}
/// Return true when a platform is supported.
pub fn supports(&self, platform: &str) -> bool {
self.supported_platforms().contains(&platform)
}
/// Probe a manually specified device to verify it matches the requested platform.
pub async fn probe_manual(
&self,
platform: &str,
address: IpAddr,
port: Option<u16>,
) -> anyhow::Result<DeviceInfo> {
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>> {
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> {
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<()> {
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<()> {
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<()> {
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<()> {
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<()> {
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<()> {
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>> + Send + '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>> + Send + 'a>,
>,
{
match device.platform.as_str() {
"roku" => Ok(call(&self.roku, device).await?),
other => anyhow::bail!("unsupported platform '{other}'"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn registry_round_trips_and_preserves_ids() {
let temp_dir = tempfile::tempdir().expect("temp dir should exist");
let path = temp_dir.path().join("devices.json");
let mut registry = DeviceRegistry::load(path.clone())
.await
.expect("registry should load");
let first = registry.merge_discovered(vec![DeviceInfo {
name: "Living Room".to_string(),
platform: "roku".to_string(),
address: "10.0.0.5".parse().expect("valid ip"),
port: 8060,
}]);
let first_id = first[0].id;
registry.save().await.expect("registry should save");
let mut loaded = DeviceRegistry::load(path)
.await
.expect("registry should reload");
let second = loaded.merge_discovered(vec![DeviceInfo {
name: "Living Room Roku".to_string(),
platform: "roku".to_string(),
address: "10.0.0.5".parse().expect("valid ip"),
port: 8060,
}]);
assert_eq!(second[0].id, first_id);
assert_eq!(second[0].original_name, "Living Room Roku");
}
#[test]
fn set_default_matches_case_insensitive_names() {
let now = Utc::now();
let mut registry = DeviceRegistry {
path: PathBuf::from("devices.json"),
devices: vec![
Device {
id: Uuid::new_v4(),
name: "Living Room".to_string(),
original_name: "Living Room".to_string(),
platform: "roku".to_string(),
address: "10.0.0.5".parse().expect("valid ip"),
port: 8060,
is_default: false,
discovered_at: now,
last_seen: now,
},
Device {
id: Uuid::new_v4(),
name: "Bedroom".to_string(),
original_name: "Bedroom".to_string(),
platform: "roku".to_string(),
address: "10.0.0.6".parse().expect("valid ip"),
port: 8060,
is_default: false,
discovered_at: now,
last_seen: now,
},
],
};
let selected = registry
.set_default("living room")
.expect("device should exist");
assert_eq!(selected.name, "Living Room");
assert_eq!(
registry.default_device().map(|device| device.name.as_str()),
Some("Living Room")
);
}
#[test]
fn merge_manual_updates_existing_device_without_replacing_id() {
let now = Utc::now();
let id = Uuid::new_v4();
let mut registry = DeviceRegistry {
path: PathBuf::from("devices.json"),
devices: vec![Device {
id,
name: "Office".to_string(),
original_name: "Office Roku".to_string(),
platform: "roku".to_string(),
address: "10.0.0.9".parse().expect("valid ip"),
port: 8060,
is_default: true,
discovered_at: now,
last_seen: now,
}],
};
let merged = registry.merge_manual(
DeviceInfo {
name: "Upstairs Roku".to_string(),
platform: "roku".to_string(),
address: "10.0.0.9".parse().expect("valid ip"),
port: 8061,
},
Some("Bedroom".to_string()),
);
assert_eq!(merged.id, id);
assert_eq!(merged.name, "Bedroom");
assert_eq!(merged.original_name, "Upstairs Roku");
assert_eq!(merged.port, 8061);
}
#[test]
fn remove_promotes_another_default_when_needed() {
let now = Utc::now();
let living_room_id = Uuid::new_v4();
let mut registry = DeviceRegistry {
path: PathBuf::from("devices.json"),
devices: vec![
Device {
id: living_room_id,
name: "Living Room".to_string(),
original_name: "Living Room".to_string(),
platform: "roku".to_string(),
address: "10.0.0.5".parse().expect("valid ip"),
port: 8060,
is_default: true,
discovered_at: now,
last_seen: now,
},
Device {
id: Uuid::new_v4(),
name: "Bedroom".to_string(),
original_name: "Bedroom".to_string(),
platform: "roku".to_string(),
address: "10.0.0.6".parse().expect("valid ip"),
port: 8060,
is_default: false,
discovered_at: now,
last_seen: now,
},
],
};
let removed = registry
.remove(&living_room_id.to_string())
.expect("device should be removed");
assert_eq!(removed.name, "Living Room");
assert_eq!(
registry.default_device().map(|device| device.name.as_str()),
Some("Bedroom")
);
}
}