Skip to main content

max / audiofiles

UI/App: trial fail-open, drag-out lifetime/COM fixes, cached disk probe Ultra-fuzz run-6 remediation, UI & App State axis. - trial (license.rs/activation.rs): a present-but-corrupt trial.json (unparseable first_launch_date) now loads as None so a fresh trial starts, instead of leaving the user with a disabled "Continue trial" button and no recovery. Clock-rollback handling switched from a permanent zero (which locked the user out, contrary to trial-mode.md "not DRM, does not lock the user out") to a monotonic max-elapsed floor: a rolled-back clock can't inflate the remaining days (anti-cheat) nor permanently expire a genuine trial. "1 day"/"N days" pluralized. - export disk-space probe (export_screens.rs): memoized in egui temp data keyed on the destination, so the config screen no longer issues a statvfs/GetDiskFreeSpace syscall every frame (hitches on slow/network/removable destinations). - macOS drag-out (macos.rs/mod.rs): keep the DragSource alive for the whole session in a thread-local (AppKit only weakly references it), so the endedAtPoint delegate that clears DRAG_ACTIVE can't fire on a freed object. Added a 30s stale-guard reclaim in begin_drag so a lost end-callback can't wedge drag-out permanently. - Windows drag-out (windows.rs/mod.rs): DoDragDrop moved onto a dedicated COM apartment (STA) thread so the modal drag loop no longer freezes the egui frame; this also fixes the OleInitialize/OleUninitialize imbalance (a fresh thread is never already-initialized, so we legitimately own the uninit — the old code uninitialized even on the S_FALSE "already initialized" path that windows-rs maps to Ok). The thread reclaims temp files + clears DRAG_ACTIVE on end, like macOS. IEnumFORMATETC::Skip now returns S_FALSE when it can't advance past celt and guards the counter against overflow. NOTE: the macOS and Windows drag_out modules are cfg-gated and CANNOT be compiled on this Linux host. They are written carefully but UNVERIFIED — build on mbp (macOS) and a Windows host before trusting. Linux build + all 12 workspace test suites pass; clippy clean (drag threads route through the spawn_detached seal). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-23 16:22 UTC
Commit: 387fcc6a372a0017757b086e2a04058c10839d12
Parent: 6c331d6
6 files changed, +173 insertions, -55 deletions
@@ -60,7 +60,8 @@ impl AudioFilesApp {
60 60 Some(ref trial) => {
61 61 let days = super::license::trial_days_remaining(trial);
62 62 if days > 0 {
63 - format!("Continue trial ({days} days left)")
63 + let unit = if days == 1 { "day" } else { "days" };
64 + format!("Continue trial ({days} {unit} left)")
64 65 } else {
65 66 "Trial expired".to_string()
66 67 }
@@ -269,7 +269,13 @@ pub struct TrialState {
269 269 pub fn load_trial(config_dir: &Path) -> Option<TrialState> {
270 270 let path = config_dir.join("trial.json");
271 271 let bytes = std::fs::read(&path).ok()?;
272 - serde_json::from_slice(&bytes).ok()
272 + let state: TrialState = serde_json::from_slice(&bytes).ok()?;
273 + // Fail-open: a present-but-corrupt trial (unparseable first_launch_date) is
274 + // treated as no trial, so the caller starts a fresh one rather than leaving
275 + // the user with a disabled "Continue trial" button and no recovery
276 + // (trial-mode.md: deleting/breaking the config just grants another 30 days).
277 + chrono::DateTime::parse_from_rfc3339(&state.first_launch_date).ok()?;
278 + Some(state)
273 279 }
274 280
275 281 /// Save the trial state to `trial.json` in the config directory.
@@ -283,23 +289,29 @@ pub fn save_trial(config_dir: &Path, state: &TrialState) -> io::Result<()> {
283 289
284 290 /// Calculate days remaining in the trial (goes negative after day 30).
285 291 ///
286 - /// Detects system clock rollback: if `now < last_seen_date`, assumes the clock
287 - /// was set back to extend the trial and returns 0 (expired).
292 + /// Uses the furthest-seen elapsed time (max of now-since-first and
293 + /// last_seen-since-first). A clock rolled backward therefore cannot *inflate* the
294 + /// remaining days (the cheat the monotonic floor blocks), but it also never
295 + /// permanently zeros a genuine trial on a legitimate NTP/DST correction — the old
296 + /// behavior, which contradicted trial-mode.md ("not DRM… does not lock the user
297 + /// out"). A corrupt `first_launch_date` is treated as expired here, but
298 + /// [`load_trial`] regenerates such a trial before this is reached.
288 299 pub fn trial_days_remaining(trial: &TrialState) -> i64 {
289 300 let Ok(first) = chrono::DateTime::parse_from_rfc3339(&trial.first_launch_date) else {
290 301 return 0;
291 302 };
292 303 let now = chrono::Utc::now();
293 304
294 - // Clock rollback detection: if now is before last_seen_date, expire immediately
305 + let mut elapsed = now.signed_duration_since(first);
295 306 if let Some(ref last) = trial.last_seen_date
296 307 && let Ok(last_seen) = chrono::DateTime::parse_from_rfc3339(last)
297 - && now.signed_duration_since(last_seen).num_hours() < -1 {
298 - // Allow up to 1 hour of drift (DST, NTP correction)
299 - return 0;
300 - }
308 + {
309 + let seen_elapsed = last_seen.signed_duration_since(first);
310 + if seen_elapsed > elapsed {
311 + elapsed = seen_elapsed;
312 + }
313 + }
301 314
302 - let elapsed = now.signed_duration_since(first);
303 315 30 - elapsed.num_days()
304 316 }
305 317
@@ -456,14 +468,29 @@ mod tests {
456 468 }
457 469
458 470 #[test]
459 - fn trial_clock_rollback_detected() {
471 + fn trial_clock_rollback_does_not_inflate_or_lock_out() {
460 472 let now = chrono::Utc::now();
461 - let future = now + chrono::Duration::days(10);
473 + let seen = now + chrono::Duration::days(10);
462 474 let state = TrialState {
463 475 first_launch_date: now.to_rfc3339(),
464 - last_seen_date: Some(future.to_rfc3339()),
476 + last_seen_date: Some(seen.to_rfc3339()),
465 477 };
466 - // now < last_seen_date by 10 days → clock was rolled back → expire
467 - assert_eq!(trial_days_remaining(&state), 0);
478 + // Clock rolled back 10 days: the monotonic floor uses the furthest-seen
479 + // elapsed (10 days), so remaining is 20 — not inflated to a fresh 30 (the
480 + // cheat) and not permanently zeroed (the lock-out trial-mode.md forbids).
481 + assert_eq!(trial_days_remaining(&state), 20);
482 + }
483 +
484 + #[test]
485 + fn load_trial_corrupt_date_returns_none() {
486 + let dir = tempfile::tempdir().unwrap();
487 + // Valid JSON, garbage date: fail-open treats it as no trial so a fresh one
488 + // is started rather than locking the user out with a disabled button.
489 + std::fs::write(
490 + dir.path().join("trial.json"),
491 + r#"{"first_launch_date":"not-a-date","last_seen_date":null}"#,
492 + )
493 + .unwrap();
494 + assert!(load_trial(dir.path()).is_none());
468 495 }
469 496 }
@@ -13,6 +13,8 @@ use std::ffi::c_void;
13 13 use std::path::PathBuf;
14 14 use std::sync::atomic::Ordering;
15 15
16 + use std::cell::RefCell;
17 +
16 18 use block2::RcBlock;
17 19 use objc2::rc::Retained;
18 20 use objc2::runtime::{NSObject, NSObjectProtocol, ProtocolObject};
@@ -63,10 +65,29 @@ define_class!(
63 65 // The drop target has taken what it needs; reclaim the temp links.
64 66 super::cleanup_drag_dir();
65 67 super::DRAG_ACTIVE.store(false, Ordering::Release);
68 + // Release the source we kept alive for this session (see ACTIVE_SOURCE).
69 + // Safe to drop our Retained here: AppKit holds its own reference for the
70 + // duration of this delegate call.
71 + ACTIVE_SOURCE.with(|s| {
72 + let _ = s.borrow_mut().take();
73 + });
66 74 }
67 75 }
68 76 );
69 77
78 + thread_local! {
79 + /// Keeps the active drag's `DragSource` alive for the whole session. AppKit
80 + /// retains only an *unowned* reference to the source handed to
81 + /// `beginDraggingSessionWithItems:event:source:`, so without this the local
82 + /// `Retained` would drop at function return and the `endedAtPoint` delegate —
83 + /// the only thing that clears `DRAG_ACTIVE` — could fire on a freed object (or
84 + /// not at all), wedging every later drag. One slot: macOS drags are gated to
85 + /// one at a time by `DRAG_ACTIVE`. Cleared in `_session_ended`. Thread-local
86 + /// because `DragSource` is `MainThreadOnly` (not `Send`) and every drag op runs
87 + /// on the main thread.
88 + static ACTIVE_SOURCE: RefCell<Option<Retained<DragSource>>> = const { RefCell::new(None) };
89 + }
90 +
70 91 impl DragSource {
71 92 fn new(mtm: MainThreadMarker) -> Retained<Self> {
72 93 // SAFETY: `alloc` + `init` is the standard NSObject construction pattern.
@@ -144,6 +165,10 @@ fn try_begin_drag(paths: &[PathBuf]) -> bool {
144 165
145 166 debug!(count = items.len(), "Starting NSDraggingSession");
146 167 let _session = view.beginDraggingSessionWithItems_event_source(&array, &event, source_proto);
168 + // Keep the source alive until `_session_ended` fires (AppKit only weakly
169 + // references it). The earlier `source_proto` borrow ends here, so we can move
170 + // `source` into the holder.
171 + ACTIVE_SOURCE.with(|s| *s.borrow_mut() = Some(source));
147 172 true
148 173 }
149 174
@@ -9,13 +9,29 @@
9 9 //! - Windows: `DoDragDrop` + COM `IDataObject`/`IDropSource` with `CF_HDROP`
10 10
11 11 use std::path::{Path, PathBuf};
12 - use std::sync::atomic::{AtomicBool, Ordering};
12 + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
13 + use std::time::{SystemTime, UNIX_EPOCH};
13 14
14 15 use tracing::warn;
15 16
16 17 /// Prevents re-entrant calls while a drag is pending or in progress.
17 18 static DRAG_ACTIVE: AtomicBool = AtomicBool::new(false);
18 19
20 + /// Wall-clock ms when the current drag was initiated, for stale-guard reclaim.
21 + static DRAG_STARTED_MS: AtomicU64 = AtomicU64::new(0);
22 +
23 + /// A drag whose end callback never arrives within this window is assumed lost
24 + /// (e.g. a session that never really started), so the guard is reclaimed rather
25 + /// than wedging drag-out permanently.
26 + const DRAG_STALE_MS: u64 = 30_000;
27 +
28 + fn now_ms() -> u64 {
29 + SystemTime::now()
30 + .duration_since(UNIX_EPOCH)
31 + .map(|d| d.as_millis() as u64)
32 + .unwrap_or(0)
33 + }
34 +
19 35 #[cfg(target_os = "macos")]
20 36 mod macos;
21 37 #[cfg(target_os = "windows")]
@@ -136,8 +152,18 @@ pub fn begin_drag(files: &[DragFile]) -> bool {
136 152 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
137 153 .is_err()
138 154 {
139 - return true; // already in progress
155 + // Already active. If the previous session's end callback was lost (a drag
156 + // that never really started), reclaim a stale guard so drag-out can't
157 + // wedge forever; otherwise it's a genuine in-progress drag.
158 + let started = DRAG_STARTED_MS.load(Ordering::Acquire);
159 + if started == 0 || now_ms().saturating_sub(started) <= DRAG_STALE_MS {
160 + return true; // genuinely in progress
161 + }
162 + warn!("Reclaiming stale drag guard (end callback never arrived)");
163 + cleanup_drag_dir();
164 + // We continue owning the guard (still `true`).
140 165 }
166 + DRAG_STARTED_MS.store(now_ms(), Ordering::Release);
141 167
142 168 let paths = prepare_files(files);
143 169 if paths.is_empty() {
@@ -151,12 +177,11 @@ pub fn begin_drag(files: &[DragFile]) -> bool {
151 177 }
152 178 #[cfg(target_os = "windows")]
153 179 {
154 - // DoDragDrop is synchronous: the drag is fully done once it returns,
155 - // so the temp links/copies can be reclaimed immediately.
156 - let result = windows::begin_drag_session(&paths);
157 - cleanup_drag_dir();
158 - DRAG_ACTIVE.store(false, Ordering::Release);
159 - result
180 + // DoDragDrop now runs on a dedicated COM apartment thread (so the modal
181 + // drag loop no longer freezes the egui frame). That thread reclaims the
182 + // temp links and clears DRAG_ACTIVE when the drag ends, like macOS — so we
183 + // must NOT clean up here (that would race the in-flight drag).
184 + windows::begin_drag_session(&paths)
160 185 }
161 186 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
162 187 {
@@ -155,8 +155,16 @@ impl IEnumFORMATETC_Impl for HDropFormatEnum_Impl {
155 155 }
156 156
157 157 fn Skip(&self, celt: u32) -> windows_core::Result<()> {
158 - self.pos.fetch_add(celt, Ordering::Relaxed);
159 - Ok(())
158 + // Exactly one format (CF_HDROP) at index 0. Per the IEnumFORMATETC
159 + // contract, Skip must return S_FALSE when it cannot advance past `celt`
160 + // elements; saturating_add guards the counter against overflow.
161 + let advanced = self.pos.load(Ordering::Relaxed).saturating_add(celt);
162 + self.pos.store(advanced.min(1), Ordering::Relaxed);
163 + if advanced > 1 {
164 + Err(Error::from(S_FALSE))
165 + } else {
166 + Ok(())
167 + }
160 168 }
161 169
162 170 fn Reset(&self) -> windows_core::Result<()> {
@@ -283,32 +291,52 @@ impl IDataObject_Impl for FileDataObject_Impl {
283 291
284 292 // ---------- Entry point ----------
285 293
286 - /// Start a blocking OLE drag-drop session. Returns `true` if the user
287 - /// completed the drop (as opposed to cancelling or dragging to a
288 - /// non-accepting target).
294 + /// Start an OLE drag-drop session on a dedicated COM apartment thread.
295 + ///
296 + /// `DoDragDrop` runs a modal nested message loop; on the egui thread it froze the
297 + /// whole UI for the duration of the drag. It now runs on its own STA thread, which
298 + /// also fixes the OleInitialize/OleUninitialize balance: a fresh thread is never
299 + /// already-initialized, so `OleInitialize` returns S_OK and we legitimately own the
300 + /// matching `OleUninitialize` — the old code called `OleUninitialize` even on the
301 + /// S_FALSE "already initialized" path (which windows-rs maps to `Ok`), tearing down
302 + /// the app's OLE after a single drag. The thread reclaims temp files and clears
303 + /// `DRAG_ACTIVE` when the drag ends, mirroring the macOS `endedAtPoint` model.
304 + ///
305 + /// Returns `true` once the drag thread is spawned (the drop result is not surfaced
306 + /// synchronously, same as macOS).
289 307 pub(super) fn begin_drag_session(paths: &[PathBuf]) -> bool {
290 - // SAFETY: OleInitialize/OleUninitialize bracket the session. build_hdrop is
291 - // called with valid paths and its HGLOBAL is owned by FileDataObject (freed
292 - // on drop). COM objects are constructed via safe `into()`. DoDragDrop is the
293 - // standard OLE drag entry point — it blocks until the user drops or cancels.
308 + let paths = paths.to_vec();
309 + let spawned = audiofiles_core::worker_runtime::spawn_detached("win-dragdrop", move || {
310 + run_drag(&paths);
311 + super::cleanup_drag_dir();
312 + super::DRAG_ACTIVE.store(false, Ordering::Release);
313 + });
314 + if spawned.is_err() {
315 + warn!("failed to spawn drag thread");
316 + super::cleanup_drag_dir();
317 + super::DRAG_ACTIVE.store(false, Ordering::Release);
318 + return false;
319 + }
320 + true
321 + }
322 +
323 + /// Run one OLE drag on the current (dedicated STA) thread: init OLE, build the
324 + /// CF_HDROP data object, `DoDragDrop` (blocks here, off the GUI thread), uninit.
325 + fn run_drag(paths: &[PathBuf]) {
326 + // SAFETY: OleInitialize/OleUninitialize bracket the session on this thread.
327 + // build_hdrop is called with valid paths and its HGLOBAL is owned by
328 + // FileDataObject (freed on drop). COM objects are constructed via safe `into()`.
294 329 unsafe {
295 - // OleInitialize is the superset of CoInitializeEx — required for DoDragDrop.
296 - // Ok(()) means we initialized, Err with S_FALSE means already initialized (fine).
297 - let ole_result = OleInitialize(None);
298 - let we_initialized = ole_result.is_ok();
299 - if !we_initialized {
300 - // Check if it's the benign "already initialized" case
301 - if let Err(ref e) = ole_result {
302 - if e.code() != S_FALSE {
303 - warn!(?ole_result, "OleInitialize failed");
304 - return false;
305 - }
306 - }
330 + // Fresh thread → S_OK (we own the init). An Err here is a real failure
331 + // (S_FALSE "already initialized" is impossible on a brand-new thread and
332 + // would be Ok regardless).
333 + if let Err(e) = OleInitialize(None) {
334 + warn!(%e, "OleInitialize failed on drag thread");
335 + return;
307 336 }
308 337
309 338 let result = (|| -> Result<bool> {
310 339 let (hdrop, size) = build_hdrop(paths)?;
311 -
312 340 let data_object: IDataObject = FileDataObject { hdrop, size }.into();
313 341 let drop_source: IDropSource = DropSource.into();
314 342
@@ -319,16 +347,11 @@ pub(super) fn begin_drag_session(paths: &[PathBuf]) -> bool {
319 347 Ok(hr == DRAGDROP_S_DROP)
320 348 })();
321 349
322 - if we_initialized {
323 - OleUninitialize();
350 + if let Err(e) = &result {
351 + warn!(%e, "Drag failed");
324 352 }
325 353
326 - match result {
327 - Ok(dropped) => dropped,
328 - Err(e) => {
329 - warn!(%e, "Drag failed");
330 - false
331 - }
332 - }
354 + // Always balanced with the OleInitialize above (we always owned the init).
355 + OleUninitialize();
333 356 }
334 357 }
@@ -154,7 +154,24 @@ pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
154 154 // heuristic. Only warn when the projection exceeds available space
155 155 // with a 10% headroom. Yellow because this is an anticipation
156 156 // warning, not a confirmed failure.
157 - if let Some(available) = available_disk_space(&config.destination) {
157 + //
158 + // Memoize the statvfs/GetDiskFreeSpaceEx probe in egui temp data keyed
159 + // on the destination, so a static config screen doesn't issue a disk
160 + // syscall every frame (which hitches on a slow/network/removable dest).
161 + let disk_cache_id = egui::Id::new("export_disk_space_probe");
162 + let cached: Option<(std::path::PathBuf, Option<u64>)> =
163 + ui.data(|d| d.get_temp(disk_cache_id));
164 + let available = match cached {
165 + Some((path, value)) if path == config.destination => value,
166 + _ => {
167 + let value = available_disk_space(&config.destination);
168 + ui.data_mut(|d| {
169 + d.insert_temp(disk_cache_id, (config.destination.clone(), value))
170 + });
171 + value
172 + }
173 + };
174 + if let Some(available) = available {
158 175 let bps = bytes_per_sec_for_config(config);
159 176 let estimated_bytes: u64 = items
160 177 .iter()