use anyhow::{bail, Context, Result};
use log::info;
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};
const GITHUB_REPO: &str = "jafreck/murmur";
#[derive(Debug, Clone)]
pub struct UpdateInfo {
pub current_version: String,
pub latest_version: String,
pub download_url: String,
pub tag: String,
}
#[derive(Deserialize)]
struct GitHubRelease {
tag_name: String,
assets: Vec<GitHubAsset>,
}
#[derive(Deserialize)]
struct GitHubAsset {
name: String,
browser_download_url: String,
}
fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
let v = v.strip_prefix('v').unwrap_or(v);
let mut parts = v.splitn(3, '.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
Some((major, minor, patch))
}
fn is_newer(current: &str, latest: &str) -> bool {
match (parse_version(current), parse_version(latest)) {
(Some(c), Some(l)) => l > c,
_ => false,
}
}
fn platform_artifact() -> Result<&'static str> {
if cfg!(target_os = "macos") && cfg!(target_arch = "aarch64") {
Ok("murmur-darwin-arm64")
} else if cfg!(target_os = "macos") && cfg!(target_arch = "x86_64") {
Ok("murmur-darwin-x86_64")
} else if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") {
Ok("murmur-linux-x86_64")
} else if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
Ok("murmur-windows-x86_64")
} else {
bail!("No pre-built binary for this platform")
}
}
fn archive_extension() -> &'static str {
if cfg!(target_os = "windows") {
"zip"
} else {
"tar.gz"
}
}
pub fn check_for_update(current_version: &str) -> Result<Option<UpdateInfo>> {
let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest");
let client = reqwest::blocking::Client::builder()
.user_agent("murmur-updater")
.build()?;
let resp = client
.get(&url)
.send()
.context("Failed to reach GitHub Releases API")?;
if !resp.status().is_success() {
bail!(
"GitHub API returned {} when checking for updates",
resp.status()
);
}
let release: GitHubRelease = resp.json().context("Failed to parse release JSON")?;
let latest = release
.tag_name
.strip_prefix('v')
.unwrap_or(&release.tag_name);
if !is_newer(current_version, latest) {
return Ok(None);
}
let artifact = platform_artifact()?;
let ext = archive_extension();
let asset_name = format!("{artifact}.{ext}");
let asset = release
.assets
.iter()
.find(|a| a.name == asset_name)
.with_context(|| format!("Release {} has no asset '{asset_name}'", release.tag_name))?;
Ok(Some(UpdateInfo {
current_version: current_version.to_string(),
latest_version: latest.to_string(),
download_url: asset.browser_download_url.clone(),
tag: release.tag_name,
}))
}
pub fn apply_update(info: &UpdateInfo, progress: impl Fn(&str)) -> Result<()> {
let current_exe =
std::env::current_exe().context("Cannot determine current executable path")?;
let artifact = platform_artifact()?;
let tmp_dir = tempdir().context("Failed to create temp directory")?;
let archive_name = format!("{artifact}.{}", archive_extension());
let archive_path = tmp_dir.join(&archive_name);
progress("Downloading update...");
download_file(&info.download_url, &archive_path)?;
progress("Extracting...");
extract_archive(&archive_path, &tmp_dir)?;
let extracted_binary = tmp_dir.join(artifact);
if !extracted_binary.exists() {
bail!(
"Expected binary '{}' not found after extraction",
extracted_binary.display()
);
}
progress("Installing...");
replace_binary(&extracted_binary, ¤t_exe)?;
#[cfg(target_os = "macos")]
{
progress("Re-signing binary...");
codesign(¤t_exe);
}
let _ = fs::remove_dir_all(&tmp_dir);
info!(
"Updated murmur from v{} to v{}",
info.current_version, info.latest_version
);
progress(&format!(
"Updated to v{} (was v{})",
info.latest_version, info.current_version
));
Ok(())
}
fn tempdir() -> Result<PathBuf> {
let dir = std::env::temp_dir().join(format!("murmur-update-{}", std::process::id()));
fs::create_dir_all(&dir)?;
Ok(dir)
}
fn download_file(url: &str, dest: &Path) -> Result<()> {
let client = reqwest::blocking::Client::builder()
.user_agent("murmur-updater")
.build()?;
let resp = client
.get(url)
.send()
.context("Failed to download update")?;
if !resp.status().is_success() {
bail!("Download failed with HTTP {}", resp.status());
}
let bytes = resp.bytes()?;
fs::write(dest, &bytes).context("Failed to write archive")?;
Ok(())
}
fn extract_archive(archive: &Path, dest: &Path) -> Result<()> {
#[cfg(not(target_os = "windows"))]
{
let status = std::process::Command::new("tar")
.args(["xzf", &archive.to_string_lossy()])
.current_dir(dest)
.status()
.context("Failed to run tar")?;
if !status.success() {
bail!("tar extraction failed");
}
}
#[cfg(target_os = "windows")]
{
let status = std::process::Command::new("powershell")
.args([
"-NoProfile",
"-Command",
&format!(
"Expand-Archive -Path '{}' -DestinationPath '{}' -Force",
archive.to_string_lossy(),
dest.to_string_lossy()
),
])
.status()
.context("Failed to run PowerShell Expand-Archive")?;
if !status.success() {
bail!("Archive extraction failed");
}
}
Ok(())
}
fn replace_binary(new_binary: &Path, target: &Path) -> Result<()> {
#[cfg(unix)]
let permissions = fs::metadata(target).ok().map(|m| m.permissions());
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("xattr")
.args(["-d", "com.apple.quarantine", &new_binary.to_string_lossy()])
.status();
}
if fs::rename(new_binary, target).is_err() {
fs::copy(new_binary, target).context("Failed to copy new binary into place")?;
}
#[cfg(unix)]
if let Some(perms) = permissions {
use std::os::unix::fs::PermissionsExt;
let mut p = perms;
p.set_mode(p.mode() | 0o111); fs::set_permissions(target, p).ok();
}
Ok(())
}
#[cfg(target_os = "macos")]
fn codesign(binary: &Path) {
let _ = std::process::Command::new("codesign")
.args(["-s", "-", "-f", &binary.to_string_lossy()])
.status();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_version() {
assert_eq!(parse_version("0.1.2"), Some((0, 1, 2)));
assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_version("10.20.30"), Some((10, 20, 30)));
assert_eq!(parse_version("bad"), None);
assert_eq!(parse_version("1.2"), None);
}
#[test]
fn test_is_newer() {
assert!(is_newer("0.1.0", "0.1.1"));
assert!(is_newer("0.1.1", "0.2.0"));
assert!(is_newer("0.1.1", "1.0.0"));
assert!(!is_newer("0.1.1", "0.1.1"));
assert!(!is_newer("0.2.0", "0.1.9"));
assert!(is_newer("0.1.1", "v0.1.2"));
}
#[test]
fn test_platform_artifact() {
let result = platform_artifact();
assert!(result.is_ok());
let name = result.unwrap();
assert!(name.starts_with("murmur-"));
}
}