Implement Driftwood AppImage manager - Phases 1 and 2

Phase 1 - Application scaffolding:
- GTK4/libadwaita application window with AdwNavigationView
- GSettings-backed window state persistence
- GResource-compiled CSS and schema
- Library view with grid/list toggle, search, sorting, filtering
- Detail view with file info, desktop integration controls
- Preferences window with scan directories, theme, behavior settings
- CLI with list, scan, integrate, remove, clean, inspect commands
- AppImage discovery, metadata extraction, desktop integration
- Orphaned desktop entry detection and cleanup
- AppImage packaging script

Phase 2 - Intelligence layer:
- Database schema v2 with migration for status tracking columns
- FUSE detection engine (libfuse2/3, fusermount, /dev/fuse, AppImageLauncher)
- Wayland awareness engine (session type, toolkit detection, XWayland)
- Update info parsing from AppImage ELF sections (.upd_info)
- GitHub/GitLab Releases API integration for update checking
- Update download with progress tracking and atomic apply
- Launch wrapper with FUSE auto-detection and usage tracking
- Duplicate and multi-version detection with recommendations
- Dashboard with system health, library stats, disk usage
- Update check dialog (single and batch)
- Duplicate resolution dialog
- Status badges on library cards and detail view
- Extended CLI: status, check-updates, duplicates, launch commands

49 tests passing across all modules.
This commit is contained in:
lashman
2026-02-26 23:04:27 +02:00
parent 588b1b1525
commit fa28955919
33 changed files with 10401 additions and 0 deletions

439
src/core/duplicates.rs Normal file
View File

