| 7 |
7 |
|
|
| 8 |
8 |
|
use parking_lot::Mutex;
|
| 9 |
9 |
|
use semver::Version;
|
|
10 |
+ |
use tokio::sync::watch;
|
| 10 |
11 |
|
|
| 11 |
12 |
|
/// OTA updater endpoint base URL.
|
| 12 |
13 |
|
const OTA_BASE_URL: &str = "https://makenot.work/api/v1/sync/ota/audiofiles";
|
| 39 |
40 |
|
}
|
| 40 |
41 |
|
|
| 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.
|
| 42 |
48 |
|
#[derive(Clone)]
|
| 43 |
49 |
|
pub struct UpdateChecker {
|
| 44 |
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>>>,
|
| 45 |
54 |
|
}
|
| 46 |
55 |
|
|
| 47 |
56 |
|
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 {
|
|
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 {
|
| 50 |
60 |
|
let status = Arc::new(Mutex::new(UpdateStatus::default()));
|
| 51 |
|
- |
let checker = Self { status: status.clone() };
|
|
61 |
+ |
let (tx, mut rx) = watch::channel(initial_enabled);
|
| 52 |
62 |
|
|
|
63 |
+ |
let status_for_task = status.clone();
|
| 53 |
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 |
+ |
|
| 54 |
78 |
|
tokio::time::sleep(std::time::Duration::from_secs(INITIAL_DELAY_SECS)).await;
|
| 55 |
79 |
|
loop {
|
| 56 |
|
- |
check_once(&status).await;
|
| 57 |
|
- |
tokio::time::sleep(std::time::Duration::from_secs(CHECK_INTERVAL_SECS)).await;
|
|
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 |
+ |
}
|
| 58 |
96 |
|
}
|
| 59 |
97 |
|
});
|
| 60 |
98 |
|
|
| 61 |
|
- |
checker
|
|
99 |
+ |
Self {
|
|
100 |
+ |
status,
|
|
101 |
+ |
enabled_tx: Some(Arc::new(tx)),
|
|
102 |
+ |
}
|
| 62 |
103 |
|
}
|
| 63 |
104 |
|
|
| 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 {
|
|
105 |
+ |
/// Inert checker for tests / no-runtime contexts. Never spawns a task.
|
|
106 |
+ |
pub fn inert() -> Self {
|
| 68 |
107 |
|
Self {
|
| 69 |
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);
|
| 70 |
118 |
|
}
|
| 71 |
119 |
|
}
|
| 72 |
120 |
|
|
| 178 |
226 |
|
assert!(s.download_url.is_empty());
|
| 179 |
227 |
|
}
|
| 180 |
228 |
|
|
|
229 |
+ |
fn checker_with_status(status: UpdateStatus) -> UpdateChecker {
|
|
230 |
+ |
UpdateChecker {
|
|
231 |
+ |
status: Arc::new(Mutex::new(status)),
|
|
232 |
+ |
enabled_tx: None,
|
|
233 |
+ |
}
|
|
234 |
+ |
}
|
|
235 |
+ |
|
| 181 |
236 |
|
#[test]
|
| 182 |
237 |
|
fn dismiss_sets_flag() {
|
| 183 |
|
- |
let checker = UpdateChecker {
|
| 184 |
|
- |
status: Arc::new(Mutex::new(UpdateStatus::default())),
|
| 185 |
|
- |
};
|
|
238 |
+ |
let checker = UpdateChecker::inert();
|
| 186 |
239 |
|
assert!(!checker.status.lock().dismissed);
|
| 187 |
240 |
|
checker.dismiss();
|
| 188 |
241 |
|
assert!(checker.status.lock().dismissed);
|
| 190 |
243 |
|
|
| 191 |
244 |
|
#[test]
|
| 192 |
245 |
|
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 |
|
- |
};
|
|
246 |
+ |
let checker = checker_with_status(UpdateStatus {
|
|
247 |
+ |
available: true,
|
|
248 |
+ |
dismissed: false,
|
|
249 |
+ |
version: "1.0.0".to_string(),
|
|
250 |
+ |
..Default::default()
|
|
251 |
+ |
});
|
| 201 |
252 |
|
assert!(checker.should_show());
|
| 202 |
253 |
|
}
|
| 203 |
254 |
|
|
| 204 |
255 |
|
#[test]
|
| 205 |
256 |
|
fn should_not_show_when_not_available() {
|
| 206 |
|
- |
let checker = UpdateChecker {
|
| 207 |
|
- |
status: Arc::new(Mutex::new(UpdateStatus::default())),
|
| 208 |
|
- |
};
|
|
257 |
+ |
let checker = UpdateChecker::inert();
|
| 209 |
258 |
|
assert!(!checker.should_show());
|
| 210 |
259 |
|
}
|
| 211 |
260 |
|
|
| 212 |
261 |
|
#[test]
|
| 213 |
262 |
|
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 |
|
- |
};
|
|
263 |
+ |
let checker = checker_with_status(UpdateStatus {
|
|
264 |
+ |
available: true,
|
|
265 |
+ |
dismissed: true,
|
|
266 |
+ |
..Default::default()
|
|
267 |
+ |
});
|
| 221 |
268 |
|
assert!(!checker.should_show());
|
| 222 |
269 |
|
}
|
| 223 |
270 |
|
|
| 224 |
271 |
|
#[test]
|
| 225 |
272 |
|
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 |
|
- |
};
|
|
273 |
+ |
let checker = checker_with_status(UpdateStatus {
|
|
274 |
+ |
available: true,
|
|
275 |
+ |
..Default::default()
|
|
276 |
+ |
});
|
| 232 |
277 |
|
assert!(checker.should_show());
|
| 233 |
278 |
|
checker.dismiss();
|
| 234 |
279 |
|
assert!(!checker.should_show());
|
| 235 |
280 |
|
}
|
| 236 |
281 |
|
|
| 237 |
282 |
|
#[test]
|
|
283 |
+ |
fn set_enabled_on_inert_is_noop() {
|
|
284 |
+ |
let checker = UpdateChecker::inert();
|
|
285 |
+ |
// No panic, no observable side-effect — there's no task to gate.
|
|
286 |
+ |
checker.set_enabled(true);
|
|
287 |
+ |
checker.set_enabled(false);
|
|
288 |
+ |
}
|
|
289 |
+ |
|
|
290 |
+ |
#[test]
|
|
291 |
+ |
fn set_enabled_propagates_through_watch_channel() {
|
|
292 |
+ |
let rt = tokio::runtime::Builder::new_current_thread()
|
|
293 |
+ |
.enable_time()
|
|
294 |
+ |
.build()
|
|
295 |
+ |
.unwrap();
|
|
296 |
+ |
let checker = UpdateChecker::new(rt.handle(), false);
|
|
297 |
+ |
|
|
298 |
+ |
// Subscribe directly to the same channel by cloning the sender's
|
|
299 |
+ |
// receiver — this is the contract the background task relies on.
|
|
300 |
+ |
let mut rx = checker
|
|
301 |
+ |
.enabled_tx
|
|
302 |
+ |
.as_ref()
|
|
303 |
+ |
.expect("real checker has a watch sender")
|
|
304 |
+ |
.subscribe();
|
|
305 |
+ |
assert!(!*rx.borrow());
|
|
306 |
+ |
|
|
307 |
+ |
checker.set_enabled(true);
|
|
308 |
+ |
assert!(*rx.borrow_and_update());
|
|
309 |
+ |
|
|
310 |
+ |
checker.set_enabled(false);
|
|
311 |
+ |
assert!(!*rx.borrow_and_update());
|
|
312 |
+ |
}
|
|
313 |
+ |
|
|
314 |
+ |
#[test]
|
| 238 |
315 |
|
fn update_response_deserializes() {
|
| 239 |
316 |
|
let json = r#"{"version":"1.2.0","url":"https://example.com/dl","notes":"Bug fixes"}"#;
|
| 240 |
317 |
|
let resp: UpdateResponse = serde_json::from_str(json).unwrap();
|