//! Native OS drag-out: lets users drag samples from the file list to //! Finder/Explorer, DAWs, or any drop target. //! //! Content-addressed files have hash-based names on disk, so we create //! temporary links with friendly names before starting the drag. //! //! Platform backends: //! - macOS: `NSDraggingSession` via objc2 //! - Windows: `DoDragDrop` + COM `IDataObject`/`IDropSource` with `CF_HDROP` use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use tracing::warn; /// Prevents re-entrant calls while a drag is pending or in progress. static DRAG_ACTIVE: AtomicBool = AtomicBool::new(false); #[cfg(target_os = "macos")] mod macos; #[cfg(target_os = "windows")] mod windows; /// A file to be dragged out of the application. pub struct DragFile { /// Display name the drop target will see (e.g. "My Kick.wav"). pub friendly_name: String, /// Absolute path to the content-addressed file in the sample store. pub store_path: PathBuf, } // ---------- Shared: temp file preparation ---------- /// Prepare a temp directory with friendly-named links to the store files. /// Uses symlinks on Unix, hard links (with copy fallback) on Windows. fn prepare_files(files: &[DragFile]) -> Vec { let dir = drag_dir(); // Clean previous drag artifacts. let _ = std::fs::remove_dir_all(&dir); if std::fs::create_dir_all(&dir).is_err() { warn!("Failed to create drag temp dir"); return Vec::new(); } let mut result = Vec::with_capacity(files.len()); for file in files { // Sanitize friendly name: reject path separators and traversal sequences. let safe_name = file.friendly_name.replace(['/', '\\'], "_"); let safe_name = safe_name.replace("..", "_"); let safe_name = safe_name.trim().to_string(); if safe_name.is_empty() { warn!(name = %file.friendly_name, "Empty or invalid drag filename, skipping"); continue; } let mut target = dir.join(&safe_name); // Handle name collisions with (1), (2), ... suffixes. if target.exists() { let stem = Path::new(&safe_name) .file_stem() .unwrap_or_default() .to_string_lossy() .into_owned(); let ext = Path::new(&safe_name) .extension() .map(|e| format!(".{}", e.to_string_lossy())) .unwrap_or_default(); for n in 1..1000 { target = dir.join(format!("{stem} ({n}){ext}")); if !target.exists() { break; } } } if create_link(&file.store_path, &target).is_err() { warn!(name = %file.friendly_name, "Failed to create drag link"); continue; } result.push(target); } result } fn drag_dir() -> PathBuf { std::env::temp_dir().join(format!("audiofiles-drag-{}", std::process::id())) } /// Create a filesystem link from `original` to `link`. /// macOS: symlink. Windows: hard link, falling back to copy. fn create_link(original: &Path, link: &Path) -> std::io::Result<()> { #[cfg(unix)] { std::os::unix::fs::symlink(original, link) } #[cfg(windows)] { // Hard links avoid copying but require same volume. // Fall back to copy if hard link fails. std::fs::hard_link(original, link).or_else(|_| std::fs::copy(original, link).map(|_| ())) } } // ---------- Public API ---------- /// Start a native OS drag session for the given files. /// /// Returns `true` if the drag session was successfully initiated. /// Returns `false` gracefully on any failure (no window, no event, link error). #[tracing::instrument(skip_all, fields(count = files.len()))] pub fn begin_drag(files: &[DragFile]) -> bool { // Only one drag session at a time (macOS defers via dispatch_async, // so this guard persists across the async boundary). if DRAG_ACTIVE .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_err() { return true; // already in progress } let paths = prepare_files(files); if paths.is_empty() { DRAG_ACTIVE.store(false, Ordering::Release); return false; } #[cfg(target_os = "macos")] { macos::begin_drag_session(&paths) } #[cfg(target_os = "windows")] { let result = windows::begin_drag_session(&paths); DRAG_ACTIVE.store(false, Ordering::Release); result } #[cfg(not(any(target_os = "macos", target_os = "windows")))] { DRAG_ACTIVE.store(false, Ordering::Release); false } }