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 for InspectorError { fn from(e: std::io::Error) -> Self { Self::IoError(e) } } #[derive(Debug, Clone, Default)] pub struct AppImageMetadata { pub app_name: Option, pub app_version: Option, pub description: Option, pub developer: Option, pub icon_name: Option, pub categories: Vec, pub desktop_entry_content: String, pub architecture: Option, pub cached_icon_path: Option, } #[derive(Debug, Default)] struct DesktopEntryFields { name: Option, icon: Option, comment: Option, categories: Vec, exec: Option, version: Option, } 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 { 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::() .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 { 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 { // 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 { 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 { 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 { // 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 { 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 { 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::() .trim_matches('-') .to_string() } /// Inspect an AppImage and extract its metadata. pub fn inspect_appimage( path: &Path, appimage_type: &AppImageType, ) -> Result { 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())); } }