Files
driftwood/src/core/inspector.rs
lashman fa28955919 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.
2026-02-26 23:04:27 +02:00

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()));
}
}