Files
driftwood/src/core/catalog.rs

347 lines
10 KiB
Rust

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<i64>,
pub name: String,
pub url: String,
pub source_type: CatalogType,
pub enabled: bool,
pub last_synced: Option<String>,
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<String>,
pub categories: Vec<String>,
pub latest_version: Option<String>,
pub download_url: String,
pub icon_url: Option<String>,
pub homepage: Option<String>,
pub file_size: Option<u64>,
pub architecture: Option<String>,
}
/// 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<u32, CatalogError> {
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<PathBuf, CatalogError> {
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<Vec<CatalogApp>, 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<CatalogApp> = 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<Vec<CatalogApp>, 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<CustomCatalogEntry> = 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<CatalogSource> {
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<AppImageHubItem>,
}
#[derive(Debug, serde::Deserialize)]
struct AppImageHubItem {
name: Option<String>,
description: Option<String>,
categories: Option<Vec<Option<String>>>,
authors: Option<Vec<AppImageHubAuthor>>,
links: Option<Vec<AppImageHubLink>>,
icons: Option<Vec<Option<String>>>,
}
#[derive(Debug, serde::Deserialize)]
struct AppImageHubAuthor {
name: Option<String>,
url: Option<String>,
}
#[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<String>,
categories: Option<Vec<String>>,
version: Option<String>,
download_url: String,
icon_url: Option<String>,
homepage: Option<String>,
file_size: Option<u64>,
architecture: Option<String>,
}
// --- 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());
}
}