Files
driftwood/src/core/updater.rs
lashman d493516efa Add launch crash detection with detailed error dialog, fix all warnings
Detect AppImages that crash immediately after spawning (within 1.5s) by
capturing stderr and using try_wait(). Show a full AlertDialog with a
plain-text explanation, scrollable error output, and a copy-to-clipboard
button. Covers Qt plugin errors, missing libraries, segfaults, permission
issues, and display connection failures.

Move launch operations to background threads in both the detail view and
context menu to avoid blocking the UI during the 1.5s crash detection
window.

Suppress all 57 compiler warnings across future-use modules (backup,
notification, report, watcher) and individual unused fields/variants in
other core modules.
2026-02-27 20:23:10 +02:00

1124 lines
35 KiB
Rust

use std::fs;
use std::io::Read;
use std::path::Path;
use serde::Deserialize;
/// Types of update information embedded in AppImages.
#[derive(Debug, Clone, PartialEq)]
pub enum UpdateType {
/// Direct zsync URL
Zsync { url: String },
/// GitHub Releases with zsync pattern
GhReleasesZsync {
owner: String,
repo: String,
release_tag: String,
filename_pattern: String,
},
/// GitLab Releases with zsync pattern
GlReleasesZsync {
host: String,
owner: String,
repo: String,
release_tag: String,
filename_pattern: String,
},
/// OCS (AppImageHub) direct
Ocs { url: String },
/// Bintray (deprecated)
Bintray { raw: String },
}
impl UpdateType {
pub fn type_label(&self) -> &'static str {
match self {
Self::Zsync { .. } => "zsync",
Self::GhReleasesZsync { .. } => "gh-releases-zsync",
Self::GlReleasesZsync { .. } => "gl-releases-zsync",
Self::Ocs { .. } => "ocs",
Self::Bintray { .. } => "bintray",
}
}
pub fn type_label_display(&self) -> &'static str {
match self {
Self::Zsync { .. } => "zsync (direct)",
Self::GhReleasesZsync { .. } => "GitHub Releases",
Self::GlReleasesZsync { .. } => "GitLab Releases",
Self::Ocs { .. } => "AppImageHub (OCS)",
Self::Bintray { .. } => "Bintray (deprecated)",
}
}
}
/// Result of checking for updates.
#[derive(Debug, Clone)]
pub struct UpdateCheckResult {
pub update_available: bool,
pub latest_version: Option<String>,
pub download_url: Option<String>,
pub release_notes: Option<String>,
pub file_size: Option<u64>,
}
/// Parse the raw update info string from an AppImage's ELF section.
pub fn parse_update_info(raw: &str) -> Option<UpdateType> {
let raw = raw.trim().trim_matches('\0');
if raw.is_empty() {
return None;
}
let parts: Vec<&str> = raw.split('|').collect();
if parts.is_empty() {
return None;
}
match parts[0] {
"zsync" if parts.len() >= 2 => {
Some(UpdateType::Zsync {
url: parts[1].to_string(),
})
}
"gh-releases-zsync" if parts.len() >= 5 => {
Some(UpdateType::GhReleasesZsync {
owner: parts[1].to_string(),
repo: parts[2].to_string(),
release_tag: parts[3].to_string(),
filename_pattern: parts[4].to_string(),
})
}
"gl-releases-zsync" if parts.len() >= 6 => {
Some(UpdateType::GlReleasesZsync {
host: parts[1].to_string(),
owner: parts[2].to_string(),
repo: parts[3].to_string(),
release_tag: parts[4].to_string(),
filename_pattern: parts[5].to_string(),
})
}
"ocs-v1-appimagehub-direct" if parts.len() >= 2 => {
Some(UpdateType::Ocs {
url: parts[1].to_string(),
})
}
"bintray-zsync" => {
Some(UpdateType::Bintray {
raw: raw.to_string(),
})
}
_ => {
log::warn!("Unknown update info format: {}", raw);
None
}
}
}
/// Read the .upd_info section from an AppImage's ELF binary.
/// The update info is stored as a null-terminated string in an ELF section
/// named ".upd_info" or ".updinfo". It can also be found at a fixed offset
/// in the AppImage runtime (bytes 0x414..0x614 in the ELF header area).
pub fn read_update_info(path: &Path) -> Option<String> {
// Only read the first 1MB - update info is always in the ELF header area,
// never deep in the squashfs payload. Avoids loading 1.5GB+ files into memory.
let mut file = fs::File::open(path).ok()?;
let file_len = file.metadata().ok()?.len() as usize;
let read_len = file_len.min(1024 * 1024);
let mut data = vec![0u8; read_len];
use std::io::Read;
file.read_exact(&mut data).ok()?;
// Method 1: Try to read from fixed offset range in AppImage Type 2 runtime.
// The update info is typically at offset 0xC48 (3144) in the ELF, but the
// exact offset varies. The standard range is after the ELF headers.
// The runtime stores it as a null-terminated ASCII string.
// Try the known range where AppImageKit stores update info.
if let Some(info) = extract_update_info_fixed_offset(&data) {
return Some(info);
}
// Method 2: Parse ELF section headers to find .upd_info section.
if let Some(info) = extract_update_info_elf_section(&data) {
return Some(info);
}
// Method 3: Run the AppImage with --appimage-updateinformation
if let Some(info) = extract_update_info_runtime(path) {
return Some(info);
}
None
}
/// Extract update info from fixed offset range in Type 2 AppImage runtime.
/// The runtime stores update info as a null-terminated string starting
/// at a well-known offset region.
fn extract_update_info_fixed_offset(data: &[u8]) -> Option<String> {
// AppImage Type 2 runtime stores update info in a fixed region.
// The region is typically 512 bytes starting at various offsets depending
// on the runtime version. Common offsets: 0xA48, 0xB48, 0xC48, 0xD48.
// We scan a broader range and look for the characteristic pipe-delimited format.
let scan_start = 0x800;
let scan_end = std::cmp::min(data.len(), 0x2000);
if scan_end <= scan_start {
return None;
}
let region = &data[scan_start..scan_end];
// Look for known update info prefixes in this region
for prefix in &[
b"zsync|" as &[u8],
b"gh-releases-zsync|",
b"gl-releases-zsync|",
b"ocs-v1-appimagehub-direct|",
b"bintray-zsync|",
] {
if let Some(pos) = find_bytes(region, prefix) {
let start = pos;
let rest = &region[start..];
// Read until null terminator or non-printable char
let end = rest
.iter()
.position(|&b| b == 0 || b < 0x20 || b > 0x7E)
.unwrap_or(rest.len());
if end > prefix.len() {
let s = String::from_utf8_lossy(&rest[..end]).to_string();
if !s.is_empty() {
return Some(s);
}
}
}
}
None
}
/// Find a byte sequence needle in haystack.
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|w| w == needle)
}
/// Extract update info by parsing ELF section headers.
fn extract_update_info_elf_section(data: &[u8]) -> Option<String> {
if data.len() < 64 {
return None;
}
// Verify ELF magic
if &data[0..4] != b"\x7FELF" {
return None;
}
let is_64bit = data[4] == 2;
if is_64bit {
parse_elf64_sections(data)
} else {
parse_elf32_sections(data)
}
}
fn parse_elf64_sections(data: &[u8]) -> Option<String> {
if data.len() < 64 {
return None;
}
let shoff = u64::from_le_bytes(data[40..48].try_into().ok()?) as usize;
let shentsize = u16::from_le_bytes(data[58..60].try_into().ok()?) as usize;
let shnum = u16::from_le_bytes(data[60..62].try_into().ok()?) as usize;
let shstrndx = u16::from_le_bytes(data[62..64].try_into().ok()?) as usize;
if shoff == 0 || shnum == 0 || shentsize < 64 {
return None;
}
// Get section header string table
let strtab_offset = shoff + shstrndx * shentsize;
if strtab_offset + shentsize > data.len() {
return None;
}
let strtab_sh_offset =
u64::from_le_bytes(data[strtab_offset + 24..strtab_offset + 32].try_into().ok()?) as usize;
let strtab_sh_size =
u64::from_le_bytes(data[strtab_offset + 32..strtab_offset + 40].try_into().ok()?) as usize;
if strtab_sh_offset + strtab_sh_size > data.len() {
return None;
}
let strtab = &data[strtab_sh_offset..strtab_sh_offset + strtab_sh_size];
// Search for .upd_info or .updinfo section
for i in 0..shnum {
let offset = shoff + i * shentsize;
if offset + shentsize > data.len() {
break;
}
let name_idx =
u32::from_le_bytes(data[offset..offset + 4].try_into().ok()?) as usize;
if name_idx >= strtab.len() {
continue;
}
let name_end = strtab[name_idx..]
.iter()
.position(|&b| b == 0)
.unwrap_or(0);
let name = std::str::from_utf8(&strtab[name_idx..name_idx + name_end]).ok()?;
if name == ".upd_info" || name == ".updinfo" {
let sec_offset =
u64::from_le_bytes(data[offset + 24..offset + 32].try_into().ok()?) as usize;
let sec_size =
u64::from_le_bytes(data[offset + 32..offset + 40].try_into().ok()?) as usize;
if sec_offset + sec_size <= data.len() && sec_size > 0 {
let section_data = &data[sec_offset..sec_offset + sec_size];
// Trim null bytes and whitespace
let end = section_data
.iter()
.position(|&b| b == 0)
.unwrap_or(section_data.len());
let s = String::from_utf8_lossy(&section_data[..end])
.trim()
.to_string();
if !s.is_empty() {
return Some(s);
}
}
}
}
None
}
fn parse_elf32_sections(data: &[u8]) -> Option<String> {
if data.len() < 52 {
return None;
}
let shoff = u32::from_le_bytes(data[32..36].try_into().ok()?) as usize;
let shentsize = u16::from_le_bytes(data[46..48].try_into().ok()?) as usize;
let shnum = u16::from_le_bytes(data[48..50].try_into().ok()?) as usize;
let shstrndx = u16::from_le_bytes(data[50..52].try_into().ok()?) as usize;
if shoff == 0 || shnum == 0 || shentsize < 40 {
return None;
}
let strtab_offset = shoff + shstrndx * shentsize;
if strtab_offset + shentsize > data.len() {
return None;
}
let strtab_sh_offset =
u32::from_le_bytes(data[strtab_offset + 16..strtab_offset + 20].try_into().ok()?) as usize;
let strtab_sh_size =
u32::from_le_bytes(data[strtab_offset + 20..strtab_offset + 24].try_into().ok()?) as usize;
if strtab_sh_offset + strtab_sh_size > data.len() {
return None;
}
let strtab = &data[strtab_sh_offset..strtab_sh_offset + strtab_sh_size];
for i in 0..shnum {
let offset = shoff + i * shentsize;
if offset + shentsize > data.len() {
break;
}
let name_idx =
u32::from_le_bytes(data[offset..offset + 4].try_into().ok()?) as usize;
if name_idx >= strtab.len() {
continue;
}
let name_end = strtab[name_idx..]
.iter()
.position(|&b| b == 0)
.unwrap_or(0);
let name = std::str::from_utf8(&strtab[name_idx..name_idx + name_end]).ok()?;
if name == ".upd_info" || name == ".updinfo" {
let sec_offset =
u32::from_le_bytes(data[offset + 16..offset + 20].try_into().ok()?) as usize;
let sec_size =
u32::from_le_bytes(data[offset + 20..offset + 24].try_into().ok()?) as usize;
if sec_offset + sec_size <= data.len() && sec_size > 0 {
let section_data = &data[sec_offset..sec_offset + sec_size];
let end = section_data
.iter()
.position(|&b| b == 0)
.unwrap_or(section_data.len());
let s = String::from_utf8_lossy(&section_data[..end])
.trim()
.to_string();
if !s.is_empty() {
return Some(s);
}
}
}
}
None
}
/// Fallback: run the AppImage with --appimage-updateinformation flag.
fn extract_update_info_runtime(path: &Path) -> Option<String> {
let output = std::process::Command::new(path)
.arg("--appimage-updateinformation")
.env("APPIMAGE_EXTRACT_AND_RUN", "1")
.output()
.ok()?;
if output.status.success() {
let info = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !info.is_empty() && info.contains('|') {
return Some(info);
}
}
None
}
// -- GitHub/GitLab API types for JSON deserialization --
#[derive(Deserialize)]
#[allow(dead_code)]
struct GhRelease {
tag_name: String,
name: Option<String>,
body: Option<String>,
assets: Vec<GhAsset>,
}
#[derive(Deserialize)]
struct GhAsset {
name: String,
browser_download_url: String,
size: u64,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct GlRelease {
tag_name: String,
name: Option<String>,
description: Option<String>,
assets: Option<GlReleaseAssets>,
}
#[derive(Deserialize)]
struct GlReleaseAssets {
links: Vec<GlAssetLink>,
}
#[derive(Deserialize)]
struct GlAssetLink {
name: String,
direct_asset_url: Option<String>,
url: String,
}
/// Check for updates based on parsed update info.
/// Returns None if the check fails (network error, API error, etc.)
pub fn check_for_update(
update_type: &UpdateType,
current_version: Option<&str>,
) -> Option<UpdateCheckResult> {
match update_type {
UpdateType::GhReleasesZsync {
owner,
repo,
release_tag,
filename_pattern,
} => check_github_release(owner, repo, release_tag, filename_pattern, current_version),
UpdateType::GlReleasesZsync {
host,
owner,
repo,
release_tag,
filename_pattern,
} => check_gitlab_release(host, owner, repo, release_tag, filename_pattern, current_version),
UpdateType::Zsync { url } => check_zsync_url(url, current_version),
UpdateType::Ocs { url } => {
log::info!("OCS update check not yet implemented for: {}", url);
None
}
UpdateType::Bintray { .. } => {
log::info!("Bintray is defunct - no update check possible");
None
}
}
}
/// Check GitHub Releases API for the latest release.
fn check_github_release(
owner: &str,
repo: &str,
release_tag: &str,
filename_pattern: &str,
current_version: Option<&str>,
) -> Option<UpdateCheckResult> {
let api_url = if release_tag == "latest" {
format!(
"https://api.github.com/repos/{}/{}/releases/latest",
owner, repo
)
} else {
format!(
"https://api.github.com/repos/{}/{}/releases/tags/{}",
owner, repo, release_tag
)
};
log::info!("Checking GitHub release: {}", api_url);
let mut response = ureq::get(&api_url)
.header("Accept", "application/vnd.github.v3+json")
.header("User-Agent", "Driftwood-AppImage-Manager/0.1")
.call()
.ok()?;
let release: GhRelease = response.body_mut().read_json().ok()?;
let latest_version = clean_version(&release.tag_name);
// Find matching asset using glob-like pattern
let matching_asset = find_matching_asset_gh(
&release.assets,
filename_pattern,
);
let update_available = if let Some(current) = current_version {
version_is_newer(&latest_version, current)
} else {
// No current version to compare - assume update might be available
true
};
Some(UpdateCheckResult {
update_available,
latest_version: Some(latest_version),
download_url: matching_asset.as_ref().map(|a| a.browser_download_url.clone()),
release_notes: release.body,
file_size: matching_asset.as_ref().map(|a| a.size),
})
}
/// Check GitLab Releases API for the latest release.
fn check_gitlab_release(
host: &str,
owner: &str,
repo: &str,
release_tag: &str,
filename_pattern: &str,
current_version: Option<&str>,
) -> Option<UpdateCheckResult> {
let project_path = format!("{}/{}", owner, repo);
let encoded_path = project_path.replace('/', "%2F");
let api_url = if release_tag == "latest" {
format!(
"https://{}/api/v4/projects/{}/releases/permalink/latest",
host, encoded_path
)
} else {
format!(
"https://{}/api/v4/projects/{}/releases/{}",
host, encoded_path, release_tag
)
};
log::info!("Checking GitLab release: {}", api_url);
let mut response = ureq::get(&api_url)
.header("User-Agent", "Driftwood-AppImage-Manager/0.1")
.call()
.ok()?;
let release: GlRelease = response.body_mut().read_json().ok()?;
let latest_version = clean_version(&release.tag_name);
let download_url = release.assets.and_then(|assets| {
find_matching_link_gl(&assets.links, filename_pattern)
.map(|link| link.direct_asset_url.clone().unwrap_or_else(|| link.url.clone()))
});
let update_available = if let Some(current) = current_version {
version_is_newer(&latest_version, current)
} else {
true
};
Some(UpdateCheckResult {
update_available,
latest_version: Some(latest_version),
download_url,
release_notes: release.description,
file_size: None,
})
}
/// Check a direct zsync URL via HEAD request to see if the file has changed.
fn check_zsync_url(url: &str, _current_version: Option<&str>) -> Option<UpdateCheckResult> {
// For zsync URLs, we can do a HEAD request to check if the file exists
// and has been modified. The actual version comparison would need the zsync
// control file, which is more complex.
log::info!("Checking zsync URL: {}", url);
let response = ureq::head(url)
.header("User-Agent", "Driftwood-AppImage-Manager/0.1")
.call()
.ok()?;
let status = response.status();
if status == 200 {
// The zsync file exists - an update might be available.
// Without downloading and parsing the zsync file, we can't tell the version.
// Mark as "check succeeded but version unknown".
Some(UpdateCheckResult {
update_available: false, // Can't determine without full zsync comparison
latest_version: None,
download_url: Some(url.replace(".zsync", "")),
release_notes: None,
file_size: None,
})
} else {
None
}
}
/// Find a matching GitHub asset using a glob-like pattern.
/// Patterns like "*x86_64.AppImage.zsync" or "App-*-x86_64.AppImage"
fn find_matching_asset_gh<'a>(
assets: &'a [GhAsset],
pattern: &str,
) -> Option<&'a GhAsset> {
// Try to match the AppImage file (not the .zsync file)
// If the pattern ends with .zsync, also look for the AppImage itself
let appimage_pattern = pattern.replace(".zsync", "");
// First try to find the AppImage binary
if let Some(asset) = match_asset_name(assets, &appimage_pattern) {
return Some(asset);
}
// Fall back to the original pattern (might be zsync)
match_asset_name(assets, pattern)
}
fn match_asset_name<'a>(assets: &'a [GhAsset], pattern: &str) -> Option<&'a GhAsset> {
for asset in assets {
if glob_match(pattern, &asset.name) {
return Some(asset);
}
}
None
}
/// Find a matching GitLab asset link using a glob-like pattern.
fn find_matching_link_gl<'a>(
links: &'a [GlAssetLink],
pattern: &str,
) -> Option<&'a GlAssetLink> {
let appimage_pattern = pattern.replace(".zsync", "");
for link in links {
if glob_match(&appimage_pattern, &link.name) {
return Some(link);
}
}
for link in links {
if glob_match(pattern, &link.name) {
return Some(link);
}
}
None
}
/// Simple glob matching supporting only '*' as wildcard.
fn glob_match(pattern: &str, text: &str) -> bool {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
// No wildcards - exact match
return pattern == text;
}
let mut pos = 0;
// First part must match at the beginning (unless pattern starts with *)
if !parts[0].is_empty() {
if !text.starts_with(parts[0]) {
return false;
}
pos = parts[0].len();
}
// Last part must match at the end (unless pattern ends with *)
let last = parts[parts.len() - 1];
if !last.is_empty() {
if !text.ends_with(last) {
return false;
}
}
// Middle parts must appear in order
for part in &parts[1..parts.len() - 1] {
if part.is_empty() {
continue;
}
if let Some(found) = text[pos..].find(part) {
pos += found + part.len();
} else {
return false;
}
}
true
}
/// Clean a version string - strip leading 'v' or 'V' prefix.
fn clean_version(version: &str) -> String {
let v = version.trim();
v.strip_prefix('v')
.or_else(|| v.strip_prefix('V'))
.unwrap_or(v)
.to_string()
}
/// Compare version strings to determine if `latest` is newer than `current`.
/// Supports semver-like versions: 1.2.3, 24.02.1, etc.
pub fn version_is_newer(latest: &str, current: &str) -> bool {
let latest_clean = clean_version(latest);
let current_clean = clean_version(current);
if latest_clean == current_clean {
return false;
}
let latest_parts = parse_version_parts(&latest_clean);
let current_parts = parse_version_parts(&current_clean);
// Compare each numeric part
for (l, c) in latest_parts.iter().zip(current_parts.iter()) {
match l.cmp(c) {
std::cmp::Ordering::Greater => return true,
std::cmp::Ordering::Less => return false,
std::cmp::Ordering::Equal => continue,
}
}
// If all compared parts are equal, longer version wins (1.2.3 > 1.2)
latest_parts.len() > current_parts.len()
}
/// Parse a version string into numeric parts.
/// "1.2.3" -> [1, 2, 3], "24.02.1" -> [24, 2, 1]
fn parse_version_parts(version: &str) -> Vec<u64> {
version
.split(|c: char| c == '.' || c == '-' || c == '_')
.filter_map(|part| {
// Strip non-numeric suffixes (e.g., "3rc1" -> "3")
let numeric: String = part.chars().take_while(|c| c.is_ascii_digit()).collect();
numeric.parse::<u64>().ok()
})
.collect()
}
/// Check if AppImageUpdate tool is available on the system.
pub fn has_appimage_update_tool() -> bool {
std::process::Command::new("AppImageUpdate")
.arg("--help")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Batch check: read update info from an AppImage and check for updates.
/// Returns (update_type_label, raw_info, check_result).
pub fn check_appimage_for_update(
path: &Path,
current_version: Option<&str>,
) -> (Option<String>, Option<String>, Option<UpdateCheckResult>) {
let raw_info = read_update_info(path);
let (type_label, result) = if let Some(ref info) = raw_info {
if let Some(update_type) = parse_update_info(info) {
let label = update_type.type_label().to_string();
let result = check_for_update(&update_type, current_version);
(Some(label), result)
} else {
(None, None)
}
} else {
(None, None)
};
(type_label, raw_info, result)
}
// -- Download and Apply --
/// Progress callback type for update downloads.
/// Called with (bytes_downloaded, total_bytes_option).
pub type ProgressCallback = Box<dyn Fn(u64, Option<u64>) + Send>;
/// Error type for update operations.
#[derive(Debug)]
pub enum UpdateError {
NoDownloadUrl,
NetworkError(String),
IoError(std::io::Error),
InvalidAppImage,
AppImageUpdateFailed(String),
}
impl std::fmt::Display for UpdateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoDownloadUrl => write!(f, "No download URL available"),
Self::NetworkError(e) => write!(f, "Network error: {}", e),
Self::IoError(e) => write!(f, "I/O error: {}", e),
Self::InvalidAppImage => write!(f, "Downloaded file is not a valid AppImage"),
Self::AppImageUpdateFailed(e) => write!(f, "AppImageUpdate failed: {}", e),
}
}
}
impl From<std::io::Error> for UpdateError {
fn from(e: std::io::Error) -> Self {
Self::IoError(e)
}
}
/// Result of a completed update.
#[derive(Debug)]
pub struct AppliedUpdate {
pub new_path: std::path::PathBuf,
pub old_path_backup: Option<std::path::PathBuf>,
pub new_version: Option<String>,
}
/// Try to update an AppImage using AppImageUpdate tool (delta update via zsync).
/// This is the preferred method as it only downloads changed blocks.
pub fn update_with_appimage_update_tool(
appimage_path: &Path,
) -> Result<AppliedUpdate, UpdateError> {
log::info!(
"Attempting delta update via AppImageUpdate for {}",
appimage_path.display()
);
let output = std::process::Command::new("AppImageUpdate")
.arg(appimage_path)
.output()
.map_err(|e| UpdateError::AppImageUpdateFailed(e.to_string()))?;
if output.status.success() {
// AppImageUpdate creates the new file alongside the old one with .zs-old suffix for backup
let backup_path = appimage_path.with_extension("AppImage.zs-old");
let old_backup = if backup_path.exists() {
Some(backup_path)
} else {
None
};
Ok(AppliedUpdate {
new_path: appimage_path.to_path_buf(),
old_path_backup: old_backup,
new_version: None, // Caller should re-inspect to get new version
})
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(UpdateError::AppImageUpdateFailed(stderr.to_string()))
}
}
/// Download and apply a full update (no delta - downloads the entire AppImage).
pub fn download_and_apply_update(
appimage_path: &Path,
download_url: &str,
keep_old: bool,
progress: Option<ProgressCallback>,
) -> Result<AppliedUpdate, UpdateError> {
log::info!("Downloading full update from {} for {}", download_url, appimage_path.display());
// Download to a temp file in the same directory (for atomic rename)
let parent = appimage_path.parent().unwrap_or(Path::new("."));
let filename = appimage_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("update");
let temp_path = parent.join(format!(".{}.driftwood-update.tmp", filename));
// Perform the download
download_file(download_url, &temp_path, progress)?;
// Verify it's a valid AppImage (check ELF magic + AppImage magic)
if !verify_appimage(&temp_path) {
fs::remove_file(&temp_path).ok();
return Err(UpdateError::InvalidAppImage);
}
// Make it executable
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o755);
fs::set_permissions(&temp_path, perms)?;
}
// Backup old file if requested
let backup_path = if keep_old {
let backup = appimage_path.with_extension("AppImage.old");
fs::rename(appimage_path, &backup)?;
Some(backup)
} else {
None
};
// Atomic rename temp -> target
if let Err(e) = fs::rename(&temp_path, appimage_path) {
// Try to restore backup on failure
if let Some(ref backup) = backup_path {
fs::rename(backup, appimage_path).ok();
}
return Err(UpdateError::IoError(e));
}
Ok(AppliedUpdate {
new_path: appimage_path.to_path_buf(),
old_path_backup: backup_path,
new_version: None,
})
}
/// Download a file from URL to a local path, reporting progress.
fn download_file(
url: &str,
dest: &Path,
progress: Option<ProgressCallback>,
) -> Result<(), UpdateError> {
let mut response = ureq::get(url)
.header("User-Agent", "Driftwood-AppImage-Manager/0.1")
.call()
.map_err(|e| UpdateError::NetworkError(e.to_string()))?;
let content_length: Option<u64> = response
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok());
let mut file = fs::File::create(dest)?;
let mut downloaded: u64 = 0;
let mut buf = [0u8; 65536]; // 64KB chunks
let mut reader = response.body_mut().as_reader();
loop {
let n = reader.read(&mut buf)
.map_err(UpdateError::IoError)?;
if n == 0 {
break;
}
std::io::Write::write_all(&mut file, &buf[..n])?;
downloaded += n as u64;
if let Some(ref cb) = progress {
cb(downloaded, content_length);
}
}
Ok(())
}
/// Verify that a file is a valid AppImage (has ELF header + AppImage magic bytes).
fn verify_appimage(path: &Path) -> bool {
if let Ok(data) = fs::read(path) {
if data.len() < 12 {
return false;
}
// Check ELF magic
if &data[0..4] != b"\x7FELF" {
return false;
}
// Check AppImage Type 2 magic at offset 8: AI\x02
if data[8] == 0x41 && data[9] == 0x49 && data[10] == 0x02 {
return true;
}
// Check AppImage Type 1 magic at offset 8: AI\x01
if data[8] == 0x41 && data[9] == 0x49 && data[10] == 0x01 {
return true;
}
}
false
}
/// Perform an update using the best available method.
/// Tries AppImageUpdate (delta) first, falls back to full download.
pub fn perform_update(
appimage_path: &Path,
download_url: Option<&str>,
keep_old: bool,
progress: Option<ProgressCallback>,
) -> Result<AppliedUpdate, UpdateError> {
// Try delta update via AppImageUpdate tool first
if has_appimage_update_tool() {
match update_with_appimage_update_tool(appimage_path) {
Ok(result) => return Ok(result),
Err(e) => {
log::warn!("AppImageUpdate delta update failed, falling back to full download: {}", e);
}
}
}
// Fall back to full download
let url = download_url.ok_or(UpdateError::NoDownloadUrl)?;
download_and_apply_update(appimage_path, url, keep_old, progress)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_update_info_github() {
let info = "gh-releases-zsync|probonopd|Firefox|latest|Firefox-*x86_64.AppImage.zsync";
let parsed = parse_update_info(info).unwrap();
assert_eq!(parsed.type_label(), "gh-releases-zsync");
match parsed {
UpdateType::GhReleasesZsync {
owner,
repo,
release_tag,
filename_pattern,
} => {
assert_eq!(owner, "probonopd");
assert_eq!(repo, "Firefox");
assert_eq!(release_tag, "latest");
assert_eq!(filename_pattern, "Firefox-*x86_64.AppImage.zsync");
}
_ => panic!("Expected GhReleasesZsync"),
}
}
#[test]
fn test_parse_update_info_zsync() {
let info = "zsync|https://example.com/app-latest.AppImage.zsync";
let parsed = parse_update_info(info).unwrap();
match parsed {
UpdateType::Zsync { url } => {
assert_eq!(url, "https://example.com/app-latest.AppImage.zsync");
}
_ => panic!("Expected Zsync"),
}
}
#[test]
fn test_parse_update_info_gitlab() {
let info = "gl-releases-zsync|gitlab.com|user|project|latest|App-*x86_64.AppImage.zsync";
let parsed = parse_update_info(info).unwrap();
match parsed {
UpdateType::GlReleasesZsync {
host,
owner,
repo,
release_tag,
filename_pattern,
} => {
assert_eq!(host, "gitlab.com");
assert_eq!(owner, "user");
assert_eq!(repo, "project");
assert_eq!(release_tag, "latest");
assert_eq!(filename_pattern, "App-*x86_64.AppImage.zsync");
}
_ => panic!("Expected GlReleasesZsync"),
}
}
#[test]
fn test_parse_update_info_ocs() {
let info = "ocs-v1-appimagehub-direct|https://appimagehub.com/api/...";
let parsed = parse_update_info(info).unwrap();
assert!(matches!(parsed, UpdateType::Ocs { .. }));
}
#[test]
fn test_parse_update_info_empty() {
assert!(parse_update_info("").is_none());
assert!(parse_update_info("\0\0\0").is_none());
}
#[test]
fn test_version_is_newer() {
assert!(version_is_newer("125.0", "124.0"));
assert!(version_is_newer("1.2.4", "1.2.3"));
assert!(version_is_newer("24.03.0", "24.02.1"));
assert!(version_is_newer("2.0.0", "1.9.9"));
assert!(!version_is_newer("1.0.0", "1.0.0"));
assert!(!version_is_newer("1.0.0", "2.0.0"));
assert!(!version_is_newer("v1.0", "1.0"));
}
#[test]
fn test_version_is_newer_with_prefix() {
assert!(version_is_newer("v2.0.0", "v1.9.0"));
assert!(version_is_newer("v2.0.0", "1.9.0"));
assert!(!version_is_newer("v1.0.0", "v1.0.0"));
}
#[test]
fn test_clean_version() {
assert_eq!(clean_version("v1.2.3"), "1.2.3");
assert_eq!(clean_version("V2.0"), "2.0");
assert_eq!(clean_version("1.0.0"), "1.0.0");
assert_eq!(clean_version(" v3.1 "), "3.1");
}
#[test]
fn test_glob_match() {
assert!(glob_match("*.AppImage", "Firefox-124.0-x86_64.AppImage"));
assert!(glob_match(
"Firefox-*x86_64.AppImage",
"Firefox-125.0-x86_64.AppImage"
));
assert!(glob_match("*", "anything"));
assert!(glob_match("exact", "exact"));
assert!(!glob_match("exact", "different"));
assert!(!glob_match("Firefox-*", "Chrome-1.0.AppImage"));
assert!(glob_match(
"App-*-x86_64.AppImage.zsync",
"App-2.0-x86_64.AppImage.zsync"
));
}
#[test]
fn test_parse_version_parts() {
assert_eq!(parse_version_parts("1.2.3"), vec![1, 2, 3]);
assert_eq!(parse_version_parts("24.02.1"), vec![24, 2, 1]);
assert_eq!(parse_version_parts("1.0"), vec![1, 0]);
assert_eq!(parse_version_parts("3.1rc1"), vec![3, 1]);
}
#[test]
fn test_read_update_info_nonexistent() {
let path = Path::new("/tmp/nonexistent_appimage_test.AppImage");
assert!(read_update_info(path).is_none());
}
}