Add GitHub metadata enrichment for catalog apps
Enrich catalog apps with GitHub API data (stars, version, downloads, release date) via two strategies: background drip for repo-level info and on-demand fetch when opening a detail page. - Add github_enrichment module with API calls, asset filtering, and architecture auto-detection for AppImage downloads - DB migrations v14/v15 for GitHub metadata and release asset columns - Extract github_owner/repo from feed links during catalog sync - Display colored stat cards (stars, version, downloads, released) on detail pages with on-demand enrichment - Show stars and version on browse tiles and featured carousel cards - Replace install button with SplitButton dropdown when multiple arch assets available, preferring detected architecture - Disable install button until enrichment completes to prevent stale AppImageHub URL downloads - Keep enrichment banner visible on catalog page until truly complete, showing paused state when rate-limited - Add GitHub token and auto-enrich toggle to preferences
This commit is contained in:
@@ -142,5 +142,15 @@
|
||||
<summary>Watch removable media</summary>
|
||||
<description>Scan removable drives for AppImages when mounted.</description>
|
||||
</key>
|
||||
<key name="github-token" type="s">
|
||||
<default>''</default>
|
||||
<summary>GitHub personal access token</summary>
|
||||
<description>Optional GitHub token for higher API rate limits (5,000 vs 60 requests per hour).</description>
|
||||
</key>
|
||||
<key name="catalog-auto-enrich" type="b">
|
||||
<default>true</default>
|
||||
<summary>Auto-enrich catalog apps</summary>
|
||||
<description>Automatically fetch GitHub metadata (stars, version, downloads) for catalog apps in the background.</description>
|
||||
</key>
|
||||
</schema>
|
||||
</schemalist>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub file_size: Option<u64>,
|
||||
pub architecture: Option<String>,
|
||||
pub screenshots: Vec<String>,
|
||||
pub license: Option<String>,
|
||||
/// GitHub link URL from the feed (e.g. "https://github.com/user/repo")
|
||||
pub github_link: 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.
|
||||
/// 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<u32, CatalogError> {
|
||||
sync_catalog_with_progress(db, source, &|_| {})
|
||||
}
|
||||
|
||||
pub fn sync_catalog_with_progress(
|
||||
db: &Database,
|
||||
source: &CatalogSource,
|
||||
on_progress: &dyn Fn(SyncProgress),
|
||||
) -> Result<u32, CatalogError> {
|
||||
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<u32, Catalo
|
||||
app.homepage.as_deref(),
|
||||
app.file_size.map(|s| s as i64),
|
||||
app.architecture.as_deref(),
|
||||
screenshots_str.as_deref(),
|
||||
app.license.as_deref(),
|
||||
).ok();
|
||||
count += 1;
|
||||
|
||||
// Extract and store GitHub owner/repo
|
||||
if let Some((owner, repo)) = github_enrichment::extract_github_repo(
|
||||
app.github_link.as_deref().or(app.homepage.as_deref()),
|
||||
&app.download_url,
|
||||
) {
|
||||
// Get the app ID we just inserted/updated
|
||||
if let Ok(Some(db_app)) = db.get_catalog_app_by_source_and_name(source_id, &app.name) {
|
||||
db.update_catalog_app_github_repo(db_app, &owner, &repo).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.update_catalog_source_sync(source_id, count as i32).ok();
|
||||
|
||||
on_progress(SyncProgress::Done { total: count });
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
@@ -152,9 +210,15 @@ fn fetch_appimage_hub() -> Result<Vec<CatalogApp>, CatalogError> {
|
||||
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()
|
||||
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<Vec<CatalogApp>, 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<Vec<CatalogApp>, 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<CatalogSource> {
|
||||
}).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<PathBuf, CatalogError> {
|
||||
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<Vec<AppImageHubAuthor>>,
|
||||
links: Option<Vec<AppImageHubLink>>,
|
||||
icons: Option<Vec<Option<String>>>,
|
||||
screenshots: Option<Vec<Option<String>>>,
|
||||
license: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
|
||||
@@ -98,6 +98,16 @@ pub struct CatalogApp {
|
||||
pub icon_url: Option<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub license: Option<String>,
|
||||
pub screenshots: Option<String>,
|
||||
pub github_owner: Option<String>,
|
||||
pub github_repo: Option<String>,
|
||||
pub github_stars: Option<i64>,
|
||||
pub github_downloads: Option<i64>,
|
||||
pub latest_version: Option<String>,
|
||||
pub release_date: Option<String>,
|
||||
pub github_enriched_at: Option<String>,
|
||||
pub github_download_url: Option<String>,
|
||||
pub github_release_assets: Option<String>,
|
||||
}
|
||||
|
||||
#[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<Vec<CatalogApp>> {
|
||||
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<Box<dyn rusqlite::types::ToSql>> = 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<Option<CatalogApp>> {
|
||||
let result = self.conn.query_row(
|
||||
"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 id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
@@ -2126,6 +2230,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)?,
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -2136,6 +2250,65 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get featured catalog apps. Apps with GitHub stars sort first (by stars desc),
|
||||
/// then unenriched apps get a deterministic shuffle that rotates every 15 minutes.
|
||||
pub fn get_featured_catalog_apps(&self, limit: i32) -> SqlResult<Vec<CatalogApp>> {
|
||||
// Time seed rotates every 15 minutes (900 seconds)
|
||||
let time_seed = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() / 900;
|
||||
|
||||
let mut stmt = self.conn.prepare(
|
||||
"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 icon_url IS NOT NULL AND icon_url != ''
|
||||
AND description IS NOT NULL AND description != ''
|
||||
AND screenshots IS NOT NULL AND screenshots != ''"
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(CatalogApp {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
categories: row.get(3)?,
|
||||
download_url: row.get(4)?,
|
||||
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)?,
|
||||
})
|
||||
})?;
|
||||
let mut apps: Vec<CatalogApp> = rows.collect::<SqlResult<Vec<_>>>()?;
|
||||
|
||||
// Enriched apps (with stars) sort first by stars descending,
|
||||
// unenriched apps get the deterministic shuffle after them
|
||||
apps.sort_by(|a, b| {
|
||||
match (a.github_stars, b.github_stars) {
|
||||
(Some(sa), Some(sb)) => sb.cmp(&sa),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => {
|
||||
let ha = (a.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95);
|
||||
let hb = (b.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95);
|
||||
ha.cmp(&hb)
|
||||
}
|
||||
}
|
||||
});
|
||||
apps.truncate(limit as usize);
|
||||
Ok(apps)
|
||||
}
|
||||
|
||||
pub fn get_catalog_categories(&self) -> SqlResult<Vec<(String, u32)>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT categories FROM catalog_apps WHERE categories IS NOT NULL AND categories != ''"
|
||||
@@ -2172,12 +2345,26 @@ impl Database {
|
||||
homepage: Option<&str>,
|
||||
file_size: Option<i64>,
|
||||
architecture: Option<&str>,
|
||||
screenshots: Option<&str>,
|
||||
license: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"INSERT OR REPLACE INTO catalog_apps
|
||||
(source_id, name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture, cached_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now'))",
|
||||
params![source_id, name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture],
|
||||
"INSERT INTO catalog_apps
|
||||
(source_id, name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture, screenshots, license, cached_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, datetime('now'))
|
||||
ON CONFLICT(source_id, name) DO UPDATE SET
|
||||
description = COALESCE(excluded.description, description),
|
||||
categories = COALESCE(excluded.categories, categories),
|
||||
latest_version = COALESCE(excluded.latest_version, latest_version),
|
||||
download_url = excluded.download_url,
|
||||
icon_url = COALESCE(excluded.icon_url, icon_url),
|
||||
homepage = COALESCE(excluded.homepage, homepage),
|
||||
file_size = COALESCE(excluded.file_size, file_size),
|
||||
architecture = COALESCE(excluded.architecture, architecture),
|
||||
screenshots = COALESCE(excluded.screenshots, screenshots),
|
||||
license = COALESCE(excluded.license, license),
|
||||
cached_at = datetime('now')",
|
||||
params![source_id, name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture, screenshots, license],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -2232,6 +2419,125 @@ impl Database {
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn get_catalog_app_by_source_and_name(&self, source_id: i64, name: &str) -> SqlResult<Option<i64>> {
|
||||
let result = self.conn.query_row(
|
||||
"SELECT id FROM catalog_apps WHERE source_id = ?1 AND name = ?2",
|
||||
params![source_id, name],
|
||||
|row| row.get(0),
|
||||
);
|
||||
match result {
|
||||
Ok(id) => Ok(Some(id)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// --- GitHub enrichment methods ---
|
||||
|
||||
pub fn update_catalog_app_github_repo(
|
||||
&self,
|
||||
app_id: i64,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET github_owner = ?2, github_repo = ?3 WHERE id = ?1",
|
||||
params![app_id, owner, repo],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_catalog_app_github_metadata(
|
||||
&self,
|
||||
app_id: i64,
|
||||
stars: i64,
|
||||
pushed_at: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET github_stars = ?2, github_enriched_at = datetime('now') WHERE id = ?1",
|
||||
params![app_id, stars],
|
||||
)?;
|
||||
// Store pushed_at in release_date if no release info yet
|
||||
if let Some(pushed) = pushed_at {
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET release_date = COALESCE(release_date, ?2) WHERE id = ?1",
|
||||
params![app_id, pushed],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_catalog_app_release_info(
|
||||
&self,
|
||||
app_id: i64,
|
||||
version: Option<&str>,
|
||||
date: Option<&str>,
|
||||
downloads: Option<i64>,
|
||||
github_download_url: Option<&str>,
|
||||
github_release_assets: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET
|
||||
latest_version = COALESCE(?2, latest_version),
|
||||
release_date = COALESCE(?3, release_date),
|
||||
github_downloads = COALESCE(?4, github_downloads),
|
||||
github_download_url = COALESCE(?5, github_download_url),
|
||||
github_release_assets = COALESCE(?6, github_release_assets),
|
||||
github_enriched_at = datetime('now')
|
||||
WHERE id = ?1",
|
||||
params![app_id, version, date, downloads, github_download_url, github_release_assets],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_unenriched_catalog_apps(&self, limit: i32) -> SqlResult<Vec<CatalogApp>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"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 github_owner IS NOT NULL AND github_enriched_at IS NULL
|
||||
ORDER BY id
|
||||
LIMIT ?1"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit], |row| {
|
||||
Ok(CatalogApp {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
categories: row.get(3)?,
|
||||
download_url: row.get(4)?,
|
||||
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)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn catalog_enrichment_progress(&self) -> SqlResult<(i64, i64)> {
|
||||
let enriched: i64 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM catalog_apps WHERE github_owner IS NOT NULL AND github_enriched_at IS NOT NULL",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let total_with_github: i64 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM catalog_apps WHERE github_owner IS NOT NULL",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok((enriched, total_with_github))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2402,7 +2708,7 @@ mod tests {
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(version, 11);
|
||||
assert_eq!(version, 15);
|
||||
|
||||
// All tables that should exist after the full v1-v7 migration chain
|
||||
let expected_tables = [
|
||||
|
||||
394
src/core/github_enrichment.rs
Normal file
394
src/core/github_enrichment.rs
Normal file
@@ -0,0 +1,394 @@
|
||||
use super::database::Database;
|
||||
|
||||
// --- API response structs ---
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct GitHubRepoInfo {
|
||||
pub stargazers_count: i64,
|
||||
pub pushed_at: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct GitHubReleaseInfo {
|
||||
pub tag_name: String,
|
||||
pub published_at: Option<String>,
|
||||
pub assets: Vec<GitHubReleaseAsset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct GitHubReleaseAsset {
|
||||
pub name: String,
|
||||
pub browser_download_url: String,
|
||||
pub download_count: i64,
|
||||
pub size: i64,
|
||||
}
|
||||
|
||||
// --- URL parsing ---
|
||||
|
||||
/// Extract (owner, repo) from a GitHub URL.
|
||||
/// Tries download_url first (most reliable for GitHub releases), then homepage.
|
||||
pub fn extract_github_repo(homepage: Option<&str>, download_url: &str) -> Option<(String, String)> {
|
||||
// Try download URL first - most AppImageHub entries point to GitHub releases
|
||||
if let Some(pair) = parse_github_url(download_url) {
|
||||
return Some(pair);
|
||||
}
|
||||
// Fallback to homepage
|
||||
if let Some(hp) = homepage {
|
||||
if let Some(pair) = parse_github_url(hp) {
|
||||
return Some(pair);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse `github.com/{owner}/{repo}` from a URL, stripping .git suffix if present.
|
||||
fn parse_github_url(url: &str) -> Option<(String, String)> {
|
||||
let stripped = url.trim_start_matches("https://")
|
||||
.trim_start_matches("http://");
|
||||
|
||||
if !stripped.starts_with("github.com/") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let path = stripped.strip_prefix("github.com/")?;
|
||||
let parts: Vec<&str> = path.splitn(3, '/').collect();
|
||||
if parts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let owner = parts[0];
|
||||
let repo = parts[1]
|
||||
.trim_end_matches(".git")
|
||||
.split('?').next().unwrap_or(parts[1]);
|
||||
|
||||
if owner.is_empty() || repo.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((owner.to_string(), repo.to_string()))
|
||||
}
|
||||
|
||||
// --- API calls ---
|
||||
|
||||
fn github_get(url: &str, token: &str) -> Result<(String, u32), String> {
|
||||
let mut req = ureq::get(url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Driftwood-AppImage-Manager");
|
||||
if !token.is_empty() {
|
||||
req = req.header("Authorization", &format!("Bearer {}", token));
|
||||
}
|
||||
let mut response = req.call()
|
||||
.map_err(|e| format!("GitHub API error: {}", e))?;
|
||||
|
||||
// Parse rate limit header
|
||||
let remaining: u32 = response.headers()
|
||||
.get("x-ratelimit-remaining")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let body = response.body_mut().read_to_string()
|
||||
.map_err(|e| format!("Read error: {}", e))?;
|
||||
|
||||
Ok((body, remaining))
|
||||
}
|
||||
|
||||
pub fn fetch_repo_info(owner: &str, repo: &str, token: &str) -> Result<(GitHubRepoInfo, u32), String> {
|
||||
let url = format!("https://api.github.com/repos/{}/{}", owner, repo);
|
||||
let (body, remaining) = github_get(&url, token)?;
|
||||
let info: GitHubRepoInfo = serde_json::from_str(&body)
|
||||
.map_err(|e| format!("Parse error: {}", e))?;
|
||||
Ok((info, remaining))
|
||||
}
|
||||
|
||||
pub fn fetch_release_info(owner: &str, repo: &str, token: &str) -> Result<(GitHubReleaseInfo, u32), String> {
|
||||
let url = format!("https://api.github.com/repos/{}/{}/releases/latest", owner, repo);
|
||||
let (body, remaining) = github_get(&url, token)?;
|
||||
let info: GitHubReleaseInfo = serde_json::from_str(&body)
|
||||
.map_err(|e| format!("Parse error: {}", e))?;
|
||||
Ok((info, remaining))
|
||||
}
|
||||
|
||||
// --- AppImage asset filtering ---
|
||||
|
||||
/// A simplified release asset for storage (JSON-serializable).
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AppImageAsset {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub size: i64,
|
||||
}
|
||||
|
||||
/// Filter release assets to only AppImage files.
|
||||
pub fn filter_appimage_assets(assets: &[GitHubReleaseAsset]) -> Vec<AppImageAsset> {
|
||||
assets.iter()
|
||||
.filter(|a| {
|
||||
let lower = a.name.to_lowercase();
|
||||
lower.ends_with(".appimage") || lower.ends_with(".appimage.zsync")
|
||||
})
|
||||
.filter(|a| !a.name.to_lowercase().ends_with(".zsync"))
|
||||
.map(|a| AppImageAsset {
|
||||
name: a.name.clone(),
|
||||
url: a.browser_download_url.clone(),
|
||||
size: a.size,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Detect the current system architecture string as used in AppImage filenames.
|
||||
pub fn detect_arch() -> &'static str {
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{ "x86_64" }
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{ "aarch64" }
|
||||
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
|
||||
{ std::env::consts::ARCH }
|
||||
}
|
||||
|
||||
/// Pick the best AppImage asset for the current architecture.
|
||||
/// Returns the matching asset, or the first one if no arch match.
|
||||
pub fn pick_best_asset(assets: &[AppImageAsset]) -> Option<&AppImageAsset> {
|
||||
if assets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let arch = detect_arch();
|
||||
// Prefer exact arch match in filename
|
||||
let arch_match = assets.iter().find(|a| {
|
||||
let lower = a.name.to_lowercase();
|
||||
lower.contains(&arch.to_lowercase())
|
||||
});
|
||||
arch_match.or(assets.first())
|
||||
}
|
||||
|
||||
// --- Enrichment logic ---
|
||||
|
||||
/// Enrich a catalog app with repo-level info (stars, pushed_at).
|
||||
pub fn enrich_app_repo_info(
|
||||
db: &Database,
|
||||
app_id: i64,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
token: &str,
|
||||
) -> Result<u32, String> {
|
||||
let (info, remaining) = fetch_repo_info(owner, repo, token)?;
|
||||
db.update_catalog_app_github_metadata(app_id, info.stargazers_count, info.pushed_at.as_deref())
|
||||
.map_err(|e| format!("DB error: {}", e))?;
|
||||
Ok(remaining)
|
||||
}
|
||||
|
||||
/// Enrich a catalog app with release info (version, date, downloads, assets).
|
||||
pub fn enrich_app_release_info(
|
||||
db: &Database,
|
||||
app_id: i64,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
token: &str,
|
||||
) -> Result<u32, String> {
|
||||
let (info, remaining) = fetch_release_info(owner, repo, token)?;
|
||||
|
||||
// Clean version string (strip leading "v")
|
||||
let version = info.tag_name.strip_prefix('v')
|
||||
.unwrap_or(&info.tag_name)
|
||||
.to_string();
|
||||
|
||||
// Sum download counts across all assets
|
||||
let total_downloads: i64 = info.assets.iter().map(|a| a.download_count).sum();
|
||||
|
||||
// Extract AppImage assets and pick the best download URL
|
||||
let appimage_assets = filter_appimage_assets(&info.assets);
|
||||
let best_url = pick_best_asset(&appimage_assets).map(|a| a.url.as_str());
|
||||
let assets_json = if appimage_assets.is_empty() {
|
||||
None
|
||||
} else {
|
||||
serde_json::to_string(&appimage_assets).ok()
|
||||
};
|
||||
|
||||
db.update_catalog_app_release_info(
|
||||
app_id,
|
||||
Some(&version),
|
||||
info.published_at.as_deref(),
|
||||
if total_downloads > 0 { Some(total_downloads) } else { None },
|
||||
best_url,
|
||||
assets_json.as_deref(),
|
||||
).map_err(|e| format!("DB error: {}", e))?;
|
||||
|
||||
Ok(remaining)
|
||||
}
|
||||
|
||||
/// Background enrichment: process a batch of unenriched apps.
|
||||
/// Returns (count_enriched, should_continue).
|
||||
pub fn background_enrich_batch(
|
||||
db: &Database,
|
||||
token: &str,
|
||||
batch_size: i32,
|
||||
on_progress: &dyn Fn(i64, i64),
|
||||
) -> Result<(u32, bool), String> {
|
||||
let apps = db.get_unenriched_catalog_apps(batch_size)
|
||||
.map_err(|e| format!("DB error: {}", e))?;
|
||||
|
||||
if apps.is_empty() {
|
||||
return Ok((0, false));
|
||||
}
|
||||
|
||||
let mut enriched = 0u32;
|
||||
|
||||
for app in &apps {
|
||||
let owner = match app.github_owner.as_deref() {
|
||||
Some(o) => o,
|
||||
None => continue,
|
||||
};
|
||||
let repo = match app.github_repo.as_deref() {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
match enrich_app_repo_info(db, app.id, owner, repo, token) {
|
||||
Ok(remaining) => {
|
||||
enriched += 1;
|
||||
|
||||
// Report progress
|
||||
if let Ok((done, total)) = db.catalog_enrichment_progress() {
|
||||
on_progress(done, total);
|
||||
}
|
||||
|
||||
// Stop if rate limit is getting low
|
||||
if remaining < 5 {
|
||||
log::info!("GitHub rate limit low ({}), pausing enrichment", remaining);
|
||||
return Ok((enriched, false));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to enrich {}/{}: {}", owner, repo, e);
|
||||
// Mark as enriched anyway so we don't retry forever
|
||||
db.update_catalog_app_github_metadata(app.id, 0, None).ok();
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep between calls to be respectful
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
|
||||
Ok((enriched, enriched > 0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_github_repo_from_download() {
|
||||
let result = extract_github_repo(
|
||||
None,
|
||||
"https://github.com/nickvdp/deno-spreadsheets/releases/download/v0.3.0/app.AppImage",
|
||||
);
|
||||
assert_eq!(result, Some(("nickvdp".to_string(), "deno-spreadsheets".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_github_repo_from_homepage() {
|
||||
let result = extract_github_repo(
|
||||
Some("https://github.com/nickvdp/deno-spreadsheets"),
|
||||
"https://example.com/download.AppImage",
|
||||
);
|
||||
assert_eq!(result, Some(("nickvdp".to_string(), "deno-spreadsheets".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_github_repo_with_git_suffix() {
|
||||
let result = extract_github_repo(
|
||||
Some("https://github.com/user/repo.git"),
|
||||
"https://example.com/download.AppImage",
|
||||
);
|
||||
assert_eq!(result, Some(("user".to_string(), "repo".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_github_repo_non_github() {
|
||||
let result = extract_github_repo(
|
||||
Some("https://gitlab.com/user/repo"),
|
||||
"https://sourceforge.net/download.AppImage",
|
||||
);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_url_empty() {
|
||||
assert_eq!(parse_github_url(""), None);
|
||||
assert_eq!(parse_github_url("https://github.com/"), None);
|
||||
assert_eq!(parse_github_url("https://github.com/user"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_appimage_assets() {
|
||||
let assets = vec![
|
||||
GitHubReleaseAsset {
|
||||
name: "app-x86_64.AppImage".to_string(),
|
||||
browser_download_url: "https://github.com/u/r/releases/download/v1/app-x86_64.AppImage".to_string(),
|
||||
download_count: 100,
|
||||
size: 50_000_000,
|
||||
},
|
||||
GitHubReleaseAsset {
|
||||
name: "app-aarch64.AppImage".to_string(),
|
||||
browser_download_url: "https://github.com/u/r/releases/download/v1/app-aarch64.AppImage".to_string(),
|
||||
download_count: 20,
|
||||
size: 48_000_000,
|
||||
},
|
||||
GitHubReleaseAsset {
|
||||
name: "app-x86_64.AppImage.zsync".to_string(),
|
||||
browser_download_url: "https://github.com/u/r/releases/download/v1/app-x86_64.AppImage.zsync".to_string(),
|
||||
download_count: 5,
|
||||
size: 1000,
|
||||
},
|
||||
GitHubReleaseAsset {
|
||||
name: "source.tar.gz".to_string(),
|
||||
browser_download_url: "https://github.com/u/r/releases/download/v1/source.tar.gz".to_string(),
|
||||
download_count: 10,
|
||||
size: 2_000_000,
|
||||
},
|
||||
];
|
||||
let filtered = filter_appimage_assets(&assets);
|
||||
assert_eq!(filtered.len(), 2);
|
||||
assert_eq!(filtered[0].name, "app-x86_64.AppImage");
|
||||
assert_eq!(filtered[1].name, "app-aarch64.AppImage");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pick_best_asset_prefers_arch() {
|
||||
let assets = vec![
|
||||
AppImageAsset {
|
||||
name: "app-aarch64.AppImage".to_string(),
|
||||
url: "https://example.com/aarch64".to_string(),
|
||||
size: 48_000_000,
|
||||
},
|
||||
AppImageAsset {
|
||||
name: "app-x86_64.AppImage".to_string(),
|
||||
url: "https://example.com/x86_64".to_string(),
|
||||
size: 50_000_000,
|
||||
},
|
||||
];
|
||||
let best = pick_best_asset(&assets).unwrap();
|
||||
// On x86_64 systems this should pick x86_64, on aarch64 it picks aarch64
|
||||
let arch = detect_arch();
|
||||
assert!(best.name.contains(arch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pick_best_asset_empty() {
|
||||
let assets: Vec<AppImageAsset> = vec![];
|
||||
assert!(pick_best_asset(&assets).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pick_best_asset_single() {
|
||||
let assets = vec![
|
||||
AppImageAsset {
|
||||
name: "app.AppImage".to_string(),
|
||||
url: "https://example.com/app".to_string(),
|
||||
size: 50_000_000,
|
||||
},
|
||||
];
|
||||
let best = pick_best_asset(&assets).unwrap();
|
||||
assert_eq!(best.name, "app.AppImage");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod backup;
|
||||
pub mod catalog;
|
||||
pub mod database;
|
||||
pub mod discovery;
|
||||
pub mod github_enrichment;
|
||||
pub mod duplicates;
|
||||
pub mod footprint;
|
||||
pub mod fuse;
|
||||
|
||||
812
src/ui/catalog_detail.rs
Normal file
812
src/ui/catalog_detail.rs
Normal file
@@ -0,0 +1,812 @@
|
||||
use adw::prelude::*;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk::gio;
|
||||
|
||||
use crate::core::catalog;
|
||||
use crate::core::database::{CatalogApp, Database};
|
||||
use crate::core::github_enrichment;
|
||||
use crate::core::github_enrichment::AppImageAsset;
|
||||
use crate::i18n::i18n;
|
||||
use super::catalog_tile;
|
||||
use super::detail_view;
|
||||
use super::widgets;
|
||||
|
||||
use crate::config::APP_ID;
|
||||
|
||||
/// Build a catalog app detail page for the given CatalogApp.
|
||||
/// Returns an adw::NavigationPage that can be pushed onto a NavigationView.
|
||||
pub fn build_catalog_detail_page(
|
||||
app: &CatalogApp,
|
||||
db: &Rc<Database>,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) -> adw::NavigationPage {
|
||||
let page_title = app.name.clone();
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
let header = adw::HeaderBar::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(800)
|
||||
.tightening_threshold(600)
|
||||
.build();
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(24)
|
||||
.margin_top(24)
|
||||
.margin_bottom(24)
|
||||
.margin_start(18)
|
||||
.margin_end(18)
|
||||
.build();
|
||||
|
||||
// --- Header section: icon + name + author + buttons ---
|
||||
let header_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(18)
|
||||
.build();
|
||||
|
||||
let icon = widgets::app_icon(None, &app.name, 96);
|
||||
icon.add_css_class("icon-dropshadow");
|
||||
icon.set_valign(gtk::Align::Start);
|
||||
header_box.append(&icon);
|
||||
|
||||
let info_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(6)
|
||||
.valign(gtk::Align::Center)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
let name_label = gtk::Label::builder()
|
||||
.label(&app.name)
|
||||
.css_classes(["title-1"])
|
||||
.xalign(0.0)
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
info_box.append(&name_label);
|
||||
|
||||
// Author (from homepage URL domain)
|
||||
if let Some(ref homepage) = app.homepage {
|
||||
let author_text = extract_author(homepage);
|
||||
let author_label = gtk::Label::builder()
|
||||
.label(&format!("by {}", author_text))
|
||||
.css_classes(["dim-label"])
|
||||
.xalign(0.0)
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
info_box.append(&author_label);
|
||||
}
|
||||
|
||||
// Button row
|
||||
let button_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.margin_top(8)
|
||||
.build();
|
||||
|
||||
// Check early if GitHub data needs loading (affects install button behavior)
|
||||
let has_github = app.github_owner.is_some() && app.github_repo.is_some();
|
||||
let needs_enrichment = has_github
|
||||
&& (app.latest_version.is_none() || is_enrichment_stale(app.github_enriched_at.as_deref()));
|
||||
let awaiting_github = needs_enrichment && app.github_download_url.is_none();
|
||||
|
||||
// Check if already installed
|
||||
let installed_names: std::collections::HashSet<String> = db
|
||||
.get_all_appimages()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|r| r.app_name.as_ref().map(|n| n.to_lowercase()))
|
||||
.collect();
|
||||
let is_installed = installed_names.contains(&app.name.to_lowercase());
|
||||
|
||||
let install_slot = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||
|
||||
if is_installed {
|
||||
let installed_badge = widgets::status_badge(&i18n("Installed"), "success");
|
||||
installed_badge.set_valign(gtk::Align::Center);
|
||||
button_box.append(&installed_badge);
|
||||
} else {
|
||||
button_box.append(&install_slot);
|
||||
|
||||
if awaiting_github {
|
||||
// GitHub release data not yet loaded - show disabled placeholder
|
||||
let placeholder = gtk::Button::builder()
|
||||
.label(&i18n("Loading..."))
|
||||
.css_classes(["suggested-action", "pill"])
|
||||
.sensitive(false)
|
||||
.build();
|
||||
install_slot.append(&placeholder);
|
||||
} else {
|
||||
populate_install_slot(
|
||||
&install_slot,
|
||||
&app.name,
|
||||
&app.download_url,
|
||||
app.github_download_url.as_deref(),
|
||||
app.github_release_assets.as_deref(),
|
||||
toast_overlay,
|
||||
db,
|
||||
app.homepage.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Homepage button
|
||||
if let Some(ref homepage) = app.homepage {
|
||||
let homepage_btn = gtk::Button::builder()
|
||||
.label(&i18n("Homepage"))
|
||||
.css_classes(["flat", "pill"])
|
||||
.build();
|
||||
let hp_clone = homepage.clone();
|
||||
homepage_btn.connect_clicked(move |btn| {
|
||||
let launcher = gtk::UriLauncher::new(&hp_clone);
|
||||
let root = btn.root().and_downcast::<gtk::Window>();
|
||||
launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {});
|
||||
});
|
||||
button_box.append(&homepage_btn);
|
||||
}
|
||||
|
||||
info_box.append(&button_box);
|
||||
header_box.append(&info_box);
|
||||
content.append(&header_box);
|
||||
|
||||
// --- GitHub stat cards row (between header and screenshots) ---
|
||||
let stars_value_label = gtk::Label::builder()
|
||||
.label(app.github_stars.filter(|&s| s > 0).map(|s| format_count(s)).as_deref().unwrap_or("-"))
|
||||
.css_classes(["stat-value"])
|
||||
.xalign(0.0)
|
||||
.build();
|
||||
let version_value_label = gtk::Label::builder()
|
||||
.label(app.latest_version.as_deref().unwrap_or("-"))
|
||||
.css_classes(["stat-value"])
|
||||
.xalign(0.0)
|
||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||
.max_width_chars(14)
|
||||
.build();
|
||||
let downloads_value_label = gtk::Label::builder()
|
||||
.label(app.github_downloads.filter(|&d| d > 0).map(|d| format_count(d)).as_deref().unwrap_or("-"))
|
||||
.css_classes(["stat-value"])
|
||||
.xalign(0.0)
|
||||
.build();
|
||||
let released_value_label = gtk::Label::builder()
|
||||
.label(app.release_date.as_deref().map(|d| widgets::relative_time(d)).as_deref().unwrap_or("-"))
|
||||
.css_classes(["stat-value"])
|
||||
.xalign(0.0)
|
||||
.build();
|
||||
|
||||
if has_github {
|
||||
let stats_row = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(10)
|
||||
.homogeneous(true)
|
||||
.build();
|
||||
|
||||
let stars_card = build_stat_card("starred-symbolic", &stars_value_label, &i18n("Stars"));
|
||||
stars_card.add_css_class("stat-stars");
|
||||
stats_row.append(&stars_card);
|
||||
|
||||
let version_card = build_stat_card("tag-symbolic", &version_value_label, &i18n("Latest"));
|
||||
version_card.add_css_class("stat-version");
|
||||
stats_row.append(&version_card);
|
||||
|
||||
let downloads_card = build_stat_card("folder-download-symbolic", &downloads_value_label, &i18n("Downloads"));
|
||||
downloads_card.add_css_class("stat-downloads");
|
||||
stats_row.append(&downloads_card);
|
||||
|
||||
let released_card = build_stat_card("month-symbolic", &released_value_label, &i18n("Released"));
|
||||
released_card.add_css_class("stat-released");
|
||||
stats_row.append(&released_card);
|
||||
|
||||
content.append(&stats_row);
|
||||
}
|
||||
|
||||
// Enrichment spinner (small, shown next to stats row while loading)
|
||||
let enrich_spinner = gtk::Spinner::builder()
|
||||
.spinning(false)
|
||||
.visible(false)
|
||||
.halign(gtk::Align::Start)
|
||||
.width_request(16)
|
||||
.height_request(16)
|
||||
.build();
|
||||
if has_github {
|
||||
content.append(&enrich_spinner);
|
||||
}
|
||||
|
||||
// On-demand enrichment: fetch release info if stale or missing
|
||||
if has_github && needs_enrichment {
|
||||
let app_id = app.id;
|
||||
let owner = app.github_owner.clone().unwrap_or_default();
|
||||
let repo = app.github_repo.clone().unwrap_or_default();
|
||||
let db_ref = db.clone();
|
||||
let stars_ref = stars_value_label.clone();
|
||||
let version_ref = version_value_label.clone();
|
||||
let downloads_ref = downloads_value_label.clone();
|
||||
let released_ref = released_value_label.clone();
|
||||
let spinner_ref = enrich_spinner.clone();
|
||||
let install_slot_ref = install_slot.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
|
||||
spinner_ref.set_visible(true);
|
||||
spinner_ref.set_spinning(true);
|
||||
|
||||
let settings = gio::Settings::new(APP_ID);
|
||||
let token = settings.string("github-token").to_string();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let owner_c = owner.clone();
|
||||
let repo_c = repo.clone();
|
||||
let token_c = token.clone();
|
||||
|
||||
let result = gio::spawn_blocking(move || {
|
||||
let bg_db = Database::open().ok();
|
||||
if let Some(ref db) = bg_db {
|
||||
let _ = github_enrichment::enrich_app_release_info(
|
||||
db, app_id, &owner_c, &repo_c, &token_c,
|
||||
);
|
||||
let _ = github_enrichment::enrich_app_repo_info(
|
||||
db, app_id, &owner_c, &repo_c, &token_c,
|
||||
);
|
||||
}
|
||||
}).await;
|
||||
|
||||
spinner_ref.set_spinning(false);
|
||||
spinner_ref.set_visible(false);
|
||||
|
||||
if let Ok(Some(updated)) = db_ref.get_catalog_app(app_id) {
|
||||
if let Some(stars) = updated.github_stars.filter(|&s| s > 0) {
|
||||
stars_ref.set_label(&format_count(stars));
|
||||
}
|
||||
if let Some(ref ver) = updated.latest_version {
|
||||
version_ref.set_label(ver);
|
||||
}
|
||||
if let Some(downloads) = updated.github_downloads.filter(|&d| d > 0) {
|
||||
downloads_ref.set_label(&format_count(downloads));
|
||||
}
|
||||
if let Some(ref date) = updated.release_date {
|
||||
released_ref.set_label(&widgets::relative_time(date));
|
||||
}
|
||||
|
||||
// Rebuild install button now that GitHub data is available
|
||||
if awaiting_github && !is_installed {
|
||||
populate_install_slot(
|
||||
&install_slot_ref,
|
||||
&updated.name,
|
||||
&updated.download_url,
|
||||
updated.github_download_url.as_deref(),
|
||||
updated.github_release_assets.as_deref(),
|
||||
&toast_ref,
|
||||
&db_ref,
|
||||
updated.homepage.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if result.is_err() {
|
||||
log::warn!("On-demand enrichment thread error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Screenshots section (click to open lightbox) ---
|
||||
if let Some(ref screenshots_str) = app.screenshots {
|
||||
let paths: Vec<&str> = screenshots_str.split(';').filter(|s| !s.is_empty()).collect();
|
||||
if !paths.is_empty() {
|
||||
let screenshots_label = gtk::Label::builder()
|
||||
.label(&i18n("Screenshots"))
|
||||
.css_classes(["title-2"])
|
||||
.xalign(0.0)
|
||||
.halign(gtk::Align::Start)
|
||||
.margin_top(12)
|
||||
.build();
|
||||
content.append(&screenshots_label);
|
||||
|
||||
let screenshot_scroll = gtk::ScrolledWindow::builder()
|
||||
.vscrollbar_policy(gtk::PolicyType::Never)
|
||||
.hscrollbar_policy(gtk::PolicyType::Automatic)
|
||||
.height_request(360)
|
||||
.build();
|
||||
|
||||
let screenshot_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(12)
|
||||
.build();
|
||||
|
||||
// Store textures for lightbox access
|
||||
let textures: Rc<RefCell<Vec<Option<gtk::gdk::Texture>>>> =
|
||||
Rc::new(RefCell::new(vec![None; paths.len()]));
|
||||
|
||||
for (i, path) in paths.iter().enumerate() {
|
||||
let frame = gtk::Frame::new(None);
|
||||
frame.add_css_class("card");
|
||||
frame.set_width_request(480);
|
||||
frame.set_height_request(340);
|
||||
|
||||
// Spinner placeholder
|
||||
let spinner = gtk::Spinner::builder()
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.spinning(true)
|
||||
.width_request(48)
|
||||
.height_request(48)
|
||||
.build();
|
||||
frame.set_child(Some(&spinner));
|
||||
|
||||
screenshot_box.append(&frame);
|
||||
|
||||
// Click handler for lightbox
|
||||
let textures_click = textures.clone();
|
||||
let click = gtk::GestureClick::new();
|
||||
let idx = i;
|
||||
click.connect_released(move |gesture, _, _, _| {
|
||||
gesture.set_state(gtk::EventSequenceState::Claimed);
|
||||
let t = textures_click.borrow();
|
||||
if t.get(idx).is_some_and(|t| t.is_some()) {
|
||||
if let Some(widget) = gesture.widget() {
|
||||
if let Some(root) = gtk::prelude::WidgetExt::root(&widget) {
|
||||
if let Ok(window) = root.downcast::<gtk::Window>() {
|
||||
detail_view::show_screenshot_lightbox(
|
||||
&window,
|
||||
&textures_click,
|
||||
idx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
frame.add_controller(click);
|
||||
|
||||
// Load screenshot asynchronously
|
||||
let app_name = app.name.clone();
|
||||
let screenshot_path = path.to_string();
|
||||
let frame_ref = frame.clone();
|
||||
let textures_ref = textures.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let name = app_name.clone();
|
||||
let spath = screenshot_path.clone();
|
||||
let load_idx = i;
|
||||
|
||||
let result = gio::spawn_blocking(move || {
|
||||
catalog::cache_screenshot(&name, &spath, load_idx)
|
||||
.map_err(|e| e.to_string())
|
||||
}).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(local_path)) => {
|
||||
if let Ok(texture) = gtk::gdk::Texture::from_filename(&local_path) {
|
||||
let picture = gtk::Picture::builder()
|
||||
.paintable(&texture)
|
||||
.content_fit(gtk::ContentFit::Contain)
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
frame_ref.set_child(Some(&picture));
|
||||
textures_ref.borrow_mut()[i] = Some(texture);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let fallback = gtk::Label::builder()
|
||||
.label("Screenshot unavailable")
|
||||
.css_classes(["dim-label"])
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
frame_ref.set_child(Some(&fallback));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
screenshot_scroll.set_child(Some(&screenshot_box));
|
||||
content.append(&screenshot_scroll);
|
||||
}
|
||||
}
|
||||
|
||||
// --- About section ---
|
||||
if let Some(ref desc) = app.description {
|
||||
if !desc.is_empty() {
|
||||
let about_group = adw::PreferencesGroup::builder()
|
||||
.title(&i18n("About"))
|
||||
.build();
|
||||
|
||||
let plain_desc = catalog_tile::strip_html(desc);
|
||||
let desc_label = gtk::Label::builder()
|
||||
.label(&plain_desc)
|
||||
.wrap(true)
|
||||
.xalign(0.0)
|
||||
.selectable(true)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.build();
|
||||
|
||||
let row = adw::ActionRow::new();
|
||||
row.set_child(Some(&desc_label));
|
||||
about_group.add(&row);
|
||||
|
||||
content.append(&about_group);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Details section ---
|
||||
let details_group = adw::PreferencesGroup::builder()
|
||||
.title(&i18n("Details"))
|
||||
.build();
|
||||
|
||||
if let Some(ref license) = app.license {
|
||||
if !license.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&i18n("License"))
|
||||
.subtitle(license)
|
||||
.subtitle_selectable(true)
|
||||
.build();
|
||||
details_group.add(&row);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref cats) = app.categories {
|
||||
if !cats.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&i18n("Categories"))
|
||||
.subtitle(cats)
|
||||
.build();
|
||||
details_group.add(&row);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref homepage) = app.homepage {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&i18n("Homepage"))
|
||||
.subtitle(homepage)
|
||||
.subtitle_selectable(true)
|
||||
.activatable(true)
|
||||
.build();
|
||||
let arrow = gtk::Image::from_icon_name("external-link-symbolic");
|
||||
arrow.set_valign(gtk::Align::Center);
|
||||
row.add_suffix(&arrow);
|
||||
|
||||
let hp = homepage.clone();
|
||||
row.connect_activated(move |row| {
|
||||
let launcher = gtk::UriLauncher::new(&hp);
|
||||
let root = row.root().and_downcast::<gtk::Window>();
|
||||
launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {});
|
||||
});
|
||||
details_group.add(&row);
|
||||
}
|
||||
|
||||
// Download URL
|
||||
let dl_row = adw::ActionRow::builder()
|
||||
.title(&i18n("Download"))
|
||||
.subtitle(&app.download_url)
|
||||
.subtitle_selectable(true)
|
||||
.activatable(true)
|
||||
.build();
|
||||
let dl_arrow = gtk::Image::from_icon_name("external-link-symbolic");
|
||||
dl_arrow.set_valign(gtk::Align::Center);
|
||||
dl_row.add_suffix(&dl_arrow);
|
||||
let dl_url = app.download_url.clone();
|
||||
dl_row.connect_activated(move |row| {
|
||||
let launcher = gtk::UriLauncher::new(&dl_url);
|
||||
let root = row.root().and_downcast::<gtk::Window>();
|
||||
launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {});
|
||||
});
|
||||
details_group.add(&dl_row);
|
||||
|
||||
content.append(&details_group);
|
||||
|
||||
// --- Status section ---
|
||||
let status_group = adw::PreferencesGroup::builder()
|
||||
.title(&i18n("Status"))
|
||||
.build();
|
||||
|
||||
let status_row = adw::ActionRow::builder()
|
||||
.title(&i18n("Installed"))
|
||||
.build();
|
||||
let status_badge = if is_installed {
|
||||
widgets::status_badge(&i18n("Yes"), "success")
|
||||
} else {
|
||||
widgets::status_badge(&i18n("No"), "neutral")
|
||||
};
|
||||
status_badge.set_valign(gtk::Align::Center);
|
||||
status_row.add_suffix(&status_badge);
|
||||
status_group.add(&status_row);
|
||||
|
||||
content.append(&status_group);
|
||||
|
||||
clamp.set_child(Some(&content));
|
||||
scrolled.set_child(Some(&clamp));
|
||||
toolbar.set_content(Some(&scrolled));
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title(&page_title)
|
||||
.tag("catalog-detail")
|
||||
.child(&toolbar)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Trigger an install from a URL. Handles async download, DB registration, and UI feedback.
|
||||
fn do_install(
|
||||
url: String,
|
||||
app_name: String,
|
||||
homepage: Option<String>,
|
||||
toast_overlay: adw::ToastOverlay,
|
||||
db: Rc<Database>,
|
||||
widget: gtk::Widget,
|
||||
) {
|
||||
glib::spawn_future_local(async move {
|
||||
let name = app_name.clone();
|
||||
let hp = homepage.clone();
|
||||
let dl_url = url.clone();
|
||||
|
||||
let result = gio::spawn_blocking(move || {
|
||||
let install_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
|
||||
.join("Applications");
|
||||
std::fs::create_dir_all(&install_dir).ok();
|
||||
|
||||
let cat_app = catalog::CatalogApp {
|
||||
name,
|
||||
description: None,
|
||||
categories: Vec::new(),
|
||||
latest_version: None,
|
||||
download_url: dl_url,
|
||||
icon_url: None,
|
||||
homepage: hp,
|
||||
file_size: None,
|
||||
architecture: None,
|
||||
screenshots: Vec::new(),
|
||||
license: None,
|
||||
github_link: None,
|
||||
};
|
||||
|
||||
catalog::install_from_catalog(&cat_app, &install_dir)
|
||||
.map_err(|e| e.to_string())
|
||||
}).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(path)) => {
|
||||
let filename = path.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let size = std::fs::metadata(&path)
|
||||
.map(|m| m.len() as i64)
|
||||
.unwrap_or(0);
|
||||
db.upsert_appimage(
|
||||
&path.to_string_lossy(),
|
||||
&filename,
|
||||
Some(2),
|
||||
size,
|
||||
true,
|
||||
None,
|
||||
).ok();
|
||||
toast_overlay.add_toast(adw::Toast::new("Installed successfully"));
|
||||
if let Some(btn) = widget.downcast_ref::<gtk::Button>() {
|
||||
btn.set_label("Installed");
|
||||
} else if let Some(split) = widget.downcast_ref::<adw::SplitButton>() {
|
||||
split.set_label("Installed");
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::error!("Install failed: {}", e);
|
||||
toast_overlay.add_toast(adw::Toast::new("Install failed"));
|
||||
widget.set_sensitive(true);
|
||||
if let Some(btn) = widget.downcast_ref::<gtk::Button>() {
|
||||
btn.set_label("Install");
|
||||
} else if let Some(split) = widget.downcast_ref::<adw::SplitButton>() {
|
||||
split.set_label("Install");
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!("Install thread panicked");
|
||||
toast_overlay.add_toast(adw::Toast::new("Install failed"));
|
||||
widget.set_sensitive(true);
|
||||
if let Some(btn) = widget.downcast_ref::<gtk::Button>() {
|
||||
btn.set_label("Install");
|
||||
} else if let Some(split) = widget.downcast_ref::<adw::SplitButton>() {
|
||||
split.set_label("Install");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Populate the install button slot with the appropriate button (plain or split).
|
||||
/// Clears any existing children first, so it can be called to rebuild after enrichment.
|
||||
fn populate_install_slot(
|
||||
slot: >k::Box,
|
||||
app_name: &str,
|
||||
download_url: &str,
|
||||
github_download_url: Option<&str>,
|
||||
github_release_assets: Option<&str>,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
db: &Rc<Database>,
|
||||
homepage: Option<&str>,
|
||||
) {
|
||||
// Clear existing children (e.g. the "Loading..." placeholder)
|
||||
while let Some(child) = slot.first_child() {
|
||||
slot.remove(&child);
|
||||
}
|
||||
|
||||
let assets: Vec<AppImageAsset> = github_release_assets
|
||||
.and_then(|json| serde_json::from_str(json).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let default_url = github_download_url
|
||||
.unwrap_or(download_url)
|
||||
.to_string();
|
||||
|
||||
if assets.len() > 1 {
|
||||
// Multiple assets available - use SplitButton with dropdown
|
||||
let menu = gio::Menu::new();
|
||||
for asset in &assets {
|
||||
let label = format_asset_label(&asset.name, asset.size);
|
||||
menu.append(Some(&label), Some(&format!("install.asset::{}", asset.url)));
|
||||
}
|
||||
menu.append(
|
||||
Some(&i18n("AppImageHub (original)")),
|
||||
Some(&format!("install.asset::{}", download_url)),
|
||||
);
|
||||
|
||||
let split_btn = adw::SplitButton::builder()
|
||||
.label(&i18n("Install"))
|
||||
.menu_model(&menu)
|
||||
.css_classes(["suggested-action", "pill"])
|
||||
.build();
|
||||
|
||||
let url_for_click = default_url;
|
||||
let name_for_click = app_name.to_string();
|
||||
let hp_for_click = homepage.map(|s| s.to_string());
|
||||
let toast_for_click = toast_overlay.clone();
|
||||
let db_for_click = db.clone();
|
||||
|
||||
split_btn.connect_clicked(move |btn| {
|
||||
btn.set_sensitive(false);
|
||||
btn.set_label("Installing...");
|
||||
do_install(
|
||||
url_for_click.clone(),
|
||||
name_for_click.clone(),
|
||||
hp_for_click.clone(),
|
||||
toast_for_click.clone(),
|
||||
db_for_click.clone(),
|
||||
btn.upcast_ref::<gtk::Widget>().clone(),
|
||||
);
|
||||
});
|
||||
|
||||
let action_group = gio::SimpleActionGroup::new();
|
||||
let asset_action = gio::SimpleAction::new("asset", Some(glib::VariantTy::STRING));
|
||||
let name_for_asset = app_name.to_string();
|
||||
let hp_for_asset = homepage.map(|s| s.to_string());
|
||||
let toast_for_asset = toast_overlay.clone();
|
||||
let db_for_asset = db.clone();
|
||||
let split_ref = split_btn.clone();
|
||||
asset_action.connect_activate(move |_, param| {
|
||||
if let Some(url) = param.and_then(|p| p.str()) {
|
||||
split_ref.set_sensitive(false);
|
||||
split_ref.set_label("Installing...");
|
||||
do_install(
|
||||
url.to_string(),
|
||||
name_for_asset.clone(),
|
||||
hp_for_asset.clone(),
|
||||
toast_for_asset.clone(),
|
||||
db_for_asset.clone(),
|
||||
split_ref.upcast_ref::<gtk::Widget>().clone(),
|
||||
);
|
||||
}
|
||||
});
|
||||
action_group.add_action(&asset_action);
|
||||
split_btn.insert_action_group("install", Some(&action_group));
|
||||
|
||||
slot.append(&split_btn);
|
||||
} else {
|
||||
// Single asset or no GitHub assets - plain button
|
||||
let install_btn = gtk::Button::builder()
|
||||
.label(&i18n("Install"))
|
||||
.css_classes(["suggested-action", "pill"])
|
||||
.build();
|
||||
|
||||
let url_clone = default_url;
|
||||
let name_clone = app_name.to_string();
|
||||
let hp_clone = homepage.map(|s| s.to_string());
|
||||
let toast_clone = toast_overlay.clone();
|
||||
let db_clone = db.clone();
|
||||
|
||||
install_btn.connect_clicked(move |btn| {
|
||||
btn.set_sensitive(false);
|
||||
btn.set_label("Installing...");
|
||||
do_install(
|
||||
url_clone.clone(),
|
||||
name_clone.clone(),
|
||||
hp_clone.clone(),
|
||||
toast_clone.clone(),
|
||||
db_clone.clone(),
|
||||
btn.upcast_ref::<gtk::Widget>().clone(),
|
||||
);
|
||||
});
|
||||
|
||||
slot.append(&install_btn);
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an asset filename with size for the dropdown menu.
|
||||
fn format_asset_label(name: &str, size: i64) -> String {
|
||||
if size > 0 {
|
||||
format!("{} ({})", name, widgets::format_size(size))
|
||||
} else {
|
||||
name.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a single stat card widget with icon, value label, and description label.
|
||||
fn build_stat_card(icon_name: &str, value_label: >k::Label, label_text: &str) -> gtk::Box {
|
||||
let card = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
card.add_css_class("stat-card");
|
||||
|
||||
let icon_row = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(6)
|
||||
.build();
|
||||
|
||||
let icon = gtk::Image::from_icon_name(icon_name);
|
||||
icon.set_pixel_size(14);
|
||||
icon_row.append(&icon);
|
||||
icon_row.append(value_label);
|
||||
card.append(&icon_row);
|
||||
|
||||
let label = gtk::Label::builder()
|
||||
.label(label_text)
|
||||
.css_classes(["stat-label"])
|
||||
.xalign(0.0)
|
||||
.build();
|
||||
card.append(&label);
|
||||
|
||||
card
|
||||
}
|
||||
|
||||
/// Check if enrichment data is stale (>24 hours old).
|
||||
fn is_enrichment_stale(enriched_at: Option<&str>) -> bool {
|
||||
let Some(ts) = enriched_at else { return true };
|
||||
let Ok(parsed) = chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S") else {
|
||||
return true;
|
||||
};
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
let elapsed = now.signed_duration_since(parsed);
|
||||
elapsed.num_hours() >= 24
|
||||
}
|
||||
|
||||
/// Re-export format_count from widgets for use in this module.
|
||||
fn format_count(n: i64) -> String {
|
||||
widgets::format_count(n)
|
||||
}
|
||||
|
||||
/// Extract author/org from a URL for display.
|
||||
/// For GitHub/GitLab URLs, extracts the username/org from the path.
|
||||
/// For other URLs, returns the domain.
|
||||
fn extract_author(url: &str) -> String {
|
||||
let stripped = url.trim_start_matches("https://")
|
||||
.trim_start_matches("http://");
|
||||
let parts: Vec<&str> = stripped.splitn(3, '/').collect();
|
||||
let domain = parts.first().copied().unwrap_or("");
|
||||
|
||||
// For GitHub/GitLab, extract the org/user from the first path segment
|
||||
if domain == "github.com" || domain == "gitlab.com" {
|
||||
if let Some(org) = parts.get(1) {
|
||||
if !org.is_empty() {
|
||||
return org.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
domain.to_string()
|
||||
}
|
||||
338
src/ui/catalog_tile.rs
Normal file
338
src/ui/catalog_tile.rs
Normal file
@@ -0,0 +1,338 @@
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crate::core::database::CatalogApp;
|
||||
use super::widgets;
|
||||
|
||||
/// Build a catalog tile for the browse grid.
|
||||
/// Left-aligned layout: icon (48px) at top, name, description, category badge.
|
||||
/// Card fills its entire FlowBoxChild cell.
|
||||
pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild {
|
||||
let card = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(6)
|
||||
.halign(gtk::Align::Fill)
|
||||
.valign(gtk::Align::Fill)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
card.add_css_class("card");
|
||||
card.add_css_class("catalog-tile");
|
||||
|
||||
let inner = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(6)
|
||||
.margin_top(14)
|
||||
.margin_bottom(14)
|
||||
.margin_start(14)
|
||||
.margin_end(14)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
// Icon (48px) - left aligned
|
||||
let icon = widgets::app_icon(None, &app.name, 48);
|
||||
icon.add_css_class("icon-dropshadow");
|
||||
icon.set_halign(gtk::Align::Start);
|
||||
inner.append(&icon);
|
||||
|
||||
// App name - left aligned
|
||||
let name_label = gtk::Label::builder()
|
||||
.label(&app.name)
|
||||
.css_classes(["heading"])
|
||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||
.max_width_chars(20)
|
||||
.xalign(0.0)
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
inner.append(&name_label);
|
||||
|
||||
// Description (always 2 lines for uniform height)
|
||||
let plain = app.description.as_deref()
|
||||
.filter(|d| !d.is_empty())
|
||||
.map(|d| strip_html(d))
|
||||
.unwrap_or_default();
|
||||
let snippet: String = plain.chars().take(80).collect();
|
||||
let text = if plain.is_empty() {
|
||||
// Non-breaking space placeholder to reserve 2 lines
|
||||
"\u{00a0}".to_string()
|
||||
} else if snippet.len() < plain.len() {
|
||||
format!("{}...", snippet.trim_end())
|
||||
} else {
|
||||
snippet
|
||||
};
|
||||
let desc_label = gtk::Label::builder()
|
||||
.label(&text)
|
||||
.css_classes(["caption", "dim-label"])
|
||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||
.lines(2)
|
||||
.wrap(true)
|
||||
.xalign(0.0)
|
||||
.max_width_chars(24)
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
// Force 2-line height
|
||||
desc_label.set_height_request(desc_label.preferred_size().1.height().max(36));
|
||||
inner.append(&desc_label);
|
||||
|
||||
// Stats row (stars + version) - only if data exists
|
||||
let has_stars = app.github_stars.is_some_and(|s| s > 0);
|
||||
let has_version = app.latest_version.is_some();
|
||||
if has_stars || has_version {
|
||||
let stats_row = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(12)
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
stats_row.add_css_class("catalog-stats-row");
|
||||
|
||||
if let Some(stars) = app.github_stars.filter(|&s| s > 0) {
|
||||
let star_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(4)
|
||||
.build();
|
||||
let star_icon = gtk::Image::from_icon_name("starred-symbolic");
|
||||
star_icon.set_pixel_size(12);
|
||||
star_box.append(&star_icon);
|
||||
let star_label = gtk::Label::new(Some(&widgets::format_count(stars)));
|
||||
star_box.append(&star_label);
|
||||
stats_row.append(&star_box);
|
||||
}
|
||||
|
||||
if let Some(ref ver) = app.latest_version {
|
||||
let ver_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(4)
|
||||
.build();
|
||||
let ver_icon = gtk::Image::from_icon_name("tag-symbolic");
|
||||
ver_icon.set_pixel_size(12);
|
||||
ver_box.append(&ver_icon);
|
||||
let ver_label = gtk::Label::builder()
|
||||
.label(ver.as_str())
|
||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||
.max_width_chars(12)
|
||||
.build();
|
||||
ver_box.append(&ver_label);
|
||||
stats_row.append(&ver_box);
|
||||
}
|
||||
|
||||
inner.append(&stats_row);
|
||||
}
|
||||
|
||||
// Category badge - left aligned
|
||||
if let Some(ref cats) = app.categories {
|
||||
let first_cat: String = cats.split(';')
|
||||
.next()
|
||||
.or_else(|| cats.split(',').next())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !first_cat.is_empty() {
|
||||
let badge = widgets::status_badge(&first_cat, "neutral");
|
||||
badge.set_halign(gtk::Align::Start);
|
||||
badge.set_margin_top(2);
|
||||
inner.append(&badge);
|
||||
}
|
||||
}
|
||||
|
||||
card.append(&inner);
|
||||
|
||||
let child = gtk::FlowBoxChild::builder()
|
||||
.child(&card)
|
||||
.build();
|
||||
child.add_css_class("activatable");
|
||||
|
||||
child
|
||||
}
|
||||
|
||||
/// Build a featured banner card for the carousel.
|
||||
/// Layout: screenshot preview on top, then icon + name + description + badge below.
|
||||
/// Width is set dynamically by the carousel layout.
|
||||
pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box {
|
||||
let card = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(0)
|
||||
.halign(gtk::Align::Fill)
|
||||
.valign(gtk::Align::Fill)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
card.add_css_class("card");
|
||||
card.add_css_class("catalog-featured-card");
|
||||
card.add_css_class("activatable");
|
||||
card.set_widget_name(&format!("featured-{}", app.id));
|
||||
|
||||
// Screenshot preview area (top)
|
||||
let screenshot_frame = gtk::Frame::new(None);
|
||||
screenshot_frame.add_css_class("catalog-featured-screenshot");
|
||||
screenshot_frame.set_height_request(160);
|
||||
screenshot_frame.set_hexpand(true);
|
||||
|
||||
// Spinner placeholder until image loads
|
||||
let spinner = gtk::Spinner::builder()
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.spinning(true)
|
||||
.width_request(32)
|
||||
.height_request(32)
|
||||
.build();
|
||||
screenshot_frame.set_child(Some(&spinner));
|
||||
card.append(&screenshot_frame);
|
||||
|
||||
// Info section below screenshot
|
||||
let info_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(12)
|
||||
.margin_top(10)
|
||||
.margin_bottom(10)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.build();
|
||||
|
||||
// Icon (48px)
|
||||
let icon = widgets::app_icon(None, &app.name, 48);
|
||||
icon.add_css_class("icon-dropshadow");
|
||||
icon.set_valign(gtk::Align::Start);
|
||||
info_box.append(&icon);
|
||||
|
||||
// Text column
|
||||
let text_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(2)
|
||||
.valign(gtk::Align::Center)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
// App name
|
||||
let name_label = gtk::Label::builder()
|
||||
.label(&app.name)
|
||||
.css_classes(["heading"])
|
||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||
.max_width_chars(28)
|
||||
.xalign(0.0)
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
text_box.append(&name_label);
|
||||
|
||||
// Description (1 line in featured since space is tight)
|
||||
if let Some(ref desc) = app.description {
|
||||
if !desc.is_empty() {
|
||||
let plain = strip_html(desc);
|
||||
let snippet: String = plain.chars().take(60).collect();
|
||||
let text = if snippet.len() < plain.len() {
|
||||
format!("{}...", snippet.trim_end())
|
||||
} else {
|
||||
snippet
|
||||
};
|
||||
let desc_label = gtk::Label::builder()
|
||||
.label(&text)
|
||||
.css_classes(["caption", "dim-label"])
|
||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||
.lines(1)
|
||||
.xalign(0.0)
|
||||
.max_width_chars(35)
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
text_box.append(&desc_label);
|
||||
}
|
||||
}
|
||||
|
||||
// Badge row: category + stars
|
||||
let badge_row = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(6)
|
||||
.margin_top(2)
|
||||
.build();
|
||||
|
||||
if let Some(ref cats) = app.categories {
|
||||
let first_cat: String = cats.split(';')
|
||||
.next()
|
||||
.or_else(|| cats.split(',').next())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !first_cat.is_empty() {
|
||||
let badge = widgets::status_badge(&first_cat, "info");
|
||||
badge.set_halign(gtk::Align::Start);
|
||||
badge_row.append(&badge);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(stars) = app.github_stars.filter(|&s| s > 0) {
|
||||
let star_badge = widgets::status_badge_with_icon(
|
||||
"starred-symbolic",
|
||||
&widgets::format_count(stars),
|
||||
"neutral",
|
||||
);
|
||||
star_badge.set_halign(gtk::Align::Start);
|
||||
badge_row.append(&star_badge);
|
||||
}
|
||||
|
||||
text_box.append(&badge_row);
|
||||
|
||||
info_box.append(&text_box);
|
||||
card.append(&info_box);
|
||||
card
|
||||
}
|
||||
|
||||
/// Strip HTML tags from a string, returning plain text.
|
||||
pub fn strip_html(html: &str) -> String {
|
||||
let mut result = String::with_capacity(html.len());
|
||||
let mut in_tag = false;
|
||||
for ch in html.chars() {
|
||||
match ch {
|
||||
'<' => in_tag = true,
|
||||
'>' => in_tag = false,
|
||||
_ if !in_tag => result.push(ch),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Collapse whitespace
|
||||
let collapsed: String = result.split_whitespace().collect::<Vec<&str>>().join(" ");
|
||||
// Decode common HTML entities
|
||||
collapsed
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_basic() {
|
||||
assert_eq!(strip_html("<p>Hello world</p>"), "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_nested() {
|
||||
assert_eq!(
|
||||
strip_html("<p>Hello <b>bold</b> world</p>"),
|
||||
"Hello bold world"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_entities() {
|
||||
assert_eq!(strip_html("& < > ""), "& < > \"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_multiline() {
|
||||
let input = "<p>Line one</p>\n<p>Line two</p>";
|
||||
assert_eq!(strip_html(input), "Line one Line two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_list() {
|
||||
let input = "<ul>\n <li>Item 1</li>\n <li>Item 2</li>\n</ul>";
|
||||
assert_eq!(strip_html(input), "Item 1 Item 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_plain_text() {
|
||||
assert_eq!(strip_html("No HTML here"), "No HTML here");
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,24 @@ use std::rc::Rc;
|
||||
use gtk::gio;
|
||||
|
||||
use crate::core::catalog;
|
||||
use crate::core::database::Database;
|
||||
use crate::core::database::{CatalogApp, Database};
|
||||
use crate::i18n::i18n;
|
||||
use super::catalog_detail;
|
||||
use super::catalog_tile;
|
||||
use super::widgets;
|
||||
|
||||
/// Build the catalog browser page.
|
||||
pub fn build_catalog_page(db: &Rc<Database>) -> adw::NavigationPage {
|
||||
/// Build the catalog page with an internal NavigationView for drill-down.
|
||||
/// Returns (NavigationView, enrichment_banner) so the window can control the banner.
|
||||
pub fn build_catalog_page(db: &Rc<Database>) -> (adw::NavigationView, gtk::Box) {
|
||||
let nav_view = adw::NavigationView::new();
|
||||
let (browse_page, enrichment_banner) = build_browse_page(db, &nav_view);
|
||||
nav_view.push(&browse_page);
|
||||
(nav_view, enrichment_banner)
|
||||
}
|
||||
|
||||
/// Build the main browse page with featured carousel + tile grid.
|
||||
/// Returns (NavigationPage, enrichment_banner).
|
||||
fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw::NavigationPage, gtk::Box) {
|
||||
let toast_overlay = adw::ToastOverlay::new();
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
@@ -31,44 +43,197 @@ pub fn build_catalog_page(db: &Rc<Database>) -> adw::NavigationPage {
|
||||
.search_mode_enabled(true)
|
||||
.build();
|
||||
|
||||
// Category filter
|
||||
let category_box = gtk::Box::builder()
|
||||
// --- Featured section (paged carousel) ---
|
||||
let featured_label = gtk::Label::builder()
|
||||
.label(&i18n("Featured"))
|
||||
.css_classes(["title-2"])
|
||||
.xalign(0.0)
|
||||
.halign(gtk::Align::Start)
|
||||
.margin_start(18)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
|
||||
// Stack for crossfade page transitions
|
||||
let featured_stack = gtk::Stack::builder()
|
||||
.transition_type(gtk::StackTransitionType::Crossfade)
|
||||
.transition_duration(250)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
// Page state: all featured apps and current page index
|
||||
let featured_apps: Rc<RefCell<Vec<CatalogApp>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let featured_page: Rc<std::cell::Cell<usize>> = Rc::new(std::cell::Cell::new(0));
|
||||
// Tracks which stack child name is active ("a" or "b") for crossfade toggling
|
||||
let featured_flip: Rc<std::cell::Cell<bool>> = Rc::new(std::cell::Cell::new(false));
|
||||
|
||||
// Navigation arrows
|
||||
let left_arrow = gtk::Button::builder()
|
||||
.icon_name("go-previous-symbolic")
|
||||
.css_classes(["circular", "osd"])
|
||||
.valign(gtk::Align::Center)
|
||||
.sensitive(false)
|
||||
.build();
|
||||
|
||||
let right_arrow = gtk::Button::builder()
|
||||
.icon_name("go-next-symbolic")
|
||||
.css_classes(["circular", "osd"])
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
|
||||
// Carousel row: [<] [stack] [>]
|
||||
let carousel_row = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.margin_start(18)
|
||||
.margin_end(18)
|
||||
.build();
|
||||
carousel_row.append(&left_arrow);
|
||||
carousel_row.append(&featured_stack);
|
||||
carousel_row.append(&right_arrow);
|
||||
|
||||
let category_scroll = gtk::ScrolledWindow::builder()
|
||||
.child(&category_box)
|
||||
.vscrollbar_policy(gtk::PolicyType::Never)
|
||||
.hscrollbar_policy(gtk::PolicyType::Automatic)
|
||||
.max_content_height(40)
|
||||
// Wire arrow navigation (page through featured apps with crossfade)
|
||||
{
|
||||
let apps_ref = featured_apps.clone();
|
||||
let page_ref = featured_page.clone();
|
||||
let flip_ref = featured_flip.clone();
|
||||
let stack_ref = featured_stack.clone();
|
||||
let left_ref = left_arrow.clone();
|
||||
let right_ref = right_arrow.clone();
|
||||
let db_ref = db.clone();
|
||||
let nav_ref = nav_view.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
left_arrow.connect_clicked(move |_| {
|
||||
let page = page_ref.get();
|
||||
if page > 0 {
|
||||
page_ref.set(page - 1);
|
||||
show_featured_page(
|
||||
&apps_ref, page - 1, &stack_ref, &flip_ref,
|
||||
&left_ref, &right_ref,
|
||||
&db_ref, &nav_ref, &toast_ref,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let apps_ref = featured_apps.clone();
|
||||
let page_ref = featured_page.clone();
|
||||
let flip_ref = featured_flip.clone();
|
||||
let stack_ref = featured_stack.clone();
|
||||
let left_ref = left_arrow.clone();
|
||||
let right_ref = right_arrow.clone();
|
||||
let db_ref = db.clone();
|
||||
let nav_ref = nav_view.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
right_arrow.connect_clicked(move |_| {
|
||||
let apps = apps_ref.borrow();
|
||||
let page = page_ref.get();
|
||||
let max_page = apps.len().saturating_sub(1) / CARDS_PER_PAGE;
|
||||
if page < max_page {
|
||||
drop(apps);
|
||||
page_ref.set(page + 1);
|
||||
show_featured_page(
|
||||
&apps_ref, page + 1, &stack_ref, &flip_ref,
|
||||
&left_ref, &right_ref,
|
||||
&db_ref, &nav_ref, &toast_ref,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Wrapping container for featured section
|
||||
let featured_section = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
featured_section.append(&featured_label);
|
||||
featured_section.append(&carousel_row);
|
||||
|
||||
// Results list
|
||||
let results_box = gtk::ListBox::builder()
|
||||
// --- Category filter chips ---
|
||||
let category_box = gtk::FlowBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.homogeneous(false)
|
||||
.min_children_per_line(3)
|
||||
.max_children_per_line(20)
|
||||
.row_spacing(6)
|
||||
.column_spacing(6)
|
||||
.margin_start(18)
|
||||
.margin_end(18)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
|
||||
// --- "All Apps" section ---
|
||||
let all_label = gtk::Label::builder()
|
||||
.label(&i18n("All Apps"))
|
||||
.css_classes(["title-2"])
|
||||
.xalign(0.0)
|
||||
.halign(gtk::Align::Start)
|
||||
.margin_start(18)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
|
||||
// FlowBox grid
|
||||
let flow_box = gtk::FlowBox::builder()
|
||||
.homogeneous(true)
|
||||
.min_children_per_line(2)
|
||||
.max_children_per_line(5)
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.css_classes(["boxed-list"])
|
||||
.margin_start(18)
|
||||
.margin_end(18)
|
||||
.margin_top(12)
|
||||
.margin_bottom(24)
|
||||
.row_spacing(12)
|
||||
.column_spacing(12)
|
||||
.build();
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(800)
|
||||
.tightening_threshold(600)
|
||||
.maximum_size(1200)
|
||||
.tightening_threshold(900)
|
||||
.build();
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(12)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
// Enrichment banner (hidden by default, shown by background enrichment)
|
||||
let enrichment_banner = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.margin_start(18)
|
||||
.margin_end(18)
|
||||
.margin_top(6)
|
||||
.visible(false)
|
||||
.build();
|
||||
enrichment_banner.add_css_class("card");
|
||||
enrichment_banner.set_halign(gtk::Align::Fill);
|
||||
|
||||
let enrich_spinner = gtk::Spinner::builder()
|
||||
.spinning(true)
|
||||
.width_request(16)
|
||||
.height_request(16)
|
||||
.margin_start(12)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
enrich_spinner.set_widget_name("enrich-spinner");
|
||||
enrichment_banner.append(&enrich_spinner);
|
||||
|
||||
let enrich_label = gtk::Label::builder()
|
||||
.label(&i18n("Enriching app data from GitHub..."))
|
||||
.css_classes(["dim-label"])
|
||||
.hexpand(true)
|
||||
.xalign(0.0)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.build();
|
||||
enrich_label.set_widget_name("enrich-label");
|
||||
enrichment_banner.append(&enrich_label);
|
||||
|
||||
// Layout order: search -> enrichment banner -> featured carousel -> categories -> all apps grid
|
||||
content.append(&search_bar);
|
||||
content.append(&category_scroll);
|
||||
content.append(&results_box);
|
||||
content.append(&enrichment_banner);
|
||||
content.append(&featured_section);
|
||||
content.append(&category_box.clone());
|
||||
content.append(&all_label);
|
||||
content.append(&flow_box);
|
||||
clamp.set_child(Some(&content));
|
||||
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
@@ -76,7 +241,7 @@ pub fn build_catalog_page(db: &Rc<Database>) -> adw::NavigationPage {
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
// Status page for empty state
|
||||
// Empty state
|
||||
let empty_page = adw::StatusPage::builder()
|
||||
.icon_name("system-software-install-symbolic")
|
||||
.title(&i18n("App Catalog"))
|
||||
@@ -94,7 +259,6 @@ pub fn build_catalog_page(db: &Rc<Database>) -> adw::NavigationPage {
|
||||
stack.add_named(&empty_page, Some("empty"));
|
||||
stack.add_named(&scrolled, Some("results"));
|
||||
|
||||
// Show empty or results based on catalog data
|
||||
let app_count = db.catalog_app_count().unwrap_or(0);
|
||||
if app_count > 0 {
|
||||
stack.set_visible_child_name("results");
|
||||
@@ -105,9 +269,25 @@ pub fn build_catalog_page(db: &Rc<Database>) -> adw::NavigationPage {
|
||||
|
||||
toast_overlay.set_child(Some(&stack));
|
||||
|
||||
// Progress bar for catalog sync
|
||||
let progress_bar = gtk::ProgressBar::builder()
|
||||
.show_text(true)
|
||||
.visible(false)
|
||||
.margin_start(18)
|
||||
.margin_end(18)
|
||||
.margin_top(6)
|
||||
.margin_bottom(6)
|
||||
.build();
|
||||
|
||||
let toolbar_content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.build();
|
||||
toolbar_content.append(&progress_bar);
|
||||
toolbar_content.append(&toast_overlay);
|
||||
|
||||
let toolbar_view = adw::ToolbarView::new();
|
||||
toolbar_view.add_top_bar(&header);
|
||||
toolbar_view.set_content(Some(&toast_overlay));
|
||||
toolbar_view.set_content(Some(&toolbar_content));
|
||||
|
||||
// Refresh button in header
|
||||
let refresh_header_btn = gtk::Button::builder()
|
||||
@@ -120,56 +300,162 @@ pub fn build_catalog_page(db: &Rc<Database>) -> adw::NavigationPage {
|
||||
let active_category: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
|
||||
|
||||
// Populate categories
|
||||
populate_categories(db, &category_box, &active_category, &results_box, &search_entry);
|
||||
populate_categories(
|
||||
db, &category_box, &active_category, &flow_box, &search_entry,
|
||||
&featured_section, &all_label, nav_view, &toast_overlay,
|
||||
);
|
||||
|
||||
// Initial population
|
||||
populate_results(db, "", None, &results_box, &toast_overlay);
|
||||
populate_featured(
|
||||
db, &featured_apps, &featured_page, &featured_stack, &featured_flip,
|
||||
&left_arrow, &right_arrow, nav_view, &toast_overlay,
|
||||
);
|
||||
populate_grid(db, "", None, &flow_box, &all_label, nav_view, &toast_overlay);
|
||||
|
||||
// Search handler
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let results_ref = results_box.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let flow_ref = flow_box.clone();
|
||||
let cat_ref = active_category.clone();
|
||||
let nav_ref = nav_view.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let all_label_ref = all_label.clone();
|
||||
let featured_section_ref = featured_section.clone();
|
||||
search_entry.connect_search_changed(move |entry| {
|
||||
let query = entry.text().to_string();
|
||||
let cat = cat_ref.borrow().clone();
|
||||
populate_results(&db_ref, &query, cat.as_deref(), &results_ref, &toast_ref);
|
||||
let is_searching = !query.is_empty() || cat.is_some();
|
||||
featured_section_ref.set_visible(!is_searching);
|
||||
populate_grid(
|
||||
&db_ref, &query, cat.as_deref(), &flow_ref,
|
||||
&all_label_ref, &nav_ref, &toast_ref,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh handler (both buttons)
|
||||
// Tile click handler for grid
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let nav_ref = nav_view.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
flow_box.connect_child_activated(move |_, child| {
|
||||
if let Some(app_id) = child.widget_name().strip_prefix("catalog-app-") {
|
||||
if let Ok(id) = app_id.parse::<i64>() {
|
||||
if let Ok(Some(app)) = db_ref.get_catalog_app(id) {
|
||||
let detail = catalog_detail::build_catalog_detail_page(
|
||||
&app, &db_ref, &toast_ref,
|
||||
);
|
||||
nav_ref.push(&detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh handler
|
||||
let wire_refresh = |btn: >k::Button| {
|
||||
let db_ref = db.clone();
|
||||
let stack_ref = stack.clone();
|
||||
let results_ref = results_box.clone();
|
||||
let flow_ref = flow_box.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let title_ref = title.clone();
|
||||
let cat_box_ref = category_box.clone();
|
||||
let active_cat_ref = active_category.clone();
|
||||
let search_ref = search_entry.clone();
|
||||
let featured_apps_ref = featured_apps.clone();
|
||||
let featured_page_ref = featured_page.clone();
|
||||
let featured_stack_ref = featured_stack.clone();
|
||||
let featured_flip_ref = featured_flip.clone();
|
||||
let left_arrow_ref = left_arrow.clone();
|
||||
let right_arrow_ref = right_arrow.clone();
|
||||
let all_label_ref = all_label.clone();
|
||||
let featured_section_ref = featured_section.clone();
|
||||
let nav_ref = nav_view.clone();
|
||||
let progress_ref = progress_bar.clone();
|
||||
|
||||
btn.connect_clicked(move |btn| {
|
||||
btn.set_sensitive(false);
|
||||
let db_c = db_ref.clone();
|
||||
let stack_c = stack_ref.clone();
|
||||
let results_c = results_ref.clone();
|
||||
let flow_c = flow_ref.clone();
|
||||
let toast_c = toast_ref.clone();
|
||||
let title_c = title_ref.clone();
|
||||
let btn_c = btn.clone();
|
||||
let cat_box_c = cat_box_ref.clone();
|
||||
let active_cat_c = active_cat_ref.clone();
|
||||
let search_c = search_ref.clone();
|
||||
let featured_apps_c = featured_apps_ref.clone();
|
||||
let featured_page_c = featured_page_ref.clone();
|
||||
let featured_stack_c = featured_stack_ref.clone();
|
||||
let featured_flip_c = featured_flip_ref.clone();
|
||||
let left_arrow_c = left_arrow_ref.clone();
|
||||
let right_arrow_c = right_arrow_ref.clone();
|
||||
let all_label_c = all_label_ref.clone();
|
||||
let featured_section_c = featured_section_ref.clone();
|
||||
let nav_c = nav_ref.clone();
|
||||
let progress_c = progress_ref.clone();
|
||||
|
||||
// Capture app count before refresh for delta calculation
|
||||
let count_before = db_c.catalog_app_count().unwrap_or(0);
|
||||
|
||||
// Show progress bar
|
||||
progress_c.set_visible(true);
|
||||
progress_c.set_fraction(0.0);
|
||||
progress_c.set_text(Some("Fetching catalog..."));
|
||||
|
||||
// Channel for progress updates from background thread
|
||||
let (tx, rx) = std::sync::mpsc::channel::<catalog::SyncProgress>();
|
||||
|
||||
// Listen for progress on the main thread
|
||||
let progress_listen = progress_c.clone();
|
||||
glib::timeout_add_local(std::time::Duration::from_millis(50), move || {
|
||||
while let Ok(progress) = rx.try_recv() {
|
||||
match progress {
|
||||
catalog::SyncProgress::FetchingFeed => {
|
||||
progress_listen.set_fraction(0.0);
|
||||
progress_listen.set_text(Some("Fetching catalog feed..."));
|
||||
}
|
||||
catalog::SyncProgress::FeedFetched { total } => {
|
||||
progress_listen.set_fraction(0.05);
|
||||
progress_listen.set_text(Some(&format!("Found {} apps, caching icons...", total)));
|
||||
}
|
||||
catalog::SyncProgress::CachingIcon { current, total, .. } => {
|
||||
let frac = 0.05 + 0.85 * (current as f64 / total.max(1) as f64);
|
||||
progress_listen.set_fraction(frac);
|
||||
progress_listen.set_text(Some(
|
||||
&format!("Caching icons ({}/{})", current, total),
|
||||
));
|
||||
}
|
||||
catalog::SyncProgress::SavingApps { current, total } => {
|
||||
let frac = 0.90 + 0.10 * (current as f64 / total.max(1) as f64);
|
||||
progress_listen.set_fraction(frac);
|
||||
progress_listen.set_text(Some(
|
||||
&format!("Saving apps ({}/{})", current, total),
|
||||
));
|
||||
}
|
||||
catalog::SyncProgress::Done { .. } => {
|
||||
progress_listen.set_fraction(1.0);
|
||||
progress_listen.set_text(Some("Complete"));
|
||||
return glib::ControlFlow::Break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !progress_listen.is_visible() {
|
||||
return glib::ControlFlow::Break;
|
||||
}
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let db_bg = Database::open().ok();
|
||||
let result = gio::spawn_blocking(move || {
|
||||
let db_bg = Database::open().ok();
|
||||
if let Some(ref db) = db_bg {
|
||||
catalog::ensure_default_sources(db);
|
||||
let sources = catalog::get_sources(db);
|
||||
if let Some(source) = sources.first() {
|
||||
catalog::sync_catalog(db, source)
|
||||
.map_err(|e| e.to_string())
|
||||
catalog::sync_catalog_with_progress(db, source, &move |p| {
|
||||
tx.send(p).ok();
|
||||
}).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err("No catalog sources configured".to_string())
|
||||
}
|
||||
@@ -179,20 +465,37 @@ pub fn build_catalog_page(db: &Rc<Database>) -> adw::NavigationPage {
|
||||
}).await;
|
||||
|
||||
btn_c.set_sensitive(true);
|
||||
progress_c.set_visible(false);
|
||||
|
||||
match result {
|
||||
Ok(Ok(count)) => {
|
||||
toast_c.add_toast(adw::Toast::new(
|
||||
&format!("Catalog refreshed: {} apps", count),
|
||||
));
|
||||
update_catalog_subtitle(&title_c, count as i64);
|
||||
Ok(Ok(_count)) => {
|
||||
let count_after = db_c.catalog_app_count().unwrap_or(0);
|
||||
let new_apps = count_after - count_before;
|
||||
let toast_msg = if new_apps > 0 {
|
||||
format!("Catalog refreshed: {} new apps added", new_apps)
|
||||
} else {
|
||||
"Catalog refreshed, no new apps".to_string()
|
||||
};
|
||||
toast_c.add_toast(adw::Toast::new(&toast_msg));
|
||||
update_catalog_subtitle(&title_c, count_after);
|
||||
stack_c.set_visible_child_name("results");
|
||||
populate_categories(&db_c, &cat_box_c, &active_cat_c, &results_c, &search_c);
|
||||
populate_results(&db_c, "", None, &results_c, &toast_c);
|
||||
populate_categories(
|
||||
&db_c, &cat_box_c, &active_cat_c, &flow_c, &search_c,
|
||||
&featured_section_c, &all_label_c,
|
||||
&nav_c, &toast_c,
|
||||
);
|
||||
populate_featured(
|
||||
&db_c, &featured_apps_c, &featured_page_c,
|
||||
&featured_stack_c, &featured_flip_c,
|
||||
&left_arrow_c, &right_arrow_c, &nav_c, &toast_c,
|
||||
);
|
||||
populate_grid(
|
||||
&db_c, "", None, &flow_c, &all_label_c, &nav_c, &toast_c,
|
||||
);
|
||||
|
||||
// Store refresh timestamp
|
||||
let settings = gio::Settings::new(crate::config::APP_ID);
|
||||
let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
let now = chrono::Utc::now()
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
settings.set_string("catalog-last-refreshed", &now).ok();
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
@@ -216,14 +519,15 @@ pub fn build_catalog_page(db: &Rc<Database>) -> adw::NavigationPage {
|
||||
refresh_btn.emit_clicked();
|
||||
}
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title(&i18n("Catalog"))
|
||||
.tag("catalog")
|
||||
.tag("catalog-browse")
|
||||
.child(&toolbar_view)
|
||||
.build()
|
||||
.build();
|
||||
|
||||
(page, enrichment_banner)
|
||||
}
|
||||
|
||||
/// Update the catalog subtitle to show app count and relative last-refreshed time.
|
||||
fn update_catalog_subtitle(title: &adw::WindowTitle, app_count: i64) {
|
||||
let settings = gtk::gio::Settings::new(crate::config::APP_ID);
|
||||
let last_refreshed = settings.string("catalog-last-refreshed");
|
||||
@@ -235,170 +539,188 @@ fn update_catalog_subtitle(title: &adw::WindowTitle, app_count: i64) {
|
||||
}
|
||||
}
|
||||
|
||||
fn populate_results(
|
||||
const CARDS_PER_PAGE: usize = 3;
|
||||
|
||||
/// Populate featured apps data and show the first page.
|
||||
fn populate_featured(
|
||||
db: &Rc<Database>,
|
||||
query: &str,
|
||||
category: Option<&str>,
|
||||
list_box: >k::ListBox,
|
||||
featured_apps: &Rc<RefCell<Vec<CatalogApp>>>,
|
||||
featured_page: &Rc<std::cell::Cell<usize>>,
|
||||
featured_stack: >k::Stack,
|
||||
featured_flip: &Rc<std::cell::Cell<bool>>,
|
||||
left_arrow: >k::Button,
|
||||
right_arrow: >k::Button,
|
||||
nav_view: &adw::NavigationView,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
// Clear existing
|
||||
while let Some(row) = list_box.row_at_index(0) {
|
||||
list_box.remove(&row);
|
||||
let apps = db.get_featured_catalog_apps(30).unwrap_or_default();
|
||||
*featured_apps.borrow_mut() = apps;
|
||||
featured_page.set(0);
|
||||
show_featured_page(
|
||||
featured_apps, 0, featured_stack, featured_flip,
|
||||
left_arrow, right_arrow, db, nav_view, toast_overlay,
|
||||
);
|
||||
}
|
||||
|
||||
let results = db.search_catalog(query, category, 50).unwrap_or_default();
|
||||
/// Display a specific page of featured cards with crossfade transition.
|
||||
fn show_featured_page(
|
||||
featured_apps: &Rc<RefCell<Vec<CatalogApp>>>,
|
||||
page: usize,
|
||||
stack: >k::Stack,
|
||||
flip: &Rc<std::cell::Cell<bool>>,
|
||||
left_arrow: >k::Button,
|
||||
right_arrow: >k::Button,
|
||||
db: &Rc<Database>,
|
||||
nav_view: &adw::NavigationView,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
let apps = featured_apps.borrow();
|
||||
let start = page * CARDS_PER_PAGE;
|
||||
let end = (start + CARDS_PER_PAGE).min(apps.len());
|
||||
let max_page = apps.len().saturating_sub(1) / CARDS_PER_PAGE;
|
||||
|
||||
if results.is_empty() {
|
||||
let empty_row = adw::ActionRow::builder()
|
||||
.title(&i18n("No results"))
|
||||
.subtitle(&i18n("Try a different search or refresh the catalog"))
|
||||
.build();
|
||||
list_box.append(&empty_row);
|
||||
return;
|
||||
}
|
||||
left_arrow.set_sensitive(page > 0);
|
||||
right_arrow.set_sensitive(page < max_page);
|
||||
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let db_ref = db.clone();
|
||||
|
||||
// Get installed app names for matching
|
||||
let installed_names: std::collections::HashSet<String> = db
|
||||
.get_all_appimages()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|r| r.app_name.as_ref().map(|n| n.to_lowercase()))
|
||||
.collect();
|
||||
|
||||
for app in &results {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&app.name)
|
||||
.activatable(true)
|
||||
// Build a new page container with equal-width cards
|
||||
let page_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(12)
|
||||
.homogeneous(true)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
if let Some(ref desc) = app.description {
|
||||
let snippet: String = desc.chars().take(80).collect();
|
||||
let subtitle = if snippet.len() < desc.len() {
|
||||
format!("{}...", snippet.trim_end())
|
||||
} else {
|
||||
snippet
|
||||
};
|
||||
row.set_subtitle(&subtitle);
|
||||
}
|
||||
for app in &apps[start..end] {
|
||||
let tile = catalog_tile::build_featured_tile(app);
|
||||
|
||||
// Category badges
|
||||
if let Some(ref cats) = app.categories {
|
||||
let first_cat: String = cats.split(';').next().unwrap_or("").to_string();
|
||||
if !first_cat.is_empty() {
|
||||
let badge = widgets::status_badge(&first_cat, "neutral");
|
||||
badge.set_valign(gtk::Align::Center);
|
||||
row.add_suffix(&badge);
|
||||
}
|
||||
}
|
||||
|
||||
// Show "Installed" badge if already installed, otherwise show Install button
|
||||
let is_installed = installed_names.contains(&app.name.to_lowercase());
|
||||
if is_installed {
|
||||
let installed_badge = widgets::status_badge(&i18n("Installed"), "success");
|
||||
installed_badge.set_valign(gtk::Align::Center);
|
||||
row.add_suffix(&installed_badge);
|
||||
list_box.append(&row);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Install button
|
||||
let install_btn = gtk::Button::builder()
|
||||
.label(&i18n("Install"))
|
||||
.valign(gtk::Align::Center)
|
||||
.css_classes(["suggested-action"])
|
||||
.build();
|
||||
|
||||
let download_url = app.download_url.clone();
|
||||
// Load screenshot asynchronously into the frame
|
||||
if let Some(ref screenshots_str) = app.screenshots {
|
||||
let first_screenshot = screenshots_str.split(';')
|
||||
.find(|s| !s.is_empty());
|
||||
if let Some(screenshot_path) = first_screenshot {
|
||||
let app_name = app.name.clone();
|
||||
let homepage = app.homepage.clone();
|
||||
let toast_install = toast_ref.clone();
|
||||
let db_install = db_ref.clone();
|
||||
|
||||
install_btn.connect_clicked(move |btn| {
|
||||
btn.set_sensitive(false);
|
||||
btn.set_label("Installing...");
|
||||
|
||||
let url = download_url.clone();
|
||||
let name = app_name.clone();
|
||||
let hp = homepage.clone();
|
||||
let toast_c = toast_install.clone();
|
||||
let btn_c = btn.clone();
|
||||
let db_c = db_install.clone();
|
||||
|
||||
let spath = screenshot_path.to_string();
|
||||
let frame = tile.first_child()
|
||||
.and_then(|w| w.downcast::<gtk::Frame>().ok());
|
||||
if let Some(frame) = frame {
|
||||
let frame_ref = frame.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let name = app_name.clone();
|
||||
let sp = spath.clone();
|
||||
let result = gio::spawn_blocking(move || {
|
||||
let install_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
|
||||
.join("Applications");
|
||||
std::fs::create_dir_all(&install_dir).ok();
|
||||
|
||||
let app = catalog::CatalogApp {
|
||||
name: name.clone(),
|
||||
description: None,
|
||||
categories: Vec::new(),
|
||||
latest_version: None,
|
||||
download_url: url,
|
||||
icon_url: None,
|
||||
homepage: hp,
|
||||
file_size: None,
|
||||
architecture: None,
|
||||
};
|
||||
|
||||
catalog::install_from_catalog(&app, &install_dir)
|
||||
catalog::cache_screenshot(&name, &sp, 0)
|
||||
.map_err(|e| e.to_string())
|
||||
}).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(path)) => {
|
||||
// Register in DB
|
||||
let filename = path.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let size = std::fs::metadata(&path)
|
||||
.map(|m| m.len() as i64)
|
||||
.unwrap_or(0);
|
||||
db_c.upsert_appimage(
|
||||
&path.to_string_lossy(),
|
||||
&filename,
|
||||
Some(2),
|
||||
size,
|
||||
true,
|
||||
None,
|
||||
).ok();
|
||||
toast_c.add_toast(adw::Toast::new("Installed successfully"));
|
||||
btn_c.set_label("Installed");
|
||||
Ok(Ok(local_path)) => {
|
||||
if let Ok(texture) = gtk::gdk::Texture::from_filename(&local_path) {
|
||||
let picture = gtk::Picture::builder()
|
||||
.paintable(&texture)
|
||||
.content_fit(gtk::ContentFit::Cover)
|
||||
.halign(gtk::Align::Fill)
|
||||
.valign(gtk::Align::Fill)
|
||||
.build();
|
||||
frame_ref.set_child(Some(&picture));
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::error!("Install failed: {}", e);
|
||||
toast_c.add_toast(adw::Toast::new("Install failed"));
|
||||
btn_c.set_sensitive(true);
|
||||
btn_c.set_label("Install");
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!("Install thread panicked");
|
||||
toast_c.add_toast(adw::Toast::new("Install failed"));
|
||||
btn_c.set_sensitive(true);
|
||||
btn_c.set_label("Install");
|
||||
_ => {
|
||||
let icon = gtk::Image::builder()
|
||||
.icon_name("image-missing-symbolic")
|
||||
.pixel_size(32)
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.css_classes(["dim-label"])
|
||||
.build();
|
||||
frame_ref.set_child(Some(&icon));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
row.add_suffix(&install_btn);
|
||||
list_box.append(&row);
|
||||
// Click handler
|
||||
let db_ref = db.clone();
|
||||
let nav_ref = nav_view.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let app_id = app.id;
|
||||
let click = gtk::GestureClick::new();
|
||||
click.connect_released(move |gesture, _, _, _| {
|
||||
gesture.set_state(gtk::EventSequenceState::Claimed);
|
||||
if let Ok(Some(catalog_app)) = db_ref.get_catalog_app(app_id) {
|
||||
let detail = catalog_detail::build_catalog_detail_page(
|
||||
&catalog_app, &db_ref, &toast_ref,
|
||||
);
|
||||
nav_ref.push(&detail);
|
||||
}
|
||||
});
|
||||
tile.add_controller(click);
|
||||
|
||||
page_box.append(&tile);
|
||||
}
|
||||
|
||||
// Crossfade: alternate between "page-a" and "page-b"
|
||||
let current = flip.get();
|
||||
let new_name = if current { "page-a" } else { "page-b" };
|
||||
flip.set(!current);
|
||||
|
||||
// Remove stale child with this name (from 2 transitions ago)
|
||||
if let Some(old) = stack.child_by_name(new_name) {
|
||||
stack.remove(&old);
|
||||
}
|
||||
|
||||
stack.add_named(&page_box, Some(new_name));
|
||||
stack.set_visible_child_name(new_name);
|
||||
}
|
||||
|
||||
/// Populate the main grid with catalog tiles.
|
||||
fn populate_grid(
|
||||
db: &Rc<Database>,
|
||||
query: &str,
|
||||
category: Option<&str>,
|
||||
flow_box: >k::FlowBox,
|
||||
all_label: >k::Label,
|
||||
_nav_view: &adw::NavigationView,
|
||||
_toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
// Clear existing
|
||||
while let Some(child) = flow_box.first_child() {
|
||||
flow_box.remove(&child);
|
||||
}
|
||||
|
||||
let results = db.search_catalog(query, category, 200).unwrap_or_default();
|
||||
|
||||
if results.is_empty() {
|
||||
all_label.set_label(&i18n("No results"));
|
||||
return;
|
||||
}
|
||||
|
||||
let label_text = if query.is_empty() && category.is_none() {
|
||||
format!("{} ({})", i18n("All Apps"), results.len())
|
||||
} else {
|
||||
format!("{} ({})", i18n("Results"), results.len())
|
||||
};
|
||||
all_label.set_label(&label_text);
|
||||
|
||||
for app in &results {
|
||||
let tile = catalog_tile::build_catalog_tile(app);
|
||||
// Store the app ID in the widget name for retrieval on click
|
||||
tile.set_widget_name(&format!("catalog-app-{}", app.id));
|
||||
flow_box.append(&tile);
|
||||
}
|
||||
}
|
||||
|
||||
fn populate_categories(
|
||||
db: &Rc<Database>,
|
||||
category_box: >k::Box,
|
||||
category_box: >k::FlowBox,
|
||||
active_category: &Rc<RefCell<Option<String>>>,
|
||||
results_box: >k::ListBox,
|
||||
flow_box: >k::FlowBox,
|
||||
search_entry: >k::SearchEntry,
|
||||
featured_section: >k::Box,
|
||||
all_label: >k::Label,
|
||||
nav_view: &adw::NavigationView,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
// Clear existing
|
||||
while let Some(child) = category_box.first_child() {
|
||||
@@ -410,55 +732,62 @@ fn populate_categories(
|
||||
return;
|
||||
}
|
||||
|
||||
// "All" chip
|
||||
let all_btn = gtk::ToggleButton::builder()
|
||||
.label(&i18n("All"))
|
||||
.active(true)
|
||||
.css_classes(["pill"])
|
||||
.build();
|
||||
category_box.append(&all_btn);
|
||||
category_box.insert(&all_btn, -1);
|
||||
|
||||
// Top 10 category chips
|
||||
let buttons: Rc<RefCell<Vec<gtk::ToggleButton>>> = Rc::new(RefCell::new(vec![all_btn.clone()]));
|
||||
let buttons: Rc<RefCell<Vec<gtk::ToggleButton>>> =
|
||||
Rc::new(RefCell::new(vec![all_btn.clone()]));
|
||||
|
||||
for (cat, _count) in categories.iter().take(10) {
|
||||
let btn = gtk::ToggleButton::builder()
|
||||
.label(cat)
|
||||
.css_classes(["pill"])
|
||||
.build();
|
||||
category_box.append(&btn);
|
||||
category_box.insert(&btn, -1);
|
||||
buttons.borrow_mut().push(btn.clone());
|
||||
|
||||
let cat_str = cat.clone();
|
||||
let active_ref = active_category.clone();
|
||||
let results_ref = results_box.clone();
|
||||
let flow_ref = flow_box.clone();
|
||||
let search_ref = search_entry.clone();
|
||||
let db_ref = db.clone();
|
||||
let buttons_ref = buttons.clone();
|
||||
let featured_section_ref = featured_section.clone();
|
||||
let all_label_ref = all_label.clone();
|
||||
let nav_ref = nav_view.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
btn.connect_toggled(move |btn| {
|
||||
if btn.is_active() {
|
||||
// Deactivate others
|
||||
for other in buttons_ref.borrow().iter() {
|
||||
if other != btn {
|
||||
other.set_active(false);
|
||||
}
|
||||
}
|
||||
*active_ref.borrow_mut() = Some(cat_str.clone());
|
||||
featured_section_ref.set_visible(false);
|
||||
let query = search_ref.text().to_string();
|
||||
// Use a dummy toast overlay for filtering
|
||||
let toast = adw::ToastOverlay::new();
|
||||
populate_results(&db_ref, &query, Some(&cat_str), &results_ref, &toast);
|
||||
populate_grid(
|
||||
&db_ref, &query, Some(&cat_str), &flow_ref,
|
||||
&all_label_ref, &nav_ref, &toast_ref,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// "All" button handler
|
||||
{
|
||||
let active_ref = active_category.clone();
|
||||
let results_ref = results_box.clone();
|
||||
let flow_ref = flow_box.clone();
|
||||
let search_ref = search_entry.clone();
|
||||
let db_ref = db.clone();
|
||||
let buttons_ref = buttons.clone();
|
||||
let featured_section_ref = featured_section.clone();
|
||||
let all_label_ref = all_label.clone();
|
||||
let nav_ref = nav_view.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
all_btn.connect_toggled(move |btn| {
|
||||
if btn.is_active() {
|
||||
for other in buttons_ref.borrow().iter() {
|
||||
@@ -467,9 +796,12 @@ fn populate_categories(
|
||||
}
|
||||
}
|
||||
*active_ref.borrow_mut() = None;
|
||||
featured_section_ref.set_visible(true);
|
||||
let query = search_ref.text().to_string();
|
||||
let toast = adw::ToastOverlay::new();
|
||||
populate_results(&db_ref, &query, None, &results_ref, &toast);
|
||||
populate_grid(
|
||||
&db_ref, &query, None, &flow_ref,
|
||||
&all_label_ref, &nav_ref, &toast_ref,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2384,7 +2384,7 @@ fn fuse_install_command(status: &FuseStatus) -> Option<&'static str> {
|
||||
|
||||
/// Show a screenshot in a fullscreen lightbox window with prev/next navigation.
|
||||
/// Uses a separate gtk::Window to avoid parent scroll position interference.
|
||||
fn show_screenshot_lightbox(
|
||||
pub fn show_screenshot_lightbox(
|
||||
parent: >k::Window,
|
||||
textures: &Rc<std::cell::RefCell<Vec<Option<gtk::gdk::Texture>>>>,
|
||||
initial_index: usize,
|
||||
@@ -2607,12 +2607,23 @@ fn fetch_favicon_async(url: &str, image: >k::Image) {
|
||||
});
|
||||
}
|
||||
|
||||
fn show_uninstall_dialog(
|
||||
pub fn show_uninstall_dialog(
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
record: &AppImageRecord,
|
||||
db: &Rc<Database>,
|
||||
is_integrated: bool,
|
||||
data_paths: &[(String, String, u64)],
|
||||
) {
|
||||
show_uninstall_dialog_with_callback(toast_overlay, record, db, is_integrated, data_paths, None);
|
||||
}
|
||||
|
||||
pub fn show_uninstall_dialog_with_callback(
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
record: &AppImageRecord,
|
||||
db: &Rc<Database>,
|
||||
is_integrated: bool,
|
||||
data_paths: &[(String, String, u64)],
|
||||
on_complete: Option<Box<dyn FnOnce() + 'static>>,
|
||||
) {
|
||||
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
@@ -2667,6 +2678,7 @@ fn show_uninstall_dialog(
|
||||
let record_path = record.path.clone();
|
||||
let db_ref = db.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let on_complete = std::cell::Cell::new(on_complete);
|
||||
dialog.connect_response(Some("uninstall"), move |_dlg, _response| {
|
||||
// Remove integration if checked
|
||||
if let Some(ref check) = integration_check {
|
||||
@@ -2700,6 +2712,11 @@ fn show_uninstall_dialog(
|
||||
|
||||
toast_ref.add_toast(adw::Toast::new("AppImage uninstalled"));
|
||||
|
||||
// Run the completion callback if provided
|
||||
if let Some(cb) = on_complete.take() {
|
||||
cb();
|
||||
}
|
||||
|
||||
// Navigate back (the detail view is now stale)
|
||||
if let Some(nav) = toast_ref.ancestor(adw::NavigationView::static_type()) {
|
||||
let nav: adw::NavigationView = nav.downcast().unwrap();
|
||||
|
||||
@@ -589,13 +589,13 @@ impl LibraryView {
|
||||
// Grid card
|
||||
let card = app_card::build_app_card(record);
|
||||
let card_menu = build_context_menu(record);
|
||||
attach_context_menu(&card, &card_menu);
|
||||
attach_context_menu(&card, &card_menu, record.id);
|
||||
self.flow_box.append(&card);
|
||||
|
||||
// List row
|
||||
let row = self.build_list_row(record);
|
||||
let row_menu = build_context_menu(record);
|
||||
attach_context_menu(&row, &row_menu);
|
||||
attach_context_menu(&row, &row_menu, record.id);
|
||||
self.list_box.append(&row);
|
||||
}
|
||||
|
||||
@@ -812,15 +812,39 @@ fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu {
|
||||
section4.append(Some("Copy file location"), Some(&format!("win.copy-path(int64 {})", record.id)));
|
||||
menu.append_section(None, §ion4);
|
||||
|
||||
// Section 5: Destructive actions
|
||||
let section5 = gtk::gio::Menu::new();
|
||||
let uninstall_item = gtk::gio::MenuItem::new(None, Some(&format!("win.uninstall-appimage(int64 {})", record.id)));
|
||||
uninstall_item.set_attribute_value("custom", Some(&"uninstall".to_variant()));
|
||||
section5.append_item(&uninstall_item);
|
||||
menu.append_section(None, §ion5);
|
||||
|
||||
menu
|
||||
}
|
||||
|
||||
/// Attach a right-click context menu to a widget.
|
||||
fn attach_context_menu(widget: &impl gtk::prelude::IsA<gtk::Widget>, menu_model: >k::gio::Menu) {
|
||||
let popover = gtk::PopoverMenu::from_model(Some(menu_model));
|
||||
fn attach_context_menu(widget: &impl gtk::prelude::IsA<gtk::Widget>, menu_model: >k::gio::Menu, record_id: i64) {
|
||||
let popover = gtk::PopoverMenu::from_model_full(menu_model, gtk::PopoverMenuFlags::NESTED);
|
||||
popover.set_parent(widget.as_ref());
|
||||
popover.set_has_arrow(false);
|
||||
|
||||
// Add custom destructive-styled uninstall button
|
||||
let uninstall_btn = gtk::Button::builder()
|
||||
.label("Uninstall")
|
||||
.build();
|
||||
uninstall_btn.add_css_class("destructive-context-item");
|
||||
// Left-align the label to match other menu items
|
||||
if let Some(label) = uninstall_btn.child().and_then(|c| c.downcast::<gtk::Label>().ok()) {
|
||||
label.set_halign(gtk::Align::Start);
|
||||
}
|
||||
uninstall_btn.set_action_name(Some("win.uninstall-appimage"));
|
||||
uninstall_btn.set_action_target_value(Some(&record_id.to_variant()));
|
||||
let popover_ref = popover.clone();
|
||||
uninstall_btn.connect_clicked(move |_| {
|
||||
popover_ref.popdown();
|
||||
});
|
||||
popover.add_child(&uninstall_btn, "uninstall");
|
||||
|
||||
// Unparent the popover when the widget is destroyed to avoid GTK warnings
|
||||
let popover_cleanup = popover.clone();
|
||||
widget.as_ref().connect_destroy(move |_| {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod app_card;
|
||||
pub mod batch_update_dialog;
|
||||
pub mod catalog_detail;
|
||||
pub mod catalog_tile;
|
||||
pub mod catalog_view;
|
||||
pub mod cleanup_wizard;
|
||||
pub mod dashboard;
|
||||
|
||||
@@ -413,6 +413,44 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
|
||||
|
||||
page.add(&security_group);
|
||||
|
||||
// Catalog Enrichment group
|
||||
let enrichment_group = adw::PreferencesGroup::builder()
|
||||
.title(&i18n("Catalog Enrichment"))
|
||||
.description(&i18n("Fetch GitHub metadata (stars, version, downloads) for catalog apps"))
|
||||
.build();
|
||||
|
||||
let auto_enrich_row = adw::SwitchRow::builder()
|
||||
.title(&i18n("Auto-enrich catalog apps"))
|
||||
.subtitle(&i18n("Fetch metadata from GitHub in the background"))
|
||||
.active(settings.boolean("catalog-auto-enrich"))
|
||||
.build();
|
||||
let settings_enrich = settings.clone();
|
||||
auto_enrich_row.connect_active_notify(move |row| {
|
||||
settings_enrich.set_boolean("catalog-auto-enrich", row.is_active()).ok();
|
||||
});
|
||||
enrichment_group.add(&auto_enrich_row);
|
||||
|
||||
let token_row = adw::PasswordEntryRow::builder()
|
||||
.title(&i18n("GitHub token"))
|
||||
.build();
|
||||
let current_token = settings.string("github-token");
|
||||
if !current_token.is_empty() {
|
||||
token_row.set_text(¤t_token);
|
||||
}
|
||||
let settings_token = settings.clone();
|
||||
token_row.connect_changed(move |row| {
|
||||
settings_token.set_string("github-token", &row.text()).ok();
|
||||
});
|
||||
enrichment_group.add(&token_row);
|
||||
|
||||
let token_hint = adw::ActionRow::builder()
|
||||
.title(&i18n("Optional - increases rate limit from 60 to 5,000 requests per hour"))
|
||||
.css_classes(["dim-label"])
|
||||
.build();
|
||||
enrichment_group.add(&token_hint);
|
||||
|
||||
page.add(&enrichment_group);
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ pub fn format_size(bytes: i64) -> String {
|
||||
/// If the icon_path exists and is loadable, show the real icon.
|
||||
/// Otherwise, generate a colored circle with the first letter of the app name.
|
||||
pub fn app_icon(icon_path: Option<&str>, app_name: &str, pixel_size: i32) -> gtk::Widget {
|
||||
// Try to load from path
|
||||
// Try to load from explicit path
|
||||
if let Some(icon_path) = icon_path {
|
||||
let path = std::path::Path::new(icon_path);
|
||||
if path.exists() {
|
||||
@@ -119,6 +119,20 @@ pub fn app_icon(icon_path: Option<&str>, app_name: &str, pixel_size: i32) -> gtk
|
||||
}
|
||||
}
|
||||
|
||||
// Try cached catalog icon
|
||||
let cache_dir = crate::core::catalog::icon_cache_dir();
|
||||
let sanitized = crate::core::catalog::sanitize_filename(app_name);
|
||||
let cached_path = cache_dir.join(format!("{}.png", sanitized));
|
||||
if cached_path.exists() {
|
||||
if let Ok(texture) = gtk::gdk::Texture::from_filename(&cached_path) {
|
||||
let image = gtk::Image::builder()
|
||||
.pixel_size(pixel_size)
|
||||
.build();
|
||||
image.set_paintable(Some(&texture));
|
||||
return image.upcast();
|
||||
}
|
||||
}
|
||||
|
||||
// Letter-circle fallback
|
||||
build_letter_icon(app_name, pixel_size)
|
||||
}
|
||||
@@ -383,6 +397,17 @@ pub fn relative_time(timestamp: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a count with K/M suffixes for readability.
|
||||
pub fn format_count(n: i64) -> String {
|
||||
if n >= 1_000_000 {
|
||||
format!("{:.1}M", n as f64 / 1_000_000.0)
|
||||
} else if n >= 1_000 {
|
||||
format!("{:.1}K", n as f64 / 1_000.0)
|
||||
} else {
|
||||
n.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a screen-reader live region announcement.
|
||||
/// Inserts a hidden label with AccessibleRole::Alert into the given container,
|
||||
/// which causes AT-SPI to announce the text to screen readers.
|
||||
|
||||
199
src/window.rs
199
src/window.rs
@@ -9,6 +9,7 @@ use crate::config::APP_ID;
|
||||
use crate::core::analysis;
|
||||
use crate::core::database::Database;
|
||||
use crate::core::discovery;
|
||||
use crate::core::footprint;
|
||||
use crate::core::integrator;
|
||||
use crate::core::launcher;
|
||||
use crate::core::notification;
|
||||
@@ -43,6 +44,7 @@ mod imp {
|
||||
pub drop_overlay: OnceCell<gtk::Box>,
|
||||
pub drop_revealer: OnceCell<gtk::Revealer>,
|
||||
pub watcher_handle: std::cell::RefCell<Option<notify::RecommendedWatcher>>,
|
||||
pub enrichment_banner: OnceCell<gtk::Box>,
|
||||
}
|
||||
|
||||
impl Default for DriftwoodWindow {
|
||||
@@ -57,6 +59,7 @@ mod imp {
|
||||
drop_overlay: OnceCell::new(),
|
||||
drop_revealer: OnceCell::new(),
|
||||
watcher_handle: std::cell::RefCell::new(None),
|
||||
enrichment_banner: OnceCell::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +102,22 @@ glib::wrapper! {
|
||||
gtk::Native, gtk::Root, gtk::ShortcutManager;
|
||||
}
|
||||
|
||||
/// Find a child widget by its widget name (breadth-first).
|
||||
fn find_child_by_name(parent: &impl IsA<gtk::Widget>, name: &str) -> Option<gtk::Widget> {
|
||||
let parent_widget = parent.upcast_ref::<gtk::Widget>();
|
||||
let mut child = parent_widget.first_child();
|
||||
while let Some(c) = child {
|
||||
if c.widget_name() == name {
|
||||
return Some(c);
|
||||
}
|
||||
if let Some(found) = find_child_by_name(&c, name) {
|
||||
return Some(found);
|
||||
}
|
||||
child = c.next_sibling();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn shortcut_row(accel: &str, description: &str) -> adw::ActionRow {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(description)
|
||||
@@ -166,8 +185,8 @@ impl DriftwoodWindow {
|
||||
let installed_nav = adw::NavigationView::new();
|
||||
installed_nav.push(&library_view.page);
|
||||
|
||||
// Catalog view
|
||||
let catalog_page = catalog_view::build_catalog_page(self.database());
|
||||
// Catalog view (has its own internal NavigationView for drill-down)
|
||||
let (catalog_nav, enrichment_banner) = catalog_view::build_catalog_page(self.database());
|
||||
|
||||
// Updates view
|
||||
let updates_toolbar = updates_view::build_updates_view(self.database());
|
||||
@@ -179,7 +198,7 @@ impl DriftwoodWindow {
|
||||
let installed_vs_page = view_stack.add_titled(&installed_nav, Some("installed"), &i18n("Installed"));
|
||||
installed_vs_page.set_icon_name(Some("view-grid-symbolic"));
|
||||
|
||||
let catalog_vs_page = view_stack.add_titled(&catalog_page, Some("catalog"), &i18n("Catalog"));
|
||||
let catalog_vs_page = view_stack.add_titled(&catalog_nav, Some("catalog"), &i18n("Catalog"));
|
||||
catalog_vs_page.set_icon_name(Some("system-software-install-symbolic"));
|
||||
|
||||
let updates_vs_page = view_stack.add_titled(&updates_toolbar, Some("updates"), &i18n("Updates"));
|
||||
@@ -481,6 +500,10 @@ impl DriftwoodWindow {
|
||||
if self.imp().library_view.set(library_view).is_err() {
|
||||
panic!("LibraryView already set");
|
||||
}
|
||||
self.imp()
|
||||
.enrichment_banner
|
||||
.set(enrichment_banner)
|
||||
.expect("EnrichmentBanner already set");
|
||||
|
||||
// Set up window actions
|
||||
self.setup_window_actions();
|
||||
@@ -1010,6 +1033,48 @@ impl DriftwoodWindow {
|
||||
}
|
||||
self.add_action(©_path_action);
|
||||
|
||||
// Uninstall action (from right-click context menu)
|
||||
let uninstall_action = gio::SimpleAction::new("uninstall-appimage", param_type);
|
||||
{
|
||||
let window_weak = self.downgrade();
|
||||
uninstall_action.connect_activate(move |_, param| {
|
||||
let Some(window) = window_weak.upgrade() else { return };
|
||||
let Some(record_id) = param.and_then(|p| p.get::<i64>()) else { return };
|
||||
let db = window.database().clone();
|
||||
let toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
|
||||
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
|
||||
let fp = footprint::get_footprint(&db, record.id, record.size_bytes as u64);
|
||||
let fp_paths: Vec<(String, String, u64)> = fp.paths.iter()
|
||||
.filter(|p| p.exists)
|
||||
.map(|p| (
|
||||
p.path.to_string_lossy().to_string(),
|
||||
p.path_type.label().to_string(),
|
||||
p.size_bytes,
|
||||
))
|
||||
.collect();
|
||||
let is_integrated = record.integrated;
|
||||
let window_ref = window.clone();
|
||||
let db_refresh = db.clone();
|
||||
detail_view::show_uninstall_dialog_with_callback(
|
||||
&toast_overlay,
|
||||
&record,
|
||||
&db,
|
||||
is_integrated,
|
||||
&fp_paths,
|
||||
Some(Box::new(move || {
|
||||
// Refresh the library view after uninstall
|
||||
if let Some(lib_view) = window_ref.imp().library_view.get() {
|
||||
if let Ok(records) = db_refresh.get_all_appimages() {
|
||||
lib_view.populate(records);
|
||||
}
|
||||
}
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
self.add_action(&uninstall_action);
|
||||
|
||||
// View switching actions for keyboard shortcuts
|
||||
let show_installed_action = gio::SimpleAction::new("show-installed", None);
|
||||
{
|
||||
@@ -1185,6 +1250,134 @@ impl DriftwoodWindow {
|
||||
|
||||
// Update badge on Updates tab
|
||||
self.refresh_update_badge();
|
||||
|
||||
// Background GitHub enrichment
|
||||
self.start_background_enrichment();
|
||||
}
|
||||
|
||||
fn start_background_enrichment(&self) {
|
||||
let settings = self.settings().clone();
|
||||
if !settings.boolean("catalog-auto-enrich") {
|
||||
return;
|
||||
}
|
||||
|
||||
let db = self.database().clone();
|
||||
|
||||
// Check if there are unenriched apps
|
||||
let (enriched, total) = db.catalog_enrichment_progress().unwrap_or((0, 0));
|
||||
if total == 0 || enriched >= total {
|
||||
return;
|
||||
}
|
||||
|
||||
let banner = self.imp().enrichment_banner.get().cloned();
|
||||
let view_stack = self.imp().view_stack.get().cloned();
|
||||
|
||||
// Show banner initially
|
||||
if let Some(ref b) = banner {
|
||||
b.set_visible(true);
|
||||
if let Some(label) = find_child_by_name(b, "enrich-label") {
|
||||
if let Ok(l) = label.downcast::<gtk::Label>() {
|
||||
l.set_label(&format!("Enriching app data from GitHub ({}/{})...", enriched, total));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Channel for progress updates
|
||||
let (tx, rx) = std::sync::mpsc::channel::<(i64, i64, bool)>();
|
||||
|
||||
let token = settings.string("github-token").to_string();
|
||||
|
||||
// Background thread: runs batch enrichment
|
||||
glib::spawn_future_local(async move {
|
||||
let tx_c = tx.clone();
|
||||
|
||||
gio::spawn_blocking(move || {
|
||||
let bg_db = match crate::core::database::Database::open() {
|
||||
Ok(db) => db,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
loop {
|
||||
let result = crate::core::github_enrichment::background_enrich_batch(
|
||||
&bg_db,
|
||||
&token,
|
||||
20,
|
||||
&|done, total| {
|
||||
tx_c.send((done, total, false)).ok();
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok((count, should_continue)) => {
|
||||
if count == 0 || !should_continue {
|
||||
// Done or rate limited
|
||||
if let Ok((done, total)) = bg_db.catalog_enrichment_progress() {
|
||||
tx_c.send((done, total, true)).ok();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Background enrichment error: {}", e);
|
||||
tx_c.send((0, 0, true)).ok();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}).await.ok();
|
||||
});
|
||||
|
||||
// Poll progress on the UI thread.
|
||||
// Timer keeps running until enrichment is truly complete (all apps enriched).
|
||||
// When rate-limited, shows a paused message but keeps the banner visible.
|
||||
let banner_ref = banner;
|
||||
glib::timeout_add_local(std::time::Duration::from_millis(250), move || {
|
||||
while let Ok((done, total, finished)) = rx.try_recv() {
|
||||
if let Some(ref b) = banner_ref {
|
||||
if finished {
|
||||
if total == 0 || done >= total {
|
||||
// Truly done - all apps enriched (or error with no data)
|
||||
b.set_visible(false);
|
||||
return glib::ControlFlow::Break;
|
||||
}
|
||||
// Rate limited or paused - keep banner visible with updated text
|
||||
if let Some(spinner) = find_child_by_name(b, "enrich-spinner") {
|
||||
if let Ok(s) = spinner.downcast::<gtk::Spinner>() {
|
||||
s.set_spinning(false);
|
||||
}
|
||||
}
|
||||
if let Some(label) = find_child_by_name(b, "enrich-label") {
|
||||
if let Ok(l) = label.downcast::<gtk::Label>() {
|
||||
l.set_label(&format!(
|
||||
"Enriching paused - rate limit ({}/{} enriched)", done, total,
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Active progress update
|
||||
if let Some(label) = find_child_by_name(b, "enrich-label") {
|
||||
if let Ok(l) = label.downcast::<gtk::Label>() {
|
||||
l.set_label(&format!(
|
||||
"Enriching app data from GitHub ({}/{})...", done, total,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show banner only when on the catalog tab
|
||||
if let Some(ref vs) = view_stack {
|
||||
let on_catalog = vs.visible_child_name()
|
||||
.map(|n| n == "catalog")
|
||||
.unwrap_or(false);
|
||||
if let Some(ref b) = banner_ref {
|
||||
b.set_visible(on_catalog);
|
||||
}
|
||||
}
|
||||
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
|
||||
fn trigger_scan(&self) {
|
||||
|
||||
Reference in New Issue
Block a user