feat: complete daemon core milestone

Finish Milestone 3 with persisted config, socket IPC, registry CRUD,
periodic discovery, manual add, and app-cache refresh support.
This commit is contained in:
44r0n7
2026-04-14 10:19:14 -04:00
parent 642fa716d1
commit 29e53d16b0
14 changed files with 2176 additions and 46 deletions
+390 -2
View File
@@ -1,8 +1,396 @@
use crate::adapters::Device;
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::{Device, DeviceInfo, TvAdapter, roku::RokuAdapter};
/// The persisted collection of known devices.
#[derive(Debug, Clone, Default)]
#[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 index = self
.devices
.iter()
.position(|device| matches_target(device, target))?;
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)]
pub struct AdapterRegistry {
roku: RokuAdapter,
}
impl Default for AdapterRegistry {
fn default() -> Self {
Self {
roku: RokuAdapter::new(),
}
}
}
impl AdapterRegistry {
/// 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>> {
match platform {
"roku" => Ok(self.roku.discover().await?),
other => anyhow::bail!("unsupported platform '{other}'"),
}
}
/// 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> {
match platform {
"roku" => Ok(self
.roku
.probe_device(address, port.unwrap_or(8060))
.await?),
other => anyhow::bail!("unsupported platform '{other}'"),
}
}
/// Return apps from a concrete device using its platform adapter.
pub async fn list_apps(
&self,
device: &Device,
) -> anyhow::Result<Vec<crate::adapters::AppInfo>> {
match device.platform.as_str() {
"roku" => Ok(self.roku.list_apps(device).await?),
other => anyhow::bail!("unsupported platform '{other}'"),
}
}
}
fn matches_target(device: &Device, target: &str) -> bool {
let target_uuid = Uuid::parse_str(target).ok();
let normalized = target.to_ascii_lowercase();
target_uuid.map(|uuid| device.id == uuid).unwrap_or(false)
|| device.name.to_ascii_lowercase() == normalized
}
#[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")
);
}
}