max / audiofiles
17 files changed,
+884 insertions,
-602 deletions
| @@ -185,7 +185,7 @@ dependencies = [ | |||
| 185 | 185 | "enumflags2", | |
| 186 | 186 | "futures-channel", | |
| 187 | 187 | "futures-util", | |
| 188 | - | "rand 0.9.2", | |
| 188 | + | "rand", | |
| 189 | 189 | "raw-window-handle", | |
| 190 | 190 | "serde", | |
| 191 | 191 | "serde_repr", | |
| @@ -491,8 +491,9 @@ dependencies = [ | |||
| 491 | 491 | "audiofiles-core", | |
| 492 | 492 | "base64", | |
| 493 | 493 | "chrono", | |
| 494 | + | "open", | |
| 494 | 495 | "parking_lot", | |
| 495 | - | "rand 0.8.5", | |
| 496 | + | "rand", | |
| 496 | 497 | "rusqlite", | |
| 497 | 498 | "serde", | |
| 498 | 499 | "serde_json", | |
| @@ -510,7 +511,7 @@ name = "audiofiles-train" | |||
| 510 | 511 | version = "0.4.0" | |
| 511 | 512 | dependencies = [ | |
| 512 | 513 | "audiofiles-core", | |
| 513 | - | "rand 0.8.5", | |
| 514 | + | "rand", | |
| 514 | 515 | "rayon", | |
| 515 | 516 | "serde", | |
| 516 | 517 | "serde_json", | |
| @@ -3934,37 +3935,16 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" | |||
| 3934 | 3935 | ||
| 3935 | 3936 | [[package]] | |
| 3936 | 3937 | name = "rand" | |
| 3937 | - | version = "0.8.5" | |
| 3938 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3939 | - | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" | |
| 3940 | - | dependencies = [ | |
| 3941 | - | "libc", | |
| 3942 | - | "rand_chacha 0.3.1", | |
| 3943 | - | "rand_core 0.6.4", | |
| 3944 | - | ] | |
| 3945 | - | ||
| 3946 | - | [[package]] | |
| 3947 | - | name = "rand" | |
| 3948 | 3938 | version = "0.9.2" | |
| 3949 | 3939 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3950 | 3940 | checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" | |
| 3951 | 3941 | dependencies = [ | |
| 3952 | - | "rand_chacha 0.9.0", | |
| 3942 | + | "rand_chacha", | |
| 3953 | 3943 | "rand_core 0.9.5", | |
| 3954 | 3944 | ] | |
| 3955 | 3945 | ||
| 3956 | 3946 | [[package]] | |
| 3957 | 3947 | name = "rand_chacha" | |
| 3958 | - | version = "0.3.1" | |
| 3959 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3960 | - | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" | |
| 3961 | - | dependencies = [ | |
| 3962 | - | "ppv-lite86", | |
| 3963 | - | "rand_core 0.6.4", | |
| 3964 | - | ] | |
| 3965 | - | ||
| 3966 | - | [[package]] | |
| 3967 | - | name = "rand_chacha" | |
| 3968 | 3948 | version = "0.9.0" | |
| 3969 | 3949 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3970 | 3950 | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" | |
| @@ -4939,7 +4919,7 @@ dependencies = [ | |||
| 4939 | 4919 | "chrono", | |
| 4940 | 4920 | "keyring", | |
| 4941 | 4921 | "parking_lot", | |
| 4942 | - | "rand 0.8.5", | |
| 4922 | + | "rand", | |
| 4943 | 4923 | "reqwest", | |
| 4944 | 4924 | "serde", | |
| 4945 | 4925 | "serde_json", |
| @@ -4,7 +4,7 @@ default-members = ["crates/audiofiles-core", "crates/audiofiles-browser", "crate | |||
| 4 | 4 | resolver = "2" | |
| 5 | 5 | ||
| 6 | 6 | [workspace.package] | |
| 7 | - | edition = "2021" | |
| 7 | + | edition = "2024" | |
| 8 | 8 | license-file = "LICENSE" | |
| 9 | 9 | ||
| 10 | 10 | [workspace.dependencies] | |
| @@ -39,7 +39,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync" | |||
| 39 | 39 | uuid = { version = "1", features = ["v4"] } | |
| 40 | 40 | base64 = "0.22" | |
| 41 | 41 | chrono = "0.4" | |
| 42 | - | rand = "0.8" | |
| 42 | + | rand = "0.9" | |
| 43 | 43 | reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] } | |
| 44 | 44 | semver = "1" | |
| 45 | 45 | open = "5" |
| @@ -0,0 +1,198 @@ | |||
| 1 | + | //! License activation screen, trial mode, and deactivation logic. | |
| 2 | + | ||
| 3 | + | use eframe::egui; | |
| 4 | + | ||
| 5 | + | use super::{AudioFilesApp, AppScreen, SYNC_SERVER_URL}; | |
| 6 | + | ||
| 7 | + | impl AudioFilesApp { | |
| 8 | + | /// Draw the license activation screen. | |
| 9 | + | pub(crate) fn draw_activation_screen(&mut self, ctx: &egui::Context) { | |
| 10 | + | // Poll async activation result (take from lock, then drop guard before mutating self) | |
| 11 | + | let activation = self.activation_result.lock().take(); | |
| 12 | + | if let Some(result) = activation { | |
| 13 | + | match result { | |
| 14 | + | Ok(()) => { | |
| 15 | + | let cache = super::license::LicenseCache { | |
| 16 | + | key_code: self.license_key_input.trim().to_string(), | |
| 17 | + | machine_id: self.machine_id.clone(), | |
| 18 | + | activated_at: chrono::Utc::now().to_rfc3339(), | |
| 19 | + | }; | |
| 20 | + | if let Err(e) = super::license::save_license(&self.config_dir, &cache) { | |
| 21 | + | tracing::error!("Failed to save license: {e}"); | |
| 22 | + | } | |
| 23 | + | self.license_cache = Some(cache); | |
| 24 | + | self.activating = false; | |
| 25 | + | self.activation_error = None; | |
| 26 | + | // If vault registry already exists (e.g. deactivate/reactivate), | |
| 27 | + | // go straight to browser. Otherwise show vault setup. | |
| 28 | + | if self.vault_registry.is_some() { | |
| 29 | + | self.activate_browser(); | |
| 30 | + | } else { | |
| 31 | + | self.screen = AppScreen::VaultSetup; | |
| 32 | + | } | |
| 33 | + | return; | |
| 34 | + | } | |
| 35 | + | Err(e) => { | |
| 36 | + | self.activation_error = Some(e); | |
| 37 | + | self.activating = false; | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | egui::CentralPanel::default().show(ctx, |ui| { | |
| 43 | + | let available = ui.available_size(); | |
| 44 | + | ||
| 45 | + | ui.add_space((available.y * 0.35).max(40.0)); | |
| 46 | + | ||
| 47 | + | ui.vertical_centered(|ui| { | |
| 48 | + | ui.heading("audiofiles"); | |
| 49 | + | ui.add_space(8.0); | |
| 50 | + | ui.label("Enter your license key to get started."); | |
| 51 | + | ui.add_space(16.0); | |
| 52 | + | ||
| 53 | + | // Key input field (fixed width, centered) | |
| 54 | + | let input_width = 360.0_f32.min(available.x - 40.0); | |
| 55 | + | ui.allocate_ui(egui::vec2(input_width, 28.0), |ui| { | |
| 56 | + | let response = ui.add_sized( | |
| 57 | + | ui.available_size(), | |
| 58 | + | egui::TextEdit::singleline(&mut self.license_key_input) | |
| 59 | + | .hint_text("bright-castle-forest-river-falcon"), | |
| 60 | + | ); | |
| 61 | + | // Submit on Enter | |
| 62 | + | if response.lost_focus() | |
| 63 | + | && ui.input(|i| i.key_pressed(egui::Key::Enter)) | |
| 64 | + | && !self.activating | |
| 65 | + | && !self.license_key_input.trim().is_empty() | |
| 66 | + | { | |
| 67 | + | self.start_activation(); | |
| 68 | + | } | |
| 69 | + | }); | |
| 70 | + | ||
| 71 | + | ui.add_space(8.0); | |
| 72 | + | ||
| 73 | + | let can_activate = !self.activating && !self.license_key_input.trim().is_empty(); | |
| 74 | + | let button_text = if self.activating { "Activating..." } else { "Activate" }; | |
| 75 | + | if ui.add_enabled(can_activate, egui::Button::new(button_text)).clicked() { | |
| 76 | + | self.start_activation(); | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | if let Some(ref err) = self.activation_error { | |
| 80 | + | ui.add_space(8.0); | |
| 81 | + | ui.colored_label(egui::Color32::from_rgb(220, 60, 60), err); | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | ui.add_space(16.0); | |
| 85 | + | ui.hyperlink_to( | |
| 86 | + | "Get a license key", | |
| 87 | + | "https://makenot.work/store/audiofiles", | |
| 88 | + | ); | |
| 89 | + | ||
| 90 | + | // Trial button | |
| 91 | + | ui.add_space(24.0); | |
| 92 | + | ui.separator(); | |
| 93 | + | ui.add_space(8.0); | |
| 94 | + | ||
| 95 | + | let trial_label = if let Some(ref trial) = self.trial_state { | |
| 96 | + | let days = super::license::trial_days_remaining(trial); | |
| 97 | + | if days > 0 { | |
| 98 | + | format!("I am still testing the software ({days} days left)") | |
| 99 | + | } else { | |
| 100 | + | format!("I am still \"testing\" the software :) ({days} days)") | |
| 101 | + | } | |
| 102 | + | } else { | |
| 103 | + | "I am still testing the software".to_string() | |
| 104 | + | }; | |
| 105 | + | ||
| 106 | + | if ui.button(trial_label).clicked() { | |
| 107 | + | self.start_trial(); | |
| 108 | + | } | |
| 109 | + | }); | |
| 110 | + | }); | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// Start or continue trial mode: create trial state if needed, then proceed. | |
| 114 | + | pub(crate) fn start_trial(&mut self) { | |
| 115 | + | if self.trial_state.is_none() { | |
| 116 | + | let now = chrono::Utc::now().to_rfc3339(); | |
| 117 | + | let trial = super::license::TrialState { | |
| 118 | + | first_launch_date: now.clone(), | |
| 119 | + | last_seen_date: Some(now), | |
| 120 | + | }; | |
| 121 | + | if let Err(e) = super::license::save_trial(&self.config_dir, &trial) { | |
| 122 | + | tracing::error!("Failed to save trial state: {e}"); | |
| 123 | + | } | |
| 124 | + | self.trial_state = Some(trial); | |
| 125 | + | } | |
| 126 | + | if self.vault_registry.is_some() { | |
| 127 | + | self.activate_browser(); | |
| 128 | + | } else { | |
| 129 | + | self.screen = AppScreen::VaultSetup; | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /// Spawn the async activation request. | |
| 134 | + | fn start_activation(&mut self) { | |
| 135 | + | self.activating = true; | |
| 136 | + | self.activation_error = None; | |
| 137 | + | let slot = self.activation_result.clone(); | |
| 138 | + | let server_url = SYNC_SERVER_URL.to_string(); | |
| 139 | + | let key = self.license_key_input.trim().to_string(); | |
| 140 | + | let mid = self.machine_id.clone(); | |
| 141 | + | self._runtime.spawn(async move { | |
| 142 | + | let result = super::license::activate_key(&server_url, &key, &mid).await; | |
| 143 | + | *slot.lock() = Some(result); | |
| 144 | + | }); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// Push license info into the browser settings state. | |
| 148 | + | pub(crate) fn sync_license_to_browser(&mut self) { | |
| 149 | + | if let Some(ref mut browser) = self.browser { | |
| 150 | + | if let Some(ref cache) = self.license_cache { | |
| 151 | + | browser.settings.license_key_masked = Some(mask_key(&cache.key_code)); | |
| 152 | + | browser.settings.trial_days_remaining = None; | |
| 153 | + | } else if let Some(ref trial) = self.trial_state { | |
| 154 | + | browser.settings.trial_days_remaining = Some(super::license::trial_days_remaining(trial)); | |
| 155 | + | } | |
| 156 | + | let mid = &self.machine_id; | |
| 157 | + | browser.settings.machine_id = Some( | |
| 158 | + | if mid.len() > 12 { | |
| 159 | + | format!("{}...{}", &mid[..8], &mid[mid.len()-4..]) | |
| 160 | + | } else { | |
| 161 | + | mid.clone() | |
| 162 | + | } | |
| 163 | + | ); | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// Deactivate the license: notify the server (best-effort), delete the | |
| 168 | + | /// local cache, and return to the activation screen. | |
| 169 | + | pub(crate) fn deactivate(&mut self) { | |
| 170 | + | if let Some(ref cache) = self.license_cache { | |
| 171 | + | let server_url = SYNC_SERVER_URL.to_string(); | |
| 172 | + | let key = cache.key_code.clone(); | |
| 173 | + | let mid = self.machine_id.clone(); | |
| 174 | + | self._runtime.spawn(async move { | |
| 175 | + | if let Err(e) = super::license::deactivate_key(&server_url, &key, &mid).await { | |
| 176 | + | tracing::warn!("Deactivation request failed (best-effort): {e}"); | |
| 177 | + | } | |
| 178 | + | }); | |
| 179 | + | } | |
| 180 | + | let _ = super::license::remove_license(&self.config_dir); | |
| 181 | + | self.license_cache = None; | |
| 182 | + | self.browser = None; | |
| 183 | + | self.sync_manager = None; | |
| 184 | + | self.screen = AppScreen::Activation; | |
| 185 | + | self.license_key_input.clear(); | |
| 186 | + | self.activation_error = None; | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /// Mask a 5-word key: show first word + ... + last word. | |
| 191 | + | pub(crate) fn mask_key(key: &str) -> String { | |
| 192 | + | let words: Vec<&str> = key.split('-').collect(); | |
| 193 | + | if words.len() >= 2 { | |
| 194 | + | format!("{}-...-{}", words[0], words[words.len() - 1]) | |
| 195 | + | } else { | |
| 196 | + | "***".to_string() | |
| 197 | + | } | |
| 198 | + | } |
| @@ -16,11 +16,13 @@ | |||
| 16 | 16 | //! (NSPasteboardItem on macOS, OLE on Windows). A webview can't initiate OS-level drags | |
| 17 | 17 | //! with file promises. | |
| 18 | 18 | ||
| 19 | + | mod activation; | |
| 19 | 20 | mod audio; | |
| 20 | 21 | mod license; | |
| 21 | 22 | mod midi; | |
| 22 | 23 | mod tray; | |
| 23 | 24 | pub mod updater; | |
| 25 | + | mod vault_setup; | |
| 24 | 26 | ||
| 25 | 27 | use std::path::{Path, PathBuf}; | |
| 26 | 28 | use std::sync::Arc; | |
| @@ -172,222 +174,6 @@ fn create_sync_manager( | |||
| 172 | 174 | Some(manager) | |
| 173 | 175 | } | |
| 174 | 176 | ||
| 175 | - | #[cfg(test)] | |
| 176 | - | mod tests { | |
| 177 | - | use super::*; | |
| 178 | - | ||
| 179 | - | #[test] | |
| 180 | - | fn load_api_key_from_file() { | |
| 181 | - | let dir = tempfile::tempdir().unwrap(); | |
| 182 | - | std::fs::write(dir.path().join("sync_api_key"), "test-key-123").unwrap(); | |
| 183 | - | let result = load_api_key(dir.path()); | |
| 184 | - | assert_eq!(result, Some("test-key-123".to_string())); | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | #[test] | |
| 188 | - | fn load_api_key_trims_whitespace() { | |
| 189 | - | let dir = tempfile::tempdir().unwrap(); | |
| 190 | - | std::fs::write(dir.path().join("sync_api_key"), " key-with-spaces \n").unwrap(); | |
| 191 | - | let result = load_api_key(dir.path()); | |
| 192 | - | assert_eq!(result, Some("key-with-spaces".to_string())); | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | #[test] | |
| 196 | - | fn load_api_key_empty_file_returns_none() { | |
| 197 | - | let dir = tempfile::tempdir().unwrap(); | |
| 198 | - | std::fs::write(dir.path().join("sync_api_key"), "").unwrap(); | |
| 199 | - | // Without env vars, empty file → None | |
| 200 | - | if std::env::var("AF_SYNC_API_KEY").is_err() { | |
| 201 | - | assert_eq!(load_api_key(dir.path()), None); | |
| 202 | - | } | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | #[test] | |
| 206 | - | fn load_api_key_whitespace_only_returns_none() { | |
| 207 | - | let dir = tempfile::tempdir().unwrap(); | |
| 208 | - | std::fs::write(dir.path().join("sync_api_key"), " \n ").unwrap(); | |
| 209 | - | if std::env::var("AF_SYNC_API_KEY").is_err() { | |
| 210 | - | assert_eq!(load_api_key(dir.path()), None); | |
| 211 | - | } | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | #[test] | |
| 215 | - | fn load_api_key_no_file_returns_none() { | |
| 216 | - | let dir = tempfile::tempdir().unwrap(); | |
| 217 | - | if std::env::var("AF_SYNC_API_KEY").is_err() { | |
| 218 | - | assert_eq!(load_api_key(dir.path()), None); | |
| 219 | - | } | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | #[test] | |
| 223 | - | fn save_api_key_creates_file() { | |
| 224 | - | let dir = tempfile::tempdir().unwrap(); | |
| 225 | - | save_api_key(dir.path(), "saved-key"); | |
| 226 | - | let content = std::fs::read_to_string(dir.path().join("sync_api_key")).unwrap(); | |
| 227 | - | assert_eq!(content, "saved-key"); | |
| 228 | - | } | |
| 229 | - | ||
| 230 | - | #[test] | |
| 231 | - | fn save_and_load_roundtrip() { | |
| 232 | - | let dir = tempfile::tempdir().unwrap(); | |
| 233 | - | save_api_key(dir.path(), "roundtrip-key"); | |
| 234 | - | let result = load_api_key(dir.path()); | |
| 235 | - | assert_eq!(result, Some("roundtrip-key".to_string())); | |
| 236 | - | } | |
| 237 | - | ||
| 238 | - | // ── Initial screen resolution ── | |
| 239 | - | ||
| 240 | - | fn make_license_cache() -> license::LicenseCache { | |
| 241 | - | license::LicenseCache { | |
| 242 | - | key_code: "bright-castle-forest-river-falcon".to_string(), | |
| 243 | - | machine_id: "test-machine".to_string(), | |
| 244 | - | activated_at: "2026-04-01T00:00:00Z".to_string(), | |
| 245 | - | } | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | fn make_registry(dir: &Path) -> VaultRegistry { | |
| 249 | - | VaultRegistry { | |
| 250 | - | vaults: vec![vault::VaultEntry { | |
| 251 | - | name: "Library".to_string(), | |
| 252 | - | path: dir.to_path_buf(), | |
| 253 | - | }], | |
| 254 | - | active: dir.to_path_buf(), | |
| 255 | - | } | |
| 256 | - | } | |
| 257 | - | ||
| 258 | - | #[test] | |
| 259 | - | fn initial_screen_licensed_with_registry() { | |
| 260 | - | let dir = tempfile::tempdir().unwrap(); | |
| 261 | - | let reg = Some(make_registry(dir.path())); | |
| 262 | - | let status = license::LicenseStatus::Licensed(make_license_cache()); | |
| 263 | - | assert_eq!(resolve_initial_screen(®, &status, false), AppScreen::Browser); | |
| 264 | - | } | |
| 265 | - | ||
| 266 | - | #[test] | |
| 267 | - | fn initial_screen_licensed_without_registry() { | |
| 268 | - | let status = license::LicenseStatus::Licensed(make_license_cache()); | |
| 269 | - | assert_eq!(resolve_initial_screen(&None, &status, false), AppScreen::VaultSetup); | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | #[test] | |
| 273 | - | fn initial_screen_unlicensed_with_registry() { | |
| 274 | - | let dir = tempfile::tempdir().unwrap(); | |
| 275 | - | let reg = Some(make_registry(dir.path())); | |
| 276 | - | let status = license::LicenseStatus::Unlicensed; | |
| 277 | - | assert_eq!(resolve_initial_screen(®, &status, false), AppScreen::Activation); | |
| 278 | - | } | |
| 279 | - | ||
| 280 | - | #[test] | |
| 281 | - | fn initial_screen_unlicensed_without_registry() { | |
| 282 | - | let status = license::LicenseStatus::Unlicensed; | |
| 283 | - | assert_eq!(resolve_initial_screen(&None, &status, false), AppScreen::Activation); | |
| 284 | - | } | |
| 285 | - | ||
| 286 | - | #[test] | |
| 287 | - | fn initial_screen_trial_with_registry() { | |
| 288 | - | let dir = tempfile::tempdir().unwrap(); | |
| 289 | - | let reg = Some(make_registry(dir.path())); | |
| 290 | - | let status = license::LicenseStatus::Unlicensed; | |
| 291 | - | assert_eq!(resolve_initial_screen(®, &status, true), AppScreen::Browser); | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | #[test] | |
| 295 | - | fn initial_screen_trial_without_registry() { | |
| 296 | - | let status = license::LicenseStatus::Unlicensed; | |
| 297 | - | assert_eq!(resolve_initial_screen(&None, &status, true), AppScreen::VaultSetup); | |
| 298 | - | } | |
| 299 | - | ||
| 300 | - | // ── License migration ── | |
| 301 | - | ||
| 302 | - | #[test] | |
| 303 | - | fn migrate_license_copies_files() { | |
| 304 | - | let src = tempfile::tempdir().unwrap(); | |
| 305 | - | let dst = tempfile::tempdir().unwrap(); | |
| 306 | - | std::fs::write(src.path().join("license.json"), r#"{"key_code":"k","machine_id":"m","activated_at":"t"}"#).unwrap(); | |
| 307 | - | std::fs::write(src.path().join("machine_id"), "mid-123").unwrap(); | |
| 308 | - | ||
| 309 | - | migrate_license_to_config(dst.path(), src.path()); | |
| 310 | - | ||
| 311 | - | assert_eq!( | |
| 312 | - | std::fs::read_to_string(dst.path().join("license.json")).unwrap(), | |
| 313 | - | r#"{"key_code":"k","machine_id":"m","activated_at":"t"}"# | |
| 314 | - | ); | |
| 315 | - | assert_eq!( | |
| 316 | - | std::fs::read_to_string(dst.path().join("machine_id")).unwrap(), | |
| 317 | - | "mid-123" | |
| 318 | - | ); | |
| 319 | - | } | |
| 320 | - | ||
| 321 | - | #[test] | |
| 322 | - | fn migrate_license_skips_when_same_dir() { | |
| 323 | - | let dir = tempfile::tempdir().unwrap(); | |
| 324 | - | // Should not panic or overwrite — same source and dest | |
| 325 | - | migrate_license_to_config(dir.path(), dir.path()); | |
| 326 | - | } | |
| 327 | - | ||
| 328 | - | #[test] | |
| 329 | - | fn migrate_license_does_not_overwrite_existing() { | |
| 330 | - | let src = tempfile::tempdir().unwrap(); | |
| 331 | - | let dst = tempfile::tempdir().unwrap(); | |
| 332 | - | std::fs::write(src.path().join("license.json"), "old").unwrap(); | |
| 333 | - | std::fs::write(dst.path().join("license.json"), "existing").unwrap(); | |
| 334 | - | ||
| 335 | - | migrate_license_to_config(dst.path(), src.path()); | |
| 336 | - | ||
| 337 | - | // Destination file should be unchanged | |
| 338 | - | assert_eq!( | |
| 339 | - | std::fs::read_to_string(dst.path().join("license.json")).unwrap(), | |
| 340 | - | "existing" | |
| 341 | - | ); | |
| 342 | - | } | |
| 343 | - | ||
| 344 | - | #[test] | |
| 345 | - | fn migrate_license_handles_missing_source() { | |
| 346 | - | let src = tempfile::tempdir().unwrap(); | |
| 347 | - | let dst = tempfile::tempdir().unwrap(); | |
| 348 | - | // No files in source — should not create anything in dest | |
| 349 | - | migrate_license_to_config(dst.path(), src.path()); | |
| 350 | - | assert!(!dst.path().join("license.json").exists()); | |
| 351 | - | assert!(!dst.path().join("machine_id").exists()); | |
| 352 | - | } | |
| 353 | - | ||
| 354 | - | // ── Key masking ── | |
| 355 | - | ||
| 356 | - | #[test] | |
| 357 | - | fn mask_key_five_words() { | |
| 358 | - | assert_eq!( | |
| 359 | - | mask_key("bright-castle-forest-river-falcon"), | |
| 360 | - | "bright-...-falcon" | |
| 361 | - | ); | |
| 362 | - | } | |
| 363 | - | ||
| 364 | - | #[test] | |
| 365 | - | fn mask_key_two_words() { | |
| 366 | - | assert_eq!(mask_key("alpha-beta"), "alpha-...-beta"); | |
| 367 | - | } | |
| 368 | - | ||
| 369 | - | #[test] | |
| 370 | - | fn mask_key_single_word() { | |
| 371 | - | assert_eq!(mask_key("onlyoneword"), "***"); | |
| 372 | - | } | |
| 373 | - | ||
| 374 | - | // ── Vault name lookup ── | |
| 375 | - | ||
| 376 | - | #[test] | |
| 377 | - | fn vault_name_for_path_found() { | |
| 378 | - | let dir = tempfile::tempdir().unwrap(); | |
| 379 | - | let reg = make_registry(dir.path()); | |
| 380 | - | assert_eq!(vault_name_for_path(®, dir.path()), "Library"); | |
| 381 | - | } | |
| 382 | - | ||
| 383 | - | #[test] | |
| 384 | - | fn vault_name_for_path_not_found() { | |
| 385 | - | let dir = tempfile::tempdir().unwrap(); | |
| 386 | - | let reg = make_registry(dir.path()); | |
| 387 | - | assert_eq!(vault_name_for_path(®, Path::new("/nonexistent")), "Library"); | |
| 388 | - | } | |
| 389 | - | } | |
| 390 | - | ||
| 391 | 177 | // ── App ── | |
| 392 | 178 | ||
| 393 | 179 | /// Which screen the app is showing. | |
| @@ -467,7 +253,7 @@ impl AudioFilesApp { | |||
| 467 | 253 | let default_vault = vault::default_vault_path(); | |
| 468 | 254 | ||
| 469 | 255 | // Migrate license/machine_id from default vault to config_dir if needed. | |
| 470 | - | migrate_license_to_config(&config_dir, &default_vault); | |
| 256 | + | vault_setup::migrate_license_to_config(&config_dir, &default_vault); | |
| 471 | 257 | ||
| 472 | 258 | let machine_id = license::get_or_create_machine_id(&config_dir); | |
| 473 | 259 | let license_status = license::load_license(&config_dir); | |
| @@ -495,14 +281,14 @@ impl AudioFilesApp { | |||
| 495 | 281 | let data_dir = reg.active.clone(); | |
| 496 | 282 | let _ = std::fs::create_dir_all(&data_dir); | |
| 497 | 283 | let sync_manager = create_sync_manager(&data_dir, runtime.handle()); | |
| 498 | - | let (browser, error) = init_browser(&data_dir, shared.clone(), &vault_name_for_path(reg, &data_dir)); | |
| 284 | + | let (browser, error) = init_browser(&data_dir, shared.clone(), &vault_setup::vault_name_for_path(reg, &data_dir)); | |
| 499 | 285 | (data_dir, browser, error, sync_manager, Some(cache.clone())) | |
| 500 | 286 | } | |
| 501 | 287 | // Registry exists, unlicensed but in trial → open the active vault (no sync) | |
| 502 | 288 | (Some(reg), license::LicenseStatus::Unlicensed) if has_active_trial => { | |
| 503 | 289 | let data_dir = reg.active.clone(); | |
| 504 | 290 | let _ = std::fs::create_dir_all(&data_dir); | |
| 505 | - | let (browser, error) = init_browser(&data_dir, shared.clone(), &vault_name_for_path(reg, &data_dir)); | |
| 291 | + | let (browser, error) = init_browser(&data_dir, shared.clone(), &vault_setup::vault_name_for_path(reg, &data_dir)); | |
| 506 | 292 | (data_dir, browser, error, None, None) | |
| 507 | 293 | } | |
| 508 | 294 | // Registry exists but unlicensed (deactivated and reactivated) | |
| @@ -543,444 +329,45 @@ impl AudioFilesApp { | |||
| 543 | 329 | vault_registry, | |
| 544 | 330 | vault_setup_path: None, | |
| 545 | 331 | vault_setup_name: "Library".to_string(), | |
| 546 | - | machine_id, | |
| 547 | - | license_key_input: String::new(), | |
| 548 | - | activation_result: Arc::new(Mutex::new(None)), | |
| 549 | - | activation_error: None, | |
| 550 | - | activating: false, | |
| 551 | - | license_cache, | |
| 552 | - | trial_state, | |
| 553 | - | }; | |
| 554 | - | app.sync_vault_list_to_browser(); | |
| 555 | - | app.sync_license_to_browser(); | |
| 556 | - | app | |
| 557 | - | } | |
| 558 | - | ||
| 559 | - | /// Initialise the browser after successful activation. | |
| 560 | - | fn activate_browser(&mut self) { | |
| 561 | - | let _ = std::fs::create_dir_all(&self.data_dir); | |
| 562 | - | self.sync_manager = create_sync_manager(&self.data_dir, self._runtime.handle()); | |
| 563 | - | let vault_name = self.vault_registry.as_ref() | |
| 564 | - | .map(|r| vault_name_for_path(r, &self.data_dir)) | |
| 565 | - | .unwrap_or_else(|| "Library".to_string()); | |
| 566 | - | let (browser, error) = init_browser(&self.data_dir, self.shared.clone(), &vault_name); | |
| 567 | - | self.browser = browser; | |
| 568 | - | self.error = error; | |
| 569 | - | self.screen = AppScreen::Browser; | |
| 570 | - | self.sync_vault_list_to_browser(); | |
| 571 | - | self.sync_license_to_browser(); | |
| 572 | - | // Read unsafe_mode from the vault's DB and run integrity check | |
| 573 | - | if let Some(ref mut browser) = self.browser { | |
| 574 | - | browser.settings.is_unsafe_mode = browser | |
| 575 | - | .backend | |
| 576 | - | .get_config("unsafe_mode") | |
| 577 | - | .ok() | |
| 578 | - | .flatten() | |
| 579 | - | .is_some_and(|v| v == "1"); | |
| 580 | - | browser.check_unsafe_integrity(); | |
| 581 | - | } | |
| 582 | - | } | |
| 583 | - | ||
| 584 | - | /// Push license info into the browser settings state. | |
| 585 | - | fn sync_license_to_browser(&mut self) { | |
| 586 | - | if let Some(ref mut browser) = self.browser { | |
| 587 | - | if let Some(ref cache) = self.license_cache { | |
| 588 | - | browser.settings.license_key_masked = Some(mask_key(&cache.key_code)); | |
| 589 | - | browser.settings.trial_days_remaining = None; | |
| 590 | - | } else if let Some(ref trial) = self.trial_state { | |
| 591 | - | browser.settings.trial_days_remaining = Some(license::trial_days_remaining(trial)); | |
| 592 | - | } | |
| 593 | - | let mid = &self.machine_id; | |
| 594 | - | browser.settings.machine_id = Some( | |
| 595 | - | if mid.len() > 12 { | |
| 596 | - | format!("{}...{}", &mid[..8], &mid[mid.len()-4..]) | |
| 597 | - | } else { | |
| 598 | - | mid.clone() | |
| 599 | - | } | |
| 600 | - | ); | |
| 601 | - | } | |
| 602 | - | } | |
| 603 | - | ||
| 604 | - | /// Deactivate the license: notify the server (best-effort), delete the | |
| 605 | - | /// local cache, and return to the activation screen. | |
| 606 | - | fn deactivate(&mut self) { | |
| 607 | - | if let Some(ref cache) = self.license_cache { | |
| 608 | - | let server_url = SYNC_SERVER_URL.to_string(); | |
| 609 | - | let key = cache.key_code.clone(); | |
| 610 | - | let mid = self.machine_id.clone(); | |
| 611 | - | self._runtime.spawn(async move { | |
| 612 | - | if let Err(e) = license::deactivate_key(&server_url, &key, &mid).await { | |
| 613 | - | tracing::warn!("Deactivation request failed (best-effort): {e}"); | |
| 614 | - | } | |
| 615 | - | }); | |
| 616 | - | } | |
| 617 | - | let _ = license::remove_license(&self.config_dir); | |
| 618 | - | self.license_cache = None; | |
| 619 | - | self.browser = None; | |
| 620 | - | self.sync_manager = None; | |
| 621 | - | self.screen = AppScreen::Activation; | |
| 622 | - | self.license_key_input.clear(); | |
| 623 | - | self.activation_error = None; | |
| 624 | - | } | |
| 625 | - | ||
| 626 | - | /// Draw the license activation screen. | |
| 627 | - | fn draw_activation_screen(&mut self, ctx: &egui::Context) { | |
| 628 | - | // Poll async activation result (take from lock, then drop guard before mutating self) | |
| 629 | - | let activation = self.activation_result.lock().take(); | |
| 630 | - | if let Some(result) = activation { | |
| 631 | - | match result { | |
| 632 | - | Ok(()) => { | |
| 633 | - | let cache = license::LicenseCache { | |
| 634 | - | key_code: self.license_key_input.trim().to_string(), | |
| 635 | - | machine_id: self.machine_id.clone(), | |
| 636 | - | activated_at: chrono::Utc::now().to_rfc3339(), | |
| 637 | - | }; | |
| 638 | - | if let Err(e) = license::save_license(&self.config_dir, &cache) { | |
| 639 | - | tracing::error!("Failed to save license: {e}"); | |
| 640 | - | } | |
| 641 | - | self.license_cache = Some(cache); | |
| 642 | - | self.activating = false; | |
| 643 | - | self.activation_error = None; | |
| 644 | - | // If vault registry already exists (e.g. deactivate/reactivate), | |
| 645 | - | // go straight to browser. Otherwise show vault setup. | |
| 646 | - | if self.vault_registry.is_some() { | |
| 647 | - | self.activate_browser(); | |
| 648 | - | } else { | |
| 649 | - | self.screen = AppScreen::VaultSetup; | |
| 650 | - | } | |
| 651 | - | return; | |
| 652 | - | } | |
| 653 | - | Err(e) => { | |
| 654 | - | self.activation_error = Some(e); | |
| 655 | - | self.activating = false; | |
| 656 | - | } | |
| 657 | - | } | |
| 658 | - | } | |
| 659 | - | ||
| 660 | - | egui::CentralPanel::default().show(ctx, |ui| { | |
| 661 | - | let available = ui.available_size(); | |
| 662 | - | ||
| 663 | - | ui.add_space((available.y * 0.35).max(40.0)); | |
| 664 | - | ||
| 665 | - | ui.vertical_centered(|ui| { | |
| 666 | - | ui.heading("audiofiles"); | |
| 667 | - | ui.add_space(8.0); | |
| 668 | - | ui.label("Enter your license key to get started."); | |
| 669 | - | ui.add_space(16.0); | |
| 670 | - | ||
| 671 | - | // Key input field (fixed width, centered) | |
| 672 | - | let input_width = 360.0_f32.min(available.x - 40.0); | |
| 673 | - | ui.allocate_ui(egui::vec2(input_width, 28.0), |ui| { | |
| 674 | - | let response = ui.add_sized( | |
| 675 | - | ui.available_size(), | |
| 676 | - | egui::TextEdit::singleline(&mut self.license_key_input) | |
| 677 | - | .hint_text("bright-castle-forest-river-falcon"), | |
| 678 | - | ); | |
| 679 | - | // Submit on Enter | |
| 680 | - | if response.lost_focus() | |
| 681 | - | && ui.input(|i| i.key_pressed(egui::Key::Enter)) | |
| 682 | - | && !self.activating | |
| 683 | - | && !self.license_key_input.trim().is_empty() | |
| 684 | - | { | |
| 685 | - | self.start_activation(); | |
| 686 | - | } | |
| 687 | - | }); | |
| 688 | - | ||
| 689 | - | ui.add_space(8.0); | |
| 690 | - | ||
| 691 | - | let can_activate = !self.activating && !self.license_key_input.trim().is_empty(); | |
| 692 | - | let button_text = if self.activating { "Activating..." } else { "Activate" }; | |
| 693 | - | if ui.add_enabled(can_activate, egui::Button::new(button_text)).clicked() { | |
| 694 | - | self.start_activation(); | |
| 695 | - | } | |
| 696 | - | ||
| 697 | - | if let Some(ref err) = self.activation_error { | |
| 698 | - | ui.add_space(8.0); | |
| 699 | - | ui.colored_label(egui::Color32::from_rgb(220, 60, 60), err); | |
| 700 | - | } | |
| 701 | - | ||
| 702 | - | ui.add_space(16.0); | |
| 703 | - | ui.hyperlink_to( | |
| 704 | - | "Get a license key", | |
| 705 | - | "https://makenot.work/store/audiofiles", | |
| 706 | - | ); | |
| 707 | - | ||
| 708 | - | // Trial button | |
| 709 | - | ui.add_space(24.0); | |
| 710 | - | ui.separator(); | |
| 711 | - | ui.add_space(8.0); | |
| 712 | - | ||
| 713 | - | let trial_label = if let Some(ref trial) = self.trial_state { | |
| 714 | - | let days = license::trial_days_remaining(trial); | |
| 715 | - | if days > 0 { | |
| 716 | - | format!("I am still testing the software ({days} days left)") | |
| 717 | - | } else { | |
| 718 | - | format!("I am still \"testing\" the software :) ({days} days)") | |
| 719 | - | } | |
| 720 | - | } else { | |
| 721 | - | "I am still testing the software".to_string() | |
| 722 | - | }; | |
| 723 | - | ||
| 724 | - | if ui.button(trial_label).clicked() { | |
| 725 | - | self.start_trial(); | |
| 726 | - | } | |
| 727 | - | }); | |
| 728 | - | }); | |
| 729 | - | } | |
| 730 | - | ||
| 731 | - | /// Start or continue trial mode: create trial state if needed, then proceed. | |
| 732 | - | fn start_trial(&mut self) { | |
| 733 | - | if self.trial_state.is_none() { | |
| 734 | - | let now = chrono::Utc::now().to_rfc3339(); | |
| 735 | - | let trial = license::TrialState { | |
| 736 | - | first_launch_date: now.clone(), | |
| 737 | - | last_seen_date: Some(now), | |
| 738 | - | }; | |
| 739 | - | if let Err(e) = license::save_trial(&self.config_dir, &trial) { | |
| 740 | - | tracing::error!("Failed to save trial state: {e}"); | |
| 741 | - | } | |
| 742 | - | self.trial_state = Some(trial); | |
| 743 | - | } | |
| 744 | - | if self.vault_registry.is_some() { | |
| 745 | - | self.activate_browser(); | |
| 746 | - | } else { | |
| 747 | - | self.screen = AppScreen::VaultSetup; | |
| 748 | - | } | |
| 749 | - | } | |
| 750 | - | ||
| 751 | - | /// Spawn the async activation request. | |
| 752 | - | fn start_activation(&mut self) { | |
| 753 | - | self.activating = true; | |
| 754 | - | self.activation_error = None; | |
| 755 | - | let slot = self.activation_result.clone(); | |
| 756 | - | let server_url = SYNC_SERVER_URL.to_string(); | |
| 757 | - | let key = self.license_key_input.trim().to_string(); | |
| 758 | - | let mid = self.machine_id.clone(); | |
| 759 | - | self._runtime.spawn(async move { | |
| 760 | - | let result = license::activate_key(&server_url, &key, &mid).await; | |
| 761 | - | *slot.lock() = Some(result); | |
| 762 | - | }); | |
| 763 | - | } | |
| 764 | - | ||
| 765 | - | /// Draw the vault setup screen (first-open flow, after activation). | |
| 766 | - | fn draw_vault_setup_screen(&mut self, ctx: &egui::Context) { | |
| 767 | - | let default_path = vault::default_vault_path(); | |
| 768 | - | let existing_db = default_path.join("audiofiles.db").exists(); | |
| 769 | - | ||
| 770 | - | egui::CentralPanel::default().show(ctx, |ui| { | |
| 771 | - | let available = ui.available_size(); | |
| 772 | - | ui.add_space((available.y * 0.25).max(40.0)); | |
| 773 | - | ||
| 774 | - | ui.vertical_centered(|ui| { | |
| 775 | - | ui.heading("Choose where to store your sample library"); | |
| 776 | - | ui.add_space(8.0); | |
| 777 | - | ||
| 778 | - | if existing_db { | |
| 779 | - | ui.label( | |
| 780 | - | egui::RichText::new(format!( | |
| 781 | - | "Your existing library was found at {}", | |
| 782 | - | default_path.display() | |
| 783 | - | )) |
Lines truncated
| @@ -0,0 +1,218 @@ | |||
| 1 | + | //! Vault setup screen, vault switching, and registry management. | |
| 2 | + | ||
| 3 | + | use std::path::{Path, PathBuf}; | |
| 4 | + | ||
| 5 | + | use audiofiles_core::vault::{self, VaultRegistry}; | |
| 6 | + | use eframe::egui; | |
| 7 | + | ||
| 8 | + | use super::AudioFilesApp; | |
| 9 | + | ||
| 10 | + | impl AudioFilesApp { | |
| 11 | + | /// Draw the vault setup screen (first-open flow, after activation). | |
| 12 | + | pub(crate) fn draw_vault_setup_screen(&mut self, ctx: &egui::Context) { | |
| 13 | + | let default_path = vault::default_vault_path(); | |
| 14 | + | let existing_db = default_path.join("audiofiles.db").exists(); | |
| 15 | + | ||
| 16 | + | egui::CentralPanel::default().show(ctx, |ui| { | |
| 17 | + | let available = ui.available_size(); | |
| 18 | + | ui.add_space((available.y * 0.25).max(40.0)); | |
| 19 | + | ||
| 20 | + | ui.vertical_centered(|ui| { | |
| 21 | + | ui.heading("Choose where to store your sample library"); | |
| 22 | + | ui.add_space(8.0); | |
| 23 | + | ||
| 24 | + | if existing_db { | |
| 25 | + | ui.label( | |
| 26 | + | egui::RichText::new(format!( | |
| 27 | + | "Your existing library was found at {}", | |
| 28 | + | default_path.display() | |
| 29 | + | )) | |
| 30 | + | .color(egui::Color32::from_rgb(120, 180, 120)), | |
| 31 | + | ); | |
| 32 | + | ui.add_space(8.0); | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // Option 1: default location | |
| 36 | + | if ui.button("Use default location").clicked() { | |
| 37 | + | self.vault_setup_path = None; | |
| 38 | + | } | |
| 39 | + | ui.label( | |
| 40 | + | egui::RichText::new(format!("Default: {}", default_path.display())) | |
| 41 | + | .small() | |
| 42 | + | .color(egui::Color32::GRAY), | |
| 43 | + | ); | |
| 44 | + | ||
| 45 | + | ui.add_space(8.0); | |
| 46 | + | ||
| 47 | + | // Option 2: custom folder | |
| 48 | + | ui.horizontal(|ui| { | |
| 49 | + | if ui.button("Choose folder...").clicked() { | |
| 50 | + | if let Some(path) = rfd::FileDialog::new().pick_folder() { | |
| 51 | + | self.vault_setup_path = Some(path); | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | if self.vault_setup_path.is_some() | |
| 55 | + | && ui.small_button("Reset to default").clicked() | |
| 56 | + | { | |
| 57 | + | self.vault_setup_path = None; | |
| 58 | + | } | |
| 59 | + | }); | |
| 60 | + | ||
| 61 | + | if let Some(ref custom) = self.vault_setup_path { | |
| 62 | + | ui.label( | |
| 63 | + | egui::RichText::new(format!("Selected: {}", custom.display())) | |
| 64 | + | .small() | |
| 65 | + | .color(egui::Color32::from_rgb(150, 200, 255)), | |
| 66 | + | ); | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | ui.add_space(12.0); | |
| 70 | + | ||
| 71 | + | // Vault name input | |
| 72 | + | ui.horizontal(|ui| { | |
| 73 | + | ui.label("Vault name:"); | |
| 74 | + | ui.text_edit_singleline(&mut self.vault_setup_name); | |
| 75 | + | }); | |
| 76 | + | ||
| 77 | + | ui.add_space(16.0); | |
| 78 | + | ||
| 79 | + | // Continue button | |
| 80 | + | let chosen = self | |
| 81 | + | .vault_setup_path | |
| 82 | + | .clone() | |
| 83 | + | .unwrap_or_else(|| default_path.clone()); | |
| 84 | + | if ui.button("Continue").clicked() { | |
| 85 | + | self.finalize_vault_setup(chosen); | |
| 86 | + | } | |
| 87 | + | }); | |
| 88 | + | }); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Finalise vault setup: create registry, set data_dir, open browser. | |
| 92 | + | fn finalize_vault_setup(&mut self, vault_path: PathBuf) { | |
| 93 | + | let name = if self.vault_setup_name.trim().is_empty() { | |
| 94 | + | "Library".to_string() | |
| 95 | + | } else { | |
| 96 | + | self.vault_setup_name.trim().to_string() | |
| 97 | + | }; | |
| 98 | + | ||
| 99 | + | let mut reg = VaultRegistry { | |
| 100 | + | vaults: Vec::new(), | |
| 101 | + | active: vault_path.clone(), | |
| 102 | + | }; | |
| 103 | + | ||
| 104 | + | // If the path already has a DB, add as existing; otherwise create new. | |
| 105 | + | if vault_path.join("audiofiles.db").exists() { | |
| 106 | + | if let Err(e) = vault::add_existing_vault(&mut reg, &name, &vault_path) { | |
| 107 | + | tracing::error!("Failed to add existing vault: {e}"); | |
| 108 | + | } | |
| 109 | + | } else if let Err(e) = vault::create_vault(&mut reg, &name, &vault_path) { | |
| 110 | + | tracing::error!("Failed to create vault: {e}"); | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | if let Err(e) = vault::save_registry(®) { | |
| 114 | + | tracing::error!("Failed to save vault registry: {e}"); | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | self.vault_registry = Some(reg); | |
| 118 | + | self.data_dir = vault_path; | |
| 119 | + | self.activate_browser(); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /// Switch to a different vault: tear down the current browser and rebuild. | |
| 123 | + | pub(crate) fn switch_vault(&mut self, path: PathBuf) { | |
| 124 | + | if !vault::is_vault_reachable(&path) { | |
| 125 | + | if let Some(ref mut browser) = self.browser { | |
| 126 | + | browser.status = format!("Vault is offline: {}", path.display()); | |
| 127 | + | } | |
| 128 | + | return; | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // Tear down current state | |
| 132 | + | self.midi_connection = None; | |
| 133 | + | if let Some(ref mut browser) = self.browser { | |
| 134 | + | browser.stop_preview(); | |
| 135 | + | } | |
| 136 | + | self.browser = None; | |
| 137 | + | self.sync_manager = None; | |
| 138 | + | ||
| 139 | + | // Update registry | |
| 140 | + | if let Some(ref mut reg) = self.vault_registry { | |
| 141 | + | reg.active = path.clone(); | |
| 142 | + | if let Err(e) = vault::save_registry(reg) { | |
| 143 | + | tracing::error!("Failed to save vault registry: {e}"); | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | self.data_dir = path; | |
| 148 | + | self.activate_browser(); | |
| 149 | + | ||
| 150 | + | // Populate vault_list on the new browser | |
| 151 | + | self.sync_vault_list_to_browser(); | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /// Run a vault registry mutation, save the registry, and sync the vault list | |
| 155 | + | /// to the browser. Returns `true` if the operation and save both succeeded. | |
| 156 | + | pub(crate) fn with_vault_registry<F>(&mut self, op: F) -> bool | |
| 157 | + | where | |
| 158 | + | F: FnOnce(&mut VaultRegistry) -> Result<(), vault::VaultError>, | |
| 159 | + | { | |
| 160 | + | let Some(ref mut reg) = self.vault_registry else { return false }; | |
| 161 | + | if let Err(e) = op(reg) { | |
| 162 | + | if let Some(ref mut b) = self.browser { | |
| 163 | + | b.status = format!("Vault error: {e}"); | |
| 164 | + | } | |
| 165 | + | return false; | |
| 166 | + | } | |
| 167 | + | if let Err(e) = vault::save_registry(reg) { | |
| 168 | + | tracing::error!("Failed to save vault registry: {e}"); | |
| 169 | + | return false; | |
| 170 | + | } | |
| 171 | + | self.sync_vault_list_to_browser(); | |
| 172 | + | true | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | /// Push the current registry's vault list into the browser state. | |
| 176 | + | pub(crate) fn sync_vault_list_to_browser(&mut self) { | |
| 177 | + | if let (Some(reg), Some(browser)) = (&self.vault_registry, &mut self.browser) { | |
| 178 | + | browser.settings.list = reg | |
| 179 | + | .vaults | |
| 180 | + | .iter() | |
| 181 | + | .map(|v| (v.name.clone(), v.path.clone(), vault::is_vault_reachable(&v.path))) | |
| 182 | + | .collect(); | |
| 183 | + | browser.settings.name = vault_name_for_path(reg, &self.data_dir); | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | /// One-time migration: copy license.json and machine_id from the default vault | |
| 189 | + | /// directory to the global config directory if they don't exist there yet. | |
| 190 | + | pub(crate) fn migrate_license_to_config(config_dir: &Path, default_vault: &Path) { | |
| 191 | + | // Skip if config_dir and default_vault are the same (macOS) | |
| 192 | + | if config_dir == default_vault { | |
| 193 | + | return; | |
| 194 | + | } | |
| 195 | + | let _ = std::fs::create_dir_all(config_dir); | |
| 196 | + | for filename in &["license.json", "machine_id"] { | |
| 197 | + | let src = default_vault.join(filename); | |
| 198 | + | let dst = config_dir.join(filename); | |
| 199 | + | if src.exists() && !dst.exists() { | |
| 200 | + | if let Err(e) = std::fs::copy(&src, &dst) { | |
| 201 | + | tracing::warn!("Failed to migrate {filename} to config_dir: {e}"); | |
| 202 | + | } else { | |
| 203 | + | tracing::info!("Migrated {filename} from {} to {}", src.display(), dst.display()); | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | /// Look up the vault name for a given path in the registry. Falls back to "Library". | |
| 210 | + | /// Uses canonicalize for consistent matching regardless of symlinks or relative components. | |
| 211 | + | pub(crate) fn vault_name_for_path(reg: &VaultRegistry, path: &Path) -> String { | |
| 212 | + | let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); | |
| 213 | + | reg.vaults | |
| 214 | + | .iter() | |
| 215 | + | .find(|v| v.path.canonicalize().unwrap_or_else(|_| v.path.clone()) == canonical) | |
| 216 | + | .map(|v| v.name.clone()) | |
| 217 | + | .unwrap_or_else(|| "Library".to_string()) | |
| 218 | + | } |
| @@ -24,7 +24,7 @@ use objc2_app_kit::{ | |||
| 24 | 24 | use objc2_foundation::{NSArray, NSPoint, NSRect, NSSize, NSURL}; | |
| 25 | 25 | use tracing::{debug, warn}; | |
| 26 | 26 | ||
| 27 | - | extern "C" { | |
| 27 | + | unsafe extern "C" { | |
| 28 | 28 | static _dispatch_main_q: c_void; | |
| 29 | 29 | fn dispatch_async(queue: *const c_void, block: &block2::Block<dyn Fn()>); | |
| 30 | 30 | } |
| @@ -88,17 +88,33 @@ impl BrowserState { | |||
| 88 | 88 | ||
| 89 | 89 | /// Node IDs of all selected items, excluding ".." parent entry. | |
| 90 | 90 | pub fn selected_node_ids(&self) -> Vec<NodeId> { | |
| 91 | - | self.selected_nodes() | |
| 91 | + | let offset = if self.current_dir.is_some() { 1 } else { 0 }; | |
| 92 | + | self.selection | |
| 93 | + | .selected | |
| 92 | 94 | .iter() | |
| 93 | - | .map(|n| n.node.id) | |
| 95 | + | .filter_map(|&idx| { | |
| 96 | + | if offset > 0 && idx == 0 { | |
| 97 | + | return None; | |
| 98 | + | } | |
| 99 | + | self.contents.get(idx - offset).map(|n| n.node.id) | |
| 100 | + | }) | |
| 94 | 101 | .collect() | |
| 95 | 102 | } | |
| 96 | 103 | ||
| 97 | 104 | /// Hashes of all selected samples. | |
| 98 | 105 | pub fn selected_sample_hashes(&self) -> Vec<String> { | |
| 99 | - | self.selected_nodes() | |
| 106 | + | let offset = if self.current_dir.is_some() { 1 } else { 0 }; | |
| 107 | + | self.selection | |
| 108 | + | .selected | |
| 100 | 109 | .iter() | |
| 101 | - | .filter_map(|n| n.node.sample_hash.as_ref().map(|h| h.to_string())) | |
| 110 | + | .filter_map(|&idx| { | |
| 111 | + | if offset > 0 && idx == 0 { | |
| 112 | + | return None; | |
| 113 | + | } | |
| 114 | + | self.contents | |
| 115 | + | .get(idx - offset) | |
| 116 | + | .and_then(|n| n.node.sample_hash.as_ref().map(|h| h.to_string())) | |
| 117 | + | }) | |
| 102 | 118 | .collect() | |
| 103 | 119 | } | |
| 104 | 120 |
| @@ -1,4 +1,4 @@ | |||
| 1 | - | use std::path::Path; | |
| 1 | + | use std::path::{Path, PathBuf}; | |
| 2 | 2 | ||
| 3 | 3 | use tracing::{error, warn}; | |
| 4 | 4 | ||
| @@ -99,9 +99,9 @@ impl BrowserState { | |||
| 99 | 99 | /// On NameConflict for a directory, reuses the existing directory node rather than failing. | |
| 100 | 100 | /// | |
| 101 | 101 | /// NOTE: A parallel implementation exists in `import.rs` (`import_directory_recursive`) | |
| 102 | - | /// for the background import worker. That version adds progress events, cancellation, | |
| 103 | - | /// audio-file filtering, and sorted iteration. The two are kept separate because the | |
| 104 | - | /// behavioral differences (file filtering, cancellation, progress) make a shared | |
| 102 | + | /// for the background import worker. That version adds progress events and cancellation. | |
| 103 | + | /// Both share the same traversal behavior (sorted, audio-only, skipped-dir filtering). | |
| 104 | + | /// Kept separate because the cancellation/progress channel differences make a shared | |
| 105 | 105 | /// abstraction more complex than the duplication. | |
| 106 | 106 | fn import_directory_recursive( | |
| 107 | 107 | &self, | |
| @@ -120,16 +120,15 @@ impl BrowserState { | |||
| 120 | 120 | } | |
| 121 | 121 | }; | |
| 122 | 122 | ||
| 123 | - | // Collect all entries first so we can process dirs then files | |
| 124 | - | let paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect(); | |
| 123 | + | let mut paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect(); | |
| 124 | + | paths.sort(); | |
| 125 | 125 | ||
| 126 | 126 | for path in paths { | |
| 127 | 127 | if path.is_dir() { | |
| 128 | - | let dir_name = path | |
| 129 | - | .file_name() | |
| 130 | - | .and_then(|n| n.to_str()) | |
| 131 | - | .unwrap_or("folder") | |
| 132 | - | .to_string(); | |
| 128 | + | if audiofiles_core::util::is_macos_metadata_dir(&path) { | |
| 129 | + | continue; | |
| 130 | + | } | |
| 131 | + | let dir_name = audiofiles_core::util::get_filename(&path, "folder"); | |
| 133 | 132 | ||
| 134 | 133 | // Create the directory node, or reuse an existing one if a | |
| 135 | 134 | // name conflict occurs (idempotent for re-imports). | |
| @@ -157,7 +156,7 @@ impl BrowserState { | |||
| 157 | 156 | errors, | |
| 158 | 157 | hashes, | |
| 159 | 158 | ); | |
| 160 | - | } else if path.is_file() { | |
| 159 | + | } else if path.is_file() && audiofiles_core::util::is_audio_file(&path) { | |
| 161 | 160 | match self.import_single_file(&path, vfs_id, parent_id) { | |
| 162 | 161 | Ok(Some(hash_ext)) => { | |
| 163 | 162 | *count += 1; | |
| @@ -557,13 +556,14 @@ impl BrowserState { | |||
| 557 | 556 | ||
| 558 | 557 | /// Apply user-entered tags to each imported folder's samples, then start analysis. | |
| 559 | 558 | pub fn apply_folder_tags(&mut self) { | |
| 559 | + | let mode = std::mem::replace(&mut self.import_mode, ImportMode::None); | |
| 560 | 560 | if let ImportMode::TagFolders { | |
| 561 | - | ref entries, | |
| 562 | - | ref sample_hashes, | |
| 563 | - | } = self.import_mode | |
| 561 | + | entries, | |
| 562 | + | sample_hashes, | |
| 563 | + | } = mode | |
| 564 | 564 | { | |
| 565 | 565 | let mut applied = 0usize; | |
| 566 | - | for entry in entries { | |
| 566 | + | for entry in &entries { | |
| 567 | 567 | if entry.tag_input.trim().is_empty() { | |
| 568 | 568 | continue; | |
| 569 | 569 | } | |
| @@ -583,21 +583,21 @@ impl BrowserState { | |||
| 583 | 583 | } | |
| 584 | 584 | } | |
| 585 | 585 | } | |
| 586 | - | let hashes = sample_hashes.clone(); | |
| 587 | 586 | self.status = format!("Applied {applied} folder tags"); | |
| 588 | 587 | self.refresh_all_tags(); | |
| 589 | - | self.start_analysis_flow(hashes); | |
| 588 | + | self.start_analysis_flow(sample_hashes); | |
| 589 | + | } else { | |
| 590 | + | self.import_mode = mode; | |
| 590 | 591 | } | |
| 591 | 592 | } | |
| 592 | 593 | ||
| 593 | 594 | /// Skip folder tagging and proceed directly to analysis. | |
| 594 | 595 | pub fn skip_folder_tags(&mut self) { | |
| 595 | - | if let ImportMode::TagFolders { | |
| 596 | - | ref sample_hashes, .. | |
| 597 | - | } = self.import_mode | |
| 598 | - | { | |
| 599 | - | let hashes = sample_hashes.clone(); | |
| 600 | - | self.start_analysis_flow(hashes); | |
| 596 | + | let mode = std::mem::replace(&mut self.import_mode, ImportMode::None); | |
| 597 | + | if let ImportMode::TagFolders { sample_hashes, .. } = mode { | |
| 598 | + | self.start_analysis_flow(sample_hashes); | |
| 599 | + | } else { | |
| 600 | + | self.import_mode = mode; | |
| 601 | 601 | } | |
| 602 | 602 | } | |
| 603 | 603 |
| @@ -49,7 +49,7 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 49 | 49 | None | |
| 50 | 50 | }; | |
| 51 | 51 | ||
| 52 | - | let resp = waveform::draw_waveform(ui, waveform_data, playback_pos, 100.0); | |
| 52 | + | let resp = waveform::draw_waveform(ui, waveform_data, playback_pos, 120.0); | |
| 53 | 53 | // Click-to-seek: map the click's X position to a 0.0–1.0 fraction | |
| 54 | 54 | // within the waveform rect, then set the playback cursor to that frame. | |
| 55 | 55 | if resp.clicked() { | |
| @@ -73,19 +73,19 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 73 | 73 | } | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | - | ui.add_space(12.0); | |
| 76 | + | ui.add_space(theme::section_spacing()); | |
| 77 | 77 | } | |
| 78 | 78 | ||
| 79 | 79 | // Sample name | |
| 80 | 80 | ui.label(egui::RichText::new(&node.node.name).strong().size(14.0)); | |
| 81 | - | ui.add_space(4.0); | |
| 81 | + | ui.add_space(8.0); | |
| 82 | 82 | ||
| 83 | 83 | // Analysis metadata grid | |
| 84 | 84 | if let Some(ref analysis) = state.selected_analysis { | |
| 85 | 85 | ui.group(|ui| { | |
| 86 | 86 | egui::Grid::new("detail_metadata") | |
| 87 | 87 | .num_columns(2) | |
| 88 | - | .spacing([8.0, 4.0]) | |
| 88 | + | .spacing([8.0, theme::grid_row_spacing()]) | |
| 89 | 89 | .show(ui, |ui| { | |
| 90 | 90 | ui.label(egui::RichText::new("Duration").color(theme::text_secondary())); | |
| 91 | 91 | ui.label(widgets::format_duration(analysis.duration)); | |
| @@ -144,9 +144,9 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 144 | 144 | }); | |
| 145 | 145 | } | |
| 146 | 146 | ||
| 147 | - | ui.add_space(12.0); | |
| 147 | + | ui.add_space(theme::section_spacing()); | |
| 148 | 148 | ui.separator(); | |
| 149 | - | ui.add_space(4.0); | |
| 149 | + | ui.add_space(theme::section_spacing() * 0.5); | |
| 150 | 150 | ||
| 151 | 151 | // Tags section | |
| 152 | 152 | ui.label(egui::RichText::new("Tags").color(theme::text_secondary())); | |
| @@ -198,7 +198,7 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 198 | 198 | let class_str = class.to_string(); | |
| 199 | 199 | let suggestions = classification_tag_suggestions(&class_str, &state.selected_tags); | |
| 200 | 200 | if !suggestions.is_empty() { | |
| 201 | - | ui.add_space(2.0); | |
| 201 | + | ui.add_space(6.0); | |
| 202 | 202 | ui.horizontal_wrapped(|ui| { | |
| 203 | 203 | ui.label(egui::RichText::new("Suggest:").small().color(theme::text_muted())); | |
| 204 | 204 | for sug in &suggestions { | |
| @@ -216,9 +216,9 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 216 | 216 | } | |
| 217 | 217 | } | |
| 218 | 218 | ||
| 219 | - | ui.add_space(12.0); | |
| 219 | + | ui.add_space(theme::section_spacing()); | |
| 220 | 220 | ui.separator(); | |
| 221 | - | ui.add_space(4.0); | |
| 221 | + | ui.add_space(theme::section_spacing() * 0.5); | |
| 222 | 222 | ||
| 223 | 223 | // Action buttons | |
| 224 | 224 | ui.horizontal(|ui| { | |
| @@ -238,7 +238,7 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 238 | 238 | ||
| 239 | 239 | // Discovery buttons | |
| 240 | 240 | if let Some(hash) = &node.node.sample_hash { | |
| 241 | - | ui.add_space(4.0); | |
| 241 | + | ui.add_space(6.0); | |
| 242 | 242 | ui.horizontal(|ui| { | |
| 243 | 243 | let hash = hash.clone(); | |
| 244 | 244 | if ui.button("Find Similar").on_hover_text("Find similar samples (Shift+F)").clicked() { |
| @@ -55,7 +55,7 @@ pub fn draw_configure_import(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 55 | 55 | let current_vfs_id = state.current_vfs_id(); | |
| 56 | 56 | let current_dir = state.current_dir; | |
| 57 | 57 | if ui.radio(is_flat, "Flat (all files in current directory)").clicked() && !is_flat { | |
| 58 | - | if let (ImportMode::ConfigureImport { ref mut strategy, .. }, Some(vfs_id)) = | |
| 58 | + | if let (ImportMode::ConfigureImport { strategy, .. }, Some(vfs_id)) = | |
| 59 | 59 | (&mut state.import_mode, current_vfs_id) | |
| 60 | 60 | { | |
| 61 | 61 | *strategy = ImportStrategy::Flat { |
| @@ -81,6 +81,11 @@ pub struct ThemeColors { | |||
| 81 | 81 | pub rounding: f32, | |
| 82 | 82 | pub item_spacing_x: f32, | |
| 83 | 83 | pub item_spacing_y: f32, | |
| 84 | + | // Detail panel layout | |
| 85 | + | pub section_spacing: f32, | |
| 86 | + | pub grid_row_spacing: f32, | |
| 87 | + | pub button_padding_x: f32, | |
| 88 | + | pub button_padding_y: f32, | |
| 84 | 89 | } | |
| 85 | 90 | ||
| 86 | 91 | impl Default for ThemeColors { | |
| @@ -104,6 +109,10 @@ impl Default for ThemeColors { | |||
| 104 | 109 | rounding: 4.0, | |
| 105 | 110 | item_spacing_x: 8.0, | |
| 106 | 111 | item_spacing_y: 5.0, | |
| 112 | + | section_spacing: 16.0, | |
| 113 | + | grid_row_spacing: 6.0, | |
| 114 | + | button_padding_x: 8.0, | |
| 115 | + | button_padding_y: 4.0, | |
| 107 | 116 | } | |
| 108 | 117 | } | |
| 109 | 118 | } | |
| @@ -181,6 +190,11 @@ pub fn accent_cyan() -> Color32 { THEME.read().accent_cyan } | |||
| 181 | 190 | /// Default border/separator color. | |
| 182 | 191 | pub fn border_default() -> Color32 { THEME.read().border_default } | |
| 183 | 192 | ||
| 193 | + | /// Section spacing for detail panel (between waveform, metadata, tags, actions). | |
| 194 | + | pub fn section_spacing() -> f32 { THEME.read().section_spacing } | |
| 195 | + | /// Grid row spacing for metadata grid. | |
| 196 | + | pub fn grid_row_spacing() -> f32 { THEME.read().grid_row_spacing } | |
| 197 | + | ||
| 184 | 198 | /// Piano white key — always a light shade regardless of theme variant. | |
| 185 | 199 | pub fn piano_white_key() -> Color32 { | |
| 186 | 200 | lerp_color(THEME.read().bg_surface, Color32::WHITE, 0.7) | |
| @@ -301,6 +315,10 @@ fn parse_theme(content: &str) -> Result<ThemeColors, toml::de::Error> { | |||
| 301 | 315 | rounding: get_f32("rounding", 4.0), | |
| 302 | 316 | item_spacing_x: get_f32("item_spacing_x", 8.0), | |
| 303 | 317 | item_spacing_y: get_f32("item_spacing_y", 5.0), | |
| 318 | + | section_spacing: get_f32("section_spacing", 16.0), | |
| 319 | + | grid_row_spacing: get_f32("grid_row_spacing", 6.0), | |
| 320 | + | button_padding_x: get_f32("button_padding_x", 8.0), | |
| 321 | + | button_padding_y: get_f32("button_padding_y", 4.0), | |
| 304 | 322 | }) | |
| 305 | 323 | } | |
| 306 | 324 | ||
| @@ -486,15 +504,31 @@ pub fn apply_theme(ctx: &egui::Context) { | |||
| 486 | 504 | visuals.widgets.active.corner_radius = rounding; | |
| 487 | 505 | visuals.widgets.open.corner_radius = rounding; | |
| 488 | 506 | ||
| 507 | + | // Softer widget borders: thinner strokes on inactive/hover states | |
| 508 | + | visuals.widgets.inactive.bg_stroke = egui::Stroke::new(0.5, lerp_color(t.border_default, t.bg_secondary, 0.3)); | |
| 509 | + | visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, t.border_default); | |
| 510 | + | visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, t.accent_blue); | |
| 511 | + | ||
| 512 | + | // Softer separator color | |
| 513 | + | visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(0.5, lerp_color(t.border_default, t.bg_secondary, 0.4)); | |
| 514 | + | ||
| 515 | + | // Widget expansion on hover for tactile feedback | |
| 516 | + | visuals.widgets.hovered.expansion = 1.0; | |
| 517 | + | visuals.widgets.active.expansion = 0.0; | |
| 518 | + | ||
| 489 | 519 | let spacing_x = t.item_spacing_x; | |
| 490 | 520 | let spacing_y = t.item_spacing_y; | |
| 521 | + | let btn_pad_x = t.button_padding_x; | |
| 522 | + | let btn_pad_y = t.button_padding_y; | |
| 491 | 523 | drop(t); | |
| 492 | 524 | ctx.set_visuals(visuals); | |
| 493 | 525 | ||
| 494 | 526 | // Apply theme spacing | |
| 495 | 527 | let mut style = (*ctx.style()).clone(); | |
| 496 | 528 | style.spacing.item_spacing = egui::vec2(spacing_x, spacing_y); | |
| 497 | - | style.spacing.button_padding = egui::vec2(6.0, 3.0); | |
| 529 | + | style.spacing.button_padding = egui::vec2(btn_pad_x, btn_pad_y); | |
| 530 | + | style.spacing.window_margin = egui::vec2(10.0, 10.0).into(); | |
| 531 | + | style.spacing.indent = 18.0; | |
| 498 | 532 | ctx.set_style(style); | |
| 499 | 533 | } | |
| 500 | 534 |
| @@ -555,7 +555,7 @@ fn predict_with_model( | |||
| 555 | 555 | let (best_class_id, &best_count) = votes | |
| 556 | 556 | .iter() | |
| 557 | 557 | .enumerate() | |
| 558 | - | .max_by_key(|(_, &count)| count) | |
| 558 | + | .max_by_key(|(_, count)| **count) | |
| 559 | 559 | .unwrap_or((0, &0)); | |
| 560 | 560 | ||
| 561 | 561 | let confidence = best_count as f64 / total; |
| @@ -81,7 +81,7 @@ impl WorkerHandle { | |||
| 81 | 81 | /// Send a command to the worker. | |
| 82 | 82 | pub fn send(&self, cmd: WorkerCommand) { | |
| 83 | 83 | if matches!(cmd, WorkerCommand::Cancel) { | |
| 84 | - | self.cancel_flag.store(true, Ordering::Relaxed); | |
| 84 | + | self.cancel_flag.store(true, Ordering::Release); | |
| 85 | 85 | } | |
| 86 | 86 | let _ = self.cmd_tx.send(cmd); | |
| 87 | 87 | } | |
| @@ -89,7 +89,7 @@ impl WorkerHandle { | |||
| 89 | 89 | ||
| 90 | 90 | impl Drop for WorkerHandle { | |
| 91 | 91 | fn drop(&mut self) { | |
| 92 | - | self.cancel_flag.store(true, Ordering::Relaxed); | |
| 92 | + | self.cancel_flag.store(true, Ordering::Release); | |
| 93 | 93 | let _ = self.cmd_tx.send(WorkerCommand::Shutdown); | |
| 94 | 94 | if let Some(handle) = self.thread.take() { | |
| 95 | 95 | let _ = handle.join(); | |
| @@ -136,7 +136,7 @@ fn worker_loop( | |||
| 136 | 136 | } | |
| 137 | 137 | WorkerCommand::AnalyzeBatch { samples, config } => { | |
| 138 | 138 | // Reset cancel flag for this batch | |
| 139 | - | cancel_flag.store(false, Ordering::Relaxed); | |
| 139 | + | cancel_flag.store(false, Ordering::Release); | |
| 140 | 140 | ||
| 141 | 141 | let total = samples.len(); | |
| 142 | 142 | let completed = Arc::new(AtomicUsize::new(0)); | |
| @@ -144,12 +144,12 @@ fn worker_loop( | |||
| 144 | 144 | // Process samples in parallel using rayon | |
| 145 | 145 | samples.par_iter().for_each(|(hash, _ext, path)| { | |
| 146 | 146 | // Check cancel flag before starting each sample | |
| 147 | - | if cancel_flag.load(Ordering::Relaxed) { | |
| 147 | + | if cancel_flag.load(Ordering::Acquire) { | |
| 148 | 148 | return; | |
| 149 | 149 | } | |
| 150 | 150 | ||
| 151 | 151 | let name = crate::util::get_filename(path, "unknown"); | |
| 152 | - | let done = completed.load(Ordering::Relaxed); | |
| 152 | + | let done = completed.load(Ordering::Acquire); | |
| 153 | 153 | ||
| 154 | 154 | let _ = event_tx.send(WorkerEvent::Progress { | |
| 155 | 155 | completed: done, | |
| @@ -188,7 +188,7 @@ fn worker_loop( | |||
| 188 | 188 | } | |
| 189 | 189 | } | |
| 190 | 190 | ||
| 191 | - | completed.fetch_add(1, Ordering::Relaxed); | |
| 191 | + | completed.fetch_add(1, Ordering::Release); | |
| 192 | 192 | }); | |
| 193 | 193 | ||
| 194 | 194 | let _ = event_tx.send(WorkerEvent::BatchComplete); | |
| @@ -267,7 +267,7 @@ mod tests { | |||
| 267 | 267 | handle.send(WorkerCommand::Cancel); | |
| 268 | 268 | // Give it a moment to process | |
| 269 | 269 | std::thread::sleep(std::time::Duration::from_millis(10)); | |
| 270 | - | assert!(handle.cancel_flag.load(Ordering::Relaxed)); | |
| 270 | + | assert!(handle.cancel_flag.load(Ordering::Acquire)); | |
| 271 | 271 | drop(handle); | |
| 272 | 272 | } | |
| 273 | 273 | } |
| @@ -28,7 +28,7 @@ pub struct AuthSession { | |||
| 28 | 28 | pub fn generate_code_verifier() -> String { | |
| 29 | 29 | use rand::RngCore; | |
| 30 | 30 | let mut buf = [0u8; 32]; | |
| 31 | - | rand::thread_rng().fill_bytes(&mut buf); | |
| 31 | + | rand::rng().fill_bytes(&mut buf); | |
| 32 | 32 | URL_SAFE_NO_PAD.encode(buf) | |
| 33 | 33 | } | |
| 34 | 34 | ||
| @@ -42,7 +42,7 @@ pub fn generate_code_challenge(verifier: &str) -> String { | |||
| 42 | 42 | pub fn generate_state() -> String { | |
| 43 | 43 | use rand::RngCore; | |
| 44 | 44 | let mut buf = [0u8; 16]; | |
| 45 | - | rand::thread_rng().fill_bytes(&mut buf); | |
| 45 | + | rand::rng().fill_bytes(&mut buf); | |
| 46 | 46 | URL_SAFE_NO_PAD.encode(buf) | |
| 47 | 47 | } | |
| 48 | 48 |
| @@ -993,4 +993,264 @@ mod tests { | |||
| 993 | 993 | set_sync_state(conn, "auto_sync_enabled", "0").unwrap(); | |
| 994 | 994 | assert_eq!(get_sync_state(conn, "auto_sync_enabled").unwrap(), "0"); | |
| 995 | 995 | } | |
| 996 | + | ||
| 997 | + | // ── Download query logic ── | |
| 998 | + | ||
| 999 | + | #[test] | |
| 1000 | + | fn missing_blobs_query_finds_sync_enabled_only() { | |
| 1001 | + | let db = setup_test_db(); | |
| 1002 | + | let conn = db.conn(); | |
| 1003 | + | ||
| 1004 | + | // Create two VFS: one with sync_files=true, one without | |
| 1005 | + | let vfs_sync = insert_vfs(conn, "Synced", true); | |
| 1006 | + | let vfs_local = insert_vfs(conn, "Local", false); | |
| 1007 | + | ||
| 1008 | + | insert_sample(conn, "hash_a", "kick.wav", "wav"); | |
| 1009 | + | insert_sample(conn, "hash_b", "snare.wav", "wav"); | |
| 1010 | + | ||
| 1011 | + | // Link hash_a to synced VFS, hash_b to local-only VFS | |
| 1012 | + | let now = chrono::Utc::now().timestamp(); | |
| 1013 | + | conn.execute( | |
| 1014 | + | "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) VALUES (?1, NULL, 'kick.wav', 'sample', 'hash_a', ?2)", | |
| 1015 | + | rusqlite::params![vfs_sync, now], | |
| 1016 | + | ).unwrap(); | |
| 1017 | + | conn.execute( | |
| 1018 | + | "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) VALUES (?1, NULL, 'snare.wav', 'sample', 'hash_b', ?2)", | |
| 1019 | + | rusqlite::params![vfs_local, now], | |
| 1020 | + | ).unwrap(); | |
| 1021 | + | ||
| 1022 | + | // Run the same query as download_missing_blobs | |
| 1023 | + | let mut stmt = conn.prepare( | |
| 1024 | + | "SELECT DISTINCT s.hash, s.file_extension | |
| 1025 | + | FROM samples s | |
| 1026 | + | JOIN vfs_nodes vn ON vn.sample_hash = s.hash | |
| 1027 | + | JOIN vfs v ON v.id = vn.vfs_id | |
| 1028 | + | WHERE v.sync_files = 1", | |
| 1029 | + | ).unwrap(); | |
| 1030 | + | let rows: Vec<(String, String)> = stmt | |
| 1031 | + | .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) | |
| 1032 | + | .unwrap() | |
| 1033 | + | .collect::<std::result::Result<Vec<_>, _>>() | |
| 1034 | + | .unwrap(); | |
| 1035 | + | ||
| 1036 | + | assert_eq!(rows.len(), 1); | |
| 1037 | + | assert_eq!(rows[0].0, "hash_a"); | |
| 1038 | + | } | |
| 1039 | + | ||
| 1040 | + | #[test] | |
| 1041 | + | fn missing_blobs_query_deduplicates_multi_vfs() { | |
| 1042 | + | let db = setup_test_db(); | |
| 1043 | + | let conn = db.conn(); | |
| 1044 | + | ||
| 1045 | + | let vfs1 = insert_vfs(conn, "Lib1", true); | |
| 1046 | + | let vfs2 = insert_vfs(conn, "Lib2", true); | |
| 1047 | + | insert_sample(conn, "shared_hash", "shared.wav", "wav"); | |
| 1048 | + | ||
| 1049 | + | // Same sample linked in two synced VFS entries | |
| 1050 | + | let now = chrono::Utc::now().timestamp(); | |
| 1051 | + | conn.execute( | |
| 1052 | + | "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) VALUES (?1, NULL, 'a.wav', 'sample', 'shared_hash', ?2)", | |
| 1053 | + | rusqlite::params![vfs1, now], | |
| 1054 | + | ).unwrap(); | |
| 1055 | + | conn.execute( | |
| 1056 | + | "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) VALUES (?1, NULL, 'b.wav', 'sample', 'shared_hash', ?2)", | |
| 1057 | + | rusqlite::params![vfs2, now], | |
| 1058 | + | ).unwrap(); | |
| 1059 | + | ||
| 1060 | + | let mut stmt = conn.prepare( | |
| 1061 | + | "SELECT DISTINCT s.hash, s.file_extension | |
| 1062 | + | FROM samples s | |
| 1063 | + | JOIN vfs_nodes vn ON vn.sample_hash = s.hash | |
| 1064 | + | JOIN vfs v ON v.id = vn.vfs_id | |
| 1065 | + | WHERE v.sync_files = 1", | |
| 1066 | + | ).unwrap(); | |
| 1067 | + | let rows: Vec<(String, String)> = stmt | |
| 1068 | + | .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) | |
| 1069 | + | .unwrap() | |
| 1070 | + | .collect::<std::result::Result<Vec<_>, _>>() | |
| 1071 | + | .unwrap(); | |
| 1072 | + | ||
| 1073 | + | // DISTINCT should yield exactly one row | |
| 1074 | + | assert_eq!(rows.len(), 1); | |
| 1075 | + | } | |
| 1076 | + | ||
| 1077 | + | // ── Upload query logic ── | |
| 1078 | + | ||
| 1079 | + | #[test] | |
| 1080 | + | fn upload_pending_query_finds_non_cloud_only() { | |
| 1081 | + | let db = setup_test_db(); | |
| 1082 | + | let conn = db.conn(); | |
| 1083 | + | ||
| 1084 | + | let vfs = insert_vfs(conn, "Synced", true); | |
| 1085 | + | insert_sample(conn, "local_hash", "kick.wav", "wav"); | |
| 1086 | + | ||
| 1087 | + | // Mark one sample as cloud_only | |
| 1088 | + | conn.execute("UPDATE samples SET cloud_only = 1 WHERE hash = 'local_hash'", []).unwrap(); | |
| 1089 | + | ||
| 1090 | + | insert_sample(conn, "present_hash", "snare.wav", "wav"); | |
| 1091 | + | ||
| 1092 | + | let now = chrono::Utc::now().timestamp(); | |
| 1093 | + | conn.execute( | |
| 1094 | + | "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) VALUES (?1, NULL, 'kick.wav', 'sample', 'local_hash', ?2)", | |
| 1095 | + | rusqlite::params![vfs, now], | |
| 1096 | + | ).unwrap(); | |
| 1097 | + | conn.execute( | |
| 1098 | + | "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) VALUES (?1, NULL, 'snare.wav', 'sample', 'present_hash', ?2)", | |
| 1099 | + | rusqlite::params![vfs, now], | |
| 1100 | + | ).unwrap(); | |
| 1101 | + | ||
| 1102 | + | // Same query as upload_pending_blobs | |
| 1103 | + | let mut stmt = conn.prepare( | |
| 1104 | + | "SELECT DISTINCT s.hash, s.file_extension, s.file_size | |
| 1105 | + | FROM samples s | |
| 1106 | + | JOIN vfs_nodes vn ON vn.sample_hash = s.hash | |
| 1107 | + | JOIN vfs v ON v.id = vn.vfs_id | |
| 1108 | + | WHERE v.sync_files = 1 AND s.cloud_only = 0", | |
| 1109 | + | ).unwrap(); | |
| 1110 | + | let rows: Vec<(String, String, i64)> = stmt | |
| 1111 | + | .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) | |
| 1112 | + | .unwrap() | |
| 1113 | + | .collect::<std::result::Result<Vec<_>, _>>() | |
| 1114 | + | .unwrap(); | |
| 1115 | + | ||
| 1116 | + | // Only present_hash (cloud_only=0) | |
| 1117 | + | assert_eq!(rows.len(), 1); | |
| 1118 | + | assert_eq!(rows[0].0, "present_hash"); | |
| 1119 | + | } | |
| 1120 | + | ||
| 1121 | + | #[test] | |
| 1122 | + | fn push_changelog_reads_unpushed_in_order() { | |
| 1123 | + | let db = setup_test_db(); | |
| 1124 | + | let conn = db.conn(); | |
| 1125 | + | clear_changelog(conn); | |
| 1126 | + | ||
| 1127 | + | // Insert changelog entries with specific order | |
| 1128 | + | for i in 0..5 { | |
| 1129 | + | conn.execute( | |
| 1130 | + | "INSERT INTO sync_changelog (table_name, op, row_id, data, pushed) VALUES ('samples', 'INSERT', ?1, '{}', 0)", | |
| 1131 | + | [format!("push-{}", i)], | |
| 1132 | + | ).unwrap(); | |
| 1133 | + | } | |
| 1134 | + | // Mark one as already pushed | |
| 1135 | + | conn.execute( | |
| 1136 | + | "UPDATE sync_changelog SET pushed = 1 WHERE row_id = 'push-2'", | |
| 1137 | + | [], | |
| 1138 | + | ).unwrap(); | |
| 1139 | + | ||
| 1140 | + | // Same query as push_changes | |
| 1141 | + | let mut stmt = conn.prepare( | |
| 1142 | + | "SELECT id, table_name, op, row_id, timestamp, data | |
| 1143 | + | FROM sync_changelog | |
| 1144 | + | WHERE pushed = 0 | |
| 1145 | + | ORDER BY id ASC | |
| 1146 | + | LIMIT ?1", | |
| 1147 | + | ).unwrap(); | |
| 1148 | + | let rows: Vec<(i64, String, String, String)> = stmt | |
| 1149 | + | .query_map([500i64], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))) | |
| 1150 | + | .unwrap() | |
| 1151 | + | .collect::<std::result::Result<Vec<_>, _>>() | |
| 1152 | + | .unwrap(); | |
| 1153 | + | ||
| 1154 | + | // Should skip push-2 (already pushed) | |
| 1155 | + | assert_eq!(rows.len(), 4); | |
| 1156 | + | let row_ids: Vec<&str> = rows.iter().map(|r| r.3.as_str()).collect(); | |
| 1157 | + | assert!(!row_ids.contains(&"push-2")); | |
| 1158 | + | // Should be in ASC order | |
| 1159 | + | assert_eq!(row_ids[0], "push-0"); | |
| 1160 | + | assert_eq!(row_ids[3], "push-4"); | |
| 1161 | + | } | |
| 1162 | + | ||
| 1163 | + | // ── Resolve: mixed upsert/delete ordering ── | |
| 1164 | + | ||
| 1165 | + | #[test] | |
| 1166 | + | fn apply_remote_changes_mixed_ops_correct_order() { | |
| 1167 | + | let db = setup_test_db(); | |
| 1168 | + | let conn = db.conn(); | |
| 1169 | + | clear_changelog(conn); | |
| 1170 | + | ||
| 1171 | + | // Insert a sample first | |
| 1172 | + | let changes_insert = vec![ | |
| 1173 | + | change("samples", ChangeOp::Insert, "mix_hash", Some(json!({ | |
| 1174 | + | "hash": "mix_hash", "original_name": "mixed.wav", | |
| 1175 | + | "file_extension": "wav", "file_size": 1024, | |
| 1176 | + | "import_date": 1000000, "last_modified": 1000000, | |
| 1177 | + | "cloud_only": 0 | |
| 1178 | + | }))), | |
| 1179 | + | change("tags", ChangeOp::Insert, "mix_hash:bass", Some(json!({ | |
| 1180 | + | "sample_hash": "mix_hash", "tag": "bass" | |
| 1181 | + | }))), | |
| 1182 | + | ]; | |
| 1183 | + | apply_remote_changes(conn, &changes_insert).unwrap(); | |
| 1184 | + | ||
| 1185 | + | // Now delete tag then sample (mixed batch) | |
| 1186 | + | let changes_delete = vec![ | |
| 1187 | + | change("tags", ChangeOp::Delete, "mix_hash:bass", None), | |
| 1188 | + | change("samples", ChangeOp::Delete, "mix_hash", None), | |
| 1189 | + | ]; | |
| 1190 | + | let applied = apply_remote_changes(conn, &changes_delete).unwrap(); | |
| 1191 | + | assert_eq!(applied, 2); | |
| 1192 | + | ||
| 1193 | + | let sample_count: i64 = conn.query_row( | |
| 1194 | + | "SELECT COUNT(*) FROM samples WHERE hash = 'mix_hash'", | |
| 1195 | + | [], |row| row.get(0), | |
| 1196 | + | ).unwrap(); | |
| 1197 | + | assert_eq!(sample_count, 0); | |
| 1198 | + | ||
| 1199 | + | let tag_count: i64 = conn.query_row( | |
| 1200 | + | "SELECT COUNT(*) FROM tags WHERE sample_hash = 'mix_hash'", | |
| 1201 | + | [], |row| row.get(0), | |
| 1202 | + | ).unwrap(); | |
| 1203 | + | assert_eq!(tag_count, 0); | |
| 1204 | + | } | |
| 1205 | + | ||
| 1206 | + | #[test] | |
| 1207 | + | fn apply_upsert_updates_existing_row() { | |
| 1208 | + | let db = setup_test_db(); | |
| 1209 | + | let conn = db.conn(); | |
| 1210 | + | ||
| 1211 | + | // Insert initial sample | |
| 1212 | + | apply_upsert(conn, "samples", &json!({ | |
| 1213 | + | "hash": "upd_hash", "original_name": "old.wav", | |
| 1214 | + | "file_extension": "wav", "file_size": 1024, | |
| 1215 | + | "import_date": 1000000, "last_modified": 1000000, | |
| 1216 | + | "cloud_only": 0 | |
| 1217 | + | })).unwrap(); | |
| 1218 | + | ||
| 1219 | + | // Update via upsert (ON CONFLICT DO UPDATE) | |
| 1220 | + | apply_upsert(conn, "samples", &json!({ | |
| 1221 | + | "hash": "upd_hash", "original_name": "new.wav", | |
| 1222 | + | "file_extension": "wav", "file_size": 2048, | |
| 1223 | + | "import_date": 1000000, "last_modified": 2000000, | |
| 1224 | + | "cloud_only": 0 | |
| 1225 | + | })).unwrap(); | |
| 1226 | + | ||
| 1227 | + | let name: String = conn.query_row( | |
| 1228 | + | "SELECT original_name FROM samples WHERE hash = 'upd_hash'", | |
| 1229 | + | [], |row| row.get(0), | |
| 1230 | + | ).unwrap(); | |
| 1231 | + | assert_eq!(name, "new.wav"); | |
| 1232 | + | ||
| 1233 | + | let size: i64 = conn.query_row( | |
| 1234 | + | "SELECT file_size FROM samples WHERE hash = 'upd_hash'", | |
| 1235 | + | [], |row| row.get(0), | |
| 1236 | + | ).unwrap(); | |
| 1237 | + | assert_eq!(size, 2048); | |
| 1238 | + | ||
| 1239 | + | // Should still be only 1 row | |
| 1240 | + | let count: i64 = conn.query_row( | |
| 1241 | + | "SELECT COUNT(*) FROM samples WHERE hash = 'upd_hash'", | |
| 1242 | + | [], |row| row.get(0), | |
| 1243 | + | ).unwrap(); | |
| 1244 | + | assert_eq!(count, 1); | |
| 1245 | + | } | |
| 1246 | + | ||
| 1247 | + | #[test] | |
| 1248 | + | fn apply_delete_nonexistent_is_noop() { | |
| 1249 | + | let db = setup_test_db(); | |
| 1250 | + | let conn = db.conn(); | |
| 1251 | + | ||
| 1252 | + | // Delete a hash that doesn't exist — should succeed (no-op) | |
| 1253 | + | let result = apply_delete(conn, "samples", "nonexistent_hash"); | |
| 1254 | + | assert!(result.is_ok()); | |
| 1255 | + | } | |
| 996 | 1256 | } |
| @@ -303,7 +303,7 @@ fn train_random_forest( | |||
| 303 | 303 | let mut boot_data = Vec::with_capacity(n); | |
| 304 | 304 | let mut boot_labels = Vec::with_capacity(n); | |
| 305 | 305 | for _ in 0..n { | |
| 306 | - | let idx = rng.gen_range(0..n); | |
| 306 | + | let idx = rng.random_range(0..n); | |
| 307 | 307 | boot_data.push(data[idx]); | |
| 308 | 308 | boot_labels.push(labels[idx]); | |
| 309 | 309 | } |
| @@ -1,102 +1,130 @@ | |||
| 1 | 1 | # audiofiles -- Code Audit Review | |
| 2 | 2 | ||
| 3 | - | **Last audited:** 2026-04-18 (nineteenth audit, Run 15 cross-project) | |
| 4 | - | **Previous audit:** 2026-04-15 (eighteenth audit, Run 14 cross-project) | |
| 3 | + | **Last audited:** 2026-05-04 (twentieth audit, Run 20 cross-project) | |
| 4 | + | **Previous audit:** 2026-04-18 (nineteenth audit, Run 15 cross-project) | |
| 5 | 5 | ||
| 6 | 6 | ## Overall Grade: A | |
| 7 | 7 | ||
| 8 | - | Run 15 cross-project audit. 688 tests (all pass). 0 clippy warnings. v0.3.6. Grade A (stable). ~40,219 LOC. Minor issues only: Relaxed atomic ordering in cancel flag (worker.rs:84,147), export CTE duplication. Previous unfixed items remain LOW severity (sidebar.rs unwraps, updater.rs URL trust). | |
| 8 | + | Run 20 cross-project audit. 773 tests (all pass). 4 clippy warnings (trivial). v0.4.0. Grade A (stable). ~42,652 LOC. Sidebar unwraps fixed. Analysis worker Relaxed ordering persists (LOW). App main.rs at 1296 lines exceeds thin-shell intent but is functional. | |
| 9 | 9 | ||
| 10 | 10 | ## Scorecard | |
| 11 | 11 | ||
| 12 | 12 | | Dimension | Grade | Notes | | |
| 13 | 13 | |-----------|:-----:|-------| | |
| 14 | - | | Code Quality | A | Clippy clean (0 warnings). No raw SQL in UI layer. Consistent error types. Zero production unwraps in sync/service.rs and theme.rs (verified). 2 guarded unwraps in sidebar.rs (safe but redundant). | | |
| 15 | - | | Architecture | A | 5-crate workspace: core (sync DB/store), browser (state + UI + backend trait), app, sync, rhai. Backend trait cleanly abstracts data access. Core crate has zero UI/async dependencies. | | |
| 16 | - | | Testing | A+ | 688 tests, all passing. Core: ~410 (incl. 25 classifier tests, 3 e2e), browser: 183, app: 22, sync: 27, rhai: 34, train: ~28. VP-tree: 10 tests. FingerprintIndex: 4 tests. SimilarityIndex: 5 tests. App: updater state machine (8), API key persistence (7), tray icon (2), audio (5). | | |
| 17 | - | | Security | A | All SQL parameterized. LIKE wildcards escaped. Hash validated. Column whitelists in sync. 17 unsafe blocks in drag_out/ (all platform FFI). Drag-out filenames sanitized. OTA updater HTTPS-only endpoint but trusts server-provided download URL (opens in browser, no auto-install). | | |
| 18 | - | | Performance | A- | try_lock on cpal audio callback. LEFT JOIN enriched queries (no N+1). 7+ indexes. WAL mode. Background workers for import/analysis/export. Pre-computed waveforms. VP-tree indexes for similarity and fingerprint search. | | |
| 19 | - | | Documentation | A | Every module has //! docs. Public functions have /// docs. SAFETY comments on unsafe blocks. architecture.md and README. | | |
| 20 | - | | Dependencies | A | All deps use semver. No unused deps. No git-pinned deps. | | |
| 21 | - | | Frontend | A- | egui patterns clean. TOML theme system with bundled themes + custom loading. Waveform painter with click-to-seek. Keyboard shortcuts. try_lock from GUI thread. file_list.rs (689 LOC, high branch density) is the largest UI file. | | |
| 22 | - | | Type Safety | A | `VfsId`, `NodeId`, `SmartFolderId`, `CollectionId` i64 newtypes via macro. `SampleHash(String)` validated newtype. Good domain enums. Typed error hierarchy. | | |
| 23 | - | | Observability | A- | `tracing` in all crates with EnvFilter subscriber. 115 `#[instrument(skip_all)]` annotations across 50+ files. | | |
| 24 | - | | Concurrency | A | Correct `try_lock()` on audio thread with silence fallback. Workers in own threads with separate DB connections. Single-lock .take() pattern eliminates double-lock TOCTOU. Relaxed atomic ordering in cancel flag (worker.rs:84,147) -- minor. | | |
| 25 | - | | Resilience | A- | Worker Drop with clean Shutdown+join. Per-file error reporting during import. Audio stream failure non-fatal. Sync optional. Tray failure non-fatal. Atomic migrations. `applying_remote` cleared on startup. | | |
| 26 | - | | API Consistency | A | 47-method Backend trait with uniform `BackendResult<T>`. Consistent naming patterns. | | |
| 27 | - | | Migration Safety | A | Inline migrations, all additive. CASCADE foreign keys. | | |
| 28 | - | | Codebase Size | A- | ~40,219 LOC across 5 crates + train for ~18 major features + ML classifier + cloud sync + Rhai scripting + native drag-out. 4 embedded RF models total 7.8MB. | | |
| 14 | + | | Code Quality | A | 4 trivial clippy warnings (repeat-take, collapsible if, same-type cast, arg count). Sidebar unwraps fixed since Run 15. Typed error hierarchy across all crates. | | |
| 15 | + | | Architecture | A- | 7-crate workspace (core, browser, app, sync, rhai, train, bench). Core is sync-only. Backend trait cleanly abstracts data. App main.rs at 1296 lines exceeds thin-shell intent (minor). | | |
| 16 | + | | Testing | A | 773 tests, all passing. Core: ~498 (incl. e2e pipeline), browser: ~199 (1653-line test file), app: ~59, sync: ~34, rhai: ~38. +85 since Run 15. | | |
| 17 | + | | Security | A | All SQL parameterized. LIKE escaped. Hash validated (64 hex). Column whitelists in sync. 17 unsafe blocks (all FFI, all with SAFETY comments). OTA URL trust (LOW, unchanged). | | |
| 18 | + | | Performance | A- | try_lock on cpal callback. Enriched LEFT JOIN queries. 7+ indexes. WAL mode. Background workers for all heavy ops. VP-tree indexes for similarity/fingerprint. | | |
| 19 | + | | Documentation | A | Every module has //! docs. Public functions have /// docs. SAFETY comments on unsafe. architecture.md, README, CONTRIBUTING.md. | | |
| 20 | + | | Dependencies | A | All deps semver-latest. No unused deps. No git-pinned deps. | | |
| 21 | + | | Frontend | A- | egui patterns clean. TOML theme system (27 bundled + custom). Waveform painter. Keyboard shortcuts. try_lock from GUI. | | |
| 22 | + | | Type Safety | A | VfsId, NodeId, SmartFolderId, CollectionId i64 newtypes via macro. SampleHash(String) validated. Domain enums. Typed error hierarchy. | | |
| 23 | + | | Observability | A- | tracing in all crates with EnvFilter. 115+ #[instrument(skip_all)] annotations. | | |
| 24 | + | | Concurrency | A | try_lock() on audio thread with silence fallback. Workers in own threads. Single-lock .take() pattern. Analysis worker uses Relaxed ordering (LOW — benign on ARM). | | |
| 25 | + | | Resilience | A- | Worker Drop with Shutdown+join. Per-file error reporting. Audio/tray/sync failures non-fatal. applying_remote cleared on startup. | | |
| 26 | + | | API Consistency | A | 47-method Backend trait with uniform BackendResult<T>. Consistent naming. | | |
| 27 | + | | Migration Safety | A | Inline migrations, all additive. CASCADE foreign keys. Duplicate-column recovery with logging. | | |
| 28 | + | | Codebase Size | A- | ~42,652 LOC across 7 crates for ~20 major features + ML classifier + cloud sync + Rhai scripting + native drag-out + instrument builder. | | |
| 29 | 29 | ||
| 30 | 30 | ## Module Heatmap | |
| 31 | 31 | ||
| 32 | - | | Module | Code | Arch | Test | Security | Perf | Docs | Frontend | | |
| 33 | - | |--------|:----:|:----:|:----:|:--------:|:----:|:----:|:--------:| | |
| 34 | - | | audiofiles-core | A | A | A | A | A- | A | n/a | | |
| 35 | - | | audiofiles-browser | A | A | A- | A- | A- | A | A | | |
| 36 | - | | audiofiles-app | A | A- | A- | A- | n/a | A | n/a | | |
| 37 | - | | audiofiles-sync | A | A | A- | A | n/a | A | n/a | | |
| 38 | - | | audiofiles-rhai | A | A | A- | A | n/a | A | n/a | | |
| 32 | + | | Module | Code | Arch | Test | Security | Perf | Docs | Types | Conc | Size | | |
| 33 | + | |--------|:----:|:----:|:----:|:--------:|:----:|:----:|:-----:|:----:|:----:| | |
| 34 | + | | core/db.rs (913L) | A- | A | A | A | A | A | A | A- | A | | |
| 35 | + | | core/store.rs (977L) | A | A- | B+ | A | A | A | A | A | B+ | | |
| 36 | + | | core/vfs.rs (1128L) | A | A | A- | A | A | A | A | A | B+ | | |
| 37 | + | | core/export/ (1058L) | A- | A | B | A | A- | A | A | A | B+ | | |
| 38 | + | | core/analysis/classify.rs (1246L) | A- | A | B+ | A | A | B | A | A | B | | |
| 39 | + | | core/search.rs (668L) | A | A | A- | A | A | A | B+ | A | A | | |
| 40 | + | | core/fingerprint.rs (642L) | A | A | B+ | A | A | A | A | A | A | | |
| 41 | + | | core/similarity.rs (648L) | A | A | B | A | B | A | B | A | A | | |
| 42 | + | | core/vp_tree.rs (521L) | A | A | A- | A | A | A | A | A | A | | |
| 43 | + | | core/tags.rs (397L) | A | A | A | A | A | A | A | A | A | | |
| 44 | + | | core/collections.rs (327L) | A | A | B | A | A | A | A | A | A | | |
| 45 | + | | core/edit/ (1393L) | A | A | B | A | A | A | A | A | B | | |
| 46 | + | | browser/backend/direct.rs (1231L) | B+ | A | B | A | A- | A | A | A | B | | |
| 47 | + | | browser/state/import_workflow.rs (1121L) | B | B+ | B | A | A | B+ | B | B+ | C | | |
| 48 | + | | browser/state/tests.rs (1653L) | B | A | A | A | A | A | A | A | B | | |
| 49 | + | | browser/ui/theme.rs (908L) | A | A | B+ | A | A | A | A | n/a | B | | |
| 50 | + | | browser/import.rs (688L) | B+ | A | B | A | A | A | A | B+ | B | | |
| 51 | + | | browser/instrument.rs (600L) | A | A | C | A | A | A | A | B | B | | |
| 52 | + | | browser/preview.rs (596L) | A | A | B | A | A | A | A | B+ | B | | |
| 53 | + | | browser/ui/overlays.rs (617L) | B+ | A | B | A | A | A | A | n/a | B | | |
| 54 | + | | browser/ui/file_list.rs (579L) | B+ | A | B | A | A | B | A | n/a | B | | |
| 55 | + | | browser/state/ui.rs (513L) | B | A | C | A | A | B | B | A | B | | |
| 56 | + | | browser/state/bulk_ops.rs (500L) | B+ | B | B | A | A | B | B | A | B | | |
| 57 | + | | app/main.rs (1296L) | B- | C+ | B | B- | n/a | B | B+ | B | D | | |
| 58 | + | | sync/service/state.rs (996L) | B+ | A- | B | B+ | n/a | A | A- | A | A | | |
| 59 | + | | sync/download+upload+resolve | B+ | A | C | B+ | n/a | A | A | A | A | | |
| 60 | + | | rhai/lib.rs (530L) | A | A | A | A | n/a | A | A | A | A | | |
| 39 | 61 | ||
| 40 | 62 | ### Cold Spots | |
| 41 | 63 | ||
| 42 | - | All previous cold spots resolved. Two LOW findings remain: | |
| 64 | + | | # | Module | Grade | Issue | Severity | | |
| 65 | + | |---|--------|-------|-------|----------| | |
| 66 | + | | 1 | **app/main.rs** | D (Size) | 1296 lines in "thin shell" entry point. Contains state, UI rendering, vault management, license lifecycle, sync, MIDI, tray — all in one struct. | MEDIUM | | |
| 67 | + | | 2 | **browser/state/import_workflow.rs** | C (Size) | 1121 lines. import_directory_recursive duplicated from import.rs (flagged in code comments). | LOW | | |
| 68 | + | | 3 | **browser/instrument.rs** | C (Test) | No tests for MIDI voice allocation, envelope state, pitch interpolation. | LOW | | |
| 69 | + | | 4 | **browser/state/ui.rs** | C (Test) | No dedicated tests for UI state mutations. | LOW | | |
| 70 | + | | 5 | **sync/download+upload+resolve** | C (Test) | Three sync submodules with zero unit tests. | LOW | | |
| 71 | + | | 6 | **core/analysis/worker.rs** | B- (Conc) | Relaxed atomic ordering on cancel flag (lines 84, 92, 139, 147, 152, 191). Benign on ARM but imprecise. CHRONIC (3 audits). | LOW | | |
| 43 | 72 | ||
| 44 | - | 1. **sidebar.rs unwraps (LOW)** -- Lines 133, 135: `.unwrap()` on `collection_rename_target` inside an `if let Some()` guard. Safe (guard ensures value exists) but stylistically redundant. UNFIXED. | |
| 45 | - | 2. **updater.rs URL trust (LOW)** -- OTA updater stores `update.url` from server response without validation and passes it to `open::that()`. Risk limited to MNW server compromise. UNFIXED. Acceptable for alpha. | |
| 46 | - | 3. **Relaxed atomic ordering in cancel flag (LOW)** -- worker.rs:84,147 uses `Ordering::Relaxed` for the cancel flag atomic. On x86 this is fine (strong memory model), but on ARM it could theoretically allow a stale read. `Ordering::Acquire`/`Release` would be more correct. | |
| 47 | - | 4. **Export CTE duplication (LOW)** -- Minor code duplication in export CTEs. Not a correctness issue. | |
| 73 | + | ## Previous Action Item Status | |
| 48 | 74 | ||
| 49 | - | ## Mandatory Surprise | |
| 75 | + | | Item | Status | | |
| 76 | + | |------|--------| | |
| 77 | + | | sidebar.rs unwraps (LOW) | **FIXED** — no unwraps remain | | |
| 78 | + | | updater.rs URL trust (LOW) | **UNFIXED** — main.rs:1051 still calls open::that with server URL | | |
| 79 | + | | Relaxed atomic ordering in analysis/worker.rs (LOW) | **UNFIXED — CHRONIC** (3+ audits). edit/worker.rs was fixed; analysis/worker.rs was not. | | |
| 80 | + | | Export CTE duplication (LOW) | **UNFIXED** — minor, no correctness impact | | |
| 50 | 81 | ||
| 51 | - | ### Finding (Run 15): Relaxed Ordering in atomic cancel flag | |
| 82 | + | ## Mandatory Surprise | |
| 52 | 83 | ||
| 53 | - | In `worker.rs:84` and `worker.rs:147`, the worker cancel flag uses `Ordering::Relaxed` for both store and load operations. On x86_64, this is equivalent to `Acquire`/`Release` due to the strong memory model, so there is no practical bug. On ARM (including Apple Silicon), `Relaxed` provides no ordering guarantees -- a worker could theoretically continue processing one more item after cancellation is signaled. | |
| 84 | + | ### Finding (Run 20): Custom percent-decoding in sync auth (audiofiles-sync) | |
| 54 | 85 | ||
| 55 | - | The window is tiny (one iteration of the work loop), and the consequence is benign (one extra file processed before stopping). But the intent is clearly "stop as soon as possible," and `Acquire`/`Release` would express that intent correctly on all architectures. | |
| 86 | + | The OAuth callback handler at `crates/audiofiles-sync/src/auth.rs` implements a hand-rolled `percent_decode()` function that decodes arbitrary bytes from URL query strings. Invalid UTF-8 sequences (e.g., `%FF%FE`) are silently converted to U+FFFD replacement characters via `String::from_utf8_lossy()`. This could theoretically cause state parameter comparison mismatches if a MITM crafts invalid UTF-8 in the OAuth callback URL. | |
| 56 | 87 | ||
| 57 | - | **Verdict:** Technically imprecise but practically harmless. The fix is a one-line change (`Relaxed` -> `Release` on store, `Relaxed` -> `Acquire` on load). | |
| 88 | + | **Risk:** Low (requires active MITM on localhost callback). **Fix:** Use `percent_encoding::percent_decode_str()` from the `percent-encoding` crate (already a transitive dep) or reject non-UTF-8 decoded output. | |
| 58 | 89 | ||
| 59 | - | ### Previous finding (Run 13): Multi-model classifier generalized prediction | |
| 90 | + | ### Previous finding (Run 15): Relaxed ordering in analysis cancel flag | |
| 60 | 91 | ||
| 61 | - | Clean extension point for Layer 2 classifiers. Adding a new classifier requires only training data, a training run, and a class array constant. Verdict: Clean design. | |
| 92 | + | Still present. See cold spot #6 above. | |
| 62 | 93 | ||
| 63 | 94 | ## Strengths | |
| 64 | 95 | ||
| 65 | - | - **Content-addressed storage is elegant.** SHA-256 dedup, CASCADE deletes, recursive CTE queries. SampleStore + VFS separation enables unlimited virtual hierarchies over one flat blob store. | |
| 66 | - | - **Backend trait abstraction is well-designed.** 47-method trait surface that maps 1:1 to what BrowserState needs. No leaked abstractions. | |
| 67 | - | - **SyncKit integration is clean.** FK-safe ordering, column whitelists, changelog triggers, blob sync scaffolding. OAuth2 PKCE auth flow properly separated. | |
| 68 | - | - **Audio thread safety is correct.** The cpal audio callback uses `try_lock()` on `Mutex<PreviewPlayback>` and falls back to silence. No heap allocations on audio thread. | |
| 69 | - | - **Rhai scripting is well-sandboxed.** No filesystem access from scripts. Host API exposes only sample metadata. | |
| 70 | - | - **Core functions live in core.** Sync-only, no async, no UI dependencies. All computation-heavy code is in core. Browser delegates through Backend trait. | |
| 96 | + | 1. **Content-addressed storage is elegant.** SHA-256 dedup, CASCADE deletes, recursive CTE queries. SampleStore + VFS separation enables unlimited virtual hierarchies over one flat blob store. | |
| 97 | + | 2. **Test growth is strong.** 688 → 773 tests (+12.4%) since last audit. Core e2e pipeline coverage is thorough. | |
| 98 | + | 3. **Rhai plugin sandbox is exemplary.** No FS/net access, ops cap 100k, expression depth 64, script size 10k. All 10 modules tested. Best-in-class isolation. | |
| 99 | + | 4. **Audio thread safety is correct.** try_lock() on cpal callback with silence fallback. No heap allocations on audio thread. Worker threads for all heavy operations. | |
| 100 | + | 5. **Code fuzz findings all resolved.** Both rounds (2026-04-27 and 2026-05-03) of critical/serious findings are fully fixed per todo.md. | |
| 71 | 101 | ||
| 72 | 102 | ## Weaknesses | |
| 73 | 103 | ||
| 74 | - | - **drag_out/ has no automated tests** -- Platform FFI code tested manually only. Hard to unit test. | |
| 75 | - | - **17 unsafe blocks in drag_out/** -- All justified (platform FFI). Each has SAFETY comments. | |
| 76 | - | - **file_list.rs branch density** -- 689 lines with high branching (egui immediate-mode). Could benefit from extracting row rendering. | |
| 104 | + | 1. **app/main.rs is monolithic.** 1296 lines violates the CONTRIBUTING.md "thin shell" principle. Contains activation, vault setup, browser, sync, MIDI, tray, and updater logic in one struct. | |
| 105 | + | 2. **Sync submodule test gap.** download.rs, upload.rs, resolve.rs have zero unit tests. State.rs has tests but the actual sync operations are untested. | |
| 106 | + | 3. **Analysis worker Relaxed ordering is chronic.** 3+ audits unfixed. Benign but technically imprecise — one-line fix (Relaxed → Acquire/Release). | |
| 77 | 107 | ||
| 78 | 108 | ## Action Items | |
| 79 | 109 | ||
| 80 | - | No new action items filed -- all findings are LOW severity (acceptable for alpha). | |
| 110 | + | Filed in `docs/todo.md` under "Audit Run 20 (2026-05-04)". | |
| 81 | 111 | ||
| 82 | - | Previously resolved: | |
| 83 | - | - ~~CRITICAL: applying_remote crash recovery~~ -- RESOLVED | |
| 84 | - | - ~~Migrate browser eprintln! to tracing~~ -- RESOLVED | |
| 85 | - | - ~~Split import_screens.rs~~ -- RESOLVED | |
| 86 | - | - ~~Fix theme include_str! paths~~ -- RESOLVED | |
| 87 | - | - ~~Add E2E integration tests~~ -- RESOLVED | |
| 112 | + | - MEDIUM: Split app/main.rs into submodules (activation, vault, browser controller) | |
| 113 | + | - LOW: Fix Relaxed → Acquire/Release in analysis/worker.rs (CHRONIC) | |
| 114 | + | - LOW: Add unit tests for sync download/upload/resolve modules | |
| 115 | + | - LOW: Deduplicate import_directory_recursive (import_workflow.rs vs import.rs) | |
| 88 | 116 | ||
| 89 | 117 | ## Metrics Over Time | |
| 90 | 118 | ||
| 91 | - | | Metric | 6th (03-11) | 7th (03-13) | Adversarial (03-13) | 11th (03-18) | 12th (03-19) | 13th (03-19) | 14th (03-22) | ML (03-26) | Run 12 (03-28) | Run 13 (04-06) | Run 14 (04-15) | Run 15 (04-18) | | |
| 92 | - | |--------|:-----------:|:-----------:|:-------------------:|:------------:|:------------:|:------------:|:------------:|:----------:|:--------------:|:--------------:|:--------------:|:--------------:| | |
| 93 | - | | Overall | A- | A- | A- | A- | A | A | A | A | A | A | A | A | | |
| 94 | - | | LOC | 25.6K | 25.6K | 25.6K | ~25K | ~23K | ~23.5K | ~23.5K | ~24.5K | ~24.5K | ~25K | ~40.2K | ~40.2K | | |
| 95 | - | | Tests | 518 | 532 | 557 | 566 | 535 | 560 | 585 | 610 | 611 | 704 | 688 | 688 | | |
| 96 | - | | Crates | 7 + xtask | 7 + xtask | 7 + xtask | 7 + xtask | 5 | 5 | 5 | 5 + train | 5 + train | 5 + train | 5 + train | 5 + train | | |
| 97 | - | | Clippy | 2 (trivial) | 2 (trivial) | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | | |
| 98 | - | | Unwrap (prod) | ~1 | 7 (all init) | 7 (all init) | 2 (sidebar, guarded) | 2 (sidebar, guarded) | 2 (sidebar) | 2 (sidebar) | 2 (sidebar) | 2 (sidebar) | 2 (sidebar) | 2 (sidebar) | 2 (sidebar) | | |
| 99 | - | | Unsafe | 2 (test) | 2 (test) | 2 (test) | 2 (test) | 17 (FFI) | 17 (FFI) | 17 (FFI) | 17 (FFI) | 17 (FFI) | 17 (FFI) | 17 (FFI) | 17 (FFI) | | |
| 119 | + | | Metric | 6th (03-11) | 7th (03-13) | Adversarial (03-13) | 11th (03-18) | 12th (03-19) | 13th (03-19) | 14th (03-22) | ML (03-26) | Run 12 (03-28) | Run 13 (04-06) | Run 14 (04-15) | Run 15 (04-18) | Run 20 (05-04) | | |
| 120 | + | |--------|:-----------:|:-----------:|:-------------------:|:------------:|:------------:|:------------:|:------------:|:----------:|:--------------:|:--------------:|:--------------:|:--------------:|:--------------:| | |
| 121 | + | | Overall | A- | A- | A- | A- | A | A | A | A | A | A | A | A | A | | |
| 122 | + | | LOC | 25.6K | 25.6K | 25.6K | ~25K | ~23K | ~23.5K | ~23.5K | ~24.5K | ~24.5K | ~25K | ~40.2K | ~40.2K | ~42.7K | | |
| 123 | + | | Tests | 518 | 532 | 557 | 566 | 535 | 560 | 585 | 610 | 611 | 704 | 688 | 688 | 773 | | |
| 124 | + | | Crates | 7 + xtask | 7 + xtask | 7 + xtask | 7 + xtask | 5 | 5 | 5 | 5 + train | 5 + train | 5 + train | 5 + train | 5 + train | 5 + train | | |
| 125 | + | | Clippy | 2 (trivial) | 2 (trivial) | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 4 (trivial) | | |
| 126 | + | | Unwrap (prod) | ~1 | 7 (all init) | 7 (all init) | 2 (sidebar, guarded) | 2 (sidebar, guarded) | 2 (sidebar) | 2 (sidebar) | 2 (sidebar) | 2 (sidebar) | 2 (sidebar) | 2 (sidebar) | 2 (sidebar) | 0 | | |
| 127 | + | | Unsafe | 2 (test) | 2 (test) | 2 (test) | 2 (test) | 17 (FFI) | 17 (FFI) | 17 (FFI) | 17 (FFI) | 17 (FFI) | 17 (FFI) | 17 (FFI) | 17 (FFI) | 17 (FFI) | | |
| 100 | 128 | ||
| 101 | 129 | --- | |
| 102 | 130 | ||
| @@ -106,7 +134,7 @@ See [audit_history.md](./audit_history.md) for full chronological audit log. | |||
| 106 | 134 | ||
| 107 | 135 | ## Documentation Review | |
| 108 | 136 | ||
| 109 | - | **Last reviewed:** 2026-03-04 (first doc audit) | |
| 137 | + | **Last reviewed:** 2026-05-04 | |
| 110 | 138 | ||
| 111 | 139 | ### Overall Grade: A | |
| 112 | 140 | ||
| @@ -116,11 +144,11 @@ Minimal but appropriate doc set for the project's current stage. No inaccuracies | |||
| 116 | 144 | ||
| 117 | 145 | | Document | Status | Last Verified | Notes | | |
| 118 | 146 | |----------|:------:|:-------------:|-------| | |
| 119 | - | | docs/todo.md | Current | 2026-04-18 | Active task list | | |
| 147 | + | | docs/todo.md | Current | 2026-05-04 | Active task list | | |
| 120 | 148 | | docs/architecture.md | Current | 2026-03-28 | System design + 5-crate workspace | | |
| 121 | 149 | | docs/competition.md | Current | 2026-03-04 | Competitive analysis | | |
| 122 | 150 | | docs/human_testing.md | Current | 2026-03-04 | Manual QA checklist | | |
| 123 | - | | docs/audit_review.md | Current | 2026-04-18 | Code audit history | | |
| 151 | + | | docs/audit_review.md | Current | 2026-05-04 | Code audit history | | |
| 124 | 152 | ||
| 125 | 153 | ### Action Items | |
| 126 | 154 |