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

- Replace featured section Stack with AdwCarousel + indicator dots
- Convert category grid to horizontal scrollable filter chips
- Add grid/list view toggle for catalog with compact row layout
- Add quick launch button on library list rows
- Add stale catalog banner when data is older than 7 days
- Add command palette (Ctrl+K) for quick app search and launch
- Show specific app names in update notifications
- Add per-app auto-update toggle (skip updates switch)
- Add keyboard shortcut hints to button tooltips
- Add source trust badges (AppImageHub/Community) on catalog tiles
- Add undo-based uninstall with toast and record restoration
- Add type-to-search in library view
- Use human-readable catalog source labels
- Show Launch button for installed apps in catalog detail
- Replace external browser link with inline AppImage explainer dialog
This commit is contained in:
lashman
2026-03-01 00:39:43 +02:00
parent 4b939f044a
commit d11546efc6
25 changed files with 1711 additions and 481 deletions

View File

@@ -36,7 +36,7 @@
<choice value='recently-added'/> <choice value='recently-added'/>
<choice value='size'/> <choice value='size'/>
</choices> </choices>
<default>'name'</default> <default>'recently-added'</default>
<summary>Library sort mode</summary> <summary>Library sort mode</summary>
<description>How to sort the library: name, recently-added, or size.</description> <description>How to sort the library: name, recently-added, or size.</description>
</key> </key>

View File

@@ -364,3 +364,28 @@ window.lightbox .lightbox-nav {
.stat-card image { .stat-card image {
opacity: 0.55; 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; }
}

View File

@@ -13,7 +13,6 @@ pub fn data_dir_fallback() -> PathBuf {
} }
/// Return the XDG config directory with a proper $HOME-based fallback. /// Return the XDG config directory with a proper $HOME-based fallback.
#[allow(dead_code)]
pub fn config_dir_fallback() -> PathBuf { pub fn config_dir_fallback() -> PathBuf {
dirs::config_dir().unwrap_or_else(|| home_dir().join(".config")) dirs::config_dir().unwrap_or_else(|| home_dir().join(".config"))
} }

View File

