//! 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; use tokio::sync::watch; /// 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). /// /// The background loop is always spawned but gated on a `watch` channel — the /// runtime toggle in the About modal flips the gate without restart. When /// disabled the loop stays parked on `enabled.changed()` (no wake-ups, no /// network), so the cost of "enabled" living in the type is zero at rest. #[derive(Clone)] pub struct UpdateChecker { pub status: Arc>, /// `None` for the no-runtime / test-only inert checker built by /// `UpdateChecker::inert()` — calling `set_enabled` on it is a no-op. enabled_tx: Option>>, } impl UpdateChecker { /// Create a checker and spawn the background loop on the given runtime. /// `initial_enabled = false` keeps the loop parked until `set_enabled(true)`. pub fn new(runtime: &tokio::runtime::Handle, initial_enabled: bool) -> Self { let status = Arc::new(Mutex::new(UpdateStatus::default())); let (tx, mut rx) = watch::channel(initial_enabled); let status_for_task = status.clone(); runtime.spawn(async move { // Honor the pref before we even start the initial-delay timer — a // user who set check_for_updates=false in their JSON shouldn't see // a 10-second network call on launch. if !*rx.borrow() { // Park until enabled. Returns Err only if the sender is // dropped (whole app exiting), in which case the task ends. while !*rx.borrow_and_update() { if rx.changed().await.is_err() { return; } } } tokio::time::sleep(std::time::Duration::from_secs(INITIAL_DELAY_SECS)).await; loop { if *rx.borrow() { check_once(&status_for_task).await; } // Sleep until the next interval or until the pref flips, whichever first. tokio::select! { _ = tokio::time::sleep(std::time::Duration::from_secs(CHECK_INTERVAL_SECS)) => {} res = rx.changed() => { if res.is_err() { return; } // Pref flipped. If it's now enabled, run a check // immediately so the user gets feedback without // waiting up to 6h. If now disabled, fall back into // the gate at the top of the loop. } } } }); Self { status, enabled_tx: Some(Arc::new(tx)), } } /// Inert checker for tests / no-runtime contexts. Never spawns a task. pub fn inert() -> Self { Self { status: Arc::new(Mutex::new(UpdateStatus::default())), enabled_tx: None, } } /// Flip the runtime enable gate. Called from the About modal when the /// user toggles `check_for_updates`. No-op on inert checkers. pub fn set_enabled(&self, enabled: bool) { if let Some(tx) = &self.enabled_tx { let _ = tx.send(enabled); } } /// 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) && 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()); } fn checker_with_status(status: UpdateStatus) -> UpdateChecker { UpdateChecker { status: Arc::new(Mutex::new(status)), enabled_tx: None, } } #[test] fn dismiss_sets_flag() { let checker = UpdateChecker::inert(); assert!(!checker.status.lock().dismissed); checker.dismiss(); assert!(checker.status.lock().dismissed); } #[test] fn should_show_when_available_and_not_dismissed() { let checker = checker_with_status(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::inert(); assert!(!checker.should_show()); } #[test] fn should_not_show_when_dismissed() { let checker = checker_with_status(UpdateStatus { available: true, dismissed: true, ..Default::default() }); assert!(!checker.should_show()); } #[test] fn dismiss_then_should_show_returns_false() { let checker = checker_with_status(UpdateStatus { available: true, ..Default::default() }); assert!(checker.should_show()); checker.dismiss(); assert!(!checker.should_show()); } #[test] fn set_enabled_on_inert_is_noop() { let checker = UpdateChecker::inert(); // No panic, no observable side-effect — there's no task to gate. checker.set_enabled(true); checker.set_enabled(false); } #[test] fn set_enabled_propagates_through_watch_channel() { let rt = tokio::runtime::Builder::new_current_thread() .enable_time() .build() .unwrap(); let checker = UpdateChecker::new(rt.handle(), false); // Subscribe directly to the same channel by cloning the sender's // receiver — this is the contract the background task relies on. let mut rx = checker .enabled_tx .as_ref() .expect("real checker has a watch sender") .subscribe(); assert!(!*rx.borrow()); checker.set_enabled(true); assert!(*rx.borrow_and_update()); checker.set_enabled(false); assert!(!*rx.borrow_and_update()); } #[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"); } }