Files
driftwood/src/core/inspector.rs
lashman 97c7250666 Extract and apply StartupWMClass for proper taskbar icons
Parse StartupWMClass from embedded .desktop entries during analysis,
store in DB, include in generated .desktop files. Detail view shows
an editable WM class field with apply button for manual override.
2026-02-28 00:02:44 +02:00

894 lines
29 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>,
// Extended metadata from AppStream XML and desktop entry
pub appstream_id: Option<String>,
pub appstream_description: Option<String>,
pub generic_name: Option<String>,
pub license: Option<String>,
pub homepage_url: Option<String>,
pub bugtracker_url: Option<String>,
pub donation_url: Option<String>,
pub help_url: Option<String>,
pub vcs_url: Option<String>,
pub keywords: Vec<String>,
pub mime_types: Vec<String>,
pub content_rating: Option<String>,
pub project_group: Option<String>,
pub releases: Vec<crate::core::appstream::ReleaseInfo>,
pub desktop_actions: Vec<String>,
pub has_signature: bool,
pub screenshot_urls: Vec<String>,
pub startup_wm_class: Option<String>,
}
#[derive(Debug, Default)]
struct DesktopEntryFields {
name: Option<String>,
icon: Option<String>,
comment: Option<String>,
categories: Vec<String>,
exec: Option<String>,
version: Option<String>,
generic_name: Option<String>,
keywords: Vec<String>,
mime_types: Vec<String>,
terminal: bool,
x_appimage_name: Option<String>,
startup_wm_class: Option<String>,
actions: Vec<String>,
}
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<u64> {
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<u64, InspectorError> {
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<u64, InspectorError> {
// 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::<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("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<PathBuf> {
// 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<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()?;
// 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<PathBuf> {
// 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<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
}
/// Find an AppStream metainfo XML file in the extract directory.
fn find_appstream_file(extract_dir: &Path) -> Option<PathBuf> {
// 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<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()
}
/// 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<PathBuf> {
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<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);
// 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()));
}
}