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