@@ -0,0 +1,439 @@
use super::database::{AppImageRecord, Database};
use std::collections::HashMap;
/// A group of AppImages that appear to be the same application.
#[derive(Debug, Clone)]
pub struct DuplicateGroup {
/// Canonical app name for this group.
pub app_name: String,
/// All records in this group, sorted by version (newest first).
pub members: Vec<DuplicateMember>,
/// Reason these were grouped together.
pub match_reason: MatchReason,
/// Total disk space used by all members.
pub total_size: u64,
/// Potential space savings if only keeping the newest.
pub potential_savings: u64,
}
#[derive(Debug, Clone)]
pub struct DuplicateMember {
pub record: AppImageRecord,
/// Whether this is the recommended one to keep.
pub is_recommended: bool,
/// Why we recommend keeping or removing this one.
pub recommendation: MemberRecommendation,
}
#[derive(Debug, Clone, PartialEq)]
pub enum MatchReason {
/// Same app name, different versions.
MultiVersion,
/// Same SHA256 hash (exact duplicates in different locations).
ExactDuplicate,
/// Same app name, same version, different paths.
SameVersionDifferentPath,
}
impl MatchReason {
pub fn label(&self) -> &'static str {
match self {
Self::MultiVersion => "Multiple versions",
Self::ExactDuplicate => "Exact duplicates",
Self::SameVersionDifferentPath => "Same version, different locations",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum MemberRecommendation {
/// This is the newest version - keep it.
KeepNewest,
/// This is the only integrated copy - keep it.
KeepIntegrated,
/// Older version that can be removed.
RemoveOlder,
/// Duplicate that can be removed.
RemoveDuplicate,
/// No clear recommendation.
UserChoice,
}
impl MemberRecommendation {
pub fn label(&self) -> &'static str {
match self {
Self::KeepNewest => "Keep (newest)",
Self::KeepIntegrated => "Keep (integrated)",
Self::RemoveOlder => "Remove (older version)",
Self::RemoveDuplicate => "Remove (duplicate)",
Self::UserChoice => "Your choice",
}
}
}
/// Detect duplicate and multi-version AppImages from the database.
pub fn detect_duplicates(db: &Database) -> Vec<DuplicateGroup> {
let records = match db.get_all_appimages() {
Ok(r) => r,
Err(e) => {
log::error!("Failed to query appimages for duplicate detection: {}", e);
return Vec::new();
}
};
if records.len() < 2 {
return Vec::new();
}
let mut groups = Vec::new();
// Phase 1: Find exact duplicates by SHA256 hash
let hash_groups = group_by_hash(&records);
for (hash, members) in &hash_groups {
if members.len() > 1 {
groups.push(build_exact_duplicate_group(hash, members));
}
}
// Phase 2: Find same app name groups (excluding already-found exact dupes)
let exact_dupe_ids: std::collections::HashSet<i64> = groups
.iter()
.flat_map(|g| g.members.iter().map(|m| m.record.id))
.collect();
let name_groups = group_by_name(&records);
for (name, members) in &name_groups {
// Skip if all members are already in exact duplicate groups
let remaining: Vec<&AppImageRecord> = members
.iter()
.filter(|r| !exact_dupe_ids.contains(&r.id))
.collect();
if remaining.len() > 1 {
groups.push(build_name_group(name, &remaining));
}
}
// Sort groups by potential savings (largest first)
groups.sort_by(|a, b| b.potential_savings.cmp(&a.potential_savings));
groups
}
/// Group records by SHA256 hash.
fn group_by_hash(records: &[AppImageRecord]) -> HashMap<String, Vec<AppImageRecord>> {
let mut map: HashMap<String, Vec<AppImageRecord>> = HashMap::new();
for record in records {
if let Some(ref hash) = record.sha256 {
if !hash.is_empty() {
map.entry(hash.clone())
.or_default()
.push(record.clone());
}
}
}
map
}
/// Group records by normalized app name.
fn group_by_name(records: &[AppImageRecord]) -> HashMap<String, Vec<AppImageRecord>> {
let mut map: HashMap<String, Vec<AppImageRecord>> = HashMap::new();
for record in records {
let name = normalize_app_name(record);
map.entry(name).or_default().push(record.clone());
}
map
}
/// Normalize an app name for grouping purposes.
/// Strips version numbers, architecture suffixes, and normalizes case.
fn normalize_app_name(record: &AppImageRecord) -> String {
let name = record
.app_name
.as_deref()
.unwrap_or(&record.filename);
// Lowercase and trim
let mut normalized = name.to_lowercase().trim().to_string();
// Remove common suffixes
for suffix in &[
".appimage",
"-x86_64",
"-aarch64",
"-armhf",
"-i386",
"-i686",
"_x86_64",
"_aarch64",
] {
if let Some(stripped) = normalized.strip_suffix(suffix) {
normalized = stripped.to_string();
}
}
// Remove trailing version-like patterns (e.g., "-1.2.3", "_v2.0")
if let Some(pos) = find_version_suffix(&normalized) {
normalized = normalized[..pos].to_string();
}
// Remove trailing hyphens/underscores
normalized = normalized.trim_end_matches(|c: char| c == '-' || c == '_').to_string();
normalized
}
/// Find the start position of a trailing version suffix.
fn find_version_suffix(s: &str) -> Option<usize> {
// Look for patterns like -1.2.3, _v2.0, -24.02.1 at the end
let bytes = s.as_bytes();
let mut i = bytes.len();
// Walk backwards past version characters (digits, dots)
while i > 0 && (bytes[i - 1].is_ascii_digit() || bytes[i - 1] == b'.') {
i -= 1;
}
// Check if we found a version separator
if i > 0 && i < bytes.len() {
// Skip optional 'v' prefix
if i > 0 && bytes[i - 1] == b'v' {
i -= 1;
}
// Must have a separator before the version
if i > 0 && (bytes[i - 1] == b'-' || bytes[i - 1] == b'_') {
// Verify it looks like a version (has at least one dot)
let version_part = &s[i..];
if version_part.contains('.') || version_part.starts_with('v') {
return Some(i - 1);
}
}
}
None
}
/// Build a DuplicateGroup for exact hash duplicates.
fn build_exact_duplicate_group(_hash: &str, records: &[AppImageRecord]) -> DuplicateGroup {
let total_size: u64 = records.iter().map(|r| r.size_bytes as u64).sum();
// Keep the one that's integrated, or the one with the shortest path
let keep_idx = records
.iter()
.position(|r| r.integrated)
.unwrap_or(0);
let members: Vec<DuplicateMember> = records
.iter()
.enumerate()
.map(|(i, r)| DuplicateMember {
record: r.clone(),
is_recommended: i == keep_idx,
recommendation: if i == keep_idx {
if r.integrated {
MemberRecommendation::KeepIntegrated
} else {
MemberRecommendation::UserChoice
}
} else {
MemberRecommendation::RemoveDuplicate
},
})
.collect();
let savings = total_size - records[keep_idx].size_bytes as u64;
let app_name = records[0]
.app_name
.clone()
.unwrap_or_else(|| records[0].filename.clone());
DuplicateGroup {
app_name,
members,
match_reason: MatchReason::ExactDuplicate,
total_size,
potential_savings: savings,
}
}
/// Build a DuplicateGroup for same-name groups.
fn build_name_group(name: &str, records: &[&AppImageRecord]) -> DuplicateGroup {
let total_size: u64 = records.iter().map(|r| r.size_bytes as u64).sum();
// Sort by version (newest first)
let mut sorted: Vec<&AppImageRecord> = records.to_vec();
sorted.sort_by(|a, b| {
let va = a.app_version.as_deref().unwrap_or("0");
let vb = b.app_version.as_deref().unwrap_or("0");
// Compare versions - newer should come first
compare_versions(vb, va)
});
// Determine if this is multi-version or same-version-different-path
let versions: std::collections::HashSet<String> = sorted
.iter()
.filter_map(|r| r.app_version.clone())
.collect();
let match_reason = if versions.len() <= 1 {
MatchReason::SameVersionDifferentPath
} else {
MatchReason::MultiVersion
};
let members: Vec<DuplicateMember> = sorted
.iter()
.enumerate()
.map(|(i, r)| {
let (is_recommended, recommendation) = if i == 0 {
// First (newest) version
(true, MemberRecommendation::KeepNewest)
} else if r.integrated {
// Older but integrated
(false, MemberRecommendation::KeepIntegrated)
} else if match_reason == MatchReason::SameVersionDifferentPath {
(false, MemberRecommendation::RemoveDuplicate)
} else {
(false, MemberRecommendation::RemoveOlder)
};
DuplicateMember {
record: (*r).clone(),
is_recommended,
recommendation,
}
})
.collect();
let savings = if !members.is_empty() {
total_size - members[0].record.size_bytes as u64
} else {
0
};
// Use the prettiest app name from the group
let app_name = sorted
.iter()
.filter_map(|r| r.app_name.as_ref())
.next()
.cloned()
.unwrap_or_else(|| name.to_string());
DuplicateGroup {
app_name,
members,
match_reason,
total_size,
potential_savings: savings,
}
}
/// Compare two version strings for ordering.
fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
use super::updater::version_is_newer;
if a == b {
std::cmp::Ordering::Equal
} else if version_is_newer(a, b) {
std::cmp::Ordering::Greater
} else {
std::cmp::Ordering::Less
}
}
/// Summary of duplicate detection results.
#[derive(Debug, Clone)]
pub struct DuplicateSummary {
pub total_groups: usize,
pub exact_duplicates: usize,
pub multi_version: usize,
pub total_potential_savings: u64,
}
pub fn summarize_duplicates(groups: &[DuplicateGroup]) -> DuplicateSummary {
let exact_duplicates = groups
.iter()
.filter(|g| g.match_reason == MatchReason::ExactDuplicate)
.count();
let multi_version = groups
.iter()
.filter(|g| g.match_reason == MatchReason::MultiVersion)
.count();
let total_potential_savings: u64 = groups.iter().map(|g| g.potential_savings).sum();
DuplicateSummary {
total_groups: groups.len(),
exact_duplicates,
multi_version,
total_potential_savings,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_app_name() {
let make_record = |name: &str, filename: &str| AppImageRecord {
id: 0,
path: String::new(),
filename: filename.to_string(),
app_name: Some(name.to_string()),
app_version: None,
appimage_type: None,
size_bytes: 0,
sha256: None,
icon_path: None,
desktop_file: None,
integrated: false,
integrated_at: None,
is_executable: true,
desktop_entry_content: None,
categories: None,
description: None,
developer: None,
architecture: None,
first_seen: String::new(),
last_scanned: String::new(),
file_modified: None,
fuse_status: None,
wayland_status: None,
update_info: None,
update_type: None,
latest_version: None,
update_checked: None,
update_url: None,
notes: None,
};
assert_eq!(
normalize_app_name(&make_record("Firefox", "Firefox.AppImage")),
"firefox"
);
assert_eq!(
normalize_app_name(&make_record("Inkscape", "Inkscape-1.3.2-x86_64.AppImage")),
"inkscape"
);
}
#[test]
fn test_find_version_suffix() {
assert_eq!(find_version_suffix("firefox-124.0"), Some(7));
assert_eq!(find_version_suffix("app-v2.0.0"), Some(3));
assert_eq!(find_version_suffix("firefox"), None);
assert_eq!(find_version_suffix("app_1.2.3"), Some(3));
}
#[test]
fn test_match_reason_labels() {
assert_eq!(MatchReason::MultiVersion.label(), "Multiple versions");
assert_eq!(MatchReason::ExactDuplicate.label(), "Exact duplicates");
}
#[test]
fn test_member_recommendation_labels() {
assert_eq!(MemberRecommendation::KeepNewest.label(), "Keep (newest)");
assert_eq!(MemberRecommendation::RemoveOlder.label(), "Remove (older version)");
}
}