@@ -35,6 +35,16 @@ impl CatalogType {
} }
} }
/// Human-readable label for display in the UI.
pub fn label(&self) -> &str {
match self {
Self::AppImageHub => "Community Feed",
Self::OcsAppImageHub => "AppImageHub Catalog",
Self::GitHubSearch => "GitHub Search",
Self::Custom => "Custom Source",
}
}
pub fn from_str(s: &str) -> Self { pub fn from_str(s: &str) -> Self {
match s { match s {
"appimage-hub" => Self::AppImageHub, "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. /// An app entry from a catalog source.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CatalogApp { pub struct CatalogApp {
@@ -212,6 +228,7 @@ pub enum SyncProgress {
AllDone, AllDone,
} }
#[allow(dead_code)]
pub fn sync_catalog(db: &Database, source: &CatalogSource) -> Result<u32, CatalogError> { pub fn sync_catalog(db: &Database, source: &CatalogSource) -> Result<u32, CatalogError> {
sync_catalog_with_progress(db, source, &|_| {}) 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)?; 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) // Build set of OCS app names for dedup (skip apps already in OCS source)
let ocs_names = get_ocs_source_names(db); 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. /// Download an AppImage from the catalog to a local directory.
/// If `ocs_id` is provided, resolves a fresh download URL from the OCS API /// If `ocs_id` is provided, resolves a fresh download URL from the OCS API
/// (since OCS download links use JWT tokens that expire). /// (since OCS download links use JWT tokens that expire).
#[allow(dead_code)]
pub fn install_from_catalog(app: &CatalogApp, install_dir: &Path) -> Result<PathBuf, CatalogError> { pub fn install_from_catalog(app: &CatalogApp, install_dir: &Path) -> Result<PathBuf, CatalogError> {
install_from_catalog_with_ocs(app, install_dir, None, 1) install_from_catalog_with_ocs(app, install_dir, None, 1)
} }
@@ -876,14 +900,14 @@ fn fetch_custom_catalog(url: &str) -> Result<Vec<CatalogApp>, CatalogError> {
pub fn ensure_default_sources(db: &Database) { pub fn ensure_default_sources(db: &Database) {
// Primary: OCS AppImageHub.com (insert first so it syncs first) // Primary: OCS AppImageHub.com (insert first so it syncs first)
db.upsert_catalog_source( db.upsert_catalog_source(
"AppImageHub.com", "AppImageHub Catalog",
OCS_API_URL, OCS_API_URL,
"ocs-appimagehub", "ocs-appimagehub",
).ok(); ).ok();
// Secondary: appimage.github.io feed // Secondary: appimage.github.io feed
db.upsert_catalog_source( db.upsert_catalog_source(
"AppImageHub", "Community Feed",
APPIMAGEHUB_API_URL, APPIMAGEHUB_API_URL,
"appimage-hub", "appimage-hub",
).ok(); ).ok();
@@ -1005,6 +1029,7 @@ pub fn sanitize_filename(name: &str) -> String {
/// Download icons for all catalog apps that have icon_url set. /// Download icons for all catalog apps that have icon_url set.
/// Saves to ~/.cache/driftwood/icons/{sanitized_name}.png /// Saves to ~/.cache/driftwood/icons/{sanitized_name}.png
#[allow(dead_code)]
fn cache_catalog_icons(apps: &[CatalogApp]) -> u32 { fn cache_catalog_icons(apps: &[CatalogApp]) -> u32 {
cache_catalog_icons_with_progress(apps, &|_| {}) cache_catalog_icons_with_progress(apps, &|_| {})
} }

View File

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

View File

@@ -19,7 +19,7 @@ impl std::fmt::Display for InspectorError {
match self { match self {
Self::IoError(e) => write!(f, "I/O error: {}", e), Self::IoError(e) => write!(f, "I/O error: {}", e),
Self::NoOffset => write!(f, "Could not determine squashfs offset"), 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::UnsquashfsFailed(msg) => write!(f, "unsquashfs failed: {}", msg),
Self::NoDesktopEntry => write!(f, "No .desktop file found in AppImage"), Self::NoDesktopEntry => write!(f, "No .desktop file found in AppImage"),
} }

View File

@@ -113,7 +113,26 @@ pub fn launch_appimage(
method 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 // Record the launch event regardless of success
if let Err(e) = db.record_launch(record_id, source) { 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. /// Execute the AppImage process with the given method.
@@ -150,6 +169,7 @@ fn execute_appimage(
method: &LaunchMethod, method: &LaunchMethod,
args: &[String], args: &[String],
extra_env: &[(&str, &str)], extra_env: &[(&str, &str)],
sandbox_profile: Option<&Path>,
) -> LaunchResult { ) -> LaunchResult {
let mut cmd = match method { let mut cmd = match method {
LaunchMethod::Direct => { LaunchMethod::Direct => {
@@ -165,6 +185,9 @@ fn execute_appimage(
} }
LaunchMethod::Sandboxed => { LaunchMethod::Sandboxed => {
let mut c = Command::new("firejail"); let mut c = Command::new("firejail");
if let Some(profile) = sandbox_profile {
c.arg(format!("--profile={}", profile.display()));
}
c.arg("--appimage"); c.arg("--appimage");
c.arg(appimage_path); c.arg(appimage_path);
c.args(args); c.args(args);

View File

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

View File

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

View File

@@ -24,23 +24,30 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
); );
icon_widget.add_css_class("icon-dropshadow"); icon_widget.add_css_class("icon-dropshadow");
if record.integrated { let icon_overlay = gtk::Overlay::new();
let overlay = gtk::Overlay::new(); icon_overlay.set_child(Some(&icon_widget));
overlay.set_child(Some(&icon_widget));
if record.integrated {
let emblem = gtk::Image::from_icon_name("emblem-ok-symbolic"); let emblem = gtk::Image::from_icon_name("emblem-ok-symbolic");
emblem.set_pixel_size(16); emblem.set_pixel_size(16);
emblem.add_css_class("integration-emblem"); emblem.add_css_class("integration-emblem");
emblem.set_halign(gtk::Align::End); emblem.set_halign(gtk::Align::End);
emblem.set_valign(gtk::Align::End); emblem.set_valign(gtk::Align::End);
emblem.update_property(&[AccessibleProperty::Label("Integrated into desktop menu")]); emblem.update_property(&[AccessibleProperty::Label("Integrated into desktop menu")]);
overlay.add_overlay(&emblem); icon_overlay.add_overlay(&emblem);
card.append(&overlay);
} else {
card.append(&icon_widget);
} }
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 // App name
let name_label = gtk::Label::builder() let name_label = gtk::Label::builder()
.label(name) .label(name)
@@ -142,7 +149,12 @@ pub fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {
return Some(widgets::status_badge("Portable", "info")); 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. /// Build a descriptive accessible label for screen readers.

View File

@@ -6,6 +6,7 @@ use gtk::gio;
use crate::core::catalog; use crate::core::catalog;
use crate::core::database::{CatalogApp, Database}; use crate::core::database::{CatalogApp, Database};
use crate::core::fuse;
use crate::core::github_enrichment; use crate::core::github_enrichment;
use crate::core::github_enrichment::AppImageAsset; use crate::core::github_enrichment::AppImageAsset;
use crate::i18n::i18n; 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())); && (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(); let awaiting_github = needs_enrichment && app.github_download_url.is_none();
// Check if already installed // Check if already installed (map name -> record id for launching)
let installed_names: std::collections::HashSet<String> = db let installed_map: std::collections::HashMap<String, i64> = db
.get_all_appimages() .get_all_appimages()
.unwrap_or_default() .unwrap_or_default()
.iter() .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(); .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); 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; let awaiting_ocs = has_ocs && !is_installed;
if 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"); let installed_badge = widgets::status_badge(&i18n("Installed"), "success");
installed_badge.set_valign(gtk::Align::Center); installed_badge.set_valign(gtk::Align::Center);
button_box.append(&installed_badge); button_box.append(&installed_badge);
@@ -166,6 +179,37 @@ pub fn build_catalog_detail_page(
} }
info_box.append(&button_box); 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); header_box.append(&info_box);
content.append(&header_box); content.append(&header_box);
@@ -1243,9 +1287,17 @@ fn format_ocs_file_label(file: &catalog::OcsDownloadFile) -> String {
if !file.version.is_empty() { if !file.version.is_empty() {
parts.push(format!("v{}", file.version)); parts.push(format!("v{}", file.version));
} }
if let Some(ref arch) = file.arch {
parts.push(arch.clone());
}
if !file.filename.is_empty() { if !file.filename.is_empty() {
parts.push(file.filename.clone()); 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 let Some(size_kb) = file.size_kb {
if size_kb > 0 { if size_kb > 0 {
parts.push(format!("({})", widgets::format_size(size_kb * 1024))); parts.push(format!("({})", widgets::format_size(size_kb * 1024)));

View File

@@ -6,7 +6,8 @@ use super::widgets;
/// Build a catalog tile for the browse grid. /// Build a catalog tile for the browse grid.
/// Left-aligned layout: icon (48px) at top, name, description, category badge. /// Left-aligned layout: icon (48px) at top, name, description, category badge.
/// Card fills its entire FlowBoxChild cell. /// 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() let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical) .orientation(gtk::Orientation::Vertical)
.spacing(6) .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)); desc_label.set_height_request(desc_label.preferred_size().1.height().max(36));
inner.append(&desc_label); 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_stars = app.github_stars.is_some_and(|s| s > 0);
let has_version = app.latest_version.is_some(); 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() let stats_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal) .orientation(gtk::Orientation::Horizontal)
.spacing(12) .spacing(12)
@@ -86,6 +88,22 @@ pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild {
.build(); .build();
stats_row.add_css_class("catalog-stats-row"); 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) { if let Some(stars) = app.github_stars.filter(|&s| s > 0) {
let star_box = gtk::Box::builder() let star_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal) .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); card.append(&inner);
let child = gtk::FlowBoxChild::builder() let child = gtk::FlowBoxChild::builder()
@@ -146,6 +179,92 @@ pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild {
child 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. /// Build a featured banner card for the carousel.
/// Layout: screenshot preview on top, then icon + name + description + badge below. /// Layout: screenshot preview on top, then icon + name + description + badge below.
/// Width is set dynamically by the carousel layout. /// 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); text_box.append(&desc_label);
} }
// Badge row: category + stars // Badge row: category + downloads/stars
let badge_row = gtk::Box::builder() let badge_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal) .orientation(gtk::Orientation::Horizontal)
.spacing(6) .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( let star_badge = widgets::status_badge_with_icon(
"starred-symbolic", "starred-symbolic",
&widgets::format_count(stars), &widgets::format_count(stars),

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,54 @@ pub fn build_dashboard_page(db: &Rc<Database>) -> adw::NavigationPage {
content.append(&banner); 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 // Section 1: System Status
content.append(&build_system_status_group()); content.append(&build_system_status_group());
@@ -89,6 +137,7 @@ fn build_system_status_group() -> adw::PreferencesGroup {
let session_row = adw::ActionRow::builder() let session_row = adw::ActionRow::builder()
.title("Display server") .title("Display server")
.subtitle(session.label()) .subtitle(session.label())
.tooltip_text("How your system draws windows on screen")
.build(); .build();
let session_badge = widgets::status_badge( let session_badge = widgets::status_badge(
session.label(), session.label(),
@@ -107,15 +156,16 @@ fn build_system_status_group() -> adw::PreferencesGroup {
let de_row = adw::ActionRow::builder() let de_row = adw::ActionRow::builder()
.title("Desktop environment") .title("Desktop environment")
.subtitle(&de) .subtitle(&de)
.tooltip_text("Your desktop interface")
.build(); .build();
group.add(&de_row); group.add(&de_row);
// FUSE status // FUSE status
let fuse_info = fuse::detect_system_fuse(); let fuse_info = fuse::detect_system_fuse();
let fuse_row = adw::ActionRow::builder() let fuse_row = adw::ActionRow::builder()
.title("FUSE") .title("App compatibility")
.subtitle(&fuse_description(&fuse_info)) .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(); .build();
let fuse_badge = widgets::status_badge( let fuse_badge = widgets::status_badge(
fuse_info.status.label(), fuse_info.status.label(),
@@ -128,7 +178,7 @@ fn build_system_status_group() -> adw::PreferencesGroup {
// Install hint if FUSE not functional // Install hint if FUSE not functional
if let Some(ref hint) = fuse_info.install_hint { if let Some(ref hint) = fuse_info.install_hint {
let hint_row = adw::ActionRow::builder() let hint_row = adw::ActionRow::builder()
.title("Fix FUSE") .title("Fix app compatibility")
.subtitle(hint) .subtitle(hint)
.subtitle_selectable(true) .subtitle_selectable(true)
.build(); .build();
@@ -141,7 +191,7 @@ fn build_system_status_group() -> adw::PreferencesGroup {
let xwayland_row = adw::ActionRow::builder() let xwayland_row = adw::ActionRow::builder()
.title("XWayland") .title("XWayland")
.subtitle(if has_xwayland { "Running" } else { "Not detected" }) .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(); .build();
let xwayland_badge = widgets::status_badge( let xwayland_badge = widgets::status_badge(
if has_xwayland { "Available" } else { "Unavailable" }, if has_xwayland { "Available" } else { "Unavailable" },

View File

@@ -12,6 +12,7 @@ use crate::core::fuse::{self, FuseStatus};
use crate::core::integrator; use crate::core::integrator;
use crate::core::launcher::{self, SandboxMode}; use crate::core::launcher::{self, SandboxMode};
use crate::core::notification; use crate::core::notification;
use crate::core::sandbox;
use crate::core::security; use crate::core::security;
use crate::core::updater; use crate::core::updater;
use crate::core::wayland::{self, WaylandStatus}; 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); text_col.append(&badge_box);
banner.append(&text_col); banner.append(&text_col);
banner 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, // Tab 1: Overview - about, description, links, updates, releases, usage,
// capabilities, file info // capabilities, file info
@@ -382,6 +413,35 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
about_group.add(&row); 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); inner.append(&about_group);
} }
@@ -620,8 +680,9 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title("Update method") .title("Update method")
.subtitle( .subtitle(
"This app does not include update information. \ "This app does not include automatic update information. \
You will need to check for new versions manually." To update manually, download a newer version from the \
developer's website and drag it into Driftwood."
) )
.tooltip_text( .tooltip_text(
"AppImages can include built-in update information that tells Driftwood \ "AppImages can include built-in update information that tells Driftwood \
@@ -675,6 +736,22 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.build(); .build();
updates_group.add(&row); 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); inner.append(&updates_group);
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -837,8 +914,8 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.build(); .build();
let type_str = match record.appimage_type { let type_str = match record.appimage_type {
Some(1) => "Type 1 - older format, still widely supported", Some(1) => "Legacy format - an older packaging style. Still works but less common.",
Some(2) => "Type 2 - modern, compressed format", Some(2) => "Modern format - compressed and efficient. This is the standard format for most AppImages.",
_ => "Unknown type", _ => "Unknown type",
}; };
let type_row = adw::ActionRow::builder() let type_row = adw::ActionRow::builder()
@@ -870,19 +947,19 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let sig_row = adw::ActionRow::builder() let sig_row = adw::ActionRow::builder()
.title("Verified by developer") .title("Verified by developer")
.subtitle(if record.has_signature { .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 { } else {
"Not signed" "This is normal for most AppImages and does not mean the app is unsafe."
}) })
.tooltip_text( .tooltip_text(
"This app was signed by its developer, which helps verify \ "Some developers sign their apps with a cryptographic key. \
it hasn't been tampered with since it was published." This helps verify the file hasn't been modified since it was published."
) )
.build(); .build();
let sig_badge = if record.has_signature { let sig_badge = if record.has_signature {
widgets::status_badge("Signed", "success") widgets::status_badge("Signed", "success")
} else { } else {
widgets::status_badge("Unsigned", "neutral") widgets::status_badge("No signature", "neutral")
}; };
sig_badge.set_valign(gtk::Align::Center); sig_badge.set_valign(gtk::Align::Center);
sig_row.add_suffix(&sig_badge); sig_row.add_suffix(&sig_badge);
@@ -1040,7 +1117,7 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
// Autostart toggle // Autostart toggle
let autostart_row = adw::SwitchRow::builder() let autostart_row = adw::SwitchRow::builder()
.title("Start at login") .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) .active(record.autostart)
.tooltip_text( .tooltip_text(
"Creates an autostart entry so this app launches \ "Creates an autostart entry so this app launches \
@@ -1070,9 +1147,9 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
}); });
integration_group.add(&autostart_row); 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() let wm_class_row = adw::EntryRow::builder()
.title("Window class (advanced)") .title("Window class")
.text(record.startup_wm_class.as_deref().unwrap_or("")) .text(record.startup_wm_class.as_deref().unwrap_or(""))
.show_apply_button(true) .show_apply_button(true)
.build(); .build();
@@ -1090,7 +1167,12 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, 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 // System-wide install toggle
if record.integrated { if record.integrated {
@@ -1409,11 +1491,11 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
let fuse_status = fuse_system.status.clone(); let fuse_status = fuse_system.status.clone();
let fuse_row = adw::ActionRow::builder() let fuse_row = adw::ActionRow::builder()
.title("App mounting") .title("App startup system")
.subtitle(fuse_user_explanation(&fuse_status)) .subtitle(fuse_user_explanation(&fuse_status))
.tooltip_text( .tooltip_text(
"FUSE lets apps like AppImages run directly without unpacking first. \ "Most AppImages need a system component called FUSE to mount and run. \
Without it, apps still work but take a little longer to start." This shows whether it is available."
) )
.build(); .build();
let fuse_badge = widgets::status_badge_with_icon( let fuse_badge = widgets::status_badge_with_icon(
@@ -1434,12 +1516,14 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
// Per-app launch method // Per-app launch method
let appimage_path = std::path::Path::new(&record.path); let appimage_path = std::path::Path::new(&record.path);
let app_fuse_status = fuse::determine_app_fuse_status(&fuse_system, appimage_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() let launch_method_row = adw::ActionRow::builder()
.title("Startup method") .title("Startup method")
.subtitle(&format!( .subtitle(&launch_method_subtitle)
"This app will launch using: {}",
app_fuse_status.label()
))
.tooltip_text( .tooltip_text(
"AppImages can start two ways: mounting (fast, instant startup) or \ "AppImages can start two ways: mounting (fast, instant startup) or \
unpacking to a temporary folder first (slower, but works everywhere). \ unpacking to a temporary folder first (slower, but works everywhere). \
@@ -1457,10 +1541,10 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
// Sandboxing group // Sandboxing group
let sandbox_group = adw::PreferencesGroup::builder() let sandbox_group = adw::PreferencesGroup::builder()
.title("App Isolation") .title("Security Restrictions")
.description( .description(
"Restrict what this app can access on your system \ "Control what this app can access on your system. \
for extra security." When enabled, the app can only reach your Documents and Downloads folders."
) )
.build(); .build();
@@ -1490,8 +1574,51 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
) )
.build(); .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 record_id = record.id;
let db_ref = db.clone(); 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| { firejail_row.connect_active_notify(move |row| {
let mode = if row.is_active() { let mode = if row.is_active() {
SandboxMode::Firejail SandboxMode::Firejail
@@ -1501,9 +1628,72 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
if let Err(e) = db_ref.update_sandbox_mode(record_id, Some(mode.as_str())) { if let Err(e) = db_ref.update_sandbox_mode(record_id, Some(mode.as_str())) {
log::warn!("Failed to update sandbox mode: {}", e); 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); 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 { if !firejail_available {
let firejail_cmd = "sudo apt install firejail"; let firejail_cmd = "sudo apt install firejail";
let info_row = adw::ActionRow::builder() let info_row = adw::ActionRow::builder()
@@ -1892,18 +2082,19 @@ fn build_storage_tab(
} }
if !fp.paths.is_empty() { if !fp.paths.is_empty() {
let categories = [ let categories: &[(&str, u64, &str)] = &[
("Configuration", fp.config_size), ("Configuration", fp.config_size, "Your preferences and settings for this app"),
("Application data", fp.data_size), ("Application data", fp.data_size, "Files and data saved by this app"),
("Cache", fp.cache_size), ("Cache", fp.cache_size, "Temporary files that can be safely deleted"),
("State", fp.state_size), ("State", fp.state_size, "Runtime state like window positions and recent files"),
("Other", fp.other_size), ("Other", fp.other_size, "Other files associated with this app"),
]; ];
for (label, size) in &categories { for (label, size, tooltip) in categories {
if *size > 0 { if *size > 0 {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(*label) .title(*label)
.subtitle(&widgets::format_size(*size as i64)) .subtitle(&widgets::format_size(*size as i64))
.tooltip_text(*tooltip)
.build(); .build();
size_group.add(&row); size_group.add(&row);
} }
@@ -2001,11 +2192,40 @@ fn build_storage_tab(
.valign(gtk::Align::Center) .valign(gtk::Align::Center)
.build(); .build();
row.add_suffix(&size_label); 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); paths_group.add(&row);
} }
} }
inner.append(&paths_group); 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 // Backups group
inner.append(&build_backup_group(record.id, toast_overlay)); 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 { fn fuse_user_explanation(status: &FuseStatus) -> &'static str {
match status { match status {
FuseStatus::FullyFunctional => FuseStatus::FullyFunctional =>
"Everything is set up - apps start instantly.", "This app can start normally using your system's app loader.",
FuseStatus::Fuse3Only => FuseStatus::Fuse3Only =>
"A small system component is missing. Most apps will still work, \ "A system component is needed for this app to start normally. \
but some may need it. Copy the install command to fix this.", Without it, the app uses a slower startup method.",
FuseStatus::NoFusermount => FuseStatus::NoFusermount =>
"A system component is missing, so apps will take a little longer \ "A system component is needed for this app to start normally. \
to start. They'll still work fine.", Without it, the app uses a slower startup method.",
FuseStatus::NoDevFuse => FuseStatus::NoDevFuse =>
"Your system doesn't support instant app mounting. Apps will unpack \ "A system component is needed for this app to start normally. \
before starting, which takes a bit longer.", Without it, the app uses a slower startup method.",
FuseStatus::MissingLibfuse2 => FuseStatus::MissingLibfuse2 =>
"A small system component is needed for fast startup. \ "A system component is needed for this app to start normally. \
Copy the install command to fix this.", Without it, the app uses a slower startup method.",
} }
} }
@@ -2624,9 +2844,9 @@ pub fn show_uninstall_dialog_with_callback(
db: &Rc<Database>, db: &Rc<Database>,
is_integrated: bool, is_integrated: bool,
data_paths: &[(String, String, u64)], data_paths: &[(String, String, u64)],
on_complete: Option<Box<dyn FnOnce() + 'static>>, on_complete: Option<Rc<dyn Fn() + 'static>>,
) { ) {
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() let dialog = adw::AlertDialog::builder()
.heading(&format!("Uninstall {}?", name)) .heading(&format!("Uninstall {}?", name))
.body("Select what to remove:") .body("Select what to remove:")
@@ -2675,46 +2895,34 @@ pub fn show_uninstall_dialog_with_callback(
dialog.set_extra_child(Some(&extra)); dialog.set_extra_child(Some(&extra));
let record_snapshot = record.clone();
let record_id = record.id; let record_id = record.id;
let record_path = record.path.clone(); let record_path = record.path.clone();
let db_ref = db.clone(); let db_ref = db.clone();
let toast_ref = toast_overlay.clone(); let toast_ref = toast_overlay.clone();
let on_complete = std::cell::Cell::new(on_complete);
dialog.connect_response(Some("uninstall"), move |_dlg, _response| { dialog.connect_response(Some("uninstall"), move |_dlg, _response| {
// Remove integration if checked // Capture deletion choices from checkboxes before dialog closes
if let Some(ref check) = integration_check { let delete_file = appimage_check.is_active();
if check.is_active() { let should_remove_integration = integration_check.as_ref().map_or(false, |c| c.is_active());
integrator::undo_all_modifications(&db_ref, record_id).ok(); let paths_to_delete: Vec<String> = path_checks.iter()
if let Ok(Some(rec)) = db_ref.get_appimage_by_id(record_id) { .filter(|(check, _)| check.is_active())
integrator::remove_integration(&rec).ok(); .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 // Remove from database (so item vanishes from lists)
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
db_ref.remove_appimage(record_id).ok(); db_ref.remove_appimage(record_id).ok();
toast_ref.add_toast(adw::Toast::new("AppImage uninstalled")); // Run the completion callback to refresh the library view
if let Some(ref cb) = on_complete {
// Run the completion callback if provided
if let Some(cb) = on_complete.take() {
cb(); cb();
} }
@@ -2723,6 +2931,66 @@ pub fn show_uninstall_dialog_with_callback(
let nav: adw::NavigationView = nav.downcast().unwrap(); let nav: adw::NavigationView = nav.downcast().unwrap();
nav.pop(); 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)); dialog.present(Some(toast_overlay));

View File

@@ -50,7 +50,13 @@ pub fn show_drop_dialog(
}; };
let body = if count == 1 { 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 { } else {
files files
.iter() .iter()
@@ -87,9 +93,9 @@ pub fn show_drop_dialog(
} }
dialog.add_response("cancel", &i18n("Cancel")); dialog.add_response("cancel", &i18n("Cancel"));
dialog.add_response("keep-in-place", &i18n("Keep in place")); dialog.add_response("keep-in-place", &i18n("Run Portable"));
dialog.add_response("copy-only", &i18n("Copy to Applications")); dialog.add_response("copy-only", &i18n("Copy to Apps"));
dialog.add_response("copy-and-integrate", &i18n("Copy & add to menu")); dialog.add_response("copy-and-integrate", &i18n("Copy & Add to Launcher"));
dialog.set_response_appearance("copy-and-integrate", adw::ResponseAppearance::Suggested); dialog.set_response_appearance("copy-and-integrate", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("copy-and-integrate")); dialog.set_default_response(Some("copy-and-integrate"));

View File

@@ -52,7 +52,7 @@ pub fn show_fuse_wizard(parent: &impl IsA<gtk::Widget>) {
.build(); .build();
let title = gtk::Label::builder() let title = gtk::Label::builder()
.label(&i18n("FUSE is required")) .label(&i18n("Additional setup needed"))
.xalign(0.0) .xalign(0.0)
.build(); .build();
title.add_css_class("title-3"); title.add_css_class("title-3");
@@ -60,8 +60,9 @@ pub fn show_fuse_wizard(parent: &impl IsA<gtk::Widget>) {
let explanation = gtk::Label::builder() let explanation = gtk::Label::builder()
.label(&i18n( .label(&i18n(
"Most AppImages require libfuse2 to mount and run. \ "Most apps need a small system component called FUSE to start quickly. \
Without it, apps will use a slower extract-and-run fallback or may not launch at all.", 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) .wrap(true)
.xalign(0.0) .xalign(0.0)
@@ -101,18 +102,34 @@ pub fn show_fuse_wizard(parent: &impl IsA<gtk::Widget>) {
.margin_top(12) .margin_top(12)
.build(); .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() let install_btn = gtk::Button::builder()
.label(&i18n("Install via pkexec")) .label(&i18n("Install via pkexec"))
.build(); .build();
install_btn.add_css_class("suggested-action"); install_btn.add_css_class("suggested-action");
install_btn.add_css_class("pill"); install_btn.add_css_class("pill");
button_box.append(&skip_btn);
button_box.append(&install_btn); button_box.append(&install_btn);
content.append(&button_box); content.append(&button_box);
toolbar.set_content(Some(&content)); toolbar.set_content(Some(&content));
dialog.set_child(Some(&toolbar)); 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 cmd = install_cmd.clone();
let status_ref = status_label.clone(); let status_ref = status_label.clone();
let btn_ref = install_btn.clone(); let btn_ref = install_btn.clone();

View File

@@ -89,7 +89,7 @@ impl LibraryView {
let search_button = gtk::ToggleButton::builder() let search_button = gtk::ToggleButton::builder()
.icon_name("system-search-symbolic") .icon_name("system-search-symbolic")
.tooltip_text(&i18n("Search")) .tooltip_text(&i18n("Search (Ctrl+F)"))
.build(); .build();
search_button.add_css_class("flat"); search_button.add_css_class("flat");
search_button.update_property(&[AccessibleProperty::Label("Toggle search")]); search_button.update_property(&[AccessibleProperty::Label("Toggle search")]);
@@ -124,7 +124,7 @@ impl LibraryView {
// Scan button // Scan button
let scan_button = gtk::Button::builder() let scan_button = gtk::Button::builder()
.icon_name("view-refresh-symbolic") .icon_name("view-refresh-symbolic")
.tooltip_text(&i18n("Scan for AppImages")) .tooltip_text(&i18n("Scan for AppImages (Ctrl+R)"))
.build(); .build();
scan_button.add_css_class("flat"); scan_button.add_css_class("flat");
scan_button.set_action_name(Some("win.scan")); 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.set_action_name(Some("win.catalog"));
browse_catalog_btn.update_property(&[AccessibleProperty::Label("Browse app 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(&scan_now_btn);
empty_button_box.append(&browse_catalog_btn); empty_button_box.append(&browse_catalog_btn);
empty_button_box.append(&learn_btn);
let empty_page = adw::StatusPage::builder() let empty_page = adw::StatusPage::builder()
.icon_name("application-x-executable-symbolic") .icon_name("application-x-executable-symbolic")
.title(&i18n("No AppImages Yet")) .title(&i18n("No AppImages Yet"))
.description(&i18n( .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) .child(&empty_button_box)
.build(); .build();
@@ -361,6 +391,9 @@ impl LibraryView {
toolbar_view.set_content(Some(&content_box)); toolbar_view.set_content(Some(&content_box));
widgets::apply_pointer_cursors(&toolbar_view); 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() let page = adw::NavigationPage::builder()
.title("Driftwood") .title("Driftwood")
.tag("library") .tag("library")
@@ -670,6 +703,18 @@ impl LibraryView {
icon.add_css_class("icon-rounded"); icon.add_css_class("icon-rounded");
row.add_prefix(&icon); 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) // Single most important badge as suffix (same priority as cards)
if let Some(badge) = app_card::build_priority_badge(record) { if let Some(badge) = app_card::build_priority_badge(record) {
badge.set_valign(gtk::Align::Center); badge.set_valign(gtk::Align::Center);
@@ -792,7 +837,7 @@ fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu {
// Section 1: Launch // Section 1: Launch
let section1 = gtk::gio::Menu::new(); 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, &section1); menu.append_section(None, &section1);
// Section 2: Actions // Section 2: Actions
@@ -803,7 +848,7 @@ fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu {
// Section 3: Integration + folder // Section 3: Integration + folder
let section3 = gtk::gio::Menu::new(); 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(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))); section3.append(Some("Show in file manager"), Some(&format!("win.open-folder(int64 {})", record.id)));
menu.append_section(None, &section3); menu.append_section(None, &section3);

View File

@@ -37,10 +37,10 @@ pub fn show_permission_dialog(
extra.append(&access_label); extra.append(&access_label);
let items = [ let items = [
"Your home directory and files", "Your files and folders",
"Network and internet access", "Internet and network access",
"Display server (Wayland/X11)", "Your screen and windows",
"System D-Bus and services", "Background system services",
]; ];
for item in &items { for item in &items {
let label = gtk::Label::builder() let label = gtk::Label::builder()
@@ -50,15 +50,29 @@ pub fn show_permission_dialog(
extra.append(&label); 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 // Show firejail option if available
if launcher::has_firejail() { if launcher::has_firejail() {
let sandbox_note = gtk::Label::builder() let sandbox_note = gtk::Label::builder()
.label(&i18n( .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) .wrap(true)
.xalign(0.0) .xalign(0.0)
.margin_top(8) .margin_top(4)
.build(); .build();
sandbox_note.add_css_class("dim-label"); sandbox_note.add_css_class("dim-label");
extra.append(&sandbox_note); extra.append(&sandbox_note);

View File

@@ -81,7 +81,7 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
// Scan Locations group // Scan Locations group
let scan_group = adw::PreferencesGroup::builder() let scan_group = adw::PreferencesGroup::builder()
.title(&i18n("Scan Locations")) .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(); .build();
let dirs = settings.strv("scan-directories"); let dirs = settings.strv("scan-directories");
@@ -157,6 +157,7 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
// Automation group // Automation group
let automation_group = adw::PreferencesGroup::builder() let automation_group = adw::PreferencesGroup::builder()
.title(&i18n("Automation")) .title(&i18n("Automation"))
.description(&i18n("Control what happens automatically when Driftwood starts or finds new apps."))
.build(); .build();
let auto_scan_row = adw::SwitchRow::builder() let auto_scan_row = adw::SwitchRow::builder()
@@ -260,6 +261,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Update Checking group // Update Checking group
let checking_group = adw::PreferencesGroup::builder() let checking_group = adw::PreferencesGroup::builder()
.title(&i18n("Update Checking")) .title(&i18n("Update Checking"))
.description(&i18n("Let Driftwood periodically check if newer versions of your apps are available."))
.build(); .build();
let auto_update_row = adw::SwitchRow::builder() let auto_update_row = adw::SwitchRow::builder()
@@ -297,6 +299,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Update Behavior group // Update Behavior group
let behavior_group = adw::PreferencesGroup::builder() let behavior_group = adw::PreferencesGroup::builder()
.title(&i18n("Update Behavior")) .title(&i18n("Update Behavior"))
.description(&i18n("Control what happens when an app is updated to a newer version."))
.build(); .build();
let cleanup_row = adw::ComboRow::builder() let cleanup_row = adw::ComboRow::builder()
@@ -359,7 +362,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Security Scanning group // Security Scanning group
let security_group = adw::PreferencesGroup::builder() let security_group = adw::PreferencesGroup::builder()
.title(&i18n("Security Scanning")) .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(); .build();
let auto_security_row = adw::SwitchRow::builder() let auto_security_row = adw::SwitchRow::builder()
@@ -417,7 +420,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Catalog Enrichment group // Catalog Enrichment group
let enrichment_group = adw::PreferencesGroup::builder() let enrichment_group = adw::PreferencesGroup::builder()
.title(&i18n("Catalog Enrichment")) .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(); .build();
let auto_enrich_row = adw::SwitchRow::builder() 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); enrichment_group.add(&token_row);
let token_hint = adw::ActionRow::builder() 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"]) .css_classes(["dim-label"])
.build(); .build();
enrichment_group.add(&token_hint); enrichment_group.add(&token_hint);

View File

@@ -277,7 +277,7 @@ fn build_report_content(db: &Rc<Database>) -> gtk::ScrolledWindow {
fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::PreferencesGroup { fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::PreferencesGroup {
let group = adw::PreferencesGroup::builder() let group = adw::PreferencesGroup::builder()
.title("Vulnerability Summary") .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(); .build();
let total_row = adw::ActionRow::builder() 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() let row = adw::ActionRow::builder()
.title("Critical") .title("Critical")
.subtitle(&summary.critical.to_string()) .subtitle(&summary.critical.to_string())
.tooltip_text("Could allow an attacker to take control of affected components")
.build(); .build();
let badge = widgets::status_badge("Critical", "error"); let badge = widgets::status_badge("Critical", "error");
badge.set_valign(gtk::Align::Center); 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() let row = adw::ActionRow::builder()
.title("High") .title("High")
.subtitle(&summary.high.to_string()) .subtitle(&summary.high.to_string())
.tooltip_text("Could allow unauthorized access to data processed by the app")
.build(); .build();
let badge = widgets::status_badge("High", "error"); let badge = widgets::status_badge("High", "error");
badge.set_valign(gtk::Align::Center); 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() let row = adw::ActionRow::builder()
.title("Medium") .title("Medium")
.subtitle(&summary.medium.to_string()) .subtitle(&summary.medium.to_string())
.tooltip_text("Could cause the app to behave unexpectedly or crash")
.build(); .build();
let badge = widgets::status_badge("Medium", "warning"); let badge = widgets::status_badge("Medium", "warning");
badge.set_valign(gtk::Align::Center); 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() let row = adw::ActionRow::builder()
.title("Low") .title("Low")
.subtitle(&summary.low.to_string()) .subtitle(&summary.low.to_string())
.tooltip_text("Minor issue with limited practical impact")
.build(); .build();
let badge = widgets::status_badge("Low", "neutral"); let badge = widgets::status_badge("Low", "neutral");
badge.set_valign(gtk::Align::Center); badge.set_valign(gtk::Align::Center);
@@ -340,7 +344,10 @@ fn build_app_findings_group(
summary: &crate::core::database::CveSummary, summary: &crate::core::database::CveSummary,
cve_matches: &[crate::core::database::CveMatchRecord], cve_matches: &[crate::core::database::CveMatchRecord],
) -> adw::PreferencesGroup { ) -> 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() let group = adw::PreferencesGroup::builder()
.title(app_name) .title(app_name)
.description(&description) .description(&description)

View File

@@ -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. /// 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<String>) {
let records = match db.get_all_appimages() { let records = match db.get_all_appimages() {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
log::error!("Failed to get appimages for update check: {}", e); log::error!("Failed to get appimages for update check: {}", e);
return 0; return (0, vec![]);
} }
}; };
let mut updates_found = 0u32; let mut updates_found = 0u32;
let mut updated_names = Vec::new();
for record in &records { for record in &records {
if record.pinned {
continue;
}
let appimage_path = std::path::Path::new(&record.path); let appimage_path = std::path::Path::new(&record.path);
if !appimage_path.exists() { if !appimage_path.exists() {
continue; continue;
@@ -292,6 +297,8 @@ pub fn batch_check_updates(db: &Database) -> u32 {
if let Some(ref version) = result.latest_version { if let Some(ref version) = result.latest_version {
db.set_update_available(record.id, Some(version), result.download_url.as_deref()).ok(); db.set_update_available(record.id, Some(version), result.download_url.as_deref()).ok();
updates_found += 1; updates_found += 1;
let name = record.app_name.as_deref().unwrap_or(&record.filename);
updated_names.push(name.to_string());
} }
} else { } else {
db.clear_update_available(record.id).ok(); 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
} }

View File

@@ -25,7 +25,7 @@ pub fn build_updates_view(db: &Rc<Database>) -> adw::ToolbarView {
// Check Now button // Check Now button
let check_btn = gtk::Button::builder() let check_btn = gtk::Button::builder()
.icon_name("view-refresh-symbolic") .icon_name("view-refresh-symbolic")
.tooltip_text(&i18n("Check for updates")) .tooltip_text(&i18n("Check for updates (Ctrl+U)"))
.build(); .build();
header.pack_end(&check_btn); header.pack_end(&check_btn);
@@ -86,6 +86,18 @@ pub fn build_updates_view(db: &Rc<Database>) -> adw::ToolbarView {
.build(); .build();
updates_content.append(&last_checked_label); 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() let clamp = adw::Clamp::builder()
.maximum_size(800) .maximum_size(800)
.tightening_threshold(600) .tightening_threshold(600)
@@ -255,10 +267,15 @@ fn populate_update_list(state: &Rc<UpdatesState>) {
.activatable(false) .activatable(false)
.build(); .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 current = record.app_version.as_deref().unwrap_or("unknown");
let latest = record.latest_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 // App icon
let icon = widgets::app_icon(record.icon_path.as_deref(), name, 32); let icon = widgets::app_icon(record.icon_path.as_deref(), name, 32);

View File

@@ -339,9 +339,8 @@ pub fn show_crash_dialog(
/// Generate a plain-text explanation of why an app crashed based on stderr patterns. /// Generate a plain-text explanation of why an app crashed based on stderr patterns.
fn crash_explanation(stderr: &str) -> String { fn crash_explanation(stderr: &str) -> String {
if stderr.contains("Could not find the Qt platform plugin") || stderr.contains("qt.qpa.plugin") { 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 \ return "This app needs a display system plugin that is not installed. \
it needs a Qt library that isn't bundled inside the AppImage or \ Try installing the Qt platform packages for your system.".to_string();
available on your system.".to_string();
} }
if stderr.contains("cannot open shared object file") { if stderr.contains("cannot open shared object file") {
if let Some(pos) = stderr.find("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(); let lib = before[start + 2..].trim();
if !lib.is_empty() { if !lib.is_empty() {
return format!( return format!(
"The app needs a system library ({}) that isn't installed. \ "This app is missing a component it needs to run \
You may be able to fix this by installing the missing package.", (similar to a missing DLL on Windows). \
The missing component is: {}",
lib, 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") { if stderr.contains("Segmentation fault") || stderr.contains("SIGSEGV") {
return "The app crashed due to a memory error. This is usually a bug \ return "This app crashed immediately. This is usually a bug in the \
in the app itself, not something you can fix.".to_string(); app itself, not something you can fix.".to_string();
} }
if stderr.contains("Permission denied") { if stderr.contains("Permission denied") {
return "The app was blocked from accessing something it needs. \ return "This app does not have permission to run. Driftwood usually \
Check that the AppImage file has the right permissions.".to_string(); fixes this automatically - try removing and re-adding the app.".to_string();
} }
if stderr.contains("fatal IO error") || stderr.contains("display connection") { if stderr.contains("fatal IO error") || stderr.contains("display connection") {
return "The app lost its connection to the display server. This can happen \ return "This app could not connect to your display. If you are using \
with apps that don't fully support your display system.".to_string(); a remote session or container, this may not work.".to_string();
} }
if stderr.contains("FATAL:") || stderr.contains("Aborted") { 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(); below may help identify the cause.".to_string();
} }
if stderr.contains("Failed to initialize") { if stderr.contains("Failed to initialize") {

View File

@@ -232,9 +232,10 @@ impl DriftwoodWindow {
.build(); .build();
let drop_overlay_subtitle = gtk::Label::builder() 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"]) .css_classes(["body", "dimmed"])
.halign(gtk::Align::Center) .halign(gtk::Align::Center)
.wrap(true)
.build(); .build();
// The card itself - acts as a clickable button to open file picker // The card itself - acts as a clickable button to open file picker
@@ -1061,8 +1062,8 @@ impl DriftwoodWindow {
&db, &db,
is_integrated, is_integrated,
&fp_paths, &fp_paths,
Some(Box::new(move || { Some(Rc::new(move || {
// Refresh the library view after uninstall // Refresh the library view after uninstall (or undo)
if let Some(lib_view) = window_ref.imp().library_view.get() { if let Some(lib_view) = window_ref.imp().library_view.get() {
if let Ok(records) = db_refresh.get_all_appimages() { if let Ok(records) = db_refresh.get_all_appimages() {
lib_view.populate(records); lib_view.populate(records);
@@ -1112,6 +1113,17 @@ impl DriftwoodWindow {
} }
self.add_action(&show_updates_action); 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 // Keyboard shortcuts
if let Some(app) = self.application() { if let Some(app) = self.application() {
let gtk_app = app.downcast_ref::<gtk::Application>().unwrap(); let gtk_app = app.downcast_ref::<gtk::Application>().unwrap();
@@ -1124,6 +1136,7 @@ impl DriftwoodWindow {
gtk_app.set_accels_for_action("win.show-installed", &["<Control>1"]); gtk_app.set_accels_for_action("win.show-installed", &["<Control>1"]);
gtk_app.set_accels_for_action("win.show-catalog", &["<Control>2"]); gtk_app.set_accels_for_action("win.show-catalog", &["<Control>2"]);
gtk_app.set_accels_for_action("win.show-updates", &["<Control>3"]); gtk_app.set_accels_for_action("win.show-updates", &["<Control>3"]);
gtk_app.set_accels_for_action("win.command-palette", &["<Control>k"]);
} }
} }
@@ -1149,6 +1162,14 @@ impl DriftwoodWindow {
// Scan on startup if enabled in preferences // Scan on startup if enabled in preferences
if self.settings().boolean("auto-scan-on-startup") { 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(); self.trigger_scan();
} }
@@ -1206,14 +1227,31 @@ impl DriftwoodWindow {
}; };
if should_check { if should_check {
let settings_save = settings_upd.clone(); let settings_save = settings_upd.clone();
let update_toast = self.imp().toast_overlay.get().cloned();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || { let result = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("DB open failed"); 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; .await;
if let Ok(count) = result { if let Ok((count, names)) = result {
log::info!("Background update check: {} updates available", count); 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(); let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
settings_save.set_string("last-update-check", &now).ok(); 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::<adw::NavigationView>() {
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) { fn show_shortcuts_dialog(&self) {
let dialog = adw::Dialog::builder() let dialog = adw::Dialog::builder()
.title("Keyboard Shortcuts") .title("Keyboard Shortcuts")
@@ -1674,6 +1880,7 @@ impl DriftwoodWindow {
nav_group.add(&shortcut_row("Ctrl+1", "Installed")); nav_group.add(&shortcut_row("Ctrl+1", "Installed"));
nav_group.add(&shortcut_row("Ctrl+2", "Catalog")); nav_group.add(&shortcut_row("Ctrl+2", "Catalog"));
nav_group.add(&shortcut_row("Ctrl+3", "Updates")); 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+F", "Search"));
nav_group.add(&shortcut_row("Ctrl+D", "Dashboard")); nav_group.add(&shortcut_row("Ctrl+D", "Dashboard"));
nav_group.add(&shortcut_row("Ctrl+,", "Preferences")); nav_group.add(&shortcut_row("Ctrl+,", "Preferences"));