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.
894 lines
29 KiB
Rust
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()));
|
|
}
|
|
}
|