Skip to main content

max / audiofiles

10.4 KB · 328 lines History Blame Raw
1 //! OTA update checker for audiofiles standalone app.
2 //!
3 //! Checks the MNW OTA endpoint on startup and periodically. Stores the result
4 //! in shared state so the egui UI can display a notification.
5
6 use std::sync::Arc;
7
8 use parking_lot::Mutex;
9 use semver::Version;
10 use tokio::sync::watch;
11
12 /// OTA updater endpoint base URL.
13 const OTA_BASE_URL: &str = "https://makenot.work/api/v1/sync/ota/audiofiles";
14
15 /// How long to wait after startup before first check (seconds).
16 const INITIAL_DELAY_SECS: u64 = 10;
17
18 /// How often to re-check for updates (seconds). 6 hours.
19 const CHECK_INTERVAL_SECS: u64 = 6 * 60 * 60;
20
21 /// Current app version (from Cargo.toml at compile time).
22 const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
23
24 /// The response format from the MNW OTA updater endpoint.
25 #[derive(serde::Deserialize)]
26 struct UpdateResponse {
27 version: String,
28 url: String,
29 notes: String,
30 }
31
32 /// Shared update status, polled by the UI each frame.
33 #[derive(Clone, Default)]
34 pub struct UpdateStatus {
35 pub available: bool,
36 pub version: String,
37 pub notes: String,
38 pub download_url: String,
39 pub dismissed: bool,
40 }
41
42 /// Handle to the update checker. Clone-cheap (Arc-wrapped).
43 ///
44 /// The background loop is always spawned but gated on a `watch` channel — the
45 /// runtime toggle in the About modal flips the gate without restart. When
46 /// disabled the loop stays parked on `enabled.changed()` (no wake-ups, no
47 /// network), so the cost of "enabled" living in the type is zero at rest.
48 #[derive(Clone)]
49 pub struct UpdateChecker {
50 pub status: Arc<Mutex<UpdateStatus>>,
51 /// `None` for the no-runtime / test-only inert checker built by
52 /// `UpdateChecker::inert()` — calling `set_enabled` on it is a no-op.
53 enabled_tx: Option<Arc<watch::Sender<bool>>>,
54 }
55
56 impl UpdateChecker {
57 /// Create a checker and spawn the background loop on the given runtime.
58 /// `initial_enabled = false` keeps the loop parked until `set_enabled(true)`.
59 pub fn new(runtime: &tokio::runtime::Handle, initial_enabled: bool) -> Self {
60 let status = Arc::new(Mutex::new(UpdateStatus::default()));
61 let (tx, mut rx) = watch::channel(initial_enabled);
62
63 let status_for_task = status.clone();
64 runtime.spawn(async move {
65 // Honor the pref before we even start the initial-delay timer — a
66 // user who set check_for_updates=false in their JSON shouldn't see
67 // a 10-second network call on launch.
68 if !*rx.borrow() {
69 // Park until enabled. Returns Err only if the sender is
70 // dropped (whole app exiting), in which case the task ends.
71 while !*rx.borrow_and_update() {
72 if rx.changed().await.is_err() {
73 return;
74 }
75 }
76 }
77
78 tokio::time::sleep(std::time::Duration::from_secs(INITIAL_DELAY_SECS)).await;
79 loop {
80 if *rx.borrow() {
81 check_once(&status_for_task).await;
82 }
83 // Sleep until the next interval or until the pref flips, whichever first.
84 tokio::select! {
85 _ = tokio::time::sleep(std::time::Duration::from_secs(CHECK_INTERVAL_SECS)) => {}
86 res = rx.changed() => {
87 if res.is_err() {
88 return;
89 }
90 // Pref flipped. If it's now enabled, run a check
91 // immediately so the user gets feedback without
92 // waiting up to 6h. If now disabled, fall back into
93 // the gate at the top of the loop.
94 }
95 }
96 }
97 });
98
99 Self {
100 status,
101 enabled_tx: Some(Arc::new(tx)),
102 }
103 }
104
105 /// Inert checker for tests / no-runtime contexts. Never spawns a task.
106 pub fn inert() -> Self {
107 Self {
108 status: Arc::new(Mutex::new(UpdateStatus::default())),
109 enabled_tx: None,
110 }
111 }
112
113 /// Flip the runtime enable gate. Called from the About modal when the
114 /// user toggles `check_for_updates`. No-op on inert checkers.
115 pub fn set_enabled(&self, enabled: bool) {
116 if let Some(tx) = &self.enabled_tx {
117 let _ = tx.send(enabled);
118 }
119 }
120
121 /// Dismiss the update notification (user clicked dismiss).
122 pub fn dismiss(&self) {
123 self.status.lock().dismissed = true;
124 }
125
126 /// Whether to show the update banner.
127 pub fn should_show(&self) -> bool {
128 let s = self.status.lock();
129 s.available && !s.dismissed
130 }
131 }
132
133 /// Verify that a download URL points to a trusted domain.
134 pub fn is_trusted_download_url(url: &str) -> bool {
135 const TRUSTED_PREFIXES: &[&str] = &[
136 "https://makenot.work/",
137 "https://dist.makenot.work/",
138 ];
139 TRUSTED_PREFIXES.iter().any(|prefix| url.starts_with(prefix))
140 }
141
142 /// Check the MNW OTA endpoint once.
143 async fn check_once(status: &Arc<Mutex<UpdateStatus>>) {
144 let current = match Version::parse(CURRENT_VERSION) {
145 Ok(v) => v,
146 Err(e) => {
147 tracing::warn!("Failed to parse current version {CURRENT_VERSION}: {e}");
148 return;
149 }
150 };
151
152 let target = if cfg!(target_os = "macos") {
153 "darwin"
154 } else if cfg!(target_os = "linux") {
155 "linux"
156 } else if cfg!(target_os = "windows") {
157 "windows"
158 } else {
159 return;
160 };
161
162 let arch = if cfg!(target_arch = "x86_64") {
163 "x86_64"
164 } else if cfg!(target_arch = "aarch64") {
165 "aarch64"
166 } else {
167 return;
168 };
169
170 let url = format!("{OTA_BASE_URL}/{target}/{arch}/{CURRENT_VERSION}");
171
172 let client = match reqwest::Client::builder()
173 .timeout(std::time::Duration::from_secs(15))
174 .build()
175 {
176 Ok(c) => c,
177 Err(e) => {
178 tracing::warn!("Failed to build HTTP client for update check: {e}");
179 return;
180 }
181 };
182
183 match client.get(&url).send().await {
184 Ok(resp) if resp.status().as_u16() == 204 => {
185 tracing::info!("audiofiles is up to date (v{CURRENT_VERSION})");
186 }
187 Ok(resp) if resp.status().is_success() => {
188 match resp.json::<UpdateResponse>().await {
189 Ok(update) => {
190 if let Ok(remote) = Version::parse(&update.version)
191 && remote > current && is_trusted_download_url(&update.url) {
192 tracing::info!("Update available: v{}", update.version);
193 let mut s = status.lock();
194 s.available = true;
195 s.version = update.version;
196 s.notes = update.notes;
197 s.download_url = update.url;
198 }
199 }
200 Err(e) => {
201 tracing::warn!("Failed to parse update response: {e}");
202 }
203 }
204 }
205 Ok(resp) => {
206 tracing::debug!("Update check returned status {}", resp.status());
207 }
208 Err(e) => {
209 tracing::warn!("Update check request failed: {e}");
210 }
211 }
212 }
213
214 #[cfg(test)]
215 mod tests {
216 use super::*;
217
218 #[test]
219 fn update_status_default_is_inactive() {
220 let s = UpdateStatus::default();
221 assert!(!s.available);
222 assert!(!s.dismissed);
223 assert!(s.version.is_empty());
224 assert!(s.notes.is_empty());
225 assert!(s.download_url.is_empty());
226 }
227
228 fn checker_with_status(status: UpdateStatus) -> UpdateChecker {
229 UpdateChecker {
230 status: Arc::new(Mutex::new(status)),
231 enabled_tx: None,
232 }
233 }
234
235 #[test]
236 fn dismiss_sets_flag() {
237 let checker = UpdateChecker::inert();
238 assert!(!checker.status.lock().dismissed);
239 checker.dismiss();
240 assert!(checker.status.lock().dismissed);
241 }
242
243 #[test]
244 fn should_show_when_available_and_not_dismissed() {
245 let checker = checker_with_status(UpdateStatus {
246 available: true,
247 dismissed: false,
248 version: "1.0.0".to_string(),
249 ..Default::default()
250 });
251 assert!(checker.should_show());
252 }
253
254 #[test]
255 fn should_not_show_when_not_available() {
256 let checker = UpdateChecker::inert();
257 assert!(!checker.should_show());
258 }
259
260 #[test]
261 fn should_not_show_when_dismissed() {
262 let checker = checker_with_status(UpdateStatus {
263 available: true,
264 dismissed: true,
265 ..Default::default()
266 });
267 assert!(!checker.should_show());
268 }
269
270 #[test]
271 fn dismiss_then_should_show_returns_false() {
272 let checker = checker_with_status(UpdateStatus {
273 available: true,
274 ..Default::default()
275 });
276 assert!(checker.should_show());
277 checker.dismiss();
278 assert!(!checker.should_show());
279 }
280
281 #[test]
282 fn set_enabled_on_inert_is_noop() {
283 let checker = UpdateChecker::inert();
284 // No panic, no observable side-effect — there's no task to gate.
285 checker.set_enabled(true);
286 checker.set_enabled(false);
287 }
288
289 #[test]
290 fn set_enabled_propagates_through_watch_channel() {
291 let rt = tokio::runtime::Builder::new_current_thread()
292 .enable_time()
293 .build()
294 .unwrap();
295 let checker = UpdateChecker::new(rt.handle(), false);
296
297 // Subscribe directly to the same channel by cloning the sender's
298 // receiver — this is the contract the background task relies on.
299 let mut rx = checker
300 .enabled_tx
301 .as_ref()
302 .expect("real checker has a watch sender")
303 .subscribe();
304 assert!(!*rx.borrow());
305
306 checker.set_enabled(true);
307 assert!(*rx.borrow_and_update());
308
309 checker.set_enabled(false);
310 assert!(!*rx.borrow_and_update());
311 }
312
313 #[test]
314 fn update_response_deserializes() {
315 let json = r#"{"version":"1.2.0","url":"https://example.com/dl","notes":"Bug fixes"}"#;
316 let resp: UpdateResponse = serde_json::from_str(json).unwrap();
317 assert_eq!(resp.version, "1.2.0");
318 assert_eq!(resp.url, "https://example.com/dl");
319 assert_eq!(resp.notes, "Bug fixes");
320 }
321
322 #[test]
323 fn current_version_is_valid_semver() {
324 Version::parse(CURRENT_VERSION)
325 .expect("CURRENT_VERSION should be valid semver");
326 }
327 }
328