Add Phase 5 enhancements: security, i18n, analysis, backup, notifications
This commit is contained in:
364
src/core/catalog.rs
Normal file
364
src/core/catalog.rs
Normal file
@@ -0,0 +1,364 @@
|
||||
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)
|
||||
}
|
||||
|
||||
/// Search the local catalog database for apps matching a query.
|
||||
pub fn search_catalog(db: &Database, query: &str) -> Vec<CatalogApp> {
|
||||
let records = db.search_catalog_apps(query).unwrap_or_default();
|
||||
records.into_iter().map(|r| CatalogApp {
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
categories: r.categories
|
||||
.map(|c| c.split(", ").map(String::from).collect())
|
||||
.unwrap_or_default(),
|
||||
latest_version: r.latest_version,
|
||||
download_url: r.download_url,
|
||||
icon_url: r.icon_url,
|
||||
homepage: r.homepage,
|
||||
file_size: r.file_size.map(|s| s as u64),
|
||||
architecture: r.architecture,
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// 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.into_iter()
|
||||
.find(|l| l.r#type == "Download")
|
||||
.map(|l| l.url)?;
|
||||
|
||||
Some(CatalogApp {
|
||||
name,
|
||||
description: item.description,
|
||||
categories: item.categories.unwrap_or_default(),
|
||||
latest_version: None,
|
||||
download_url,
|
||||
icon_url: item.icons.and_then(|icons| icons.into_iter().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<String>>,
|
||||
authors: Option<Vec<AppImageHubAuthor>>,
|
||||
links: Vec<AppImageHubLink>,
|
||||
icons: Option<Vec<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 = search_catalog(&db, "firefox");
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user