Skip to main content

max / audiofiles

Fix ultra-fuzz UI-correctness findings: dangling cursor, drag leak, index panics - Clamp selection in refresh_contents(): new Selection::clamp_to(len) drops out-of-range selected indices and pulls anchor/focus back inside the list after a delete/purge/search shrinks contents, so the highlight can no longer dangle off the end and disagree with the detail panel. Also clamps a pending scroll_to_row. Single source-of-truth fix for the S-1 leak sites in library.rs/bulk_ops.rs. (UI S-1) - extend_to now clamps the range to 0..max_len so a stale shift-click target can't insert indices past the end and overcount count(). (UI minor) - Drag-out temp dir is now reclaimed at the true end of the drag: synchronously after DoDragDrop on Windows, in the macOS endedAtPoint callback (and its failure path). The dir path is process-id deterministic, so the async macOS handler recomputes it; a Drop-on-return guard would be wrong there since the session outlives begin_drag. Stops real copied bytes lingering in temp on Windows. (UI S-4) - Two raw index panics replaced with .get()/current_vfs_id(): the new-folder modal (overlays.rs) and the merge-vfs strategy build (configure.rs). (UI M-2/M-3) Tests: 221 browser green (3 new selection tests), clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 22:51 UTC
Commit: f8a8ff768032dfd708d382d1efc4d168566fb756
Parent: a6865ee
8 files changed, +200 insertions, -18 deletions
@@ -60,6 +60,8 @@ define_class!(
60 60 _operation: NSDragOperation,
61 61 ) {
62 62 debug!("Drag session ended");
63 + // The drop target has taken what it needs; reclaim the temp links.
64 + super::cleanup_drag_dir();
63 65 super::DRAG_ACTIVE.store(false, Ordering::Release);
64 66 }
65 67 }
@@ -81,6 +83,7 @@ impl DragSource {
81 83 /// `_session_ended` callback clears it when the drag session finishes.
82 84 fn do_begin_drag(paths: &[PathBuf]) {
83 85 if !try_begin_drag(paths) {
86 + super::cleanup_drag_dir();
84 87 super::DRAG_ACTIVE.store(false, Ordering::Release);
85 88 }
86 89 }
@@ -90,6 +90,23 @@ fn drag_dir() -> PathBuf {
90 90 std::env::temp_dir().join(format!("audiofiles-drag-{}", std::process::id()))
91 91 }
92 92
93 + /// Remove the temp drag directory and its friendly-named links.
94 + ///
95 + /// Called at the true end of a drag — synchronously after `DoDragDrop` on
96 + /// Windows, from the `draggingSession:endedAtPoint:` callback on macOS — so
97 + /// the copied/linked files don't linger in temp until the next drag or
98 + /// process exit. The directory path is deterministic (process-id based), so
99 + /// the async macOS completion handler can recompute it without holding state.
100 + /// A Drop-on-return guard would be wrong on macOS, where the session outlives
101 + /// `begin_drag`.
102 + #[cfg_attr(
103 + not(any(target_os = "macos", target_os = "windows")),
104 + allow(dead_code)
105 + )]
106 + fn cleanup_drag_dir() {
107 + let _ = std::fs::remove_dir_all(drag_dir());
108 + }
109 +
93 110 /// Create a filesystem link from `original` to `link`.
94 111 /// macOS: symlink. Windows: hard link, falling back to copy.
95 112 fn create_link(original: &Path, link: &Path) -> std::io::Result<()> {
@@ -134,7 +151,10 @@ pub fn begin_drag(files: &[DragFile]) -> bool {
134 151 }
135 152 #[cfg(target_os = "windows")]
136 153 {
154 + // DoDragDrop is synchronous: the drag is fully done once it returns,
155 + // so the temp links/copies can be reclaimed immediately.
137 156 let result = windows::begin_drag_session(&paths);
157 + cleanup_drag_dir();
138 158 DRAG_ACTIVE.store(false, Ordering::Release);
139 159 result
140 160 }
@@ -60,6 +60,17 @@ impl BrowserState {
60 60 }
61 61
62 62 self.sort_contents();
63 +
64 + // Contents may have shrunk (delete/purge/search). Pull the cursor and
65 + // any pending scroll target back inside the new bounds so the highlight
66 + // can't dangle off the end and disagree with the detail panel.
67 + let len = self.contents.len();
68 + self.selection.clamp_to(len);
69 + if let Some(row) = self.scroll_to_row
70 + && row >= len {
71 + self.scroll_to_row = if len == 0 { None } else { Some(len - 1) };
72 + }
73 +
63 74 self.refresh_selected_tags();
64 75 self.mark_mirror_dirty();
65 76 }
@@ -131,6 +131,46 @@ mod selection {
131 131 }
132 132
133 133 #[test]
134 + fn extend_to_clamps_target_to_list_len() {
135 + // A stale target past the end must not insert out-of-range indices.
136 + let mut sel = Selection::new();
137 + sel.set_single(2);
138 + sel.extend_to(99, 5); // list has 5 rows (0..=4)
139 + assert_eq!(sel.count(), 3); // 2, 3, 4 — not 2..=99
140 + assert!(sel.contains(4));
141 + assert!(!sel.contains(5));
142 + assert_eq!(sel.focus, 4);
143 + }
144 +
145 + #[test]
146 + fn clamp_to_drops_out_of_range_and_pulls_cursor_in() {
147 + let mut sel = Selection::new();
148 + sel.select_all(10);
149 + sel.focus = 9;
150 + sel.anchor = 7;
151 + // The list shrinks to 4 rows after a delete/purge.
152 + sel.clamp_to(4);
153 + assert_eq!(sel.count(), 4); // only 0..=3 survive
154 + for i in 0..4 {
155 + assert!(sel.contains(i));
156 + }
157 + assert!(!sel.contains(4));
158 + assert_eq!(sel.focus, 3);
159 + assert_eq!(sel.anchor, 3);
160 + }
161 +
162 + #[test]
163 + fn clamp_to_empty_resets_selection() {
164 + let mut sel = Selection::new();
165 + sel.select_all(6);
166 + sel.focus = 5;
167 + sel.clamp_to(0);
168 + assert_eq!(sel.count(), 0);
169 + assert_eq!(sel.focus, 0);
170 + assert_eq!(sel.anchor, 0);
171 + }
172 +
173 + #[test]
134 174 fn select_all_selects_all_items() {
135 175 let mut sel = Selection::new();
136 176 sel.select_all(5);
@@ -862,6 +862,23 @@ impl Selection {
862 862 self.focus = 0;
863 863 }
864 864
865 + /// Clamp every index to a list of `len` rows after the contents shrink.
866 + ///
867 + /// Drops selected indices that no longer exist and pulls `anchor`/`focus`
868 + /// back inside `0..len`, so a delete/purge can't leave the cursor pointing
869 + /// at a row the detail panel no longer has. With `len == 0` everything
870 + /// resets to an empty selection at index 0.
871 + pub fn clamp_to(&mut self, len: usize) {
872 + if len == 0 {
873 + self.clear();
874 + return;
875 + }
876 + let last = len - 1;
877 + self.selected.retain(|&i| i < len);
878 + self.anchor = self.anchor.min(last);
879 + self.focus = self.focus.min(last);
880 + }
881 +
865 882 /// Single-select one item, clearing all others.
866 883 pub fn set_single(&mut self, idx: usize) {
867 884 self.selected.clear();
@@ -882,11 +899,16 @@ impl Selection {
882 899 }
883 900
884 901 /// Extend selection from anchor to target (Shift+Click).
885 - /// `_max_len` is accepted for API consistency but unused — the range is
886 - /// always anchor..=target regardless of list length.
887 - pub fn extend_to(&mut self, target: usize, _max_len: usize) {
888 - let start = self.anchor.min(target);
889 - let end = self.anchor.max(target);
902 + /// The range is clamped to `0..max_len` so a stale `target` can't insert
903 + /// indices past the end of the list and overcount `count()`.
904 + pub fn extend_to(&mut self, target: usize, max_len: usize) {
905 + if max_len == 0 {
906 + return;
907 + }
908 + let last = max_len - 1;
909 + let target = target.min(last);
910 + let start = self.anchor.min(target).min(last);
911 + let end = self.anchor.max(target).min(last);
890 912 for i in start..=end {
891 913 self.selected.insert(i);
892 914 }
@@ -121,8 +121,8 @@ pub fn draw_configure_import(ui: &mut egui::Ui, state: &mut BrowserState) {
121 121 selected_merge_vfs_idx,
122 122 ..
123 123 } = state.import_mode
124 + && let Some(vfs) = available_vfs.get(selected_merge_vfs_idx)
124 125 {
125 - let vfs = &available_vfs[selected_merge_vfs_idx];
126 126 *strategy = ImportStrategy::MergeIntoVfs {
127 127 vfs_id: vfs.id,
128 128 parent_id: None,
@@ -228,22 +228,23 @@ pub fn draw_configure_import(ui: &mut egui::Ui, state: &mut BrowserState) {
228 228 {
229 229 let source = source.clone();
230 230 let strategy = match strat {
231 - ImportStrategy::Flat { vfs_id, parent_id } => ImportStrategy::Flat {
231 + ImportStrategy::Flat { vfs_id, parent_id } => Some(ImportStrategy::Flat {
232 232 vfs_id: *vfs_id,
233 233 parent_id: *parent_id,
234 - },
235 - ImportStrategy::NewVfs { .. } => ImportStrategy::NewVfs {
234 + }),
235 + ImportStrategy::NewVfs { .. } => Some(ImportStrategy::NewVfs {
236 236 vfs_name: new_vfs_name.clone(),
237 - },
238 - ImportStrategy::MergeIntoVfs { .. } => {
239 - let vfs = &available_vfs[selected_merge_vfs_idx];
240 - ImportStrategy::MergeIntoVfs {
237 + }),
238 + ImportStrategy::MergeIntoVfs { .. } => available_vfs
239 + .get(selected_merge_vfs_idx)
240 + .map(|vfs| ImportStrategy::MergeIntoVfs {
241 241 vfs_id: vfs.id,
242 242 parent_id: None,
243 - }
244 - }
243 + }),
245 244 };
246 - state.start_folder_import(source, strategy);
245 + if let Some(strategy) = strategy {
246 + state.start_folder_import(source, strategy);
247 + }
247 248 }
248 249 });
249 250 });
@@ -1020,8 +1020,7 @@ pub fn draw_dir_create_modal(ctx: &egui::Context, state: &mut BrowserState) {
1020 1020 if name.is_empty() {
1021 1021 state.show_dir_create = false;
1022 1022 state.name_modal_error = None;
1023 - } else {
1024 - let vfs_id = state.vfs_list[state.current_vfs_idx].id;
1023 + } else if let Some(vfs_id) = state.current_vfs_id() {
1025 1024 match state.backend.create_directory(vfs_id, state.current_dir, &name) {
1026 1025 Ok(_) => {
1027 1026 state.refresh_contents();
@@ -0,0 +1,86 @@
1 + # Audiofiles — Privacy Policy
2 +
3 + *Last updated: June 14, 2026*
4 +
5 + Audiofiles is published by Make Creative, LLC. This policy explains what data the app handles, where it goes, and what it does not do.
6 +
7 + ## Summary
8 +
9 + - Audiofiles is a local-first app. Your samples and your entire library — audio files, tags, analysis, and edits — live on your own device.
10 + - We do not run a server that receives your audio. We do not see your library.
11 + - Audio content never leaves your device except, optionally, through end-to-end encrypted cloud sync that you turn on yourself.
12 + - Audiofiles is a paid app. Activating a license sends only your license key and a random per-install identifier to our licensing server — no hardware fingerprint, no personal profile.
13 + - No telemetry, analytics, ads, or tracking.
14 +
15 + ## What Audiofiles Stores on Your Device
16 +
17 + Your library lives in a vault — a folder you choose on your own disk. It contains:
18 +
19 + - Your audio sample files, stored by content hash in a content-addressed store
20 + - A local database of sample metadata: original filenames, tags, collections, and your virtual folder structure
21 + - Analysis results computed on your device: BPM, musical key, loudness, spectral features, and instrument classification
22 + - Waveform data and an edit history for undo/redo
23 +
24 + Separately, a small configuration folder holds your preferences, your cached license, and a random per-install identifier (see "License Activation" below).
25 +
26 + This data stays on your device unless you choose to sync it. The local database and audio files are not encrypted at rest; they rely on your operating system's user account and any full-disk encryption you have enabled.
27 +
28 + ## License Activation
29 +
30 + Audiofiles is sold as a licensed app. When you activate or deactivate a license, the app contacts our licensing server at makenot.work and sends:
31 +
32 + - your license key, and
33 + - a per-install identifier.
34 +
35 + That identifier is a random value generated once on first run. It is **not** a hardware fingerprint and is not derived from your device, network, or any personal information — it only lets the license server count and release activations. Once activated, the license is cached locally and the app works offline; it does not re-check on every launch.
36 +
37 + ## What Audiofiles Sends Off Your Device
38 +
39 + Audiofiles makes a small, fixed set of outbound connections:
40 +
41 + - **License server (makenot.work).** On activation and deactivation only, as described above.
42 + - **Update checks (makenot.work).** The app can check for new versions. The check sends only your current app version, platform, and CPU architecture — no personal data, no library data. You can turn this off in the app's preferences, and when off the checker never runs.
43 + - **Cloud sync (optional, off by default).** If you sign in and enable sync, the app talks to a sync server — by default Make Creative's at makenot.work — using end-to-end encrypted payloads.
44 + - **Payments.** If you subscribe to sync storage, the app opens your browser to a Stripe checkout hosted through makenot.work. Audiofiles never sees or stores your card details.
45 +
46 + ## Cloud Sync Details
47 +
48 + If you enable sync:
49 +
50 + - **What can sync:** your sample metadata, tags, collections, virtual folders, analysis results, and edit history — and, for vaults you mark for file sync, the audio files themselves as encrypted blobs.
51 + - **Encryption:** everything is encrypted on your device before upload using ChaCha20-Poly1305, with a key derived from a password you set (via Argon2). The key is stored in your operating system's keychain and is never sent to the server, so the sync server operator cannot read your library.
52 + - **Authentication:** sign-in uses OAuth2 with PKCE; a temporary local callback handles the response on your machine.
53 + - **Control:** sync is off until you turn it on. Audio files only upload for vaults you explicitly mark for file sync.
54 +
55 + ## Audio Analysis and Classification
56 +
57 + All audio analysis — loudness, BPM, key, spectral features, and the instrument classifier — runs entirely on your device using a model bundled with the app. No audio is uploaded for analysis or classification. Your audio leaves the device only through cloud sync if you enable it, and only as encrypted blobs.
58 +
59 + ## Plugins
60 +
61 + Audiofiles supports user scripts (Rhai) for tasks like export naming. These run in a sandbox with no network access and no general filesystem access; they can read only the sample metadata passed to them.
62 +
63 + ## What Audiofiles Does Not Do
64 +
65 + - No analytics SDK, no crash-reporter beacons, no usage tracking
66 + - No advertising, no third-party trackers
67 + - No location collection
68 + - No selling or sharing of your data with third parties
69 +
70 + ## Your Data and Your Control
71 +
72 + - Your library is yours and lives in a vault folder you control. You can move, back up, or delete it directly.
73 + - Deleting the app does not delete your vault; remove the vault folder yourself to erase your local library.
74 + - To stop syncing, turn sync off in the app. To remove synced data from the server, contact us at info@makenot.work.
75 +
76 + ## Children
77 +
78 + Audiofiles is not directed at children under 13, and we do not knowingly collect data from them.
79 +
80 + ## Changes
81 +
82 + Material changes to this policy will be noted in the app's release notes and reflected in the date above.
83 +
84 + ## Contact
85 +
86 + info@makenot.work