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