diff --git a/data/app.driftwood.Driftwood.gschema.xml b/data/app.driftwood.Driftwood.gschema.xml index 805cf82..08efe9a 100644 --- a/data/app.driftwood.Driftwood.gschema.xml +++ b/data/app.driftwood.Driftwood.gschema.xml @@ -36,7 +36,7 @@ - 'name' + 'recently-added' Library sort mode How to sort the library: name, recently-added, or size. diff --git a/data/resources/style.css b/data/resources/style.css index bace028..e8ac193 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -364,3 +364,28 @@ window.lightbox .lightbox-nav { .stat-card image { opacity: 0.55; } + +/* ===== Catalog Row (compact list view) ===== */ +.catalog-row { + border: 1px solid alpha(@window_fg_color, 0.08); + border-radius: 8px; + padding: 0; +} + +.catalog-row:hover { + border-color: alpha(@accent_bg_color, 0.4); +} + +/* ===== Skeleton Loading Placeholder ===== */ +.skeleton-card { + background: alpha(@card_bg_color, 0.5); + border-radius: 12px; + min-height: 180px; + min-width: 140px; + animation: skeleton-pulse 1.5s ease-in-out infinite; +} + +@keyframes skeleton-pulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 0.7; } +} diff --git a/src/config.rs b/src/config.rs index 233caf2..d439726 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,7 +13,6 @@ pub fn data_dir_fallback() -> PathBuf { } /// Return the XDG config directory with a proper $HOME-based fallback. -#[allow(dead_code)] pub fn config_dir_fallback() -> PathBuf { dirs::config_dir().unwrap_or_else(|| home_dir().join(".config")) } diff --git a/src/core/catalog.rs b/src/core/catalog.rs index 588ec0b..e0fc49b 100644 --- a/src/core/catalog.rs +++ b/src/core/catalog.rs @@ -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 { 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 { install_from_catalog_with_ocs(app, install_dir, None, 1) } @@ -876,14 +900,14 @@ fn fetch_custom_catalog(url: &str) -> Result, 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, &|_| {}) } diff --git a/src/core/database.rs b/src/core/database.rs index 459419a..aa3c11f 100644 --- a/src/core/database.rs +++ b/src/core/database.rs @@ -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 { 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::>().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> { 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> { + 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> { 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> = 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 { + let mut sql = format!( + "SELECT COUNT(*) FROM catalog_apps WHERE 1=1 {}", + Self::CATALOG_DEDUP_FILTER, + ); + let mut params_list: Vec> = 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> { 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> { - 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 { - 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 { + 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> { 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)) } diff --git a/src/core/inspector.rs b/src/core/inspector.rs index 6b03175..001db74 100644 --- a/src/core/inspector.rs +++ b/src/core/inspector.rs @@ -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"), } diff --git a/src/core/launcher.rs b/src/core/launcher.rs index 67a5078..1056916 100644 --- a/src/core/launcher.rs +++ b/src/core/launcher.rs @@ -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); diff --git a/src/core/mod.rs b/src/core/mod.rs index ee9bbdb..8c67661 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -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; diff --git a/src/core/sandbox.rs b/src/core/sandbox.rs index e08d50b..aa974ee 100644 --- a/src/core/sandbox.rs +++ b/src/core/sandbox.rs @@ -116,18 +116,19 @@ pub fn list_profiles(db: &Database) -> Vec { /// 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, SanboxError> { +#[allow(dead_code)] // Community registry UI not yet wired +pub fn search_community_profiles(registry_url: &str, app_name: &str) -> Result, 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 = index.profiles @@ -139,16 +140,17 @@ pub fn search_community_profiles(registry_url: &str, app_name: &str) -> Result Result { +) -> Result { 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 { // --- Community registry types --- +#[allow(dead_code)] // Used by community registry search #[derive(Debug, Clone, serde::Deserialize)] pub struct CommunityIndex { pub profiles: Vec, } +#[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] diff --git a/src/ui/app_card.rs b/src/ui/app_card.rs index 1bce9cb..366a92f 100644 --- a/src/ui/app_card.rs +++ b/src/ui/app_card.rs @@ -24,23 +24,30 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild { ); icon_widget.add_css_class("icon-dropshadow"); - if record.integrated { - let overlay = gtk::Overlay::new(); - overlay.set_child(Some(&icon_widget)); + let icon_overlay = gtk::Overlay::new(); + icon_overlay.set_child(Some(&icon_widget)); + if record.integrated { let emblem = gtk::Image::from_icon_name("emblem-ok-symbolic"); emblem.set_pixel_size(16); emblem.add_css_class("integration-emblem"); emblem.set_halign(gtk::Align::End); emblem.set_valign(gtk::Align::End); emblem.update_property(&[AccessibleProperty::Label("Integrated into desktop menu")]); - overlay.add_overlay(&emblem); - - card.append(&overlay); - } else { - card.append(&icon_widget); + icon_overlay.add_overlay(&emblem); } + if record.sandbox_mode.as_deref() == Some("firejail") { + let shield = gtk::Image::from_icon_name("security-high-symbolic"); + shield.set_pixel_size(16); + shield.set_halign(gtk::Align::Start); + shield.set_valign(gtk::Align::End); + shield.set_tooltip_text(Some("This app runs with security restrictions")); + icon_overlay.add_overlay(&shield); + } + + card.append(&icon_overlay); + // App name let name_label = gtk::Label::builder() .label(name) @@ -142,7 +149,12 @@ pub fn build_priority_badge(record: &AppImageRecord) -> Option { return Some(widgets::status_badge("Portable", "info")); } - None + // 4. Fallback: integration status + if record.integrated { + return Some(widgets::status_badge("Ready", "success")); + } + + Some(widgets::status_badge("Not in menu", "neutral")) } /// Build a descriptive accessible label for screen readers. diff --git a/src/ui/catalog_detail.rs b/src/ui/catalog_detail.rs index 44d0465..56c1ef9 100644 --- a/src/ui/catalog_detail.rs +++ b/src/ui/catalog_detail.rs @@ -6,6 +6,7 @@ use gtk::gio; use crate::core::catalog; use crate::core::database::{CatalogApp, Database}; +use crate::core::fuse; use crate::core::github_enrichment; use crate::core::github_enrichment::AppImageAsset; use crate::i18n::i18n; @@ -100,14 +101,15 @@ pub fn build_catalog_detail_page( && (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 = db + // Check if already installed (map name -> record id for launching) + let installed_map: std::collections::HashMap = db .get_all_appimages() .unwrap_or_default() .iter() - .filter_map(|r| r.app_name.as_ref().map(|n| n.to_lowercase())) + .filter_map(|r| r.app_name.as_ref().map(|n| (n.to_lowercase(), r.id))) .collect(); - let is_installed = installed_names.contains(&app.name.to_lowercase()); + let installed_record_id = installed_map.get(&app.name.to_lowercase()).copied(); + let is_installed = installed_record_id.is_some(); let install_slot = gtk::Box::new(gtk::Orientation::Horizontal, 0); @@ -120,6 +122,17 @@ pub fn build_catalog_detail_page( let awaiting_ocs = has_ocs && !is_installed; if is_installed { + // Show Launch button for installed apps + if let Some(record_id) = installed_record_id { + let launch_btn = gtk::Button::builder() + .label(&i18n("Launch")) + .css_classes(["suggested-action", "pill"]) + .build(); + launch_btn.set_action_name(Some("win.launch-appimage")); + launch_btn.set_action_target_value(Some(&record_id.to_variant())); + widgets::set_pointer_cursor(&launch_btn); + button_box.append(&launch_btn); + } let installed_badge = widgets::status_badge(&i18n("Installed"), "success"); installed_badge.set_valign(gtk::Align::Center); button_box.append(&installed_badge); @@ -166,6 +179,37 @@ pub fn build_catalog_detail_page( } info_box.append(&button_box); + + // "What you'll get" info and compatibility check for install + if !is_installed { + let size_hint = app.ocs_downloadsize.filter(|&s| s > 0) + .map(|s| format!(" ({})", widgets::format_size(s))) + .unwrap_or_default(); + let install_info = gtk::Label::builder() + .label(&format!( + "Downloads to ~/Applications and adds to your app launcher{}", + size_hint + )) + .css_classes(["caption", "dim-label"]) + .wrap(true) + .xalign(0.0) + .halign(gtk::Align::Start) + .build(); + info_box.append(&install_info); + + // System compatibility check + let fuse_info = fuse::detect_system_fuse(); + let (compat_text, compat_class) = if fuse_info.status.is_functional() { + ("Works with your system", "success") + } else { + ("May need additional setup to run", "warning") + }; + let compat_badge = widgets::status_badge(compat_text, compat_class); + compat_badge.set_halign(gtk::Align::Start); + compat_badge.set_margin_top(2); + info_box.append(&compat_badge); + } + header_box.append(&info_box); content.append(&header_box); @@ -1243,9 +1287,17 @@ fn format_ocs_file_label(file: &catalog::OcsDownloadFile) -> String { if !file.version.is_empty() { parts.push(format!("v{}", file.version)); } + if let Some(ref arch) = file.arch { + parts.push(arch.clone()); + } if !file.filename.is_empty() { parts.push(file.filename.clone()); } + if let Some(ref pkg_type) = file.pkg_type { + if pkg_type != "appimage" { + parts.push(format!("[{}]", pkg_type)); + } + } if let Some(size_kb) = file.size_kb { if size_kb > 0 { parts.push(format!("({})", widgets::format_size(size_kb * 1024))); diff --git a/src/ui/catalog_tile.rs b/src/ui/catalog_tile.rs index d7ba49a..7c3ac00 100644 --- a/src/ui/catalog_tile.rs +++ b/src/ui/catalog_tile.rs @@ -6,7 +6,8 @@ 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 { +/// If `installed` is true, an "Installed" badge is shown on the card. +pub fn build_catalog_tile(app: &CatalogApp, installed: bool) -> gtk::FlowBoxChild { let card = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(6) @@ -75,10 +76,11 @@ pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild { 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 + // Stats row (downloads + stars + version) - only if data exists + let has_downloads = app.ocs_downloads.is_some_and(|d| d > 0); 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 { + if has_downloads || has_stars || has_version { let stats_row = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(12) @@ -86,6 +88,22 @@ pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild { .build(); stats_row.add_css_class("catalog-stats-row"); + if let Some(downloads) = app.ocs_downloads.filter(|&d| d > 0) { + let dl_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(4) + .build(); + let dl_icon = gtk::Image::from_icon_name("folder-download-symbolic"); + dl_icon.set_pixel_size(12); + dl_box.append(&dl_icon); + let dl_label = gtk::Label::new(Some(&widgets::format_count(downloads))); + dl_label.add_css_class("caption"); + dl_label.add_css_class("dim-label"); + dl_box.append(&dl_label); + dl_box.set_tooltip_text(Some(&format!("{} downloads", downloads))); + stats_row.append(&dl_box); + } + if let Some(stars) = app.github_stars.filter(|&s| s > 0) { let star_box = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) @@ -135,6 +153,21 @@ pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild { } } + // Installed badge + if installed { + let installed_badge = widgets::status_badge("Installed", "success"); + installed_badge.set_halign(gtk::Align::Start); + installed_badge.set_margin_top(4); + inner.append(&installed_badge); + } + + // Source badge - show which source this app came from + let source_label = if app.ocs_id.is_some() { "AppImageHub" } else { "Community" }; + let source_badge = widgets::status_badge(source_label, "neutral"); + source_badge.set_halign(gtk::Align::Start); + source_badge.set_margin_top(2); + inner.append(&source_badge); + card.append(&inner); let child = gtk::FlowBoxChild::builder() @@ -146,6 +179,92 @@ pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild { child } +/// Build a compact list-row tile for the browse grid in list mode. +/// Horizontal layout: icon (32px) | name | description snippet | stats. +pub fn build_catalog_row(app: &CatalogApp, installed: bool) -> gtk::FlowBoxChild { + let row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(12) + .halign(gtk::Align::Fill) + .hexpand(true) + .build(); + row.add_css_class("card"); + row.add_css_class("catalog-row"); + + let inner = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(12) + .margin_top(8) + .margin_bottom(8) + .margin_start(12) + .margin_end(12) + .hexpand(true) + .build(); + + // Icon (32px) + let icon = widgets::app_icon(None, &app.name, 32); + icon.set_valign(gtk::Align::Center); + inner.append(&icon); + + // Name + let name_label = gtk::Label::builder() + .label(&app.name) + .css_classes(["heading"]) + .ellipsize(gtk::pango::EllipsizeMode::End) + .max_width_chars(18) + .xalign(0.0) + .width_chars(14) + .build(); + inner.append(&name_label); + + // Description (single line) + let plain = app.ocs_summary.as_deref() + .filter(|d| !d.is_empty()) + .or(app.github_description.as_deref().filter(|d| !d.is_empty())) + .or(app.description.as_deref().filter(|d| !d.is_empty())) + .map(|d| strip_html(d)) + .unwrap_or_default(); + let desc_label = gtk::Label::builder() + .label(&plain) + .css_classes(["dim-label"]) + .ellipsize(gtk::pango::EllipsizeMode::End) + .hexpand(true) + .xalign(0.0) + .build(); + inner.append(&desc_label); + + // Stats (compact) + if let Some(downloads) = app.ocs_downloads.filter(|&d| d > 0) { + let dl_label = gtk::Label::builder() + .label(&format!("{} dl", widgets::format_count(downloads))) + .css_classes(["caption", "dim-label"]) + .build(); + inner.append(&dl_label); + } else if let Some(stars) = app.github_stars.filter(|&s| s > 0) { + let star_label = gtk::Label::builder() + .label(&format!("{} stars", widgets::format_count(stars))) + .css_classes(["caption", "dim-label"]) + .build(); + inner.append(&star_label); + } + + // Installed badge + if installed { + let badge = widgets::status_badge("Installed", "success"); + badge.set_valign(gtk::Align::Center); + inner.append(&badge); + } + + row.append(&inner); + + let child = gtk::FlowBoxChild::builder() + .child(&row) + .build(); + child.add_css_class("activatable"); + widgets::set_pointer_cursor(&child); + 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. @@ -241,7 +360,7 @@ pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box { text_box.append(&desc_label); } - // Badge row: category + stars + // Badge row: category + downloads/stars let badge_row = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(6) @@ -262,7 +381,15 @@ pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box { } } - if let Some(stars) = app.github_stars.filter(|&s| s > 0) { + if let Some(downloads) = app.ocs_downloads.filter(|&d| d > 0) { + let dl_badge = widgets::status_badge_with_icon( + "folder-download-symbolic", + &widgets::format_count(downloads), + "neutral", + ); + dl_badge.set_halign(gtk::Align::Start); + badge_row.append(&dl_badge); + } else 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), diff --git a/src/ui/catalog_view.rs b/src/ui/catalog_view.rs index 2dbaa22..23351ee 100644 --- a/src/ui/catalog_view.rs +++ b/src/ui/catalog_view.rs @@ -1,5 +1,6 @@ use adw::prelude::*; use std::cell::RefCell; +use std::collections::HashSet; use std::rc::Rc; use gtk::gio; @@ -43,7 +44,7 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: .search_mode_enabled(true) .build(); - // --- Featured section (paged carousel) --- + // --- Featured section (AdwCarousel with swipe support) --- let featured_label = gtk::Label::builder() .label(&i18n("Featured")) .css_classes(["title-2"]) @@ -52,92 +53,18 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: .margin_start(18) .build(); - // Stack for crossfade page transitions - let featured_stack = gtk::Stack::builder() - .transition_type(gtk::StackTransitionType::Crossfade) - .transition_duration(250) + let featured_carousel = adw::Carousel::builder() .hexpand(true) + .allow_scroll_wheel(true) + .allow_long_swipes(false) .build(); - // Page state: all featured apps and current page index + let carousel_dots = adw::CarouselIndicatorDots::builder() + .carousel(&featured_carousel) + .build(); + + // State: all featured apps (for later population) let featured_apps: Rc>> = Rc::new(RefCell::new(Vec::new())); - let featured_page: Rc> = Rc::new(std::cell::Cell::new(0)); - // Tracks which stack child name is active ("a" or "b") for crossfade toggling - let featured_flip: Rc> = 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); - - // 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() @@ -145,19 +72,21 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: .spacing(8) .build(); featured_section.append(&featured_label); - featured_section.append(&carousel_row); + featured_section.append(&featured_carousel); + featured_section.append(&carousel_dots); - // --- Category filter tiles (wrapping grid) --- - let category_box = gtk::FlowBox::builder() - .homogeneous(false) - .min_children_per_line(3) - .max_children_per_line(6) - .selection_mode(gtk::SelectionMode::None) + // --- Category filter chips (horizontal scrollable row) --- + let category_scroll = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Automatic) + .vscrollbar_policy(gtk::PolicyType::Never) .margin_start(18) .margin_end(18) - .row_spacing(8) - .column_spacing(8) .build(); + let category_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .build(); + category_scroll.set_child(Some(&category_box)); // --- "All Apps" section header with sort dropdown --- let all_label = gtk::Label::builder() @@ -171,10 +100,8 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: let sort_options = [ ("Name (A-Z)", CatalogSortOrder::NameAsc), ("Name (Z-A)", CatalogSortOrder::NameDesc), - ("Stars (most first)", CatalogSortOrder::StarsDesc), - ("Stars (fewest first)", CatalogSortOrder::StarsAsc), - ("Downloads (most first)", CatalogSortOrder::DownloadsDesc), - ("Downloads (fewest first)", CatalogSortOrder::DownloadsAsc), + ("Popularity (most first)", CatalogSortOrder::PopularityDesc), + ("Popularity (least first)", CatalogSortOrder::PopularityAsc), ("Release date (newest first)", CatalogSortOrder::ReleaseDateDesc), ("Release date (oldest first)", CatalogSortOrder::ReleaseDateAsc), ]; @@ -186,7 +113,7 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: .model(&sort_model) .selected(0) .valign(gtk::Align::Center) - .tooltip_text(&i18n("Sort apps")) + .tooltip_text(&i18n("Popularity is based on download count, GitHub stars, and community activity")) .build(); sort_dropdown.add_css_class("flat"); @@ -203,6 +130,17 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: sort_row.append(&sort_icon); sort_row.append(&sort_dropdown); + // View mode toggle (grid vs list) + let view_toggle = gtk::ToggleButton::builder() + .icon_name("view-list-symbolic") + .tooltip_text(&i18n("Compact list view")) + .valign(gtk::Align::Center) + .css_classes(["flat", "circular"]) + .build(); + widgets::set_pointer_cursor(&view_toggle); + + let compact_mode: Rc> = Rc::new(std::cell::Cell::new(false)); + let all_header = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(12) @@ -211,11 +149,15 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: .build(); all_header.append(&all_label); all_header.append(&sort_row); + all_header.append(&view_toggle); // Sort state let active_sort: Rc> = Rc::new(std::cell::Cell::new(CatalogSortOrder::NameAsc)); + // Pagination state + let current_page: Rc> = Rc::new(std::cell::Cell::new(0)); + // FlowBox grid let flow_box = gtk::FlowBox::builder() .homogeneous(true) @@ -224,11 +166,38 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: .selection_mode(gtk::SelectionMode::None) .margin_start(18) .margin_end(18) - .margin_bottom(24) .row_spacing(12) .column_spacing(12) .build(); + // Pagination controls + let page_prev_btn = gtk::Button::builder() + .icon_name("go-previous-symbolic") + .tooltip_text(&i18n("Previous page")) + .sensitive(false) + .css_classes(["flat", "circular"]) + .build(); + let page_next_btn = gtk::Button::builder() + .icon_name("go-next-symbolic") + .tooltip_text(&i18n("Next page")) + .sensitive(false) + .css_classes(["flat", "circular"]) + .build(); + let page_label = gtk::Label::builder() + .label("Page 1") + .css_classes(["dim-label"]) + .build(); + let page_bar = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .halign(gtk::Align::Center) + .spacing(12) + .margin_bottom(24) + .visible(false) + .build(); + page_bar.append(&page_prev_btn); + page_bar.append(&page_label); + page_bar.append(&page_next_btn); + let clamp = adw::Clamp::builder() .maximum_size(1200) .tightening_threshold(900) @@ -271,13 +240,34 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: enrich_label.set_widget_name("enrich-label"); enrichment_banner.append(&enrich_label); - // Layout order: search -> enrichment banner -> featured carousel -> categories -> all apps grid + // Stale catalog banner - shown when catalog hasn't been refreshed in 7+ days + let stale_banner = adw::Banner::builder() + .title(&i18n("Catalog data may be outdated - tap to refresh")) + .button_label(&i18n("Refresh")) + .revealed(false) + .build(); + { + let settings = gio::Settings::new(crate::config::APP_ID); + let last_refreshed = settings.string("catalog-last-refreshed").to_string(); + if !last_refreshed.is_empty() { + if let Ok(then) = chrono::DateTime::parse_from_rfc3339(&last_refreshed) { + let days = (chrono::Utc::now() - then.with_timezone(&chrono::Utc)).num_days(); + if days >= 7 { + stale_banner.set_revealed(true); + } + } + } + } + + // Layout order: search -> stale banner -> enrichment banner -> featured carousel -> categories -> all apps grid content.append(&search_bar); + content.append(&stale_banner); content.append(&enrichment_banner); content.append(&featured_section); - content.append(&category_box); + content.append(&category_scroll); content.append(&all_header); content.append(&flow_box); + content.append(&page_bar); clamp.set_child(Some(&content)); let scrolled = gtk::ScrolledWindow::builder() @@ -306,7 +296,7 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: let app_count = db.catalog_app_count().unwrap_or(0); if app_count > 0 { stack.set_visible_child_name("results"); - update_catalog_subtitle(&title, app_count); + update_catalog_subtitle(&title, db); } else { stack.set_visible_child_name("empty"); } @@ -345,16 +335,57 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: // Populate categories populate_categories( - db, &category_box, &active_category, &active_sort, &flow_box, &search_entry, - &featured_section, &all_label, nav_view, &toast_overlay, + db, &category_box, &active_category, &active_sort, ¤t_page, + &flow_box, &search_entry, &featured_section, &all_label, + &page_bar, &page_label, &page_prev_btn, &page_next_btn, &scrolled, + &compact_mode, ); // Initial population populate_featured( - db, &featured_apps, &featured_page, &featured_stack, &featured_flip, - &left_arrow, &right_arrow, nav_view, &toast_overlay, + db, &featured_apps, &featured_carousel, nav_view, &toast_overlay, ); - populate_grid(db, "", None, active_sort.get(), &flow_box, &all_label, nav_view, &toast_overlay); + populate_grid( + db, "", None, active_sort.get(), 0, + &flow_box, &all_label, &page_bar, &page_label, &page_prev_btn, &page_next_btn, &scrolled, + compact_mode.get(), + ); + + // View toggle handler + { + let db_ref = db.clone(); + let flow_ref = flow_box.clone(); + let cat_ref = active_category.clone(); + let sort_ref = active_sort.clone(); + let page_ref = current_page.clone(); + let search_ref = search_entry.clone(); + let compact_ref = compact_mode.clone(); + let all_label_ref = all_label.clone(); + let page_bar_ref = page_bar.clone(); + let page_label_ref = page_label.clone(); + let page_prev_ref = page_prev_btn.clone(); + let page_next_ref = page_next_btn.clone(); + let scrolled_ref = scrolled.clone(); + view_toggle.connect_toggled(move |btn| { + let is_compact = btn.is_active(); + compact_ref.set(is_compact); + if is_compact { + btn.set_icon_name("view-grid-symbolic"); + btn.set_tooltip_text(Some(&i18n("Grid view"))); + } else { + btn.set_icon_name("view-list-symbolic"); + btn.set_tooltip_text(Some(&i18n("Compact list view"))); + } + let query = search_ref.text().to_string(); + let cat = cat_ref.borrow().clone(); + populate_grid( + &db_ref, &query, cat.as_deref(), sort_ref.get(), page_ref.get(), + &flow_ref, &all_label_ref, &page_bar_ref, &page_label_ref, + &page_prev_ref, &page_next_ref, &scrolled_ref, + is_compact, + ); + }); + } // Sort dropdown handler { @@ -362,29 +393,35 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: let flow_ref = flow_box.clone(); let cat_ref = active_category.clone(); let sort_ref = active_sort.clone(); + let page_ref = current_page.clone(); let search_ref = search_entry.clone(); + let compact_ref = compact_mode.clone(); let all_label_ref = all_label.clone(); - let nav_ref = nav_view.clone(); - let toast_ref = toast_overlay.clone(); + let page_bar_ref = page_bar.clone(); + let page_label_ref = page_label.clone(); + let page_prev_ref = page_prev_btn.clone(); + let page_next_ref = page_next_btn.clone(); + let scrolled_ref = scrolled.clone(); sort_dropdown.connect_selected_notify(move |dd| { let idx = dd.selected() as usize; let sort_options_local = [ CatalogSortOrder::NameAsc, CatalogSortOrder::NameDesc, - CatalogSortOrder::StarsDesc, - CatalogSortOrder::StarsAsc, - CatalogSortOrder::DownloadsDesc, - CatalogSortOrder::DownloadsAsc, + CatalogSortOrder::PopularityDesc, + CatalogSortOrder::PopularityAsc, CatalogSortOrder::ReleaseDateDesc, CatalogSortOrder::ReleaseDateAsc, ]; let sort = sort_options_local.get(idx).copied().unwrap_or(CatalogSortOrder::NameAsc); sort_ref.set(sort); + page_ref.set(0); let query = search_ref.text().to_string(); let cat = cat_ref.borrow().clone(); populate_grid( - &db_ref, &query, cat.as_deref(), sort, - &flow_ref, &all_label_ref, &nav_ref, &toast_ref, + &db_ref, &query, cat.as_deref(), sort, 0, + &flow_ref, &all_label_ref, &page_bar_ref, &page_label_ref, + &page_prev_ref, &page_next_ref, &scrolled_ref, + compact_ref.get(), ); }); } @@ -395,18 +432,84 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: let flow_ref = flow_box.clone(); let cat_ref = active_category.clone(); let sort_ref = active_sort.clone(); - let nav_ref = nav_view.clone(); - let toast_ref = toast_overlay.clone(); + let page_ref = current_page.clone(); + let compact_ref = compact_mode.clone(); let all_label_ref = all_label.clone(); let featured_section_ref = featured_section.clone(); + let page_bar_ref = page_bar.clone(); + let page_label_ref = page_label.clone(); + let page_prev_ref = page_prev_btn.clone(); + let page_next_ref = page_next_btn.clone(); + let scrolled_ref = scrolled.clone(); search_entry.connect_search_changed(move |entry| { let query = entry.text().to_string(); let cat = cat_ref.borrow().clone(); let is_searching = !query.is_empty() || cat.is_some(); featured_section_ref.set_visible(!is_searching); + page_ref.set(0); populate_grid( - &db_ref, &query, cat.as_deref(), sort_ref.get(), - &flow_ref, &all_label_ref, &nav_ref, &toast_ref, + &db_ref, &query, cat.as_deref(), sort_ref.get(), 0, + &flow_ref, &all_label_ref, &page_bar_ref, &page_label_ref, + &page_prev_ref, &page_next_ref, &scrolled_ref, + compact_ref.get(), + ); + }); + } + + // Pagination: Previous page + { + let db_ref = db.clone(); + let flow_ref = flow_box.clone(); + let cat_ref = active_category.clone(); + let sort_ref = active_sort.clone(); + let page_ref = current_page.clone(); + let search_ref = search_entry.clone(); + let compact_ref = compact_mode.clone(); + let all_label_ref = all_label.clone(); + let page_bar_ref = page_bar.clone(); + let page_label_ref = page_label.clone(); + let page_prev_ref = page_prev_btn.clone(); + let page_next_ref = page_next_btn.clone(); + let scrolled_ref = scrolled.clone(); + page_prev_btn.connect_clicked(move |_| { + let new_page = (page_ref.get() - 1).max(0); + page_ref.set(new_page); + let query = search_ref.text().to_string(); + let cat = cat_ref.borrow().clone(); + populate_grid( + &db_ref, &query, cat.as_deref(), sort_ref.get(), new_page, + &flow_ref, &all_label_ref, &page_bar_ref, &page_label_ref, + &page_prev_ref, &page_next_ref, &scrolled_ref, + compact_ref.get(), + ); + }); + } + + // Pagination: Next page + { + let db_ref = db.clone(); + let flow_ref = flow_box.clone(); + let cat_ref = active_category.clone(); + let sort_ref = active_sort.clone(); + let page_ref = current_page.clone(); + let search_ref = search_entry.clone(); + let compact_ref = compact_mode.clone(); + let all_label_ref = all_label.clone(); + let page_bar_ref = page_bar.clone(); + let page_label_ref = page_label.clone(); + let page_prev_ref = page_prev_btn.clone(); + let page_next_ref = page_next_btn.clone(); + let scrolled_ref = scrolled.clone(); + page_next_btn.connect_clicked(move |_| { + let new_page = page_ref.get() + 1; + page_ref.set(new_page); + let query = search_ref.text().to_string(); + let cat = cat_ref.borrow().clone(); + populate_grid( + &db_ref, &query, cat.as_deref(), sort_ref.get(), new_page, + &flow_ref, &all_label_ref, &page_bar_ref, &page_label_ref, + &page_prev_ref, &page_next_ref, &scrolled_ref, + compact_ref.get(), ); }); } @@ -440,17 +543,20 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: let cat_box_ref = category_box.clone(); let active_cat_ref = active_category.clone(); let active_sort_ref = active_sort.clone(); + let page_ref = current_page.clone(); let search_ref = search_entry.clone(); + let compact_ref = compact_mode.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 featured_carousel_ref = featured_carousel.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(); + let page_bar_ref = page_bar.clone(); + let page_label_ref = page_label.clone(); + let page_prev_ref = page_prev_btn.clone(); + let page_next_ref = page_next_btn.clone(); + let scrolled_ref = scrolled.clone(); btn.connect_clicked(move |btn| { btn.set_sensitive(false); @@ -465,15 +571,18 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: let active_sort_c = active_sort_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 featured_carousel_c = featured_carousel_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(); + let page_bar_c = page_bar_ref.clone(); + let page_label_c = page_label_ref.clone(); + let page_prev_c = page_prev_ref.clone(); + let page_next_c = page_next_ref.clone(); + let scrolled_c = scrolled_ref.clone(); + let page_c = page_ref.clone(); + let compact_c = compact_ref.clone(); // Capture app count before refresh for delta calculation let count_before = db_c.catalog_app_count().unwrap_or(0); @@ -483,6 +592,11 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: progress_c.set_fraction(0.0); progress_c.set_text(Some("Fetching catalog...")); + // Show skeleton placeholder cards while syncing + if count_before == 0 { + show_skeleton(&flow_c); + } + // Channel for progress updates from background thread let (tx, rx) = std::sync::mpsc::channel::(); @@ -525,12 +639,12 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: &format!("{}: Found {} apps", &*name, total), )); } - catalog::SyncProgress::CachingIcon { current, total, .. } => { + catalog::SyncProgress::CachingIcon { current, total, ref app_name } => { let name = src_name.borrow(); let inner = 0.05 + 0.85 * (current as f64 / total.max(1) as f64); progress_listen.set_fraction(src_base.get() + src_span.get() * inner); progress_listen.set_text(Some( - &format!("{}: Caching icons ({}/{})", &*name, current, total), + &format!("{}: Caching icon for {} ({}/{})", &*name, app_name, current, total), )); } catalog::SyncProgress::SavingApps { current, total } => { @@ -541,12 +655,12 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: &format!("{}: Saving ({}/{})", &*name, current, total), )); } - catalog::SyncProgress::Done { .. } => { + catalog::SyncProgress::Done { total } => { // Single source done - don't break, more sources may follow let name = src_name.borrow(); progress_listen.set_fraction(src_base.get() + src_span.get()); progress_listen.set_text(Some( - &format!("{}: Complete", &*name), + &format!("{}: {} apps synced", &*name, total), )); } catalog::SyncProgress::AllDone => { @@ -611,20 +725,24 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: "Catalog refreshed, no new apps".to_string() }; toast_c.add_toast(adw::Toast::new(&toast_msg)); - update_catalog_subtitle(&title_c, count_after); + update_catalog_subtitle(&title_c, &db_c); stack_c.set_visible_child_name("results"); populate_categories( - &db_c, &cat_box_c, &active_cat_c, &active_sort_c, &flow_c, &search_c, - &featured_section_c, &all_label_c, - &nav_c, &toast_c, + &db_c, &cat_box_c, &active_cat_c, &active_sort_c, &page_c, + &flow_c, &search_c, &featured_section_c, &all_label_c, + &page_bar_c, &page_label_c, &page_prev_c, &page_next_c, &scrolled_c, + &compact_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, + &db_c, &featured_apps_c, &featured_carousel_c, + &nav_c, &toast_c, ); + page_c.set(0); populate_grid( - &db_c, "", None, active_sort_c.get(), &flow_c, &all_label_c, &nav_c, &toast_c, + &db_c, "", None, active_sort_c.get(), 0, + &flow_c, &all_label_c, &page_bar_c, &page_label_c, + &page_prev_c, &page_next_c, &scrolled_c, + compact_c.get(), ); let settings = gio::Settings::new(crate::config::APP_ID); @@ -648,6 +766,16 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: wire_refresh(&refresh_btn); wire_refresh(&refresh_header_btn); + // Stale banner button triggers a refresh + { + let refresh_btn_ref = refresh_header_btn.clone(); + let stale_ref = stale_banner.clone(); + stale_banner.connect_button_clicked(move |_| { + stale_ref.set_revealed(false); + refresh_btn_ref.emit_clicked(); + }); + } + // Auto-refresh on first visit when catalog is empty if app_count == 0 { refresh_btn.emit_clicked(); @@ -664,188 +792,241 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: (page, enrichment_banner) } -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"); - if last_refreshed.is_empty() { - title.set_subtitle(&format!("{} apps available", app_count)); +fn update_catalog_subtitle(title: &adw::WindowTitle, db: &Database) { + let app_count = db.catalog_app_count().unwrap_or(0); + let sources = catalog::get_sources(db); + // Use the most recent last_synced across all sources + let last_synced = sources.iter() + .filter_map(|s| s.last_synced.as_deref()) + .max(); + // Show per-source app counts in tooltip-friendly format + let source_summary: String = sources.iter() + .filter(|s| s.app_count > 0) + .map(|s| format!("{}: {}", s.name, s.app_count)) + .collect::>() + .join(", "); + if let Some(synced) = last_synced { + let relative = widgets::relative_time(synced); + title.set_subtitle(&format!("{} apps ({}) - Refreshed {}", app_count, source_summary, relative)); } else { - let relative = widgets::relative_time(&last_refreshed); - title.set_subtitle(&format!("{} apps - Refreshed {}", app_count, relative)); + title.set_subtitle(&format!("{} apps available", app_count)); } } const CARDS_PER_PAGE: usize = 3; -/// Populate featured apps data and show the first page. +/// Populate featured apps into the AdwCarousel, one page of 3 cards each. fn populate_featured( db: &Rc, featured_apps: &Rc>>, - featured_page: &Rc>, - featured_stack: >k::Stack, - featured_flip: &Rc>, - left_arrow: >k::Button, - right_arrow: >k::Button, + carousel: &adw::Carousel, nav_view: &adw::NavigationView, toast_overlay: &adw::ToastOverlay, ) { + // Remove old pages + while carousel.n_pages() > 0 { + let child = carousel.nth_page(0); + carousel.remove(&child); + } + 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, - ); -} + if apps.is_empty() { + *featured_apps.borrow_mut() = apps; + return; + } -/// Display a specific page of featured cards with crossfade transition. -fn show_featured_page( - featured_apps: &Rc>>, - page: usize, - stack: >k::Stack, - flip: &Rc>, - left_arrow: >k::Button, - right_arrow: >k::Button, - db: &Rc, - 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; + let total_pages = (apps.len() + CARDS_PER_PAGE - 1) / CARDS_PER_PAGE; - left_arrow.set_sensitive(page > 0); - right_arrow.set_sensitive(page < max_page); + for page_idx in 0..total_pages { + let start = page_idx * CARDS_PER_PAGE; + let end = (start + CARDS_PER_PAGE).min(apps.len()); - // 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(); + let page_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(12) + .homogeneous(true) + .hexpand(true) + .margin_start(18) + .margin_end(18) + .build(); - for app in &apps[start..end] { - let tile = catalog_tile::build_featured_tile(app); + for app in &apps[start..end] { + let tile = catalog_tile::build_featured_tile(app); - // 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 spath = screenshot_path.to_string(); - let frame = tile.first_child() - .and_then(|w| w.downcast::().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 || { - catalog::cache_screenshot(&name, &sp, 0) - .map_err(|e| e.to_string()) - }).await; + // 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 spath = screenshot_path.to_string(); + let frame = tile.first_child() + .and_then(|w| w.downcast::().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 || { + catalog::cache_screenshot(&name, &sp, 0) + .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::Cover) - .halign(gtk::Align::Fill) - .valign(gtk::Align::Fill) + 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::Cover) + .halign(gtk::Align::Fill) + .valign(gtk::Align::Fill) + .build(); + frame_ref.set_child(Some(&picture)); + } + } + _ => { + 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(&picture)); + frame_ref.set_child(Some(&icon)); } } - _ => { - 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)); - } - } - }); + }); + } } } + + // 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); } - // 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); + carousel.append(&page_box); } - // 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); + *featured_apps.borrow_mut() = apps; } +const PAGE_SIZE: i32 = 100; + /// Populate the main grid with catalog tiles. fn populate_grid( db: &Rc, query: &str, category: Option<&str>, sort: CatalogSortOrder, + page: i32, flow_box: >k::FlowBox, all_label: >k::Label, - _nav_view: &adw::NavigationView, - _toast_overlay: &adw::ToastOverlay, + page_bar: >k::Box, + page_label: >k::Label, + page_prev: >k::Button, + page_next: >k::Button, + scrolled: >k::ScrolledWindow, + compact: bool, ) { // Clear existing while let Some(child) = flow_box.first_child() { flow_box.remove(&child); } - let results = db.search_catalog(query, category, 200, sort).unwrap_or_default(); + let total = db.count_catalog_matches(query, category).unwrap_or(0); + let total_pages = ((total as f64) / (PAGE_SIZE as f64)).ceil() as i32; + let page = page.clamp(0, (total_pages - 1).max(0)); + let offset = page * PAGE_SIZE; + + let results = db.search_catalog(query, category, PAGE_SIZE, offset, sort).unwrap_or_default(); if results.is_empty() { all_label.set_label(&i18n("No results")); + page_bar.set_visible(false); return; } + let start = offset + 1; + let end = offset + results.len() as i32; let label_text = if query.is_empty() && category.is_none() { - format!("{} ({})", i18n("All Apps"), results.len()) + format!("{} ({}-{} of {})", i18n("All Apps"), start, end, total) } else { - format!("{} ({})", i18n("Results"), results.len()) + format!("{} ({}-{} of {})", i18n("Results"), start, end, total) }; all_label.set_label(&label_text); + // Build set of installed app names for badge display + let installed_names: HashSet = db.get_all_appimages() + .unwrap_or_default() + .into_iter() + .filter_map(|r| r.app_name.map(|n| n.to_lowercase())) + .collect(); + + // Adjust grid layout for compact mode + if compact { + flow_box.set_min_children_per_line(1); + flow_box.set_max_children_per_line(1); + } else { + flow_box.set_min_children_per_line(2); + flow_box.set_max_children_per_line(5); + } + for app in &results { - let tile = catalog_tile::build_catalog_tile(app); - // Store the app ID in the widget name for retrieval on click + let is_installed = installed_names.contains(&app.name.to_lowercase()); + let tile = if compact { + catalog_tile::build_catalog_row(app, is_installed) + } else { + catalog_tile::build_catalog_tile(app, is_installed) + }; tile.set_widget_name(&format!("catalog-app-{}", app.id)); flow_box.append(&tile); } + + // Update pagination controls + if total_pages > 1 { + page_bar.set_visible(true); + page_label.set_label(&format!("Page {} of {}", page + 1, total_pages)); + page_prev.set_sensitive(page > 0); + page_next.set_sensitive(page < total_pages - 1); + } else { + page_bar.set_visible(false); + } + + // Scroll to top when changing pages + scrolled.vadjustment().set_value(0.0); +} + +/// Show skeleton placeholder cards while the catalog is syncing. +fn show_skeleton(flow_box: >k::FlowBox) { + while let Some(child) = flow_box.first_child() { + flow_box.remove(&child); + } + for _ in 0..12 { + let placeholder = gtk::Box::builder() + .css_classes(["skeleton-card"]) + .build(); + let child = gtk::FlowBoxChild::builder() + .child(&placeholder) + .focusable(false) + .build(); + flow_box.append(&child); + } } /// Map a FreeDesktop category name to (icon_name, color_css_class). @@ -866,18 +1047,16 @@ fn category_meta(name: &str) -> (&'static str, &'static str) { } } -/// Build a category tile toggle button with icon and label. -fn build_category_tile(label_text: &str, icon_name: &str, color_class: &str, active: bool) -> gtk::ToggleButton { +/// Build a category chip toggle button (pill-shaped, horizontal scrollable). +fn build_category_chip(label_text: &str, icon_name: &str, _color_class: &str, active: bool) -> gtk::ToggleButton { let icon = gtk::Image::from_icon_name(icon_name); - icon.set_pixel_size(24); + icon.set_pixel_size(16); let label = gtk::Label::new(Some(label_text)); - label.set_ellipsize(gtk::pango::EllipsizeMode::End); let inner = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) - .spacing(10) - .halign(gtk::Align::Center) + .spacing(6) .build(); inner.append(&icon); inner.append(&label); @@ -885,7 +1064,7 @@ fn build_category_tile(label_text: &str, icon_name: &str, color_class: &str, act let btn = gtk::ToggleButton::builder() .child(&inner) .active(active) - .css_classes(["flat", "category-tile", color_class]) + .css_classes(["pill"]) .build(); widgets::set_pointer_cursor(&btn); btn @@ -893,15 +1072,20 @@ fn build_category_tile(label_text: &str, icon_name: &str, color_class: &str, act fn populate_categories( db: &Rc, - category_box: >k::FlowBox, + category_box: >k::Box, active_category: &Rc>>, active_sort: &Rc>, + current_page: &Rc>, flow_box: >k::FlowBox, search_entry: >k::SearchEntry, featured_section: >k::Box, all_label: >k::Label, - nav_view: &adw::NavigationView, - toast_overlay: &adw::ToastOverlay, + page_bar: >k::Box, + page_label: >k::Label, + page_prev: >k::Button, + page_next: >k::Button, + scrolled: >k::ScrolledWindow, + compact_mode: &Rc>, ) { // Clear existing while let Some(child) = category_box.first_child() { @@ -913,7 +1097,7 @@ fn populate_categories( return; } - let all_btn = build_category_tile( + let all_btn = build_category_chip( &i18n("All"), "view-grid-symbolic", "cat-accent", true, ); category_box.append(&all_btn); @@ -923,21 +1107,26 @@ fn populate_categories( for (cat, _count) in categories.iter().take(12) { let (icon_name, color_class) = category_meta(cat); - let btn = build_category_tile(cat, icon_name, color_class, false); + let btn = build_category_chip(cat, icon_name, color_class, false); category_box.append(&btn); buttons.borrow_mut().push(btn.clone()); let cat_str = cat.clone(); let active_ref = active_category.clone(); let sort_ref = active_sort.clone(); + let page_ref = current_page.clone(); let flow_ref = flow_box.clone(); let search_ref = search_entry.clone(); let db_ref = db.clone(); let buttons_ref = buttons.clone(); + let compact_ref = compact_mode.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(); + let page_bar_ref = page_bar.clone(); + let page_label_ref = page_label.clone(); + let page_prev_ref = page_prev.clone(); + let page_next_ref = page_next.clone(); + let scrolled_ref = scrolled.clone(); btn.connect_toggled(move |btn| { if btn.is_active() { for other in buttons_ref.borrow().iter() { @@ -947,10 +1136,13 @@ fn populate_categories( } *active_ref.borrow_mut() = Some(cat_str.clone()); featured_section_ref.set_visible(false); + page_ref.set(0); let query = search_ref.text().to_string(); populate_grid( - &db_ref, &query, Some(&cat_str), sort_ref.get(), - &flow_ref, &all_label_ref, &nav_ref, &toast_ref, + &db_ref, &query, Some(&cat_str), sort_ref.get(), 0, + &flow_ref, &all_label_ref, &page_bar_ref, &page_label_ref, + &page_prev_ref, &page_next_ref, &scrolled_ref, + compact_ref.get(), ); } }); @@ -959,14 +1151,19 @@ fn populate_categories( { let active_ref = active_category.clone(); let sort_ref = active_sort.clone(); + let page_ref = current_page.clone(); let flow_ref = flow_box.clone(); let search_ref = search_entry.clone(); let db_ref = db.clone(); let buttons_ref = buttons.clone(); + let compact_ref = compact_mode.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(); + let page_bar_ref = page_bar.clone(); + let page_label_ref = page_label.clone(); + let page_prev_ref = page_prev.clone(); + let page_next_ref = page_next.clone(); + let scrolled_ref = scrolled.clone(); all_btn.connect_toggled(move |btn| { if btn.is_active() { for other in buttons_ref.borrow().iter() { @@ -976,10 +1173,13 @@ fn populate_categories( } *active_ref.borrow_mut() = None; featured_section_ref.set_visible(true); + page_ref.set(0); let query = search_ref.text().to_string(); populate_grid( - &db_ref, &query, None, sort_ref.get(), - &flow_ref, &all_label_ref, &nav_ref, &toast_ref, + &db_ref, &query, None, sort_ref.get(), 0, + &flow_ref, &all_label_ref, &page_bar_ref, &page_label_ref, + &page_prev_ref, &page_next_ref, &scrolled_ref, + compact_ref.get(), ); } }); diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index 09fac23..1c11112 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -38,6 +38,54 @@ pub fn build_dashboard_page(db: &Rc) -> adw::NavigationPage { content.append(&banner); } + // Getting Started checklist (shown for new users) + let records = db.get_all_appimages().unwrap_or_default(); + let total_count = records.len(); + let integrated_count = records.iter().filter(|r| r.integrated).count(); + if total_count < 3 { + let started_group = adw::PreferencesGroup::builder() + .title("Getting Started") + .description("New to Driftwood? Here are three steps to get you up and running.") + .build(); + + let scan_row = adw::ActionRow::builder() + .title("Scan your system for apps") + .subtitle("Look for AppImage files in your configured folders") + .activatable(true) + .build(); + scan_row.set_action_name(Some("win.scan")); + if total_count > 0 { + let check = widgets::status_badge_with_icon("emblem-ok-symbolic", "Done", "success"); + check.set_valign(gtk::Align::Center); + scan_row.add_suffix(&check); + } + started_group.add(&scan_row); + + let catalog_row = adw::ActionRow::builder() + .title("Browse the app catalog") + .subtitle("Discover and install apps from the AppImage ecosystem") + .activatable(true) + .build(); + catalog_row.set_action_name(Some("win.catalog")); + let arrow1 = gtk::Image::from_icon_name("go-next-symbolic"); + arrow1.set_valign(gtk::Align::Center); + catalog_row.add_suffix(&arrow1); + started_group.add(&catalog_row); + + let menu_row = adw::ActionRow::builder() + .title("Add an app to your launcher") + .subtitle("Make an app findable in your application menu") + .build(); + if integrated_count > 0 { + let check = widgets::status_badge_with_icon("emblem-ok-symbolic", "Done", "success"); + check.set_valign(gtk::Align::Center); + menu_row.add_suffix(&check); + } + started_group.add(&menu_row); + + content.append(&started_group); + } + // Section 1: System Status content.append(&build_system_status_group()); @@ -89,6 +137,7 @@ fn build_system_status_group() -> adw::PreferencesGroup { let session_row = adw::ActionRow::builder() .title("Display server") .subtitle(session.label()) + .tooltip_text("How your system draws windows on screen") .build(); let session_badge = widgets::status_badge( session.label(), @@ -107,15 +156,16 @@ fn build_system_status_group() -> adw::PreferencesGroup { let de_row = adw::ActionRow::builder() .title("Desktop environment") .subtitle(&de) + .tooltip_text("Your desktop interface") .build(); group.add(&de_row); // FUSE status let fuse_info = fuse::detect_system_fuse(); let fuse_row = adw::ActionRow::builder() - .title("FUSE") + .title("App compatibility") .subtitle(&fuse_description(&fuse_info)) - .tooltip_text("Filesystem in Userspace - required for mounting AppImages") + .tooltip_text("Most AppImages need a system component called FUSE to run. This shows whether it is set up correctly.") .build(); let fuse_badge = widgets::status_badge( fuse_info.status.label(), @@ -128,7 +178,7 @@ fn build_system_status_group() -> adw::PreferencesGroup { // Install hint if FUSE not functional if let Some(ref hint) = fuse_info.install_hint { let hint_row = adw::ActionRow::builder() - .title("Fix FUSE") + .title("Fix app compatibility") .subtitle(hint) .subtitle_selectable(true) .build(); @@ -141,7 +191,7 @@ fn build_system_status_group() -> adw::PreferencesGroup { let xwayland_row = adw::ActionRow::builder() .title("XWayland") .subtitle(if has_xwayland { "Running" } else { "Not detected" }) - .tooltip_text("X11 compatibility layer for Wayland desktops") + .tooltip_text("Compatibility layer that lets older apps run on modern displays") .build(); let xwayland_badge = widgets::status_badge( if has_xwayland { "Available" } else { "Unavailable" }, diff --git a/src/ui/detail_view.rs b/src/ui/detail_view.rs index 1a61969..ec696f9 100644 --- a/src/ui/detail_view.rs +++ b/src/ui/detail_view.rs @@ -12,6 +12,7 @@ use crate::core::fuse::{self, FuseStatus}; use crate::core::integrator; use crate::core::launcher::{self, SandboxMode}; use crate::core::notification; +use crate::core::sandbox; use crate::core::security; use crate::core::updater; use crate::core::wayland::{self, WaylandStatus}; @@ -296,11 +297,41 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box { } } + // Windows equivalent hint for novice users + if let Some(equiv) = windows_equivalent(name) { + let equiv_label = gtk::Label::builder() + .label(equiv) + .css_classes(["caption", "dim-label"]) + .halign(gtk::Align::Start) + .build(); + text_col.append(&equiv_label); + } + text_col.append(&badge_box); banner.append(&text_col); banner } +fn windows_equivalent(app_name: &str) -> Option<&'static str> { + match app_name.to_lowercase().as_str() { + s if s.contains("vlc") => Some("Similar to Windows Media Player"), + s if s.contains("gimp") => Some("Similar to Photoshop"), + s if s.contains("libreoffice") => Some("Similar to Microsoft Office"), + s if s.contains("firefox") => Some("Similar to Edge / Chrome"), + s if s.contains("chromium") || s.contains("brave") => Some("Similar to Chrome"), + s if s.contains("kdenlive") || s.contains("shotcut") => Some("Similar to Windows Video Editor"), + s if s.contains("krita") => Some("Similar to Paint / Photoshop"), + s if s.contains("thunderbird") => Some("Similar to Outlook"), + s if s.contains("telegram") || s.contains("signal") => Some("Similar to WhatsApp Desktop"), + s if s.contains("obs") => Some("Similar to OBS Studio (same app!)"), + s if s.contains("blender") => Some("Similar to Blender (same app!)"), + s if s.contains("audacity") => Some("Similar to Audacity (same app!)"), + s if s.contains("inkscape") => Some("Similar to Illustrator"), + s if s.contains("handbrake") => Some("Similar to HandBrake (same app!)"), + _ => None, + } +} + // --------------------------------------------------------------------------- // Tab 1: Overview - about, description, links, updates, releases, usage, // capabilities, file info @@ -382,6 +413,35 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { about_group.add(&row); } + // Categories display + if let Some(ref cats) = record.categories { + if !cats.is_empty() { + let cat_row = adw::ActionRow::builder() + .title("Categories") + .build(); + let cat_box = gtk::Box::new(gtk::Orientation::Horizontal, 4); + cat_box.set_valign(gtk::Align::Center); + for cat in cats.split(';').filter(|c| !c.is_empty()) { + let friendly = match cat.trim() { + "AudioVideo" | "Audio" | "Video" => "Media", + "Development" => "Developer Tools", + "Education" | "Science" => "Science & Education", + "Game" => "Games", + "Graphics" => "Graphics", + "Network" => "Internet", + "Office" => "Office", + "System" => "System Tools", + "Utility" => "Utilities", + other => other, + }; + let badge = widgets::status_badge(friendly, "neutral"); + cat_box.append(&badge); + } + cat_row.add_suffix(&cat_box); + about_group.add(&cat_row); + } + } + inner.append(&about_group); } @@ -620,8 +680,9 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { let row = adw::ActionRow::builder() .title("Update method") .subtitle( - "This app does not include update information. \ - You will need to check for new versions manually." + "This app does not include automatic update information. \ + To update manually, download a newer version from the \ + developer's website and drag it into Driftwood." ) .tooltip_text( "AppImages can include built-in update information that tells Driftwood \ @@ -675,6 +736,22 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { .build(); updates_group.add(&row); } + + // Per-app auto-update toggle (pinned = skip updates) + let pin_row = adw::SwitchRow::builder() + .title("Skip auto-updates") + .subtitle("When enabled, this app will be excluded from batch update checks") + .active(record.pinned) + .build(); + { + let db_ref = db.clone(); + let record_id = record.id; + pin_row.connect_active_notify(move |row| { + let _ = db_ref.set_pinned(record_id, row.is_active()); + }); + } + updates_group.add(&pin_row); + inner.append(&updates_group); // ----------------------------------------------------------------------- @@ -837,8 +914,8 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { .build(); let type_str = match record.appimage_type { - Some(1) => "Type 1 - older format, still widely supported", - Some(2) => "Type 2 - modern, compressed format", + Some(1) => "Legacy format - an older packaging style. Still works but less common.", + Some(2) => "Modern format - compressed and efficient. This is the standard format for most AppImages.", _ => "Unknown type", }; let type_row = adw::ActionRow::builder() @@ -870,19 +947,19 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { let sig_row = adw::ActionRow::builder() .title("Verified by developer") .subtitle(if record.has_signature { - "Signed by the developer" + "This app includes a developer signature, which helps verify it has not been tampered with." } else { - "Not signed" + "This is normal for most AppImages and does not mean the app is unsafe." }) .tooltip_text( - "This app was signed by its developer, which helps verify \ - it hasn't been tampered with since it was published." + "Some developers sign their apps with a cryptographic key. \ + This helps verify the file hasn't been modified since it was published." ) .build(); let sig_badge = if record.has_signature { widgets::status_badge("Signed", "success") } else { - widgets::status_badge("Unsigned", "neutral") + widgets::status_badge("No signature", "neutral") }; sig_badge.set_valign(gtk::Align::Center); sig_row.add_suffix(&sig_badge); @@ -1040,7 +1117,7 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc, toast_overlay: & // Autostart toggle let autostart_row = adw::SwitchRow::builder() .title("Start at login") - .subtitle("Launch this app automatically when you log in") + .subtitle("Launch this app automatically when you log in, like a Windows Startup program") .active(record.autostart) .tooltip_text( "Creates an autostart entry so this app launches \ @@ -1070,9 +1147,9 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc, toast_overlay: & }); integration_group.add(&autostart_row); - // StartupWMClass row with editable override + // StartupWMClass row with editable override - hidden in Advanced expander let wm_class_row = adw::EntryRow::builder() - .title("Window class (advanced)") + .title("Window class") .text(record.startup_wm_class.as_deref().unwrap_or("")) .show_apply_button(true) .build(); @@ -1090,7 +1167,12 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc, toast_overlay: & } } }); - integration_group.add(&wm_class_row); + let advanced_expander = adw::ExpanderRow::builder() + .title("Advanced settings") + .show_enable_switch(false) + .build(); + advanced_expander.add_row(&wm_class_row); + integration_group.add(&advanced_expander); // System-wide install toggle if record.integrated { @@ -1409,11 +1491,11 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc, toast_overlay: & let fuse_status = fuse_system.status.clone(); let fuse_row = adw::ActionRow::builder() - .title("App mounting") + .title("App startup system") .subtitle(fuse_user_explanation(&fuse_status)) .tooltip_text( - "FUSE lets apps like AppImages run directly without unpacking first. \ - Without it, apps still work but take a little longer to start." + "Most AppImages need a system component called FUSE to mount and run. \ + This shows whether it is available." ) .build(); let fuse_badge = widgets::status_badge_with_icon( @@ -1434,12 +1516,14 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc, toast_overlay: & // Per-app launch method let appimage_path = std::path::Path::new(&record.path); let app_fuse_status = fuse::determine_app_fuse_status(&fuse_system, appimage_path); + let launch_method_subtitle = if app_fuse_status.as_str() == "extract_and_run" { + "This app unpacks itself each time it starts. This is slower than normal but works without extra system setup.".to_string() + } else { + format!("This app will launch using: {}", app_fuse_status.label()) + }; let launch_method_row = adw::ActionRow::builder() .title("Startup method") - .subtitle(&format!( - "This app will launch using: {}", - app_fuse_status.label() - )) + .subtitle(&launch_method_subtitle) .tooltip_text( "AppImages can start two ways: mounting (fast, instant startup) or \ unpacking to a temporary folder first (slower, but works everywhere). \ @@ -1457,10 +1541,10 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc, toast_overlay: & // Sandboxing group let sandbox_group = adw::PreferencesGroup::builder() - .title("App Isolation") + .title("Security Restrictions") .description( - "Restrict what this app can access on your system \ - for extra security." + "Control what this app can access on your system. \ + When enabled, the app can only reach your Documents and Downloads folders." ) .build(); @@ -1490,8 +1574,51 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc, toast_overlay: & ) .build(); + // Profile status row - shows current sandbox profile info + let profile_row = adw::ActionRow::builder() + .title("Sandbox profile") + .build(); + + let app_name_for_sandbox = record + .app_name + .clone() + .unwrap_or_else(|| { + std::path::Path::new(&record.filename) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string() + }); + + // Show current profile status + let loaded_profile = sandbox::load_profile(db, &app_name_for_sandbox) + .ok() + .flatten(); + let profile_badge = if loaded_profile.is_some() { + widgets::status_badge("Active", "success") + } else if current_mode == SandboxMode::Firejail { + widgets::status_badge("Default", "neutral") + } else { + widgets::status_badge("None", "dim") + }; + profile_badge.set_valign(gtk::Align::Center); + profile_row.add_suffix(&profile_badge); + + // Show total profile count across all apps in group description + let all_profiles = sandbox::list_profiles(db); + if !all_profiles.is_empty() { + sandbox_group.set_description(Some(&format!( + "Restrict what this app can access on your system \ + for extra security. {} sandbox {} configured across all apps.", + all_profiles.len(), + if all_profiles.len() == 1 { "profile" } else { "profiles" }, + ))); + } + let record_id = record.id; let db_ref = db.clone(); + let sandbox_name = app_name_for_sandbox.clone(); + let profile_row_ref = profile_row.clone(); firejail_row.connect_active_notify(move |row| { let mode = if row.is_active() { SandboxMode::Firejail @@ -1501,9 +1628,72 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc, toast_overlay: & if let Err(e) = db_ref.update_sandbox_mode(record_id, Some(mode.as_str())) { log::warn!("Failed to update sandbox mode: {}", e); } + // Auto-generate a default sandbox profile when enabling isolation + if mode == SandboxMode::Firejail { + let existing = sandbox::load_profile(&db_ref, &sandbox_name) + .ok() + .flatten(); + if existing.is_none() { + let profile = sandbox::generate_default_profile(&sandbox_name); + match sandbox::save_profile(&db_ref, &profile) { + Ok(path) => { + log::info!("Created default sandbox profile at {}", path.display()); + // Update badge to show profile is active + while let Some(child) = profile_row_ref.last_child() { + profile_row_ref.remove(&child); + } + let badge = widgets::status_badge("Active", "success"); + badge.set_valign(gtk::Align::Center); + profile_row_ref.add_suffix(&badge); + } + Err(e) => log::warn!("Failed to create sandbox profile: {}", e), + } + } + } }); sandbox_group.add(&firejail_row); + if firejail_available && current_mode == SandboxMode::Firejail { + // Show profile explanation when sandbox is active + if let Some(path) = sandbox::profile_path_for_app(&app_name_for_sandbox) { + profile_row.set_subtitle(&format!( + "Blocks access to most of your files, camera, microphone, USB devices, and other apps. Only Documents and Downloads folders are accessible.\n{}", + path.display() + )); + } else { + profile_row.set_subtitle("Default restrictive profile will be created on next launch"); + } + + // Reset button to delete current profile and regenerate default + if let Some(ref profile) = loaded_profile { + if let Some(profile_id) = profile.id { + let reset_btn = gtk::Button::builder() + .icon_name("edit-clear-symbolic") + .tooltip_text("Reset to default profile") + .valign(gtk::Align::Center) + .css_classes(["flat"]) + .build(); + let db_reset = db.clone(); + let reset_name = app_name_for_sandbox.clone(); + reset_btn.connect_clicked(move |_btn| { + if let Err(e) = sandbox::delete_profile(&db_reset, profile_id) { + log::warn!("Failed to delete sandbox profile: {}", e); + return; + } + // Regenerate a fresh default + let profile = sandbox::generate_default_profile(&reset_name); + if let Err(e) = sandbox::save_profile(&db_reset, &profile) { + log::warn!("Failed to regenerate sandbox profile: {}", e); + } else { + log::info!("Reset sandbox profile for {}", reset_name); + } + }); + profile_row.add_suffix(&reset_btn); + } + } + sandbox_group.add(&profile_row); + } + if !firejail_available { let firejail_cmd = "sudo apt install firejail"; let info_row = adw::ActionRow::builder() @@ -1892,18 +2082,19 @@ fn build_storage_tab( } if !fp.paths.is_empty() { - let categories = [ - ("Configuration", fp.config_size), - ("Application data", fp.data_size), - ("Cache", fp.cache_size), - ("State", fp.state_size), - ("Other", fp.other_size), + let categories: &[(&str, u64, &str)] = &[ + ("Configuration", fp.config_size, "Your preferences and settings for this app"), + ("Application data", fp.data_size, "Files and data saved by this app"), + ("Cache", fp.cache_size, "Temporary files that can be safely deleted"), + ("State", fp.state_size, "Runtime state like window positions and recent files"), + ("Other", fp.other_size, "Other files associated with this app"), ]; - for (label, size) in &categories { + for (label, size, tooltip) in categories { if *size > 0 { let row = adw::ActionRow::builder() .title(*label) .subtitle(&widgets::format_size(*size as i64)) + .tooltip_text(*tooltip) .build(); size_group.add(&row); } @@ -2001,11 +2192,40 @@ fn build_storage_tab( .valign(gtk::Align::Center) .build(); row.add_suffix(&size_label); + + // Open folder button + let open_btn = gtk::Button::builder() + .icon_name("folder-open-symbolic") + .tooltip_text("Open in file manager") + .valign(gtk::Align::Center) + .build(); + open_btn.add_css_class("flat"); + let path_str = dp.path.to_string_lossy().to_string(); + open_btn.connect_clicked(move |_| { + let file = gio::File::for_path(&path_str); + let launcher = gtk::FileLauncher::new(Some(&file)); + launcher.open_containing_folder(gtk::Window::NONE, None::<&gio::Cancellable>, |_| {}); + }); + row.add_suffix(&open_btn); + paths_group.add(&row); } } inner.append(&paths_group); + // Backup estimate + let est_size = fp.config_size + fp.data_size + fp.state_size; + if est_size > 0 { + let estimate_row = adw::ActionRow::builder() + .title("Backup estimate") + .subtitle(&format!( + "Estimated backup size: {} (settings and data files)", + widgets::format_size(est_size as i64) + )) + .build(); + paths_group.add(&estimate_row); + } + // Backups group inner.append(&build_backup_group(record.id, toast_overlay)); @@ -2357,19 +2577,19 @@ fn wayland_user_explanation(status: &WaylandStatus) -> &'static str { fn fuse_user_explanation(status: &FuseStatus) -> &'static str { match status { FuseStatus::FullyFunctional => - "Everything is set up - apps start instantly.", + "This app can start normally using your system's app loader.", FuseStatus::Fuse3Only => - "A small system component is missing. Most apps will still work, \ - but some may need it. Copy the install command to fix this.", + "A system component is needed for this app to start normally. \ + Without it, the app uses a slower startup method.", FuseStatus::NoFusermount => - "A system component is missing, so apps will take a little longer \ - to start. They'll still work fine.", + "A system component is needed for this app to start normally. \ + Without it, the app uses a slower startup method.", FuseStatus::NoDevFuse => - "Your system doesn't support instant app mounting. Apps will unpack \ - before starting, which takes a bit longer.", + "A system component is needed for this app to start normally. \ + Without it, the app uses a slower startup method.", FuseStatus::MissingLibfuse2 => - "A small system component is needed for fast startup. \ - Copy the install command to fix this.", + "A system component is needed for this app to start normally. \ + Without it, the app uses a slower startup method.", } } @@ -2624,9 +2844,9 @@ pub fn show_uninstall_dialog_with_callback( db: &Rc, is_integrated: bool, data_paths: &[(String, String, u64)], - on_complete: Option>, + on_complete: Option>, ) { - let name = record.app_name.as_deref().unwrap_or(&record.filename); + let name = record.app_name.as_deref().unwrap_or(&record.filename).to_string(); let dialog = adw::AlertDialog::builder() .heading(&format!("Uninstall {}?", name)) .body("Select what to remove:") @@ -2675,46 +2895,34 @@ pub fn show_uninstall_dialog_with_callback( dialog.set_extra_child(Some(&extra)); + let record_snapshot = record.clone(); let record_id = record.id; 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 { - if check.is_active() { - integrator::undo_all_modifications(&db_ref, record_id).ok(); - if let Ok(Some(rec)) = db_ref.get_appimage_by_id(record_id) { - integrator::remove_integration(&rec).ok(); - } + // Capture deletion choices from checkboxes before dialog closes + let delete_file = appimage_check.is_active(); + let should_remove_integration = integration_check.as_ref().map_or(false, |c| c.is_active()); + let paths_to_delete: Vec = path_checks.iter() + .filter(|(check, _)| check.is_active()) + .map(|(_, path)| path.clone()) + .collect(); + + // Remove integration immediately (before DB delete, since CASCADE + // removes system_modifications entries we need for undo_all_modifications) + if should_remove_integration { + integrator::undo_all_modifications(&db_ref, record_id).ok(); + if let Ok(Some(rec)) = db_ref.get_appimage_by_id(record_id) { + integrator::remove_integration(&rec).ok(); } } - // Remove checked data paths - for (check, path) in &path_checks { - if check.is_active() { - let p = std::path::Path::new(path); - if p.is_dir() { - std::fs::remove_dir_all(p).ok(); - } else if p.is_file() { - std::fs::remove_file(p).ok(); - } - } - } - - // Remove AppImage file if checked - if appimage_check.is_active() { - std::fs::remove_file(&record_path).ok(); - } - - // Remove from database + // Remove from database (so item vanishes from lists) db_ref.remove_appimage(record_id).ok(); - toast_ref.add_toast(adw::Toast::new("AppImage uninstalled")); - - // Run the completion callback if provided - if let Some(cb) = on_complete.take() { + // Run the completion callback to refresh the library view + if let Some(ref cb) = on_complete { cb(); } @@ -2723,6 +2931,66 @@ pub fn show_uninstall_dialog_with_callback( let nav: adw::NavigationView = nav.downcast().unwrap(); nav.pop(); } + + // Show undo toast - file deletion is deferred until toast dismisses + let toast = adw::Toast::builder() + .title(&format!("{} uninstalled", name)) + .button_label("Undo") + .timeout(7) + .build(); + + let undo_clicked = Rc::new(Cell::new(false)); + + // On Undo: restore record to DB, re-integrate if needed, refresh + { + let undo_flag = undo_clicked.clone(); + let db_undo = db_ref.clone(); + let snapshot = record_snapshot.clone(); + let toast_undo = toast_ref.clone(); + let on_complete_undo = on_complete.clone(); + let name_undo = name.clone(); + let was_integrated = should_remove_integration; + toast.connect_button_clicked(move |_| { + undo_flag.set(true); + db_undo.restore_appimage_record(&snapshot).ok(); + // Re-integrate if it was previously integrated + if was_integrated { + integrator::integrate(&snapshot).ok(); + } + if let Some(ref cb) = on_complete_undo { + cb(); + } + toast_undo.add_toast(adw::Toast::new(&format!("{} restored", name_undo))); + }); + } + + // On dismiss (timeout or any reason): perform actual file deletions if not undone + { + let undo_flag = undo_clicked.clone(); + let path = record_path.clone(); + toast.connect_dismissed(move |_| { + if undo_flag.get() { + return; // Undo was clicked, nothing to delete + } + + // Remove data paths + for data_path in &paths_to_delete { + let p = std::path::Path::new(data_path); + if p.is_dir() { + std::fs::remove_dir_all(p).ok(); + } else if p.is_file() { + std::fs::remove_file(p).ok(); + } + } + + // Remove AppImage file + if delete_file { + std::fs::remove_file(&path).ok(); + } + }); + } + + toast_ref.add_toast(toast); }); dialog.present(Some(toast_overlay)); diff --git a/src/ui/drop_dialog.rs b/src/ui/drop_dialog.rs index c8a830d..651698a 100644 --- a/src/ui/drop_dialog.rs +++ b/src/ui/drop_dialog.rs @@ -50,7 +50,13 @@ pub fn show_drop_dialog( }; let body = if count == 1 { - files[0].to_string_lossy().to_string() + let path_str = files[0].to_string_lossy().to_string(); + let size = std::fs::metadata(&files[0]).map(|m| m.len()).unwrap_or(0); + if size > 0 { + format!("{}\n({})", path_str, super::widgets::format_size(size as i64)) + } else { + path_str + } } else { files .iter() @@ -87,9 +93,9 @@ pub fn show_drop_dialog( } dialog.add_response("cancel", &i18n("Cancel")); - dialog.add_response("keep-in-place", &i18n("Keep in place")); - dialog.add_response("copy-only", &i18n("Copy to Applications")); - dialog.add_response("copy-and-integrate", &i18n("Copy & add to menu")); + dialog.add_response("keep-in-place", &i18n("Run Portable")); + dialog.add_response("copy-only", &i18n("Copy to Apps")); + dialog.add_response("copy-and-integrate", &i18n("Copy & Add to Launcher")); dialog.set_response_appearance("copy-and-integrate", adw::ResponseAppearance::Suggested); dialog.set_default_response(Some("copy-and-integrate")); diff --git a/src/ui/fuse_wizard.rs b/src/ui/fuse_wizard.rs index af1a876..874a188 100644 --- a/src/ui/fuse_wizard.rs +++ b/src/ui/fuse_wizard.rs @@ -52,7 +52,7 @@ pub fn show_fuse_wizard(parent: &impl IsA) { .build(); let title = gtk::Label::builder() - .label(&i18n("FUSE is required")) + .label(&i18n("Additional setup needed")) .xalign(0.0) .build(); title.add_css_class("title-3"); @@ -60,8 +60,9 @@ pub fn show_fuse_wizard(parent: &impl IsA) { let explanation = gtk::Label::builder() .label(&i18n( - "Most AppImages require libfuse2 to mount and run. \ - Without it, apps will use a slower extract-and-run fallback or may not launch at all.", + "Most apps need a small system component called FUSE to start quickly. \ + Without it, apps will still work but will take longer to start each time. \ + You can install it now (requires your password) or skip and use the slower method.", )) .wrap(true) .xalign(0.0) @@ -101,18 +102,34 @@ pub fn show_fuse_wizard(parent: &impl IsA) { .margin_top(12) .build(); + let skip_btn = gtk::Button::builder() + .label(&i18n("Skip and use slower method")) + .tooltip_text(&i18n("Apps will still work, but they will take longer to start because they unpack themselves each time")) + .build(); + skip_btn.add_css_class("flat"); + skip_btn.add_css_class("pill"); + let install_btn = gtk::Button::builder() .label(&i18n("Install via pkexec")) .build(); install_btn.add_css_class("suggested-action"); install_btn.add_css_class("pill"); + button_box.append(&skip_btn); button_box.append(&install_btn); content.append(&button_box); toolbar.set_content(Some(&content)); dialog.set_child(Some(&toolbar)); + // Skip button just closes the dialog + let dialog_weak = dialog.downgrade(); + skip_btn.connect_clicked(move |_| { + if let Some(dlg) = dialog_weak.upgrade() { + dlg.close(); + } + }); + let cmd = install_cmd.clone(); let status_ref = status_label.clone(); let btn_ref = install_btn.clone(); diff --git a/src/ui/library_view.rs b/src/ui/library_view.rs index 6679a5b..b651ba9 100644 --- a/src/ui/library_view.rs +++ b/src/ui/library_view.rs @@ -89,7 +89,7 @@ impl LibraryView { let search_button = gtk::ToggleButton::builder() .icon_name("system-search-symbolic") - .tooltip_text(&i18n("Search")) + .tooltip_text(&i18n("Search (Ctrl+F)")) .build(); search_button.add_css_class("flat"); search_button.update_property(&[AccessibleProperty::Label("Toggle search")]); @@ -124,7 +124,7 @@ impl LibraryView { // Scan button let scan_button = gtk::Button::builder() .icon_name("view-refresh-symbolic") - .tooltip_text(&i18n("Scan for AppImages")) + .tooltip_text(&i18n("Scan for AppImages (Ctrl+R)")) .build(); scan_button.add_css_class("flat"); scan_button.set_action_name(Some("win.scan")); @@ -244,14 +244,44 @@ impl LibraryView { browse_catalog_btn.set_action_name(Some("win.catalog")); browse_catalog_btn.update_property(&[AccessibleProperty::Label("Browse app catalog")]); + let learn_btn = gtk::Button::builder() + .label(&i18n("What is an AppImage?")) + .build(); + learn_btn.add_css_class("flat"); + learn_btn.add_css_class("pill"); + learn_btn.connect_clicked(|btn| { + let dialog = adw::AlertDialog::builder() + .heading(&i18n("What is an AppImage?")) + .body(&i18n( + "AppImages are self-contained app files for Linux, similar to .exe files on Windows or .dmg files on Mac.\n\n\ + Key differences from traditional Linux packages:\n\ + - No installation needed - just download and run\n\ + - One file per app - easy to back up and share\n\ + - Works on most Linux distributions\n\ + - Does not require admin/root access\n\n\ + Driftwood helps you discover, organize, and keep your AppImages up to date." + )) + .build(); + dialog.add_response("learn-more", &i18n("Learn More Online")); + dialog.add_response("ok", &i18n("Got It")); + dialog.set_default_response(Some("ok")); + dialog.set_close_response("ok"); + dialog.connect_response(Some("learn-more"), |_, _| { + gtk::UriLauncher::new("https://appimage.org") + .launch(gtk::Window::NONE, gtk::gio::Cancellable::NONE, |_| {}); + }); + dialog.present(Some(btn)); + }); + empty_button_box.append(&scan_now_btn); empty_button_box.append(&browse_catalog_btn); + empty_button_box.append(&learn_btn); let empty_page = adw::StatusPage::builder() .icon_name("application-x-executable-symbolic") .title(&i18n("No AppImages Yet")) .description(&i18n( - "Drag and drop AppImage files here, or scan your system to find them.", + "AppImages are portable apps for Linux - like .exe files, but they run without installation. Drag one here, scan your system, or browse the catalog to get started.", )) .child(&empty_button_box) .build(); @@ -361,6 +391,9 @@ impl LibraryView { toolbar_view.set_content(Some(&content_box)); widgets::apply_pointer_cursors(&toolbar_view); + // Enable type-to-search: any keypress in the view opens the search bar + search_bar.set_key_capture_widget(Some(&toolbar_view)); + let page = adw::NavigationPage::builder() .title("Driftwood") .tag("library") @@ -670,6 +703,18 @@ impl LibraryView { icon.add_css_class("icon-rounded"); row.add_prefix(&icon); + // Quick launch button + let launch_btn = gtk::Button::builder() + .icon_name("media-playback-start-symbolic") + .tooltip_text(&i18n("Launch")) + .css_classes(["flat", "circular"]) + .valign(gtk::Align::Center) + .build(); + launch_btn.set_action_name(Some("win.launch-appimage")); + launch_btn.set_action_target_value(Some(&record.id.to_variant())); + widgets::set_pointer_cursor(&launch_btn); + row.add_suffix(&launch_btn); + // Single most important badge as suffix (same priority as cards) if let Some(badge) = app_card::build_priority_badge(record) { badge.set_valign(gtk::Align::Center); @@ -792,7 +837,7 @@ fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu { // Section 1: Launch let section1 = gtk::gio::Menu::new(); - section1.append(Some("Launch"), Some(&format!("win.launch-appimage(int64 {})", record.id))); + section1.append(Some("Open"), Some(&format!("win.launch-appimage(int64 {})", record.id))); menu.append_section(None, §ion1); // Section 2: Actions @@ -803,7 +848,7 @@ fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu { // Section 3: Integration + folder let section3 = gtk::gio::Menu::new(); - let integrate_label = if record.integrated { "Remove from app menu" } else { "Add to app menu" }; + let integrate_label = if record.integrated { "Remove from launcher" } else { "Add to launcher" }; section3.append(Some(integrate_label), Some(&format!("win.toggle-integration(int64 {})", record.id))); section3.append(Some("Show in file manager"), Some(&format!("win.open-folder(int64 {})", record.id))); menu.append_section(None, §ion3); diff --git a/src/ui/permission_dialog.rs b/src/ui/permission_dialog.rs index 0e00e57..968c825 100644 --- a/src/ui/permission_dialog.rs +++ b/src/ui/permission_dialog.rs @@ -37,10 +37,10 @@ pub fn show_permission_dialog( extra.append(&access_label); let items = [ - "Your home directory and files", - "Network and internet access", - "Display server (Wayland/X11)", - "System D-Bus and services", + "Your files and folders", + "Internet and network access", + "Your screen and windows", + "Background system services", ]; for item in &items { let label = gtk::Label::builder() @@ -50,15 +50,29 @@ pub fn show_permission_dialog( extra.append(&label); } + // Explanation paragraph + let explain = gtk::Label::builder() + .label(&i18n( + "AppImages run like regular programs on your computer. Unlike phone apps, \ + desktop apps typically have full access to your files and system. This is normal.", + )) + .wrap(true) + .xalign(0.0) + .margin_top(8) + .build(); + explain.add_css_class("caption"); + explain.add_css_class("dim-label"); + extra.append(&explain); + // Show firejail option if available if launcher::has_firejail() { let sandbox_note = gtk::Label::builder() .label(&i18n( - "Firejail is available on your system. You can configure sandboxing in the app's system tab.", + "You can restrict this app's access later in Details > Security Restrictions.", )) .wrap(true) .xalign(0.0) - .margin_top(8) + .margin_top(4) .build(); sandbox_note.add_css_class("dim-label"); extra.append(&sandbox_note); diff --git a/src/ui/preferences.rs b/src/ui/preferences.rs index f853abb..2d1477b 100644 --- a/src/ui/preferences.rs +++ b/src/ui/preferences.rs @@ -81,7 +81,7 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog) // Scan Locations group let scan_group = adw::PreferencesGroup::builder() .title(&i18n("Scan Locations")) - .description(&i18n("Directories to scan for AppImage files")) + .description(&i18n("Folders where Driftwood looks for AppImage files. Add any folder where you save downloaded apps.")) .build(); let dirs = settings.strv("scan-directories"); @@ -157,6 +157,7 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog) // Automation group let automation_group = adw::PreferencesGroup::builder() .title(&i18n("Automation")) + .description(&i18n("Control what happens automatically when Driftwood starts or finds new apps.")) .build(); let auto_scan_row = adw::SwitchRow::builder() @@ -260,6 +261,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage { // Update Checking group let checking_group = adw::PreferencesGroup::builder() .title(&i18n("Update Checking")) + .description(&i18n("Let Driftwood periodically check if newer versions of your apps are available.")) .build(); let auto_update_row = adw::SwitchRow::builder() @@ -297,6 +299,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage { // Update Behavior group let behavior_group = adw::PreferencesGroup::builder() .title(&i18n("Update Behavior")) + .description(&i18n("Control what happens when an app is updated to a newer version.")) .build(); let cleanup_row = adw::ComboRow::builder() @@ -359,7 +362,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage { // Security Scanning group let security_group = adw::PreferencesGroup::builder() .title(&i18n("Security Scanning")) - .description(&i18n("Check bundled libraries for known CVEs via OSV.dev")) + .description(&i18n("Automatically check the components bundled inside your apps for known security issues via the OSV.dev database.")) .build(); let auto_security_row = adw::SwitchRow::builder() @@ -417,7 +420,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage { // Catalog Enrichment group let enrichment_group = adw::PreferencesGroup::builder() .title(&i18n("Catalog Enrichment")) - .description(&i18n("Fetch GitHub metadata (stars, version, downloads) for catalog apps")) + .description(&i18n("Fetch additional app information like stars, downloads, and descriptions from GitHub to enrich the catalog.")) .build(); let auto_enrich_row = adw::SwitchRow::builder() @@ -445,7 +448,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage { 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")) + .title(&i18n("Optional. Speeds up catalog data fetching. Get a free token at github.com/settings/tokens (no special permissions needed).")) .css_classes(["dim-label"]) .build(); enrichment_group.add(&token_hint); diff --git a/src/ui/security_report.rs b/src/ui/security_report.rs index 3c7aba5..1a48404 100644 --- a/src/ui/security_report.rs +++ b/src/ui/security_report.rs @@ -277,7 +277,7 @@ fn build_report_content(db: &Rc) -> gtk::ScrolledWindow { fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::PreferencesGroup { let group = adw::PreferencesGroup::builder() .title("Vulnerability Summary") - .description("Overall security status across all your apps") + .description("Driftwood checks the software components bundled inside your apps against a database of known security issues (CVEs). Most issues are in underlying libraries, not the apps themselves.") .build(); let total_row = adw::ActionRow::builder() @@ -294,6 +294,7 @@ fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::Pref let row = adw::ActionRow::builder() .title("Critical") .subtitle(&summary.critical.to_string()) + .tooltip_text("Could allow an attacker to take control of affected components") .build(); let badge = widgets::status_badge("Critical", "error"); badge.set_valign(gtk::Align::Center); @@ -304,6 +305,7 @@ fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::Pref let row = adw::ActionRow::builder() .title("High") .subtitle(&summary.high.to_string()) + .tooltip_text("Could allow unauthorized access to data processed by the app") .build(); let badge = widgets::status_badge("High", "error"); badge.set_valign(gtk::Align::Center); @@ -314,6 +316,7 @@ fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::Pref let row = adw::ActionRow::builder() .title("Medium") .subtitle(&summary.medium.to_string()) + .tooltip_text("Could cause the app to behave unexpectedly or crash") .build(); let badge = widgets::status_badge("Medium", "warning"); badge.set_valign(gtk::Align::Center); @@ -324,6 +327,7 @@ fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::Pref let row = adw::ActionRow::builder() .title("Low") .subtitle(&summary.low.to_string()) + .tooltip_text("Minor issue with limited practical impact") .build(); let badge = widgets::status_badge("Low", "neutral"); badge.set_valign(gtk::Align::Center); @@ -340,7 +344,10 @@ fn build_app_findings_group( summary: &crate::core::database::CveSummary, cve_matches: &[crate::core::database::CveMatchRecord], ) -> adw::PreferencesGroup { - let description = format!("{} known security issues found", summary.total()); + let description = format!( + "{} known security issues found. Check if a newer version is available in the catalog or from the developer's website. Most security issues are fixed in newer releases.", + summary.total() + ); let group = adw::PreferencesGroup::builder() .title(app_name) .description(&description) diff --git a/src/ui/update_dialog.rs b/src/ui/update_dialog.rs index a597e0c..7490221 100644 --- a/src/ui/update_dialog.rs +++ b/src/ui/update_dialog.rs @@ -255,18 +255,23 @@ fn handle_old_version_cleanup(dialog: &adw::AlertDialog, old_path: PathBuf) { } /// Batch check all AppImages for updates. Returns count of updates found. -pub fn batch_check_updates(db: &Database) -> u32 { +/// Check all apps for updates, returns (count, list of app names with updates). +pub fn batch_check_updates_detailed(db: &Database) -> (u32, Vec) { let records = match db.get_all_appimages() { Ok(r) => r, Err(e) => { log::error!("Failed to get appimages for update check: {}", e); - return 0; + return (0, vec![]); } }; let mut updates_found = 0u32; + let mut updated_names = Vec::new(); for record in &records { + if record.pinned { + continue; + } let appimage_path = std::path::Path::new(&record.path); if !appimage_path.exists() { continue; @@ -292,6 +297,8 @@ pub fn batch_check_updates(db: &Database) -> u32 { if let Some(ref version) = result.latest_version { db.set_update_available(record.id, Some(version), result.download_url.as_deref()).ok(); updates_found += 1; + let name = record.app_name.as_deref().unwrap_or(&record.filename); + updated_names.push(name.to_string()); } } else { db.clear_update_available(record.id).ok(); @@ -299,5 +306,9 @@ pub fn batch_check_updates(db: &Database) -> u32 { } } - updates_found + (updates_found, updated_names) +} + +pub fn batch_check_updates(db: &Database) -> u32 { + batch_check_updates_detailed(db).0 } diff --git a/src/ui/updates_view.rs b/src/ui/updates_view.rs index ff2f721..ad5f887 100644 --- a/src/ui/updates_view.rs +++ b/src/ui/updates_view.rs @@ -25,7 +25,7 @@ pub fn build_updates_view(db: &Rc) -> adw::ToolbarView { // Check Now button let check_btn = gtk::Button::builder() .icon_name("view-refresh-symbolic") - .tooltip_text(&i18n("Check for updates")) + .tooltip_text(&i18n("Check for updates (Ctrl+U)")) .build(); header.pack_end(&check_btn); @@ -86,6 +86,18 @@ pub fn build_updates_view(db: &Rc) -> adw::ToolbarView { .build(); updates_content.append(&last_checked_label); + // "What will happen" explanation + let explanation = gtk::Label::builder() + .label(&i18n("Each app will be downloaded fresh. Your settings and data are kept. You can choose to keep old versions as backup in Preferences.")) + .css_classes(["caption", "dim-label"]) + .wrap(true) + .xalign(0.0) + .margin_start(18) + .margin_end(18) + .margin_top(6) + .build(); + updates_content.append(&explanation); + let clamp = adw::Clamp::builder() .maximum_size(800) .tightening_threshold(600) @@ -255,10 +267,15 @@ fn populate_update_list(state: &Rc) { .activatable(false) .build(); - // Show version info: current -> latest + // Show version info: current -> latest (with size if available) let current = record.app_version.as_deref().unwrap_or("unknown"); let latest = record.latest_version.as_deref().unwrap_or("unknown"); - row.set_subtitle(&format!("{} -> {}", current, latest)); + let subtitle = if record.size_bytes > 0 { + format!("{} -> {} ({})", current, latest, widgets::format_size(record.size_bytes)) + } else { + format!("{} -> {}", current, latest) + }; + row.set_subtitle(&subtitle); // App icon let icon = widgets::app_icon(record.icon_path.as_deref(), name, 32); diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index e8e0ddb..fc1e65f 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -339,9 +339,8 @@ pub fn show_crash_dialog( /// Generate a plain-text explanation of why an app crashed based on stderr patterns. fn crash_explanation(stderr: &str) -> String { if stderr.contains("Could not find the Qt platform plugin") || stderr.contains("qt.qpa.plugin") { - return "The app couldn't find a required display plugin. This usually means \ - it needs a Qt library that isn't bundled inside the AppImage or \ - available on your system.".to_string(); + return "This app needs a display system plugin that is not installed. \ + Try installing the Qt platform packages for your system.".to_string(); } if stderr.contains("cannot open shared object file") { if let Some(pos) = stderr.find("cannot open shared object file") { @@ -350,29 +349,31 @@ fn crash_explanation(stderr: &str) -> String { let lib = before[start + 2..].trim(); if !lib.is_empty() { return format!( - "The app needs a system library ({}) that isn't installed. \ - You may be able to fix this by installing the missing package.", + "This app is missing a component it needs to run \ + (similar to a missing DLL on Windows). \ + The missing component is: {}", lib, ); } } } - return "The app needs a system library that isn't installed on your system.".to_string(); + return "This app is missing a component it needs to run \ + (similar to a missing DLL on Windows).".to_string(); } if stderr.contains("Segmentation fault") || stderr.contains("SIGSEGV") { - return "The app crashed due to a memory error. This is usually a bug \ - in the app itself, not something you can fix.".to_string(); + return "This app crashed immediately. This is usually a bug in the \ + app itself, not something you can fix.".to_string(); } if stderr.contains("Permission denied") { - return "The app was blocked from accessing something it needs. \ - Check that the AppImage file has the right permissions.".to_string(); + return "This app does not have permission to run. Driftwood usually \ + fixes this automatically - try removing and re-adding the app.".to_string(); } if stderr.contains("fatal IO error") || stderr.contains("display connection") { - return "The app lost its connection to the display server. This can happen \ - with apps that don't fully support your display system.".to_string(); + return "This app could not connect to your display. If you are using \ + a remote session or container, this may not work.".to_string(); } if stderr.contains("FATAL:") || stderr.contains("Aborted") { - return "The app hit a fatal error and had to stop. The error details \ + return "This app encountered an error during startup. The error details \ below may help identify the cause.".to_string(); } if stderr.contains("Failed to initialize") { diff --git a/src/window.rs b/src/window.rs index 0a39412..ab25fe3 100644 --- a/src/window.rs +++ b/src/window.rs @@ -232,9 +232,10 @@ impl DriftwoodWindow { .build(); let drop_overlay_subtitle = gtk::Label::builder() - .label(&i18n("Drop a file here or click to browse")) + .label(&i18n("Drop an AppImage file (.AppImage) here, or click to browse your files")) .css_classes(["body", "dimmed"]) .halign(gtk::Align::Center) + .wrap(true) .build(); // The card itself - acts as a clickable button to open file picker @@ -1061,8 +1062,8 @@ impl DriftwoodWindow { &db, is_integrated, &fp_paths, - Some(Box::new(move || { - // Refresh the library view after uninstall + Some(Rc::new(move || { + // Refresh the library view after uninstall (or undo) if let Some(lib_view) = window_ref.imp().library_view.get() { if let Ok(records) = db_refresh.get_all_appimages() { lib_view.populate(records); @@ -1112,6 +1113,17 @@ impl DriftwoodWindow { } self.add_action(&show_updates_action); + // Command palette (Ctrl+K) + let palette_action = gio::SimpleAction::new("command-palette", None); + { + let window_weak = self.downgrade(); + palette_action.connect_activate(move |_, _| { + let Some(window) = window_weak.upgrade() else { return }; + window.show_command_palette(); + }); + } + self.add_action(&palette_action); + // Keyboard shortcuts if let Some(app) = self.application() { let gtk_app = app.downcast_ref::().unwrap(); @@ -1124,6 +1136,7 @@ impl DriftwoodWindow { gtk_app.set_accels_for_action("win.show-installed", &["1"]); gtk_app.set_accels_for_action("win.show-catalog", &["2"]); gtk_app.set_accels_for_action("win.show-updates", &["3"]); + gtk_app.set_accels_for_action("win.command-palette", &["k"]); } } @@ -1149,6 +1162,14 @@ impl DriftwoodWindow { // Scan on startup if enabled in preferences if self.settings().boolean("auto-scan-on-startup") { + if let Some(toast_overlay) = self.imp().toast_overlay.get() { + toast_overlay.add_toast( + adw::Toast::builder() + .title(&i18n("Scanning for apps in your configured folders...")) + .timeout(2) + .build(), + ); + } self.trigger_scan(); } @@ -1206,14 +1227,31 @@ impl DriftwoodWindow { }; if should_check { let settings_save = settings_upd.clone(); + let update_toast = self.imp().toast_overlay.get().cloned(); glib::spawn_future_local(async move { let result = gio::spawn_blocking(move || { let bg_db = Database::open().expect("DB open failed"); - update_dialog::batch_check_updates(&bg_db) + update_dialog::batch_check_updates_detailed(&bg_db) }) .await; - if let Ok(count) = result { + if let Ok((count, names)) = result { log::info!("Background update check: {} updates available", count); + if count > 0 { + if let Some(toast_overlay) = update_toast { + let title = if names.len() <= 3 { + format!("Updates available: {}", names.join(", ")) + } else { + format!("{} app updates available ({}, ...)", + count, names[..2].join(", ")) + }; + let toast = adw::Toast::builder() + .title(&title) + .button_label("View") + .action_name("win.show-updates") + .build(); + toast_overlay.add_toast(toast); + } + } } let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); settings_save.set_string("last-update-check", &now).ok(); @@ -1643,6 +1681,174 @@ impl DriftwoodWindow { } } + fn show_command_palette(&self) { + let db = self.database().clone(); + + let dialog = adw::Dialog::builder() + .title("Quick Launch") + .content_width(450) + .content_height(400) + .build(); + + let toolbar = adw::ToolbarView::new(); + let header = adw::HeaderBar::new(); + toolbar.add_top_bar(&header); + + let content_box = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .margin_start(12) + .margin_end(12) + .margin_top(8) + .margin_bottom(8) + .build(); + + let search_entry = gtk::SearchEntry::builder() + .placeholder_text(&i18n("Type to search installed and catalog apps...")) + .hexpand(true) + .build(); + content_box.append(&search_entry); + + let scrolled = gtk::ScrolledWindow::builder() + .vexpand(true) + .build(); + + let results_list = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::Single) + .css_classes(["boxed-list"]) + .build(); + scrolled.set_child(Some(&results_list)); + content_box.append(&scrolled); + + // Populate results based on search + let db_ref = db.clone(); + let results_ref = results_list.clone(); + let dialog_ref = dialog.clone(); + let window_weak = self.downgrade(); + + let update_results = std::rc::Rc::new(move |query: &str| { + // Clear existing + while let Some(child) = results_ref.first_child() { + results_ref.remove(&child); + } + + if query.is_empty() { + return; + } + + let q = query.to_lowercase(); + + // Search installed apps + let installed = db_ref.get_all_appimages().unwrap_or_default(); + let mut count = 0; + for record in &installed { + let name = record.app_name.as_deref().unwrap_or(&record.filename); + if name.to_lowercase().contains(&q) && count < 10 { + let row = adw::ActionRow::builder() + .title(name) + .subtitle(&i18n("Installed - click to launch")) + .activatable(true) + .build(); + let icon = widgets::app_icon( + record.icon_path.as_deref(), name, 32, + ); + row.add_prefix(&icon); + let play_icon = gtk::Image::from_icon_name("media-playback-start-symbolic"); + row.add_suffix(&play_icon); + + let record_id = record.id; + let dialog_c = dialog_ref.clone(); + let window_w = window_weak.clone(); + row.connect_activated(move |_| { + dialog_c.close(); + if let Some(win) = window_w.upgrade() { + gio::prelude::ActionGroupExt::activate_action( + &win, "launch-appimage", Some(&record_id.to_variant()), + ); + } + }); + results_ref.append(&row); + count += 1; + } + } + + // Search catalog apps + if let Ok(catalog_results) = db_ref.search_catalog( + query, None, 10, 0, + crate::core::database::CatalogSortOrder::PopularityDesc, + ) { + for app in &catalog_results { + if count >= 15 { break; } + let row = adw::ActionRow::builder() + .title(&app.name) + .subtitle(&i18n("Catalog - click to view")) + .activatable(true) + .build(); + let icon = widgets::app_icon(None, &app.name, 32); + row.add_prefix(&icon); + let nav_icon = gtk::Image::from_icon_name("go-next-symbolic"); + row.add_suffix(&nav_icon); + + let app_id = app.id; + let dialog_c = dialog_ref.clone(); + let window_w = window_weak.clone(); + let db_c = db_ref.clone(); + row.connect_activated(move |_| { + dialog_c.close(); + if let Some(win) = window_w.upgrade() { + // Switch to catalog tab + if let Some(vs) = win.imp().view_stack.get() { + vs.set_visible_child_name("catalog"); + } + // Navigate to the app detail + if let Ok(Some(catalog_app)) = db_c.get_catalog_app(app_id) { + if let Some(toast) = win.imp().toast_overlay.get() { + let detail = crate::ui::catalog_detail::build_catalog_detail_page( + &catalog_app, &db_c, toast, + ); + // Push onto the catalog NavigationView + // The catalog page is a NavigationView inside the ViewStack + if let Some(vs) = win.imp().view_stack.get() { + if let Some(child) = vs.child_by_name("catalog") { + if let Ok(nav) = child.downcast::() { + nav.push(&detail); + } + } + } + } + } + } + }); + results_ref.append(&row); + count += 1; + } + } + + if count == 0 { + let row = adw::ActionRow::builder() + .title(&i18n("No results found")) + .sensitive(false) + .build(); + results_ref.append(&row); + } + }); + + { + let update_fn = update_results.clone(); + search_entry.connect_search_changed(move |entry| { + let query = entry.text().to_string(); + update_fn(&query); + }); + } + + toolbar.set_content(Some(&content_box)); + dialog.set_child(Some(&toolbar)); + dialog.present(Some(self)); + + // Focus the search entry after presenting + search_entry.grab_focus(); + } + fn show_shortcuts_dialog(&self) { let dialog = adw::Dialog::builder() .title("Keyboard Shortcuts") @@ -1674,6 +1880,7 @@ impl DriftwoodWindow { nav_group.add(&shortcut_row("Ctrl+1", "Installed")); nav_group.add(&shortcut_row("Ctrl+2", "Catalog")); nav_group.add(&shortcut_row("Ctrl+3", "Updates")); + nav_group.add(&shortcut_row("Ctrl+K", "Quick Launch")); nav_group.add(&shortcut_row("Ctrl+F", "Search")); nav_group.add(&shortcut_row("Ctrl+D", "Dashboard")); nav_group.add(&shortcut_row("Ctrl+,", "Preferences"));