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, // Extended metadata from AppStream XML and desktop entry pub appstream_id: Option, pub appstream_description: Option, pub generic_name: Option, pub license: Option, pub homepage_url: Option, pub bugtracker_url: Option, pub donation_url: Option, pub help_url: Option, pub vcs_url: Option, pub keywords: Vec, pub mime_types: Vec, pub content_rating: Option, pub project_group: Option, pub releases: Vec, pub desktop_actions: Vec, pub has_signature: bool, pub screenshot_urls: Vec, pub startup_wm_class: Option, } #[derive(Debug, Default)] struct DesktopEntryFields { name: Option, icon: Option, comment: Option, categories: Vec, exec: Option, version: Option, generic_name: Option, keywords: Vec, mime_types: Vec, terminal: bool, x_appimage_name: Option, startup_wm_class: Option, actions: Vec, } fn icons_cache_dir() -> PathBuf { let dir = crate::config::data_dir_fallback() .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() } /// Public wrapper for binary squashfs offset detection. /// Used by other modules (e.g. wayland) to avoid executing the AppImage. pub fn find_squashfs_offset_for(path: &Path) -> Option { find_squashfs_offset(path).ok() } /// Find the squashfs offset by scanning for a valid superblock in the binary. /// This avoids executing the AppImage, which can hang for apps with custom AppRun scripts. /// Uses buffered chunk-based reading to avoid loading entire files into memory /// (critical for large AppImages like Affinity at 1.5GB+). fn find_squashfs_offset(path: &Path) -> Result { use std::io::{BufReader, Seek, SeekFrom}; let file = fs::File::open(path)?; let file_len = file.metadata()?.len(); let mut reader = BufReader::with_capacity(256 * 1024, file); // Skip first 4KB to avoid false matches in ELF header let start: u64 = 4096.min(file_len); reader.seek(SeekFrom::Start(start))?; // Read in 256KB chunks with 96-byte overlap to catch magic spanning boundaries let chunk_size: usize = 256 * 1024; let overlap: usize = 96; let mut buf = vec![0u8; chunk_size]; let mut file_pos = start; loop { if file_pos >= file_len { break; } let to_read = chunk_size.min((file_len - file_pos) as usize); let mut total_read = 0; while total_read < to_read { let n = Read::read(&mut reader, &mut buf[total_read..to_read])?; if n == 0 { break; } total_read += n; } if total_read < 32 { break; } // Scan this chunk for squashfs magic let scan_end = total_read.saturating_sub(31); for i in 0..scan_end { if buf[i..i + 4] == *b"hsqs" { let major = u16::from_le_bytes([buf[i + 28], buf[i + 29]]); let minor = u16::from_le_bytes([buf[i + 30], buf[i + 31]]); if major == 4 && minor == 0 { let block_size = u32::from_le_bytes([ buf[i + 12], buf[i + 13], buf[i + 14], buf[i + 15], ]); if block_size.is_power_of_two() && block_size >= 4096 && block_size <= 1_048_576 { return Ok(file_pos + i as u64); } } } } // Advance, keeping overlap to catch magic spanning chunks let advance = if total_read > overlap { total_read - overlap } else { total_read }; file_pos += advance as u64; reader.seek(SeekFrom::Start(file_pos))?; } Err(InspectorError::NoOffset) } /// Get the squashfs offset from the AppImage by running it with --appimage-offset. /// Falls back to binary scanning if execution times out or fails. fn get_squashfs_offset(path: &Path) -> Result { // First try the fast binary scan approach (no execution needed) if let Ok(offset) = find_squashfs_offset(path) { return Ok(offset); } // Fallback: run the AppImage with a timeout let child = Command::new(path) .arg("--appimage-offset") .env("APPIMAGE_EXTRACT_AND_RUN", "0") .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn(); let mut child = match child { Ok(c) => c, Err(e) => return Err(InspectorError::IoError(e)), }; // Wait up to 5 seconds let start = std::time::Instant::now(); loop { match child.try_wait() { Ok(Some(_)) => break, Ok(None) => { if start.elapsed() > std::time::Duration::from_secs(5) { let _ = child.kill(); let _ = child.wait(); return Err(InspectorError::NoOffset); } std::thread::sleep(std::time::Duration::from_millis(50)); } Err(e) => return Err(InspectorError::IoError(e)), } } let output = child.wait_with_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("usr/share/applications/*.desktop") .arg(".DirIcon") .arg("*.png") .arg("*.svg") .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(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("usr/share/applications/*.desktop") .arg(".DirIcon") .arg("*.png") .arg("*.svg") .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 the extract directory. /// Checks root level first, then usr/share/applications/. fn find_desktop_file(dir: &Path) -> Option { // Check root of extract dir 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); } } } // Check usr/share/applications/ let apps_dir = dir.join("usr/share/applications"); if let Ok(entries) = fs::read_dir(&apps_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()), "GenericName" => fields.generic_name = Some(value.to_string()), "Keywords" => { fields.keywords = value .split(';') .filter(|s| !s.is_empty()) .map(String::from) .collect(); } "MimeType" => { fields.mime_types = value .split(';') .filter(|s| !s.is_empty()) .map(String::from) .collect(); } "Terminal" => fields.terminal = value == "true", "X-AppImage-Name" => fields.x_appimage_name = Some(value.to_string()), "StartupWMClass" => fields.startup_wm_class = Some(value.to_string()), "Actions" => { fields.actions = value .split(';') .filter(|s| !s.is_empty()) .map(String::from) .collect(); } _ => {} } } } 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()?; // Validate ELF magic if &header[0..4] != b"\x7FELF" { return None; } // ELF e_machine at offset 18, endianness from byte 5 let machine = if header[5] == 2 { // Big-endian u16::from_be_bytes([header[18], header[19]]) } else { // Little-endian (default) 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 (skip if it's a broken symlink) let dir_icon = extract_dir.join(".DirIcon"); if dir_icon.exists() && dir_icon.metadata().is_ok() { 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 (prefer largest resolution) 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); } } } // Fallback: grab any .png or .svg at the root level if let Ok(entries) = fs::read_dir(extract_dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_file() { let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); if ext == "png" || ext == "svg" { return Some(path); } } } } 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 } /// Find an AppStream metainfo XML file in the extract directory. fn find_appstream_file(extract_dir: &Path) -> Option { // Check modern path first let metainfo_dir = extract_dir.join("usr/share/metainfo"); if let Ok(entries) = fs::read_dir(&metainfo_dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("xml") { return Some(path); } } } // Check legacy path let appdata_dir = extract_dir.join("usr/share/appdata"); if let Ok(entries) = fs::read_dir(&appdata_dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("xml") { return Some(path); } } } None } /// Check if an AppImage has a GPG signature by looking for the .sha256_sig section name. fn detect_signature(path: &Path) -> bool { use std::io::{BufReader, Read}; let file = match fs::File::open(path) { Ok(f) => f, Err(_) => return false, }; let needle = b".sha256_sig"; let mut reader = BufReader::new(file); let mut buf = vec![0u8; 64 * 1024]; let mut carry = Vec::new(); loop { let n = match reader.read(&mut buf) { Ok(0) => break, Ok(n) => n, Err(_) => break, }; // Prepend carry bytes from previous chunk to handle needle spanning chunks let search_buf = if carry.is_empty() { &buf[..n] } else { carry.extend_from_slice(&buf[..n]); carry.as_slice() }; if search_buf.windows(needle.len()).any(|w| w == needle) { return true; } // Keep the last (needle.len - 1) bytes as carry for the next iteration let keep = needle.len() - 1; carry.clear(); if n >= keep { carry.extend_from_slice(&buf[n - keep..n]); } else { carry.extend_from_slice(&buf[..n]); } } false } /// 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() } /// Quickly extract just the icon from an AppImage (for preview). /// Only extracts .DirIcon and root-level .png/.svg files. /// Returns the path to the cached icon if successful. pub fn extract_icon_fast(appimage_path: &Path) -> Option { if !has_unsquashfs() { return None; } let offset = find_squashfs_offset(appimage_path).ok()?; let tmp = tempfile::tempdir().ok()?; let dest = tmp.path().join("icon_extract"); let status = Command::new("unsquashfs") .arg("-offset") .arg(offset.to_string()) .arg("-no-progress") .arg("-force") .arg("-dest") .arg(&dest) .arg(appimage_path) .arg(".DirIcon") .arg("*.png") .arg("*.svg") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .ok()?; if !status.success() { return None; } let icon_path = find_icon(&dest, None)?; // Generate app_id from filename let stem = appimage_path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown"); let app_id = make_app_id(stem); cache_icon(&icon_path, &app_id) } /// 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); // Parse AppStream metainfo XML if available let appstream = find_appstream_file(&extract_dir) .and_then(|p| crate::core::appstream::parse_appstream_file(&p)); // Merge: AppStream takes priority for overlapping fields let final_name = appstream .as_ref() .and_then(|a| a.name.clone()) .or(fields.name); let final_description = appstream .as_ref() .and_then(|a| a.description.clone()) .or(appstream.as_ref().and_then(|a| a.summary.clone())) .or(fields.comment); let final_developer = appstream.as_ref().and_then(|a| a.developer.clone()); let final_categories = if let Some(ref a) = appstream { if !a.categories.is_empty() { a.categories.clone() } else { fields.categories } } else { fields.categories }; // 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( final_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)); // Merge keywords from both sources let mut all_keywords = fields.keywords; if let Some(ref a) = appstream { for kw in &a.keywords { if !all_keywords.contains(kw) { all_keywords.push(kw.clone()); } } } // Merge MIME types from both sources let mut all_mime_types = fields.mime_types; if let Some(ref a) = appstream { for mt in &a.mime_types { if !all_mime_types.contains(mt) { all_mime_types.push(mt.clone()); } } } let has_sig = detect_signature(path); Ok(AppImageMetadata { app_name: final_name, app_version: version, description: final_description, developer: final_developer, icon_name: fields.icon, categories: final_categories, desktop_entry_content: desktop_content, architecture: detect_architecture(path), cached_icon_path: cached_icon, appstream_id: appstream.as_ref().and_then(|a| a.id.clone()), appstream_description: appstream.as_ref().and_then(|a| a.description.clone()), generic_name: fields .generic_name .or_else(|| appstream.as_ref().and_then(|a| a.summary.clone())), license: appstream.as_ref().and_then(|a| a.project_license.clone()), homepage_url: appstream .as_ref() .and_then(|a| a.urls.get("homepage").cloned()), bugtracker_url: appstream .as_ref() .and_then(|a| a.urls.get("bugtracker").cloned()), donation_url: appstream .as_ref() .and_then(|a| a.urls.get("donation").cloned()), help_url: appstream .as_ref() .and_then(|a| a.urls.get("help").cloned()), vcs_url: appstream .as_ref() .and_then(|a| a.urls.get("vcs-browser").cloned()), keywords: all_keywords, mime_types: all_mime_types, content_rating: appstream .as_ref() .and_then(|a| a.content_rating_summary.clone()), project_group: appstream.as_ref().and_then(|a| a.project_group.clone()), releases: appstream .as_ref() .map(|a| a.releases.clone()) .unwrap_or_default(), desktop_actions: fields.actions, has_signature: has_sig, screenshot_urls: appstream .as_ref() .map(|a| a.screenshot_urls.clone()) .unwrap_or_default(), startup_wm_class: fields.startup_wm_class, }) } #[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())); } }