Skip to main content

max / audiofiles

7.5 KB · 252 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
11 /// OTA updater endpoint base URL.
12 const OTA_BASE_URL: &str = "https://makenot.work/api/v1/sync/ota/audiofiles";
13
14 /// How long to wait after startup before first check (seconds).
15 const INITIAL_DELAY_SECS: u64 = 10;
16
17 /// How often to re-check for updates (seconds). 6 hours.
18 const CHECK_INTERVAL_SECS: u64 = 6 * 60 * 60;
19
20 /// Current app version (from Cargo.toml at compile time).
21 const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
22
23 /// The response format from the MNW OTA updater endpoint.
24 #[derive(serde::Deserialize)]
25 struct UpdateResponse {
26 version: String,
27 url: String,
28 notes: String,
29 }
30
31 /// Shared update status, polled by the UI each frame.
32 #[derive(Clone, Default)]
33 pub struct UpdateStatus {
34 pub available: bool,
35 pub version: String,
36 pub notes: String,
37 pub download_url: String,
38 pub dismissed: bool,
39 }
40
41 /// Handle to the update checker. Clone-cheap (Arc-wrapped).
42 #[derive(Clone)]
43 pub struct UpdateChecker {
44 pub status: Arc<Mutex<UpdateStatus>>,
45 }
46
47 impl UpdateChecker {
48 /// Create a new checker and spawn the background check loop on the given runtime.
49 pub fn new(runtime: &tokio::runtime::Handle) -> Self {
50 let status = Arc::new(Mutex::new(UpdateStatus::default()));
51 let checker = Self { status: status.clone() };
52
53 runtime.spawn(async move {
54 tokio::time::sleep(std::time::Duration::from_secs(INITIAL_DELAY_SECS)).await;
55 loop {
56 check_once(&status).await;
57 tokio::time::sleep(std::time::Duration::from_secs(CHECK_INTERVAL_SECS)).await;
58 }
59 });
60
61 checker
62 }
63
64 /// Construct an inert checker that never contacts the network. Used when
65 /// the user has disabled `check_for_updates` in preferences — keeps the
66 /// rest of the app's `update_checker` plumbing trivial (no Options).
67 pub fn disabled() -> Self {
68 Self {
69 status: Arc::new(Mutex::new(UpdateStatus::default())),
70 }
71 }
72
73 /// Dismiss the update notification (user clicked dismiss).
74 pub fn dismiss(&self) {
75 self.status.lock().dismissed = true;
76 }
77
78 /// Whether to show the update banner.
79 pub fn should_show(&self) -> bool {
80 let s = self.status.lock();
81 s.available && !s.dismissed
82 }
83 }
84
85 /// Verify that a download URL points to a trusted domain.
86 pub fn is_trusted_download_url(url: &str) -> bool {
87 const TRUSTED_PREFIXES: &[&str] = &[
88 "https://makenot.work/",
89 "https://dist.makenot.work/",
90 ];
91 TRUSTED_PREFIXES.iter().any(|prefix| url.starts_with(prefix))
92 }
93
94 /// Check the MNW OTA endpoint once.
95 async fn check_once(status: &Arc<Mutex<UpdateStatus>>) {
96 let current = match Version::parse(CURRENT_VERSION) {
97 Ok(v) => v,
98 Err(e) => {
99 tracing::warn!("Failed to parse current version {CURRENT_VERSION}: {e}");
100 return;
101 }
102 };
103
104 let target = if cfg!(target_os = "macos") {
105 "darwin"
106 } else if cfg!(target_os = "linux") {
107 "linux"
108 } else if cfg!(target_os = "windows") {
109 "windows"
110 } else {
111 return;
112 };
113
114 let arch = if cfg!(target_arch = "x86_64") {
115 "x86_64"
116 } else if cfg!(target_arch = "aarch64") {
117 "aarch64"
118 } else {
119 return;
120 };
121
122 let url = format!("{OTA_BASE_URL}/{target}/{arch}/{CURRENT_VERSION}");
123
124 let client = match reqwest::Client::builder()
125 .timeout(std::time::Duration::from_secs(15))
126 .build()
127 {
128 Ok(c) => c,
129 Err(e) => {
130 tracing::warn!("Failed to build HTTP client for update check: {e}");
131 return;
132 }
133 };
134
135 match client.get(&url).send().await {
136 Ok(resp) if resp.status().as_u16() == 204 => {
137 tracing::info!("audiofiles is up to date (v{CURRENT_VERSION})");
138 }
139 Ok(resp) if resp.status().is_success() => {
140 match resp.json::<UpdateResponse>().await {
141 Ok(update) => {
142 if let Ok(remote) = Version::parse(&update.version) {
143 if remote > current && is_trusted_download_url(&update.url) {
144 tracing::info!("Update available: v{}", update.version);
145 let mut s = status.lock();
146 s.available = true;
147 s.version = update.version;
148 s.notes = update.notes;
149 s.download_url = update.url;
150 }
151 }
152 }
153 Err(e) => {
154 tracing::warn!("Failed to parse update response: {e}");
155 }
156 }
157 }
158 Ok(resp) => {
159 tracing::debug!("Update check returned status {}", resp.status());
160 }
161 Err(e) => {
162 tracing::warn!("Update check request failed: {e}");
163 }
164 }
165 }
166
167 #[cfg(test)]
168 mod tests {
169 use super::*;
170
171 #[test]
172 fn update_status_default_is_inactive() {
173 let s = UpdateStatus::default();
174 assert!(!s.available);
175 assert!(!s.dismissed);
176 assert!(s.version.is_empty());
177 assert!(s.notes.is_empty());
178 assert!(s.download_url.is_empty());
179 }
180
181 #[test]
182 fn dismiss_sets_flag() {
183 let checker = UpdateChecker {
184 status: Arc::new(Mutex::new(UpdateStatus::default())),
185 };
186 assert!(!checker.status.lock().dismissed);
187 checker.dismiss();
188 assert!(checker.status.lock().dismissed);
189 }
190
191 #[test]
192 fn should_show_when_available_and_not_dismissed() {
193 let checker = UpdateChecker {
194 status: Arc::new(Mutex::new(UpdateStatus {
195 available: true,
196 dismissed: false,
197 version: "1.0.0".to_string(),
198 ..Default::default()
199 })),
200 };
201 assert!(checker.should_show());
202 }
203
204 #[test]
205 fn should_not_show_when_not_available() {
206 let checker = UpdateChecker {
207 status: Arc::new(Mutex::new(UpdateStatus::default())),
208 };
209 assert!(!checker.should_show());
210 }
211
212 #[test]
213 fn should_not_show_when_dismissed() {
214 let checker = UpdateChecker {
215 status: Arc::new(Mutex::new(UpdateStatus {
216 available: true,
217 dismissed: true,
218 ..Default::default()
219 })),
220 };
221 assert!(!checker.should_show());
222 }
223
224 #[test]
225 fn dismiss_then_should_show_returns_false() {
226 let checker = UpdateChecker {
227 status: Arc::new(Mutex::new(UpdateStatus {
228 available: true,
229 ..Default::default()
230 })),
231 };
232 assert!(checker.should_show());
233 checker.dismiss();
234 assert!(!checker.should_show());
235 }
236
237 #[test]
238 fn update_response_deserializes() {
239 let json = r#"{"version":"1.2.0","url":"https://example.com/dl","notes":"Bug fixes"}"#;
240 let resp: UpdateResponse = serde_json::from_str(json).unwrap();
241 assert_eq!(resp.version, "1.2.0");
242 assert_eq!(resp.url, "https://example.com/dl");
243 assert_eq!(resp.notes, "Bug fixes");
244 }
245
246 #[test]
247 fn current_version_is_valid_semver() {
248 Version::parse(CURRENT_VERSION)
249 .expect("CURRENT_VERSION should be valid semver");
250 }
251 }
252