//! OTA update checker for audiofiles standalone app. //! //! Checks the MNW OTA endpoint on startup and periodically. Stores the result //! in shared state so the egui UI can display a notification. use std::sync::Arc; use parking_lot::Mutex; use semver::Version; /// OTA updater endpoint base URL. const OTA_BASE_URL: &str = "https://makenot.work/api/v1/sync/ota/audiofiles"; /// How long to wait after startup before first check (seconds). const INITIAL_DELAY_SECS: u64 = 10; /// How often to re-check for updates (seconds). 6 hours. const CHECK_INTERVAL_SECS: u64 = 6 * 60 * 60; /// Current app version (from Cargo.toml at compile time). const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); /// The response format from the MNW OTA updater endpoint. #[derive(serde::Deserialize)] struct UpdateResponse { version: String, url: String, notes: String, } /// Shared update status, polled by the UI each frame. #[derive(Clone, Default)] pub struct UpdateStatus { pub available: bool, pub version: String, pub notes: String, pub download_url: String, pub dismissed: bool, } /// Handle to the update checker. Clone-cheap (Arc-wrapped). #[derive(Clone)] pub struct UpdateChecker { pub status: Arc>, } impl UpdateChecker { /// Create a new checker and spawn the background check loop on the given runtime. pub fn new(runtime: &tokio::runtime::Handle) -> Self { let status = Arc::new(Mutex::new(UpdateStatus::default())); let checker = Self { status: status.clone() }; runtime.spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(INITIAL_DELAY_SECS)).await; loop { check_once(&status).await; tokio::time::sleep(std::time::Duration::from_secs(CHECK_INTERVAL_SECS)).await; } }); checker } /// Construct an inert checker that never contacts the network. Used when /// the user has disabled `check_for_updates` in preferences — keeps the /// rest of the app's `update_checker` plumbing trivial (no Options). pub fn disabled() -> Self { Self { status: Arc::new(Mutex::new(UpdateStatus::default())), } } /// Dismiss the update notification (user clicked dismiss). pub fn dismiss(&self) { self.status.lock().dismissed = true; } /// Whether to show the update banner. pub fn should_show(&self) -> bool { let s = self.status.lock(); s.available && !s.dismissed } } /// Verify that a download URL points to a trusted domain. pub fn is_trusted_download_url(url: &str) -> bool { const TRUSTED_PREFIXES: &[&str] = &[ "https://makenot.work/", "https://dist.makenot.work/", ]; TRUSTED_PREFIXES.iter().any(|prefix| url.starts_with(prefix)) } /// Check the MNW OTA endpoint once. async fn check_once(status: &Arc>) { let current = match Version::parse(CURRENT_VERSION) { Ok(v) => v, Err(e) => { tracing::warn!("Failed to parse current version {CURRENT_VERSION}: {e}"); return; } }; let target = if cfg!(target_os = "macos") { "darwin" } else if cfg!(target_os = "linux") { "linux" } else if cfg!(target_os = "windows") { "windows" } else { return; }; let arch = if cfg!(target_arch = "x86_64") { "x86_64" } else if cfg!(target_arch = "aarch64") { "aarch64" } else { return; }; let url = format!("{OTA_BASE_URL}/{target}/{arch}/{CURRENT_VERSION}"); let client = match reqwest::Client::builder() .timeout(std::time::Duration::from_secs(15)) .build() { Ok(c) => c, Err(e) => { tracing::warn!("Failed to build HTTP client for update check: {e}"); return; } }; match client.get(&url).send().await { Ok(resp) if resp.status().as_u16() == 204 => { tracing::info!("audiofiles is up to date (v{CURRENT_VERSION})"); } Ok(resp) if resp.status().is_success() => { match resp.json::().await { Ok(update) => { if let Ok(remote) = Version::parse(&update.version) { if remote > current && is_trusted_download_url(&update.url) { tracing::info!("Update available: v{}", update.version); let mut s = status.lock(); s.available = true; s.version = update.version; s.notes = update.notes; s.download_url = update.url; } } } Err(e) => { tracing::warn!("Failed to parse update response: {e}"); } } } Ok(resp) => { tracing::debug!("Update check returned status {}", resp.status()); } Err(e) => { tracing::warn!("Update check request failed: {e}"); } } } #[cfg(test)] mod tests { use super::*; #[test] fn update_status_default_is_inactive() { let s = UpdateStatus::default(); assert!(!s.available); assert!(!s.dismissed); assert!(s.version.is_empty()); assert!(s.notes.is_empty()); assert!(s.download_url.is_empty()); } #[test] fn dismiss_sets_flag() { let checker = UpdateChecker { status: Arc::new(Mutex::new(UpdateStatus::default())), }; assert!(!checker.status.lock().dismissed); checker.dismiss(); assert!(checker.status.lock().dismissed); } #[test] fn should_show_when_available_and_not_dismissed() { let checker = UpdateChecker { status: Arc::new(Mutex::new(UpdateStatus { available: true, dismissed: false, version: "1.0.0".to_string(), ..Default::default() })), }; assert!(checker.should_show()); } #[test] fn should_not_show_when_not_available() { let checker = UpdateChecker { status: Arc::new(Mutex::new(UpdateStatus::default())), }; assert!(!checker.should_show()); } #[test] fn should_not_show_when_dismissed() { let checker = UpdateChecker { status: Arc::new(Mutex::new(UpdateStatus { available: true, dismissed: true, ..Default::default() })), }; assert!(!checker.should_show()); } #[test] fn dismiss_then_should_show_returns_false() { let checker = UpdateChecker { status: Arc::new(Mutex::new(UpdateStatus { available: true, ..Default::default() })), }; assert!(checker.should_show()); checker.dismiss(); assert!(!checker.should_show()); } #[test] fn update_response_deserializes() { let json = r#"{"version":"1.2.0","url":"https://example.com/dl","notes":"Bug fixes"}"#; let resp: UpdateResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.version, "1.2.0"); assert_eq!(resp.url, "https://example.com/dl"); assert_eq!(resp.notes, "Bug fixes"); } #[test] fn current_version_is_valid_semver() { Version::parse(CURRENT_VERSION) .expect("CURRENT_VERSION should be valid semver"); } }