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.
497 lines
15 KiB
Rust
497 lines
15 KiB
Rust
use std::fs;
|
|
use std::io::Read;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
use super::discovery::AppImageType;
|
|
|
|
#[derive(Debug)]
|
|
pub enum InspectorError {
|
|
IoError(std::io::Error),
|
|
NoOffset,
|
|
UnsquashfsNotFound,
|
|
UnsquashfsFailed(String),
|
|
NoDesktopEntry,
|
|
}
|
|
|
|
impl std::fmt::Display for InspectorError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
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::UnsquashfsFailed(msg) => write!(f, "unsquashfs failed: {}", msg),
|
|
Self::NoDesktopEntry => write!(f, "No .desktop file found in AppImage"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<std::io::Error> for InspectorError {
|
|
fn from(e: std::io::Error) -> Self {
|
|
Self::IoError(e)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct AppImageMetadata {
|
|
pub app_name: Option<String>,
|
|
pub app_version: Option<String>,
|
|
pub description: Option<String>,
|
|
pub developer: Option<String>,
|
|
pub icon_name: Option<String>,
|
|
pub categories: Vec<String>,
|
|
pub desktop_entry_content: String,
|
|
pub architecture: Option<String>,
|
|
pub cached_icon_path: Option<PathBuf>,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct DesktopEntryFields {
|
|
name: Option<String>,
|
|
icon: Option<String>,
|
|
comment: Option<String>,
|
|
categories: Vec<String>,
|
|
exec: Option<String>,
|
|
version: Option<String>,
|
|
}
|
|
|
|
fn icons_cache_dir() -> PathBuf {
|
|
let dir = dirs::data_dir()
|
|
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
|
.join("driftwood")
|
|
.join("icons");
|
|
fs::create_dir_all(&dir).ok();
|
|
dir
|
|
}
|
|
|
|
/// Check if unsquashfs is available.
|
|
fn has_unsquashfs() -> bool {
|
|
Command::new("unsquashfs")
|
|
.arg("--help")
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status()
|
|
.is_ok()
|
|
}
|
|
|
|
/// Get the squashfs offset from the AppImage by running it with --appimage-offset.
|
|
fn get_squashfs_offset(path: &Path) -> Result<u64, InspectorError> {
|
|
let output = Command::new(path)
|
|
.arg("--appimage-offset")
|
|
.env("APPIMAGE_EXTRACT_AND_RUN", "0")
|
|
.output()?;
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
stdout
|
|
.trim()
|
|
.parse::<u64>()
|
|
.map_err(|_| InspectorError::NoOffset)
|
|
}
|
|
|
|
/// Extract specific files from the AppImage squashfs into a temp directory.
|
|
fn extract_metadata_files(
|
|
appimage_path: &Path,
|
|
offset: u64,
|
|
dest: &Path,
|
|
) -> Result<(), InspectorError> {
|
|
let status = Command::new("unsquashfs")
|
|
.arg("-offset")
|
|
.arg(offset.to_string())
|
|
.arg("-no-progress")
|
|
.arg("-force")
|
|
.arg("-dest")
|
|
.arg(dest)
|
|
.arg(appimage_path)
|
|
.arg("*.desktop")
|
|
.arg(".DirIcon")
|
|
.arg("usr/share/icons/*")
|
|
.arg("usr/share/metainfo/*.xml")
|
|
.arg("usr/share/appdata/*.xml")
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::piped())
|
|
.status();
|
|
|
|
match status {
|
|
Ok(s) if s.success() => Ok(()),
|
|
Ok(s) => Err(InspectorError::UnsquashfsFailed(
|
|
format!("exit code {}", s.code().unwrap_or(-1)),
|
|
)),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
Err(InspectorError::UnsquashfsNotFound)
|
|
}
|
|
Err(e) => Err(InspectorError::IoError(e)),
|
|
}
|
|
}
|
|
|
|
/// Try extraction without offset (for cases where --appimage-offset fails).
|
|
fn extract_metadata_files_direct(
|
|
appimage_path: &Path,
|
|
dest: &Path,
|
|
) -> Result<(), InspectorError> {
|
|
let status = Command::new("unsquashfs")
|
|
.arg("-no-progress")
|
|
.arg("-force")
|
|
.arg("-dest")
|
|
.arg(dest)
|
|
.arg(appimage_path)
|
|
.arg("*.desktop")
|
|
.arg(".DirIcon")
|
|
.arg("usr/share/icons/*")
|
|
.arg("usr/share/metainfo/*.xml")
|
|
.arg("usr/share/appdata/*.xml")
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status();
|
|
|
|
match status {
|
|
Ok(s) if s.success() => Ok(()),
|
|
Ok(_) => Err(InspectorError::UnsquashfsFailed(
|
|
"direct extraction failed".into(),
|
|
)),
|
|
Err(e) => Err(InspectorError::IoError(e)),
|
|
}
|
|
}
|
|
|
|
/// Find the first .desktop file in a directory.
|
|
fn find_desktop_file(dir: &Path) -> Option<PathBuf> {
|
|
if let Ok(entries) = fs::read_dir(dir) {
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.extension().and_then(|e| e.to_str()) == Some("desktop") {
|
|
return Some(path);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Parse a .desktop file into structured fields.
|
|
fn parse_desktop_entry(content: &str) -> DesktopEntryFields {
|
|
let mut fields = DesktopEntryFields::default();
|
|
let mut in_section = false;
|
|
|
|
for line in content.lines() {
|
|
let line = line.trim();
|
|
if line == "[Desktop Entry]" {
|
|
in_section = true;
|
|
continue;
|
|
}
|
|
if line.starts_with('[') {
|
|
in_section = false;
|
|
continue;
|
|
}
|
|
if !in_section {
|
|
continue;
|
|
}
|
|
if let Some((key, value)) = line.split_once('=') {
|
|
let key = key.trim();
|
|
let value = value.trim();
|
|
match key {
|
|
"Name" => fields.name = Some(value.to_string()),
|
|
"Icon" => fields.icon = Some(value.to_string()),
|
|
"Comment" => fields.comment = Some(value.to_string()),
|
|
"Categories" => {
|
|
fields.categories = value
|
|
.split(';')
|
|
.filter(|s| !s.is_empty())
|
|
.map(String::from)
|
|
.collect();
|
|
}
|
|
"Exec" => fields.exec = Some(value.to_string()),
|
|
"X-AppImage-Version" => fields.version = Some(value.to_string()),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fields
|
|
}
|
|
|
|
/// Try to extract a version from the filename.
|
|
/// Common patterns: App-1.2.3-x86_64.AppImage, App_v1.2.3.AppImage
|
|
fn extract_version_from_filename(filename: &str) -> Option<String> {
|
|
// Strip .AppImage extension
|
|
let stem = filename.strip_suffix(".AppImage")
|
|
.or_else(|| filename.strip_suffix(".appimage"))
|
|
.unwrap_or(filename);
|
|
|
|
// Look for version-like patterns: digits.digits or digits.digits.digits
|
|
let re_like = |s: &str| -> Option<String> {
|
|
let mut best: Option<(usize, &str)> = None;
|
|
for (i, _) in s.match_indices(|c: char| c.is_ascii_digit()) {
|
|
// Walk back to find start of version (might have leading 'v')
|
|
let start = if i > 0 && s.as_bytes()[i - 1] == b'v' {
|
|
i - 1
|
|
} else {
|
|
i
|
|
};
|
|
|
|
// Walk forward to consume version string
|
|
let rest = &s[i..];
|
|
let end = rest
|
|
.find(|c: char| !c.is_ascii_digit() && c != '.')
|
|
.unwrap_or(rest.len());
|
|
let candidate = &rest[..end];
|
|
|
|
// Must contain at least one dot (to be a version, not just a number)
|
|
if candidate.contains('.') && candidate.len() > 2 {
|
|
let full = &s[start..i + end];
|
|
if best.is_none() || full.len() > best.unwrap().1.len() {
|
|
best = Some((start, full));
|
|
}
|
|
}
|
|
}
|
|
best.map(|(_, v)| v.to_string())
|
|
};
|
|
|
|
re_like(stem)
|
|
}
|
|
|
|
/// Read the ELF architecture from the header.
|
|
fn detect_architecture(path: &Path) -> Option<String> {
|
|
let mut file = fs::File::open(path).ok()?;
|
|
let mut header = [0u8; 20];
|
|
file.read_exact(&mut header).ok()?;
|
|
|
|
// ELF e_machine at offset 18 (little-endian)
|
|
let machine = u16::from_le_bytes([header[18], header[19]]);
|
|
match machine {
|
|
0x03 => Some("i386".to_string()),
|
|
0x3E => Some("x86_64".to_string()),
|
|
0xB7 => Some("aarch64".to_string()),
|
|
0x28 => Some("armhf".to_string()),
|
|
_ => Some(format!("unknown(0x{:02X})", machine)),
|
|
}
|
|
}
|
|
|
|
/// Find an icon file in the extracted squashfs directory.
|
|
fn find_icon(extract_dir: &Path, icon_name: Option<&str>) -> Option<PathBuf> {
|
|
// First try .DirIcon
|
|
let dir_icon = extract_dir.join(".DirIcon");
|
|
if dir_icon.exists() {
|
|
return Some(dir_icon);
|
|
}
|
|
|
|
// Try icon by name from .desktop
|
|
if let Some(name) = icon_name {
|
|
// Check root of extract dir
|
|
for ext in &["png", "svg", "xpm"] {
|
|
let candidate = extract_dir.join(format!("{}.{}", name, ext));
|
|
if candidate.exists() {
|
|
return Some(candidate);
|
|
}
|
|
}
|
|
|
|
// Check usr/share/icons recursively
|
|
let icons_dir = extract_dir.join("usr/share/icons");
|
|
if icons_dir.exists() {
|
|
if let Some(found) = find_icon_recursive(&icons_dir, name) {
|
|
return Some(found);
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn find_icon_recursive(dir: &Path, name: &str) -> Option<PathBuf> {
|
|
let entries = fs::read_dir(dir).ok()?;
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
if let Some(found) = find_icon_recursive(&path, name) {
|
|
return Some(found);
|
|
}
|
|
} else {
|
|
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
|
if stem == name {
|
|
return Some(path);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Cache an icon file to the driftwood icons directory.
|
|
fn cache_icon(source: &Path, app_id: &str) -> Option<PathBuf> {
|
|
let ext = source
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("png");
|
|
let dest = icons_cache_dir().join(format!("{}.{}", app_id, ext));
|
|
fs::copy(source, &dest).ok()?;
|
|
Some(dest)
|
|
}
|
|
|
|
/// Make a filesystem-safe app ID from a name.
|
|
fn make_app_id(name: &str) -> String {
|
|
name.chars()
|
|
.map(|c| {
|
|
if c.is_alphanumeric() || c == '-' || c == '_' {
|
|
c.to_ascii_lowercase()
|
|
} else {
|
|
'-'
|
|
}
|
|
})
|
|
.collect::<String>()
|
|
.trim_matches('-')
|
|
.to_string()
|
|
}
|
|
|
|
/// Inspect an AppImage and extract its metadata.
|
|
pub fn inspect_appimage(
|
|
path: &Path,
|
|
appimage_type: &AppImageType,
|
|
) -> Result<AppImageMetadata, InspectorError> {
|
|
if !has_unsquashfs() {
|
|
return Err(InspectorError::UnsquashfsNotFound);
|
|
}
|
|
|
|
let temp_dir = tempfile::tempdir()?;
|
|
let extract_dir = temp_dir.path().join("squashfs-root");
|
|
|
|
// Try to extract metadata files
|
|
let extracted = match appimage_type {
|
|
AppImageType::Type2 => {
|
|
match get_squashfs_offset(path) {
|
|
Ok(offset) => extract_metadata_files(path, offset, &extract_dir),
|
|
Err(_) => {
|
|
log::warn!(
|
|
"Could not get offset for {}, trying direct extraction",
|
|
path.display()
|
|
);
|
|
extract_metadata_files_direct(path, &extract_dir)
|
|
}
|
|
}
|
|
}
|
|
AppImageType::Type1 => extract_metadata_files_direct(path, &extract_dir),
|
|
};
|
|
|
|
if let Err(e) = extracted {
|
|
log::warn!("Extraction failed for {}: {}", path.display(), e);
|
|
// Return minimal metadata from filename/ELF
|
|
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
|
return Ok(AppImageMetadata {
|
|
app_name: Some(
|
|
filename
|
|
.strip_suffix(".AppImage")
|
|
.or_else(|| filename.strip_suffix(".appimage"))
|
|
.unwrap_or(filename)
|
|
.split(|c: char| c == '-' || c == '_')
|
|
.next()
|
|
.unwrap_or(filename)
|
|
.to_string(),
|
|
),
|
|
app_version: extract_version_from_filename(filename),
|
|
architecture: detect_architecture(path),
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
// Find and parse .desktop file
|
|
let desktop_path = find_desktop_file(&extract_dir)
|
|
.ok_or(InspectorError::NoDesktopEntry)?;
|
|
let desktop_content = fs::read_to_string(&desktop_path)?;
|
|
let fields = parse_desktop_entry(&desktop_content);
|
|
|
|
// Determine version (desktop entry > filename heuristic)
|
|
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
|
let version = fields
|
|
.version
|
|
.or_else(|| extract_version_from_filename(filename));
|
|
|
|
// Find and cache icon
|
|
let icon = find_icon(&extract_dir, fields.icon.as_deref());
|
|
let app_id = make_app_id(
|
|
fields.name.as_deref().unwrap_or(
|
|
filename
|
|
.strip_suffix(".AppImage")
|
|
.unwrap_or(filename),
|
|
),
|
|
);
|
|
let cached_icon = icon.and_then(|icon_path| cache_icon(&icon_path, &app_id));
|
|
|
|
Ok(AppImageMetadata {
|
|
app_name: fields.name,
|
|
app_version: version,
|
|
description: fields.comment,
|
|
developer: None,
|
|
icon_name: fields.icon,
|
|
categories: fields.categories,
|
|
desktop_entry_content: desktop_content,
|
|
architecture: detect_architecture(path),
|
|
cached_icon_path: cached_icon,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_desktop_entry() {
|
|
let content = "[Desktop Entry]
|
|
Type=Application
|
|
Name=Test App
|
|
Icon=test-icon
|
|
Comment=A test application
|
|
Categories=Utility;Development;
|
|
Exec=test %U
|
|
X-AppImage-Version=1.2.3
|
|
|
|
[Desktop Action New]
|
|
Name=New Window
|
|
";
|
|
let fields = parse_desktop_entry(content);
|
|
assert_eq!(fields.name.as_deref(), Some("Test App"));
|
|
assert_eq!(fields.icon.as_deref(), Some("test-icon"));
|
|
assert_eq!(fields.comment.as_deref(), Some("A test application"));
|
|
assert_eq!(fields.categories, vec!["Utility", "Development"]);
|
|
assert_eq!(fields.exec.as_deref(), Some("test %U"));
|
|
assert_eq!(fields.version.as_deref(), Some("1.2.3"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_version_from_filename() {
|
|
assert_eq!(
|
|
extract_version_from_filename("Firefox-124.0.1-x86_64.AppImage"),
|
|
Some("124.0.1".to_string())
|
|
);
|
|
assert_eq!(
|
|
extract_version_from_filename("Kdenlive-24.02.1-x86_64.AppImage"),
|
|
Some("24.02.1".to_string())
|
|
);
|
|
assert_eq!(
|
|
extract_version_from_filename("SimpleApp.AppImage"),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
extract_version_from_filename("App_v2.0.0.AppImage"),
|
|
Some("v2.0.0".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_make_app_id() {
|
|
assert_eq!(make_app_id("Firefox"), "firefox");
|
|
assert_eq!(make_app_id("My Cool App"), "my-cool-app");
|
|
assert_eq!(make_app_id("App 2.0"), "app-2-0");
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_architecture() {
|
|
// Create a minimal ELF header for x86_64
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("test_elf");
|
|
let mut header = vec![0u8; 20];
|
|
// ELF magic
|
|
header[0..4].copy_from_slice(&[0x7F, 0x45, 0x4C, 0x46]);
|
|
// e_machine = 0x3E (x86_64) at offset 18, little-endian
|
|
header[18] = 0x3E;
|
|
header[19] = 0x00;
|
|
fs::write(&path, &header).unwrap();
|
|
|
|
assert_eq!(detect_architecture(&path), Some("x86_64".to_string()));
|
|
}
|
|
}
|