347 lines
10 KiB
Rust
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());
|
|
}
|
|
}
|