Skip to main content

max / audiofiles

Audit fixes, split main.rs, atomic ordering, sync tests, UI polish Split app/main.rs into activation.rs + vault_setup.rs. Fixed Relaxed to Acquire/Release atomics. Added 7 sync tests. Theme spacing improvements. Classifier and import workflow refinements. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-07 03:26 UTC
Commit: ddaa48bc1cdde6261c7cb019c6193f6d4e515805
Parent: ab85102
17 files changed, +884 insertions, -602 deletions
M Cargo.lock +6 -26
@@ -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",
M Cargo.toml +2 -2
@@ -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(&reg, &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(&reg, &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(&reg, &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(&reg, 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(&reg, 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(&reg) {
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