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:
439
src/core/duplicates.rs
Normal file
439
src/core/duplicates.rs
Normal 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)");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user