Add UX enhancements: carousel, filter chips, command palette, and more
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user