//! Self-update mechanism. //! //! Checks GitHub releases for newer versions and downloads the binary. //! Uses `curl` as a subprocess for HTTP — no additional Rust HTTP dependencies. //! JSON parsing uses serde_json (already in deps for persistence). use std::env; use std::fs; use std::process::Command; use serde::Deserialize; const GITHUB_REPO: &str = "ogulcancelik/herdr"; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); const FAKE_UPDATE_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_VERSION"; fn fake_release_notes_body(version: &str) -> String { format!( "### Changed\n- Test update v{version} for local UI validation.\n\n### Notes\n- This is stub release-notes content generated by {FAKE_UPDATE_VERSION_ENV}." ) } // --------------------------------------------------------------------------- // Version // --------------------------------------------------------------------------- /// Parsed semver version for comparison. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct Version { pub major: u32, pub minor: u32, pub patch: u32, } impl Version { pub fn parse(s: &str) -> Option { let s = s.strip_prefix('v').unwrap_or(s); let parts: Vec<&str> = s.split('.').collect(); if parts.len() != 3 { return None; } Some(Self { major: parts[0].parse().ok()?, minor: parts[1].parse().ok()?, patch: parts[2].parse().ok()?, }) } pub fn current() -> Self { Self::parse(CURRENT_VERSION).expect("invalid CARGO_PKG_VERSION") } } impl std::fmt::Display for Version { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}.{}.{}", self.major, self.minor, self.patch) } } // --------------------------------------------------------------------------- // GitHub release API types // --------------------------------------------------------------------------- #[derive(Deserialize)] struct GitHubRelease { tag_name: String, #[serde(default)] body: String, assets: Vec, } #[derive(Deserialize)] struct GitHubAsset { name: String, browser_download_url: String, } // --------------------------------------------------------------------------- // Release info // --------------------------------------------------------------------------- /// Information about an available update. struct ReleaseInfo { version: Version, download_url: String, notes_body: String, } /// Check GitHub for the latest release. Returns release info if newer. fn check_latest() -> Result, String> { let current = Version::current(); let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest"); let output = Command::new("curl") .args([ "-sfL", "--max-time", "10", "-H", "Accept: application/vnd.github+json", "-H", "User-Agent: herdr-updater", &url, ]) .output() .map_err(|e| format!("curl failed: {e}"))?; if !output.status.success() { return Err("failed to fetch release info".into()); } let release: GitHubRelease = serde_json::from_slice(&output.stdout) .map_err(|e| format!("failed to parse release JSON: {e}"))?; let latest = Version::parse(&release.tag_name) .ok_or_else(|| format!("invalid version tag: {}", release.tag_name))?; if latest <= current { return Ok(None); // up to date } // Find the asset for our platform let (os, arch) = platform_target(); let asset_name = format!("herdr-{os}-{arch}"); let download_url = release .assets .iter() .find(|a| a.name == asset_name) .map(|a| a.browser_download_url.clone()) .ok_or_else(|| format!("no binary for {os}-{arch} in release {}", release.tag_name))?; Ok(Some(ReleaseInfo { version: latest, download_url, notes_body: release.body.trim().to_string(), })) } // --------------------------------------------------------------------------- // Download + install // --------------------------------------------------------------------------- /// Download and install a release. Returns the installed version. fn download_and_install(release: &ReleaseInfo) -> Result<(), String> { let current_exe = env::current_exe().map_err(|e| format!("can't find current binary: {e}"))?; let parent = current_exe.parent().ok_or("can't find binary directory")?; // Check write permissions early let test_path = parent.join(".herdr-write-test"); if let Err(e) = fs::write(&test_path, b"") { let _ = fs::remove_file(&test_path); return Err(format!( "install directory not writable: {} ({}). Try running with appropriate permissions.", parent.display(), e )); } let _ = fs::remove_file(&test_path); // Unique temp file (avoids races with concurrent instances) let tmp_path = parent.join(format!(".herdr-update-{}.tmp", std::process::id())); // Download the exact asset URL (pinned to the release we checked) let status = Command::new("curl") .args(["-sfL", "--max-time", "120", "-o"]) .arg(&tmp_path) .arg(&release.download_url) .status() .map_err(|e| format!("download failed: {e}"))?; if !status.success() { let _ = fs::remove_file(&tmp_path); return Err("download failed".into()); } // Make executable #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if let Err(e) = fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755)) { let _ = fs::remove_file(&tmp_path); return Err(format!("chmod failed: {e}")); } } // Atomic replace — rename over the current binary. // On Linux, the running process keeps its fd to the old inode. // Next launch picks up the new file. if let Err(e) = fs::rename(&tmp_path, ¤t_exe) { let _ = fs::remove_file(&tmp_path); return Err(format!("failed to replace binary: {e}")); } Ok(()) } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /// Manual self-update command (`herdr update`). pub fn self_update() -> Result { eprintln!("checking for updates..."); let current = Version::current(); let release = match check_latest()? { Some(r) => r, None => { eprintln!("already up to date (v{current})"); return Ok(current); } }; eprintln!("downloading v{}...", release.version); if let Err(e) = crate::release_notes::save_pending(&release.version.to_string(), &release.notes_body) { tracing::warn!("failed to save pending release notes: {e}"); } download_and_install(&release)?; eprintln!("updated to v{}", release.version); Ok(release.version) } /// Background auto-update: check, download, install, notify TUI. /// Runs in a background thread at startup. pub fn auto_update(events: tokio::sync::mpsc::Sender) { if let Ok(version) = env::var(FAKE_UPDATE_VERSION_ENV) { let version = version.trim(); if !version.is_empty() { tracing::info!( env = FAKE_UPDATE_VERSION_ENV, version, "using fake update version for local testing" ); if let Err(e) = crate::release_notes::save_pending(version, &fake_release_notes_body(version)) { tracing::warn!("failed to save fake pending release notes: {e}"); } let _ = events.blocking_send(crate::events::AppEvent::UpdateReady { version: version.to_string(), }); } return; } let release = match check_latest() { Ok(Some(r)) => r, _ => return, // up to date or failed — silently do nothing }; tracing::info!( "new version v{} available, downloading from {}", release.version, release.download_url ); if let Err(e) = crate::release_notes::save_pending(&release.version.to_string(), &release.notes_body) { tracing::warn!("failed to save pending release notes: {e}"); } if let Err(e) = download_and_install(&release) { tracing::warn!("auto-update failed: {e}"); return; } tracing::info!( "auto-update: v{} installed, restart to use", release.version ); // Notify the TUI — blocking_send is safe from a std::thread let _ = events.blocking_send(crate::events::AppEvent::UpdateReady { version: release.version.to_string(), }); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn platform_target() -> (&'static str, &'static str) { let os = if cfg!(target_os = "linux") { "linux" } else if cfg!(target_os = "macos") { "macos" } else { "unknown" }; let arch = if cfg!(target_arch = "x86_64") { "x86_64" } else if cfg!(target_arch = "aarch64") { "aarch64" } else { "unknown" }; (os, arch) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn parse_version_basic() { assert_eq!( Version::parse("1.2.3"), Some(Version { major: 1, minor: 2, patch: 3 }) ); } #[test] fn parse_version_with_v_prefix() { assert_eq!( Version::parse("v0.1.0"), Some(Version { major: 0, minor: 1, patch: 0 }) ); } #[test] fn parse_version_invalid() { assert_eq!(Version::parse("1.2"), None); assert_eq!(Version::parse("abc"), None); assert_eq!(Version::parse(""), None); } #[test] fn fake_release_notes_include_version_and_context() { let body = fake_release_notes_body("9.4.9"); assert!(body.contains("v9.4.9")); assert!(body.contains("local UI validation")); assert!(body.contains(FAKE_UPDATE_VERSION_ENV)); } #[test] fn version_ordering() { let v010 = Version::parse("0.1.0").unwrap(); let v011 = Version::parse("0.1.1").unwrap(); let v020 = Version::parse("0.2.0").unwrap(); let v100 = Version::parse("1.0.0").unwrap(); assert!(v010 < v011); assert!(v011 < v020); assert!(v020 < v100); assert!(v010 == Version::parse("0.1.0").unwrap()); } #[test] fn version_display() { let v = Version { major: 0, minor: 1, patch: 0, }; assert_eq!(v.to_string(), "0.1.0"); } #[test] fn current_version_parses() { let v = Version::current(); assert!(v.major < 100); } #[test] fn platform_target_is_known() { let (os, arch) = platform_target(); assert!(os == "linux" || os == "macos", "os: {os}"); assert!(arch == "x86_64" || arch == "aarch64", "arch: {arch}"); } #[test] fn github_release_deserializes() { let json = r#"{ "tag_name": "v0.2.0", "assets": [ {"name": "herdr-linux-x86_64", "browser_download_url": "https://example.com/herdr-linux-x86_64"}, {"name": "herdr-macos-aarch64", "browser_download_url": "https://example.com/herdr-macos-aarch64"} ] }"#; let release: GitHubRelease = serde_json::from_str(json).unwrap(); assert_eq!(release.tag_name, "v0.2.0"); assert_eq!(release.assets.len(), 2); assert_eq!(release.assets[0].name, "herdr-linux-x86_64"); } #[test] fn github_release_ignores_extra_fields() { let json = r#"{ "tag_name": "v0.1.0", "name": "Release v0.1.0", "body": "Some notes", "draft": false, "prerelease": false, "assets": [] }"#; let release: GitHubRelease = serde_json::from_str(json).unwrap(); assert_eq!(release.tag_name, "v0.1.0"); assert!(release.assets.is_empty()); } }