1194 lines
37 KiB
Rust
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 = ®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<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(§ion_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(§ion_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(¤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, 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());
|
|
}
|
|
}
|