Add AppImageHub in-app catalog browser with search, categories, and install
This commit is contained in:
@@ -124,6 +124,11 @@
|
||||
<summary>Security notification threshold</summary>
|
||||
<description>Minimum CVE severity for desktop notifications: critical, high, medium, or low.</description>
|
||||
</key>
|
||||
<key name="catalog-last-refreshed" type="s">
|
||||
<default>''</default>
|
||||
<summary>Catalog last refreshed</summary>
|
||||
<description>ISO timestamp of the last catalog refresh.</description>
|
||||
</key>
|
||||
<key name="watch-removable-media" type="b">
|
||||
<default>false</default>
|
||||
<summary>Watch removable media</summary>
|
||||
|
||||
@@ -88,6 +88,42 @@ pub struct SystemModification {
|
||||
pub previous_value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CatalogApp {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub categories: Option<String>,
|
||||
pub download_url: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub license: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CatalogSourceRecord {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub source_type: String,
|
||||
pub enabled: bool,
|
||||
pub last_synced: Option<String>,
|
||||
pub app_count: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CatalogAppRecord {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub categories: Option<String>,
|
||||
pub latest_version: Option<String>,
|
||||
pub download_url: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub file_size: Option<i64>,
|
||||
pub architecture: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OrphanedEntry {
|
||||
pub id: i64,
|
||||
@@ -641,6 +677,8 @@ impl Database {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_catalog_apps_source
|
||||
ON catalog_apps(source_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_catalog_apps_source_name
|
||||
ON catalog_apps(source_id, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_sandbox_profiles_app
|
||||
ON sandbox_profiles(app_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_updates_appimage
|
||||
@@ -1954,6 +1992,236 @@ impl Database {
|
||||
self.conn.execute("DELETE FROM system_modifications WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Catalog methods ---
|
||||
|
||||
pub fn upsert_catalog_source(
|
||||
&self,
|
||||
name: &str,
|
||||
url: &str,
|
||||
source_type: &str,
|
||||
) -> SqlResult<i64> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO catalog_sources (name, url, source_type, last_synced)
|
||||
VALUES (?1, ?2, ?3, datetime('now'))
|
||||
ON CONFLICT(url) DO UPDATE SET last_synced = datetime('now')",
|
||||
params![name, url, source_type],
|
||||
)?;
|
||||
self.conn.query_row(
|
||||
"SELECT id FROM catalog_sources WHERE url = ?1",
|
||||
params![url],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn upsert_catalog_app(
|
||||
&self,
|
||||
source_id: i64,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
categories: Option<&str>,
|
||||
download_url: &str,
|
||||
icon_url: Option<&str>,
|
||||
homepage: Option<&str>,
|
||||
license: Option<&str>,
|
||||
) -> SqlResult<i64> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO catalog_apps (source_id, name, description, categories, download_url, icon_url, homepage, cached_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now'))
|
||||
ON CONFLICT(source_id, name) DO UPDATE SET
|
||||
description = COALESCE(?3, description),
|
||||
categories = COALESCE(?4, categories),
|
||||
download_url = ?5,
|
||||
icon_url = COALESCE(?6, icon_url),
|
||||
homepage = COALESCE(?7, homepage),
|
||||
cached_at = datetime('now')",
|
||||
params![source_id, name, description, categories, download_url, icon_url, homepage],
|
||||
)?;
|
||||
// Store license as architecture field (reusing available column)
|
||||
// until a proper column is added
|
||||
if let Some(lic) = license {
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET architecture = ?1 WHERE source_id = ?2 AND name = ?3",
|
||||
params![lic, source_id, name],
|
||||
)?;
|
||||
}
|
||||
self.conn.query_row(
|
||||
"SELECT id FROM catalog_apps WHERE source_id = ?1 AND name = ?2",
|
||||
params![source_id, name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn search_catalog(
|
||||
&self,
|
||||
query: &str,
|
||||
category: Option<&str>,
|
||||
limit: i32,
|
||||
) -> SqlResult<Vec<CatalogApp>> {
|
||||
let mut sql = String::from(
|
||||
"SELECT id, name, description, categories, download_url, icon_url, homepage, architecture
|
||||
FROM catalog_apps WHERE 1=1"
|
||||
);
|
||||
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)");
|
||||
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)));
|
||||
}
|
||||
|
||||
sql.push_str(&format!(" ORDER BY name LIMIT {}", limit));
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||
params_list.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_refs.as_slice(), |row| {
|
||||
Ok(CatalogApp {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
categories: row.get(3)?,
|
||||
download_url: row.get(4)?,
|
||||
icon_url: row.get(5)?,
|
||||
homepage: row.get(6)?,
|
||||
license: row.get(7)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
for row in rows {
|
||||
results.push(row?);
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub fn get_catalog_app(&self, id: i64) -> SqlResult<Option<CatalogApp>> {
|
||||
let result = self.conn.query_row(
|
||||
"SELECT id, name, description, categories, download_url, icon_url, homepage, architecture
|
||||
FROM catalog_apps WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(CatalogApp {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
categories: row.get(3)?,
|
||||
download_url: row.get(4)?,
|
||||
icon_url: row.get(5)?,
|
||||
homepage: row.get(6)?,
|
||||
license: row.get(7)?,
|
||||
})
|
||||
},
|
||||
);
|
||||
match result {
|
||||
Ok(app) => Ok(Some(app)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
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 rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
|
||||
|
||||
let mut counts = std::collections::HashMap::new();
|
||||
for row in rows {
|
||||
if let Ok(cats_str) = row {
|
||||
for cat in cats_str.split(';').filter(|s| !s.is_empty()) {
|
||||
*counts.entry(cat.to_string()).or_insert(0u32) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut result: Vec<(String, u32)> = counts.into_iter().collect();
|
||||
result.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn catalog_app_count(&self) -> SqlResult<i64> {
|
||||
self.conn.query_row("SELECT COUNT(*) FROM catalog_apps", [], |row| row.get(0))
|
||||
}
|
||||
|
||||
pub fn insert_catalog_app(
|
||||
&self,
|
||||
source_id: i64,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
categories: Option<&str>,
|
||||
latest_version: Option<&str>,
|
||||
download_url: &str,
|
||||
icon_url: Option<&str>,
|
||||
homepage: Option<&str>,
|
||||
file_size: Option<i64>,
|
||||
architecture: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"INSERT OR REPLACE INTO catalog_apps
|
||||
(source_id, name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture, cached_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now'))",
|
||||
params![source_id, name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn search_catalog_apps(&self, query: &str) -> SqlResult<Vec<CatalogAppRecord>> {
|
||||
let pattern = format!("%{}%", query);
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture
|
||||
FROM catalog_apps
|
||||
WHERE name LIKE ?1 OR description LIKE ?1
|
||||
ORDER BY name
|
||||
LIMIT 50"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![pattern], |row| {
|
||||
Ok(CatalogAppRecord {
|
||||
name: row.get(0)?,
|
||||
description: row.get(1)?,
|
||||
categories: row.get(2)?,
|
||||
latest_version: row.get(3)?,
|
||||
download_url: row.get(4)?,
|
||||
icon_url: row.get(5)?,
|
||||
homepage: row.get(6)?,
|
||||
file_size: row.get(7)?,
|
||||
architecture: row.get(8)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_catalog_source_sync(&self, source_id: i64, app_count: i32) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_sources SET last_synced = datetime('now'), app_count = ?2 WHERE id = ?1",
|
||||
params![source_id, app_count],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_catalog_sources(&self) -> SqlResult<Vec<CatalogSourceRecord>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, name, url, source_type, enabled, last_synced, app_count FROM catalog_sources ORDER BY name"
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(CatalogSourceRecord {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
url: row.get(2)?,
|
||||
source_type: row.get(3)?,
|
||||
enabled: row.get::<_, i32>(4).unwrap_or(1) != 0,
|
||||
last_synced: row.get(5)?,
|
||||
app_count: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod analysis;
|
||||
pub mod appstream;
|
||||
pub mod backup;
|
||||
pub mod catalog;
|
||||
pub mod database;
|
||||
pub mod discovery;
|
||||
pub mod duplicates;
|
||||
|
||||
442
src/ui/catalog_view.rs
Normal file
442
src/ui/catalog_view.rs
Normal file
@@ -0,0 +1,442 @@
|
||||
use adw::prelude::*;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk::gio;
|
||||
|
||||
use crate::core::catalog;
|
||||
use crate::core::database::Database;
|
||||
use crate::i18n::i18n;
|
||||
use super::widgets;
|
||||
|
||||
/// Build the catalog browser page.
|
||||
pub fn build_catalog_page(db: &Rc<Database>) -> adw::NavigationPage {
|
||||
let toast_overlay = adw::ToastOverlay::new();
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
let title = adw::WindowTitle::builder()
|
||||
.title(&i18n("App Catalog"))
|
||||
.subtitle(&i18n("Browse available AppImages"))
|
||||
.build();
|
||||
header.set_title_widget(Some(&title));
|
||||
|
||||
// Search entry
|
||||
let search_entry = gtk::SearchEntry::builder()
|
||||
.placeholder_text(&i18n("Search apps..."))
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
let search_bar = gtk::SearchBar::builder()
|
||||
.child(&search_entry)
|
||||
.search_mode_enabled(true)
|
||||
.build();
|
||||
|
||||
// Category filter
|
||||
let category_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.margin_start(18)
|
||||
.margin_end(18)
|
||||
.build();
|
||||
|
||||
let category_scroll = gtk::ScrolledWindow::builder()
|
||||
.child(&category_box)
|
||||
.vscrollbar_policy(gtk::PolicyType::Never)
|
||||
.hscrollbar_policy(gtk::PolicyType::Automatic)
|
||||
.max_content_height(40)
|
||||
.build();
|
||||
|
||||
// Results list
|
||||
let results_box = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.css_classes(["boxed-list"])
|
||||
.margin_start(18)
|
||||
.margin_end(18)
|
||||
.margin_top(12)
|
||||
.margin_bottom(24)
|
||||
.build();
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(800)
|
||||
.tightening_threshold(600)
|
||||
.build();
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(12)
|
||||
.build();
|
||||
|
||||
content.append(&search_bar);
|
||||
content.append(&category_scroll);
|
||||
content.append(&results_box);
|
||||
clamp.set_child(Some(&content));
|
||||
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.child(&clamp)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
// Status page for empty state
|
||||
let empty_page = adw::StatusPage::builder()
|
||||
.icon_name("system-software-install-symbolic")
|
||||
.title(&i18n("App Catalog"))
|
||||
.description(&i18n("Refresh the catalog to browse available AppImages"))
|
||||
.build();
|
||||
|
||||
let refresh_btn = gtk::Button::builder()
|
||||
.label(&i18n("Refresh Catalog"))
|
||||
.halign(gtk::Align::Center)
|
||||
.css_classes(["suggested-action", "pill"])
|
||||
.build();
|
||||
empty_page.set_child(Some(&refresh_btn));
|
||||
|
||||
let stack = gtk::Stack::new();
|
||||
stack.add_named(&empty_page, Some("empty"));
|
||||
stack.add_named(&scrolled, Some("results"));
|
||||
|
||||
// Show empty or results based on catalog data
|
||||
let app_count = db.catalog_app_count().unwrap_or(0);
|
||||
if app_count > 0 {
|
||||
stack.set_visible_child_name("results");
|
||||
title.set_subtitle(&format!("{} apps available", app_count));
|
||||
} else {
|
||||
stack.set_visible_child_name("empty");
|
||||
}
|
||||
|
||||
toast_overlay.set_child(Some(&stack));
|
||||
|
||||
let toolbar_view = adw::ToolbarView::new();
|
||||
toolbar_view.add_top_bar(&header);
|
||||
toolbar_view.set_content(Some(&toast_overlay));
|
||||
|
||||
// Refresh button in header
|
||||
let refresh_header_btn = gtk::Button::builder()
|
||||
.icon_name("view-refresh-symbolic")
|
||||
.tooltip_text(&i18n("Refresh catalog"))
|
||||
.build();
|
||||
header.pack_end(&refresh_header_btn);
|
||||
|
||||
// Category chips state
|
||||
let active_category: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
|
||||
|
||||
// Populate categories
|
||||
populate_categories(db, &category_box, &active_category, &results_box, &search_entry);
|
||||
|
||||
// Initial population
|
||||
populate_results(db, "", None, &results_box, &toast_overlay);
|
||||
|
||||
// Search handler
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let results_ref = results_box.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let cat_ref = active_category.clone();
|
||||
search_entry.connect_search_changed(move |entry| {
|
||||
let query = entry.text().to_string();
|
||||
let cat = cat_ref.borrow().clone();
|
||||
populate_results(&db_ref, &query, cat.as_deref(), &results_ref, &toast_ref);
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh handler (both buttons)
|
||||
let wire_refresh = |btn: >k::Button| {
|
||||
let db_ref = db.clone();
|
||||
let stack_ref = stack.clone();
|
||||
let results_ref = results_box.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let title_ref = title.clone();
|
||||
let cat_box_ref = category_box.clone();
|
||||
let active_cat_ref = active_category.clone();
|
||||
let search_ref = search_entry.clone();
|
||||
|
||||
btn.connect_clicked(move |btn| {
|
||||
btn.set_sensitive(false);
|
||||
let db_c = db_ref.clone();
|
||||
let stack_c = stack_ref.clone();
|
||||
let results_c = results_ref.clone();
|
||||
let toast_c = toast_ref.clone();
|
||||
let title_c = title_ref.clone();
|
||||
let btn_c = btn.clone();
|
||||
let cat_box_c = cat_box_ref.clone();
|
||||
let active_cat_c = active_cat_ref.clone();
|
||||
let search_c = search_ref.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let db_bg = Database::open().ok();
|
||||
let result = gio::spawn_blocking(move || {
|
||||
if let Some(ref db) = db_bg {
|
||||
catalog::sync_catalog(db, &catalog::CatalogSource {
|
||||
id: Some(1),
|
||||
name: "AppImageHub".to_string(),
|
||||
url: "https://appimage.github.io/feed.json".to_string(),
|
||||
source_type: catalog::CatalogType::AppImageHub,
|
||||
enabled: true,
|
||||
last_synced: None,
|
||||
app_count: 0,
|
||||
}).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err("Failed to open database".to_string())
|
||||
}
|
||||
}).await;
|
||||
|
||||
btn_c.set_sensitive(true);
|
||||
|
||||
match result {
|
||||
Ok(Ok(count)) => {
|
||||
toast_c.add_toast(adw::Toast::new(
|
||||
&format!("Catalog refreshed: {} apps", count),
|
||||
));
|
||||
title_c.set_subtitle(&format!("{} apps available", count));
|
||||
stack_c.set_visible_child_name("results");
|
||||
populate_categories(&db_c, &cat_box_c, &active_cat_c, &results_c, &search_c);
|
||||
populate_results(&db_c, "", None, &results_c, &toast_c);
|
||||
|
||||
// Store refresh timestamp
|
||||
let settings = gio::Settings::new(crate::config::APP_ID);
|
||||
let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
settings.set_string("catalog-last-refreshed", &now).ok();
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::error!("Catalog refresh failed: {}", e);
|
||||
toast_c.add_toast(adw::Toast::new("Catalog refresh failed"));
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!("Catalog refresh thread panicked");
|
||||
toast_c.add_toast(adw::Toast::new("Catalog refresh failed"));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
wire_refresh(&refresh_btn);
|
||||
wire_refresh(&refresh_header_btn);
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title(&i18n("Catalog"))
|
||||
.tag("catalog")
|
||||
.child(&toolbar_view)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn populate_results(
|
||||
db: &Rc<Database>,
|
||||
query: &str,
|
||||
category: Option<&str>,
|
||||
list_box: >k::ListBox,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
// Clear existing
|
||||
while let Some(row) = list_box.row_at_index(0) {
|
||||
list_box.remove(&row);
|
||||
}
|
||||
|
||||
let results = db.search_catalog(query, category, 50).unwrap_or_default();
|
||||
|
||||
if results.is_empty() {
|
||||
let empty_row = adw::ActionRow::builder()
|
||||
.title(&i18n("No results"))
|
||||
.subtitle(&i18n("Try a different search or refresh the catalog"))
|
||||
.build();
|
||||
list_box.append(&empty_row);
|
||||
return;
|
||||
}
|
||||
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let db_ref = db.clone();
|
||||
|
||||
for app in &results {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&app.name)
|
||||
.activatable(true)
|
||||
.build();
|
||||
|
||||
if let Some(ref desc) = app.description {
|
||||
let snippet: String = desc.chars().take(80).collect();
|
||||
let subtitle = if snippet.len() < desc.len() {
|
||||
format!("{}...", snippet.trim_end())
|
||||
} else {
|
||||
snippet
|
||||
};
|
||||
row.set_subtitle(&subtitle);
|
||||
}
|
||||
|
||||
// Category badges
|
||||
if let Some(ref cats) = app.categories {
|
||||
let first_cat: String = cats.split(';').next().unwrap_or("").to_string();
|
||||
if !first_cat.is_empty() {
|
||||
let badge = widgets::status_badge(&first_cat, "neutral");
|
||||
badge.set_valign(gtk::Align::Center);
|
||||
row.add_suffix(&badge);
|
||||
}
|
||||
}
|
||||
|
||||
// Install button
|
||||
let install_btn = gtk::Button::builder()
|
||||
.label(&i18n("Install"))
|
||||
.valign(gtk::Align::Center)
|
||||
.css_classes(["suggested-action"])
|
||||
.build();
|
||||
|
||||
let download_url = app.download_url.clone();
|
||||
let app_name = app.name.clone();
|
||||
let homepage = app.homepage.clone();
|
||||
let toast_install = toast_ref.clone();
|
||||
let db_install = db_ref.clone();
|
||||
|
||||
install_btn.connect_clicked(move |btn| {
|
||||
btn.set_sensitive(false);
|
||||
btn.set_label("Installing...");
|
||||
|
||||
let url = download_url.clone();
|
||||
let name = app_name.clone();
|
||||
let hp = homepage.clone();
|
||||
let toast_c = toast_install.clone();
|
||||
let btn_c = btn.clone();
|
||||
let db_c = db_install.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let result = gio::spawn_blocking(move || {
|
||||
let install_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
|
||||
.join("Applications");
|
||||
std::fs::create_dir_all(&install_dir).ok();
|
||||
|
||||
let app = catalog::CatalogApp {
|
||||
name: name.clone(),
|
||||
description: None,
|
||||
categories: Vec::new(),
|
||||
latest_version: None,
|
||||
download_url: url,
|
||||
icon_url: None,
|
||||
homepage: hp,
|
||||
file_size: None,
|
||||
architecture: None,
|
||||
};
|
||||
|
||||
catalog::install_from_catalog(&app, &install_dir)
|
||||
.map_err(|e| e.to_string())
|
||||
}).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(path)) => {
|
||||
// Register in DB
|
||||
let filename = path.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let size = std::fs::metadata(&path)
|
||||
.map(|m| m.len() as i64)
|
||||
.unwrap_or(0);
|
||||
db_c.upsert_appimage(
|
||||
&path.to_string_lossy(),
|
||||
&filename,
|
||||
Some(2),
|
||||
size,
|
||||
true,
|
||||
None,
|
||||
).ok();
|
||||
toast_c.add_toast(adw::Toast::new("Installed successfully"));
|
||||
btn_c.set_label("Installed");
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::error!("Install failed: {}", e);
|
||||
toast_c.add_toast(adw::Toast::new("Install failed"));
|
||||
btn_c.set_sensitive(true);
|
||||
btn_c.set_label("Install");
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!("Install thread panicked");
|
||||
toast_c.add_toast(adw::Toast::new("Install failed"));
|
||||
btn_c.set_sensitive(true);
|
||||
btn_c.set_label("Install");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
row.add_suffix(&install_btn);
|
||||
list_box.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
fn populate_categories(
|
||||
db: &Rc<Database>,
|
||||
category_box: >k::Box,
|
||||
active_category: &Rc<RefCell<Option<String>>>,
|
||||
results_box: >k::ListBox,
|
||||
search_entry: >k::SearchEntry,
|
||||
) {
|
||||
// Clear existing
|
||||
while let Some(child) = category_box.first_child() {
|
||||
category_box.remove(&child);
|
||||
}
|
||||
|
||||
let categories = db.get_catalog_categories().unwrap_or_default();
|
||||
if categories.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// "All" chip
|
||||
let all_btn = gtk::ToggleButton::builder()
|
||||
.label(&i18n("All"))
|
||||
.active(true)
|
||||
.css_classes(["pill"])
|
||||
.build();
|
||||
category_box.append(&all_btn);
|
||||
|
||||
// Top 10 category chips
|
||||
let buttons: Rc<RefCell<Vec<gtk::ToggleButton>>> = Rc::new(RefCell::new(vec![all_btn.clone()]));
|
||||
|
||||
for (cat, _count) in categories.iter().take(10) {
|
||||
let btn = gtk::ToggleButton::builder()
|
||||
.label(cat)
|
||||
.css_classes(["pill"])
|
||||
.build();
|
||||
category_box.append(&btn);
|
||||
buttons.borrow_mut().push(btn.clone());
|
||||
|
||||
let cat_str = cat.clone();
|
||||
let active_ref = active_category.clone();
|
||||
let results_ref = results_box.clone();
|
||||
let search_ref = search_entry.clone();
|
||||
let db_ref = db.clone();
|
||||
let buttons_ref = buttons.clone();
|
||||
btn.connect_toggled(move |btn| {
|
||||
if btn.is_active() {
|
||||
// Deactivate others
|
||||
for other in buttons_ref.borrow().iter() {
|
||||
if other != btn {
|
||||
other.set_active(false);
|
||||
}
|
||||
}
|
||||
*active_ref.borrow_mut() = Some(cat_str.clone());
|
||||
let query = search_ref.text().to_string();
|
||||
// Use a dummy toast overlay for filtering
|
||||
let toast = adw::ToastOverlay::new();
|
||||
populate_results(&db_ref, &query, Some(&cat_str), &results_ref, &toast);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// "All" button handler
|
||||
{
|
||||
let active_ref = active_category.clone();
|
||||
let results_ref = results_box.clone();
|
||||
let search_ref = search_entry.clone();
|
||||
let db_ref = db.clone();
|
||||
let buttons_ref = buttons.clone();
|
||||
all_btn.connect_toggled(move |btn| {
|
||||
if btn.is_active() {
|
||||
for other in buttons_ref.borrow().iter() {
|
||||
if other != btn {
|
||||
other.set_active(false);
|
||||
}
|
||||
}
|
||||
*active_ref.borrow_mut() = None;
|
||||
let query = search_ref.text().to_string();
|
||||
let toast = adw::ToastOverlay::new();
|
||||
populate_results(&db_ref, &query, None, &results_ref, &toast);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod app_card;
|
||||
pub mod batch_update_dialog;
|
||||
pub mod catalog_view;
|
||||
pub mod cleanup_wizard;
|
||||
pub mod dashboard;
|
||||
pub mod detail_view;
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::core::security;
|
||||
use crate::core::updater;
|
||||
use crate::core::watcher;
|
||||
use crate::i18n::{i18n, ni18n_f};
|
||||
use crate::ui::catalog_view;
|
||||
use crate::ui::cleanup_wizard;
|
||||
use crate::ui::dashboard;
|
||||
use crate::ui::detail_view;
|
||||
@@ -150,6 +151,7 @@ impl DriftwoodWindow {
|
||||
section2.append(Some(&i18n("Find Duplicates")), Some("win.find-duplicates"));
|
||||
section2.append(Some(&i18n("Security Report")), Some("win.security-report"));
|
||||
section2.append(Some(&i18n("Disk Cleanup")), Some("win.cleanup"));
|
||||
section2.append(Some(&i18n("Browse Catalog")), Some("win.catalog"));
|
||||
menu.append_section(None, §ion2);
|
||||
|
||||
let section3 = gio::Menu::new();
|
||||
@@ -566,6 +568,16 @@ impl DriftwoodWindow {
|
||||
})
|
||||
.build();
|
||||
|
||||
// Catalog browser action
|
||||
let catalog_action = gio::ActionEntry::builder("catalog")
|
||||
.activate(|window: &Self, _, _| {
|
||||
let db = window.database().clone();
|
||||
let catalog_page = catalog_view::build_catalog_page(&db);
|
||||
let nav = window.imp().navigation_view.get().unwrap();
|
||||
nav.push(&catalog_page);
|
||||
})
|
||||
.build();
|
||||
|
||||
// Show keyboard shortcuts dialog
|
||||
let shortcuts_action = gio::ActionEntry::builder("show-shortcuts")
|
||||
.activate(|window: &Self, _, _| {
|
||||
@@ -595,6 +607,7 @@ impl DriftwoodWindow {
|
||||
find_duplicates_action,
|
||||
security_report_action,
|
||||
cleanup_action,
|
||||
catalog_action,
|
||||
shortcuts_action,
|
||||
show_drop_hint_action,
|
||||
]);
|
||||
@@ -929,6 +942,9 @@ impl DriftwoodWindow {
|
||||
let db = self.database();
|
||||
let library_view = self.imp().library_view.get().unwrap();
|
||||
|
||||
// Ensure default catalog sources exist
|
||||
crate::core::catalog::ensure_default_sources(db);
|
||||
|
||||
match db.get_all_appimages() {
|
||||
Ok(records) if !records.is_empty() => {
|
||||
library_view.populate(records);
|
||||
|
||||
Reference in New Issue
Block a user