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:
238
src/core/discovery.rs
Normal file
238
src/core/discovery.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
use std::fs::{self, File};
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AppImageType {
|
||||
Type1,
|
||||
Type2,
|
||||
}
|
||||
|
||||
impl AppImageType {
|
||||
pub fn as_i32(&self) -> i32 {
|
||||
match self {
|
||||
Self::Type1 => 1,
|
||||
Self::Type2 => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredAppImage {
|
||||
pub path: PathBuf,
|
||||
pub filename: String,
|
||||
pub appimage_type: AppImageType,
|
||||
pub size_bytes: u64,
|
||||
pub modified_time: Option<SystemTime>,
|
||||
pub is_executable: bool,
|
||||
}
|
||||
|
||||
/// Expand ~ to home directory.
|
||||
pub fn expand_tilde(path: &str) -> PathBuf {
|
||||
if let Some(rest) = path.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(rest);
|
||||
}
|
||||
}
|
||||
if path == "~" {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home;
|
||||
}
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
/// Check a single file for AppImage magic bytes.
|
||||
/// ELF magic at offset 0: 0x7F 'E' 'L' 'F'
|
||||
/// AppImage Type 2 at offset 8: 'A' 'I' 0x02
|
||||
/// AppImage Type 1 at offset 8: 'A' 'I' 0x01
|
||||
fn detect_appimage(path: &Path) -> Option<AppImageType> {
|
||||
let mut file = File::open(path).ok()?;
|
||||
let mut header = [0u8; 16];
|
||||
file.read_exact(&mut header).ok()?;
|
||||
|
||||
// Check ELF magic
|
||||
if header[0..4] != [0x7F, 0x45, 0x4C, 0x46] {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check AppImage magic at offset 8
|
||||
if header[8] == 0x41 && header[9] == 0x49 {
|
||||
match header[10] {
|
||||
0x02 => return Some(AppImageType::Type2),
|
||||
0x01 => return Some(AppImageType::Type1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Scan a single directory for AppImage files (non-recursive).
|
||||
fn scan_directory(dir: &Path) -> Vec<DiscoveredAppImage> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
log::warn!("Cannot read directory {}: {}", dir.display(), e);
|
||||
return results;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
// Skip directories and symlinks to directories
|
||||
if path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip very small files (AppImages are at least a few KB)
|
||||
let metadata = match fs::metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if metadata.len() < 4096 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for AppImage magic bytes
|
||||
if let Some(appimage_type) = detect_appimage(&path) {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
let is_executable = metadata.permissions().mode() & 0o111 != 0;
|
||||
let modified_time = metadata.modified().ok();
|
||||
|
||||
results.push(DiscoveredAppImage {
|
||||
path,
|
||||
filename,
|
||||
appimage_type,
|
||||
size_bytes: metadata.len(),
|
||||
modified_time,
|
||||
is_executable,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Scan all configured directories for AppImages.
|
||||
/// Directories are expanded (~ -> home) and deduplicated.
|
||||
pub fn scan_directories(dirs: &[String]) -> Vec<DiscoveredAppImage> {
|
||||
let mut results = Vec::new();
|
||||
let mut seen_paths = std::collections::HashSet::new();
|
||||
|
||||
for dir_str in dirs {
|
||||
let dir = expand_tilde(dir_str);
|
||||
if !dir.exists() {
|
||||
log::info!("Scan directory does not exist: {}", dir.display());
|
||||
continue;
|
||||
}
|
||||
if !dir.is_dir() {
|
||||
log::warn!("Scan path is not a directory: {}", dir.display());
|
||||
continue;
|
||||
}
|
||||
|
||||
for discovered in scan_directory(&dir) {
|
||||
// Deduplicate by canonical path
|
||||
let canonical = discovered.path.canonicalize()
|
||||
.unwrap_or_else(|_| discovered.path.clone());
|
||||
if seen_paths.insert(canonical) {
|
||||
results.push(discovered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn create_fake_appimage(dir: &Path, name: &str, appimage_type: u8) -> PathBuf {
|
||||
let path = dir.join(name);
|
||||
let mut f = File::create(&path).unwrap();
|
||||
|
||||
// ELF magic
|
||||
f.write_all(&[0x7F, 0x45, 0x4C, 0x46]).unwrap();
|
||||
// ELF class, data, version, OS/ABI (padding to offset 8)
|
||||
f.write_all(&[0x02, 0x01, 0x01, 0x00]).unwrap();
|
||||
// AppImage magic at offset 8
|
||||
f.write_all(&[0x41, 0x49, appimage_type]).unwrap();
|
||||
// Pad to make it bigger than 4096 bytes
|
||||
f.write_all(&vec![0u8; 8192]).unwrap();
|
||||
|
||||
// Make executable
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_type2() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = create_fake_appimage(dir.path(), "test.AppImage", 0x02);
|
||||
assert_eq!(detect_appimage(&path), Some(AppImageType::Type2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_type1() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = create_fake_appimage(dir.path(), "test.AppImage", 0x01);
|
||||
assert_eq!(detect_appimage(&path), Some(AppImageType::Type1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_not_appimage() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("not_appimage");
|
||||
let mut f = File::create(&path).unwrap();
|
||||
f.write_all(&[0x7F, 0x45, 0x4C, 0x46, 0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00]).unwrap();
|
||||
f.write_all(&vec![0u8; 8192]).unwrap();
|
||||
assert_eq!(detect_appimage(&path), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_non_elf() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("text.txt");
|
||||
let mut f = File::create(&path).unwrap();
|
||||
f.write_all(b"Hello world, this is not an ELF file at all").unwrap();
|
||||
f.write_all(&vec![0u8; 8192]).unwrap();
|
||||
assert_eq!(detect_appimage(&path), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
create_fake_appimage(dir.path(), "app1.AppImage", 0x02);
|
||||
create_fake_appimage(dir.path(), "app2.AppImage", 0x02);
|
||||
// Create a non-AppImage file
|
||||
let non_ai = dir.path().join("readme.txt");
|
||||
fs::write(&non_ai, &vec![0u8; 8192]).unwrap();
|
||||
|
||||
let results = scan_directory(dir.path());
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results.iter().all(|r| r.appimage_type == AppImageType::Type2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_tilde() {
|
||||
let expanded = expand_tilde("~/Applications");
|
||||
assert!(!expanded.to_string_lossy().starts_with('~'));
|
||||
assert!(expanded.to_string_lossy().ends_with("Applications"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user