max / audiofiles
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() |