use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use super::database::Database; /// A catalog source that can be synced to discover available AppImages. #[derive(Debug, Clone)] pub struct CatalogSource { pub id: Option, pub name: String, pub url: String, pub source_type: CatalogType, pub enabled: bool, pub last_synced: Option, pub app_count: i32, } #[derive(Debug, Clone, PartialEq)] pub enum CatalogType { AppImageHub, GitHubSearch, Custom, } impl CatalogType { pub fn as_str(&self) -> &str { match self { Self::AppImageHub => "appimage-hub", Self::GitHubSearch => "github-search", Self::Custom => "custom", } } pub fn from_str(s: &str) -> Self { match s { "appimage-hub" => Self::AppImageHub, "github-search" => Self::GitHubSearch, _ => Self::Custom, } } } /// An app entry from a catalog source. #[derive(Debug, Clone)] pub struct CatalogApp { pub name: String, pub description: Option, pub categories: Vec, pub latest_version: Option, pub download_url: String, pub icon_url: Option, pub homepage: Option, pub file_size: Option, pub architecture: Option, } /// Default AppImageHub registry URL. const APPIMAGEHUB_API_URL: &str = "https://appimage.github.io/feed.json"; /// Sync a catalog source - fetch the index and store entries in the database. pub fn sync_catalog(db: &Database, source: &CatalogSource) -> Result { let apps = match source.source_type { CatalogType::AppImageHub => fetch_appimage_hub()?, CatalogType::Custom => fetch_custom_catalog(&source.url)?, CatalogType::GitHubSearch => { // GitHub search requires a token and is more complex - stub for now log::warn!("GitHub catalog search not yet implemented"); Vec::new() } }; let source_id = source.id.ok_or(CatalogError::NoSourceId)?; let mut count = 0u32; for app in &apps { db.insert_catalog_app( source_id, &app.name, app.description.as_deref(), Some(&app.categories.join(", ")), app.latest_version.as_deref(), &app.download_url, app.icon_url.as_deref(), app.homepage.as_deref(), app.file_size.map(|s| s as i64), app.architecture.as_deref(), ).ok(); count += 1; } db.update_catalog_source_sync(source_id, count as i32).ok(); Ok(count) } /// Download an AppImage from the catalog to a local directory. pub fn install_from_catalog(app: &CatalogApp, install_dir: &Path) -> Result { fs::create_dir_all(install_dir).map_err(|e| CatalogError::Io(e.to_string()))?; // Derive filename from URL let filename = app.download_url .rsplit('/') .next() .unwrap_or("downloaded.AppImage"); let dest = install_dir.join(filename); log::info!("Downloading {} to {}", app.download_url, dest.display()); let response = ureq::get(&app.download_url) .call() .map_err(|e| CatalogError::Network(e.to_string()))?; let mut file = fs::File::create(&dest) .map_err(|e| CatalogError::Io(e.to_string()))?; let mut reader = response.into_body().into_reader(); let mut buf = [0u8; 65536]; loop { let n = reader.read(&mut buf) .map_err(|e| CatalogError::Network(e.to_string()))?; if n == 0 { break; } file.write_all(&buf[..n]) .map_err(|e| CatalogError::Io(e.to_string()))?; } // Set executable permission #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = fs::Permissions::from_mode(0o755); fs::set_permissions(&dest, perms) .map_err(|e| CatalogError::Io(e.to_string()))?; } Ok(dest) } /// Fetch the AppImageHub feed and parse it into CatalogApp entries. fn fetch_appimage_hub() -> Result, CatalogError> { let response = ureq::get(APPIMAGEHUB_API_URL) .call() .map_err(|e| CatalogError::Network(format!("AppImageHub fetch failed: {}", e)))?; let body = response.into_body().read_to_string() .map_err(|e| CatalogError::Network(e.to_string()))?; let feed: AppImageHubFeed = serde_json::from_str(&body) .map_err(|e| CatalogError::Parse(format!("AppImageHub JSON parse failed: {}", e)))?; let apps: Vec = feed.items.into_iter().filter_map(|item| { // AppImageHub items need at least a name and a link let name = item.name?; let download_url = item.links.unwrap_or_default().into_iter() .find(|l| l.r#type == "Download") .map(|l| l.url)?; Some(CatalogApp { name, description: item.description, categories: item.categories.unwrap_or_default().into_iter().flatten().collect(), latest_version: None, download_url, icon_url: item.icons.and_then(|icons| icons.into_iter().flatten().next()), homepage: item.authors.and_then(|a| { let first = a.into_iter().next()?; if let Some(ref author_name) = first.name { log::debug!("Catalog app author: {}", author_name); } first.url }), file_size: None, architecture: None, }) }).collect(); Ok(apps) } /// Fetch a custom catalog from a URL (expects a JSON array of CatalogApp-like objects). fn fetch_custom_catalog(url: &str) -> Result, CatalogError> { let response = ureq::get(url) .call() .map_err(|e| CatalogError::Network(e.to_string()))?; let body = response.into_body().read_to_string() .map_err(|e| CatalogError::Network(e.to_string()))?; let items: Vec = serde_json::from_str(&body) .map_err(|e| CatalogError::Parse(e.to_string()))?; Ok(items.into_iter().map(|item| CatalogApp { name: item.name, description: item.description, categories: item.categories.unwrap_or_default(), latest_version: item.version, download_url: item.download_url, icon_url: item.icon_url, homepage: item.homepage, file_size: item.file_size, architecture: item.architecture, }).collect()) } /// Ensure the default AppImageHub source exists in the database. pub fn ensure_default_sources(db: &Database) { db.upsert_catalog_source( "AppImageHub", APPIMAGEHUB_API_URL, "appimage-hub", ).ok(); } /// Get all catalog sources from the database. pub fn get_sources(db: &Database) -> Vec { let records = db.get_catalog_sources().unwrap_or_default(); records.into_iter().map(|r| CatalogSource { id: Some(r.id), name: r.name, url: r.url, source_type: CatalogType::from_str(&r.source_type), enabled: r.enabled, last_synced: r.last_synced, app_count: r.app_count, }).collect() } // --- AppImageHub feed format --- #[derive(Debug, serde::Deserialize)] struct AppImageHubFeed { items: Vec, } #[derive(Debug, serde::Deserialize)] struct AppImageHubItem { name: Option, description: Option, categories: Option>>, authors: Option>, links: Option>, icons: Option>>, } #[derive(Debug, serde::Deserialize)] struct AppImageHubAuthor { name: Option, url: Option, } #[derive(Debug, serde::Deserialize)] struct AppImageHubLink { r#type: String, url: String, } // --- Custom catalog entry format --- #[derive(Debug, serde::Deserialize)] struct CustomCatalogEntry { name: String, description: Option, categories: Option>, version: Option, download_url: String, icon_url: Option, homepage: Option, file_size: Option, architecture: Option, } // --- Error types --- #[derive(Debug)] pub enum CatalogError { Network(String), Parse(String), Io(String), NoSourceId, } impl std::fmt::Display for CatalogError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Network(e) => write!(f, "Network error: {}", e), Self::Parse(e) => write!(f, "Parse error: {}", e), Self::Io(e) => write!(f, "I/O error: {}", e), Self::NoSourceId => write!(f, "Catalog source has no ID"), } } } use std::io::Read; #[cfg(test)] mod tests { use super::*; #[test] fn test_catalog_type_roundtrip() { assert_eq!(CatalogType::from_str("appimage-hub"), CatalogType::AppImageHub); assert_eq!(CatalogType::from_str("github-search"), CatalogType::GitHubSearch); assert_eq!(CatalogType::from_str("custom"), CatalogType::Custom); assert_eq!(CatalogType::from_str("unknown"), CatalogType::Custom); } #[test] fn test_catalog_type_as_str() { assert_eq!(CatalogType::AppImageHub.as_str(), "appimage-hub"); assert_eq!(CatalogType::GitHubSearch.as_str(), "github-search"); assert_eq!(CatalogType::Custom.as_str(), "custom"); } #[test] fn test_catalog_error_display() { let err = CatalogError::Network("timeout".to_string()); assert!(format!("{}", err).contains("timeout")); let err = CatalogError::NoSourceId; assert!(format!("{}", err).contains("no ID")); } #[test] fn test_ensure_default_sources() { let db = crate::core::database::Database::open_in_memory().unwrap(); ensure_default_sources(&db); let sources = get_sources(&db); assert_eq!(sources.len(), 1); assert_eq!(sources[0].name, "AppImageHub"); assert_eq!(sources[0].source_type, CatalogType::AppImageHub); } #[test] fn test_search_catalog_empty() { let db = crate::core::database::Database::open_in_memory().unwrap(); let results = db.search_catalog_apps("firefox").unwrap(); assert!(results.is_empty()); } #[test] fn test_get_sources_empty() { let db = crate::core::database::Database::open_in_memory().unwrap(); let sources = get_sources(&db); assert!(sources.is_empty()); } }