Files
driftwood/src/core/inspector.rs

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