diff --git a/data/app.driftwood.Driftwood.gschema.xml b/data/app.driftwood.Driftwood.gschema.xml
index 379a2b4..b5a1908 100644
--- a/data/app.driftwood.Driftwood.gschema.xml
+++ b/data/app.driftwood.Driftwood.gschema.xml
@@ -124,6 +124,11 @@
Security notification threshold
Minimum CVE severity for desktop notifications: critical, high, medium, or low.
+
+ ''
+ Catalog last refreshed
+ ISO timestamp of the last catalog refresh.
+
false
Watch removable media
diff --git a/src/core/database.rs b/src/core/database.rs
index 79f7ce3..a24591b 100644
--- a/src/core/database.rs
+++ b/src/core/database.rs
@@ -88,6 +88,42 @@ pub struct SystemModification {
pub previous_value: Option,
}
+#[derive(Debug, Clone)]
+pub struct CatalogApp {
+ pub id: i64,
+ pub name: String,
+ pub description: Option,
+ pub categories: Option,
+ pub download_url: String,
+ pub icon_url: Option,
+ pub homepage: Option,
+ pub license: Option,
+}
+
+#[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,
+ pub app_count: i32,
+}
+
+#[derive(Debug, Clone)]
+pub struct CatalogAppRecord {
+ pub name: String,
+ pub description: Option,
+ pub categories: Option,
+ pub latest_version: Option,
+ pub download_url: String,
+ pub icon_url: Option,
+ pub homepage: Option,
+ pub file_size: Option,
+ pub architecture: Option,
+}
+
#[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 {
+ 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 {
+ 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> {
+ 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> = 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