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
+185
View File
@@ -1,3 +1,7 @@
use std::{collections::BTreeMap, path::PathBuf};
use tokio::fs;
use crate::adapters::AppInfo;
/// A platform-level cache of app metadata discovered from live devices.
@@ -8,3 +12,184 @@ pub struct AppCache {
/// The apps currently known for that platform.
pub apps: Vec<AppInfo>,
}
/// Persisted store for per-platform app cache files.
#[derive(Debug, Clone)]
pub struct AppCacheStore {
root_dir: PathBuf,
}
impl AppCacheStore {
/// Create a cache store rooted at the given directory.
pub fn new(root_dir: PathBuf) -> Self {
Self { root_dir }
}
/// Load the app cache for a single platform or return an empty cache when absent.
pub async fn load_platform(&self, platform: &str) -> anyhow::Result<AppCache> {
let path = self.platform_path(platform);
let apps = match fs::read_to_string(&path).await {
Ok(contents) => serde_json::from_str::<Vec<AppInfo>>(&contents)?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(error.into()),
};
Ok(AppCache {
platform: platform.to_string(),
apps,
})
}
/// Persist the app list for a platform.
pub async fn save_platform(&self, platform: &str, apps: &[AppInfo]) -> anyhow::Result<()> {
fs::create_dir_all(&self.root_dir).await?;
let contents = serde_json::to_string_pretty(apps)?;
fs::write(self.platform_path(platform), contents).await?;
Ok(())
}
/// Merge newly seen apps into the normalized, de-duplicated platform cache.
pub async fn record_platform_apps(
&self,
platform: &str,
apps: Vec<AppInfo>,
) -> anyhow::Result<AppCache> {
let mut deduped = BTreeMap::new();
for app in self.load_platform(platform).await?.apps {
deduped.insert(app.platform_id.clone(), app);
}
for app in apps {
deduped.insert(app.platform_id.clone(), app);
}
let apps: Vec<AppInfo> = deduped.into_values().collect();
self.save_platform(platform, &apps).await?;
Ok(AppCache {
platform: platform.to_string(),
apps,
})
}
/// 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>> {
let cache = self.load_platform(platform).await?;
let normalized = query.to_ascii_lowercase();
Ok(cache.apps.into_iter().find(|app| {
app.platform_id == query
|| app.id == query
|| app.name.to_ascii_lowercase() == normalized
}))
}
/// Remove the persisted app cache for a platform.
pub async fn clear_platform(&self, platform: &str) -> anyhow::Result<()> {
match fs::remove_file(self.platform_path(platform)).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
fn platform_path(&self, platform: &str) -> PathBuf {
self.root_dir.join(format!("{platform}.apps.json"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn cache_round_trips_and_resolves_names() {
let temp_dir = tempfile::tempdir().expect("temp dir should exist");
let store = AppCacheStore::new(temp_dir.path().join("cache"));
let apps = vec![
AppInfo {
id: "12".to_string(),
name: "Netflix".to_string(),
version: None,
platform_id: "12".to_string(),
},
AppInfo {
id: "837".to_string(),
name: "YouTube".to_string(),
version: None,
platform_id: "837".to_string(),
},
];
store
.record_platform_apps("roku", apps.clone())
.await
.expect("apps should save");
let loaded = store.load_platform("roku").await.expect("apps should load");
assert_eq!(loaded.apps, apps);
let resolved = store
.find_app("roku", "youtube")
.await
.expect("app lookup should work")
.expect("youtube should exist");
assert_eq!(resolved.platform_id, "837");
}
#[tokio::test]
async fn record_platform_apps_merges_existing_entries() {
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: "12".to_string(),
name: "Netflix".to_string(),
version: None,
platform_id: "12".to_string(),
}],
)
.await
.expect("first cache should save");
store
.record_platform_apps(
"roku",
vec![AppInfo {
id: "837".to_string(),
name: "YouTube".to_string(),
version: None,
platform_id: "837".to_string(),
}],
)
.await
.expect("second cache should merge");
let loaded = store.load_platform("roku").await.expect("apps should load");
assert_eq!(loaded.apps.len(), 2);
}
#[tokio::test]
async fn clear_platform_removes_persisted_cache() {
let temp_dir = tempfile::tempdir().expect("temp dir should exist");
let store = AppCacheStore::new(temp_dir.path().join("cache"));
store
.save_platform(
"roku",
&[AppInfo {
id: "12".to_string(),
name: "Netflix".to_string(),
version: None,
platform_id: "12".to_string(),
}],
)
.await
.expect("apps should save");
store
.clear_platform("roku")
.await
.expect("cache should clear");
let loaded = store.load_platform("roku").await.expect("apps should load");
assert!(loaded.apps.is_empty());
}
}