Skip to main content

max / audiofiles

4.6 KB · 147 lines History Blame Raw
1 //! Native OS drag-out: lets users drag samples from the file list to
2 //! Finder/Explorer, DAWs, or any drop target.
3 //!
4 //! Content-addressed files have hash-based names on disk, so we create
5 //! temporary links with friendly names before starting the drag.
6 //!
7 //! Platform backends:
8 //! - macOS: `NSDraggingSession` via objc2
9 //! - Windows: `DoDragDrop` + COM `IDataObject`/`IDropSource` with `CF_HDROP`
10
11 use std::path::{Path, PathBuf};
12 use std::sync::atomic::{AtomicBool, Ordering};
13
14 use tracing::warn;
15
16 /// Prevents re-entrant calls while a drag is pending or in progress.
17 static DRAG_ACTIVE: AtomicBool = AtomicBool::new(false);
18
19 #[cfg(target_os = "macos")]
20 mod macos;
21 #[cfg(target_os = "windows")]
22 mod windows;
23
24 /// A file to be dragged out of the application.
25 pub struct DragFile {
26 /// Display name the drop target will see (e.g. "My Kick.wav").
27 pub friendly_name: String,
28 /// Absolute path to the content-addressed file in the sample store.
29 pub store_path: PathBuf,
30 }
31
32 // ---------- Shared: temp file preparation ----------
33
34 /// Prepare a temp directory with friendly-named links to the store files.
35 /// Uses symlinks on Unix, hard links (with copy fallback) on Windows.
36 fn prepare_files(files: &[DragFile]) -> Vec<PathBuf> {
37 let dir = drag_dir();
38
39 // Clean previous drag artifacts.
40 let _ = std::fs::remove_dir_all(&dir);
41 if std::fs::create_dir_all(&dir).is_err() {
42 warn!("Failed to create drag temp dir");
43 return Vec::new();
44 }
45
46 let mut result = Vec::with_capacity(files.len());
47
48 for file in files {
49 // Sanitize friendly name: reject path separators and traversal sequences.
50 let safe_name = file.friendly_name.replace(['/', '\\'], "_");
51 let safe_name = safe_name.replace("..", "_");
52 let safe_name = safe_name.trim().to_string();
53 if safe_name.is_empty() {
54 warn!(name = %file.friendly_name, "Empty or invalid drag filename, skipping");
55 continue;
56 }
57 let mut target = dir.join(&safe_name);
58
59 // Handle name collisions with (1), (2), ... suffixes.
60 if target.exists() {
61 let stem = Path::new(&safe_name)
62 .file_stem()
63 .unwrap_or_default()
64 .to_string_lossy()
65 .into_owned();
66 let ext = Path::new(&safe_name)
67 .extension()
68 .map(|e| format!(".{}", e.to_string_lossy()))
69 .unwrap_or_default();
70 for n in 1..1000 {
71 target = dir.join(format!("{stem} ({n}){ext}"));
72 if !target.exists() {
73 break;
74 }
75 }
76 }
77
78 if create_link(&file.store_path, &target).is_err() {
79 warn!(name = %file.friendly_name, "Failed to create drag link");
80 continue;
81 }
82
83 result.push(target);
84 }
85
86 result
87 }
88
89 fn drag_dir() -> PathBuf {
90 std::env::temp_dir().join(format!("audiofiles-drag-{}", std::process::id()))
91 }
92
93 /// Create a filesystem link from `original` to `link`.
94 /// macOS: symlink. Windows: hard link, falling back to copy.
95 fn create_link(original: &Path, link: &Path) -> std::io::Result<()> {
96 #[cfg(unix)]
97 {
98 std::os::unix::fs::symlink(original, link)
99 }
100 #[cfg(windows)]
101 {
102 // Hard links avoid copying but require same volume.
103 // Fall back to copy if hard link fails.
104 std::fs::hard_link(original, link).or_else(|_| std::fs::copy(original, link).map(|_| ()))
105 }
106 }
107
108 // ---------- Public API ----------
109
110 /// Start a native OS drag session for the given files.
111 ///
112 /// Returns `true` if the drag session was successfully initiated.
113 /// Returns `false` gracefully on any failure (no window, no event, link error).
114 #[tracing::instrument(skip_all, fields(count = files.len()))]
115 pub fn begin_drag(files: &[DragFile]) -> bool {
116 // Only one drag session at a time (macOS defers via dispatch_async,
117 // so this guard persists across the async boundary).
118 if DRAG_ACTIVE
119 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
120 .is_err()
121 {
122 return true; // already in progress
123 }
124
125 let paths = prepare_files(files);
126 if paths.is_empty() {
127 DRAG_ACTIVE.store(false, Ordering::Release);
128 return false;
129 }
130
131 #[cfg(target_os = "macos")]
132 {
133 macos::begin_drag_session(&paths)
134 }
135 #[cfg(target_os = "windows")]
136 {
137 let result = windows::begin_drag_session(&paths);
138 DRAG_ACTIVE.store(false, Ordering::Release);
139 result
140 }
141 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
142 {
143 DRAG_ACTIVE.store(false, Ordering::Release);
144 false
145 }
146 }
147