diff --git a/data/app.driftwood.Driftwood.gschema.xml b/data/app.driftwood.Driftwood.gschema.xml
index d3a0b0b..805cf82 100644
--- a/data/app.driftwood.Driftwood.gschema.xml
+++ b/data/app.driftwood.Driftwood.gschema.xml
@@ -142,5 +142,15 @@
Watch removable media
Scan removable drives for AppImages when mounted.
+
+ ''
+ GitHub personal access token
+ Optional GitHub token for higher API rate limits (5,000 vs 60 requests per hour).
+
+
+ true
+ Auto-enrich catalog apps
+ Automatically fetch GitHub metadata (stars, version, downloads) for catalog apps in the background.
+
diff --git a/data/resources/style.css b/data/resources/style.css
index 8f9d95d..45f64c5 100644
--- a/data/resources/style.css
+++ b/data/resources/style.css
@@ -175,6 +175,53 @@ button.flat:not(.pill):not(.suggested-action):not(.destructive-action) {
min-height: 24px;
}
+/* ===== Catalog Tile Cards ===== */
+.catalog-tile {
+ border: 1px solid alpha(@window_fg_color, 0.12);
+ border-radius: 12px;
+}
+
+.catalog-tile:hover {
+ border-color: alpha(@accent_bg_color, 0.5);
+}
+
+/* ===== Featured Banner Cards ===== */
+.catalog-featured-card {
+ border-radius: 12px;
+ border: 1px solid alpha(@window_fg_color, 0.15);
+ padding: 0;
+}
+
+.catalog-featured-card:hover {
+ border-color: alpha(@accent_bg_color, 0.5);
+}
+
+/* Screenshot area inside featured card */
+.catalog-featured-screenshot {
+ border-radius: 11px 11px 0 0;
+ border: none;
+ background: alpha(@window_fg_color, 0.04);
+}
+
+.catalog-featured-screenshot picture {
+ border-radius: 11px 11px 0 0;
+}
+
+/* ===== Destructive Context Menu Item ===== */
+.destructive-context-item {
+ color: @error_fg_color;
+ background: alpha(@error_bg_color, 0.85);
+ border: none;
+ box-shadow: none;
+ padding: 6px 12px;
+ border-radius: 6px;
+ min-height: 28px;
+}
+
+.destructive-context-item:hover {
+ background: @error_bg_color;
+}
+
/* ===== Screenshot Lightbox ===== */
window.lightbox {
background-color: rgba(0, 0, 0, 0.92);
@@ -193,3 +240,75 @@ window.lightbox .lightbox-nav {
min-width: 48px;
min-height: 48px;
}
+
+/* ===== Catalog Tile Stats Row ===== */
+.catalog-stats-row {
+ font-size: 0.8em;
+ color: alpha(@window_fg_color, 0.7);
+}
+
+.catalog-stats-row image {
+ opacity: 0.65;
+}
+
+/* ===== Detail Page Stat Cards ===== */
+.stat-card {
+ background: alpha(@window_fg_color, 0.06);
+ border-radius: 12px;
+ padding: 14px 16px;
+ border: 1px solid alpha(@window_fg_color, 0.08);
+}
+
+.stat-card.stat-stars {
+ background: alpha(@warning_bg_color, 0.12);
+ border-color: alpha(@warning_bg_color, 0.2);
+}
+
+.stat-card.stat-stars image {
+ color: @warning_bg_color;
+ opacity: 0.85;
+}
+
+.stat-card.stat-version {
+ background: alpha(@accent_bg_color, 0.1);
+ border-color: alpha(@accent_bg_color, 0.18);
+}
+
+.stat-card.stat-version image {
+ color: @accent_bg_color;
+ opacity: 0.85;
+}
+
+.stat-card.stat-downloads {
+ background: alpha(@success_bg_color, 0.1);
+ border-color: alpha(@success_bg_color, 0.18);
+}
+
+.stat-card.stat-downloads image {
+ color: @success_bg_color;
+ opacity: 0.85;
+}
+
+.stat-card.stat-released {
+ background: alpha(@purple_3, 0.12);
+ border-color: alpha(@purple_3, 0.2);
+}
+
+.stat-card.stat-released image {
+ color: @purple_3;
+ opacity: 0.85;
+}
+
+.stat-card .stat-value {
+ font-weight: 700;
+ font-size: 1.15em;
+}
+
+.stat-card .stat-label {
+ font-size: 0.8em;
+ color: alpha(@window_fg_color, 0.6);
+}
+
+.stat-card image {
+ opacity: 0.55;
+}
diff --git a/src/core/catalog.rs b/src/core/catalog.rs
index ea2d9c4..822f95a 100644
--- a/src/core/catalog.rs
+++ b/src/core/catalog.rs
@@ -3,6 +3,7 @@ use std::io::Write;
use std::path::{Path, PathBuf};
use super::database::Database;
+use super::github_enrichment;
/// A catalog source that can be synced to discover available AppImages.
#[derive(Debug, Clone)]
@@ -53,27 +54,71 @@ pub struct CatalogApp {
pub homepage: Option,
pub file_size: Option,
pub architecture: Option,
+ pub screenshots: Vec,
+ pub license: Option,
+ /// GitHub link URL from the feed (e.g. "https://github.com/user/repo")
+ pub github_link: 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.
+/// Progress updates sent during catalog sync.
+#[derive(Debug, Clone)]
+pub enum SyncProgress {
+ /// Fetching the feed from the remote source.
+ FetchingFeed,
+ /// Feed fetched, total number of apps found.
+ FeedFetched { total: u32 },
+ /// Caching icon for an app.
+ CachingIcon { current: u32, total: u32, app_name: String },
+ /// Saving apps to the database.
+ SavingApps { current: u32, total: u32 },
+ /// Sync complete.
+ Done { total: u32 },
+}
+
pub fn sync_catalog(db: &Database, source: &CatalogSource) -> Result {
+ sync_catalog_with_progress(db, source, &|_| {})
+}
+
+pub fn sync_catalog_with_progress(
+ db: &Database,
+ source: &CatalogSource,
+ on_progress: &dyn Fn(SyncProgress),
+) -> Result {
+ on_progress(SyncProgress::FetchingFeed);
+
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 total = apps.len() as u32;
+ on_progress(SyncProgress::FeedFetched { total });
+
+ // Cache icons with progress reporting
+ let icon_count = cache_catalog_icons_with_progress(&apps, on_progress);
+ log::info!("Cached {} catalog icons", icon_count);
+
let source_id = source.id.ok_or(CatalogError::NoSourceId)?;
let mut count = 0u32;
for app in &apps {
+ count += 1;
+ on_progress(SyncProgress::SavingApps { current: count, total });
+
+ let screenshots_str = if app.screenshots.is_empty() {
+ None
+ } else {
+ Some(app.screenshots.join(";"))
+ };
+
db.insert_catalog_app(
source_id,
&app.name,
@@ -85,12 +130,25 @@ pub fn sync_catalog(db: &Database, source: &CatalogSource) -> Result Result, CatalogError> {
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()
+ let links = item.links.unwrap_or_default();
+ let download_url = links.iter()
.find(|l| l.r#type == "Download")
- .map(|l| l.url)?;
+ .map(|l| l.url.clone())?;
+
+ // Extract GitHub link from feed links
+ let github_link = links.iter()
+ .find(|l| l.r#type.to_lowercase().contains("github"))
+ .map(|l| l.url.clone());
Some(CatalogApp {
name,
@@ -172,6 +236,9 @@ fn fetch_appimage_hub() -> Result, CatalogError> {
}),
file_size: None,
architecture: None,
+ screenshots: item.screenshots.unwrap_or_default().into_iter().flatten().collect(),
+ license: item.license,
+ github_link,
})
}).collect();
@@ -200,6 +267,9 @@ fn fetch_custom_catalog(url: &str) -> Result, CatalogError> {
homepage: item.homepage,
file_size: item.file_size,
architecture: item.architecture,
+ screenshots: Vec::new(),
+ license: None,
+ github_link: None,
}).collect())
}
@@ -226,6 +296,126 @@ pub fn get_sources(db: &Database) -> Vec {
}).collect()
}
+/// Base URL for AppImageHub database assets (icons, screenshots).
+pub const APPIMAGEHUB_DATABASE_URL: &str = "https://appimage.github.io/database/";
+
+/// Get the icon cache directory, creating it if needed.
+pub fn icon_cache_dir() -> PathBuf {
+ let dir = dirs::cache_dir()
+ .unwrap_or_else(|| PathBuf::from("/tmp"))
+ .join("driftwood")
+ .join("icons");
+ fs::create_dir_all(&dir).ok();
+ dir
+}
+
+/// Get the screenshot cache directory, creating it if needed.
+pub fn screenshot_cache_dir() -> PathBuf {
+ let dir = dirs::cache_dir()
+ .unwrap_or_else(|| PathBuf::from("/tmp"))
+ .join("driftwood")
+ .join("screenshots");
+ fs::create_dir_all(&dir).ok();
+ dir
+}
+
+/// Resolve an asset path to a full URL (handles relative paths from AppImageHub).
+fn resolve_asset_url(path: &str) -> String {
+ if path.starts_with("http://") || path.starts_with("https://") {
+ path.to_string()
+ } else {
+ format!("{}{}", APPIMAGEHUB_DATABASE_URL, path)
+ }
+}
+
+/// Download a file from a URL to a local path.
+fn download_file(url: &str, dest: &Path) -> Result<(), CatalogError> {
+ let response = ureq::get(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()))?;
+ }
+
+ Ok(())
+}
+
+/// Sanitize a name for use as a filename.
+pub fn sanitize_filename(name: &str) -> String {
+ name.chars()
+ .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
+ .collect()
+}
+
+/// Download icons for all catalog apps that have icon_url set.
+/// Saves to ~/.cache/driftwood/icons/{sanitized_name}.png
+fn cache_catalog_icons(apps: &[CatalogApp]) -> u32 {
+ cache_catalog_icons_with_progress(apps, &|_| {})
+}
+
+fn cache_catalog_icons_with_progress(apps: &[CatalogApp], on_progress: &dyn Fn(SyncProgress)) -> u32 {
+ let cache_dir = icon_cache_dir();
+ let mut count = 0u32;
+ let total = apps.len() as u32;
+
+ for (i, app) in apps.iter().enumerate() {
+ on_progress(SyncProgress::CachingIcon {
+ current: i as u32 + 1,
+ total,
+ app_name: app.name.clone(),
+ });
+
+ if let Some(ref icon_url) = app.icon_url {
+ let sanitized = sanitize_filename(&app.name);
+ let dest = cache_dir.join(format!("{}.png", sanitized));
+
+ // Skip if already cached
+ if dest.exists() {
+ count += 1;
+ continue;
+ }
+
+ let url = resolve_asset_url(icon_url);
+ match download_file(&url, &dest) {
+ Ok(_) => {
+ count += 1;
+ log::debug!("Cached icon for {}", app.name);
+ }
+ Err(e) => {
+ log::debug!("Failed to cache icon for {}: {}", app.name, e);
+ }
+ }
+ }
+ }
+
+ count
+}
+
+/// Download a screenshot to the cache. Returns the local path on success.
+pub fn cache_screenshot(app_name: &str, screenshot_path: &str, index: usize) -> Result {
+ let cache_dir = screenshot_cache_dir();
+ let sanitized = sanitize_filename(app_name);
+ let dest = cache_dir.join(format!("{}_{}.png", sanitized, index));
+
+ if dest.exists() {
+ return Ok(dest);
+ }
+
+ let url = resolve_asset_url(screenshot_path);
+ download_file(&url, &dest)?;
+ Ok(dest)
+}
+
// --- AppImageHub feed format ---
#[derive(Debug, serde::Deserialize)]
@@ -241,6 +431,8 @@ struct AppImageHubItem {
authors: Option>,
links: Option>,
icons: Option>>,
+ screenshots: Option>>,
+ license: Option,
}
#[derive(Debug, serde::Deserialize)]
diff --git a/src/core/database.rs b/src/core/database.rs
index be5b78c..1c74fff 100644
--- a/src/core/database.rs
+++ b/src/core/database.rs
@@ -98,6 +98,16 @@ pub struct CatalogApp {
pub icon_url: Option,
pub homepage: Option,
pub license: Option,
+ pub screenshots: Option,
+ pub github_owner: Option,
+ pub github_repo: Option,
+ pub github_stars: Option,
+ pub github_downloads: Option,
+ pub latest_version: Option,
+ pub release_date: Option,
+ pub github_enriched_at: Option,
+ pub github_download_url: Option,
+ pub github_release_assets: Option,
}
#[derive(Debug, Clone)]
@@ -400,6 +410,22 @@ impl Database {
self.migrate_to_v11()?;
}
+ if current_version < 12 {
+ self.migrate_to_v12()?;
+ }
+
+ if current_version < 13 {
+ self.migrate_to_v13()?;
+ }
+
+ if current_version < 14 {
+ self.migrate_to_v14()?;
+ }
+
+ if current_version < 15 {
+ self.migrate_to_v15()?;
+ }
+
// Ensure all expected columns exist (repairs DBs where a migration
// was updated after it had already run on this database)
self.ensure_columns()?;
@@ -838,6 +864,72 @@ impl Database {
Ok(())
}
+ fn migrate_to_v12(&self) -> SqlResult<()> {
+ let new_columns = [
+ "screenshots TEXT",
+ "license TEXT",
+ ];
+ for col in &new_columns {
+ let sql = format!("ALTER TABLE catalog_apps ADD COLUMN {}", col);
+ self.conn.execute(&sql, []).ok();
+ }
+ self.conn.execute(
+ "UPDATE schema_version SET version = ?1",
+ params![12],
+ )?;
+ Ok(())
+ }
+
+ fn migrate_to_v13(&self) -> SqlResult<()> {
+ // Remove duplicate catalog_apps entries, keeping the row with the highest id
+ // (most recent insert) per (source_id, name) pair
+ self.conn.execute_batch(
+ "DELETE FROM catalog_apps WHERE id NOT IN (
+ SELECT MAX(id) FROM catalog_apps GROUP BY source_id, name
+ );
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_catalog_apps_source_name
+ ON catalog_apps(source_id, name);
+ UPDATE schema_version SET version = 13;"
+ )?;
+ Ok(())
+ }
+
+ fn migrate_to_v14(&self) -> SqlResult<()> {
+ let new_columns = [
+ "github_owner TEXT",
+ "github_repo TEXT",
+ "github_stars INTEGER",
+ "github_downloads INTEGER",
+ "release_date TEXT",
+ "github_enriched_at TEXT",
+ ];
+ for col in &new_columns {
+ let sql = format!("ALTER TABLE catalog_apps ADD COLUMN {}", col);
+ self.conn.execute(&sql, []).ok();
+ }
+ self.conn.execute(
+ "UPDATE schema_version SET version = ?1",
+ params![14],
+ )?;
+ Ok(())
+ }
+
+ fn migrate_to_v15(&self) -> SqlResult<()> {
+ let new_columns = [
+ "github_download_url TEXT",
+ "github_release_assets TEXT",
+ ];
+ for col in &new_columns {
+ let sql = format!("ALTER TABLE catalog_apps ADD COLUMN {}", col);
+ self.conn.execute(&sql, []).ok();
+ }
+ self.conn.execute(
+ "UPDATE schema_version SET version = ?1",
+ params![15],
+ )?;
+ Ok(())
+ }
+
pub fn upsert_appimage(
&self,
path: &str,
@@ -2069,7 +2161,8 @@ impl Database {
limit: i32,
) -> SqlResult> {
let mut sql = String::from(
- "SELECT id, name, description, categories, download_url, icon_url, homepage, architecture
+ "SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots,
+ github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets
FROM catalog_apps WHERE 1=1"
);
let mut params_list: Vec> = Vec::new();
@@ -2101,6 +2194,16 @@ impl Database {
icon_url: row.get(5)?,
homepage: row.get(6)?,
license: row.get(7)?,
+ screenshots: row.get(8)?,
+ github_owner: row.get(9)?,
+ github_repo: row.get(10)?,
+ github_stars: row.get(11)?,
+ github_downloads: row.get(12)?,
+ latest_version: row.get(13)?,
+ release_date: row.get(14)?,
+ github_enriched_at: row.get(15)?,
+ github_download_url: row.get(16)?,
+ github_release_assets: row.get(17)?,
})
})?;
@@ -2113,7 +2216,8 @@ impl Database {
pub fn get_catalog_app(&self, id: i64) -> SqlResult