Skip to main content

max / audiofiles

updater: runtime enable/disable via watch channel The check_for_updates pref toggle previously took effect on next launch only — the spawned tokio task kept running regardless. Now the loop is always spawned but gated on a tokio::sync::watch<bool>; the About modal calls update_checker.set_enabled(new) which signals through the channel. - Loop uses tokio::select! between the 6h interval sleep and watch.changed(), so enabling triggers an immediate check (no 6h wait) and disabling parks the task cheaply (no tear-down, no respawn). - Initial state honored before the 10s startup delay — a user with check_for_updates=false in their JSON sees zero network calls on launch. - UpdateChecker::disabled() replaced by inert() (no-runtime/test-only helper). The single non-test caller in main.rs collapses to a single UpdateChecker::new(runtime, prefs.check_for_updates). - Tests cover the watch-channel propagation and the inert no-op path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-03 02:20 UTC
Commit: 8b4c5a501dda8648933c36c20e62b84499ff1a25
Parent: 2b73641
3 files changed, +126 insertions, -49 deletions
@@ -74,15 +74,13 @@ fn main() -> eframe::Result<()> {
74 74 // Load user preferences (controls the network-touching update checker).
75 75 let prefs = preferences::Preferences::load(&config_dir);
76 76
77 - // OTA update checker (runs in background on the tokio runtime). Only
78 - // spawned if the user hasn't opted out — preserves the "no silent
79 - // network without consent" rule.
80 - let update_checker = if prefs.check_for_updates {
81 - updater::UpdateChecker::new(runtime.handle())
82 - } else {
83 - tracing::info!("Update checks disabled by preferences");
84 - updater::UpdateChecker::disabled()
85 - };
77 + // OTA update checker (runs in background on the tokio runtime). The task
78 + // is always spawned but gated on the pref — toggling the About-modal
79 + // checkbox flips the gate via `set_enabled` without restart.
80 + if !prefs.check_for_updates {
81 + tracing::info!("Update checks disabled by preferences (toggle in About to enable)");
82 + }
83 + let update_checker = updater::UpdateChecker::new(runtime.handle(), prefs.check_for_updates);
86 84
87 85 let shared = Arc::new(SharedState::new());
88 86
@@ -874,8 +872,10 @@ impl AudioFilesApp {
874 872 if updated_check_pref != self.prefs.check_for_updates {
875 873 self.prefs.check_for_updates = updated_check_pref;
876 874 self.prefs.save(&self.config_dir);
877 - // The change takes effect on next launch — we don't tear down the
878 - // already-spawned tokio task at runtime.
875 + // Apply at runtime via the watch gate. Enabling triggers an
876 + // immediate check; disabling parks the loop without tearing it
877 + // down (cheap, instant, no restart).
878 + self.update_checker.set_enabled(updated_check_pref);
879 879 }
880 880 if !open {
881 881 self.show_about = false;
@@ -7,6 +7,7 @@ use std::sync::Arc;
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,34 +40,81 @@ pub struct UpdateStatus {
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,11 +226,16 @@ mod tests {
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,51 +243,75 @@ mod tests {
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();
M todo.md +1 -1
@@ -40,7 +40,7 @@ Launch shipped 2026-06-01 (see `/Users/max/Code/launchplan_final.md`). Post-laun
40 40
41 41 ## Audit deltas to revisit
42 42
43 - - [ ] **In-app updater toggle takes effect on next launch only.** Current implementation persists the pref + skips spawn at startup but doesn't tear down the already-spawned tokio task at runtime. Either accept (cheap, restart-required) or wire a `tokio::sync::watch` cancel signal into the check loop.
43 + - [x] **In-app updater toggle now takes effect at runtime.** Wired `tokio::sync::watch<bool>` into the background loop: the task is always spawned and parked on `watch.changed()` when the pref is off, no network calls. Toggling the About-modal checkbox calls `update_checker.set_enabled(new)` which signals through the channel; enabling triggers an immediate check via the loop's `tokio::select!` between the 6h sleep and the watch. `UpdateChecker::disabled()` replaced by `inert()` (kept for tests / no-runtime contexts). Tests added for the watch-channel propagation and the inert no-op.
44 44
45 45 ## Future enhancements (not blocking)
46 46