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, pub download_url: Option, pub release_notes: Option, pub file_size: Option, } /// Parse the raw update info string from an AppImage's ELF section. pub fn parse_update_info(raw: &str) -> Option { 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 { // 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 { // 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 = ®ion[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 { 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 { 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 { 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(§ion_data[..end]) .trim() .to_string(); if !s.is_empty() { return Some(s); } } } } None } fn parse_elf32_sections(data: &[u8]) -> Option { 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(§ion_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 { 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, body: Option, assets: Vec, } #[derive(Deserialize)] struct GhAsset { name: String, browser_download_url: String, size: u64, } #[derive(Deserialize)] #[allow(dead_code)] struct GlRelease { tag_name: String, name: Option, description: Option, assets: Option, } #[derive(Deserialize)] struct GlReleaseAssets { links: Vec, } #[derive(Deserialize)] struct GlAssetLink { name: String, direct_asset_url: Option, 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 { 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 { 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 { 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 { // 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(¤t_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 { 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::().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, Option, Option) { 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) + 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 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, pub new_version: Option, } /// 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 { 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, ) -> Result { 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, ) -> 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 = 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, ) -> Result { // 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()); } }