Files
driftwood/src/core/updater.rs

1194 lines
37 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>,
}
/// Detect the source URL for an AppImage from its update_info string.
pub fn detect_source_url(update_info: Option<&str>) -> Option<String> {
let info = update_info?;
let parts: Vec<&str> = info.split('|').collect();
if info.starts_with("gh-releases-zsync|") && parts.len() >= 3 {
return Some(format!("https://github.com/{}/{}", parts[1], parts[2]));
}
if info.starts_with("gl-releases-zsync|") && parts.len() >= 4 {
return Some(format!("https://{}/{}/{}", parts[1], parts[2], parts[3]));
}
if info.starts_with("zsync|") {
if let Some(url_part) = parts.get(1) {
// Extract the base URL (up to the hostname)
if let Some(idx) = url_part.find("://") {
if let Some(slash) = url_part[idx + 3..].find('/') {
return Some(url_part[..idx + 3 + slash].to_string());
}
}
}
}
None
}
/// 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.
/// Uses a 5-second timeout to avoid hanging on apps with custom AppRun scripts.
fn extract_update_info_runtime(path: &Path) -> Option<String> {
let mut child = std::process::Command::new(path)
.arg("--appimage-updateinformation")
.env("APPIMAGE_EXTRACT_AND_RUN", "1")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.ok()?;
let timeout = std::time::Duration::from_secs(5);
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => {
if status.success() {
let mut output = String::new();
if let Some(mut stdout) = child.stdout.take() {
use std::io::Read;
stdout.read_to_string(&mut output).ok()?;
}
let info = output.trim().to_string();
if !info.is_empty() && info.contains('|') {
return Some(info);
}
}
return None;
}
Ok(None) => {
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
log::warn!("Timed out reading update info from {}", path.display());
return None;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(_) => return None,
}
}
}
// -- GitHub/GitLab API types for JSON deserialization --
#[derive(Deserialize)]
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)]
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()?;
log::info!(
"GitHub release: tag={}, name={:?}",
release.tag_name,
release.name.as_deref().unwrap_or("(none)"),
);
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()?;
log::info!(
"GitLab release: tag={}, name={:?}",
release.tag_name,
release.name.as_deref().unwrap_or("(none)"),
);
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];
let end_limit = if !last.is_empty() {
if !text.ends_with(last) {
return false;
}
text.len() - last.len()
} else {
text.len()
};
// Middle parts must appear in order within the allowed range
for part in &parts[1..parts.len() - 1] {
if part.is_empty() {
continue;
}
if pos >= end_limit {
return false;
}
if let Some(found) = text[pos..end_limit].find(part) {
pos += found + part.len();
} else {
return false;
}
}
true
}
/// Clean a version string - strip leading 'v' or 'V' prefix.
pub(crate) 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, only consider newer if extra parts are non-zero
// (e.g., 1.2.1 > 1.2, but 1.2.0 == 1.2)
if latest_parts.len() > current_parts.len() {
return latest_parts[current_parts.len()..].iter().any(|&p| p > 0);
}
false
}
/// 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 {
// Check that the binary exists and can be spawned (--help may return non-zero)
std::process::Command::new("AppImageUpdate")
.arg("--help")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok()
}
/// 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) {
// Clean up temp file and restore backup on failure
fs::remove_file(&temp_path).ok();
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 {
use std::io::Read;
let mut file = match fs::File::open(path) {
Ok(f) => f,
Err(_) => return false,
};
let mut header = [0u8; 12];
if file.read_exact(&mut header).is_err() {
return false;
}
// Check ELF magic
if &header[0..4] != b"\x7FELF" {
return false;
}
// Check AppImage Type 2 magic at offset 8: AI\x02
if header[8] == 0x41 && header[9] == 0x49 && header[10] == 0x02 {
return true;
}
// Check AppImage Type 1 magic at offset 8: AI\x01
header[8] == 0x41 && header[9] == 0x49 && header[10] == 0x01
}
/// 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());
}
}