890 lines
28 KiB
Rust
890 lines
28 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>,
|
|
}
|
|
|
|
#[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>,
|
|
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()),
|
|
"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(),
|
|
})
|
|
}
|
|
|
|
#[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()));
|
|
}
|
|
}
|