Add UX enhancements: carousel, filter chips, command palette, and more

This commit is contained in:
2026-03-01 00:39:43 +02:00
parent 960eab965d
commit 5112338c0f
25 changed files with 1711 additions and 481 deletions

View File

@@ -35,6 +35,16 @@ impl CatalogType {
}
}
/// Human-readable label for display in the UI.
pub fn label(&self) -> &str {
match self {
Self::AppImageHub => "Community Feed",
Self::OcsAppImageHub => "AppImageHub Catalog",
Self::GitHubSearch => "GitHub Search",
Self::Custom => "Custom Source",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"appimage-hub" => Self::AppImageHub,
@@ -45,6 +55,12 @@ impl CatalogType {
}
}
impl std::fmt::Display for CatalogType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// An app entry from a catalog source.
#[derive(Debug, Clone)]
pub struct CatalogApp {
@@ -212,6 +228,7 @@ pub enum SyncProgress {
AllDone,
}
#[allow(dead_code)]
pub fn sync_catalog(db: &Database, source: &CatalogSource) -> Result<u32, CatalogError> {
sync_catalog_with_progress(db, source, &|_| {})
}
@@ -249,6 +266,12 @@ pub fn sync_catalog_with_progress(
let source_id = source.id.ok_or(CatalogError::NoSourceId)?;
// Clean up old duplicates: delete secondary source entries that now have OCS counterparts
match db.delete_secondary_duplicates(source_id) {
Ok(n) if n > 0 => log::info!("Removed {} secondary duplicates with OCS counterparts", n),
_ => {}
}
// Build set of OCS app names for dedup (skip apps already in OCS source)
let ocs_names = get_ocs_source_names(db);
@@ -305,6 +328,7 @@ pub fn sync_catalog_with_progress(
/// Download an AppImage from the catalog to a local directory.
/// If `ocs_id` is provided, resolves a fresh download URL from the OCS API
/// (since OCS download links use JWT tokens that expire).
#[allow(dead_code)]
pub fn install_from_catalog(app: &CatalogApp, install_dir: &Path) -> Result<PathBuf, CatalogError> {
install_from_catalog_with_ocs(app, install_dir, None, 1)
}
@@ -876,14 +900,14 @@ fn fetch_custom_catalog(url: &str) -> Result<Vec<CatalogApp>, CatalogError> {
pub fn ensure_default_sources(db: &Database) {
// Primary: OCS AppImageHub.com (insert first so it syncs first)
db.upsert_catalog_source(
"AppImageHub.com",
"AppImageHub Catalog",
OCS_API_URL,
"ocs-appimagehub",
).ok();
// Secondary: appimage.github.io feed
db.upsert_catalog_source(
"AppImageHub",
"Community Feed",
APPIMAGEHUB_API_URL,
"appimage-hub",
).ok();
@@ -1005,6 +1029,7 @@ pub fn sanitize_filename(name: &str) -> String {
/// Download icons for all catalog apps that have icon_url set.
/// Saves to ~/.cache/driftwood/icons/{sanitized_name}.png
#[allow(dead_code)]
fn cache_catalog_icons(apps: &[CatalogApp]) -> u32 {
cache_catalog_icons_with_progress(apps, &|_| {})
}

View File

@@ -92,23 +92,21 @@ pub struct SystemModification {
pub enum CatalogSortOrder {
NameAsc,
NameDesc,
StarsDesc,
StarsAsc,
DownloadsDesc,
DownloadsAsc,
PopularityDesc,
PopularityAsc,
ReleaseDateDesc,
ReleaseDateAsc,
}
impl CatalogSortOrder {
/// Popularity combines OCS downloads, GitHub stars, and GitHub downloads
/// into a single comparable score.
pub fn sql_clause(&self) -> &'static str {
match self {
Self::NameAsc => "ORDER BY name COLLATE NOCASE ASC",
Self::NameDesc => "ORDER BY name COLLATE NOCASE DESC",
Self::StarsDesc => "ORDER BY COALESCE(github_stars, 0) DESC, name COLLATE NOCASE ASC",
Self::StarsAsc => "ORDER BY CASE WHEN github_stars IS NULL THEN 1 ELSE 0 END, github_stars ASC, name COLLATE NOCASE ASC",
Self::DownloadsDesc => "ORDER BY (COALESCE(ocs_downloads, 0) + COALESCE(github_downloads, 0)) DESC, name COLLATE NOCASE ASC",
Self::DownloadsAsc => "ORDER BY CASE WHEN ocs_downloads IS NULL AND github_downloads IS NULL THEN 1 ELSE 0 END, (COALESCE(ocs_downloads, 0) + COALESCE(github_downloads, 0)) ASC, name COLLATE NOCASE ASC",
Self::PopularityDesc => "ORDER BY (COALESCE(ocs_downloads, 0) + COALESCE(github_stars, 0) + COALESCE(github_downloads, 0)) DESC, name COLLATE NOCASE ASC",
Self::PopularityAsc => "ORDER BY CASE WHEN ocs_downloads IS NULL AND github_stars IS NULL AND github_downloads IS NULL THEN 1 ELSE 0 END, (COALESCE(ocs_downloads, 0) + COALESCE(github_stars, 0) + COALESCE(github_downloads, 0)) ASC, name COLLATE NOCASE ASC",
Self::ReleaseDateDesc => "ORDER BY COALESCE(release_date, '0000') DESC, name COLLATE NOCASE ASC",
Self::ReleaseDateAsc => "ORDER BY CASE WHEN release_date IS NULL THEN 1 ELSE 0 END, release_date ASC, name COLLATE NOCASE ASC",
}
@@ -1225,6 +1223,20 @@ impl Database {
ocs_version, ocs_tags, ocs_changed, ocs_preview_url,
ocs_detailpage, ocs_created, ocs_downloadname, ocs_downloadsize, ocs_arch, ocs_md5sum, ocs_comments";
/// SQL filter that deduplicates catalog apps by lowercase name.
/// Keeps the OCS entry when both OCS and secondary source entries exist for the same name.
/// Also handles within-source case duplicates (e.g. "Sabaki" vs "sabaki").
const CATALOG_DEDUP_FILTER: &str =
"AND id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY LOWER(name)
ORDER BY CASE WHEN ocs_id IS NOT NULL THEN 0 ELSE 1 END, id DESC
) AS rn
FROM catalog_apps
) WHERE rn = 1
)";
fn catalog_app_from_row(row: &rusqlite::Row) -> rusqlite::Result<CatalogApp> {
Ok(CatalogApp {
id: row.get(0)?,
@@ -1371,6 +1383,37 @@ impl Database {
Ok(())
}
/// Re-insert a previously deleted AppImageRecord with its original ID.
/// Used for undo-uninstall support.
pub fn restore_appimage_record(&self, r: &AppImageRecord) -> SqlResult<()> {
self.conn.execute(
&format!(
"INSERT OR REPLACE INTO appimages ({}) VALUES ({})",
Self::APPIMAGE_COLUMNS,
(1..=63).map(|i| format!("?{}", i)).collect::<Vec<_>>().join(", ")
),
params![
r.id, r.path, r.filename, r.app_name, r.app_version, r.appimage_type,
r.size_bytes, r.sha256, r.icon_path, r.desktop_file, r.integrated,
r.integrated_at, r.is_executable, r.desktop_entry_content,
r.categories, r.description, r.developer, r.architecture,
r.first_seen, r.last_scanned, r.file_modified,
r.fuse_status, r.wayland_status, r.update_info, r.update_type,
r.latest_version, r.update_checked, r.update_url, r.notes, r.sandbox_mode,
r.runtime_wayland_status, r.runtime_wayland_checked, r.analysis_status,
r.launch_args, r.tags, r.pinned, r.avg_startup_ms,
r.appstream_id, r.appstream_description, r.generic_name, r.license,
r.homepage_url, r.bugtracker_url, r.donation_url, r.help_url, r.vcs_url,
r.keywords, r.mime_types, r.content_rating, r.project_group,
r.release_history, r.desktop_actions, r.has_signature, r.screenshot_urls,
r.previous_version_path, r.source_url, r.autostart, r.startup_wm_class,
r.verification_status, r.first_run_prompted, r.system_wide, r.is_portable,
r.mount_point,
],
)?;
Ok(())
}
pub fn remove_missing_appimages(&self) -> SqlResult<Vec<AppImageRecord>> {
let all = self.get_all_appimages()?;
let mut removed = Vec::new();
@@ -1984,7 +2027,34 @@ impl Database {
Ok(rows.next().transpose()?)
}
// --- Phase 5: Runtime Updates ---
pub fn delete_sandbox_profile(&self, profile_id: i64) -> SqlResult<()> {
self.conn.execute(
"DELETE FROM sandbox_profiles WHERE id = ?1",
params![profile_id],
)?;
Ok(())
}
pub fn get_all_sandbox_profiles(&self) -> SqlResult<Vec<SandboxProfileRecord>> {
let mut stmt = self.conn.prepare(
"SELECT id, app_name, profile_version, author, description, content, source, registry_id, created_at
FROM sandbox_profiles ORDER BY app_name ASC"
)?;
let rows = stmt.query_map([], |row| {
Ok(SandboxProfileRecord {
id: row.get(0)?,
app_name: row.get(1)?,
profile_version: row.get(2)?,
author: row.get(3)?,
description: row.get(4)?,
content: row.get(5)?,
source: row.get(6)?,
registry_id: row.get(7)?,
created_at: row.get(8)?,
})
})?;
rows.collect()
}
// --- Phase 6: Tags, Pin, Startup Time ---
@@ -2332,11 +2402,13 @@ impl Database {
query: &str,
category: Option<&str>,
limit: i32,
offset: i32,
sort: CatalogSortOrder,
) -> SqlResult<Vec<CatalogApp>> {
let mut sql = format!(
"SELECT {} FROM catalog_apps WHERE 1=1",
Self::CATALOG_APP_COLUMNS
"SELECT {} FROM catalog_apps WHERE 1=1 {}",
Self::CATALOG_APP_COLUMNS,
Self::CATALOG_DEDUP_FILTER,
);
let mut params_list: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
@@ -2351,7 +2423,7 @@ impl Database {
params_list.push(Box::new(format!("%{}%", cat)));
}
sql.push_str(&format!(" {} LIMIT {}", sort.sql_clause(), limit));
sql.push_str(&format!(" {} LIMIT {} OFFSET {}", sort.sql_clause(), limit, offset));
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
params_list.iter().map(|p| p.as_ref()).collect();
@@ -2366,6 +2438,34 @@ impl Database {
Ok(results)
}
/// Count matching catalog apps (for pagination).
pub fn count_catalog_matches(
&self,
query: &str,
category: Option<&str>,
) -> SqlResult<i32> {
let mut sql = format!(
"SELECT COUNT(*) FROM catalog_apps WHERE 1=1 {}",
Self::CATALOG_DEDUP_FILTER,
);
let mut params_list: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if !query.is_empty() {
sql.push_str(" AND (name LIKE ?1 OR description LIKE ?1 OR ocs_summary LIKE ?1 OR ocs_description LIKE ?1)");
params_list.push(Box::new(format!("%{}%", query)));
}
if let Some(cat) = category {
let idx = params_list.len() + 1;
sql.push_str(&format!(" AND categories LIKE ?{}", idx));
params_list.push(Box::new(format!("%{}%", cat)));
}
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
params_list.iter().map(|p| p.as_ref()).collect();
self.conn.query_row(&sql, params_refs.as_slice(), |row| row.get(0))
}
pub fn get_catalog_app(&self, id: i64) -> SqlResult<Option<CatalogApp>> {
let sql = format!(
"SELECT {} FROM catalog_apps WHERE id = ?1",
@@ -2395,8 +2495,10 @@ impl Database {
AND (description IS NOT NULL AND description != ''
OR ocs_summary IS NOT NULL AND ocs_summary != '')
AND (screenshots IS NOT NULL AND screenshots != ''
OR ocs_preview_url IS NOT NULL AND ocs_preview_url != '')",
Self::CATALOG_APP_COLUMNS
OR ocs_preview_url IS NOT NULL AND ocs_preview_url != '')
{}",
Self::CATALOG_APP_COLUMNS,
Self::CATALOG_DEDUP_FILTER,
);
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map([], Self::catalog_app_from_row)?;
@@ -2425,9 +2527,11 @@ impl Database {
}
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 != ''"
)?;
let sql = format!(
"SELECT categories FROM catalog_apps WHERE categories IS NOT NULL AND categories != '' {}",
Self::CATALOG_DEDUP_FILTER,
);
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
let mut counts = std::collections::HashMap::new();
@@ -2445,7 +2549,11 @@ impl Database {
}
pub fn catalog_app_count(&self) -> SqlResult<i64> {
self.conn.query_row("SELECT COUNT(*) FROM catalog_apps", [], |row| row.get(0))
let sql = format!(
"SELECT COUNT(*) FROM catalog_apps WHERE 1=1 {}",
Self::CATALOG_DEDUP_FILTER,
);
self.conn.query_row(&sql, [], |row| row.get(0))
}
pub fn insert_catalog_app(
@@ -2562,6 +2670,20 @@ impl Database {
Ok(())
}
/// Delete secondary source entries that have a matching name in the OCS source.
/// This cleans up duplicates from before OCS was added as primary source.
pub fn delete_secondary_duplicates(&self, secondary_source_id: i64) -> SqlResult<usize> {
self.conn.execute(
"DELETE FROM catalog_apps
WHERE source_id = ?1
AND LOWER(name) IN (
SELECT LOWER(name) FROM catalog_apps
WHERE source_id != ?1 AND ocs_id IS NOT NULL
)",
params![secondary_source_id],
)
}
/// Get all app names for a given source (used for deduplication).
pub fn get_catalog_app_names_for_source(&self, source_id: i64) -> SqlResult<std::collections::HashSet<String>> {
let mut stmt = self.conn.prepare(
@@ -2702,9 +2824,11 @@ impl Database {
let sql = format!(
"SELECT {} FROM catalog_apps
WHERE github_owner IS NOT NULL AND github_enriched_at IS NULL
{}
ORDER BY id
LIMIT ?1",
Self::CATALOG_APP_COLUMNS
Self::CATALOG_APP_COLUMNS,
Self::CATALOG_DEDUP_FILTER,
);
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map(params![limit], Self::catalog_app_from_row)?;
@@ -2712,16 +2836,16 @@ impl Database {
}
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),
)?;
let enriched_sql = format!(
"SELECT COUNT(*) FROM catalog_apps WHERE github_owner IS NOT NULL AND github_enriched_at IS NOT NULL {}",
Self::CATALOG_DEDUP_FILTER,
);
let enriched: i64 = self.conn.query_row(&enriched_sql, [], |row| row.get(0))?;
let total_sql = format!(
"SELECT COUNT(*) FROM catalog_apps WHERE github_owner IS NOT NULL {}",
Self::CATALOG_DEDUP_FILTER,
);
let total_with_github: i64 = self.conn.query_row(&total_sql, [], |row| row.get(0))?;
Ok((enriched, total_with_github))
}

View File

@@ -19,7 +19,7 @@ impl std::fmt::Display for InspectorError {
match self {
Self::IoError(e) => write!(f, "I/O error: {}", e),
Self::NoOffset => write!(f, "Could not determine squashfs offset"),
Self::UnsquashfsNotFound => write!(f, "unsquashfs not found - install squashfs-tools"),
Self::UnsquashfsNotFound => write!(f, "A system tool needed to read app contents is missing. Install it by running: sudo apt install squashfs-tools"),
Self::UnsquashfsFailed(msg) => write!(f, "unsquashfs failed: {}", msg),
Self::NoDesktopEntry => write!(f, "No .desktop file found in AppImage"),
}

View File

@@ -113,7 +113,26 @@ pub fn launch_appimage(
method
};
let result = execute_appimage(appimage_path, &method, extra_args, extra_env);
// When sandboxed, ensure a profile exists and resolve its path
let profile_path = if method == LaunchMethod::Sandboxed {
let app_name = appimage_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
// Auto-generate a default profile if none exists yet
if super::sandbox::profile_path_for_app(&app_name).is_none() {
let profile = super::sandbox::generate_default_profile(&app_name);
if let Err(e) = super::sandbox::save_profile(db, &profile) {
log::warn!("Failed to create default sandbox profile: {}", e);
}
}
super::sandbox::profile_path_for_app(&app_name)
} else {
None
};
let result = execute_appimage(appimage_path, &method, extra_args, extra_env, profile_path.as_deref());
// Record the launch event regardless of success
if let Err(e) = db.record_launch(record_id, source) {
@@ -141,7 +160,7 @@ pub fn launch_appimage_simple(
}
};
execute_appimage(appimage_path, &method, extra_args, &[])
execute_appimage(appimage_path, &method, extra_args, &[], None)
}
/// Execute the AppImage process with the given method.
@@ -150,6 +169,7 @@ fn execute_appimage(
method: &LaunchMethod,
args: &[String],
extra_env: &[(&str, &str)],
sandbox_profile: Option<&Path>,
) -> LaunchResult {
let mut cmd = match method {
LaunchMethod::Direct => {
@@ -165,6 +185,9 @@ fn execute_appimage(
}
LaunchMethod::Sandboxed => {
let mut c = Command::new("firejail");
if let Some(profile) = sandbox_profile {
c.arg(format!("--profile={}", profile.display()));
}
c.arg("--appimage");
c.arg(appimage_path);
c.args(args);

View File

@@ -15,6 +15,7 @@ pub mod notification;
pub mod orphan;
pub mod portable;
pub mod report;
pub mod sandbox;
pub mod security;
pub mod updater;
pub mod verification;

View File

@@ -116,18 +116,19 @@ pub fn list_profiles(db: &Database) -> Vec<SandboxProfile> {
/// Search the community registry for sandbox profiles matching an app name.
/// Uses the GitHub-based registry approach (fetches a JSON index).
pub fn search_community_profiles(registry_url: &str, app_name: &str) -> Result<Vec<CommunityProfileEntry>, SanboxError> {
#[allow(dead_code)] // Community registry UI not yet wired
pub fn search_community_profiles(registry_url: &str, app_name: &str) -> Result<Vec<CommunityProfileEntry>, SandboxError> {
let index_url = format!("{}/index.json", registry_url.trim_end_matches('/'));
let response = ureq::get(&index_url)
.call()
.map_err(|e| SanboxError::Network(e.to_string()))?;
.map_err(|e| SandboxError::Network(e.to_string()))?;
let body = response.into_body().read_to_string()
.map_err(|e| SanboxError::Network(e.to_string()))?;
.map_err(|e| SandboxError::Network(e.to_string()))?;
let index: CommunityIndex = serde_json::from_str(&body)
.map_err(|e| SanboxError::Parse(e.to_string()))?;
.map_err(|e| SandboxError::Parse(e.to_string()))?;
let query = app_name.to_lowercase();
let matches: Vec<CommunityProfileEntry> = index.profiles
@@ -139,16 +140,17 @@ pub fn search_community_profiles(registry_url: &str, app_name: &str) -> Result<V
}
/// Download a community profile by its URL and save it locally.
#[allow(dead_code)] // Community registry UI not yet wired
pub fn download_community_profile(
db: &Database,
entry: &CommunityProfileEntry,
) -> Result<SandboxProfile, SanboxError> {
) -> Result<SandboxProfile, SandboxError> {
let response = ureq::get(&entry.url)
.call()
.map_err(|e| SanboxError::Network(e.to_string()))?;
.map_err(|e| SandboxError::Network(e.to_string()))?;
let content = response.into_body().read_to_string()
.map_err(|e| SanboxError::Network(e.to_string()))?;
.map_err(|e| SandboxError::Network(e.to_string()))?;
let profile = SandboxProfile {
id: None,
@@ -163,7 +165,7 @@ pub fn download_community_profile(
};
save_profile(db, &profile)
.map_err(|e| SanboxError::Io(e.to_string()))?;
.map_err(|e| SandboxError::Io(e.to_string()))?;
Ok(profile)
}
@@ -221,11 +223,13 @@ pub fn profile_path_for_app(app_name: &str) -> Option<PathBuf> {
// --- Community registry types ---
#[allow(dead_code)] // Used by community registry search
#[derive(Debug, Clone, serde::Deserialize)]
pub struct CommunityIndex {
pub profiles: Vec<CommunityProfileEntry>,
}
#[allow(dead_code)] // Used by community registry search/download
#[derive(Debug, Clone, serde::Deserialize)]
pub struct CommunityProfileEntry {
pub id: String,
@@ -240,16 +244,12 @@ pub struct CommunityProfileEntry {
// --- Error types ---
#[derive(Debug)]
#[allow(dead_code)] // Network + Parse variants used by community registry functions
pub enum SandboxError {
Io(String),
Database(String),
}
#[derive(Debug)]
pub enum SanboxError {
Network(String),
Parse(String),
Io(String),
}
impl std::fmt::Display for SandboxError {
@@ -257,16 +257,8 @@ impl std::fmt::Display for SandboxError {
match self {
Self::Io(e) => write!(f, "I/O error: {}", e),
Self::Database(e) => write!(f, "Database error: {}", e),
}
}
}
impl std::fmt::Display for SanboxError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Network(e) => write!(f, "Network error: {}", e),
Self::Parse(e) => write!(f, "Parse error: {}", e),
Self::Io(e) => write!(f, "I/O error: {}", e),
}
}
}
@@ -379,6 +371,10 @@ mod tests {
assert!(format!("{}", err).contains("permission denied"));
let err = SandboxError::Database("db locked".to_string());
assert!(format!("{}", err).contains("db locked"));
let err = SandboxError::Network("connection refused".to_string());
assert!(format!("{}", err).contains("connection refused"));
let err = SandboxError::Parse("invalid json".to_string());
assert!(format!("{}", err).contains("invalid json"));
}
#[test]