//! Self-update mechanism. //! //! Checks the hosted herdr.dev update manifest 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::collections::BTreeMap; use std::env; use std::fs; use std::process::Command; use serde::Deserialize; const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json"; 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) } } // --------------------------------------------------------------------------- // Update manifest // --------------------------------------------------------------------------- #[derive(Deserialize)] struct UpdateManifest { version: String, notes: String, assets: BTreeMap, } impl UpdateManifest { fn download_url_for(&self, os: &str, arch: &str) -> Option { self.assets.get(&format!("{os}-{arch}")).cloned() } fn notes_body(&self) -> String { self.notes.trim().to_string() } } // --------------------------------------------------------------------------- // Release info // --------------------------------------------------------------------------- /// Information about an available update. struct ReleaseInfo { version: Version, download_url: String, notes_body: String, } /// Check the hosted update manifest for the latest release. Returns release info if newer. fn check_latest() -> Result, String> { let current = Version::current(); let output = Command::new("curl") .args([ "-sfL", "--retry", "3", "--connect-timeout", "10", "--max-time", "20", UPDATE_MANIFEST_URL, ]) .output() .map_err(|e| format!("curl failed: {e}"))?; if !output.status.success() { return Err("failed to fetch update manifest".into()); } let manifest: UpdateManifest = serde_json::from_slice(&output.stdout) .map_err(|e| format!("failed to parse update manifest JSON: {e}"))?; let latest = Version::parse(&manifest.version) .ok_or_else(|| format!("invalid version in update manifest: {}", manifest.version))?; if latest <= current { return Ok(None); // up to date } let notes_body = manifest.notes_body(); if notes_body.is_empty() { return Err("update manifest notes are empty".into()); } let (os, arch) = platform_target(); let download_url = manifest .download_url_for(os, arch) .ok_or_else(|| format!("no binary for {os}-{arch} in update manifest"))?; Ok(Some(ReleaseInfo { version: latest, download_url, notes_body, })) } // --------------------------------------------------------------------------- // 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 update_manifest_deserializes() { let json = "{\n\ \"version\": \"0.2.0\",\n\ \"notes\": \"### Changed\\n- One\",\n\ \"assets\": {\n\ \"linux-x86_64\": \"https://example.com/herdr-linux-x86_64\",\n\ \"macos-aarch64\": \"https://example.com/herdr-macos-aarch64\"\n\ }\n\ }"; let manifest: UpdateManifest = serde_json::from_str(json).unwrap(); assert_eq!(manifest.version, "0.2.0"); assert_eq!(manifest.assets.len(), 2); assert_eq!(manifest.notes_body(), "### Changed\n- One"); assert_eq!( manifest.download_url_for("linux", "x86_64").as_deref(), Some("https://example.com/herdr-linux-x86_64") ); } #[test] fn update_manifest_requires_notes_field() { let json = r#"{ "version": "0.2.0", "assets": { "linux-x86_64": "https://example.com/herdr-linux-x86_64" } }"#; assert!(serde_json::from_str::(json).is_err()); } #[test] fn checked_in_website_manifest_matches_update_schema() { let manifest: UpdateManifest = serde_json::from_str(include_str!("../website/latest.json")) .expect("website/latest.json should match updater schema"); assert!(!manifest.notes_body().is_empty()); assert_eq!(manifest.assets.len(), 4); for target in [ "linux-x86_64", "linux-aarch64", "macos-x86_64", "macos-aarch64", ] { let url = manifest .assets .get(target) .unwrap_or_else(|| panic!("missing asset URL for {target}")); assert!( url.contains(&format!("/releases/download/v{}/", manifest.version)), "unexpected release URL for {target}: {url}" ); assert!( url.ends_with(&format!("herdr-{target}")), "unexpected asset name for {target}: {url}" ); } } }