1pub mod app_i18n;
21pub mod audio_engine;
22pub mod audio_extensions;
23pub mod audio_scanner;
24pub mod bpm;
25pub mod bulk_stat;
26pub mod content_hash;
27pub mod daw_scanner;
28pub mod db;
29pub mod file_watcher;
30pub mod history;
31pub mod key_detect;
32pub mod kvr;
33pub mod lufs;
34pub mod midi;
35pub mod midi_scanner;
36pub mod native_menu;
37mod open_with_app;
38pub mod pdf_meta;
39pub mod path_norm;
40pub mod pdf_scanner;
41pub mod preset_scanner;
42pub mod scanner;
43pub mod scanner_skip_dirs;
44pub mod similarity;
45pub mod tray_menu;
46mod tray_popover_escape_macos;
47pub mod unified_walker;
48pub mod xref;
49
50pub fn format_size(bytes: u64) -> String {
52 if bytes == 0 {
53 return "0 B".into();
54 }
55 let units = ["B", "KB", "MB", "GB", "TB"];
56 let i = (bytes as f64).log(1024.0).floor() as usize;
57 let i = i.min(units.len() - 1);
58 format!("{:.1} {}", bytes as f64 / 1024f64.powi(i as i32), units[i])
59}
60
61use history::{AudioSample, DawProject, KvrCacheUpdateEntry, PdfFile, PresetFile};
62use path_norm::normalize_path_for_db;
63use scanner::PluginInfo;
64use serde::{Deserialize, Serialize};
65use std::collections::{HashMap, HashSet};
66use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
67use std::sync::Arc;
68use tauri::{AppHandle, Emitter, Manager};
69
70pub const DIRECTORY_SCAN_INCREMENTAL_DOMAIN: &str = "unified";
72
73static LOG_VERBOSITY_LEVEL: AtomicU8 = AtomicU8::new(1);
75
76static PDF_META_EXTRACT_ABORT: AtomicBool = AtomicBool::new(false);
78
79#[inline]
80pub fn log_verbosity_level() -> u8 {
81 LOG_VERBOSITY_LEVEL.load(Ordering::Relaxed)
82}
83
84fn refresh_log_verbosity_from_prefs() {
85 let level = history::get_preference("logVerbosity")
86 .and_then(|v| v.as_str().map(std::string::ToString::to_string))
87 .unwrap_or_else(|| "normal".to_string());
88 let n = match level.as_str() {
89 "quiet" => 0u8,
90 "verbose" => 2u8,
91 _ => 1u8,
92 };
93 LOG_VERBOSITY_LEVEL.store(n, Ordering::Relaxed);
94}
95
96fn normalize_fingerprint_cache_map(
99 cache: HashMap<String, similarity::AudioFingerprint>,
100) -> HashMap<String, similarity::AudioFingerprint> {
101 cache
102 .into_iter()
103 .map(|(k, mut v)| {
104 let nk = normalize_path_for_db(&k);
105 v.path = nk.clone();
106 (nk, v)
107 })
108 .collect()
109}
110
111fn should_suppress_app_log_line(msg: &str) -> bool {
112 if LOG_VERBOSITY_LEVEL.load(Ordering::Relaxed) != 0 {
113 return false;
114 }
115 const PREFIXES: &[&str] = &[];
117 if PREFIXES.is_empty() {
118 return false;
119 }
120 let m = msg.trim_start();
121 PREFIXES.iter().any(|p| m.starts_with(p))
122}
123
124fn incremental_directory_scan_enabled() -> bool {
125 let prefs = history::load_preferences();
126 prefs
127 .get("incrementalDirectoryScan")
128 .and_then(|v| v.as_str())
129 .map(|s| s != "off")
130 .unwrap_or(true)
131}
132
133fn load_incremental_dir_state_for_walk() -> Option<Arc<unified_walker::IncrementalDirState>> {
134 if !incremental_directory_scan_enabled() {
135 return None;
136 }
137 match db::global().unified_scan_incremental_snapshot_is_trusted() {
138 Ok(false) => {
139 crate::write_app_log(
140 "SCAN INCREMENTAL — last unified scan did not finish successfully; full walk"
141 .into(),
142 );
143 None
144 }
145 Err(e) => {
146 crate::write_app_log(format!(
147 "SCAN INCREMENTAL — could not read unified scan outcome ({e}); full walk",
148 ));
149 None
150 }
151 Ok(true) => match db::global().load_directory_scan_snapshot(DIRECTORY_SCAN_INCREMENTAL_DOMAIN)
152 {
153 Ok(m) => {
154 let n = m.len();
155 crate::app_log_verbose(move || {
156 format!("SCAN VERBOSE — incremental snapshot loaded: {n} directory keys")
157 });
158 Some(Arc::new(unified_walker::IncrementalDirState::new(m)))
159 }
160 Err(e) => {
161 crate::write_app_log(format!(
162 "SCAN INCREMENTAL — load directory snapshot failed ({e}); full walk",
163 ));
164 None
165 }
166 },
167 }
168}
169
170fn persist_incremental_dir_state_after_walk(
171 inc: Option<&Arc<unified_walker::IncrementalDirState>>,
172 scan_id_for_audit: &str,
173) {
174 let Some(inc) = inc else {
175 return;
176 };
177 let pending = inc.take_pending();
178 if pending.is_empty() {
179 return;
180 }
181 let _ = db::global().upsert_directory_scan_batch(
182 DIRECTORY_SCAN_INCREMENTAL_DOMAIN,
183 &pending,
184 Some(scan_id_for_audit),
185 );
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct ExportPayload {
192 pub version: String,
193 pub exported_at: String,
194 pub plugins: Vec<ExportPlugin>,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct ExportPlugin {
199 pub name: String,
200 #[serde(rename = "type")]
201 pub plugin_type: String,
202 pub version: String,
203 pub manufacturer: String,
204 #[serde(skip_serializing_if = "Option::is_none")]
205 pub manufacturer_url: Option<String>,
206 pub path: String,
207 pub size: String,
208 #[serde(rename = "sizeBytes", default)]
209 pub size_bytes: u64,
210 pub modified: String,
211 #[serde(default)]
212 pub architectures: Vec<String>,
213}
214
215struct ScanState {
217 scanning: AtomicBool,
218 stop_scan: AtomicBool,
219}
220
221struct UpdateState {
222 checking: AtomicBool,
223 stop_updates: AtomicBool,
224}
225
226struct AudioScanState {
227 scanning: AtomicBool,
228 stop_scan: AtomicBool,
229}
230
231struct DawScanState {
232 scanning: AtomicBool,
233 stop_scan: AtomicBool,
234}
235
236struct PresetScanState {
237 scanning: AtomicBool,
238 stop_scan: AtomicBool,
239}
240
241struct MidiScanState {
242 scanning: AtomicBool,
243 stop_scan: AtomicBool,
244}
245
246struct PdfScanState {
247 scanning: AtomicBool,
248 stop_scan: AtomicBool,
249}
250
251struct WalkerStatus {
253 plugin_dirs: Arc<std::sync::Mutex<Vec<String>>>,
254 audio_dirs: Arc<std::sync::Mutex<Vec<String>>>,
255 daw_dirs: Arc<std::sync::Mutex<Vec<String>>>,
256 preset_dirs: Arc<std::sync::Mutex<Vec<String>>>,
257 midi_dirs: Arc<std::sync::Mutex<Vec<String>>>,
258 pdf_dirs: Arc<std::sync::Mutex<Vec<String>>>,
259 unified_scanning: AtomicBool,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
268struct UpdatedPlugin {
269 #[serde(flatten)]
270 plugin: PluginInfo,
271 #[serde(rename = "currentVersion")]
272 current_version: String,
273 #[serde(rename = "latestVersion")]
274 latest_version: String,
275 #[serde(rename = "hasUpdate")]
276 has_update: bool,
277 #[serde(rename = "updateUrl")]
278 update_url: Option<String>,
279 #[serde(rename = "kvrUrl")]
280 kvr_url: Option<String>,
281 #[serde(rename = "hasPlatformDownload")]
282 has_platform_download: bool,
283 source: String,
284}
285
286#[inline]
289async fn blocking<T, F>(f: F) -> Result<T, String>
290where
291 T: Send + 'static,
292 F: FnOnce() -> T + Send + 'static,
293{
294 tokio::task::spawn_blocking(f)
295 .await
296 .map_err(|e| format!("spawn_blocking: {e}"))
297}
298
299#[inline]
300async fn blocking_res<T, F>(f: F) -> Result<T, String>
301where
302 T: Send + 'static,
303 F: FnOnce() -> Result<T, String> + Send + 'static,
304{
305 tokio::task::spawn_blocking(f)
306 .await
307 .map_err(|e| format!("spawn_blocking: {e}"))?
308}
309
310#[derive(Debug, Clone, Serialize)]
314#[serde(rename_all = "camelCase")]
315pub struct BuildInfo {
316 pub version: String,
317 pub git_sha_short: String,
318 pub git_sha_full: String,
319 pub git_commit_date: String,
320}
321
322#[tauri::command]
323fn get_build_info(app: AppHandle) -> BuildInfo {
324 BuildInfo {
325 version: app.package_info().version.to_string(),
326 git_sha_short: env!("AUDIO_HAXOR_GIT_SHA_SHORT").to_string(),
327 git_sha_full: env!("AUDIO_HAXOR_GIT_SHA_FULL").to_string(),
328 git_commit_date: env!("AUDIO_HAXOR_GIT_COMMIT_DATE").to_string(),
329 }
330}
331
332#[tauri::command]
333fn get_version(app: AppHandle) -> String {
334 app.package_info().version.to_string()
335}
336
337#[tauri::command]
338fn get_walker_status(app: AppHandle) -> serde_json::Value {
339 let ws = app.state::<WalkerStatus>();
340 let plugin = ws
341 .plugin_dirs
342 .lock()
343 .unwrap_or_else(|e| e.into_inner())
344 .clone();
345 let audio = ws
346 .audio_dirs
347 .lock()
348 .unwrap_or_else(|e| e.into_inner())
349 .clone();
350 let daw = ws
351 .daw_dirs
352 .lock()
353 .unwrap_or_else(|e| e.into_inner())
354 .clone();
355 let preset = ws
356 .preset_dirs
357 .lock()
358 .unwrap_or_else(|e| e.into_inner())
359 .clone();
360 let midi = ws
361 .midi_dirs
362 .lock()
363 .unwrap_or_else(|e| e.into_inner())
364 .clone();
365 let pdf = ws
366 .pdf_dirs
367 .lock()
368 .unwrap_or_else(|e| e.into_inner())
369 .clone();
370 let pool_threads = num_cpus::get().max(4);
371 let plugin_scanning = app.state::<ScanState>().scanning.load(Ordering::Relaxed);
372 let audio_scanning = app
373 .state::<AudioScanState>()
374 .scanning
375 .load(Ordering::Relaxed);
376 let daw_scanning = app.state::<DawScanState>().scanning.load(Ordering::Relaxed);
377 let preset_scanning = app
378 .state::<PresetScanState>()
379 .scanning
380 .load(Ordering::Relaxed);
381 let pdf_scanning = app.state::<PdfScanState>().scanning.load(Ordering::Relaxed);
382 let midi_scanning = app
383 .state::<MidiScanState>()
384 .scanning
385 .load(Ordering::Relaxed);
386 let unified_scanning = ws.unified_scanning.load(Ordering::Relaxed);
387 serde_json::json!({
388 "plugin": plugin,
389 "audio": audio,
390 "daw": daw,
391 "preset": preset,
392 "midi": midi,
393 "pdf": pdf,
394 "poolThreads": pool_threads,
395 "pluginScanning": plugin_scanning,
396 "audioScanning": audio_scanning,
397 "dawScanning": daw_scanning,
398 "presetScanning": preset_scanning,
399 "midiScanning": midi_scanning,
400 "pdfScanning": pdf_scanning,
401 "unifiedScanning": unified_scanning,
402 })
403}
404
405#[tauri::command]
406async fn scan_plugins(
407 app: AppHandle,
408 custom_roots: Option<Vec<String>>,
409 exclude_paths: Option<Vec<String>>,
410) -> Result<serde_json::Value, String> {
411 let state = app.state::<ScanState>();
412
413 if state.scanning.swap(true, Ordering::SeqCst) {
414 return Err("Scan already in progress".into());
415 }
416 state.stop_scan.store(false, Ordering::SeqCst);
417 let scan_start = Instant::now();
418 append_log(format!(
419 "SCAN START — plugins | roots: {:?}",
420 custom_roots.as_deref().unwrap_or(&[])
421 ));
422
423 let app_handle = app.clone();
424 let result = tokio::task::spawn_blocking(move || {
425 let scan_state = app_handle.state::<ScanState>();
426 let directories = if let Some(ref extra) = custom_roots {
427 let custom: Vec<String> = extra
428 .iter()
429 .filter(|r| std::path::Path::new(r).exists())
430 .cloned()
431 .collect();
432 if custom.is_empty() {
433 scanner::get_vst_directories()
434 } else {
435 custom
436 }
437 } else {
438 scanner::get_vst_directories()
439 };
440 let plugin_scan_id = history::gen_id();
441 let now_iso = history::now_iso();
442 let db = db::global();
443 let plugin_paths = scanner::discover_plugins(&directories, None);
447 let total = plugin_paths.len();
448
449 let _ = db.plugin_scan_parent_create(&plugin_scan_id, &now_iso, &directories);
450
451 let _ = app_handle.emit(
452 "scan-progress",
453 serde_json::json!({
454 "phase": "start",
455 "total": total,
456 "processed": 0
457 }),
458 );
459
460 let exclude_set: HashSet<String> = exclude_paths.unwrap_or_default().into_iter().collect();
462 let mut seen = HashSet::new();
463 let unique_paths: Vec<_> = plugin_paths
464 .into_iter()
465 .filter(|p| {
466 let s = p.to_string_lossy().to_string();
467 !exclude_set.contains(&s) && seen.insert(s)
468 })
469 .collect();
470
471 use rayon::prelude::*;
473 let prefs = history::load_preferences();
474 let batch_size = prefs
475 .get("batchSize")
476 .and_then(|v| {
477 v.as_str()
478 .and_then(|s| s.parse::<usize>().ok())
479 .or(v.as_u64().map(|n| n as usize))
480 })
481 .unwrap_or(100)
482 .clamp(10, 200);
483 let chan_buf = prefs
484 .get("channelBuffer")
485 .and_then(|v| {
486 v.as_str()
487 .and_then(|s| s.parse::<usize>().ok())
488 .or(v.as_u64().map(|n| n as usize))
489 })
490 .unwrap_or(256)
491 .clamp(64, 512);
492 let (tx, rx) = std::sync::mpsc::sync_channel::<scanner::PluginInfo>(chan_buf);
493 let stop_flag = std::sync::Arc::new(AtomicBool::new(false));
495 let stop_flag2 = stop_flag.clone();
496 let plugin_dirs = Arc::clone(&app_handle.state::<WalkerStatus>().plugin_dirs);
497
498 let pool = rayon::ThreadPoolBuilder::new()
500 .num_threads(num_cpus::get().max(4))
501 .build()
502 .unwrap_or_else(|e| {
503 let msg = format!("Thread pool creation failed ({e}), retrying with 2 threads");
504 eprintln!("{msg}");
505 append_log(msg);
506 rayon::ThreadPoolBuilder::new()
507 .num_threads(2)
508 .build()
509 .expect("fallback 2-thread pool")
510 });
511 std::thread::spawn(move || {
512 pool.install(|| {
513 unique_paths.par_iter().for_each(|p| {
514 if stop_flag2.load(Ordering::Relaxed) {
515 return;
516 }
517 {
519 let mut ad = plugin_dirs.lock().unwrap_or_else(|e| e.into_inner());
520 ad.push(p.to_string_lossy().to_string());
521 if ad.len() > 30 {
522 let excess = ad.len() - 30;
523 ad.drain(..excess);
524 }
525 }
526 if let Some(info) = scanner::get_plugin_info(p) {
527 if stop_flag2.load(Ordering::Relaxed) {
528 return;
529 }
530 let _ = tx.send(info);
531 }
532 });
533 });
534 });
535
536 let mut all_plugins = Vec::new();
537 let mut batch = Vec::new();
538 let mut processed = 0usize;
539
540 loop {
542 if scan_state.stop_scan.load(Ordering::Relaxed) {
543 stop_flag.store(true, Ordering::Relaxed);
544 while rx.try_recv().is_ok() {}
546 break;
547 }
548 let info = match rx.recv_timeout(std::time::Duration::from_millis(10)) {
549 Ok(info) => info,
550 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
551 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
552 };
553 batch.push(info);
554 processed += 1;
555 if batch.len() >= batch_size || processed == total {
556 let _ = db.insert_plugin_batch(&plugin_scan_id, &batch);
557 all_plugins.extend(batch.clone());
558 let _ = app_handle.emit(
559 "scan-progress",
560 serde_json::json!({
561 "phase": "scanning",
562 "plugins": batch,
563 "processed": processed,
564 "total": total
565 }),
566 );
567 batch.clear();
568 }
569 }
570 if !batch.is_empty() {
571 let _ = db.insert_plugin_batch(&plugin_scan_id, &batch);
572 all_plugins.extend(batch.clone());
573 let _ = app_handle.emit(
574 "scan-progress",
575 serde_json::json!({
576 "phase": "scanning",
577 "plugins": batch,
578 "processed": processed,
579 "total": total
580 }),
581 );
582 }
583
584 let was_stopped = scan_state.stop_scan.load(Ordering::Relaxed);
585 all_plugins.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
586 let roots: Vec<String> = directories.clone();
587 let _ = db.plugin_scan_parent_finalize(
588 &plugin_scan_id,
589 all_plugins.len(),
590 &directories,
591 &roots,
592 );
593 let _ = db.set_plugin_scan_complete(&plugin_scan_id, !was_stopped);
594 db.checkpoint();
595
596 serde_json::json!({
597 "plugins": all_plugins,
598 "directories": directories,
599 "snapshotId": plugin_scan_id,
600 "stopped": was_stopped
601 })
602 })
603 .await;
604
605 state.scanning.store(false, Ordering::SeqCst);
606 {
607 let ws = app.state::<WalkerStatus>();
608 let mut ad = ws.plugin_dirs.lock().unwrap_or_else(|e| e.into_inner());
609 ad.clear();
610 }
611 let elapsed = scan_start.elapsed();
612 match &result {
613 Ok(v) => append_log(format!(
614 "SCAN END — plugins | {}s | {} found",
615 elapsed.as_secs(),
616 v.get("plugins")
617 .and_then(|p| p.as_array())
618 .map(|a| a.len())
619 .unwrap_or(0)
620 )),
621 Err(e) => append_log(format!(
622 "SCAN ERROR — plugins | {}s | {}",
623 elapsed.as_secs(),
624 e
625 )),
626 }
627 result.map_err(|e| e.to_string())
628}
629
630#[tauri::command]
631async fn stop_scan(app: AppHandle) -> Result<(), String> {
632 append_log("SCAN STOP — plugins (user requested)".into());
633 let state = app.state::<ScanState>();
634 state.stop_scan.store(true, Ordering::SeqCst);
635 Ok(())
636}
637
638#[tauri::command]
639async fn check_updates(
640 app: AppHandle,
641 plugins: Vec<PluginInfo>,
642) -> Result<Vec<UpdatedPlugin>, String> {
643 let state = app.state::<UpdateState>();
644 if state.checking.swap(true, Ordering::SeqCst) {
645 return Err("Update check already in progress".into());
646 }
647 state.stop_updates.store(false, Ordering::SeqCst);
648
649 let kvr_cache = history::load_kvr_cache();
651
652 let total = plugins.len();
653 #[cfg(not(test))]
654 append_log(format!("UPDATE CHECK — {} plugins", total));
655 let _ = app.emit(
656 "update-progress",
657 serde_json::json!({
658 "phase": "start",
659 "total": total,
660 "processed": 0
661 }),
662 );
663
664 let mut search_groups: std::collections::HashMap<String, (PluginInfo, Vec<PluginInfo>)> =
666 std::collections::HashMap::new();
667 for plugin in &plugins {
668 let key = format!("{}|||{}", plugin.manufacturer, plugin.name).to_lowercase();
669 search_groups
670 .entry(key)
671 .or_insert_with(|| (plugin.clone(), Vec::new()))
672 .1
673 .push(plugin.clone());
674 }
675
676 let groups: Vec<(PluginInfo, Vec<PluginInfo>)> = search_groups.into_values().collect();
677 let mut results: std::collections::HashMap<String, UpdatedPlugin> =
678 std::collections::HashMap::new();
679 let mut processed = 0usize;
680
681 for (representative, siblings) in &groups {
682 if state.stop_updates.load(Ordering::SeqCst) {
683 break;
684 }
685
686 let cache_key =
687 format!("{}|||{}", representative.manufacturer, representative.name).to_lowercase();
688
689 let update_result = if let Some(cached) = kvr_cache.get(&cache_key) {
691 Some(kvr::UpdateResult {
692 latest_version: cached
693 .latest_version
694 .clone()
695 .unwrap_or_else(|| representative.version.clone()),
696 has_update: cached.has_update,
697 update_url: cached.update_url.clone(),
698 kvr_url: cached.kvr_url.clone(),
699 has_platform_download: cached.update_url.is_some(),
700 source: cached.source.clone(),
701 })
702 } else {
703 kvr::find_latest_version(
704 &representative.name,
705 &representative.manufacturer,
706 &representative.version,
707 )
708 .await
709 };
710
711 let mut batch_plugins = Vec::new();
712 for sibling in siblings {
713 let current_version = sibling.version.clone();
714 let updated = if let Some(ref result) = update_result {
715 let has_update = kvr::compare_versions(&result.latest_version, ¤t_version)
716 == std::cmp::Ordering::Greater
717 && current_version != "Unknown";
718 UpdatedPlugin {
719 plugin: sibling.clone(),
720 current_version,
721 latest_version: result.latest_version.clone(),
722 has_update,
723 update_url: result.update_url.clone(),
724 kvr_url: result.kvr_url.clone(),
725 has_platform_download: result.has_platform_download,
726 source: result.source.clone(),
727 }
728 } else {
729 UpdatedPlugin {
730 plugin: sibling.clone(),
731 current_version: current_version.clone(),
732 latest_version: current_version,
733 has_update: false,
734 update_url: None,
735 kvr_url: None,
736 has_platform_download: false,
737 source: "not-found".into(),
738 }
739 };
740
741 results.insert(sibling.path.clone(), updated.clone());
742 batch_plugins.push(updated);
743 processed += 1;
744 }
745
746 let _ = app.emit(
747 "update-progress",
748 serde_json::json!({
749 "phase": "checking",
750 "plugins": batch_plugins,
751 "processed": processed,
752 "total": total
753 }),
754 );
755
756 if !kvr_cache.contains_key(&cache_key) {
758 crate::app_log_verbose(|| {
759 format!(
760 "UPDATE VERBOSE — KVR network fetch | {} | {}",
761 representative.name, representative.manufacturer
762 )
763 });
764 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
765 }
766 }
767
768 state.checking.store(false, Ordering::SeqCst);
769
770 let final_plugins: Vec<UpdatedPlugin> = plugins
771 .iter()
772 .map(|p| {
773 results.remove(&p.path).unwrap_or_else(|| UpdatedPlugin {
774 plugin: p.clone(),
775 current_version: p.version.clone(),
776 latest_version: p.version.clone(),
777 has_update: false,
778 update_url: None,
779 kvr_url: None,
780 has_platform_download: false,
781 source: "not-found".into(),
782 })
783 })
784 .collect();
785
786 Ok(final_plugins)
787}
788
789#[tauri::command]
790async fn stop_updates(app: AppHandle) -> Result<(), String> {
791 append_log("UPDATE STOP — user cancelled update check".into());
792 let state = app.state::<UpdateState>();
793 state.stop_updates.store(true, Ordering::SeqCst);
794 Ok(())
795}
796
797#[tauri::command]
798async fn resolve_kvr(direct_url: String, plugin_name: String) -> Result<kvr::KvrResult, String> {
799 Ok(kvr::resolve_kvr(&direct_url, &plugin_name).await)
800}
801
802#[tauri::command]
804async fn history_get_scans() -> Result<Vec<serde_json::Value>, String> {
805 blocking_res(|| db::global().get_plugin_scans()).await
806}
807
808#[tauri::command]
809async fn history_get_detail(id: String) -> Result<history::ScanSnapshot, String> {
810 blocking_res(move || db::global().get_plugin_scan_detail(&id)).await
811}
812
813#[tauri::command]
814async fn history_delete(id: String) -> Result<(), String> {
815 blocking_res(move || db::global().delete_plugin_scan(&id)).await
816}
817
818#[tauri::command]
819async fn history_clear() -> Result<(), String> {
820 #[cfg(not(test))]
821 append_log("HISTORY CLEAR — plugins (all scan history deleted)".into());
822 blocking_res(|| db::global().clear_plugin_history()).await
823}
824
825#[tauri::command]
826async fn history_diff(old_id: String, new_id: String) -> Option<history::ScanDiff> {
827 tokio::task::spawn_blocking(move || {
828 let old = db::global().get_plugin_scan_detail(&old_id).ok()?;
829 let new = db::global().get_plugin_scan_detail(&new_id).ok()?;
830 Some(history::compute_plugin_diff(&old, &new))
831 })
832 .await
833 .ok()
834 .flatten()
835}
836
837#[tauri::command]
838async fn history_latest() -> Result<Option<history::ScanSnapshot>, String> {
839 blocking_res(|| db::global().get_latest_plugin_scan()).await
840}
841
842#[tauri::command]
843async fn kvr_cache_get(
844) -> Result<std::collections::HashMap<String, history::KvrCacheEntry>, String> {
845 blocking_res(|| db::global().load_kvr_cache()).await
846}
847
848#[tauri::command]
849async fn kvr_cache_update(entries: Vec<KvrCacheUpdateEntry>) -> Result<(), String> {
850 blocking_res(move || db::global().update_kvr_cache(&entries)).await
851}
852
853#[tauri::command]
855async fn scan_audio_samples(
856 app: AppHandle,
857 custom_roots: Option<Vec<String>>,
858 exclude_paths: Option<Vec<String>>,
859) -> Result<serde_json::Value, String> {
860 let state = app.state::<AudioScanState>();
861 let scan_start = Instant::now();
862 append_log(format!(
863 "SCAN START — audio | roots: {:?}",
864 custom_roots.as_deref().unwrap_or(&[])
865 ));
866 if state.scanning.swap(true, Ordering::SeqCst) {
867 return Err("Audio scan already in progress".into());
868 }
869 state.stop_scan.store(false, Ordering::SeqCst);
870
871 let _ = app.emit(
872 "audio-scan-progress",
873 serde_json::json!({
874 "phase": "status",
875 "message": "Walking filesystem directories parallelized for audio files..."
876 }),
877 );
878
879 let app_handle = app.clone();
880 let result = tokio::task::spawn_blocking(move || {
881 let audio_state = app_handle.state::<AudioScanState>();
882 let roots = if let Some(ref extra) = custom_roots {
883 let custom: Vec<std::path::PathBuf> = extra
884 .iter()
885 .map(std::path::PathBuf::from)
886 .filter(|p| p.exists())
887 .collect();
888 if custom.is_empty() {
889 audio_scanner::get_audio_roots()
890 } else {
891 custom
892 }
893 } else {
894 audio_scanner::get_audio_roots()
895 };
896 let root_strs: Vec<String> = roots
897 .iter()
898 .map(|r| r.to_string_lossy().to_string())
899 .collect();
900 let now_iso = history::now_iso();
901 let audio_scan_id = history::gen_id();
902 let db = db::global();
903 let _ = db.audio_scan_parent_create(&audio_scan_id, &now_iso, &root_strs);
904
905 let mut audio_count: u64 = 0;
906 let mut audio_bytes: u64 = 0;
907 let mut audio_format_counts: HashMap<String, usize> = HashMap::new();
908 let exclude_set = exclude_paths.map(|v| v.into_iter().collect::<HashSet<String>>());
909 let incremental_state = load_incremental_dir_state_for_walk();
910
911 audio_scanner::walk_for_audio(
912 &roots,
913 &mut |batch, _found| {
914 for s in batch.iter() {
915 audio_bytes += s.size;
916 *audio_format_counts.entry(s.format.clone()).or_insert(0) += 1;
917 }
918 let inserted = db.insert_audio_batch(&audio_scan_id, batch).unwrap_or(0);
919 audio_count += inserted;
920 let _ = app_handle.emit(
921 "audio-scan-progress",
922 serde_json::json!({
923 "phase": "scanning",
924 "samples": batch,
925 "found": audio_count,
926 }),
927 );
928 },
929 &|| audio_state.stop_scan.load(Ordering::SeqCst),
930 exclude_set,
931 Some(Arc::clone(&app_handle.state::<WalkerStatus>().audio_dirs)),
932 incremental_state.clone(),
933 );
934
935 persist_incremental_dir_state_after_walk(incremental_state.as_ref(), &audio_scan_id);
936
937 {
939 let ws = app_handle.state::<WalkerStatus>();
940 let mut ad = ws.audio_dirs.lock().unwrap_or_else(|e| e.into_inner());
941 ad.clear();
942 }
943
944 let was_stopped = audio_state.stop_scan.load(Ordering::Relaxed);
945 let _ = db.audio_scan_parent_finalize(
946 &audio_scan_id,
947 audio_count,
948 audio_bytes,
949 &audio_format_counts,
950 );
951 let _ = db.set_audio_scan_complete(&audio_scan_id, !was_stopped);
952 db.checkpoint();
953 serde_json::json!({
954 "samples": [],
955 "roots": root_strs,
956 "stopped": was_stopped,
957 "streamed": true,
958 "audioScanId": audio_scan_id,
959 "audioCount": audio_count,
960 })
961 })
962 .await;
963
964 state.scanning.store(false, Ordering::SeqCst);
965 let elapsed = scan_start.elapsed();
966 match &result {
967 Ok(v) => append_log(format!(
968 "SCAN END — audio | {}s | {} found",
969 elapsed.as_secs(),
970 v.get("audioCount")
971 .and_then(|n| n.as_u64())
972 .or_else(|| {
973 v.get("samples")
974 .and_then(|p| p.as_array())
975 .map(|a| a.len() as u64)
976 })
977 .unwrap_or(0)
978 )),
979 Err(e) => append_log(format!(
980 "SCAN ERROR — audio | {}s | {}",
981 elapsed.as_secs(),
982 e
983 )),
984 }
985 result.map_err(|e| e.to_string())
986}
987
988#[tauri::command]
989async fn stop_audio_scan(app: AppHandle) -> Result<(), String> {
990 append_log("SCAN STOP — audio (user requested)".into());
991 let state = app.state::<AudioScanState>();
992 state.stop_scan.store(true, Ordering::SeqCst);
993 Ok(())
994}
995
996#[tauri::command]
997async fn get_audio_metadata(file_path: String) -> audio_scanner::AudioMetadata {
998 let fallback_path = file_path.clone();
999 tokio::task::spawn_blocking(move || audio_scanner::get_audio_metadata(&file_path))
1000 .await
1001 .unwrap_or_else(|_| audio_scanner::get_audio_metadata(&fallback_path))
1002}
1003
1004#[tauri::command]
1006async fn audio_history_save(
1007 samples: Vec<AudioSample>,
1008 roots: Option<Vec<String>>,
1009) -> Result<history::AudioScanSnapshot, String> {
1010 let roots = roots.unwrap_or_default();
1011 blocking_res(move || {
1012 let snap = history::build_audio_snapshot(&samples, &roots);
1013 db::global().save_audio_scan_full(&snap)?;
1014 db::global().checkpoint();
1015 Ok(snap)
1016 })
1017 .await
1018}
1019
1020#[tauri::command]
1021async fn audio_history_get_scans() -> Result<Vec<serde_json::Value>, String> {
1022 blocking_res(|| db::global().get_audio_scans_list()).await
1023}
1024
1025#[tauri::command]
1026async fn audio_history_get_detail(id: String) -> Result<history::AudioScanSnapshot, String> {
1027 blocking_res(move || db::global().get_audio_scan_detail(&id)).await
1028}
1029
1030#[tauri::command]
1031async fn audio_history_delete(id: String) -> Result<(), String> {
1032 blocking_res(move || db::global().delete_audio_scan(&id)).await
1033}
1034
1035#[tauri::command]
1036async fn audio_history_clear() -> Result<(), String> {
1037 #[cfg(not(test))]
1038 append_log("HISTORY CLEAR — audio samples (all scan history deleted)".into());
1039 blocking_res(|| db::global().clear_audio_history()).await
1040}
1041
1042#[tauri::command]
1043async fn audio_history_latest() -> Result<Option<history::AudioScanSnapshot>, String> {
1044 blocking_res(|| db::global().get_latest_audio_scan()).await
1045}
1046
1047#[tauri::command]
1048async fn audio_history_diff(old_id: String, new_id: String) -> Option<history::AudioScanDiff> {
1049 tokio::task::spawn_blocking(move || {
1050 let old = db::global().get_audio_scan_detail(&old_id).ok()?;
1051 let new = db::global().get_audio_scan_detail(&new_id).ok()?;
1052 Some(history::compute_audio_diff(&old, &new))
1053 })
1054 .await
1055 .ok()
1056 .flatten()
1057}
1058
1059#[tauri::command]
1061async fn scan_daw_projects(
1062 app: AppHandle,
1063 custom_roots: Option<Vec<String>>,
1064 exclude_paths: Option<Vec<String>>,
1065) -> Result<serde_json::Value, String> {
1066 let state = app.state::<DawScanState>();
1067 let scan_start = Instant::now();
1068 append_log(format!(
1069 "SCAN START — daw | roots: {:?}",
1070 custom_roots.as_deref().unwrap_or(&[])
1071 ));
1072 if state.scanning.swap(true, Ordering::SeqCst) {
1073 return Err("DAW scan already in progress".into());
1074 }
1075 state.stop_scan.store(false, Ordering::SeqCst);
1076
1077 let _ = app.emit(
1078 "daw-scan-progress",
1079 serde_json::json!({
1080 "phase": "status",
1081 "message": "Walking filesystem directories parallelized for DAW project files..."
1082 }),
1083 );
1084
1085 let app_handle = app.clone();
1086 let result = tokio::task::spawn_blocking(move || {
1087 let daw_state = app_handle.state::<DawScanState>();
1088 let roots = if let Some(ref extra) = custom_roots {
1089 let custom: Vec<std::path::PathBuf> = extra
1090 .iter()
1091 .map(std::path::PathBuf::from)
1092 .filter(|p| p.exists())
1093 .collect();
1094 if custom.is_empty() {
1095 daw_scanner::get_daw_roots()
1096 } else {
1097 custom
1098 }
1099 } else {
1100 daw_scanner::get_daw_roots()
1101 };
1102 let root_strs: Vec<String> = roots
1103 .iter()
1104 .map(|r| r.to_string_lossy().to_string())
1105 .collect();
1106 let now_iso = history::now_iso();
1107 let daw_scan_id = history::gen_id();
1108 let db = db::global();
1109 let _ = db.daw_scan_parent_create(&daw_scan_id, &now_iso, &root_strs);
1110
1111 let mut daw_count: u64 = 0;
1112 let mut daw_bytes: u64 = 0;
1113 let mut daw_daw_counts: HashMap<String, usize> = HashMap::new();
1114 let exclude_set = exclude_paths.map(|v| v.into_iter().collect::<HashSet<String>>());
1115 let incremental_state = load_incremental_dir_state_for_walk();
1116
1117 daw_scanner::walk_for_daw(
1118 &roots,
1119 &mut |batch, _found| {
1120 let inserted_idx = db.insert_daw_batch(&daw_scan_id, batch).unwrap_or_default();
1121 let deduped: Vec<&DawProject> = inserted_idx.iter().map(|&i| &batch[i]).collect();
1122 for p in &deduped {
1123 daw_bytes += p.size;
1124 *daw_daw_counts.entry(p.daw.clone()).or_insert(0) += 1;
1125 }
1126 daw_count += deduped.len() as u64;
1127 let _ = app_handle.emit(
1128 "daw-scan-progress",
1129 serde_json::json!({
1130 "phase": "scanning",
1131 "projects": deduped,
1132 "found": daw_count,
1133 }),
1134 );
1135 },
1136 &|| daw_state.stop_scan.load(Ordering::SeqCst),
1137 exclude_set,
1138 {
1139 let prefs = history::load_preferences();
1140 prefs
1141 .get("includeAbletonBackups")
1142 .and_then(|v| v.as_str())
1143 .map(|s| s == "on")
1144 .unwrap_or(false)
1145 },
1146 Some(Arc::clone(&app_handle.state::<WalkerStatus>().daw_dirs)),
1147 incremental_state.clone(),
1148 );
1149
1150 persist_incremental_dir_state_after_walk(incremental_state.as_ref(), &daw_scan_id);
1151
1152 {
1153 let ws = app_handle.state::<WalkerStatus>();
1154 let mut ad = ws.daw_dirs.lock().unwrap_or_else(|e| e.into_inner());
1155 ad.clear();
1156 }
1157 let was_stopped = daw_state.stop_scan.load(Ordering::Relaxed);
1158 let _ = db.daw_scan_parent_finalize(
1159 &daw_scan_id,
1160 daw_count as usize,
1161 daw_bytes,
1162 &daw_daw_counts,
1163 );
1164 let _ = db.set_daw_scan_complete(&daw_scan_id, !was_stopped);
1165 db.checkpoint();
1166 serde_json::json!({
1167 "projects": [],
1168 "roots": root_strs,
1169 "stopped": was_stopped,
1170 "streamed": true,
1171 "dawScanId": daw_scan_id,
1172 "dawCount": daw_count,
1173 })
1174 })
1175 .await;
1176
1177 state.scanning.store(false, Ordering::SeqCst);
1178 let elapsed = scan_start.elapsed();
1179 match &result {
1180 Ok(v) => append_log(format!(
1181 "SCAN END — daw | {}s | {} found",
1182 elapsed.as_secs(),
1183 v.get("dawCount")
1184 .and_then(|n| n.as_u64())
1185 .or_else(|| {
1186 v.get("projects")
1187 .and_then(|p| p.as_array())
1188 .map(|a| a.len() as u64)
1189 })
1190 .unwrap_or(0)
1191 )),
1192 Err(e) => append_log(format!("SCAN ERROR — daw | {}s | {}", elapsed.as_secs(), e)),
1193 }
1194 result.map_err(|e| e.to_string())
1195}
1196
1197#[tauri::command]
1198async fn stop_daw_scan(app: AppHandle) -> Result<(), String> {
1199 append_log("SCAN STOP — daw (user requested)".into());
1200 let state = app.state::<DawScanState>();
1201 state.stop_scan.store(true, Ordering::SeqCst);
1202 Ok(())
1203}
1204
1205#[tauri::command]
1207async fn daw_history_save(
1208 projects: Vec<DawProject>,
1209 roots: Option<Vec<String>>,
1210) -> Result<history::DawScanSnapshot, String> {
1211 let roots = roots.unwrap_or_default();
1212 blocking_res(move || {
1213 let snap = history::build_daw_snapshot(&projects, &roots);
1214 db::global().save_daw_scan(&snap)?;
1215 db::global().checkpoint();
1216 Ok(snap)
1217 })
1218 .await
1219}
1220
1221#[tauri::command]
1222async fn daw_history_get_scans() -> Result<Vec<serde_json::Value>, String> {
1223 blocking_res(|| db::global().get_daw_scans()).await
1224}
1225
1226#[tauri::command]
1227async fn daw_history_get_detail(id: String) -> Result<history::DawScanSnapshot, String> {
1228 blocking_res(move || db::global().get_daw_scan_detail(&id)).await
1229}
1230
1231#[tauri::command]
1232async fn daw_history_delete(id: String) -> Result<(), String> {
1233 blocking_res(move || db::global().delete_daw_scan(&id)).await
1234}
1235
1236#[tauri::command]
1237async fn daw_history_clear() -> Result<(), String> {
1238 #[cfg(not(test))]
1239 append_log("HISTORY CLEAR — DAW projects".into());
1240 blocking_res(|| db::global().clear_daw_history()).await
1241}
1242
1243#[tauri::command]
1244async fn daw_history_latest() -> Result<Option<history::DawScanSnapshot>, String> {
1245 blocking_res(|| db::global().get_latest_daw_scan()).await
1246}
1247
1248#[tauri::command]
1249async fn daw_history_diff(old_id: String, new_id: String) -> Option<history::DawScanDiff> {
1250 tokio::task::spawn_blocking(move || {
1251 let old = db::global().get_daw_scan_detail(&old_id).ok()?;
1252 let new = db::global().get_daw_scan_detail(&new_id).ok()?;
1253 Some(history::compute_daw_diff(&old, &new))
1254 })
1255 .await
1256 .ok()
1257 .flatten()
1258}
1259
1260#[tauri::command]
1262async fn scan_presets(
1263 app: AppHandle,
1264 custom_roots: Option<Vec<String>>,
1265 exclude_paths: Option<Vec<String>>,
1266) -> Result<serde_json::Value, String> {
1267 let state = app.state::<PresetScanState>();
1268 let scan_start = Instant::now();
1269 append_log(format!(
1270 "SCAN START — presets | roots: {:?}",
1271 custom_roots.as_deref().unwrap_or(&[])
1272 ));
1273 if state.scanning.swap(true, Ordering::SeqCst) {
1274 return Err("Preset scan already in progress".into());
1275 }
1276 state.stop_scan.store(false, Ordering::SeqCst);
1277
1278 let _ = app.emit(
1279 "preset-scan-progress",
1280 serde_json::json!({
1281 "phase": "status",
1282 "message": "Walking filesystem directories parallelized for preset files..."
1283 }),
1284 );
1285
1286 let app_handle = app.clone();
1287 let result = tokio::task::spawn_blocking(move || {
1288 let preset_state = app_handle.state::<PresetScanState>();
1289 let roots = if let Some(ref extra) = custom_roots {
1290 let custom: Vec<std::path::PathBuf> = extra
1291 .iter()
1292 .map(std::path::PathBuf::from)
1293 .filter(|p| p.exists())
1294 .collect();
1295 if custom.is_empty() {
1296 preset_scanner::get_preset_roots()
1297 } else {
1298 custom
1299 }
1300 } else {
1301 preset_scanner::get_preset_roots()
1302 };
1303 let root_strs: Vec<String> = roots
1304 .iter()
1305 .map(|r| r.to_string_lossy().to_string())
1306 .collect();
1307 let now_iso = history::now_iso();
1308 let preset_scan_id = history::gen_id();
1309 let db = db::global();
1310 let _ = db.preset_scan_parent_create(&preset_scan_id, &now_iso, &root_strs);
1311
1312 let mut preset_count: u64 = 0;
1313 let mut preset_bytes: u64 = 0;
1314 let mut preset_format_counts: HashMap<String, usize> = HashMap::new();
1315 let exclude_set = exclude_paths.map(|v| v.into_iter().collect::<HashSet<String>>());
1316 let incremental_state = load_incremental_dir_state_for_walk();
1317
1318 preset_scanner::walk_for_presets(
1319 &roots,
1320 &mut |batch, _found| {
1321 for p in batch.iter() {
1322 preset_bytes += p.size;
1323 *preset_format_counts.entry(p.format.clone()).or_insert(0) += 1;
1324 }
1325 let inserted = db.insert_preset_batch(&preset_scan_id, batch).unwrap_or(0);
1326 preset_count += inserted;
1327 let _ = app_handle.emit(
1328 "preset-scan-progress",
1329 serde_json::json!({
1330 "phase": "scanning",
1331 "presets": batch,
1332 "found": preset_count,
1333 }),
1334 );
1335 },
1336 &|| preset_state.stop_scan.load(Ordering::SeqCst),
1337 exclude_set,
1338 Some(Arc::clone(&app_handle.state::<WalkerStatus>().preset_dirs)),
1339 incremental_state.clone(),
1340 );
1341
1342 persist_incremental_dir_state_after_walk(incremental_state.as_ref(), &preset_scan_id);
1343
1344 {
1345 let ws = app_handle.state::<WalkerStatus>();
1346 let mut ad = ws.preset_dirs.lock().unwrap_or_else(|e| e.into_inner());
1347 ad.clear();
1348 }
1349 let was_stopped = preset_state.stop_scan.load(Ordering::Relaxed);
1350 let _ = db.preset_scan_parent_finalize(
1351 &preset_scan_id,
1352 preset_count as usize,
1353 preset_bytes,
1354 &preset_format_counts,
1355 );
1356 let _ = db.set_preset_scan_complete(&preset_scan_id, !was_stopped);
1357 db.checkpoint();
1358 serde_json::json!({
1359 "presets": [],
1360 "roots": root_strs,
1361 "stopped": was_stopped,
1362 "streamed": true,
1363 "presetScanId": preset_scan_id,
1364 "presetCount": preset_count,
1365 })
1366 })
1367 .await;
1368
1369 state.scanning.store(false, Ordering::SeqCst);
1370 let elapsed = scan_start.elapsed();
1371 match &result {
1372 Ok(v) => append_log(format!(
1373 "SCAN END — presets | {}s | {} found",
1374 elapsed.as_secs(),
1375 v.get("presetCount")
1376 .and_then(|n| n.as_u64())
1377 .or_else(|| {
1378 v.get("presets")
1379 .and_then(|p| p.as_array())
1380 .map(|a| a.len() as u64)
1381 })
1382 .unwrap_or(0)
1383 )),
1384 Err(e) => append_log(format!(
1385 "SCAN ERROR — presets | {}s | {}",
1386 elapsed.as_secs(),
1387 e
1388 )),
1389 }
1390 result.map_err(|e| e.to_string())
1391}
1392
1393#[tauri::command]
1394async fn stop_preset_scan(app: AppHandle) -> Result<(), String> {
1395 append_log("SCAN STOP — presets (user requested)".into());
1396 let state = app.state::<PresetScanState>();
1397 state.stop_scan.store(true, Ordering::SeqCst);
1398 Ok(())
1399}
1400
1401#[tauri::command]
1403async fn preset_history_save(
1404 presets: Vec<PresetFile>,
1405 roots: Option<Vec<String>>,
1406) -> Result<history::PresetScanSnapshot, String> {
1407 let roots = roots.unwrap_or_default();
1408 blocking_res(move || {
1409 let snap = history::build_preset_snapshot(&presets, &roots);
1410 db::global().save_preset_scan(&snap)?;
1411 db::global().checkpoint();
1412 Ok(snap)
1413 })
1414 .await
1415}
1416
1417#[tauri::command]
1418async fn preset_history_get_scans() -> Result<Vec<serde_json::Value>, String> {
1419 blocking_res(|| db::global().get_preset_scans()).await
1420}
1421
1422#[tauri::command]
1423async fn preset_history_get_detail(id: String) -> Result<history::PresetScanSnapshot, String> {
1424 blocking_res(move || db::global().get_preset_scan_detail(&id)).await
1425}
1426
1427#[tauri::command]
1428async fn preset_history_delete(id: String) -> Result<(), String> {
1429 blocking_res(move || db::global().delete_preset_scan(&id)).await
1430}
1431
1432#[tauri::command]
1433async fn preset_history_clear() -> Result<(), String> {
1434 #[cfg(not(test))]
1435 append_log("HISTORY CLEAR — presets".into());
1436 blocking_res(|| db::global().clear_preset_history()).await
1437}
1438
1439#[tauri::command]
1440async fn preset_history_latest() -> Result<Option<history::PresetScanSnapshot>, String> {
1441 blocking_res(|| db::global().get_latest_preset_scan()).await
1442}
1443
1444#[tauri::command]
1445async fn preset_history_diff(old_id: String, new_id: String) -> Option<history::PresetScanDiff> {
1446 tokio::task::spawn_blocking(move || {
1447 let old = db::global().get_preset_scan_detail(&old_id).ok()?;
1448 let new = db::global().get_preset_scan_detail(&new_id).ok()?;
1449 Some(history::compute_preset_diff(&old, &new))
1450 })
1451 .await
1452 .ok()
1453 .flatten()
1454}
1455
1456#[tauri::command]
1458async fn scan_midi_files(
1459 app: AppHandle,
1460 custom_roots: Option<Vec<String>>,
1461 exclude_paths: Option<Vec<String>>,
1462) -> Result<serde_json::Value, String> {
1463 let state = app.state::<MidiScanState>();
1464 let scan_start = Instant::now();
1465 append_log(format!(
1466 "SCAN START — midi | roots: {:?}",
1467 custom_roots.as_deref().unwrap_or(&[])
1468 ));
1469 if state.scanning.swap(true, Ordering::SeqCst) {
1470 return Err("MIDI scan already in progress".into());
1471 }
1472 state.stop_scan.store(false, Ordering::SeqCst);
1473
1474 let _ = app.emit(
1475 "midi-scan-progress",
1476 serde_json::json!({
1477 "phase": "status",
1478 "message": "Walking filesystem directories parallelized for MIDI files..."
1479 }),
1480 );
1481
1482 let app_handle = app.clone();
1483 let result = tokio::task::spawn_blocking(move || {
1484 let midi_state = app_handle.state::<MidiScanState>();
1485 let roots = if let Some(ref extra) = custom_roots {
1486 let custom: Vec<std::path::PathBuf> = extra
1487 .iter()
1488 .map(std::path::PathBuf::from)
1489 .filter(|p| p.exists())
1490 .collect();
1491 if custom.is_empty() {
1492 midi_scanner::get_midi_roots()
1493 } else {
1494 custom
1495 }
1496 } else {
1497 midi_scanner::get_midi_roots()
1498 };
1499 let exclude_set = exclude_paths.map(|v| v.into_iter().collect::<HashSet<String>>());
1500 let root_strs: Vec<String> = roots
1501 .iter()
1502 .map(|r| r.to_string_lossy().to_string())
1503 .collect();
1504
1505 let now_iso = history::now_iso();
1508 let midi_scan_id = history::gen_id();
1509 let db = db::global();
1510 let _ = db.midi_scan_parent_create(&midi_scan_id, &now_iso, &root_strs);
1511
1512 let mut midi_count: u64 = 0;
1513 let mut midi_bytes: u64 = 0;
1514 let mut midi_format_counts: HashMap<String, usize> = HashMap::new();
1515 let incremental_state = load_incremental_dir_state_for_walk();
1516
1517 midi_scanner::walk_for_midi(
1518 &roots,
1519 &mut |batch, found| {
1520 for m in batch {
1521 midi_bytes += m.size;
1522 *midi_format_counts.entry(m.format.clone()).or_insert(0) += 1;
1523 }
1524 midi_count += batch.len() as u64;
1525 let _ = db.insert_midi_batch(&midi_scan_id, batch);
1526 let _ = app_handle.emit(
1527 "midi-scan-progress",
1528 serde_json::json!({
1529 "phase": "scanning",
1530 "midiFiles": batch,
1531 "found": found
1532 }),
1533 );
1534 },
1535 &|| midi_state.stop_scan.load(Ordering::SeqCst),
1536 exclude_set,
1537 Some(Arc::clone(&app_handle.state::<WalkerStatus>().midi_dirs)),
1538 incremental_state.clone(),
1539 );
1540
1541 persist_incremental_dir_state_after_walk(incremental_state.as_ref(), &midi_scan_id);
1542
1543 {
1544 let ws = app_handle.state::<WalkerStatus>();
1545 let mut ad = ws.midi_dirs.lock().unwrap_or_else(|e| e.into_inner());
1546 ad.clear();
1547 }
1548 let was_stopped = midi_state.stop_scan.load(Ordering::Relaxed);
1549 let _ = db.midi_scan_parent_finalize(
1550 &midi_scan_id,
1551 midi_count as usize,
1552 midi_bytes,
1553 &midi_format_counts,
1554 );
1555 let _ = db.set_midi_scan_complete(&midi_scan_id, !was_stopped);
1556 db.checkpoint();
1557 serde_json::json!({
1558 "midiCount": midi_count,
1559 "roots": root_strs,
1560 "stopped": was_stopped,
1561 "midiScanId": midi_scan_id,
1562 "streamed": true
1563 })
1564 })
1565 .await;
1566
1567 state.scanning.store(false, Ordering::SeqCst);
1568 let elapsed = scan_start.elapsed();
1569 match &result {
1570 Ok(v) => append_log(format!(
1571 "SCAN END — midi | {}s | {} found",
1572 elapsed.as_secs(),
1573 v.get("midiCount").and_then(|x| x.as_u64()).unwrap_or(0)
1574 )),
1575 Err(e) => append_log(format!(
1576 "SCAN ERROR — midi | {}s | {}",
1577 elapsed.as_secs(),
1578 e
1579 )),
1580 }
1581 result.map_err(|e| e.to_string())
1582}
1583
1584#[tauri::command]
1585async fn stop_midi_scan(app: AppHandle) -> Result<(), String> {
1586 append_log("SCAN STOP — midi (user requested)".into());
1587 let state = app.state::<MidiScanState>();
1588 state.stop_scan.store(true, Ordering::SeqCst);
1589 Ok(())
1590}
1591
1592#[tauri::command]
1593async fn midi_history_save(
1594 midi_files: Vec<history::MidiFile>,
1595 roots: Option<Vec<String>>,
1596) -> Result<history::MidiScanSnapshot, String> {
1597 let roots = roots.unwrap_or_default();
1598 blocking_res(move || {
1599 let snap = history::build_midi_snapshot(&midi_files, &roots);
1600 db::global().save_midi_scan(&snap)?;
1601 db::global().checkpoint();
1602 Ok(snap)
1603 })
1604 .await
1605}
1606
1607#[tauri::command]
1608async fn midi_history_get_scans() -> Result<Vec<serde_json::Value>, String> {
1609 blocking_res(|| db::global().get_midi_scans()).await
1610}
1611
1612#[tauri::command]
1613async fn midi_history_get_detail(id: String) -> Result<history::MidiScanSnapshot, String> {
1614 blocking_res(move || db::global().get_midi_scan_detail(&id)).await
1615}
1616
1617#[tauri::command]
1618async fn midi_history_delete(id: String) -> Result<(), String> {
1619 blocking_res(move || db::global().delete_midi_scan(&id)).await
1620}
1621
1622#[tauri::command]
1623async fn midi_history_clear() -> Result<(), String> {
1624 #[cfg(not(test))]
1625 append_log("HISTORY CLEAR — midi".into());
1626 blocking_res(|| db::global().clear_midi_history()).await
1627}
1628
1629#[tauri::command]
1630async fn midi_history_latest() -> Result<Option<history::MidiScanSnapshot>, String> {
1631 blocking_res(|| db::global().get_latest_midi_scan()).await
1632}
1633
1634#[tauri::command]
1635async fn midi_history_diff(old_id: String, new_id: String) -> Option<history::MidiScanDiff> {
1636 tokio::task::spawn_blocking(move || {
1637 let old = db::global().get_midi_scan_detail(&old_id).ok()?;
1638 let new = db::global().get_midi_scan_detail(&new_id).ok()?;
1639 Some(history::compute_midi_diff(&old, &new))
1640 })
1641 .await
1642 .ok()
1643 .flatten()
1644}
1645
1646#[tauri::command(rename_all = "snake_case")]
1647async fn db_query_midi(
1648 search: Option<String>,
1649 format_filter: Option<String>,
1650 sort_key: Option<String>,
1651 sort_asc: Option<bool>,
1652 search_regex: Option<bool>,
1653 offset: Option<u64>,
1654 limit: Option<u64>,
1655) -> Result<db::MidiQueryResult, String> {
1656 let search_regex = search_regex.unwrap_or(false);
1657 tokio::task::spawn_blocking(move || {
1658 db::global().query_midi(
1659 search.as_deref(),
1660 format_filter.as_deref(),
1661 sort_key.as_deref().unwrap_or("name"),
1662 sort_asc.unwrap_or(true),
1663 search_regex,
1664 offset.unwrap_or(0),
1665 limit.unwrap_or(500),
1666 )
1667 })
1668 .await
1669 .map_err(|e| format!("db_query_midi task: {e}"))?
1670}
1671
1672#[tauri::command(rename_all = "snake_case")]
1673async fn db_midi_filter_stats(
1674 search: Option<String>,
1675 format_filter: Option<String>,
1676 search_regex: Option<bool>,
1677) -> Result<db::FilterStatsResult, String> {
1678 let search_regex = search_regex.unwrap_or(false);
1679 tokio::task::spawn_blocking(move || {
1680 db::global().midi_filter_stats(
1681 search.as_deref(),
1682 format_filter.as_deref(),
1683 search_regex,
1684 )
1685 })
1686 .await
1687 .map_err(|e| format!("db_midi_filter_stats task: {e}"))?
1688}
1689
1690#[tauri::command]
1692async fn scan_pdfs(
1693 app: AppHandle,
1694 custom_roots: Option<Vec<String>>,
1695 exclude_paths: Option<Vec<String>>,
1696) -> Result<serde_json::Value, String> {
1697 let state = app.state::<PdfScanState>();
1698 let scan_start = Instant::now();
1699 append_log(format!(
1700 "SCAN START — pdfs | roots: {:?}",
1701 custom_roots.as_deref().unwrap_or(&[])
1702 ));
1703 if state.scanning.swap(true, Ordering::SeqCst) {
1704 return Err("PDF scan already in progress".into());
1705 }
1706 state.stop_scan.store(false, Ordering::SeqCst);
1707
1708 let _ = app.emit(
1709 "pdf-scan-progress",
1710 serde_json::json!({
1711 "phase": "status",
1712 "message": "Walking filesystem directories parallelized for PDF files..."
1713 }),
1714 );
1715
1716 let app_handle = app.clone();
1717 let result = tokio::task::spawn_blocking(move || {
1718 let pdf_state = app_handle.state::<PdfScanState>();
1719 let roots = if let Some(ref extra) = custom_roots {
1720 let custom: Vec<std::path::PathBuf> = extra
1721 .iter()
1722 .map(std::path::PathBuf::from)
1723 .filter(|p| p.exists())
1724 .collect();
1725 if custom.is_empty() {
1726 pdf_scanner::get_pdf_roots()
1727 } else {
1728 custom
1729 }
1730 } else {
1731 pdf_scanner::get_pdf_roots()
1732 };
1733 let root_strs: Vec<String> = roots
1734 .iter()
1735 .map(|r| r.to_string_lossy().to_string())
1736 .collect();
1737 let now_iso = history::now_iso();
1738 let pdf_scan_id = history::gen_id();
1739 let db = db::global();
1740 let _ = db.pdf_scan_parent_create(&pdf_scan_id, &now_iso, &root_strs);
1741
1742 let mut pdf_count: u64 = 0;
1743 let mut pdf_bytes: u64 = 0;
1744 let exclude_set = exclude_paths.map(|v| v.into_iter().collect::<HashSet<String>>());
1745 let incremental_state = load_incremental_dir_state_for_walk();
1746
1747 pdf_scanner::walk_for_pdfs(
1748 &roots,
1749 &mut |batch, _found| {
1750 for p in batch.iter() {
1751 pdf_bytes += p.size;
1752 }
1753 let inserted = db.insert_pdf_batch(&pdf_scan_id, batch).unwrap_or(0);
1754 pdf_count += inserted;
1755 let _ = app_handle.emit(
1756 "pdf-scan-progress",
1757 serde_json::json!({
1758 "phase": "scanning",
1759 "pdfs": batch,
1760 "found": pdf_count,
1761 }),
1762 );
1763 },
1764 &|| pdf_state.stop_scan.load(Ordering::SeqCst),
1765 exclude_set,
1766 Some(Arc::clone(&app_handle.state::<WalkerStatus>().pdf_dirs)),
1767 incremental_state.clone(),
1768 );
1769
1770 persist_incremental_dir_state_after_walk(incremental_state.as_ref(), &pdf_scan_id);
1771
1772 {
1773 let ws = app_handle.state::<WalkerStatus>();
1774 let mut ad = ws.pdf_dirs.lock().unwrap_or_else(|e| e.into_inner());
1775 ad.clear();
1776 }
1777 let was_stopped = pdf_state.stop_scan.load(Ordering::Relaxed);
1778 let _ = db.pdf_scan_parent_finalize(&pdf_scan_id, pdf_count as usize, pdf_bytes);
1779 let _ = db.set_pdf_scan_complete(&pdf_scan_id, !was_stopped);
1780 db.checkpoint();
1781 serde_json::json!({
1782 "pdfs": [],
1783 "roots": root_strs,
1784 "stopped": was_stopped,
1785 "streamed": true,
1786 "pdfScanId": pdf_scan_id,
1787 "pdfCount": pdf_count,
1788 })
1789 })
1790 .await;
1791
1792 state.scanning.store(false, Ordering::SeqCst);
1793 let elapsed = scan_start.elapsed();
1794 match &result {
1795 Ok(v) => append_log(format!(
1796 "SCAN END — pdfs | {}s | {} found",
1797 elapsed.as_secs(),
1798 v.get("pdfCount")
1799 .and_then(|n| n.as_u64())
1800 .or_else(|| {
1801 v.get("pdfs")
1802 .and_then(|p| p.as_array())
1803 .map(|a| a.len() as u64)
1804 })
1805 .unwrap_or(0)
1806 )),
1807 Err(e) => append_log(format!(
1808 "SCAN ERROR — pdfs | {}s | {}",
1809 elapsed.as_secs(),
1810 e
1811 )),
1812 }
1813 result.map_err(|e| e.to_string())
1814}
1815
1816#[tauri::command]
1817async fn stop_pdf_scan(app: AppHandle) -> Result<(), String> {
1818 append_log("SCAN STOP — pdfs (user requested)".into());
1819 let state = app.state::<PdfScanState>();
1820 state.stop_scan.store(true, Ordering::SeqCst);
1821 Ok(())
1822}
1823
1824#[tauri::command]
1832async fn scan_unified(
1833 app: AppHandle,
1834 audio_custom_roots: Option<Vec<String>>,
1835 audio_exclude_paths: Option<Vec<String>>,
1836 daw_custom_roots: Option<Vec<String>>,
1837 daw_exclude_paths: Option<Vec<String>>,
1838 daw_include_backups: Option<bool>,
1839 preset_custom_roots: Option<Vec<String>>,
1840 preset_exclude_paths: Option<Vec<String>>,
1841 pdf_custom_roots: Option<Vec<String>>,
1842 pdf_exclude_paths: Option<Vec<String>>,
1843) -> Result<serde_json::Value, String> {
1844 let scan_start = Instant::now();
1845 append_log("SCAN START — unified (audio+daw+preset+pdf)".into());
1846
1847 let audio_state = app.state::<AudioScanState>();
1849 let daw_state = app.state::<DawScanState>();
1850 let preset_state = app.state::<PresetScanState>();
1851 let pdf_state = app.state::<PdfScanState>();
1852
1853 if audio_state.scanning.swap(true, Ordering::SeqCst) {
1854 return Err("Audio scan already in progress".into());
1855 }
1856 if daw_state.scanning.swap(true, Ordering::SeqCst) {
1857 audio_state.scanning.store(false, Ordering::SeqCst);
1858 return Err("DAW scan already in progress".into());
1859 }
1860 if preset_state.scanning.swap(true, Ordering::SeqCst) {
1861 audio_state.scanning.store(false, Ordering::SeqCst);
1862 daw_state.scanning.store(false, Ordering::SeqCst);
1863 return Err("Preset scan already in progress".into());
1864 }
1865 if pdf_state.scanning.swap(true, Ordering::SeqCst) {
1866 audio_state.scanning.store(false, Ordering::SeqCst);
1867 daw_state.scanning.store(false, Ordering::SeqCst);
1868 preset_state.scanning.store(false, Ordering::SeqCst);
1869 return Err("PDF scan already in progress".into());
1870 }
1871 if audio_state.stop_scan.load(Ordering::SeqCst)
1875 || daw_state.stop_scan.load(Ordering::SeqCst)
1876 || preset_state.stop_scan.load(Ordering::SeqCst)
1877 || pdf_state.stop_scan.load(Ordering::SeqCst)
1878 {
1879 audio_state.scanning.store(false, Ordering::SeqCst);
1880 daw_state.scanning.store(false, Ordering::SeqCst);
1881 preset_state.scanning.store(false, Ordering::SeqCst);
1882 pdf_state.scanning.store(false, Ordering::SeqCst);
1883 app.state::<WalkerStatus>()
1884 .unified_scanning
1885 .store(false, Ordering::SeqCst);
1886 append_log("SCAN CANCELLED — unified (stop before walk)".into());
1887 return Ok(serde_json::json!({
1888 "audioCount": 0u64,
1889 "dawCount": 0u64,
1890 "presetCount": 0u64,
1891 "pdfCount": 0u64,
1892 "audioRoots": serde_json::json!([]),
1893 "dawRoots": serde_json::json!([]),
1894 "presetRoots": serde_json::json!([]),
1895 "pdfRoots": serde_json::json!([]),
1896 "audioScanId": "",
1897 "dawScanId": "",
1898 "presetScanId": "",
1899 "pdfScanId": "",
1900 "unifiedRunId": "",
1901 "stopped": true,
1902 "streamed": true,
1903 }));
1904 }
1905 app.state::<WalkerStatus>()
1907 .unified_scanning
1908 .store(true, Ordering::SeqCst);
1909
1910 for ev in [
1913 "audio-scan-progress",
1914 "daw-scan-progress",
1915 "preset-scan-progress",
1916 "pdf-scan-progress",
1917 ] {
1918 let _ = app.emit(
1919 ev,
1920 serde_json::json!({
1921 "phase": "status",
1922 "message": "Walking filesystem (unified) — single traversal classifying all types..."
1923 }),
1924 );
1925 }
1926
1927 let app_handle = app.clone();
1928 let result = tokio::task::spawn_blocking(move || -> Result<serde_json::Value, String> {
1929 let resolve = |custom: Option<Vec<String>>,
1930 default: &dyn Fn() -> Vec<std::path::PathBuf>|
1931 -> Vec<std::path::PathBuf> {
1932 if let Some(extra) = custom {
1933 let v: Vec<std::path::PathBuf> = extra
1934 .into_iter()
1935 .map(std::path::PathBuf::from)
1936 .filter(|p| p.exists())
1937 .collect();
1938 if v.is_empty() {
1939 default()
1940 } else {
1941 v
1942 }
1943 } else {
1944 default()
1945 }
1946 };
1947 let audio_roots = resolve(audio_custom_roots, &audio_scanner::get_audio_roots);
1948 let daw_roots = resolve(daw_custom_roots, &daw_scanner::get_daw_roots);
1949 let preset_roots = resolve(preset_custom_roots, &preset_scanner::get_preset_roots);
1950 let pdf_roots = resolve(pdf_custom_roots, &pdf_scanner::get_pdf_roots);
1951
1952 let spec = unified_walker::UnifiedSpec {
1953 audio_roots: audio_roots.clone(),
1954 audio_exclude: audio_exclude_paths.into_iter().flatten().collect(),
1955 daw_roots: daw_roots.clone(),
1956 daw_exclude: daw_exclude_paths.into_iter().flatten().collect(),
1957 daw_include_backups: daw_include_backups.unwrap_or(false),
1958 preset_roots: preset_roots.clone(),
1959 preset_exclude: preset_exclude_paths.into_iter().flatten().collect(),
1960 pdf_roots: pdf_roots.clone(),
1961 pdf_exclude: pdf_exclude_paths.into_iter().flatten().collect(),
1962 };
1963
1964 let now_iso = history::now_iso();
1968 let audio_scan_id = history::gen_id();
1969 let daw_scan_id = history::gen_id();
1970 let preset_scan_id = history::gen_id();
1971 let pdf_scan_id = history::gen_id();
1972
1973 let to_strs = |v: &[std::path::PathBuf]| -> Vec<String> {
1974 v.iter().map(|r| r.to_string_lossy().to_string()).collect()
1975 };
1976 let audio_roots_strs = to_strs(&audio_roots);
1977 let daw_roots_strs = to_strs(&daw_roots);
1978 let preset_roots_strs = to_strs(&preset_roots);
1979 let pdf_roots_strs = to_strs(&pdf_roots);
1980
1981 let unified_run_id = history::gen_id();
1982 let roots_json = serde_json::json!({
1983 "audio": &audio_roots_strs,
1984 "daw": &daw_roots_strs,
1985 "preset": &preset_roots_strs,
1986 "pdf": &pdf_roots_strs,
1987 })
1988 .to_string();
1989
1990 let incremental_state = load_incremental_dir_state_for_walk();
1991 let db = db::global();
1992 let _ = db.unified_scan_run_start(
1993 &unified_run_id,
1994 &now_iso,
1995 &audio_scan_id,
1996 &daw_scan_id,
1997 &preset_scan_id,
1998 &pdf_scan_id,
1999 &roots_json,
2000 );
2001
2002 let _ = db.audio_scan_parent_create(&audio_scan_id, &now_iso, &audio_roots_strs);
2003 let _ = db.daw_scan_parent_create(&daw_scan_id, &now_iso, &daw_roots_strs);
2004 let _ = db.preset_scan_parent_create(&preset_scan_id, &now_iso, &preset_roots_strs);
2005 let _ = db.pdf_scan_parent_create(&pdf_scan_id, &now_iso, &pdf_roots_strs);
2006
2007 let mut audio_count: u64 = 0;
2008 let mut daw_count: u64 = 0;
2009 let mut preset_count: u64 = 0;
2010 let mut pdf_count: u64 = 0;
2011 let mut audio_bytes: u64 = 0;
2012 let mut daw_bytes: u64 = 0;
2013 let mut preset_bytes: u64 = 0;
2014 let mut pdf_bytes: u64 = 0;
2015 let mut audio_format_counts: HashMap<String, usize> = HashMap::new();
2016 let mut daw_daw_counts: HashMap<String, usize> = HashMap::new();
2017 let mut preset_format_counts: HashMap<String, usize> = HashMap::new();
2018
2019 let audio_state2 = app_handle.state::<AudioScanState>();
2020 let daw_state2 = app_handle.state::<DawScanState>();
2021 let preset_state2 = app_handle.state::<PresetScanState>();
2022 let pdf_state2 = app_handle.state::<PdfScanState>();
2023
2024 let closure_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2025 unified_walker::walk_unified(
2026 &spec,
2027 &mut |batch, _counts| {
2028 use unified_walker::ClassifiedBatch;
2029 match batch {
2030 ClassifiedBatch::Audio(b) => {
2031 for s in &b {
2032 audio_bytes += s.size;
2033 *audio_format_counts.entry(s.format.clone()).or_insert(0) += 1;
2034 }
2035 let inserted = db.insert_audio_batch(&audio_scan_id, &b).unwrap_or(0);
2036 audio_count += inserted;
2037 let _ = app_handle.emit(
2038 "audio-scan-progress",
2039 serde_json::json!({
2040 "phase": "scanning",
2041 "samples": &b,
2042 "found": audio_count,
2043 }),
2044 );
2045 }
2046 ClassifiedBatch::Daw(b) => {
2047 let inserted_idx =
2048 db.insert_daw_batch(&daw_scan_id, &b).unwrap_or_default();
2049 let deduped: Vec<&DawProject> =
2050 inserted_idx.iter().map(|&i| &b[i]).collect();
2051 for p in &deduped {
2052 daw_bytes += p.size;
2053 *daw_daw_counts.entry(p.daw.clone()).or_insert(0) += 1;
2054 }
2055 daw_count += deduped.len() as u64;
2056 let _ = app_handle.emit(
2057 "daw-scan-progress",
2058 serde_json::json!({
2059 "phase": "scanning",
2060 "projects": &deduped,
2061 "found": daw_count,
2062 }),
2063 );
2064 }
2065 ClassifiedBatch::Preset(b) => {
2066 for p in &b {
2067 preset_bytes += p.size;
2068 *preset_format_counts.entry(p.format.clone()).or_insert(0) += 1;
2069 }
2070 let inserted = db.insert_preset_batch(&preset_scan_id, &b).unwrap_or(0);
2071 preset_count += inserted;
2072 let _ = app_handle.emit(
2073 "preset-scan-progress",
2074 serde_json::json!({
2075 "phase": "scanning",
2076 "presets": &b,
2077 "found": preset_count,
2078 }),
2079 );
2080 }
2081 ClassifiedBatch::Pdf(b) => {
2082 for p in &b {
2083 pdf_bytes += p.size;
2084 }
2085 let inserted = db.insert_pdf_batch(&pdf_scan_id, &b).unwrap_or(0);
2086 pdf_count += inserted;
2087 let _ = app_handle.emit(
2088 "pdf-scan-progress",
2089 serde_json::json!({
2090 "phase": "scanning",
2091 "pdfs": &b,
2092 "found": pdf_count,
2093 }),
2094 );
2095 }
2096 }
2097 },
2098 &|| {
2099 audio_state2.stop_scan.load(Ordering::SeqCst)
2101 || daw_state2.stop_scan.load(Ordering::SeqCst)
2102 || preset_state2.stop_scan.load(Ordering::SeqCst)
2103 || pdf_state2.stop_scan.load(Ordering::SeqCst)
2104 },
2105 {
2108 let ws = app_handle.state::<WalkerStatus>();
2109 vec![
2110 Arc::clone(&ws.audio_dirs),
2111 Arc::clone(&ws.daw_dirs),
2112 Arc::clone(&ws.preset_dirs),
2113 Arc::clone(&ws.pdf_dirs),
2114 ]
2115 },
2116 incremental_state.clone(),
2117 );
2118
2119 let stopped = audio_state2.stop_scan.load(Ordering::Relaxed)
2120 || daw_state2.stop_scan.load(Ordering::Relaxed)
2121 || preset_state2.stop_scan.load(Ordering::Relaxed)
2122 || pdf_state2.stop_scan.load(Ordering::Relaxed);
2123
2124 if !stopped {
2125 persist_incremental_dir_state_after_walk(incremental_state.as_ref(), &audio_scan_id);
2126 }
2127
2128 {
2130 let ws = app_handle.state::<WalkerStatus>();
2131 for sink in [&ws.audio_dirs, &ws.daw_dirs, &ws.preset_dirs, &ws.pdf_dirs] {
2132 sink.lock().unwrap_or_else(|e| e.into_inner()).clear();
2133 }
2134 }
2135
2136 let _ = db.audio_scan_parent_finalize(
2138 &audio_scan_id,
2139 audio_count,
2140 audio_bytes,
2141 &audio_format_counts,
2142 );
2143 let _ = db.daw_scan_parent_finalize(
2144 &daw_scan_id,
2145 daw_count as usize,
2146 daw_bytes,
2147 &daw_daw_counts,
2148 );
2149 let _ = db.preset_scan_parent_finalize(
2150 &preset_scan_id,
2151 preset_count as usize,
2152 preset_bytes,
2153 &preset_format_counts,
2154 );
2155 let _ = db.pdf_scan_parent_finalize(&pdf_scan_id, pdf_count as usize, pdf_bytes);
2156 let complete = !stopped;
2157 let _ = db.set_audio_scan_complete(&audio_scan_id, complete);
2158 let _ = db.set_daw_scan_complete(&daw_scan_id, complete);
2159 let _ = db.set_preset_scan_complete(&preset_scan_id, complete);
2160 let _ = db.set_pdf_scan_complete(&pdf_scan_id, complete);
2161 db.checkpoint();
2162
2163 let finished_at = history::now_iso();
2164 if stopped {
2165 let _ = db.unified_scan_run_finish(&finished_at, "stopped", None, None);
2166 } else {
2167 let _ = db.unified_scan_run_finish(&finished_at, "complete", None, None);
2168 }
2169
2170 serde_json::json!({
2171 "audioCount": audio_count,
2172 "dawCount": daw_count,
2173 "presetCount": preset_count,
2174 "pdfCount": pdf_count,
2175 "audioRoots": audio_roots_strs,
2176 "dawRoots": daw_roots_strs,
2177 "presetRoots": preset_roots_strs,
2178 "pdfRoots": pdf_roots_strs,
2179 "audioScanId": audio_scan_id,
2180 "dawScanId": daw_scan_id,
2181 "presetScanId": preset_scan_id,
2182 "pdfScanId": pdf_scan_id,
2183 "unifiedRunId": unified_run_id,
2184 "stopped": stopped,
2185 "streamed": true,
2186 })
2187 }));
2188
2189 match closure_result {
2190 Ok(v) => Ok(v),
2191 Err(_) => {
2192 let _ = db.unified_scan_run_finish(
2193 &history::now_iso(),
2194 "error",
2195 Some("panic"),
2196 None,
2197 );
2198 Err("unified scan panicked".into())
2199 }
2200 }
2201 })
2202 .await;
2203
2204 let result: Result<serde_json::Value, String> = match result {
2205 Ok(inner) => inner,
2206 Err(e) => Err(e.to_string()),
2207 };
2208
2209 audio_state.scanning.store(false, Ordering::SeqCst);
2210 daw_state.scanning.store(false, Ordering::SeqCst);
2211 preset_state.scanning.store(false, Ordering::SeqCst);
2212 pdf_state.scanning.store(false, Ordering::SeqCst);
2213 app.state::<WalkerStatus>()
2214 .unified_scanning
2215 .store(false, Ordering::SeqCst);
2216
2217 let elapsed = scan_start.elapsed();
2218 match &result {
2219 Ok(v) => append_log(format!(
2220 "SCAN END — unified | {}s | audio:{} daw:{} preset:{} pdf:{}",
2221 elapsed.as_secs(),
2222 v.get("audioCount").and_then(|x| x.as_u64()).unwrap_or(0),
2223 v.get("dawCount").and_then(|x| x.as_u64()).unwrap_or(0),
2224 v.get("presetCount").and_then(|x| x.as_u64()).unwrap_or(0),
2225 v.get("pdfCount").and_then(|x| x.as_u64()).unwrap_or(0),
2226 )),
2227 Err(e) => append_log(format!(
2228 "SCAN ERROR — unified | {}s | {}",
2229 elapsed.as_secs(),
2230 e
2231 )),
2232 }
2233 result
2234}
2235
2236#[tauri::command]
2237async fn get_unified_scan_run() -> Result<db::UnifiedScanRunRow, String> {
2238 blocking_res(|| db::global().get_unified_scan_run()).await
2239}
2240
2241#[tauri::command]
2246async fn prepare_unified_scan(app: AppHandle) -> Result<(), String> {
2247 app.state::<AudioScanState>()
2248 .stop_scan
2249 .store(false, Ordering::SeqCst);
2250 app.state::<DawScanState>()
2251 .stop_scan
2252 .store(false, Ordering::SeqCst);
2253 app.state::<PresetScanState>()
2254 .stop_scan
2255 .store(false, Ordering::SeqCst);
2256 app.state::<PdfScanState>()
2257 .stop_scan
2258 .store(false, Ordering::SeqCst);
2259 Ok(())
2260}
2261
2262#[tauri::command]
2265async fn stop_unified_scan(app: AppHandle) -> Result<(), String> {
2266 append_log("SCAN STOP — unified (user requested)".into());
2267 app.state::<AudioScanState>()
2268 .stop_scan
2269 .store(true, Ordering::SeqCst);
2270 app.state::<DawScanState>()
2271 .stop_scan
2272 .store(true, Ordering::SeqCst);
2273 app.state::<PresetScanState>()
2274 .stop_scan
2275 .store(true, Ordering::SeqCst);
2276 app.state::<PdfScanState>()
2277 .stop_scan
2278 .store(true, Ordering::SeqCst);
2279 Ok(())
2280}
2281
2282#[tauri::command]
2283async fn pdf_history_save(
2284 pdfs: Vec<PdfFile>,
2285 roots: Option<Vec<String>>,
2286) -> Result<history::PdfScanSnapshot, String> {
2287 let roots = roots.unwrap_or_default();
2288 blocking_res(move || {
2289 let snap = history::build_pdf_snapshot(&pdfs, &roots);
2290 db::global().save_pdf_scan(&snap)?;
2291 db::global().checkpoint();
2292 Ok(snap)
2293 })
2294 .await
2295}
2296
2297#[tauri::command]
2298async fn pdf_history_get_scans() -> Result<Vec<serde_json::Value>, String> {
2299 blocking_res(|| db::global().get_pdf_scans()).await
2300}
2301
2302#[tauri::command]
2303async fn pdf_history_get_detail(id: String) -> Result<history::PdfScanSnapshot, String> {
2304 blocking_res(move || db::global().get_pdf_scan_detail(&id)).await
2305}
2306
2307#[tauri::command]
2308async fn pdf_history_delete(id: String) -> Result<(), String> {
2309 blocking_res(move || db::global().delete_pdf_scan(&id)).await
2310}
2311
2312#[tauri::command]
2313async fn pdf_history_clear() -> Result<(), String> {
2314 #[cfg(not(test))]
2315 append_log("HISTORY CLEAR — pdfs".into());
2316 blocking_res(|| db::global().clear_pdf_history()).await
2317}
2318
2319#[tauri::command]
2320async fn pdf_history_latest() -> Result<Option<history::PdfScanSnapshot>, String> {
2321 blocking_res(|| db::global().get_latest_pdf_scan()).await
2322}
2323
2324#[tauri::command]
2325async fn pdf_history_diff(old_id: String, new_id: String) -> Option<history::PdfScanDiff> {
2326 tokio::task::spawn_blocking(move || {
2327 let old = db::global().get_pdf_scan_detail(&old_id).ok()?;
2328 let new = db::global().get_pdf_scan_detail(&new_id).ok()?;
2329 Some(history::compute_pdf_diff(&old, &new))
2330 })
2331 .await
2332 .ok()
2333 .flatten()
2334}
2335
2336#[tauri::command]
2337async fn open_pdf_file(file_path: String) -> Result<(), String> {
2338 open_plugin_folder(file_path).await
2339}
2340
2341#[tauri::command]
2342async fn pdf_metadata_get(paths: Vec<String>) -> Result<serde_json::Value, String> {
2343 tokio::task::spawn_blocking(move || {
2344 let map = db::global().get_pdf_metadata(&paths)?;
2345 let mut out = serde_json::Map::new();
2346 for (k, v) in map {
2347 out.insert(
2348 k,
2349 match v {
2350 Some(n) => serde_json::json!(n),
2351 None => serde_json::Value::Null,
2352 },
2353 );
2354 }
2355 Ok::<serde_json::Value, String>(serde_json::Value::Object(out))
2356 })
2357 .await
2358 .map_err(|e| e.to_string())?
2359}
2360
2361#[tauri::command]
2362fn pdf_metadata_extract_abort() {
2363 PDF_META_EXTRACT_ABORT.store(true, Ordering::Relaxed);
2364}
2365
2366#[tauri::command]
2367async fn pdf_metadata_extract_batch(
2368 app: AppHandle,
2369 paths: Vec<String>,
2370) -> Result<serde_json::Value, String> {
2371 tokio::task::spawn_blocking(move || {
2372 PDF_META_EXTRACT_ABORT.store(false, Ordering::Relaxed);
2373 let total = paths.len();
2374 if total == 0 {
2375 return serde_json::json!({ "extracted": 0, "total": 0, "aborted": false });
2376 }
2377 let _ = app.emit(
2378 "pdf-metadata-progress",
2379 serde_json::json!({ "phase": "start", "total": total }),
2380 );
2381 const CHUNK: usize = 100;
2383 let mut done = 0usize;
2384 let mut extracted = 0usize;
2385 for chunk in paths.chunks(CHUNK) {
2386 if PDF_META_EXTRACT_ABORT.load(Ordering::Relaxed) {
2387 let _ = app.emit(
2388 "pdf-metadata-progress",
2389 serde_json::json!({
2390 "phase": "aborted", "done": done, "total": total, "extracted": extracted
2391 }),
2392 );
2393 let _ = app.emit(
2394 "pdf-metadata-progress",
2395 serde_json::json!({
2396 "phase": "done", "extracted": extracted, "total": total, "aborted": true
2397 }),
2398 );
2399 return serde_json::json!({ "extracted": extracted, "total": total, "aborted": true });
2400 }
2401 let pairs = pdf_meta::extract_pages_batch(chunk);
2402 let pairs_map: std::collections::HashMap<&String, u32> =
2404 pairs.iter().map(|(p, n)| (p, *n)).collect();
2405 let mut rows: Vec<(String, Option<u32>)> = Vec::with_capacity(chunk.len());
2406 for p in chunk {
2407 rows.push((p.clone(), pairs_map.get(p).copied()));
2408 }
2409 let _ = db::global().save_pdf_metadata(&rows);
2410 extracted += pairs.len();
2411 done += chunk.len();
2412 let _ = app.emit(
2413 "pdf-metadata-progress",
2414 serde_json::json!({
2415 "phase": "progress", "done": done, "total": total, "extracted": extracted
2416 }),
2417 );
2418 }
2419 let _ = app.emit(
2420 "pdf-metadata-progress",
2421 serde_json::json!({ "phase": "done", "extracted": extracted, "total": total, "aborted": false }),
2422 );
2423 serde_json::json!({ "extracted": extracted, "total": total, "aborted": false })
2424 })
2425 .await
2426 .map_err(|e| e.to_string())
2427}
2428
2429#[tauri::command]
2432async fn pdf_metadata_unindexed(limit: Option<u64>) -> Result<Vec<String>, String> {
2433 let lim = limit.unwrap_or(100000);
2434 blocking_res(move || db::global().unindexed_pdf_paths(lim)).await
2435}
2436
2437#[tauri::command]
2438async fn open_preset_folder(file_path: String) -> Result<(), String> {
2439 open_plugin_folder(file_path).await
2440}
2441
2442#[tauri::command]
2443async fn open_daw_folder(file_path: String) -> Result<(), String> {
2444 open_plugin_folder(file_path).await
2445}
2446
2447#[tauri::command]
2448async fn open_daw_project(file_path: String) -> Result<(), String> {
2449 let path = std::path::Path::new(&file_path);
2450 if !path.exists() {
2451 return Err(format!("File not found: {}", file_path));
2452 }
2453
2454 #[cfg(target_os = "macos")]
2455 {
2456 let output = std::process::Command::new("open")
2457 .arg(&file_path)
2458 .output()
2459 .map_err(|e| e.to_string())?;
2460 if !output.status.success() {
2461 let stderr = String::from_utf8_lossy(&output.stderr);
2462 if stderr.contains("No application can open") || stderr.contains("no application set") {
2463 return Err("No application installed to open this project file".to_string());
2464 }
2465 return Err(format!("Failed to open project: {}", stderr.trim()));
2466 }
2467 }
2468
2469 #[cfg(target_os = "windows")]
2470 {
2471 let output = std::process::Command::new("cmd")
2472 .args(["/C", "start", "", &file_path])
2473 .output()
2474 .map_err(|e| e.to_string())?;
2475 if !output.status.success() {
2476 return Err("No application installed to open this project file".to_string());
2477 }
2478 }
2479
2480 #[cfg(target_os = "linux")]
2481 {
2482 let output = std::process::Command::new("xdg-open")
2483 .arg(&file_path)
2484 .output()
2485 .map_err(|e| format!("No application installed to open this project file: {}", e))?;
2486 if !output.status.success() {
2487 return Err("No application installed to open this project file".to_string());
2488 }
2489 }
2490
2491 Ok(())
2492}
2493
2494#[tauri::command]
2495async fn extract_project_plugins(file_path: String) -> Result<Vec<xref::PluginRef>, String> {
2496 let mut result = xref::extract_plugins(&file_path);
2497 if result.iter().any(|p| p.manufacturer.is_empty()) {
2499 if let Ok(all) = db::global().query_plugins(None, None, None, "name", true, false, 0, 100000) {
2500 let mfg_map: std::collections::HashMap<String, String> = all
2501 .plugins
2502 .iter()
2503 .filter(|p| !p.manufacturer.is_empty())
2504 .map(|p| (p.name.to_lowercase(), p.manufacturer.clone()))
2505 .collect();
2506 for p in &mut result {
2507 if p.manufacturer.is_empty() {
2508 if let Some(mfg) = mfg_map.get(&p.name.to_lowercase()) {
2509 p.manufacturer = mfg.clone();
2510 }
2511 }
2512 }
2513 }
2514 }
2515 #[cfg(not(test))]
2516 append_log(format!(
2517 "XREF EXTRACT — {} | {} plugins found",
2518 file_path,
2519 result.len()
2520 ));
2521 Ok(result)
2522}
2523
2524fn read_als_xml_impl(file_path: &str) -> Result<String, String> {
2525 use flate2::read::GzDecoder;
2526 use std::io::Read;
2527 let data = std::fs::read(file_path).map_err(|e| e.to_string())?;
2528 let mut decoder = GzDecoder::new(&data[..]);
2529 const MAX_XML_SIZE: usize = 20_000_000; let mut xml = String::new();
2531 decoder
2532 .read_to_string(&mut xml)
2533 .map_err(|e| format!("Not a valid gzip file: {}", e))?;
2534 if xml.len() > MAX_XML_SIZE {
2535 xml.truncate(MAX_XML_SIZE);
2536 xml.push_str("\n<!-- TRUNCATED: file too large for viewer -->");
2537 }
2538 Ok(xml)
2539}
2540
2541#[tauri::command]
2542async fn read_als_xml(file_path: String) -> Result<String, String> {
2543 blocking_res(move || read_als_xml_impl(&file_path)).await
2544}
2545
2546#[tauri::command]
2547async fn estimate_bpm(file_path: String) -> Result<Option<f64>, String> {
2548 Ok(bpm::estimate_bpm(&file_path))
2549}
2550
2551#[tauri::command]
2552async fn detect_audio_key(file_path: String) -> Result<Option<String>, String> {
2553 tokio::task::spawn_blocking(move || key_detect::detect_key(&file_path))
2554 .await
2555 .map_err(|e| e.to_string())
2556}
2557
2558#[tauri::command]
2559async fn measure_lufs(file_path: String) -> Result<Option<f64>, String> {
2560 tokio::task::spawn_blocking(move || lufs::measure_lufs(&file_path))
2561 .await
2562 .map_err(|e| e.to_string())
2563}
2564
2565#[tauri::command]
2573async fn batch_analyze(paths: Vec<String>) -> Result<serde_json::Value, String> {
2574 let inner = tokio::task::spawn_blocking(move || {
2575 use rayon::prelude::*;
2576 if paths.is_empty() {
2577 return Ok(serde_json::json!({ "count": 0, "results": [] }));
2578 }
2579 const MAX_BATCH_ANALYSIS_THREADS: usize = 4;
2580 let num_threads = std::cmp::min(paths.len(), MAX_BATCH_ANALYSIS_THREADS).max(1);
2581 let pool = rayon::ThreadPoolBuilder::new()
2582 .num_threads(num_threads)
2583 .build()
2584 .expect("batch_analyze rayon pool");
2585 let results: Vec<db::AnalysisBatchRow> = pool.install(|| {
2586 paths
2587 .par_iter()
2588 .map(|path| {
2589 let bpm_val = bpm::estimate_bpm(path);
2590 let key_val = key_detect::detect_key(path);
2591 let lufs_val = lufs::measure_lufs(path);
2592 (path.clone(), bpm_val, key_val, lufs_val)
2593 })
2594 .collect()
2595 });
2596 let count = db::global().batch_update_analysis(&results)?;
2598 let items: Vec<serde_json::Value> = results
2600 .iter()
2601 .map(|(path, bpm, key, lufs)| {
2602 let bpm_exhausted = bpm.is_none() && key.is_some() && lufs.is_some();
2603 serde_json::json!({
2604 "path": path,
2605 "bpm": bpm,
2606 "key": key,
2607 "lufs": lufs,
2608 "bpmExhausted": bpm_exhausted,
2609 })
2610 })
2611 .collect();
2612 Ok(serde_json::json!({ "count": count, "results": items }))
2613 })
2614 .await
2615 .map_err(|e| e.to_string())?;
2616 inner
2617}
2618
2619#[tauri::command]
2620async fn compute_fingerprint(
2621 file_path: String,
2622) -> Result<Option<similarity::AudioFingerprint>, String> {
2623 tokio::task::spawn_blocking(move || similarity::compute_fingerprint(&file_path))
2624 .await
2625 .map_err(|e| e.to_string())
2626}
2627
2628#[tauri::command]
2629async fn build_fingerprint_cache(
2630 app: AppHandle,
2631 candidate_paths: Vec<String>,
2632) -> Result<serde_json::Value, String> {
2633 tokio::task::spawn_blocking(move || {
2634 let fp_json = db::global()
2635 .read_cache("fingerprint-cache.json")
2636 .unwrap_or_default();
2637 let raw: HashMap<String, similarity::AudioFingerprint> =
2638 serde_json::from_value(fp_json).unwrap_or_default();
2639 let mut cache = normalize_fingerprint_cache_map(raw);
2640 use rayon::prelude::*;
2641 let uncached: Vec<&String> = candidate_paths
2642 .iter()
2643 .filter(|p| !cache.contains_key(&normalize_path_for_db(p.as_str())))
2644 .collect();
2645 let total = uncached.len();
2646 if total == 0 {
2647 return serde_json::json!({ "built": 0, "cached": cache.len() });
2648 }
2649 let _ = app.emit(
2650 "fingerprint-build-progress",
2651 serde_json::json!({
2652 "phase": "start", "total": total, "cached": cache.len()
2653 }),
2654 );
2655 const CHUNK: usize = 500;
2656 let mut done = 0usize;
2657 for chunk in uncached.chunks(CHUNK) {
2658 let new_fps: Vec<similarity::AudioFingerprint> = chunk
2659 .par_iter()
2660 .filter_map(|p| similarity::compute_fingerprint(p))
2661 .collect();
2662 for mut fp in new_fps {
2663 let k = normalize_path_for_db(&fp.path);
2664 fp.path = k.clone();
2665 cache.insert(k, fp);
2666 }
2667 done += chunk.len();
2668 let _ = app.emit(
2669 "fingerprint-build-progress",
2670 serde_json::json!({
2671 "phase": "progress", "done": done, "total": total
2672 }),
2673 );
2674 if let Ok(val) = serde_json::to_value(&cache) {
2675 let _ = db::global().write_cache("fingerprint-cache.json", &val);
2676 }
2677 }
2678 let _ = app.emit(
2679 "fingerprint-build-progress",
2680 serde_json::json!({ "phase": "done", "built": done, "cached": cache.len() }),
2681 );
2682 serde_json::json!({ "built": done, "cached": cache.len() })
2683 })
2684 .await
2685 .map_err(|e| e.to_string())
2686}
2687
2688#[tauri::command]
2689async fn find_similar_samples(
2690 app: AppHandle,
2691 file_path: String,
2692 candidate_paths: Vec<String>,
2693 max_results: usize,
2694) -> Result<Vec<serde_json::Value>, String> {
2695 tokio::task::spawn_blocking(move || {
2696 let fp_json = db::global()
2698 .read_cache("fingerprint-cache.json")
2699 .unwrap_or_default();
2700 let raw: HashMap<String, similarity::AudioFingerprint> =
2701 serde_json::from_value(fp_json).unwrap_or_default();
2702 let mut cache = normalize_fingerprint_cache_map(raw);
2703
2704 let file_key = normalize_path_for_db(&file_path);
2705 let reference = if let Some(fp) = cache.get(&file_key) {
2707 fp.clone()
2708 } else {
2709 match similarity::compute_fingerprint(&file_path) {
2710 Some(mut fp) => {
2711 let k = normalize_path_for_db(&fp.path);
2712 fp.path = k.clone();
2713 cache.insert(k.clone(), fp.clone());
2714 fp
2715 }
2716 None => return vec![],
2717 }
2718 };
2719
2720 use rayon::prelude::*;
2722 let uncached: Vec<&String> = candidate_paths
2723 .iter()
2724 .filter(|p| !cache.contains_key(&normalize_path_for_db(p.as_str())))
2725 .collect();
2726
2727 if !uncached.is_empty() {
2728 let uncached_count = uncached.len();
2730 let candidate_count = candidate_paths.len();
2731 let cached_count = candidate_count.saturating_sub(uncached_count);
2732 let _ = app.emit(
2733 "similarity-progress",
2734 serde_json::json!({
2735 "phase": "computing",
2736 "candidate_count": candidate_count,
2737 "uncached_count": uncached_count,
2738 "cached_count": cached_count,
2739 "total": uncached_count,
2740 "cached": cached_count
2741 }),
2742 );
2743
2744 let new_fps: Vec<similarity::AudioFingerprint> = uncached
2745 .par_iter()
2746 .filter_map(|p| similarity::compute_fingerprint(p))
2747 .collect();
2748
2749 for mut fp in new_fps {
2750 let k = normalize_path_for_db(&fp.path);
2751 fp.path = k.clone();
2752 cache.insert(k, fp);
2753 }
2754
2755 if let Ok(val) = serde_json::to_value(&cache) {
2757 let _ = db::global().write_cache("fingerprint-cache.json", &val);
2758 }
2759 }
2760
2761 let candidates: Vec<similarity::AudioFingerprint> = candidate_paths
2763 .iter()
2764 .filter_map(|p| cache.get(&normalize_path_for_db(p.as_str())).cloned())
2765 .collect();
2766
2767 similarity::find_similar(&reference, &candidates, max_results)
2768 .into_iter()
2769 .map(|(path, distance)| {
2770 serde_json::json!({
2771 "path": path,
2772 "distance": distance,
2773 "similarity": (1.0 - distance.min(1.0)) * 100.0
2774 })
2775 })
2776 .collect()
2777 })
2778 .await
2779 .map_err(|e| e.to_string())
2780}
2781
2782#[tauri::command]
2784async fn find_content_duplicates(app: AppHandle) -> Result<serde_json::Value, String> {
2785 let app_pb = Arc::new(app);
2786 tokio::task::spawn_blocking(move || {
2787 let entries = db::global().library_paths_for_content_hash()?;
2788 let progress = Some((app_pb, 25usize));
2789 let r = content_hash::find_byte_duplicate_groups(entries, progress);
2790 serde_json::to_value(&r).map_err(|e| e.to_string())
2791 })
2792 .await
2793 .map_err(|e| e.to_string())?
2794}
2795
2796#[tauri::command]
2797async fn open_file_default(file_path: String) -> Result<(), String> {
2798 blocking_res(move || {
2799 let path = std::path::Path::new(&file_path);
2800 if !path.exists() {
2801 return Err(format!("File not found: {}", file_path));
2802 }
2803 #[cfg(target_os = "macos")]
2804 {
2805 let output = std::process::Command::new("open")
2806 .arg(&file_path)
2807 .output()
2808 .map_err(|e| e.to_string())?;
2809 if !output.status.success() {
2810 let stderr = String::from_utf8_lossy(&output.stderr);
2811 return Err(format!("open failed: {}", stderr.trim()));
2812 }
2813 }
2814 #[cfg(target_os = "windows")]
2815 {
2816 let output = std::process::Command::new("cmd")
2817 .args(["/C", "start", "", &file_path])
2818 .output()
2819 .map_err(|e| e.to_string())?;
2820 if !output.status.success() {
2821 return Err("start failed".into());
2822 }
2823 }
2824 #[cfg(target_os = "linux")]
2825 {
2826 let output = std::process::Command::new("xdg-open")
2827 .arg(&file_path)
2828 .output()
2829 .map_err(|e| e.to_string())?;
2830 if !output.status.success() {
2831 return Err("xdg-open failed".into());
2832 }
2833 }
2834 Ok(())
2835 })
2836 .await
2837}
2838
2839#[tauri::command]
2840async fn open_with_app(file_path: String, app_name: String) -> Result<(), String> {
2841 blocking_res(move || {
2842 let path = std::path::Path::new(&file_path);
2843 open_with_app::open_with_application(path, &app_name)
2844 })
2845 .await
2846}
2847
2848#[tauri::command]
2849async fn open_update_url(url: String) -> Result<(), String> {
2850 opener::open(&url).map_err(|e| e.to_string())
2851}
2852
2853#[tauri::command]
2854async fn open_plugin_folder(plugin_path: String) -> Result<(), String> {
2855 #[cfg(target_os = "macos")]
2856 {
2857 let plugin_path_owned = plugin_path.clone();
2870 std::thread::spawn(move || {
2871 let raw = plugin_path_owned.trim();
2872 let p = std::path::Path::new(raw);
2873 let target = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
2874 if target.is_file() {
2875 let _ = std::process::Command::new("open")
2876 .arg("-R")
2877 .arg(&target)
2878 .spawn();
2879 } else if target.is_dir() {
2880 let _ = std::process::Command::new("open").arg(&target).spawn();
2881 } else if let Some(parent) = p.parent() {
2882 if !parent.as_os_str().is_empty() {
2883 let pp = parent.canonicalize().unwrap_or_else(|_| parent.to_path_buf());
2884 let _ = std::process::Command::new("open").arg(&pp).spawn();
2885 }
2886 }
2887 });
2888 }
2889 #[cfg(target_os = "windows")]
2890 {
2891 std::process::Command::new("explorer")
2892 .arg(format!("/select,{}", plugin_path))
2893 .spawn()
2894 .map_err(|e| e.to_string())?;
2895 }
2896 #[cfg(target_os = "linux")]
2897 {
2898 let parent = std::path::Path::new(&plugin_path)
2899 .parent()
2900 .map(|p| p.to_string_lossy().to_string())
2901 .unwrap_or_default();
2902 opener::open(&parent).map_err(|e| e.to_string())?;
2903 }
2904 Ok(())
2905}
2906
2907#[tauri::command]
2908async fn open_audio_folder(file_path: String) -> Result<(), String> {
2909 open_plugin_folder(file_path).await
2910}
2911
2912#[tauri::command]
2915async fn prefs_get_all() -> history::PrefsMap {
2916 blocking(|| history::load_preferences())
2917 .await
2918 .unwrap_or_default()
2919}
2920
2921#[tauri::command]
2922async fn prefs_set(app: AppHandle, key: String, value: serde_json::Value) {
2923 let refresh_log = key == "logVerbosity";
2924 let tray_theme = if key == "theme" {
2925 let s = match &value {
2926 serde_json::Value::String(t) => t.as_str(),
2927 _ => "",
2928 };
2929 Some(if s == "light" {
2930 "light".to_string()
2931 } else {
2932 "dark".to_string()
2933 })
2934 } else {
2935 None
2936 };
2937 let _ = blocking_res(move || {
2938 history::set_preference(&key, value);
2939 Ok(())
2940 })
2941 .await;
2942 if refresh_log {
2943 refresh_log_verbosity_from_prefs();
2944 }
2945 if let Some(ref t) = tray_theme {
2946 tray_menu::emit_tray_popover_ui_theme(&app, t);
2947 }
2948}
2949
2950#[tauri::command]
2951async fn prefs_remove(key: String) {
2952 let _ = blocking_res(move || {
2953 history::remove_preference(&key);
2954 Ok(())
2955 })
2956 .await;
2957}
2958
2959#[tauri::command]
2960async fn prefs_save_all(prefs: history::PrefsMap) {
2961 let _ = blocking_res(move || {
2962 history::save_preferences(&prefs);
2963 Ok(())
2964 })
2965 .await;
2966 refresh_log_verbosity_from_prefs();
2967}
2968
2969#[tauri::command]
2970async fn open_prefs_file() -> Result<(), String> {
2971 let path = history::get_preferences_path();
2972 opener::open(&path).map_err(|e| e.to_string())
2973}
2974
2975#[tauri::command]
2976async fn get_prefs_path() -> String {
2977 blocking(|| {
2978 history::get_preferences_path()
2979 .to_string_lossy()
2980 .to_string()
2981 })
2982 .await
2983 .unwrap_or_default()
2984}
2985
2986#[tauri::command]
2988async fn read_cache_file(name: String) -> Result<serde_json::Value, String> {
2989 blocking_res(move || db::global().read_cache(&name)).await
2990}
2991
2992#[tauri::command]
2993async fn write_cache_file(name: String, data: serde_json::Value) -> Result<(), String> {
2994 blocking_res(move || db::global().write_cache(&name, &data)).await
2995}
2996
2997#[tauri::command]
2998async fn audio_engine_invoke(request: serde_json::Value) -> Result<serde_json::Value, String> {
2999 let payload = audio_engine::normalize_ipc_request_payload(&request);
3000 let v = tokio::task::spawn_blocking({
3001 let payload = payload.clone();
3002 move || audio_engine::spawn_audio_engine_request(&payload)
3003 })
3004 .await
3005 .map_err(|e| format!("audio-engine spawn_blocking: {e}"))??;
3006 if v.get("ok") == Some(&serde_json::Value::Bool(false)) {
3007 let cmd = payload
3008 .get("cmd")
3009 .and_then(|c| c.as_str())
3010 .unwrap_or("?");
3011 let err = v
3012 .get("error")
3013 .and_then(|e| e.as_str())
3014 .unwrap_or("?");
3015 write_app_log(format!("audio-engine [{cmd}] {err}"));
3016 }
3017 Ok(v)
3018}
3019
3020#[tauri::command]
3021fn audio_engine_restart() -> Result<(), String> {
3022 audio_engine::restart_audio_engine_child()
3023}
3024
3025#[tauri::command]
3026fn audio_engine_eof_watchdog_start(app: AppHandle) -> Result<(), String> {
3027 audio_engine::audio_engine_eof_watchdog_start(app);
3028 Ok(())
3029}
3030
3031#[tauri::command]
3032fn audio_engine_eof_watchdog_stop() -> Result<(), String> {
3033 audio_engine::audio_engine_eof_watchdog_stop();
3034 Ok(())
3035}
3036
3037#[tauri::command]
3038fn append_log(msg: String) {
3039 write_app_log(msg);
3040}
3041
3042fn write_app_log_line(msg: &str) {
3043 let path = history::get_data_dir().join("app.log");
3044 if let Some(parent) = path.parent() {
3046 let _ = std::fs::create_dir_all(parent);
3047 }
3048 const MAX_LOG_SIZE: u64 = 5 * 1024 * 1024;
3050 if let Ok(meta) = std::fs::metadata(&path) {
3051 if meta.len() > MAX_LOG_SIZE {
3052 let backup = path.with_extension("log.1");
3053 let _ = std::fs::remove_file(&backup);
3054 if std::fs::rename(&path, &backup).is_err() {
3055 let _ = std::fs::write(&path, "");
3056 }
3057 }
3058 }
3059 let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
3060 let line = format!("[{}] {}\n", timestamp, msg);
3061 let _ = std::fs::OpenOptions::new()
3062 .create(true)
3063 .append(true)
3064 .open(&path)
3065 .and_then(|mut f| {
3066 use std::io::Write;
3067 f.write_all(line.as_bytes())
3068 });
3069}
3070
3071pub fn app_log_verbose<F: FnOnce() -> String>(f: F) {
3073 if LOG_VERBOSITY_LEVEL.load(Ordering::Relaxed) < 2 {
3074 return;
3075 }
3076 write_app_log_line(&f());
3077}
3078
3079pub fn write_app_log_verbose(msg: String) {
3081 if LOG_VERBOSITY_LEVEL.load(Ordering::Relaxed) < 2 {
3082 return;
3083 }
3084 write_app_log_line(&msg);
3085}
3086
3087pub fn write_app_log(msg: String) {
3091 if should_suppress_app_log_line(&msg) {
3092 return;
3093 }
3094 write_app_log_line(&msg);
3095}
3096
3097#[tauri::command]
3098async fn read_log() -> Result<String, String> {
3099 blocking_res(|| {
3100 let path = history::get_data_dir().join("app.log");
3101 match std::fs::read_to_string(&path) {
3102 Ok(s) => Ok(s),
3103 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
3104 Err(e) => Err(e.to_string()),
3105 }
3106 })
3107 .await
3108}
3109
3110#[tauri::command]
3111async fn clear_log() -> Result<(), String> {
3112 blocking_res(|| {
3113 let path = history::ensure_data_dir().join("app.log");
3114 std::fs::write(&path, "").map_err(|e| e.to_string())
3115 })
3116 .await
3117}
3118
3119#[tauri::command]
3122async fn read_project_file(file_path: String) -> Result<serde_json::Value, String> {
3123 blocking_res(move || {
3124 let path = std::path::Path::new(&file_path);
3125 let ext = path
3126 .extension()
3127 .and_then(|e| e.to_str())
3128 .unwrap_or("")
3129 .to_lowercase();
3130 match ext.as_str() {
3131 "als" => {
3132 let xml = read_als_xml_impl(&file_path)?;
3133 Ok(serde_json::json!({"type": "xml", "format": "Ableton Live Set", "content": xml, "path": file_path}))
3134 }
3135 "song" => {
3136 let xml = read_zip_xml(&file_path, &["song.xml", "Song/song.xml", "metainfo.xml"])?;
3137 Ok(serde_json::json!({"type": "xml", "format": "Studio One Song", "content": xml, "path": file_path}))
3138 }
3139 "dawproject" => {
3140 let xml = read_zip_xml(&file_path, &["project.xml", "metadata.xml"])?;
3141 Ok(serde_json::json!({"type": "xml", "format": "DAWproject", "content": xml, "path": file_path}))
3142 }
3143 "rpp" | "rpp-bak" => {
3144 let content = std::fs::read_to_string(&file_path).map_err(|e| e.to_string())?;
3145 Ok(serde_json::json!({"type": "text", "format": "REAPER Project", "content": content, "path": file_path}))
3146 }
3147 _ => read_binary_project(file_path, &ext),
3148 }
3149 })
3150 .await
3151}
3152
3153fn read_zip_xml(file_path: &str, names: &[&str]) -> Result<String, String> {
3155 use std::io::Read;
3156 let file = std::fs::File::open(file_path).map_err(|e| e.to_string())?;
3157 let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Not a valid ZIP: {e}"))?;
3158 for name in names {
3159 if let Ok(mut entry) = archive.by_name(name) {
3160 let mut s = String::new();
3161 entry.read_to_string(&mut s).map_err(|e| e.to_string())?;
3162 if !s.is_empty() {
3163 return Ok(s);
3164 }
3165 }
3166 }
3167 let mut xml_name = None;
3169 for i in 0..archive.len() {
3170 if let Ok(entry) = archive.by_index(i) {
3171 if entry.name().ends_with(".xml") {
3172 xml_name = Some(entry.name().to_string());
3173 break;
3174 }
3175 }
3176 }
3177 if let Some(name) = xml_name {
3178 let mut entry = archive.by_name(&name).map_err(|e| e.to_string())?;
3179 let mut s = String::new();
3180 entry.read_to_string(&mut s).map_err(|e| e.to_string())?;
3181 return Ok(s);
3182 }
3183 Err("No XML found in archive".into())
3184}
3185
3186fn read_binary_project(file_path: String, ext: &str) -> Result<serde_json::Value, String> {
3188 let format_name = match ext {
3189 "bwproject" => "Bitwig Studio Project (.bwproject)",
3190 "flp" => "FL Studio Project (.flp)",
3191 "logicx" => "Logic Pro Project (.logicx)",
3192 "cpr" => "Cubase Project (.cpr)",
3193 "npr" => "Nuendo Project (.npr)",
3194 "ptx" => "Pro Tools Session (.ptx)",
3195 "ptf" => "Pro Tools Session (.ptf)",
3196 "reason" => "Reason Song (.reason)",
3197 "band" => "GarageBand Project (.band)",
3198 _ => "Binary DAW Project",
3199 };
3200 let mut result = read_binary_project_inner(&file_path)?;
3201 if let Some(obj) = result.as_object_mut() {
3202 obj.insert(
3203 "_format".into(),
3204 serde_json::Value::String(format_name.into()),
3205 );
3206 }
3207 Ok(result)
3208}
3209
3210fn read_binary_project_inner(file_path: &str) -> Result<serde_json::Value, String> {
3211 let path = std::path::Path::new(file_path);
3212 let data = if path.is_dir() {
3214 let mut buf = Vec::new();
3216 fn collect_dir(dir: &std::path::Path, buf: &mut Vec<u8>, limit: usize) {
3217 if buf.len() > limit {
3218 return;
3219 }
3220 if let Ok(entries) = std::fs::read_dir(dir) {
3221 for entry in entries.flatten() {
3222 let p = entry.path();
3223 if p.is_file() {
3224 if let Ok(data) = std::fs::read(&p) {
3225 buf.extend_from_slice(&data);
3226 if buf.len() > limit {
3227 return;
3228 }
3229 }
3230 } else if p.is_dir() {
3231 collect_dir(&p, buf, limit);
3232 }
3233 }
3234 }
3235 }
3236 collect_dir(path, &mut buf, 50_000_000); buf
3238 } else {
3239 std::fs::read(file_path).map_err(|e| format!("Failed to read: {e}"))?
3240 };
3241
3242 let mut metadata = serde_json::Map::new();
3243 let mut strings_found = Vec::new();
3244 let mut plugins = Vec::new();
3245
3246 let mut i = 0;
3248 while i + 4 < data.len() && i < 10000 {
3249 if data[i] >= 0x20 && data[i] <= 0x7E {
3250 let start = i;
3251 while i < data.len() && data[i] >= 0x20 && data[i] <= 0x7E {
3252 i += 1;
3253 }
3254 if i - start >= 3 {
3255 let s = String::from_utf8_lossy(&data[start..i]).to_string();
3256 strings_found.push(s);
3257 }
3258 } else {
3259 i += 1;
3260 }
3261 }
3262
3263 let meta_keys = [
3264 "album",
3265 "application_version_name",
3266 "artist",
3267 "branch",
3268 "comment",
3269 "copyright",
3270 "creator",
3271 "genre",
3272 "orig_artist",
3273 "producer",
3274 "title",
3275 "version",
3276 ];
3277 let mut idx = 0;
3278 while idx + 1 < strings_found.len() {
3279 let key = &strings_found[idx];
3280 if meta_keys.contains(&key.as_str()) && idx + 1 < strings_found.len() {
3281 let val = &strings_found[idx + 1];
3282 if !val.is_empty() && !meta_keys.contains(&val.as_str()) {
3283 metadata.insert(key.clone(), serde_json::Value::String(val.clone()));
3284 idx += 2;
3285 continue;
3286 }
3287 }
3288 idx += 1;
3289 }
3290
3291 let mut current = Vec::new();
3293 for &byte in &data {
3294 if (0x20..=0x7E).contains(&byte) {
3295 current.push(byte);
3296 } else {
3297 if current.len() >= 6 {
3298 let s = String::from_utf8_lossy(¤t).to_string();
3299 if s.ends_with(".dll")
3300 || s.ends_with(".vst3")
3301 || s.ends_with(".component")
3302 || s.ends_with(".clap")
3303 || s.ends_with(".aaxplugin")
3304 {
3305 plugins.push(s);
3306 }
3307 }
3308 current.clear();
3309 }
3310 }
3311 plugins.sort();
3312 plugins.dedup();
3313
3314 let mut tree = serde_json::Map::new();
3315 tree.insert(
3316 "_path".into(),
3317 serde_json::Value::String(file_path.to_string()),
3318 );
3319 tree.insert(
3320 "_size".into(),
3321 serde_json::Value::String(format_size(data.len() as u64)),
3322 );
3323 tree.insert("metadata".into(), serde_json::Value::Object(metadata));
3324 tree.insert(
3325 "plugins".into(),
3326 serde_json::Value::Array(plugins.into_iter().map(serde_json::Value::String).collect()),
3327 );
3328
3329 let mut fxb_count = 0usize;
3330 for window in data.windows(4) {
3331 if window == b".fxb" {
3332 fxb_count += 1;
3333 }
3334 }
3335 if fxb_count > 0 {
3336 tree.insert(
3337 "pluginStateCount".into(),
3338 serde_json::Value::Number(fxb_count.into()),
3339 );
3340 }
3341
3342 Ok(serde_json::Value::Object(tree))
3343}
3344
3345#[tauri::command]
3346async fn read_bwproject(file_path: String) -> Result<serde_json::Value, String> {
3347 blocking_res(move || read_binary_project(file_path, "bwproject")).await
3348}
3349
3350#[tauri::command]
3353async fn get_midi_info(file_path: String) -> Result<Option<midi::MidiInfo>, String> {
3354 blocking_res(move || Ok(midi::parse_midi(std::path::Path::new(&file_path)))).await
3355}
3356
3357fn plugins_to_export(plugins: &[PluginInfo]) -> Vec<ExportPlugin> {
3360 plugins
3361 .iter()
3362 .map(|p| ExportPlugin {
3363 name: p.name.clone(),
3364 plugin_type: p.plugin_type.clone(),
3365 version: p.version.clone(),
3366 manufacturer: p.manufacturer.clone(),
3367 manufacturer_url: p.manufacturer_url.clone(),
3368 path: p.path.clone(),
3369 size: p.size.clone(),
3370 size_bytes: p.size_bytes,
3371 modified: p.modified.clone(),
3372 architectures: p.architectures.clone(),
3373 })
3374 .collect()
3375}
3376
3377#[tauri::command]
3378async fn export_plugins_json(plugins: Vec<PluginInfo>, file_path: String) -> Result<(), String> {
3379 blocking_res(move || {
3380 #[cfg(not(test))]
3381 append_log(format!(
3382 "EXPORT — {} plugins → {}",
3383 plugins.len(),
3384 file_path
3385 ));
3386 let payload = ExportPayload {
3387 version: env!("CARGO_PKG_VERSION").into(),
3388 exported_at: chrono::Utc::now().to_rfc3339(),
3389 plugins: plugins_to_export(&plugins),
3390 };
3391 let json = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?;
3392 std::fs::write(&file_path, json).map_err(|e| e.to_string())
3393 })
3394 .await
3395}
3396
3397#[tauri::command]
3398async fn export_plugins_csv(plugins: Vec<PluginInfo>, file_path: String) -> Result<(), String> {
3399 blocking_res(move || {
3400 #[cfg(not(test))]
3401 append_log(format!(
3402 "EXPORT — {} plugins → {}",
3403 plugins.len(),
3404 file_path
3405 ));
3406 let sep = detect_separator(&file_path);
3407 let mut out = format!(
3408 "Name{s}Type{s}Version{s}Manufacturer{s}Manufacturer URL{s}Path{s}Size{s}Modified\n",
3409 s = sep
3410 );
3411 for p in &plugins {
3412 out.push_str(&format!(
3413 "{}{sep}{}{sep}{}{sep}{}{sep}{}{sep}{}{sep}{}{sep}{}\n",
3414 dsv_escape(&p.name, sep),
3415 dsv_escape(&p.plugin_type, sep),
3416 dsv_escape(&p.version, sep),
3417 dsv_escape(&p.manufacturer, sep),
3418 dsv_escape(p.manufacturer_url.as_deref().unwrap_or(""), sep),
3419 dsv_escape(&p.path, sep),
3420 dsv_escape(&p.size, sep),
3421 dsv_escape(&p.modified, sep),
3422 ));
3423 }
3424 std::fs::write(&file_path, out).map_err(|e| e.to_string())
3425 })
3426 .await
3427}
3428
3429#[cfg(test)]
3430fn csv_escape(s: &str) -> String {
3431 if s.contains(',') || s.contains('"') || s.contains('\n') {
3432 format!("\"{}\"", s.replace('"', "\"\""))
3433 } else {
3434 s.to_string()
3435 }
3436}
3437
3438fn dsv_escape(s: &str, sep: char) -> String {
3439 if s.contains(sep) || s.contains('"') || s.contains('\n') {
3440 format!("\"{}\"", s.replace('"', "\"\""))
3441 } else {
3442 s.to_string()
3443 }
3444}
3445
3446fn detect_separator(file_path: &str) -> char {
3447 if file_path.ends_with(".tsv") {
3448 '\t'
3449 } else {
3450 ','
3451 }
3452}
3453
3454#[tauri::command]
3457async fn export_audio_json(
3458 samples: Vec<history::AudioSample>,
3459 file_path: String,
3460) -> Result<(), String> {
3461 blocking_res(move || {
3462 let payload = serde_json::json!({
3463 "version": env!("CARGO_PKG_VERSION"),
3464 "exported_at": chrono::Utc::now().to_rfc3339(),
3465 "samples": samples,
3466 });
3467 let json = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?;
3468 std::fs::write(&file_path, json).map_err(|e| e.to_string())
3469 })
3470 .await
3471}
3472
3473#[tauri::command]
3474async fn export_audio_dsv(
3475 samples: Vec<history::AudioSample>,
3476 file_path: String,
3477) -> Result<(), String> {
3478 blocking_res(move || {
3479 let sep = detect_separator(&file_path);
3480 let mut out = format!(
3481 "Name{s}Format{s}Path{s}Directory{s}Size{s}Modified\n",
3482 s = sep
3483 );
3484 for s in &samples {
3485 out.push_str(&format!(
3486 "{}{sep}{}{sep}{}{sep}{}{sep}{}{sep}{}\n",
3487 dsv_escape(&s.name, sep),
3488 dsv_escape(&s.format, sep),
3489 dsv_escape(&s.path, sep),
3490 dsv_escape(&s.directory, sep),
3491 dsv_escape(&s.size_formatted, sep),
3492 dsv_escape(&s.modified, sep),
3493 ));
3494 }
3495 std::fs::write(&file_path, out).map_err(|e| e.to_string())
3496 })
3497 .await
3498}
3499
3500#[tauri::command]
3503async fn export_daw_json(
3504 projects: Vec<history::DawProject>,
3505 file_path: String,
3506) -> Result<(), String> {
3507 blocking_res(move || {
3508 let payload = serde_json::json!({
3509 "version": env!("CARGO_PKG_VERSION"),
3510 "exported_at": chrono::Utc::now().to_rfc3339(),
3511 "projects": projects,
3512 });
3513 let json = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?;
3514 std::fs::write(&file_path, json).map_err(|e| e.to_string())
3515 })
3516 .await
3517}
3518
3519#[tauri::command]
3520async fn export_daw_dsv(
3521 projects: Vec<history::DawProject>,
3522 file_path: String,
3523) -> Result<(), String> {
3524 blocking_res(move || {
3525 let sep = detect_separator(&file_path);
3526 let mut out = format!(
3527 "Name{s}DAW{s}Format{s}Path{s}Directory{s}Size{s}Modified\n",
3528 s = sep
3529 );
3530 for p in &projects {
3531 out.push_str(&format!(
3532 "{}{sep}{}{sep}{}{sep}{}{sep}{}{sep}{}{sep}{}\n",
3533 dsv_escape(&p.name, sep),
3534 dsv_escape(&p.daw, sep),
3535 dsv_escape(&p.format, sep),
3536 dsv_escape(&p.path, sep),
3537 dsv_escape(&p.directory, sep),
3538 dsv_escape(&p.size_formatted, sep),
3539 dsv_escape(&p.modified, sep),
3540 ));
3541 }
3542 std::fs::write(&file_path, out).map_err(|e| e.to_string())
3543 })
3544 .await
3545}
3546
3547#[tauri::command]
3548async fn import_plugins_json(file_path: String) -> Result<Vec<PluginInfo>, String> {
3549 blocking_res(move || {
3550 #[cfg(not(test))]
3551 append_log(format!("IMPORT — plugins ← {}", file_path));
3552 let data = std::fs::read_to_string(&file_path).map_err(|e| e.to_string())?;
3553 let payload: ExportPayload = serde_json::from_str(&data).map_err(|e| e.to_string())?;
3554 Ok(payload
3555 .plugins
3556 .into_iter()
3557 .map(|p| PluginInfo {
3558 name: p.name,
3559 path: p.path,
3560 plugin_type: p.plugin_type,
3561 version: p.version,
3562 manufacturer: p.manufacturer,
3563 manufacturer_url: p.manufacturer_url,
3564 size: p.size,
3565 size_bytes: p.size_bytes,
3566 modified: p.modified,
3567 architectures: p.architectures,
3568 })
3569 .collect())
3570 })
3571 .await
3572}
3573
3574use std::sync::Mutex;
3577use std::time::{Duration, Instant};
3578
3579struct SlowStatsSnapshot {
3581 at: Instant,
3582 dir_key: String,
3583 disk_total: u64,
3584 disk_free: u64,
3585 db_bytes: u64,
3586 prefs_bytes: u64,
3587 table_counts: serde_json::Value,
3588}
3589
3590static SLOW_STATS_CACHE: Mutex<Option<SlowStatsSnapshot>> = Mutex::new(None);
3591const SLOW_STATS_TTL: Duration = Duration::from_secs(4);
3592
3593fn compute_slow_stats(data_dir: &std::path::Path) -> (u64, u64, u64, u64, serde_json::Value) {
3594 let file_size = |name: &str| -> u64 {
3595 std::fs::metadata(data_dir.join(name))
3596 .map(|m| m.len())
3597 .unwrap_or(0)
3598 };
3599 let (disk_total, disk_free) = {
3600 use sysinfo::Disks;
3601 let disks = Disks::new_with_refreshed_list();
3602 let data_str = data_dir.to_string_lossy().to_string();
3603 let data_path = std::path::Path::new(&data_str);
3604 disks
3605 .iter()
3606 .filter(|d| data_path.starts_with(d.mount_point()))
3607 .max_by_key(|d| d.mount_point().as_os_str().len())
3608 .map(|d| (d.total_space(), d.available_space()))
3609 .unwrap_or((0, 0))
3610 };
3611 let db_bytes = file_size("audio_haxor.db")
3612 + file_size("audio_haxor.db-wal")
3613 + file_size("audio_haxor.db-shm");
3614 let prefs_bytes = file_size("preferences.toml");
3615 let table_counts = db::global().table_counts().unwrap_or_default();
3616 (disk_total, disk_free, db_bytes, prefs_bytes, table_counts)
3617}
3618
3619fn cached_slow_stats(data_dir: &std::path::Path) -> (u64, u64, u64, u64, serde_json::Value) {
3620 let now = Instant::now();
3621 let dir_key = data_dir.to_string_lossy().to_string();
3622 if let Ok(guard) = SLOW_STATS_CACHE.lock() {
3623 if let Some(s) = guard.as_ref() {
3624 if s.dir_key == dir_key && now.saturating_duration_since(s.at) < SLOW_STATS_TTL {
3625 return (
3626 s.disk_total,
3627 s.disk_free,
3628 s.db_bytes,
3629 s.prefs_bytes,
3630 s.table_counts.clone(),
3631 );
3632 }
3633 }
3634 }
3635 let (disk_total, disk_free, db_bytes, prefs_bytes, table_counts) = compute_slow_stats(data_dir);
3636 if let Ok(mut guard) = SLOW_STATS_CACHE.lock() {
3637 *guard = Some(SlowStatsSnapshot {
3638 at: now,
3639 dir_key,
3640 disk_total,
3641 disk_free,
3642 db_bytes,
3643 prefs_bytes,
3644 table_counts: table_counts.clone(),
3645 });
3646 }
3647 (disk_total, disk_free, db_bytes, prefs_bytes, table_counts)
3648}
3649
3650fn dotted_extensions_to_upper_tags(exts: &[&str]) -> Vec<String> {
3651 exts.iter()
3652 .map(|e| e.strip_prefix('.').unwrap_or(e).to_ascii_uppercase())
3653 .collect()
3654}
3655
3656fn build_process_stats(app: AppHandle) -> serde_json::Value {
3657 let rss = get_rss_bytes();
3658 let virt = get_virtual_bytes();
3659 let threads = get_thread_count();
3660 let cpu_pct = get_cpu_percent();
3661 let rayon_threads = rayon::current_num_threads();
3662 let uptime_secs = get_uptime_secs();
3663 let pid = std::process::id();
3664 let open_fds = get_open_fd_count();
3665 let ncpus = num_cpus::get();
3666
3667 let scan_state = app.state::<ScanState>();
3669 let update_state = app.state::<UpdateState>();
3670 let audio_state = app.state::<AudioScanState>();
3671 let daw_state = app.state::<DawScanState>();
3672 let preset_state = app.state::<PresetScanState>();
3673 let pdf_state = app.state::<PdfScanState>();
3674 let midi_state = app.state::<MidiScanState>();
3675
3676 let prefs = history::load_preferences();
3678 let thread_mult = prefs
3679 .get("threadMultiplier")
3680 .and_then(|v| {
3681 v.as_str()
3682 .and_then(|s| s.parse::<usize>().ok())
3683 .or(v.as_u64().map(|n| n as usize))
3684 })
3685 .unwrap_or(4);
3686 let batch_size = prefs
3687 .get("batchSize")
3688 .and_then(|v| {
3689 v.as_str()
3690 .and_then(|s| s.parse::<usize>().ok())
3691 .or(v.as_u64().map(|n| n as usize))
3692 })
3693 .unwrap_or(100);
3694 let chan_buf = prefs
3695 .get("channelBuffer")
3696 .and_then(|v| {
3697 v.as_str()
3698 .and_then(|s| s.parse::<usize>().ok())
3699 .or(v.as_u64().map(|n| n as usize))
3700 })
3701 .unwrap_or(512);
3702 let flush_interval = prefs
3703 .get("flushInterval")
3704 .and_then(|v| {
3705 v.as_str()
3706 .and_then(|s| s.parse::<usize>().ok())
3707 .or(v.as_u64().map(|n| n as usize))
3708 })
3709 .unwrap_or(100);
3710 let page_size = prefs
3711 .get("pageSize")
3712 .and_then(|v| {
3713 v.as_str()
3714 .and_then(|s| s.parse::<usize>().ok())
3715 .or(v.as_u64().map(|n| n as usize))
3716 })
3717 .unwrap_or(200);
3718
3719 let sqlite_read_pool_pref = prefs
3720 .get("sqliteReadPoolExtra")
3721 .map(|v| {
3722 v.as_str()
3723 .map(std::string::ToString::to_string)
3724 .unwrap_or_else(|| v.to_string())
3725 })
3726 .unwrap_or_else(|| "auto".to_string());
3727
3728 let (sqlite_read_pool_extra, sqlite_read_pool_total) = if db::global_initialized() {
3729 let db = db::global();
3730 (
3731 db.sqlite_read_pool_extra_slots(),
3732 db.sqlite_read_pool_total_handles(),
3733 )
3734 } else {
3735 (0, 0)
3736 };
3737
3738 let data_dir = history::get_data_dir();
3739 let (disk_total, disk_free, db_bytes, prefs_bytes, db_table_counts) =
3740 cached_slow_stats(&data_dir);
3741
3742 let os_name = std::env::consts::OS;
3744 let os_arch = std::env::consts::ARCH;
3745 let hostname = gethostname();
3746
3747 #[cfg(unix)]
3749 let (fd_soft, fd_hard) = {
3750 let mut rlim = libc::rlimit {
3751 rlim_cur: 0,
3752 rlim_max: 0,
3753 };
3754 if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut rlim) } == 0 {
3755 (rlim.rlim_cur, rlim.rlim_max)
3756 } else {
3757 (0, 0)
3758 }
3759 };
3760 #[cfg(not(unix))]
3761 let (fd_soft, fd_hard) = (0u64, 0u64);
3762
3763 let plugin_formats = ["VST2", "VST3", "AU", "CLAP", "AAX"];
3765 let daw_formats = dotted_extensions_to_upper_tags(crate::daw_scanner::DAW_EXTENSIONS);
3766 let preset_formats = dotted_extensions_to_upper_tags(crate::preset_scanner::PRESET_EXTENSIONS);
3767 let xref_formats = dotted_extensions_to_upper_tags(crate::xref::XREF_SUPPORTED_EXTENSIONS);
3768 let midi_formats = ["MID", "MIDI"];
3769 let pdf_formats = ["PDF"];
3770
3771 serde_json::json!({
3772 "pid": pid,
3773 "rssBytes": rss,
3774 "virtualBytes": virt,
3775 "threads": threads,
3776 "cpuPercent": cpu_pct,
3777 "rayonThreads": rayon_threads,
3778 "numCpus": ncpus,
3779 "uptimeSecs": uptime_secs,
3780 "openFds": open_fds,
3781 "fdSoftLimit": fd_soft,
3782 "fdHardLimit": fd_hard,
3783 "os": os_name,
3784 "arch": os_arch,
3785 "hostname": hostname,
3786 "appVersion": env!("CARGO_PKG_VERSION"),
3787 "tauriVersion": tauri::VERSION,
3788 "rustcTarget": option_env!("AUDIO_HAXOR_TARGET_TRIPLE").unwrap_or("unknown"),
3789 "buildProfile": if cfg!(debug_assertions) { "debug" } else { "release" },
3790 "diskTotalBytes": disk_total,
3791 "diskFreeBytes": disk_free,
3792 "app": {
3793 "audioFormats": crate::audio_extensions::audio_format_tags_for_app_info(),
3794 "pluginFormats": plugin_formats,
3795 "dawFormats": daw_formats,
3796 "presetFormats": preset_formats,
3797 "xrefFormats": xref_formats,
3798 "midiFormats": midi_formats,
3799 "pdfFormats": pdf_formats,
3800 "analysisEngines": ["BPM (autocorrelation)", "Key (Goertzel chromagram)", "LUFS (RMS dBFS)", "Fingerprint (spectral)"],
3801 "visualizers": ["FFT spectrum", "Waveform", "Spectrogram", "Stereo Lissajous", "Level meters", "Frequency bands"],
3802 "exportFormats": ["JSON", "TOML", "CSV", "TSV", "PDF"],
3803 "storageBackend": "SQLite (WAL mode)",
3804 "uiFramework": "Tauri v2 + vanilla JS",
3805 "searchEngine": "fzf-style fuzzy matching",
3806 },
3807 "scanner": {
3808 "pluginScanning": scan_state.scanning.load(Ordering::Relaxed),
3809 "pluginStopped": scan_state.stop_scan.load(Ordering::Relaxed),
3810 "updateChecking": update_state.checking.load(Ordering::Relaxed),
3811 "updateStopped": update_state.stop_updates.load(Ordering::Relaxed),
3812 "audioScanning": audio_state.scanning.load(Ordering::Relaxed),
3813 "audioStopped": audio_state.stop_scan.load(Ordering::Relaxed),
3814 "dawScanning": daw_state.scanning.load(Ordering::Relaxed),
3815 "dawStopped": daw_state.stop_scan.load(Ordering::Relaxed),
3816 "presetScanning": preset_state.scanning.load(Ordering::Relaxed),
3817 "presetStopped": preset_state.stop_scan.load(Ordering::Relaxed),
3818 "pdfScanning": pdf_state.scanning.load(Ordering::Relaxed),
3819 "pdfStopped": pdf_state.stop_scan.load(Ordering::Relaxed),
3820 "midiScanning": midi_state.scanning.load(Ordering::Relaxed),
3821 "midiStopped": midi_state.stop_scan.load(Ordering::Relaxed),
3822 },
3823 "config": {
3824 "threadMultiplier": thread_mult,
3825 "globalPoolSize": ncpus * thread_mult,
3826 "perScannerThreads": ncpus * 2,
3827 "batchSize": batch_size,
3828 "channelBuffer": chan_buf,
3829 "walkerChannelBuffer": 2048,
3830 "walkerBatchSize": 100,
3831 "flushInterval": flush_interval,
3832 "pageSize": page_size,
3833 "stackSize": "8 MB",
3834 "depthLimit": 50,
3835 "pluginChannelMin": 64,
3836 "pluginChannelMax": 8192,
3837 },
3838 "database": {
3839 "sizeBytes": db_bytes,
3840 "tables": db_table_counts,
3841 "sqliteReadPoolExtra": sqlite_read_pool_extra,
3842 "sqliteReadPoolTotal": sqlite_read_pool_total,
3843 "sqliteReadPoolExtraPref": sqlite_read_pool_pref,
3844 },
3845 "dataFiles": {
3846 "preferencesBytes": prefs_bytes,
3847 },
3848 "dataDir": data_dir.to_string_lossy(),
3849 })
3850}
3851
3852#[tauri::command]
3853async fn get_process_stats(app: AppHandle) -> serde_json::Value {
3854 let app = app.clone();
3855 blocking(move || build_process_stats(app))
3856 .await
3857 .unwrap_or_else(|_| serde_json::json!({}))
3858}
3859
3860#[tauri::command]
3861async fn list_data_files() -> Vec<serde_json::Value> {
3862 blocking(move || {
3863 let data_dir = history::get_data_dir();
3864 let mut files = Vec::new();
3865 if let Ok(entries) = std::fs::read_dir(&data_dir) {
3866 for entry in entries.flatten() {
3867 let path = entry.path();
3868 if !path.is_file() {
3869 continue;
3870 }
3871 let name = path
3872 .file_name()
3873 .map(|n| n.to_string_lossy().to_string())
3874 .unwrap_or_default();
3875 let meta = std::fs::metadata(&path).ok();
3876 let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
3877 let modified = meta
3878 .and_then(|m| m.modified().ok())
3879 .map(|t| {
3880 let dt: chrono::DateTime<chrono::Utc> = t.into();
3881 dt.format("%Y-%m-%d %H:%M:%S").to_string()
3882 })
3883 .unwrap_or_default();
3884 files.push(serde_json::json!({
3885 "name": name,
3886 "path": path.to_string_lossy(),
3887 "size": size,
3888 "sizeFormatted": format_size(size),
3889 "modified": modified,
3890 }));
3891 }
3892 }
3893 files.sort_by(|a, b| {
3894 a["name"]
3895 .as_str()
3896 .unwrap_or("")
3897 .cmp(b["name"].as_str().unwrap_or(""))
3898 });
3899 files
3900 })
3901 .await
3902 .unwrap_or_default()
3903}
3904
3905#[tauri::command]
3906async fn delete_data_file(name: String) -> Result<(), String> {
3907 blocking_res(move || {
3908 let path = history::get_data_dir().join(&name);
3909 if !path.exists() {
3910 return Ok(());
3911 }
3912 std::fs::remove_file(&path).map_err(|e| e.to_string())
3913 })
3914 .await
3915}
3916
3917static APP_START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
3918
3919fn get_uptime_secs() -> u64 {
3920 APP_START.get_or_init(Instant::now).elapsed().as_secs()
3921}
3922
3923fn get_process_info() -> (u64, u64, f32) {
3926 use std::sync::atomic::{AtomicBool, Ordering};
3927 use std::sync::{Mutex, OnceLock};
3928 use sysinfo::{Pid, System};
3929 static SYS: OnceLock<Mutex<System>> = OnceLock::new();
3930 static PRIMED: AtomicBool = AtomicBool::new(false);
3931 let sys_mutex = SYS.get_or_init(|| Mutex::new(System::new()));
3932 let mut sys = sys_mutex.lock().unwrap();
3933 let pid = Pid::from_u32(std::process::id());
3934 if !PRIMED.swap(true, Ordering::Relaxed) {
3936 sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
3937 std::thread::sleep(std::time::Duration::from_millis(200));
3938 }
3939 sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
3940 if let Some(proc_info) = sys.process(pid) {
3941 (
3942 proc_info.memory(),
3943 proc_info.virtual_memory(),
3944 proc_info.cpu_usage(),
3945 )
3946 } else {
3947 (0, 0, 0.0)
3948 }
3949}
3950
3951fn get_rss_bytes() -> u64 {
3952 get_process_info().0
3953}
3954
3955fn get_virtual_bytes() -> u64 {
3956 get_process_info().1
3957}
3958
3959fn get_thread_count() -> u32 {
3960 #[cfg(target_os = "linux")]
3963 {
3964 use std::sync::{Mutex, OnceLock};
3965 use sysinfo::{Pid, System};
3966 static SYS: OnceLock<Mutex<System>> = OnceLock::new();
3967 let mut sys = SYS
3968 .get_or_init(|| Mutex::new(System::new()))
3969 .lock()
3970 .unwrap();
3971 let pid = Pid::from_u32(std::process::id());
3972 sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
3973 if let Some(p) = sys.process(pid) {
3974 if let Some(tasks) = p.tasks() {
3975 return (tasks.len() as u32).saturating_add(1);
3976 }
3977 }
3978 }
3979 #[cfg(target_os = "macos")]
3980 {
3981 let pid = std::process::id();
3982 if let Ok(out) = std::process::Command::new("ps")
3983 .args(["-M", "-p", &pid.to_string()])
3984 .output()
3985 {
3986 return String::from_utf8_lossy(&out.stdout)
3987 .lines()
3988 .count()
3989 .saturating_sub(1) as u32;
3990 }
3991 }
3992 0
3993}
3994
3995fn foreign_process_cpu_times_us(pid: u32) -> Option<(i64, i64)> {
3998 if pid == 0 {
3999 return None;
4000 }
4001 #[cfg(target_os = "linux")]
4002 {
4003 let path = format!("/proc/{pid}/stat");
4004 let line = std::fs::read_to_string(&path).ok()?;
4005 let idx = line.rfind(')')?;
4006 let rest = line[idx + 1..].trim_start();
4007 let fields: Vec<&str> = rest.split_whitespace().collect();
4008 if fields.len() < 13 {
4009 return None;
4010 }
4011 let utime_ticks: i64 = fields[11].parse().ok()?;
4012 let stime_ticks: i64 = fields[12].parse().ok()?;
4013 let clk = unsafe { libc::sysconf(libc::_SC_CLK_TCK) } as i64;
4014 if clk <= 0 {
4015 return None;
4016 }
4017 let user_us = utime_ticks * 1_000_000 / clk;
4018 let sys_us = stime_ticks * 1_000_000 / clk;
4019 return Some((user_us, sys_us));
4020 }
4021 #[cfg(target_os = "macos")]
4022 {
4023 #[repr(C)]
4025 struct ProcTaskInfo {
4026 virtual_size: u64,
4027 resident_size: u64,
4028 total_user: u64,
4029 total_system: u64,
4030 threads_user: u64,
4031 threads_system: u64,
4032 policy: u64,
4033 ssugg: u64,
4034 flags: u64,
4035 }
4036 #[link(name = "proc", kind = "dylib")]
4037 unsafe extern "C" {
4038 fn proc_pidinfo(
4039 pid: i32,
4040 flavor: i32,
4041 arg: u64,
4042 buffer: *mut ProcTaskInfo,
4043 buffersize: i32,
4044 ) -> i32;
4045 }
4046 const PROC_PIDTASKINFO: i32 = 4;
4047 let mut info: ProcTaskInfo = unsafe { std::mem::zeroed() };
4048 let n = unsafe {
4049 proc_pidinfo(
4050 pid as i32,
4051 PROC_PIDTASKINFO,
4052 0,
4053 &mut info,
4054 std::mem::size_of::<ProcTaskInfo>() as i32,
4055 )
4056 };
4057 if n <= 0 {
4058 return None;
4059 }
4060 let user_us = (info.total_user / 1000) as i64;
4062 let sys_us = (info.total_system / 1000) as i64;
4063 return Some((user_us, sys_us));
4064 }
4065 #[cfg(target_os = "windows")]
4066 {
4067 use std::ffi::c_void;
4068 use std::mem::MaybeUninit;
4069 #[link(name = "kernel32")]
4070 unsafe extern "system" {
4071 fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> *mut c_void;
4072 fn CloseHandle(h: *mut c_void) -> i32;
4073 fn GetProcessTimes(
4074 h: *mut c_void,
4075 creation: *mut [u32; 2],
4076 exit: *mut [u32; 2],
4077 kernel: *mut [u32; 2],
4078 user: *mut [u32; 2],
4079 ) -> i32;
4080 }
4081 const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
4082 unsafe {
4083 let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
4084 if h.is_null() {
4085 return None;
4086 }
4087 let mut creation = MaybeUninit::<[u32; 2]>::uninit();
4088 let mut exit = MaybeUninit::<[u32; 2]>::uninit();
4089 let mut kernel = MaybeUninit::<[u32; 2]>::uninit();
4090 let mut user = MaybeUninit::<[u32; 2]>::uninit();
4091 let ok = GetProcessTimes(
4092 h,
4093 creation.as_mut_ptr(),
4094 exit.as_mut_ptr(),
4095 kernel.as_mut_ptr(),
4096 user.as_mut_ptr(),
4097 );
4098 let _ = CloseHandle(h);
4099 if ok == 0 {
4100 return None;
4101 }
4102 let ft_to_us = |ft: [u32; 2]| -> i64 {
4103 let ticks = (ft[1] as i64) << 32 | ft[0] as i64;
4104 ticks / 10
4105 };
4106 let user_us = ft_to_us(user.assume_init());
4107 let sys_us = ft_to_us(kernel.assume_init());
4108 return Some((user_us, sys_us));
4109 }
4110 }
4111 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
4112 {
4113 let _ = pid;
4114 None
4115 }
4116}
4117
4118fn get_cpu_percent_like_rusage_for_pid(pid: u32) -> f64 {
4120 use std::sync::{Mutex, OnceLock};
4121 use std::time::Instant;
4122
4123 struct CpuSample {
4124 wall: Instant,
4125 user_us: i64,
4126 sys_us: i64,
4127 }
4128
4129 static PREV: OnceLock<Mutex<Option<(u32, CpuSample)>>> = OnceLock::new();
4130 let prev_lock = PREV.get_or_init(|| Mutex::new(None));
4131
4132 if pid == 0 {
4133 let mut prev = prev_lock.lock().unwrap();
4134 *prev = None;
4135 return 0.0;
4136 }
4137
4138 let Some((user_us, sys_us)) = foreign_process_cpu_times_us(pid) else {
4139 let mut prev = prev_lock.lock().unwrap();
4140 *prev = None;
4141 return 0.0;
4142 };
4143
4144 let now = Instant::now();
4145 let mut prev_guard = prev_lock.lock().unwrap();
4146 let pct = match *prev_guard {
4147 Some((prev_pid, ref p)) if prev_pid == pid => {
4148 let wall_us = now.duration_since(p.wall).as_micros() as f64;
4149 if wall_us > 0.0 {
4150 let cpu_us = ((user_us - p.user_us) + (sys_us - p.sys_us)) as f64;
4151 (cpu_us / wall_us) * 100.0
4152 } else {
4153 0.0
4154 }
4155 }
4156 _ => 0.0,
4157 };
4158 *prev_guard = Some((
4159 pid,
4160 CpuSample {
4161 wall: now,
4162 user_us,
4163 sys_us,
4164 },
4165 ));
4166 pct
4167}
4168
4169fn get_cpu_percent() -> f64 {
4170 use std::sync::{Mutex, OnceLock};
4171 use std::time::Instant;
4172
4173 struct CpuSample {
4174 wall: Instant,
4175 user_us: i64,
4176 sys_us: i64,
4177 }
4178
4179 static PREV: OnceLock<Mutex<Option<CpuSample>>> = OnceLock::new();
4180 let prev_lock = PREV.get_or_init(|| Mutex::new(None));
4181
4182 #[cfg(any(target_os = "macos", target_os = "linux"))]
4183 {
4184 let mut usage: libc::rusage = unsafe { std::mem::zeroed() };
4185 let ret = unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut usage) };
4186 if ret != 0 {
4187 return get_process_info().2 as f64;
4188 }
4189
4190 let now = Instant::now();
4191 let user_us = usage.ru_utime.tv_sec as i64 * 1_000_000 + usage.ru_utime.tv_usec as i64;
4192 let sys_us = usage.ru_stime.tv_sec as i64 * 1_000_000 + usage.ru_stime.tv_usec as i64;
4193
4194 let mut prev = prev_lock.lock().unwrap();
4195 let pct = if let Some(ref p) = *prev {
4196 let wall_us = now.duration_since(p.wall).as_micros() as f64;
4197 if wall_us > 0.0 {
4198 let cpu_us = ((user_us - p.user_us) + (sys_us - p.sys_us)) as f64;
4199 (cpu_us / wall_us) * 100.0
4200 } else {
4201 0.0
4202 }
4203 } else {
4204 0.0
4205 };
4206 *prev = Some(CpuSample {
4207 wall: now,
4208 user_us,
4209 sys_us,
4210 });
4211 pct
4212 }
4213 #[cfg(target_os = "windows")]
4214 {
4215 use std::ffi::c_void;
4216 use std::mem::MaybeUninit;
4217 #[link(name = "kernel32")]
4218 unsafe extern "system" {
4219 fn GetCurrentProcess() -> *mut c_void;
4220 fn GetProcessTimes(
4221 h: *mut c_void,
4222 creation: *mut [u32; 2],
4223 exit: *mut [u32; 2],
4224 kernel: *mut [u32; 2],
4225 user: *mut [u32; 2],
4226 ) -> i32;
4227 }
4228 let mut creation = MaybeUninit::<[u32; 2]>::uninit();
4229 let mut exit = MaybeUninit::<[u32; 2]>::uninit();
4230 let mut kernel = MaybeUninit::<[u32; 2]>::uninit();
4231 let mut user = MaybeUninit::<[u32; 2]>::uninit();
4232 let ok = unsafe {
4233 GetProcessTimes(
4234 GetCurrentProcess(),
4235 creation.as_mut_ptr(),
4236 exit.as_mut_ptr(),
4237 kernel.as_mut_ptr(),
4238 user.as_mut_ptr(),
4239 )
4240 };
4241 if ok == 0 {
4242 return get_process_info().2 as f64;
4243 }
4244 let ft_to_us = |ft: [u32; 2]| -> i64 {
4245 let ticks = (ft[1] as i64) << 32 | ft[0] as i64; ticks / 10 };
4248 let now = Instant::now();
4249 let user_us = ft_to_us(unsafe { user.assume_init() });
4250 let sys_us = ft_to_us(unsafe { kernel.assume_init() });
4251
4252 let mut prev = prev_lock.lock().unwrap();
4253 let pct = if let Some(ref p) = *prev {
4254 let wall_us = now.duration_since(p.wall).as_micros() as f64;
4255 if wall_us > 0.0 {
4256 let cpu_us = ((user_us - p.user_us) + (sys_us - p.sys_us)) as f64;
4257 (cpu_us / wall_us) * 100.0
4258 } else {
4259 0.0
4260 }
4261 } else {
4262 0.0
4263 };
4264 *prev = Some(CpuSample {
4265 wall: now,
4266 user_us,
4267 sys_us,
4268 });
4269 pct
4270 }
4271 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
4272 {
4273 get_process_info().2 as f64
4274 }
4275}
4276
4277fn get_open_fd_count() -> u32 {
4278 #[cfg(any(target_os = "macos", target_os = "linux"))]
4279 {
4280 for dir in &["/dev/fd", "/proc/self/fd"] {
4282 if let Ok(entries) = std::fs::read_dir(dir) {
4283 return entries.count() as u32;
4284 }
4285 }
4286 }
4287 #[cfg(target_os = "windows")]
4288 {
4289 use std::ffi::c_void;
4290 #[link(name = "kernel32")]
4291 unsafe extern "system" {
4292 fn GetCurrentProcess() -> *mut c_void;
4293 fn GetProcessHandleCount(h_process: *mut c_void, p_count: *mut u32) -> i32;
4294 }
4295 unsafe {
4296 let mut count = 0u32;
4297 if GetProcessHandleCount(GetCurrentProcess(), &mut count) != 0 {
4298 return count;
4299 }
4300 }
4301 }
4302 0
4303}
4304
4305#[cfg(not(target_os = "linux"))]
4308fn thread_count_for_pid_non_sysinfo(pid: u32) -> u32 {
4309 if pid == 0 {
4310 return 0;
4311 }
4312 #[cfg(target_os = "macos")]
4313 {
4314 if let Ok(out) = std::process::Command::new("ps")
4315 .args(["-M", "-p", &pid.to_string()])
4316 .output()
4317 {
4318 return String::from_utf8_lossy(&out.stdout)
4319 .lines()
4320 .count()
4321 .saturating_sub(1) as u32;
4322 }
4323 }
4324 #[cfg(target_os = "windows")]
4325 {
4326 use std::os::windows::process::CommandExt;
4327 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
4328 if let Ok(out) = std::process::Command::new("powershell")
4329 .args([
4330 "-NoProfile",
4331 "-NonInteractive",
4332 "-Command",
4333 &format!("(Get-Process -Id {pid} -ErrorAction SilentlyContinue).Threads.Count"),
4334 ])
4335 .creation_flags(CREATE_NO_WINDOW)
4336 .output()
4337 {
4338 if out.status.success() {
4339 if let Ok(n) = String::from_utf8_lossy(&out.stdout).trim().parse::<u32>() {
4340 return n;
4341 }
4342 }
4343 }
4344 }
4345 0
4346}
4347
4348fn open_fd_count_for_pid(pid: u32) -> u32 {
4349 if pid == 0 {
4350 return 0;
4351 }
4352 #[cfg(target_os = "linux")]
4353 {
4354 let path = format!("/proc/{pid}/fd");
4355 if let Ok(entries) = std::fs::read_dir(&path) {
4356 return entries.count() as u32;
4357 }
4358 }
4359 #[cfg(target_os = "macos")]
4360 {
4361 if let Ok(out) = std::process::Command::new("lsof")
4362 .args(["-w", "-p", &pid.to_string()])
4363 .output()
4364 {
4365 if !out.status.success() {
4366 return 0;
4367 }
4368 let stdout = String::from_utf8_lossy(&out.stdout);
4369 let lines: Vec<&str> = stdout
4370 .lines()
4371 .filter(|l| !l.trim().is_empty())
4372 .collect();
4373 if lines.is_empty() {
4374 return 0;
4375 }
4376 return lines.len().saturating_sub(1) as u32;
4377 }
4378 }
4379 #[cfg(target_os = "windows")]
4380 {
4381 use std::ffi::c_void;
4382 #[link(name = "kernel32")]
4383 unsafe extern "system" {
4384 fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> *mut c_void;
4385 fn CloseHandle(h: *mut c_void) -> i32;
4386 fn GetProcessHandleCount(hProcess: *mut c_void, lpdwHandleCount: *mut u32) -> i32;
4387 }
4388 const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
4389 unsafe {
4390 let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
4391 if h.is_null() {
4392 return 0;
4393 }
4394 let mut count = 0u32;
4395 let ok = GetProcessHandleCount(h, &mut count);
4396 let _ = CloseHandle(h);
4397 if ok != 0 {
4398 return count;
4399 }
4400 }
4401 }
4402 0
4403}
4404
4405fn collect_audio_engine_process_metrics(pid: u32) -> (u64, u64, u64, u32) {
4406 use std::sync::atomic::{AtomicBool, Ordering};
4407 use std::sync::{Mutex, OnceLock};
4408 use sysinfo::{Pid, ProcessesToUpdate, System};
4409
4410 static SYS: OnceLock<Mutex<System>> = OnceLock::new();
4411 static PRIMED: AtomicBool = AtomicBool::new(false);
4412 static LAST_PID: Mutex<Option<u32>> = Mutex::new(None);
4413
4414 if pid == 0 {
4415 return (0, 0, 0, 0);
4416 }
4417
4418 let sys_mutex = SYS.get_or_init(|| Mutex::new(System::new()));
4419 let mut sys = sys_mutex.lock().unwrap();
4420 let spid = Pid::from_u32(pid);
4421
4422 {
4423 let mut last = LAST_PID.lock().unwrap();
4424 if *last != Some(pid) {
4425 PRIMED.store(false, Ordering::Relaxed);
4426 *last = Some(pid);
4427 }
4428 }
4429
4430 if !PRIMED.swap(true, Ordering::Relaxed) {
4431 sys.refresh_processes(ProcessesToUpdate::Some(&[spid]), true);
4432 std::thread::sleep(std::time::Duration::from_millis(200));
4433 }
4434 sys.refresh_processes(ProcessesToUpdate::Some(&[spid]), true);
4435
4436 let Some(proc_info) = sys.process(spid) else {
4437 return (0, 0, 0, 0);
4438 };
4439
4440 let rss = proc_info.memory();
4441 let virt = proc_info.virtual_memory();
4442 let run_time = proc_info.run_time();
4443
4444 #[cfg(target_os = "linux")]
4445 {
4446 let threads = proc_info
4447 .tasks()
4448 .map(|t| (t.len() as u32).saturating_add(1))
4449 .unwrap_or(0);
4450 (rss, virt, run_time, threads)
4451 }
4452
4453 #[cfg(not(target_os = "linux"))]
4454 {
4455 drop(sys);
4456 let threads = thread_count_for_pid_non_sysinfo(pid);
4457 (rss, virt, run_time, threads)
4458 }
4459}
4460
4461fn build_audio_engine_process_stats() -> serde_json::Value {
4462 let pid = audio_engine::audio_engine_child_pid();
4463 let ncpus = num_cpus::get() as u32;
4464 if pid == 0 {
4465 return serde_json::json!({
4466 "running": false,
4467 "pid": 0u32,
4468 "numCpus": ncpus,
4469 "rssBytes": 0u64,
4470 "virtualBytes": 0u64,
4471 "cpuPercent": 0.0,
4472 "threads": 0u32,
4473 "openFds": 0u32,
4474 "uptimeSecs": 0u64,
4475 });
4476 }
4477 let (rss, virt, run_time, threads) = collect_audio_engine_process_metrics(pid);
4478 let fds = open_fd_count_for_pid(pid);
4479 let cpu_pct = get_cpu_percent_like_rusage_for_pid(pid);
4480 serde_json::json!({
4481 "running": true,
4482 "pid": pid,
4483 "numCpus": ncpus,
4484 "rssBytes": rss,
4485 "virtualBytes": virt,
4486 "cpuPercent": cpu_pct,
4487 "threads": threads,
4488 "openFds": fds,
4489 "uptimeSecs": run_time,
4490 })
4491}
4492
4493#[tauri::command]
4494async fn get_audio_engine_process_stats() -> serde_json::Value {
4495 blocking(move || build_audio_engine_process_stats())
4496 .await
4497 .unwrap_or_else(|_| serde_json::json!({}))
4498}
4499
4500fn gethostname() -> String {
4501 sysinfo::System::host_name().unwrap_or_default()
4502}
4503
4504#[tauri::command]
4507async fn export_pdfs_json(pdfs: Vec<PdfFile>, file_path: String) -> Result<(), String> {
4508 blocking_res(move || {
4509 let json = serde_json::to_string_pretty(&pdfs).map_err(|e| e.to_string())?;
4510 std::fs::write(&file_path, json).map_err(|e| e.to_string())
4511 })
4512 .await
4513}
4514
4515#[tauri::command]
4516async fn export_pdfs_dsv(pdfs: Vec<PdfFile>, file_path: String) -> Result<(), String> {
4517 blocking_res(move || {
4518 let sep = detect_separator(&file_path);
4519 let mut out = format!("Name{s}Path{s}Directory{s}Size{s}Modified\n", s = sep);
4520 for p in &pdfs {
4521 out.push_str(&format!(
4522 "{}{sep}{}{sep}{}{sep}{}{sep}{}\n",
4523 dsv_escape(&p.name, sep),
4524 dsv_escape(&p.path, sep),
4525 dsv_escape(&p.directory, sep),
4526 dsv_escape(&p.size_formatted, sep),
4527 dsv_escape(&p.modified, sep),
4528 ));
4529 }
4530 std::fs::write(&file_path, out).map_err(|e| e.to_string())
4531 })
4532 .await
4533}
4534
4535#[tauri::command]
4538async fn export_presets_json(presets: Vec<PresetFile>, file_path: String) -> Result<(), String> {
4539 blocking_res(move || {
4540 let json = serde_json::to_string_pretty(&presets).map_err(|e| e.to_string())?;
4541 std::fs::write(&file_path, json).map_err(|e| e.to_string())
4542 })
4543 .await
4544}
4545
4546#[tauri::command]
4547async fn export_presets_dsv(presets: Vec<PresetFile>, file_path: String) -> Result<(), String> {
4548 blocking_res(move || {
4549 let sep = detect_separator(&file_path);
4550 let mut out = format!(
4551 "Name{s}Format{s}Path{s}Directory{s}Size{s}Modified\n",
4552 s = sep
4553 );
4554 for p in &presets {
4555 out.push_str(&format!(
4556 "{}{sep}{}{sep}{}{sep}{}{sep}{}{sep}{}\n",
4557 dsv_escape(&p.name, sep),
4558 dsv_escape(&p.format, sep),
4559 dsv_escape(&p.path, sep),
4560 dsv_escape(&p.directory, sep),
4561 dsv_escape(&p.size_formatted, sep),
4562 dsv_escape(&p.modified, sep),
4563 ));
4564 }
4565 std::fs::write(&file_path, out).map_err(|e| e.to_string())
4566 })
4567 .await
4568}
4569
4570#[tauri::command]
4573async fn export_toml(data: serde_json::Value, file_path: String) -> Result<(), String> {
4574 blocking_res(move || {
4575 let toml_str = toml::to_string_pretty(&data).map_err(|e| e.to_string())?;
4576 std::fs::write(&file_path, toml_str).map_err(|e| e.to_string())
4577 })
4578 .await
4579}
4580
4581#[tauri::command]
4582async fn import_toml(file_path: String) -> Result<serde_json::Value, String> {
4583 blocking_res(move || {
4584 let data = std::fs::read_to_string(&file_path).map_err(|e| e.to_string())?;
4585 let val: toml::Value = toml::from_str(&data).map_err(|e| e.to_string())?;
4586 let json_str = serde_json::to_string(&val).map_err(|e| e.to_string())?;
4587 serde_json::from_str(&json_str).map_err(|e| e.to_string())
4588 })
4589 .await
4590}
4591
4592fn export_pdf_impl(
4595 title: String,
4596 headers: Vec<String>,
4597 rows: Vec<Vec<String>>,
4598 file_path: String,
4599) -> Result<(), String> {
4600 #[cfg(not(test))]
4601 append_log(format!(
4602 "EXPORT PDF — \"{}\" | {} rows | {} columns → {}",
4603 title,
4604 rows.len(),
4605 headers.len(),
4606 file_path
4607 ));
4608 use printpdf::*;
4609
4610 let icon_bytes: &[u8] = include_bytes!("../icons/32x32.png");
4611
4612 let page_w_mm = Mm(297.0); let page_h_mm = Mm(210.0);
4614 let page_w = page_w_mm.0;
4615 let page_h = page_h_mm.0;
4616 let margin_x = 10.0_f32;
4617 let margin_bottom = 12.0_f32;
4618 let row_height = 4.5_f32;
4619 let header_row_h = 7.0_f32;
4620 let col_count = headers.len();
4621 let usable_w = page_w - margin_x * 2.0;
4622
4623 const MAX_PDF_ROWS: usize = 10_000;
4624 let total_row_count = rows.len();
4625 let capped = total_row_count > MAX_PDF_ROWS;
4626 let export_rows = if capped {
4627 &rows[..MAX_PDF_ROWS]
4628 } else {
4629 &rows[..]
4630 };
4631
4632 let col_widths: Vec<f32> = if col_count > 0 {
4633 let sample_step = (export_rows.len() / 500).max(1);
4634 let mut col_maxes: Vec<usize> = headers.iter().map(|h| h.len()).collect();
4635 let mut col_sums: Vec<usize> = vec![0; col_count];
4636 let mut sample_count = 0_usize;
4637 for (idx, row) in export_rows.iter().enumerate() {
4638 if idx % sample_step != 0 {
4639 continue;
4640 }
4641 sample_count += 1;
4642 for (i, cell) in row.iter().enumerate() {
4643 if i < col_count {
4644 let l = cell.len().min(120);
4645 if l > col_maxes[i] {
4646 col_maxes[i] = l;
4647 }
4648 col_sums[i] += l;
4649 }
4650 }
4651 }
4652 let effective: Vec<usize> = col_sums
4653 .iter()
4654 .enumerate()
4655 .map(|(i, &s)| {
4656 let avg = if sample_count > 0 {
4657 s / sample_count
4658 } else {
4659 6
4660 };
4661 let p90_approx = (avg as f32 * 1.3) as usize;
4662 p90_approx
4663 .max(headers[i].len() * 2)
4664 .max(6)
4665 .min(col_maxes[i])
4666 })
4667 .collect();
4668 let total_len: usize = effective.iter().sum::<usize>().max(1);
4669 let min_col = 12.0_f32;
4670 let mut widths: Vec<f32> = effective
4671 .iter()
4672 .map(|&l| (l as f32 / total_len as f32 * usable_w).max(min_col))
4673 .collect();
4674 let sum: f32 = widths.iter().sum();
4675 let scale = usable_w / sum;
4676 for w in &mut widths {
4677 *w *= scale;
4678 }
4679 widths
4680 } else {
4681 vec![usable_w]
4682 };
4683 let version = env!("CARGO_PKG_VERSION");
4684
4685 fn rgb_color(r: f32, g: f32, b: f32) -> Color {
4686 Color::Rgb(Rgb::new(r, g, b, None))
4687 }
4688
4689 fn push_fill_rect(
4690 ops: &mut Vec<Op>,
4691 x: f32,
4692 y: f32,
4693 w: f32,
4694 h: f32,
4695 r: f32,
4696 g: f32,
4697 b: f32,
4698 ) {
4699 ops.push(Op::SetFillColor {
4700 col: rgb_color(r, g, b),
4701 });
4702 let lp = |x: f32, y: f32| LinePoint {
4703 p: Point::new(Mm(x), Mm(y)),
4704 bezier: false,
4705 };
4706 ops.push(Op::DrawPolygon {
4707 polygon: Polygon {
4708 rings: vec![PolygonRing {
4709 points: vec![
4710 lp(x, y),
4711 lp(x + w, y),
4712 lp(x + w, y + h),
4713 lp(x, y + h),
4714 ],
4715 }],
4716 mode: PaintMode::Fill,
4717 winding_order: WindingOrder::NonZero,
4718 },
4719 });
4720 }
4721
4722 fn push_stroke_line(
4723 ops: &mut Vec<Op>,
4724 x1: f32,
4725 y1: f32,
4726 x2: f32,
4727 y2: f32,
4728 r: f32,
4729 g: f32,
4730 b: f32,
4731 thick_pt: f32,
4732 ) {
4733 ops.push(Op::SetOutlineColor {
4734 col: rgb_color(r, g, b),
4735 });
4736 ops.push(Op::SetOutlineThickness {
4737 pt: Pt(thick_pt),
4738 });
4739 ops.push(Op::DrawLine {
4740 line: Line {
4741 points: vec![
4742 LinePoint {
4743 p: Point::new(Mm(x1), Mm(y1)),
4744 bezier: false,
4745 },
4746 LinePoint {
4747 p: Point::new(Mm(x2), Mm(y2)),
4748 bezier: false,
4749 },
4750 ],
4751 is_closed: false,
4752 },
4753 });
4754 }
4755
4756 fn push_text(
4757 ops: &mut Vec<Op>,
4758 text: String,
4759 font: BuiltinFont,
4760 size_pt: f32,
4761 x_mm: f32,
4762 y_mm: f32,
4763 color: Color,
4764 ) {
4765 ops.push(Op::StartTextSection);
4766 ops.push(Op::SetTextCursor {
4767 pos: Point::new(Mm(x_mm), Mm(y_mm)),
4768 });
4769 ops.push(Op::SetFont {
4770 font: PdfFontHandle::Builtin(font),
4771 size: Pt(size_pt),
4772 });
4773 ops.push(Op::SetLineHeight { lh: Pt(size_pt) });
4774 ops.push(Op::SetFillColor { col: color });
4775 ops.push(Op::ShowText {
4776 items: vec![TextItem::Text(text)],
4777 });
4778 ops.push(Op::EndTextSection);
4779 }
4780
4781 let mut doc = PdfDocument::new(title.as_str());
4782 let mut decode_warnings = Vec::new();
4783 let icon_info: Option<(XObjectId, f32, f32)> =
4784 match RawImage::decode_from_bytes(icon_bytes, &mut decode_warnings) {
4785 Ok(img) => {
4786 let iw = img.width as f32;
4787 let ih = img.height as f32;
4788 let id = doc.add_image(&img);
4789 Some((id, iw, ih))
4790 }
4791 Err(_) => None,
4792 };
4793
4794 let mut pages: Vec<PdfPage> = Vec::new();
4795 let mut ops: Vec<Op> = Vec::new();
4796
4797 let render_header = |ops: &mut Vec<Op>, y: &mut f32, page: usize| {
4798 push_fill_rect(ops, 0.0, page_h - 22.0, page_w, 22.0, 0.02, 0.02, 0.04);
4799
4800 let mut icon_offset = 0.0_f32;
4801 if let Some((ref id, iw, ih)) = icon_info {
4802 let icon_size = 6.0_f32;
4803 ops.push(Op::UseXobject {
4804 id: id.clone(),
4805 transform: XObjectTransform {
4806 translate_x: Some(Mm(margin_x).into_pt()),
4807 translate_y: Some(Mm(page_h - 19.0).into_pt()),
4808 scale_x: Some(icon_size / iw),
4809 scale_y: Some(icon_size / ih),
4810 dpi: Some(300.0),
4811 ..Default::default()
4812 },
4813 });
4814 icon_offset = icon_size + 2.0;
4815 }
4816
4817 push_text(
4818 ops,
4819 "AUDIO_HAXOR".to_string(),
4820 BuiltinFont::HelveticaBold,
4821 14.0,
4822 margin_x + icon_offset,
4823 page_h - 14.0,
4824 rgb_color(0.02, 0.85, 0.91),
4825 );
4826 push_text(
4827 ops,
4828 format!("v{}", version),
4829 BuiltinFont::Helvetica,
4830 8.0,
4831 margin_x + icon_offset + 58.0,
4832 page_h - 14.0,
4833 rgb_color(1.0, 1.0, 1.0),
4834 );
4835 push_text(
4836 ops,
4837 title.clone(),
4838 BuiltinFont::HelveticaBold,
4839 12.0,
4840 page_w - margin_x - 80.0,
4841 page_h - 14.0,
4842 rgb_color(1.0, 1.0, 1.0),
4843 );
4844
4845 push_stroke_line(
4846 ops,
4847 0.0,
4848 page_h - 22.0,
4849 page_w,
4850 page_h - 22.0,
4851 0.02,
4852 0.85,
4853 0.91,
4854 1.5,
4855 );
4856
4857 *y = page_h - 28.0;
4858
4859 if page == 1 {
4860 let sub = if capped {
4861 format!(
4862 "Showing {} of {} items (capped) | Exported {} | by MenkeTechnologies",
4863 export_rows.len(),
4864 total_row_count,
4865 chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
4866 )
4867 } else {
4868 format!(
4869 "{} items | Exported {} | by MenkeTechnologies",
4870 total_row_count,
4871 chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
4872 )
4873 };
4874 push_text(
4875 ops,
4876 sub,
4877 BuiltinFont::HelveticaOblique,
4878 8.0,
4879 margin_x,
4880 *y,
4881 rgb_color(0.4, 0.4, 0.45),
4882 );
4883 *y -= 6.0;
4884 }
4885 };
4886
4887 let render_col_headers = |ops: &mut Vec<Op>, y: &mut f32| {
4888 push_fill_rect(
4889 ops,
4890 margin_x - 1.0,
4891 *y - 1.5,
4892 usable_w + 2.0,
4893 header_row_h,
4894 0.04,
4895 0.04,
4896 0.08,
4897 );
4898 push_stroke_line(
4899 ops,
4900 margin_x - 1.0,
4901 *y - 1.5,
4902 margin_x + usable_w + 1.0,
4903 *y - 1.5,
4904 0.02,
4905 0.85,
4906 0.91,
4907 0.5,
4908 );
4909
4910 let mut x = margin_x + 1.0;
4911 for (i, h) in headers.iter().enumerate() {
4912 push_text(
4913 ops,
4914 h.clone(),
4915 BuiltinFont::HelveticaBold,
4916 9.0,
4917 x,
4918 *y,
4919 rgb_color(0.02, 0.85, 0.91),
4920 );
4921 x += col_widths[i];
4922 }
4923 *y -= header_row_h;
4924 };
4925
4926 let render_footer = |ops: &mut Vec<Op>, page: usize| {
4927 let footer_y = 8.0;
4928 push_fill_rect(
4929 ops,
4930 0.0,
4931 0.0,
4932 page_w,
4933 footer_y + 4.0,
4934 0.02,
4935 0.02,
4936 0.04,
4937 );
4938 push_stroke_line(
4939 ops,
4940 margin_x,
4941 footer_y + 3.0,
4942 page_w - margin_x,
4943 footer_y + 3.0,
4944 0.02,
4945 0.85,
4946 0.91,
4947 0.5,
4948 );
4949 push_text(
4950 ops,
4951 format!("AUDIO_HAXOR v{} — {}", version, title),
4952 BuiltinFont::Helvetica,
4953 7.0,
4954 margin_x,
4955 footer_y,
4956 rgb_color(0.4, 0.4, 0.45),
4957 );
4958 push_text(
4959 ops,
4960 format!("Page {}", page),
4961 BuiltinFont::Helvetica,
4962 7.0,
4963 page_w - margin_x - 25.0,
4964 footer_y,
4965 rgb_color(0.4, 0.4, 0.45),
4966 );
4967 };
4968
4969 let mut y = 0.0_f32;
4970 let mut page_num = 1_usize;
4971 let mut row_idx = 0_usize;
4972
4973 render_header(&mut ops, &mut y, page_num);
4974 render_col_headers(&mut ops, &mut y);
4975 y -= 1.0;
4976
4977 for row in export_rows {
4978 if y < margin_bottom + 5.0 {
4979 render_footer(&mut ops, page_num);
4980 pages.push(PdfPage::new(page_w_mm, page_h_mm, std::mem::take(&mut ops)));
4981 page_num += 1;
4982 y = 0.0;
4983 render_header(&mut ops, &mut y, page_num);
4984 render_col_headers(&mut ops, &mut y);
4985 y -= 1.0;
4986 row_idx = 0;
4987 }
4988
4989 if row_idx == 0 {
4990 push_fill_rect(&mut ops, 0.0, 0.0, page_w, y + 2.0, 0.03, 0.03, 0.06);
4991 }
4992 if row_idx % 2 == 1 {
4993 push_fill_rect(
4994 &mut ops,
4995 margin_x - 1.0,
4996 y - 1.2,
4997 usable_w + 2.0,
4998 row_height,
4999 0.06,
5000 0.06,
5001 0.10,
5002 );
5003 } else {
5004 push_fill_rect(
5005 &mut ops,
5006 margin_x - 1.0,
5007 y - 1.2,
5008 usable_w + 2.0,
5009 row_height,
5010 0.04,
5011 0.04,
5012 0.08,
5013 );
5014 }
5015
5016 let mut x = margin_x + 0.5;
5017 for (i, cell) in row.iter().enumerate() {
5018 let w = if i < col_widths.len() {
5019 col_widths[i]
5020 } else {
5021 30.0
5022 };
5023 let max_chars = (w / 1.2) as usize;
5024 let cell_text = if cell.len() > max_chars && max_chars > 3 {
5025 format!("{}...", &cell[..max_chars - 3])
5026 } else {
5027 cell.clone()
5028 };
5029 push_text(
5030 &mut ops,
5031 cell_text,
5032 BuiltinFont::Helvetica,
5033 7.0,
5034 x,
5035 y,
5036 rgb_color(0.85, 0.85, 0.90),
5037 );
5038 x += w;
5039 }
5040
5041 y -= row_height;
5042 row_idx += 1;
5043 }
5044
5045 if capped {
5046 y -= 3.0;
5047 push_text(
5048 &mut ops,
5049 format!(
5050 "Export capped at {} of {} rows. Use CSV/JSON for the full dataset.",
5051 MAX_PDF_ROWS, total_row_count
5052 ),
5053 BuiltinFont::HelveticaBold,
5054 8.0,
5055 margin_x,
5056 y,
5057 rgb_color(0.83, 0.0, 0.77),
5058 );
5059 }
5060
5061 render_footer(&mut ops, page_num);
5062 pages.push(PdfPage::new(page_w_mm, page_h_mm, ops));
5063
5064 doc.with_pages(pages);
5065 let bytes = doc.save(&PdfSaveOptions::default(), &mut decode_warnings);
5066 std::fs::write(&file_path, bytes).map_err(|e| e.to_string())
5067}
5068
5069#[tauri::command]
5070async fn export_pdf(
5071 title: String,
5072 headers: Vec<String>,
5073 rows: Vec<Vec<String>>,
5074 file_path: String,
5075) -> Result<(), String> {
5076 blocking_res(move || export_pdf_impl(title, headers, rows, file_path)).await
5077}
5078
5079#[tauri::command]
5082async fn fs_list_dir(dir_path: String) -> Result<serde_json::Value, String> {
5083 blocking_res(move || {
5084 let path = std::path::Path::new(&dir_path);
5085 if !path.exists() {
5086 return Err(format!("Directory not found: {}", dir_path));
5087 }
5088 if !path.is_dir() {
5089 return Err(format!("Not a directory: {}", dir_path));
5090 }
5091
5092 let mut entries = Vec::new();
5093 let read = std::fs::read_dir(path).map_err(|e| e.to_string())?;
5094 for entry in read.flatten() {
5095 let ep = entry.path();
5096 let name = entry.file_name().to_string_lossy().to_string();
5097 if name.starts_with('.') {
5098 continue;
5099 }
5100 let is_dir = ep.is_dir();
5101 let meta = std::fs::metadata(&ep).ok();
5102 let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
5103 let modified = meta
5104 .and_then(|m| m.modified().ok())
5105 .map(|t| {
5106 let dt: chrono::DateTime<chrono::Utc> = t.into();
5107 dt.format("%Y-%m-%d %H:%M").to_string()
5108 })
5109 .unwrap_or_default();
5110 let ext = ep
5111 .extension()
5112 .map(|e| e.to_string_lossy().to_lowercase())
5113 .unwrap_or_default();
5114 entries.push(serde_json::json!({
5115 "name": name,
5116 "path": ep.to_string_lossy(),
5117 "isDir": is_dir,
5118 "size": size,
5119 "sizeFormatted": scanner::format_size(size),
5120 "modified": modified,
5121 "ext": ext,
5122 }));
5123 }
5124 entries.sort_by(|a, b| {
5125 let a_dir = a["isDir"].as_bool().unwrap_or(false);
5126 let b_dir = b["isDir"].as_bool().unwrap_or(false);
5127 b_dir.cmp(&a_dir).then_with(|| {
5128 a["name"]
5129 .as_str()
5130 .unwrap_or("")
5131 .to_lowercase()
5132 .cmp(&b["name"].as_str().unwrap_or("").to_lowercase())
5133 })
5134 });
5135 Ok(serde_json::json!({ "entries": entries, "path": dir_path }))
5136 })
5137 .await
5138}
5139
5140#[tauri::command]
5141async fn delete_file(file_path: String) -> Result<(), String> {
5142 blocking_res(move || {
5143 #[cfg(not(test))]
5144 append_log(format!("FILE DELETE — {}", file_path));
5145 let path = std::path::Path::new(&file_path);
5146 if !path.exists() {
5147 return Err("File not found".into());
5148 }
5149 if path.is_dir() {
5150 std::fs::remove_dir_all(path).map_err(|e| e.to_string())
5151 } else {
5152 std::fs::remove_file(path).map_err(|e| e.to_string())
5153 }
5154 })
5155 .await
5156}
5157
5158#[tauri::command]
5159async fn rename_file(old_path: String, new_path: String) -> Result<(), String> {
5160 blocking_res(move || {
5161 #[cfg(not(test))]
5162 append_log(format!("FILE RENAME — {} → {}", old_path, new_path));
5163 std::fs::rename(&old_path, &new_path).map_err(|e| e.to_string())
5164 })
5165 .await
5166}
5167
5168#[tauri::command]
5169async fn write_text_file(file_path: String, contents: String) -> Result<(), String> {
5170 blocking_res(move || std::fs::write(&file_path, &contents).map_err(|e| e.to_string())).await
5171}
5172
5173#[tauri::command]
5174async fn read_text_file(file_path: String) -> Result<String, String> {
5175 blocking_res(move || std::fs::read_to_string(&file_path).map_err(|e| e.to_string())).await
5176}
5177
5178#[tauri::command]
5179async fn get_home_dir() -> Result<String, String> {
5180 blocking_res(|| {
5181 dirs::home_dir()
5182 .map(|p| p.to_string_lossy().to_string())
5183 .ok_or_else(|| "Could not determine home directory".into())
5184 })
5185 .await
5186}
5187
5188#[tauri::command]
5189async fn import_presets_json(file_path: String) -> Result<Vec<PresetFile>, String> {
5190 blocking_res(move || {
5191 let data = std::fs::read_to_string(&file_path).map_err(|e| e.to_string())?;
5192 if let Ok(arr) = serde_json::from_str::<Vec<PresetFile>>(&data) {
5193 return Ok(arr);
5194 }
5195 let val: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
5196 if let Some(arr) = val.get("presets") {
5197 return serde_json::from_value(arr.clone()).map_err(|e| e.to_string());
5198 }
5199 Err("Expected a JSON array of presets or { \"presets\": [...] }".into())
5200 })
5201 .await
5202}
5203
5204#[tauri::command]
5205async fn import_pdfs_json(file_path: String) -> Result<Vec<PdfFile>, String> {
5206 blocking_res(move || {
5207 let data = std::fs::read_to_string(&file_path).map_err(|e| e.to_string())?;
5208 if let Ok(arr) = serde_json::from_str::<Vec<PdfFile>>(&data) {
5209 return Ok(arr);
5210 }
5211 let val: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
5212 if let Some(arr) = val.get("pdfs") {
5213 return serde_json::from_value(arr.clone()).map_err(|e| e.to_string());
5214 }
5215 Err("Expected a JSON array of PDFs or { \"pdfs\": [...] }".into())
5216 })
5217 .await
5218}
5219
5220#[tauri::command]
5221async fn import_audio_json(file_path: String) -> Result<Vec<AudioSample>, String> {
5222 blocking_res(move || {
5223 let data = std::fs::read_to_string(&file_path).map_err(|e| e.to_string())?;
5224 if let Ok(arr) = serde_json::from_str::<Vec<AudioSample>>(&data) {
5225 return Ok(arr);
5226 }
5227 let val: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
5228 if let Some(arr) = val.get("samples") {
5229 return serde_json::from_value(arr.clone()).map_err(|e| e.to_string());
5230 }
5231 Err("Expected a JSON array of samples or { \"samples\": [...] }".into())
5232 })
5233 .await
5234}
5235
5236#[tauri::command]
5237async fn import_daw_json(file_path: String) -> Result<Vec<DawProject>, String> {
5238 blocking_res(move || {
5239 let data = std::fs::read_to_string(&file_path).map_err(|e| e.to_string())?;
5240 if let Ok(arr) = serde_json::from_str::<Vec<DawProject>>(&data) {
5241 return Ok(arr);
5242 }
5243 let val: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
5244 if let Some(arr) = val.get("projects") {
5245 return serde_json::from_value(arr.clone()).map_err(|e| e.to_string());
5246 }
5247 Err("Expected a JSON array of projects or { \"projects\": [...] }".into())
5248 })
5249 .await
5250}
5251
5252#[cfg(test)]
5255mod tests {
5256 use super::*;
5257 use std::fs;
5258 use std::sync::Mutex;
5259
5260 static APP_LOG_TEST_LOCK: Mutex<()> = Mutex::new(());
5262
5263 static TEST_DATA_DIR_SERIAL: Mutex<()> = Mutex::new(());
5267
5268 fn app_log_lock() -> std::sync::MutexGuard<'static, ()> {
5269 APP_LOG_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
5270 }
5271
5272 fn rt_block_on<F: std::future::Future>(f: F) -> F::Output {
5274 tokio::runtime::Runtime::new()
5275 .expect("tokio runtime for lib.rs tests")
5276 .block_on(f)
5277 }
5278
5279 struct TestDataDirGuard {
5282 path: std::path::PathBuf,
5283 _serial: std::sync::MutexGuard<'static, ()>,
5284 }
5285 impl Drop for TestDataDirGuard {
5286 fn drop(&mut self) {
5287 history::clear_test_data_dir_path();
5288 let _ = fs::remove_dir_all(&self.path);
5289 }
5290 }
5291
5292 fn test_data_dir() -> TestDataDirGuard {
5293 let serial = TEST_DATA_DIR_SERIAL
5294 .lock()
5295 .unwrap_or_else(|e| e.into_inner());
5296 let tmp = std::env::temp_dir().join(format!(
5297 "ah_data_test_{}_{}",
5298 std::process::id(),
5299 std::time::SystemTime::now()
5300 .duration_since(std::time::UNIX_EPOCH)
5301 .map(|d| d.as_nanos())
5302 .unwrap_or(0)
5303 ));
5304 let _ = fs::remove_dir_all(&tmp);
5305 fs::create_dir_all(&tmp).unwrap();
5306 history::set_test_data_dir_path(tmp.clone());
5307 TestDataDirGuard {
5308 path: tmp,
5309 _serial: serial,
5310 }
5311 }
5312
5313 fn make_plugin(name: &str, plugin_type: &str) -> PluginInfo {
5314 PluginInfo {
5315 name: name.into(),
5316 path: format!("/lib/{}.vst3", name),
5317 plugin_type: plugin_type.into(),
5318 version: "1.0.0".into(),
5319 manufacturer: "TestCo".into(),
5320 manufacturer_url: Some("https://testco.com".into()),
5321 size: "2.5 MB".into(),
5322 size_bytes: 2621440,
5323 modified: "2025-01-01".into(),
5324 architectures: vec!["ARM64".into(), "x86_64".into()],
5325 }
5326 }
5327
5328 #[test]
5329 fn test_csv_escape_plain() {
5330 assert_eq!(csv_escape("hello"), "hello");
5331 }
5332
5333 #[test]
5334 fn test_csv_escape_comma() {
5335 assert_eq!(csv_escape("a,b"), "\"a,b\"");
5336 }
5337
5338 #[test]
5339 fn test_csv_escape_quotes() {
5340 assert_eq!(csv_escape("say \"hi\""), "\"say \"\"hi\"\"\"");
5341 }
5342
5343 #[test]
5344 fn test_csv_escape_newline() {
5345 assert_eq!(csv_escape("line1\nline2"), "\"line1\nline2\"");
5346 }
5347
5348 #[test]
5349 fn test_csv_escape_empty() {
5350 assert_eq!(csv_escape(""), "");
5351 }
5352
5353 #[test]
5354 fn test_csv_escape_comma_and_quotes() {
5355 assert_eq!(csv_escape("a,\"b\""), "\"a,\"\"b\"\"\"");
5356 }
5357
5358 #[test]
5359 fn test_format_size_shared_tb() {
5360 assert_eq!(format_size(0), "0 B");
5361 assert_eq!(format_size(1024_u64.pow(4)), "1.0 TB");
5362 assert_eq!(format_size(1024_u64.pow(5)), "1024.0 TB");
5364 }
5365
5366 #[test]
5367 fn test_format_size_fractional_kb() {
5368 assert_eq!(format_size(2048 + 512), "2.5 KB");
5369 }
5370
5371 #[test]
5372 fn test_format_size_single_byte_and_sub_kb() {
5373 assert_eq!(format_size(1), "1.0 B");
5374 assert_eq!(format_size(1023), "1023.0 B");
5375 }
5376
5377 #[test]
5378 fn test_format_size_mb_boundary() {
5379 assert_eq!(format_size(1024 * 1024), "1.0 MB");
5380 assert_eq!(format_size(1024 * 1024 + 512 * 1024), "1.5 MB");
5381 }
5382
5383 #[test]
5384 fn test_dsv_escape_tab_in_field() {
5385 assert_eq!(dsv_escape("a\tb", ','), "a\tb");
5386 assert_eq!(dsv_escape("a\tb", '\t'), "\"a\tb\"");
5387 }
5388
5389 #[test]
5390 fn test_dsv_escape_semicolon_field_when_sep_is_semicolon() {
5391 assert_eq!(dsv_escape("a;b", ';'), "\"a;b\"");
5392 assert_eq!(dsv_escape("plain", ';'), "plain");
5393 }
5394
5395 #[test]
5396 fn test_dsv_escape_quote_only() {
5397 assert_eq!(dsv_escape("\"", ','), "\"\"\"\"");
5398 }
5399
5400 #[test]
5401 fn test_dsv_escape_newline_requires_quoting() {
5402 assert_eq!(
5403 dsv_escape("a\nb", ','),
5404 "\"a\nb\"",
5405 "embedded newline must quote for CSV/DSV"
5406 );
5407 assert_eq!(dsv_escape("line1\nline2", '\t'), "\"line1\nline2\"");
5408 }
5409
5410 #[test]
5411 fn test_detect_separator() {
5412 assert_eq!(detect_separator("x.csv"), ',');
5413 assert_eq!(detect_separator("/path/to/out.tsv"), '\t');
5414 assert_eq!(detect_separator("nested/dir/report.csv"), ',');
5415 assert_eq!(detect_separator("sheet.tsv"), '\t');
5416 }
5417
5418 #[test]
5419 fn test_read_zip_xml_returns_named_entry() {
5420 use std::io::Write;
5421 let tmp = std::env::temp_dir().join("upum_test_lib_read_zip_named.zip");
5422 let _ = fs::remove_file(&tmp);
5423 let file = fs::File::create(&tmp).unwrap();
5424 let mut zip = zip::ZipWriter::new(file);
5425 zip.start_file::<_, ()>("notes.txt", Default::default())
5426 .unwrap();
5427 zip.write_all(b"noise").unwrap();
5428 zip.start_file::<_, ()>("project.xml", Default::default())
5429 .unwrap();
5430 zip.write_all(b"<Project>ok</Project>").unwrap();
5431 zip.finish().unwrap();
5432
5433 let xml = read_zip_xml(tmp.to_str().unwrap(), &["project.xml"]).unwrap();
5434 assert_eq!(xml, "<Project>ok</Project>");
5435 let _ = fs::remove_file(&tmp);
5436 }
5437
5438 #[test]
5439 fn test_read_zip_xml_fallback_scans_first_xml_member() {
5440 use std::io::Write;
5441 let tmp = std::env::temp_dir().join("upum_test_lib_read_zip_fallback.zip");
5442 let _ = fs::remove_file(&tmp);
5443 let file = fs::File::create(&tmp).unwrap();
5444 let mut zip = zip::ZipWriter::new(file);
5445 zip.start_file::<_, ()>("nested/session.xml", Default::default())
5446 .unwrap();
5447 zip.write_all(b"<Session/>").unwrap();
5448 zip.finish().unwrap();
5449
5450 let xml = read_zip_xml(tmp.to_str().unwrap(), &["project.xml"]).unwrap();
5451 assert_eq!(xml, "<Session/>");
5452 let _ = fs::remove_file(&tmp);
5453 }
5454
5455 #[test]
5456 fn test_read_zip_xml_invalid_file_errors() {
5457 let tmp = std::env::temp_dir().join("upum_test_lib_not_zip.bin");
5458 let _ = fs::remove_file(&tmp);
5459 fs::write(&tmp, b"plain text not zip").unwrap();
5460 let err = read_zip_xml(tmp.to_str().unwrap(), &["a.xml"]).unwrap_err();
5461 assert!(
5462 err.contains("Not a valid ZIP") || err.contains("zip"),
5463 "unexpected err: {err}"
5464 );
5465 let _ = fs::remove_file(&tmp);
5466 }
5467
5468 #[test]
5469 fn test_read_zip_xml_no_xml_member_errors() {
5470 use std::io::Write;
5471 let tmp = std::env::temp_dir().join("upum_test_lib_zip_no_xml.zip");
5472 let _ = fs::remove_file(&tmp);
5473 let file = fs::File::create(&tmp).unwrap();
5474 let mut zip = zip::ZipWriter::new(file);
5475 zip.start_file::<_, ()>("readme.txt", Default::default())
5476 .unwrap();
5477 zip.write_all(b"hello").unwrap();
5478 zip.finish().unwrap();
5479
5480 let err = read_zip_xml(tmp.to_str().unwrap(), &["missing.xml"]).unwrap_err();
5481 assert_eq!(err, "No XML found in archive");
5482 let _ = fs::remove_file(&tmp);
5483 }
5484
5485 #[test]
5486 fn test_read_binary_project_inner_missing_file_errors() {
5487 assert!(read_binary_project_inner("/nonexistent/audio_haxor_binary_probe.bin").is_err());
5488 }
5489
5490 #[test]
5491 fn test_read_binary_project_inner_extracts_printable_plugin_paths() {
5492 let tmp = std::env::temp_dir().join("upum_test_read_bin_inner.flp");
5493 let _ = fs::remove_file(&tmp);
5494 let mut blob = vec![0u8, 0x01, 0x02, 0x03];
5495 blob.extend_from_slice(b"/Library/Audio/Plug-Ins/VST3/PluginA.vst3");
5496 blob.push(0);
5497 blob.extend_from_slice(b"C:\\VSTPlugins\\PluginB.dll");
5498 blob.push(0);
5499 fs::write(&tmp, &blob).unwrap();
5500 let v = read_binary_project_inner(tmp.to_str().unwrap()).unwrap();
5501 let plugins: Vec<&str> = v["plugins"]
5502 .as_array()
5503 .unwrap()
5504 .iter()
5505 .filter_map(|x| x.as_str())
5506 .collect();
5507 assert!(
5508 plugins.contains(&"/Library/Audio/Plug-Ins/VST3/PluginA.vst3"),
5509 "plugins={plugins:?}"
5510 );
5511 assert!(
5512 plugins.contains(&"C:\\VSTPlugins\\PluginB.dll"),
5513 "plugins={plugins:?}"
5514 );
5515 let _ = fs::remove_file(&tmp);
5516 }
5517
5518 #[test]
5519 fn test_read_binary_project_adds_format_display_name() {
5520 let tmp = std::env::temp_dir().join("upum_test_read_bin_fmt.cpr");
5521 let _ = fs::remove_file(&tmp);
5522 fs::write(&tmp, b"x").unwrap();
5523 let v = read_binary_project(tmp.to_string_lossy().to_string(), "cpr").unwrap();
5524 assert_eq!(
5525 v.get("_format").and_then(|x| x.as_str()),
5526 Some("Cubase Project (.cpr)")
5527 );
5528 let _ = fs::remove_file(&tmp);
5529 }
5530
5531 #[test]
5532 fn test_plugins_to_export_empty() {
5533 let result = plugins_to_export(&[]);
5534 assert!(result.is_empty());
5535 }
5536
5537 #[test]
5538 fn test_plugins_to_export_preserves_fields() {
5539 let plugins = vec![make_plugin("Serum", "VST3")];
5540 let exported = plugins_to_export(&plugins);
5541 assert_eq!(exported.len(), 1);
5542 assert_eq!(exported[0].name, "Serum");
5543 assert_eq!(exported[0].plugin_type, "VST3");
5544 assert_eq!(exported[0].version, "1.0.0");
5545 assert_eq!(exported[0].manufacturer, "TestCo");
5546 assert_eq!(
5547 exported[0].manufacturer_url,
5548 Some("https://testco.com".into())
5549 );
5550 }
5551
5552 #[test]
5553 fn test_plugins_to_export_no_url() {
5554 let mut p = make_plugin("NoUrl", "AU");
5555 p.manufacturer_url = None;
5556 let exported = plugins_to_export(&[p]);
5557 assert_eq!(exported[0].manufacturer_url, None);
5558 }
5559
5560 #[test]
5561 fn test_export_import_json_roundtrip() {
5562 let tmp = std::env::temp_dir().join("upum_test_export_json.json");
5563 let _ = fs::remove_file(&tmp);
5564
5565 let plugins = vec![make_plugin("PluginA", "VST3"), make_plugin("PluginB", "AU")];
5566
5567 rt_block_on(export_plugins_json(
5568 plugins.clone(),
5569 tmp.to_string_lossy().to_string(),
5570 ))
5571 .unwrap();
5572 let imported = rt_block_on(import_plugins_json(tmp.to_string_lossy().to_string())).unwrap();
5573
5574 assert_eq!(imported.len(), 2);
5575 assert_eq!(imported[0].name, "PluginA");
5576 assert_eq!(imported[0].plugin_type, "VST3");
5577 assert_eq!(imported[1].name, "PluginB");
5578 assert_eq!(imported[1].plugin_type, "AU");
5579 assert_eq!(imported[1].manufacturer, "TestCo");
5580
5581 let _ = fs::remove_file(&tmp);
5582 }
5583
5584 #[test]
5585 fn test_export_json_contains_metadata() {
5586 let tmp = std::env::temp_dir().join("upum_test_export_meta.json");
5587 let _ = fs::remove_file(&tmp);
5588
5589 let plugins = vec![make_plugin("Test", "VST2")];
5590 rt_block_on(export_plugins_json(plugins, tmp.to_string_lossy().to_string())).unwrap();
5591
5592 let content = fs::read_to_string(&tmp).unwrap();
5593 let payload: serde_json::Value = serde_json::from_str(&content).unwrap();
5594 assert_eq!(payload["version"], env!("CARGO_PKG_VERSION"));
5595 assert!(payload["exported_at"].as_str().unwrap().contains("T"));
5596 assert_eq!(payload["plugins"].as_array().unwrap().len(), 1);
5597
5598 let _ = fs::remove_file(&tmp);
5599 }
5600
5601 #[test]
5602 fn test_export_csv_format() {
5603 let tmp = std::env::temp_dir().join("upum_test_export.csv");
5604 let _ = fs::remove_file(&tmp);
5605
5606 let plugins = vec![make_plugin("Serum", "VST3")];
5607 rt_block_on(export_plugins_csv(plugins, tmp.to_string_lossy().to_string())).unwrap();
5608
5609 let content = fs::read_to_string(&tmp).unwrap();
5610 let lines: Vec<&str> = content.lines().collect();
5611 assert_eq!(
5612 lines[0],
5613 "Name,Type,Version,Manufacturer,Manufacturer URL,Path,Size,Modified"
5614 );
5615 assert!(lines[1].starts_with("Serum,VST3,1.0.0,TestCo,"));
5616
5617 let _ = fs::remove_file(&tmp);
5618 }
5619
5620 #[test]
5621 fn test_export_csv_escapes_commas() {
5622 let tmp = std::env::temp_dir().join("upum_test_export_escape.csv");
5623 let _ = fs::remove_file(&tmp);
5624
5625 let mut p = make_plugin("Plugin, With Comma", "VST3");
5626 p.manufacturer = "Company, Inc.".into();
5627 rt_block_on(export_plugins_csv(vec![p], tmp.to_string_lossy().to_string())).unwrap();
5628
5629 let content = fs::read_to_string(&tmp).unwrap();
5630 assert!(content.contains("\"Plugin, With Comma\""));
5631 assert!(content.contains("\"Company, Inc.\""));
5632
5633 let _ = fs::remove_file(&tmp);
5634 }
5635
5636 #[test]
5637 fn test_export_plugins_tsv_uses_tab_separator_and_header() {
5638 let tmp = std::env::temp_dir().join("upum_test_export_plugins.tsv");
5639 let _ = fs::remove_file(&tmp);
5640
5641 let plugins = vec![make_plugin("Serum", "VST3")];
5642 rt_block_on(export_plugins_csv(plugins, tmp.to_string_lossy().to_string())).unwrap();
5643
5644 let content = fs::read_to_string(&tmp).unwrap();
5645 let lines: Vec<&str> = content.lines().collect();
5646 assert_eq!(
5647 lines[0],
5648 "Name\tType\tVersion\tManufacturer\tManufacturer URL\tPath\tSize\tModified"
5649 );
5650 assert!(
5651 !lines[1].contains(','),
5652 "TSV data row should use tabs, not commas: {}",
5653 lines[1]
5654 );
5655 assert!(lines[1].contains('\t'));
5656
5657 let _ = fs::remove_file(&tmp);
5658 }
5659
5660 #[test]
5661 fn test_import_plugins_json_errors_on_malformed_json() {
5662 let tmp = std::env::temp_dir().join("upum_test_import_plugins_bad.json");
5663 let _ = fs::remove_file(&tmp);
5664 fs::write(&tmp, "{ not json").unwrap();
5665 let err = rt_block_on(import_plugins_json(tmp.to_string_lossy().to_string())).unwrap_err();
5666 assert!(!err.is_empty());
5667 let _ = fs::remove_file(&tmp);
5668 }
5669
5670 #[test]
5671 fn test_import_json_invalid_file() {
5672 let result = rt_block_on(import_plugins_json("/nonexistent/path.json".into()));
5673 assert!(result.is_err());
5674 }
5675
5676 #[test]
5677 fn test_import_json_invalid_format() {
5678 let tmp = std::env::temp_dir().join("upum_test_import_bad.json");
5679 fs::write(&tmp, "not valid json").unwrap();
5680
5681 let result = rt_block_on(import_plugins_json(tmp.to_string_lossy().to_string()));
5682 assert!(result.is_err());
5683
5684 let _ = fs::remove_file(&tmp);
5685 }
5686
5687 #[test]
5688 fn test_import_json_empty_plugins() {
5689 let tmp = std::env::temp_dir().join("upum_test_import_empty.json");
5690 let content = r#"{"version":"1.0","exported_at":"2025-01-01T00:00:00Z","plugins":[]}"#;
5691 fs::write(&tmp, content).unwrap();
5692
5693 let result = rt_block_on(import_plugins_json(tmp.to_string_lossy().to_string())).unwrap();
5694 assert!(result.is_empty());
5695
5696 let _ = fs::remove_file(&tmp);
5697 }
5698
5699 #[test]
5700 fn test_import_plugins_json_errors_when_plugins_is_not_array() {
5701 let tmp = std::env::temp_dir().join("upum_test_import_plugins_wrong_type.json");
5702 let _ = fs::remove_file(&tmp);
5703 fs::write(
5704 &tmp,
5705 r#"{"version":"1.0","exported_at":"2025-01-01T00:00:00Z","plugins":"not-an-array"}"#,
5706 )
5707 .unwrap();
5708 let err = rt_block_on(import_plugins_json(tmp.to_string_lossy().to_string())).unwrap_err();
5709 assert!(!err.is_empty());
5710 let _ = fs::remove_file(&tmp);
5711 }
5712
5713 #[test]
5715 fn test_import_plugins_json_extra_keys_on_plugin_ignored() {
5716 let tmp = std::env::temp_dir().join("upum_test_import_plugins_extra_keys.json");
5717 let _ = fs::remove_file(&tmp);
5718 let content = r#"{
5719 "version":"1.0",
5720 "exported_at":"2025-01-01T00:00:00Z",
5721 "plugins":[{
5722 "name":"Extra",
5723 "type":"VST3",
5724 "version":"1",
5725 "manufacturer":"M",
5726 "path":"/p.vst3",
5727 "size":"1 B",
5728 "sizeBytes":1,
5729 "modified":"t",
5730 "architectures":[],
5731 "futureProofField":true
5732 }]
5733 }"#;
5734 fs::write(&tmp, content).unwrap();
5735 let imported = rt_block_on(import_plugins_json(tmp.to_string_lossy().to_string())).unwrap();
5736 assert_eq!(imported.len(), 1);
5737 assert_eq!(imported[0].name, "Extra");
5738 assert_eq!(imported[0].plugin_type, "VST3");
5739 let _ = fs::remove_file(&tmp);
5740 }
5741
5742 #[test]
5743 fn test_export_csv_empty_plugins() {
5744 let tmp = std::env::temp_dir().join("upum_test_export_empty.csv");
5745 let _ = fs::remove_file(&tmp);
5746
5747 rt_block_on(export_plugins_csv(vec![], tmp.to_string_lossy().to_string())).unwrap();
5748 let content = fs::read_to_string(&tmp).unwrap();
5749 let lines: Vec<&str> = content.lines().collect();
5750 assert_eq!(lines.len(), 1); assert!(lines[0].starts_with("Name,"));
5752
5753 let _ = fs::remove_file(&tmp);
5754 }
5755
5756 #[test]
5757 fn test_plugins_to_export_multiple() {
5758 let plugins = vec![
5759 make_plugin("A", "VST2"),
5760 make_plugin("B", "VST3"),
5761 make_plugin("C", "AU"),
5762 ];
5763 let exported = plugins_to_export(&plugins);
5764 assert_eq!(exported.len(), 3);
5765 assert_eq!(exported[0].name, "A");
5766 assert_eq!(exported[2].plugin_type, "AU");
5767 }
5768
5769 #[test]
5770 fn test_export_payload_serde() {
5771 let payload = ExportPayload {
5772 version: "1.0".into(),
5773 exported_at: "2025-01-01T00:00:00Z".into(),
5774 plugins: vec![ExportPlugin {
5775 name: "Test".into(),
5776 plugin_type: "VST3".into(),
5777 version: "2.0".into(),
5778 manufacturer: "Co".into(),
5779 manufacturer_url: None,
5780 path: "/test".into(),
5781 size: "1 MB".into(),
5782 size_bytes: 1048576,
5783 modified: "2025-01-01".into(),
5784 architectures: vec![],
5785 }],
5786 };
5787
5788 let json = serde_json::to_string(&payload).unwrap();
5789 let deserialized: ExportPayload = serde_json::from_str(&json).unwrap();
5790 assert_eq!(deserialized.version, "1.0");
5791 assert_eq!(deserialized.plugins.len(), 1);
5792 assert_eq!(deserialized.plugins[0].name, "Test");
5793 assert!(deserialized.plugins[0].manufacturer_url.is_none());
5794 }
5795
5796 #[test]
5797 fn test_export_plugin_skips_none_url_in_json() {
5798 let plugin = ExportPlugin {
5799 name: "Test".into(),
5800 plugin_type: "VST3".into(),
5801 version: "1.0".into(),
5802 manufacturer: "Co".into(),
5803 manufacturer_url: None,
5804 path: "/test".into(),
5805 size: "1 MB".into(),
5806 size_bytes: 0,
5807 modified: "2025-01-01".into(),
5808 architectures: vec![],
5809 };
5810 let json = serde_json::to_string(&plugin).unwrap();
5811 assert!(!json.contains("manufacturer_url"));
5812 }
5813
5814 #[test]
5815 fn test_export_plugin_includes_url_in_json() {
5816 let plugin = ExportPlugin {
5817 name: "Test".into(),
5818 plugin_type: "VST3".into(),
5819 version: "1.0".into(),
5820 manufacturer: "Co".into(),
5821 manufacturer_url: Some("https://co.com".into()),
5822 path: "/test".into(),
5823 size: "1 MB".into(),
5824 size_bytes: 0,
5825 modified: "2025-01-01".into(),
5826 architectures: vec![],
5827 };
5828 let json = serde_json::to_string(&plugin).unwrap();
5829 assert!(json.contains("manufacturer_url"));
5830 assert!(json.contains("https://co.com"));
5831 }
5832
5833 fn make_audio_sample(name: &str, format: &str) -> AudioSample {
5836 AudioSample {
5837 name: name.into(),
5838 path: format!("/tmp/{}.{}", name, format.to_lowercase()),
5839 directory: "/tmp".into(),
5840 format: format.into(),
5841 size: 1024,
5842 size_formatted: "1.0 KB".into(),
5843 modified: "2025-01-01".into(),
5844 duration: None,
5845 channels: None,
5846 sample_rate: None,
5847 bits_per_sample: None,
5848 }
5849 }
5850
5851 fn make_daw_project(name: &str, format: &str, daw: &str) -> DawProject {
5852 DawProject {
5853 name: name.into(),
5854 path: format!("/tmp/{}.{}", name, format.to_lowercase()),
5855 directory: "/tmp".into(),
5856 format: format.into(),
5857 daw: daw.into(),
5858 size: 2048,
5859 size_formatted: "2.0 KB".into(),
5860 modified: "2025-01-01".into(),
5861 }
5862 }
5863
5864 fn make_preset(name: &str, format: &str) -> PresetFile {
5865 PresetFile {
5866 name: name.into(),
5867 path: format!("/tmp/{}.{}", name, format.to_lowercase()),
5868 directory: "/tmp".into(),
5869 format: format.into(),
5870 size: 512,
5871 size_formatted: "512 B".into(),
5872 modified: "2025-01-01".into(),
5873 }
5874 }
5875
5876 #[test]
5877 fn test_import_audio_json_valid() {
5878 let tmp = std::env::temp_dir().join("upum_test_import_audio.json");
5879 let samples = vec![
5880 make_audio_sample("kick", "WAV"),
5881 make_audio_sample("snare", "FLAC"),
5882 ];
5883 let json = serde_json::to_string_pretty(&samples).unwrap();
5884 fs::write(&tmp, &json).unwrap();
5885
5886 let result = rt_block_on(import_audio_json(tmp.to_string_lossy().to_string()));
5887 assert!(result.is_ok());
5888 let imported = result.unwrap();
5889 assert_eq!(imported.len(), 2);
5890 assert_eq!(imported[0].name, "kick");
5891 assert_eq!(imported[1].format, "FLAC");
5892
5893 let _ = fs::remove_file(&tmp);
5894 }
5895
5896 #[test]
5897 fn test_import_audio_json_extra_field_on_sample_ignored() {
5898 let tmp = std::env::temp_dir().join("upum_test_import_audio_extra_field.json");
5899 let _ = fs::remove_file(&tmp);
5900 let content = r#"{
5901 "version":"1.0",
5902 "exported_at":"2025-01-01T00:00:00Z",
5903 "samples":[{
5904 "name":"Extra",
5905 "path":"/a.wav",
5906 "directory":"/d",
5907 "format":"WAV",
5908 "size":100,
5909 "sizeFormatted":"100 B",
5910 "modified":"t",
5911 "futureProof":true
5912 }]
5913 }"#;
5914 fs::write(&tmp, content).unwrap();
5915 let imported = rt_block_on(import_audio_json(tmp.to_string_lossy().to_string())).unwrap();
5916 assert_eq!(imported.len(), 1);
5917 assert_eq!(imported[0].name, "Extra");
5918 assert_eq!(imported[0].format, "WAV");
5919 let _ = fs::remove_file(&tmp);
5920 }
5921
5922 #[test]
5923 fn test_import_audio_json_invalid_format() {
5924 let tmp = std::env::temp_dir().join("upum_test_import_audio_bad.json");
5925 fs::write(&tmp, r#"{"not": "an array"}"#).unwrap();
5926
5927 let result = rt_block_on(import_audio_json(tmp.to_string_lossy().to_string()));
5928 assert!(result.is_err());
5929
5930 let _ = fs::remove_file(&tmp);
5931 }
5932
5933 #[test]
5934 fn test_import_audio_json_nonexistent() {
5935 let result = rt_block_on(import_audio_json("/tmp/nonexistent_audio_file.json".into()));
5936 assert!(result.is_err());
5937 }
5938
5939 #[test]
5940 fn test_import_daw_json_valid() {
5941 let tmp = std::env::temp_dir().join("upum_test_import_daw.json");
5942 let projects = vec![
5943 make_daw_project("Song1", "ALS", "Ableton Live"),
5944 make_daw_project("Song2", "FLP", "FL Studio"),
5945 ];
5946 let json = serde_json::to_string_pretty(&projects).unwrap();
5947 fs::write(&tmp, &json).unwrap();
5948
5949 let result = rt_block_on(import_daw_json(tmp.to_string_lossy().to_string()));
5950 assert!(result.is_ok());
5951 let imported = result.unwrap();
5952 assert_eq!(imported.len(), 2);
5953 assert_eq!(imported[0].daw, "Ableton Live");
5954 assert_eq!(imported[1].format, "FLP");
5955
5956 let _ = fs::remove_file(&tmp);
5957 }
5958
5959 #[test]
5960 fn test_import_daw_json_extra_field_on_project_ignored() {
5961 let tmp = std::env::temp_dir().join("upum_test_import_daw_extra_field.json");
5962 let _ = fs::remove_file(&tmp);
5963 let content = r#"{
5964 "version":"1.0",
5965 "exported_at":"2025-01-01T00:00:00Z",
5966 "projects":[{
5967 "name":"Extra",
5968 "path":"/p.als",
5969 "directory":"/tmp",
5970 "format":"ALS",
5971 "daw":"Ableton Live",
5972 "size":2048,
5973 "sizeFormatted":"2.0 KB",
5974 "modified":"t",
5975 "futureProof":true
5976 }]
5977 }"#;
5978 fs::write(&tmp, content).unwrap();
5979 let imported = rt_block_on(import_daw_json(tmp.to_string_lossy().to_string())).unwrap();
5980 assert_eq!(imported.len(), 1);
5981 assert_eq!(imported[0].name, "Extra");
5982 assert_eq!(imported[0].daw, "Ableton Live");
5983 let _ = fs::remove_file(&tmp);
5984 }
5985
5986 #[test]
5987 fn test_import_daw_json_invalid_format() {
5988 let tmp = std::env::temp_dir().join("upum_test_import_daw_bad.json");
5989 fs::write(&tmp, "not json at all").unwrap();
5990
5991 let result = rt_block_on(import_daw_json(tmp.to_string_lossy().to_string()));
5992 assert!(result.is_err());
5993
5994 let _ = fs::remove_file(&tmp);
5995 }
5996
5997 #[test]
5998 fn test_import_presets_json_valid() {
5999 let tmp = std::env::temp_dir().join("upum_test_import_presets.json");
6000 let presets = vec![make_preset("Lead", "FXP"), make_preset("Pad", "VSTPRESET")];
6001 let json = serde_json::to_string_pretty(&presets).unwrap();
6002 fs::write(&tmp, &json).unwrap();
6003
6004 let result = rt_block_on(import_presets_json(tmp.to_string_lossy().to_string()));
6005 assert!(result.is_ok());
6006 let imported = result.unwrap();
6007 assert_eq!(imported.len(), 2);
6008 assert_eq!(imported[0].name, "Lead");
6009 assert_eq!(imported[1].format, "VSTPRESET");
6010
6011 let _ = fs::remove_file(&tmp);
6012 }
6013
6014 #[test]
6015 fn test_import_presets_json_extra_field_on_preset_ignored() {
6016 let tmp = std::env::temp_dir().join("upum_test_import_presets_extra_field.json");
6017 let _ = fs::remove_file(&tmp);
6018 let content = r#"{
6019 "version":"1.0",
6020 "exported_at":"2025-01-01T00:00:00Z",
6021 "presets":[{
6022 "name":"Extra",
6023 "path":"/p.fxp",
6024 "directory":"/d",
6025 "format":"FXP",
6026 "size":100,
6027 "sizeFormatted":"100 B",
6028 "modified":"t",
6029 "futureProof":true
6030 }]
6031 }"#;
6032 fs::write(&tmp, content).unwrap();
6033 let imported = rt_block_on(import_presets_json(tmp.to_string_lossy().to_string())).unwrap();
6034 assert_eq!(imported.len(), 1);
6035 assert_eq!(imported[0].name, "Extra");
6036 assert_eq!(imported[0].format, "FXP");
6037 let _ = fs::remove_file(&tmp);
6038 }
6039
6040 #[test]
6041 fn test_import_presets_json_invalid_format() {
6042 let tmp = std::env::temp_dir().join("upum_test_import_presets_bad.json");
6043 fs::write(&tmp, r#"[{"wrong": "fields"}]"#).unwrap();
6044
6045 let result = rt_block_on(import_presets_json(tmp.to_string_lossy().to_string()));
6046 assert!(result.is_err());
6047
6048 let _ = fs::remove_file(&tmp);
6049 }
6050
6051 #[test]
6052 fn test_export_import_presets_roundtrip() {
6053 let tmp = std::env::temp_dir().join("upum_test_preset_roundtrip.json");
6054 let presets = vec![
6055 make_preset("Bass", "FXB"),
6056 make_preset("Keys", "AUPRESET"),
6057 make_preset("Strings", "H2P"),
6058 ];
6059
6060 rt_block_on(export_presets_json(
6061 presets.clone(),
6062 tmp.to_string_lossy().to_string(),
6063 ))
6064 .unwrap();
6065 let imported = rt_block_on(import_presets_json(tmp.to_string_lossy().to_string())).unwrap();
6066
6067 assert_eq!(imported.len(), 3);
6068 assert_eq!(imported[0].name, presets[0].name);
6069 assert_eq!(imported[1].format, presets[1].format);
6070 assert_eq!(imported[2].size, presets[2].size);
6071
6072 let _ = fs::remove_file(&tmp);
6073 }
6074
6075 #[test]
6076 fn test_export_import_audio_roundtrip() {
6077 let tmp = std::env::temp_dir().join("upum_test_audio_roundtrip.json");
6078 let samples = vec![
6079 make_audio_sample("hi-hat", "WAV"),
6080 make_audio_sample("pad", "FLAC"),
6081 ];
6082
6083 rt_block_on(export_audio_json(
6084 samples.clone(),
6085 tmp.to_string_lossy().to_string(),
6086 ))
6087 .unwrap();
6088 let imported = rt_block_on(import_audio_json(tmp.to_string_lossy().to_string())).unwrap();
6089
6090 assert_eq!(imported.len(), 2);
6091 assert_eq!(imported[0].name, "hi-hat");
6092 assert_eq!(imported[1].format, "FLAC");
6093
6094 let _ = fs::remove_file(&tmp);
6095 }
6096
6097 #[test]
6098 fn test_export_import_daw_roundtrip() {
6099 let tmp = std::env::temp_dir().join("upum_test_daw_roundtrip.json");
6100 let projects = vec![
6101 make_daw_project("Track1", "LOGICX", "Logic Pro"),
6102 make_daw_project("Track2", "RPP", "REAPER"),
6103 ];
6104
6105 rt_block_on(export_daw_json(
6106 projects.clone(),
6107 tmp.to_string_lossy().to_string(),
6108 ))
6109 .unwrap();
6110 let imported = rt_block_on(import_daw_json(tmp.to_string_lossy().to_string())).unwrap();
6111
6112 assert_eq!(imported.len(), 2);
6113 assert_eq!(imported[0].daw, "Logic Pro");
6114 assert_eq!(imported[1].format, "RPP");
6115
6116 let _ = fs::remove_file(&tmp);
6117 }
6118
6119 #[test]
6120 fn test_import_presets_json_nonexistent() {
6121 let result = rt_block_on(import_presets_json("/tmp/nonexistent_preset_file.json".into()));
6122 assert!(result.is_err());
6123 }
6124
6125 #[test]
6126 fn test_import_daw_json_nonexistent() {
6127 let result = rt_block_on(import_daw_json("/tmp/nonexistent_daw_file.json".into()));
6128 assert!(result.is_err());
6129 }
6130
6131 #[test]
6132 fn test_import_audio_json_empty_array() {
6133 let tmp = std::env::temp_dir().join("upum_test_import_audio_empty.json");
6134 fs::write(&tmp, "[]").unwrap();
6135
6136 let result = rt_block_on(import_audio_json(tmp.to_string_lossy().to_string()));
6137 assert!(result.is_ok());
6138 assert_eq!(result.unwrap().len(), 0);
6139
6140 let _ = fs::remove_file(&tmp);
6141 }
6142
6143 #[test]
6144 fn test_import_presets_json_empty_array() {
6145 let tmp = std::env::temp_dir().join("upum_test_import_presets_empty.json");
6146 fs::write(&tmp, "[]").unwrap();
6147
6148 let result = rt_block_on(import_presets_json(tmp.to_string_lossy().to_string()));
6149 assert!(result.is_ok());
6150 assert_eq!(result.unwrap().len(), 0);
6151
6152 let _ = fs::remove_file(&tmp);
6153 }
6154
6155 #[test]
6156 fn test_import_audio_json_errors_when_object_has_no_samples_key() {
6157 let tmp = std::env::temp_dir().join("upum_test_import_audio_no_samples.json");
6158 fs::write(
6159 &tmp,
6160 r#"{"version":"1.0","exported_at":"2025-01-01T00:00:00Z"}"#,
6161 )
6162 .unwrap();
6163 let err = rt_block_on(import_audio_json(tmp.to_string_lossy().to_string())).unwrap_err();
6164 assert!(err.contains("samples"), "unexpected error: {err}");
6165 let _ = fs::remove_file(&tmp);
6166 }
6167
6168 #[test]
6169 fn test_import_daw_json_empty_array() {
6170 let tmp = std::env::temp_dir().join("upum_test_import_daw_empty.json");
6171 fs::write(&tmp, "[]").unwrap();
6172 let result = rt_block_on(import_daw_json(tmp.to_string_lossy().to_string()));
6173 assert!(result.is_ok());
6174 assert_eq!(result.unwrap().len(), 0);
6175 let _ = fs::remove_file(&tmp);
6176 }
6177
6178 #[test]
6179 fn test_import_daw_json_errors_when_object_has_no_projects_key() {
6180 let tmp = std::env::temp_dir().join("upum_test_import_daw_no_projects.json");
6181 fs::write(&tmp, r#"{"version":"1.0","samples":[]}"#).unwrap();
6182 let err = rt_block_on(import_daw_json(tmp.to_string_lossy().to_string())).unwrap_err();
6183 assert!(err.contains("projects"), "unexpected error: {err}");
6184 let _ = fs::remove_file(&tmp);
6185 }
6186
6187 #[test]
6188 fn test_import_audio_json_errors_when_envelope_uses_projects_key() {
6189 let tmp = std::env::temp_dir().join("upum_test_import_audio_wrong_envelope.json");
6190 fs::write(&tmp, r#"{"projects":[]}"#).unwrap();
6191 let err = rt_block_on(import_audio_json(tmp.to_string_lossy().to_string())).unwrap_err();
6192 assert!(err.contains("samples"), "unexpected error: {err}");
6193 let _ = fs::remove_file(&tmp);
6194 }
6195
6196 #[test]
6197 fn test_import_presets_json_envelope_without_bare_array() {
6198 let tmp = std::env::temp_dir().join("upum_test_import_presets_envelope_only.json");
6199 let preset = make_preset("OnlyEnvelope", "FXP");
6200 let json = serde_json::json!({ "presets": [preset] });
6201 fs::write(&tmp, serde_json::to_string(&json).unwrap()).unwrap();
6202 let imported = rt_block_on(import_presets_json(tmp.to_string_lossy().to_string())).unwrap();
6203 assert_eq!(imported.len(), 1);
6204 assert_eq!(imported[0].name, "OnlyEnvelope");
6205 let _ = fs::remove_file(&tmp);
6206 }
6207
6208 #[test]
6209 fn test_import_audio_json_samples_not_array_returns_error() {
6210 let tmp = std::env::temp_dir().join("upum_test_import_audio_samples_bad_type.json");
6211 fs::write(&tmp, r#"{"samples":"nope"}"#).unwrap();
6212 assert!(rt_block_on(import_audio_json(tmp.to_string_lossy().to_string())).is_err());
6213 let _ = fs::remove_file(&tmp);
6214 }
6215
6216 #[test]
6217 fn test_import_daw_json_projects_not_array_returns_error() {
6218 let tmp = std::env::temp_dir().join("upum_test_import_daw_projects_bad_type.json");
6219 fs::write(&tmp, r#"{"projects":{}}"#).unwrap();
6220 assert!(rt_block_on(import_daw_json(tmp.to_string_lossy().to_string())).is_err());
6221 let _ = fs::remove_file(&tmp);
6222 }
6223
6224 #[test]
6227 fn test_fs_list_dir_valid() {
6228 let tmp = std::env::temp_dir().join("upum_test_fs_list");
6229 let _ = fs::remove_dir_all(&tmp);
6230 fs::create_dir_all(&tmp).unwrap();
6231 fs::write(tmp.join("file1.txt"), "hello").unwrap();
6232 fs::write(tmp.join("file2.wav"), "audio").unwrap();
6233 fs::create_dir(tmp.join("subdir")).unwrap();
6234 fs::write(tmp.join(".hidden"), "skip").unwrap();
6235
6236 let result = rt_block_on(fs_list_dir(tmp.to_string_lossy().to_string())).unwrap();
6237 let entries = result["entries"].as_array().unwrap();
6238 assert_eq!(entries.len(), 3);
6240 assert!(entries[0]["isDir"].as_bool().unwrap());
6242 assert_eq!(entries[0]["name"].as_str().unwrap(), "subdir");
6243 let _ = fs::remove_dir_all(&tmp);
6244 }
6245
6246 #[test]
6247 fn test_fs_list_dir_nonexistent() {
6248 let result = rt_block_on(fs_list_dir("/nonexistent/upum_dir_xyz".into()));
6249 assert!(result.is_err());
6250 }
6251
6252 #[test]
6253 fn test_fs_list_dir_not_a_dir() {
6254 let tmp = std::env::temp_dir().join("upum_test_fs_notdir.txt");
6255 fs::write(&tmp, "data").unwrap();
6256 let result = rt_block_on(fs_list_dir(tmp.to_string_lossy().to_string()));
6257 assert!(result.is_err());
6258 let _ = fs::remove_file(&tmp);
6259 }
6260
6261 #[test]
6262 fn test_delete_file_regular() {
6263 let tmp = std::env::temp_dir().join("upum_test_delete.txt");
6264 fs::write(&tmp, "delete me").unwrap();
6265 assert!(tmp.exists());
6266 rt_block_on(delete_file(tmp.to_string_lossy().to_string())).unwrap();
6267 assert!(!tmp.exists());
6268 }
6269
6270 #[test]
6271 fn test_delete_file_directory() {
6272 let tmp = std::env::temp_dir().join("upum_test_delete_dir");
6273 fs::create_dir_all(tmp.join("inner")).unwrap();
6274 fs::write(tmp.join("inner").join("file.txt"), "data").unwrap();
6275 rt_block_on(delete_file(tmp.to_string_lossy().to_string())).unwrap();
6276 assert!(!tmp.exists());
6277 }
6278
6279 #[test]
6280 fn test_delete_file_nonexistent() {
6281 let result = rt_block_on(delete_file("/nonexistent/upum_file_xyz.txt".into()));
6282 assert!(result.is_err());
6283 }
6284
6285 #[test]
6286 fn test_rename_file() {
6287 let tmp1 = std::env::temp_dir().join("upum_test_rename_old.txt");
6288 let tmp2 = std::env::temp_dir().join("upum_test_rename_new.txt");
6289 let _ = fs::remove_file(&tmp2);
6290 fs::write(&tmp1, "content").unwrap();
6291 rt_block_on(rename_file(
6292 tmp1.to_string_lossy().to_string(),
6293 tmp2.to_string_lossy().to_string(),
6294 ))
6295 .unwrap();
6296 assert!(!tmp1.exists());
6297 assert!(tmp2.exists());
6298 assert_eq!(fs::read_to_string(&tmp2).unwrap(), "content");
6299 let _ = fs::remove_file(&tmp2);
6300 }
6301
6302 #[test]
6303 fn test_get_home_dir() {
6304 let result = rt_block_on(get_home_dir());
6305 assert!(result.is_ok());
6306 let home = result.unwrap();
6307 assert!(!home.is_empty());
6308 assert!(std::path::Path::new(&home).exists());
6309 }
6310
6311 #[test]
6314 fn test_cache_file_roundtrip() {
6315 let _guard = test_data_dir();
6316 let db = db::Database::open().expect("open db for cache roundtrip");
6317 let data = serde_json::json!({"hello": "world", "count": 42});
6318 db.write_cache("test-cache-roundtrip.json", &data).unwrap();
6319 let result = db.read_cache("test-cache-roundtrip.json").unwrap();
6320 assert_eq!(result["hello"], "world");
6321 assert_eq!(result["count"], 42);
6322 }
6323
6324 #[test]
6325 fn test_cache_file_nonexistent() {
6326 let _guard = test_data_dir();
6327 let db = db::Database::open().expect("open db for cache read");
6328 let result = db.read_cache("nonexistent-cache-xyz.json").unwrap();
6329 assert!(result.is_object());
6331 }
6332
6333 #[test]
6334 fn test_append_and_read_log() {
6335 let _guard = app_log_lock();
6336 let _tmp = test_data_dir();
6337 rt_block_on(clear_log()).unwrap();
6338 let token = format!(
6339 "log-test-{}",
6340 std::time::SystemTime::now()
6341 .duration_since(std::time::UNIX_EPOCH)
6342 .map(|d| d.as_nanos())
6343 .unwrap_or(0)
6344 );
6345 append_log(format!("{token} entry1"));
6346 append_log(format!("{token} entry2"));
6347 let log = rt_block_on(read_log()).unwrap();
6348 assert!(
6349 log.contains(&format!("{token} entry1")),
6350 "missing first line in log (len {})",
6351 log.len()
6352 );
6353 assert!(
6354 log.contains(&format!("{token} entry2")),
6355 "missing second line in log (len {})",
6356 log.len()
6357 );
6358 }
6359
6360 #[test]
6361 fn test_clear_log() {
6362 let _guard = app_log_lock();
6363 let _tmp = test_data_dir();
6364 rt_block_on(clear_log()).unwrap();
6365 append_log("before clear".into());
6366 rt_block_on(clear_log()).unwrap();
6367 let log = rt_block_on(read_log()).unwrap();
6368 assert!(!log.contains("before clear"));
6369 }
6370
6371 #[test]
6372 fn test_read_log_missing_file_returns_empty() {
6373 let _guard = app_log_lock();
6374 let tmp = test_data_dir();
6375 let _ = fs::remove_file(tmp.path.join("app.log"));
6376 assert_eq!(rt_block_on(read_log()).unwrap(), "");
6377 }
6378
6379 #[test]
6380 fn test_log_entries_have_timestamp() {
6381 let _guard = app_log_lock();
6382 let _tmp = test_data_dir();
6383 rt_block_on(clear_log()).unwrap();
6384 append_log("timestamp-check".into());
6385 let log = rt_block_on(read_log()).unwrap();
6386 let re =
6388 regex::Regex::new(r"\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] timestamp-check").unwrap();
6389 assert!(re.is_match(&log), "log entry missing timestamp: {}", log);
6390 }
6391
6392 #[test]
6393 fn test_log_appends_not_overwrites() {
6394 let _guard = app_log_lock();
6395 let _tmp = test_data_dir();
6396 rt_block_on(clear_log()).unwrap();
6397 append_log("first".into());
6398 append_log("second".into());
6399 append_log("third".into());
6400 let log = rt_block_on(read_log()).unwrap();
6401 let lines: Vec<&str> = log.lines().collect();
6402 assert!(
6403 lines.len() >= 3,
6404 "expected at least 3 lines, got {}",
6405 lines.len()
6406 );
6407 assert!(lines.iter().any(|l| l.contains("first")));
6408 assert!(lines.iter().any(|l| l.contains("second")));
6409 assert!(lines.iter().any(|l| l.contains("third")));
6410 let first_pos = log.find("first").unwrap();
6412 let second_pos = log.find("second").unwrap();
6413 let third_pos = log.find("third").unwrap();
6414 assert!(
6415 first_pos < second_pos && second_pos < third_pos,
6416 "log entries out of order"
6417 );
6418 }
6419
6420 #[test]
6421 fn test_log_handles_special_characters() {
6422 let _guard = app_log_lock();
6423 let _tmp = test_data_dir();
6424 rt_block_on(clear_log()).unwrap();
6425 append_log("unicode: 日本語テスト 🎵 emoji".into());
6426 append_log("newlines: line1\nline2".into());
6427 append_log("path: /Users/test/my file (1).vst3".into());
6428 let log = rt_block_on(read_log()).unwrap();
6429 assert!(log.contains("日本語テスト"));
6430 assert!(log.contains("🎵"));
6431 assert!(log.contains("my file (1).vst3"));
6432 }
6433
6434 #[test]
6435 fn test_log_concurrent_appends() {
6436 let _guard = app_log_lock();
6437 let tmp = test_data_dir();
6438 rt_block_on(clear_log()).unwrap();
6439 let path = tmp.path.clone();
6440 let handles: Vec<_> = (0..10)
6441 .map(|i| {
6442 let path = path.clone();
6443 std::thread::spawn(move || {
6444 history::set_test_data_dir_path(path);
6445 append_log(format!("concurrent-{i}"));
6446 })
6447 })
6448 .collect();
6449 for h in handles {
6450 h.join().unwrap();
6451 }
6452 let log = rt_block_on(read_log()).unwrap();
6453 for i in 0..10 {
6454 assert!(
6455 log.contains(&format!("concurrent-{i}")),
6456 "missing concurrent-{i}"
6457 );
6458 }
6459 }
6460
6461 #[test]
6462 fn test_clear_log_then_append_works() {
6463 let _guard = app_log_lock();
6464 let _tmp = test_data_dir();
6465 rt_block_on(clear_log()).unwrap();
6466 append_log("before".into());
6467 rt_block_on(clear_log()).unwrap();
6468 append_log("after".into());
6469 let log = rt_block_on(read_log()).unwrap();
6470 assert!(!log.contains("before"), "cleared content should be gone");
6471 assert!(log.contains("after"), "new content should be present");
6472 }
6473
6474 #[test]
6477 fn test_export_import_toml_roundtrip() {
6478 let tmp = std::env::temp_dir().join("upum_test_export.toml");
6479 let data = serde_json::json!({
6480 "plugins": [{"name": "Test", "version": "1.0"}]
6481 });
6482 rt_block_on(export_toml(data.clone(), tmp.to_string_lossy().to_string())).unwrap();
6483 let imported = rt_block_on(import_toml(tmp.to_string_lossy().to_string())).unwrap();
6484 assert!(imported["plugins"].is_array());
6485 let _ = fs::remove_file(&tmp);
6486 }
6487
6488 #[test]
6489 fn test_import_toml_nonexistent() {
6490 let result = rt_block_on(import_toml("/nonexistent/file.toml".into()));
6491 assert!(result.is_err());
6492 }
6493
6494 #[test]
6495 fn test_import_toml_invalid() {
6496 let tmp = std::env::temp_dir().join("upum_test_invalid.toml");
6497 fs::write(&tmp, "this is not valid toml [[[").unwrap();
6498 let result = rt_block_on(import_toml(tmp.to_string_lossy().to_string()));
6499 assert!(result.is_err());
6500 let _ = fs::remove_file(&tmp);
6501 }
6502
6503 #[test]
6506 fn test_export_presets_dsv_csv() {
6507 let tmp = std::env::temp_dir().join("upum_test_presets.csv");
6508 let presets = vec![PresetFile {
6509 name: "Lead".into(),
6510 path: "/presets/lead.fxp".into(),
6511 directory: "/presets".into(),
6512 format: "FXP".into(),
6513 size: 1024,
6514 size_formatted: "1.0 KB".into(),
6515 modified: "2024-01-01".into(),
6516 }];
6517 rt_block_on(export_presets_dsv(presets, tmp.to_string_lossy().to_string())).unwrap();
6518 let content = fs::read_to_string(&tmp).unwrap();
6519 assert!(content.contains("Lead"));
6520 assert!(content.contains("FXP"));
6521 assert!(content.contains(","));
6522 let _ = fs::remove_file(&tmp);
6523 }
6524
6525 #[test]
6526 fn test_export_presets_dsv_tsv() {
6527 let tmp = std::env::temp_dir().join("upum_test_presets.tsv");
6528 let presets = vec![PresetFile {
6529 name: "Bass".into(),
6530 path: "/presets/bass.fxp".into(),
6531 directory: "/presets".into(),
6532 format: "FXP".into(),
6533 size: 2048,
6534 size_formatted: "2.0 KB".into(),
6535 modified: "2024-02-01".into(),
6536 }];
6537 rt_block_on(export_presets_dsv(presets, tmp.to_string_lossy().to_string())).unwrap();
6538 let content = fs::read_to_string(&tmp).unwrap();
6539 assert!(content.contains("Bass"));
6540 assert!(content.contains("\t"));
6541 let _ = fs::remove_file(&tmp);
6542 }
6543
6544 #[test]
6547 fn test_band_validation_valid() {
6548 let tmp = std::env::temp_dir().join("upum_test_valid.band");
6549 fs::create_dir_all(tmp.join("Media")).unwrap();
6550 fs::write(tmp.join("projectData"), b"bplist00fake").unwrap();
6551 assert!(daw_scanner::is_package_ext(&tmp));
6552 let _ = fs::remove_dir_all(&tmp);
6553 }
6554
6555 #[test]
6556 fn test_band_validation_no_bplist() {
6557 let tmp = std::env::temp_dir().join("upum_test_nobplist.band");
6558 fs::create_dir_all(tmp.join("Media")).unwrap();
6559 fs::write(tmp.join("projectData"), b"not a plist").unwrap();
6560 assert!(daw_scanner::is_package_ext(&tmp));
6563 let _ = fs::remove_dir_all(&tmp);
6564 }
6565
6566 #[test]
6569 fn test_open_daw_project_nonexistent() {
6570 let rt = tokio::runtime::Runtime::new().unwrap();
6571 let result = rt.block_on(open_daw_project("/nonexistent/project.als".into()));
6572 assert!(result.is_err());
6573 assert!(result.unwrap_err().contains("not found"));
6574 }
6575
6576 #[test]
6577 fn bulk_format_size_non_empty() {
6578 for i in 0..12_000u32 {
6579 let b = i as u64 * 17 + (i as u64 % 1024);
6580 let s = format_size(b);
6581 assert!(!s.is_empty(), "format_size({b})");
6582 }
6583 }
6584
6585 #[test]
6586 fn test_format_size_one_gb() {
6587 assert_eq!(format_size(1024_u64.pow(3)), "1.0 GB");
6588 }
6589
6590 #[test]
6591 fn test_format_size_one_byte_below_one_gib_stays_in_mb_tier() {
6592 let b = 1024_u64.pow(3) - 1;
6593 let s = format_size(b);
6594 assert!(
6595 s.ends_with(" MB"),
6596 "just under 1 GiB should use MB unit, got {s}"
6597 );
6598 }
6599
6600 #[test]
6601 fn test_detect_separator_unknown_extension_defaults_csv() {
6602 assert_eq!(detect_separator("export.data"), ',');
6603 assert_eq!(detect_separator("/tmp/no_extension"), ',');
6604 }
6605
6606 #[test]
6607 fn test_export_pdf_writes_pdf_magic_bytes() {
6608 let tmp =
6609 std::env::temp_dir().join(format!("ah_export_pdf_test_{}.pdf", std::process::id()));
6610 let _ = fs::remove_file(&tmp);
6611 rt_block_on(export_pdf(
6612 "Unit test".into(),
6613 vec!["Col A".into(), "Col B".into()],
6614 vec![vec!["cell-a".into(), "cell-b".into()]],
6615 tmp.to_string_lossy().to_string(),
6616 ))
6617 .unwrap();
6618 let bytes = fs::read(&tmp).unwrap();
6619 assert!(
6620 bytes.starts_with(b"%PDF-"),
6621 "expected PDF header, got {:?}",
6622 &bytes[..bytes.len().min(16)]
6623 );
6624 let _ = fs::remove_file(&tmp);
6625 }
6626}
6627
6628#[tauri::command]
6631async fn db_query_audio(params: db::AudioQueryParams) -> Result<db::AudioQueryResult, String> {
6632 tokio::task::spawn_blocking(move || db::global().query_audio(¶ms))
6633 .await
6634 .map_err(|e| format!("db_query_audio task: {e}"))?
6635}
6636
6637#[tauri::command(rename_all = "snake_case")]
6638async fn db_query_plugins(
6639 search: Option<String>,
6640 type_filter: Option<String>,
6641 status_filter: Option<String>,
6642 sort_key: Option<String>,
6643 sort_asc: Option<bool>,
6644 search_regex: Option<bool>,
6645 offset: Option<u64>,
6646 limit: Option<u64>,
6647) -> Result<db::PluginQueryResult, String> {
6648 let search_regex = search_regex.unwrap_or(false);
6649 tokio::task::spawn_blocking(move || {
6650 db::global().query_plugins(
6651 search.as_deref(),
6652 type_filter.as_deref(),
6653 status_filter.as_deref(),
6654 &sort_key.unwrap_or("name".into()),
6655 sort_asc.unwrap_or(true),
6656 search_regex,
6657 offset.unwrap_or(0),
6658 limit.unwrap_or(200),
6659 )
6660 })
6661 .await
6662 .map_err(|e| format!("db_query_plugins task: {e}"))?
6663}
6664
6665#[tauri::command(rename_all = "snake_case")]
6666async fn db_query_daw(
6667 search: Option<String>,
6668 daw_filter: Option<String>,
6669 sort_key: Option<String>,
6670 sort_asc: Option<bool>,
6671 search_regex: Option<bool>,
6672 offset: Option<u64>,
6673 limit: Option<u64>,
6674) -> Result<db::DawQueryResult, String> {
6675 let search_regex = search_regex.unwrap_or(false);
6676 tokio::task::spawn_blocking(move || {
6677 db::global().query_daw(
6678 search.as_deref(),
6679 daw_filter.as_deref(),
6680 &sort_key.unwrap_or("name".into()),
6681 sort_asc.unwrap_or(true),
6682 search_regex,
6683 offset.unwrap_or(0),
6684 limit.unwrap_or(200),
6685 )
6686 })
6687 .await
6688 .map_err(|e| format!("db_query_daw task: {e}"))?
6689}
6690
6691#[tauri::command(rename_all = "snake_case")]
6692async fn db_query_presets(
6693 search: Option<String>,
6694 format_filter: Option<String>,
6695 sort_key: Option<String>,
6696 sort_asc: Option<bool>,
6697 search_regex: Option<bool>,
6698 offset: Option<u64>,
6699 limit: Option<u64>,
6700) -> Result<db::PresetQueryResult, String> {
6701 let search_regex = search_regex.unwrap_or(false);
6702 tokio::task::spawn_blocking(move || {
6703 db::global().query_presets(
6704 search.as_deref(),
6705 format_filter.as_deref(),
6706 &sort_key.unwrap_or("name".into()),
6707 sort_asc.unwrap_or(true),
6708 search_regex,
6709 offset.unwrap_or(0),
6710 limit.unwrap_or(200),
6711 )
6712 })
6713 .await
6714 .map_err(|e| format!("db_query_presets task: {e}"))?
6715}
6716
6717#[tauri::command]
6718async fn db_audio_stats(scan_id: Option<String>) -> Result<db::AudioStatsResult, String> {
6719 blocking_res(move || db::global().audio_stats(scan_id.as_deref())).await
6720}
6721
6722#[tauri::command]
6723async fn db_daw_stats(scan_id: Option<String>) -> Result<db::DawStatsResult, String> {
6724 blocking_res(move || db::global().daw_stats(scan_id.as_deref())).await
6725}
6726
6727#[tauri::command]
6728async fn db_preset_stats(scan_id: Option<String>) -> Result<db::PresetStatsResult, String> {
6729 blocking_res(move || db::global().preset_stats(scan_id.as_deref())).await
6730}
6731
6732#[tauri::command(rename_all = "snake_case")]
6733async fn db_query_pdfs(
6734 search: Option<String>,
6735 sort_key: Option<String>,
6736 sort_asc: Option<bool>,
6737 search_regex: Option<bool>,
6738 offset: Option<u64>,
6739 limit: Option<u64>,
6740) -> Result<db::PdfQueryResult, String> {
6741 let search_regex = search_regex.unwrap_or(false);
6742 tokio::task::spawn_blocking(move || {
6743 db::global().query_pdfs(
6744 search.as_deref(),
6745 &sort_key.unwrap_or("name".into()),
6746 sort_asc.unwrap_or(true),
6747 search_regex,
6748 offset.unwrap_or(0),
6749 limit.unwrap_or(200),
6750 )
6751 })
6752 .await
6753 .map_err(|e| format!("db_query_pdfs task: {e}"))?
6754}
6755
6756#[derive(Debug, Serialize)]
6758pub struct PalettePreviewResult {
6759 pub plugins: db::PluginQueryResult,
6760 pub audio: db::AudioQueryResult,
6761 pub daw: db::DawQueryResult,
6762 pub presets: db::PresetQueryResult,
6763 pub pdfs: db::PdfQueryResult,
6764 pub midi: db::MidiQueryResult,
6765}
6766
6767fn palette_preview_empty() -> PalettePreviewResult {
6768 PalettePreviewResult {
6769 plugins: db::PluginQueryResult {
6770 plugins: vec![],
6771 total_count: 0,
6772 total_count_capped: false,
6773 total_unfiltered: 0,
6774 },
6775 audio: db::AudioQueryResult {
6776 samples: vec![],
6777 total_count: 0,
6778 total_count_capped: false,
6779 total_unfiltered: 0,
6780 },
6781 daw: db::DawQueryResult {
6782 projects: vec![],
6783 total_count: 0,
6784 total_count_capped: false,
6785 total_unfiltered: 0,
6786 },
6787 presets: db::PresetQueryResult {
6788 presets: vec![],
6789 total_count: 0,
6790 total_count_capped: false,
6791 total_unfiltered: 0,
6792 },
6793 pdfs: db::PdfQueryResult {
6794 pdfs: vec![],
6795 total_count: 0,
6796 total_count_capped: false,
6797 total_unfiltered: 0,
6798 },
6799 midi: db::MidiQueryResult {
6800 midi_files: vec![],
6801 total_count: 0,
6802 total_count_capped: false,
6803 total_unfiltered: 0,
6804 },
6805 }
6806}
6807
6808#[tauri::command(rename_all = "snake_case")]
6809async fn db_query_palette_preview(search: String) -> Result<PalettePreviewResult, String> {
6810 let search = search.trim().to_string();
6811 if search.len() < 2 {
6812 return Ok(palette_preview_empty());
6813 }
6814 tokio::task::spawn_blocking(move || {
6815 let db = db::global();
6816 let plugins = db.query_plugins(Some(&search), None, None, "name", true, false, 0, 6)?;
6817 let audio = db.query_audio(&db::AudioQueryParams {
6818 scan_id: None,
6819 search: Some(search.clone()),
6820 search_regex: false,
6821 format_filter: None,
6822 sort_key: "name".into(),
6823 sort_asc: true,
6824 offset: 0,
6825 limit: 6,
6826 })?;
6827 let daw = db.query_daw(Some(&search), None, "name", true, false, 0, 6)?;
6828 let presets = db.query_presets(Some(&search), None, "name", true, false, 0, 6)?;
6829 let pdfs = db.query_pdfs(Some(&search), "name", true, false, 0, 6)?;
6830 let midi = db.query_midi(Some(&search), None, "name", true, false, 0, 6)?;
6831 Ok(PalettePreviewResult {
6832 plugins,
6833 audio,
6834 daw,
6835 presets,
6836 pdfs,
6837 midi,
6838 })
6839 })
6840 .await
6841 .map_err(|e| format!("db_query_palette_preview task: {e}"))?
6842}
6843
6844#[tauri::command]
6845async fn db_pdf_stats(scan_id: Option<String>) -> Result<db::PdfStatsResult, String> {
6846 blocking_res(move || db::global().pdf_stats(scan_id.as_deref())).await
6847}
6848
6849#[tauri::command(rename_all = "snake_case")]
6850async fn db_audio_filter_stats(
6851 search: Option<String>,
6852 format_filter: Option<String>,
6853 search_regex: Option<bool>,
6854) -> Result<db::FilterStatsResult, String> {
6855 let search_regex = search_regex.unwrap_or(false);
6856 tokio::task::spawn_blocking(move || {
6857 db::global().audio_filter_stats(
6858 search.as_deref(),
6859 format_filter.as_deref(),
6860 search_regex,
6861 )
6862 })
6863 .await
6864 .map_err(|e| format!("db_audio_filter_stats task: {e}"))?
6865}
6866
6867#[tauri::command(rename_all = "snake_case")]
6868async fn db_daw_filter_stats(
6869 search: Option<String>,
6870 daw_filter: Option<String>,
6871 search_regex: Option<bool>,
6872) -> Result<db::FilterStatsResult, String> {
6873 let search_regex = search_regex.unwrap_or(false);
6874 tokio::task::spawn_blocking(move || {
6875 db::global().daw_filter_stats(
6876 search.as_deref(),
6877 daw_filter.as_deref(),
6878 search_regex,
6879 )
6880 })
6881 .await
6882 .map_err(|e| format!("db_daw_filter_stats task: {e}"))?
6883}
6884
6885#[tauri::command(rename_all = "snake_case")]
6886async fn db_preset_filter_stats(
6887 search: Option<String>,
6888 format_filter: Option<String>,
6889 search_regex: Option<bool>,
6890) -> Result<db::FilterStatsResult, String> {
6891 let search_regex = search_regex.unwrap_or(false);
6892 tokio::task::spawn_blocking(move || {
6893 db::global().preset_filter_stats(
6894 search.as_deref(),
6895 format_filter.as_deref(),
6896 search_regex,
6897 )
6898 })
6899 .await
6900 .map_err(|e| format!("db_preset_filter_stats task: {e}"))?
6901}
6902
6903#[tauri::command(rename_all = "snake_case")]
6904async fn db_plugin_filter_stats(
6905 search: Option<String>,
6906 type_filter: Option<String>,
6907 search_regex: Option<bool>,
6908) -> Result<db::FilterStatsResult, String> {
6909 let search_regex = search_regex.unwrap_or(false);
6910 tokio::task::spawn_blocking(move || {
6911 db::global().plugin_filter_stats(
6912 search.as_deref(),
6913 type_filter.as_deref(),
6914 search_regex,
6915 )
6916 })
6917 .await
6918 .map_err(|e| format!("db_plugin_filter_stats task: {e}"))?
6919}
6920
6921#[tauri::command(rename_all = "snake_case")]
6922async fn db_pdf_filter_stats(
6923 search: Option<String>,
6924 search_regex: Option<bool>,
6925) -> Result<db::FilterStatsResult, String> {
6926 let search_regex = search_regex.unwrap_or(false);
6927 tokio::task::spawn_blocking(move || {
6928 db::global().pdf_filter_stats(search.as_deref(), search_regex)
6929 })
6930 .await
6931 .map_err(|e| format!("db_pdf_filter_stats task: {e}"))?
6932}
6933
6934#[tauri::command]
6937async fn get_active_scan_inventory_counts() -> Result<serde_json::Value, String> {
6938 blocking_res(|| db::global().active_scan_inventory_counts()).await
6939}
6940
6941#[tauri::command]
6942async fn db_list_scans() -> Result<Vec<db::ScanInfo>, String> {
6943 blocking_res(|| db::global().list_scans()).await
6944}
6945
6946#[tauri::command]
6947async fn db_update_bpm(path: String, bpm: Option<f64>) -> Result<(), String> {
6948 blocking_res(move || db::global().update_bpm(&path, bpm)).await
6949}
6950
6951#[tauri::command]
6952async fn db_update_key(path: String, key: Option<String>) -> Result<(), String> {
6953 blocking_res(move || db::global().update_key(&path, key.as_deref())).await
6954}
6955
6956#[tauri::command]
6957async fn db_update_lufs(path: String, lufs: Option<f64>) -> Result<(), String> {
6958 blocking_res(move || db::global().update_lufs(&path, lufs)).await
6959}
6960
6961#[tauri::command]
6963async fn db_update_analysis(
6964 path: String,
6965 bpm: Option<f64>,
6966 key: Option<String>,
6967 lufs: Option<f64>,
6968) -> Result<(), String> {
6969 let row = vec![(path, bpm, key, lufs)];
6970 blocking_res(move || db::global().batch_update_analysis(&row).map(|_| ())).await
6971}
6972
6973#[tauri::command]
6974async fn db_backfill_audio_meta(paths: Vec<String>) -> Result<serde_json::Value, String> {
6975 blocking_res(move || {
6976 let missing = db::global().paths_missing_audio_meta(&paths)?;
6977 if missing.is_empty() {
6978 return Ok(serde_json::json!({}));
6979 }
6980 let mut updated = serde_json::Map::new();
6981 for p in &missing {
6982 let am = audio_scanner::get_audio_metadata(p);
6983 if am.duration.is_some() || am.channels.is_some() {
6984 db::global().update_audio_meta(
6985 p,
6986 am.duration,
6987 am.channels,
6988 am.sample_rate,
6989 am.bits_per_sample,
6990 )?;
6991 let mut obj = serde_json::Map::new();
6992 if let Some(d) = am.duration {
6993 obj.insert("duration".into(), serde_json::json!(d));
6994 }
6995 if let Some(c) = am.channels {
6996 obj.insert("channels".into(), serde_json::json!(c));
6997 }
6998 if let Some(sr) = am.sample_rate {
6999 obj.insert("sampleRate".into(), serde_json::json!(sr));
7000 }
7001 if let Some(bps) = am.bits_per_sample {
7002 obj.insert("bitsPerSample".into(), serde_json::json!(bps));
7003 }
7004 updated.insert(p.clone(), serde_json::Value::Object(obj));
7005 }
7006 }
7007 Ok(serde_json::Value::Object(updated))
7008 })
7009 .await
7010}
7011
7012#[tauri::command]
7013async fn db_get_analysis(path: String) -> Result<serde_json::Value, String> {
7014 blocking_res(move || db::global().get_analysis(&path)).await
7015}
7016
7017#[tauri::command]
7018async fn db_unanalyzed_paths(limit: Option<u64>) -> Result<Vec<String>, String> {
7019 blocking_res(move || db::global().unanalyzed_paths(limit.unwrap_or(100))).await
7020}
7021
7022#[tauri::command]
7023async fn db_audio_library_paths() -> Result<Vec<String>, String> {
7024 blocking_res(move || db::global().audio_library_paths()).await
7025}
7026
7027#[tauri::command]
7028async fn db_migrate_json() -> Result<usize, String> {
7029 blocking_res(|| db::global().migrate_from_json()).await
7030}
7031
7032#[tauri::command]
7033async fn db_cache_stats() -> Result<Vec<db::CacheStat>, String> {
7034 blocking_res(|| db::global().cache_stats()).await
7035}
7036
7037#[tauri::command]
7038async fn db_clear_caches() -> Result<(), String> {
7039 append_log("DB CLEAR — all caches (waveform, spectrogram, xref, fingerprint, kvr)".into());
7040 blocking_res(|| db::global().clear_all_caches()).await
7041}
7042
7043#[tauri::command]
7044async fn db_clear_cache_table(table: String) -> Result<(), String> {
7045 append_log(format!("DB CLEAR — cache table: {}", table));
7046 blocking_res(move || db::global().clear_cache_table(&table)).await
7047}
7048
7049fn resolve_ui_locale(locale: Option<String>) -> String {
7050 locale.unwrap_or_else(|| {
7051 history::load_preferences()
7052 .get("uiLocale")
7053 .and_then(|v| v.as_str().map(|s| s.to_string()))
7054 .unwrap_or_else(|| "en".to_string())
7055 })
7056}
7057
7058#[tauri::command]
7059async fn get_app_strings(
7060 locale: Option<String>,
7061) -> Result<std::collections::HashMap<String, String>, String> {
7062 let loc = resolve_ui_locale(locale);
7063 blocking_res(move || db::global().get_app_strings(&loc)).await
7064}
7065
7066#[tauri::command]
7067async fn get_toast_strings(
7068 locale: Option<String>,
7069) -> Result<std::collections::HashMap<String, String>, String> {
7070 get_app_strings(locale).await
7071}
7072
7073#[tauri::command]
7075fn refresh_native_menu(app: AppHandle) -> Result<(), String> {
7076 let ui_locale = resolve_ui_locale(None);
7077 let strings = db::global().get_app_strings(&ui_locale).unwrap_or_default();
7078 let menu = native_menu::build_native_menu_bar(&app, &strings).map_err(|e| e.to_string())?;
7079 app.set_menu(menu).map_err(|e| e.to_string())?;
7080 let tray_state = app.state::<tray_menu::TrayState>();
7081 tray_menu::refresh_tray_popup_menu(&app, &tray_state, &strings)?;
7082 Ok(())
7083}
7084
7085#[tauri::command]
7088fn start_file_watcher(app: AppHandle, dirs: Vec<String>) -> Result<(), String> {
7089 append_log(format!(
7090 "FILE WATCHER START — {} directories: {:?}",
7091 dirs.len(),
7092 dirs
7093 ));
7094 let state = app.state::<file_watcher::FileWatcherState>();
7095 file_watcher::start_watching(&app, &state, dirs)
7096}
7097
7098#[tauri::command]
7099fn stop_file_watcher(app: AppHandle) -> Result<(), String> {
7100 append_log("FILE WATCHER STOP".into());
7101 let state = app.state::<file_watcher::FileWatcherState>();
7102 file_watcher::stop_watching(&state);
7103 Ok(())
7104}
7105
7106#[tauri::command]
7107fn get_file_watcher_status(app: AppHandle) -> serde_json::Value {
7108 let state = app.state::<file_watcher::FileWatcherState>();
7109 serde_json::json!({
7110 "watching": file_watcher::is_watching(&state),
7111 "dirs": file_watcher::get_watched_dirs(&state),
7112 })
7113}
7114
7115#[cfg_attr(mobile, tauri::mobile_entry_point)]
7118pub fn run() {
7119 std::panic::set_hook(Box::new(|info| {
7121 let path = history::ensure_data_dir().join("app.log");
7122 let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
7123 let location = info
7124 .location()
7125 .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
7126 .unwrap_or_default();
7127 let payload = if let Some(s) = info.payload().downcast_ref::<&str>() {
7128 s.to_string()
7129 } else if let Some(s) = info.payload().downcast_ref::<String>() {
7130 s.clone()
7131 } else {
7132 "unknown panic".into()
7133 };
7134 let backtrace = std::backtrace::Backtrace::force_capture();
7135 let msg = format!("[{timestamp}] PANIC at {location}: {payload}\n{backtrace}\n");
7136 eprintln!("{msg}");
7137 let _ = std::fs::OpenOptions::new()
7138 .create(true)
7139 .append(true)
7140 .open(&path)
7141 .and_then(|mut f| {
7142 use std::io::Write;
7143 f.write_all(msg.as_bytes())
7144 });
7145 }));
7146
7147 APP_START.get_or_init(Instant::now);
7149
7150 extern "C" fn on_exit() {
7152 let _ = audio_engine::shutdown_audio_engine_child();
7153 log_shutdown();
7154 }
7155 unsafe {
7156 libc::atexit(on_exit);
7157 }
7158
7159 let prefs = history::load_preferences();
7161 refresh_log_verbosity_from_prefs();
7162
7163 let rss = get_rss_bytes();
7165 let db_path = history::ensure_data_dir().join("audio_haxor.db");
7166 let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
7167 let rayon_threads = rayon::current_num_threads();
7168 let hostname = sysinfo::System::host_name().unwrap_or_default();
7169 #[cfg(unix)]
7171 let fd_target: u64 = prefs
7172 .get("fdLimit")
7173 .and_then(|v| v.as_str().and_then(|s| s.parse().ok()).or(v.as_u64()))
7174 .unwrap_or(10240)
7175 .clamp(256, 65536);
7176 #[cfg(not(unix))]
7177 let fd_target: u64 = 0;
7178
7179 let batch_size = prefs
7180 .get("batchSize")
7181 .and_then(|v| v.as_str())
7182 .unwrap_or("100");
7183 let channel_buffer = prefs
7184 .get("channelBuffer")
7185 .and_then(|v| v.as_str())
7186 .unwrap_or("512");
7187 let flush_interval = prefs
7188 .get("flushInterval")
7189 .and_then(|v| v.as_str())
7190 .unwrap_or("100");
7191 let analysis_pause = prefs
7192 .get("analysisPause")
7193 .and_then(|v| v.as_str())
7194 .unwrap_or("100");
7195 let page_size = prefs
7196 .get("pageSize")
7197 .and_then(|v| v.as_str())
7198 .unwrap_or("200");
7199 let auto_scan = prefs
7200 .get("autoScan")
7201 .and_then(|v| v.as_str())
7202 .unwrap_or("off");
7203 let folder_watch = prefs
7204 .get("folderWatch")
7205 .and_then(|v| v.as_str())
7206 .unwrap_or("off");
7207 let log_verbosity = prefs
7208 .get("logVerbosity")
7209 .and_then(|v| v.as_str())
7210 .unwrap_or("normal");
7211
7212 append_log(format!(
7213 "APP START — v{} | {} {} | {} | {} cores | {} rayon threads | pid {} | RSS {} | DB {}",
7214 env!("CARGO_PKG_VERSION"),
7215 std::env::consts::OS,
7216 std::env::consts::ARCH,
7217 hostname,
7218 num_cpus::get(),
7219 rayon_threads,
7220 std::process::id(),
7221 format_size(rss),
7222 format_size(db_size),
7223 ));
7224 append_log(format!(
7225 "CONFIG — fd_limit: {} | batch_size: {} | channel_buffer: {} | flush_interval: {}ms | analysis_pause: {}ms | page_size: {} | auto_scan: {} | folder_watch: {} | log_verbosity: {}",
7226 fd_target, batch_size, channel_buffer, flush_interval, analysis_pause, page_size, auto_scan, folder_watch, log_verbosity,
7227 ));
7228
7229 #[cfg(unix)]
7230 {
7231 let mut rlim = libc::rlimit {
7232 rlim_cur: 0,
7233 rlim_max: 0,
7234 };
7235 unsafe {
7236 if libc::getrlimit(libc::RLIMIT_NOFILE, &mut rlim) == 0 {
7237 let target = (rlim.rlim_max).min(fd_target);
7238 if rlim.rlim_cur < target {
7239 rlim.rlim_cur = target;
7240 libc::setrlimit(libc::RLIMIT_NOFILE, &rlim);
7241 }
7242 }
7243 }
7244 }
7245
7246 let multiplier = prefs
7251 .get("threadMultiplier")
7252 .and_then(|v| {
7253 v.as_str()
7254 .or_else(|| v.as_u64().map(|_| ""))
7255 .and_then(|s| s.parse::<usize>().ok())
7256 })
7257 .or_else(|| {
7258 prefs
7259 .get("threadMultiplier")
7260 .and_then(|v| v.as_u64().map(|n| n as usize))
7261 })
7262 .unwrap_or(8)
7263 .clamp(1, 16);
7264 let pool_size = num_cpus::get() * multiplier;
7265 append_log(format!(
7266 "THREAD POOL — {}x multiplier | {} threads | 8MB stack",
7267 multiplier, pool_size,
7268 ));
7269 rayon::ThreadPoolBuilder::new()
7270 .num_threads(pool_size)
7271 .stack_size(8 * 1024 * 1024)
7272 .panic_handler(|panic_info| {
7273 let msg = format!("Rayon thread panicked: {:?}", panic_info);
7274 eprintln!("{msg}");
7275 append_log(msg);
7276 })
7277 .build_global()
7278 .ok();
7279
7280 db::init_global().expect("Failed to initialize database");
7282
7283 const STARTUP_DB_LIGHT_DELAY_MS: u64 = 750;
7287 const STARTUP_DB_HEAVY_DELAY_SECS: u64 = 12;
7288 std::thread::spawn(|| {
7289 std::thread::sleep(std::time::Duration::from_millis(STARTUP_DB_LIGHT_DELAY_MS));
7290 db::global().housekeep_light();
7291 });
7292 std::thread::spawn(|| {
7293 std::thread::sleep(std::time::Duration::from_secs(STARTUP_DB_HEAVY_DELAY_SECS));
7294 db::global().housekeep_heavy();
7295 if let Ok(counts) = db::global().table_counts() {
7296 let m = counts.as_object().unwrap();
7297 let get = |k: &str| m.get(k).and_then(|v| v.as_u64()).unwrap_or(0);
7298 append_log(format!(
7299 "DB STATS — {} plugins | {} samples | {} DAW projects | {} presets | {} KVR cache | {} waveforms | {} spectrograms | {} xref | {} fingerprints",
7300 get("plugins"), get("audio_samples"), get("daw_projects"), get("presets"),
7301 get("kvr_cache"), get("waveform_cache"), get("spectrogram_cache"), get("xref_cache"), get("fingerprint_cache"),
7302 ));
7303 }
7304 });
7305
7306 tauri::Builder::default()
7307 .plugin(tauri_plugin_shell::init())
7308 .plugin(tauri_plugin_dialog::init())
7309 .plugin(tauri_plugin_drag::init())
7310 .manage(ScanState {
7311 scanning: AtomicBool::new(false),
7312 stop_scan: AtomicBool::new(false),
7313 })
7314 .manage(UpdateState {
7315 checking: AtomicBool::new(false),
7316 stop_updates: AtomicBool::new(false),
7317 })
7318 .manage(AudioScanState {
7319 scanning: AtomicBool::new(false),
7320 stop_scan: AtomicBool::new(false),
7321 })
7322 .manage(DawScanState {
7323 scanning: AtomicBool::new(false),
7324 stop_scan: AtomicBool::new(false),
7325 })
7326 .manage(PresetScanState {
7327 scanning: AtomicBool::new(false),
7328 stop_scan: AtomicBool::new(false),
7329 })
7330 .manage(MidiScanState {
7331 scanning: AtomicBool::new(false),
7332 stop_scan: AtomicBool::new(false),
7333 })
7334 .manage(PdfScanState {
7335 scanning: AtomicBool::new(false),
7336 stop_scan: AtomicBool::new(false),
7337 })
7338 .manage(WalkerStatus {
7339 plugin_dirs: Arc::new(std::sync::Mutex::new(Vec::new())),
7340 audio_dirs: Arc::new(std::sync::Mutex::new(Vec::new())),
7341 daw_dirs: Arc::new(std::sync::Mutex::new(Vec::new())),
7342 preset_dirs: Arc::new(std::sync::Mutex::new(Vec::new())),
7343 midi_dirs: Arc::new(std::sync::Mutex::new(Vec::new())),
7344 pdf_dirs: Arc::new(std::sync::Mutex::new(Vec::new())),
7345 unified_scanning: AtomicBool::new(false),
7346 })
7347 .manage(file_watcher::FileWatcherState::new())
7348 .manage(tray_menu::TrayState::default())
7349 .invoke_handler(tauri::generate_handler![
7350 get_version,
7351 get_build_info,
7352 get_walker_status,
7353 scan_plugins,
7354 stop_scan,
7355 check_updates,
7356 stop_updates,
7357 resolve_kvr,
7358 history_get_scans,
7359 history_get_detail,
7360 history_delete,
7361 history_clear,
7362 history_diff,
7363 history_latest,
7364 kvr_cache_get,
7365 kvr_cache_update,
7366 scan_audio_samples,
7367 stop_audio_scan,
7368 get_audio_metadata,
7369 audio_history_save,
7370 audio_history_get_scans,
7371 audio_history_get_detail,
7372 audio_history_delete,
7373 audio_history_clear,
7374 audio_history_latest,
7375 audio_history_diff,
7376 scan_daw_projects,
7377 stop_daw_scan,
7378 daw_history_save,
7379 daw_history_get_scans,
7380 daw_history_get_detail,
7381 daw_history_delete,
7382 daw_history_clear,
7383 daw_history_latest,
7384 daw_history_diff,
7385 open_daw_folder,
7386 open_daw_project,
7387 extract_project_plugins,
7388 read_als_xml,
7389 estimate_bpm,
7390 detect_audio_key,
7391 measure_lufs,
7392 batch_analyze,
7393 read_cache_file,
7394 write_cache_file,
7395 audio_engine_invoke,
7396 audio_engine_restart,
7397 audio_engine_eof_watchdog_start,
7398 audio_engine_eof_watchdog_stop,
7399 get_audio_engine_process_stats,
7400 append_log,
7401 read_log,
7402 clear_log,
7403 list_data_files,
7404 delete_data_file,
7405 read_bwproject,
7406 read_project_file,
7407 compute_fingerprint,
7408 find_similar_samples,
7409 build_fingerprint_cache,
7410 find_content_duplicates,
7411 open_update_url,
7412 open_plugin_folder,
7413 open_audio_folder,
7414 export_plugins_json,
7415 export_plugins_csv,
7416 import_plugins_json,
7417 export_audio_json,
7418 export_audio_dsv,
7419 export_daw_json,
7420 export_daw_dsv,
7421 prefs_get_all,
7422 prefs_set,
7423 prefs_remove,
7424 prefs_save_all,
7425 scan_presets,
7426 stop_preset_scan,
7427 preset_history_save,
7428 preset_history_get_scans,
7429 preset_history_get_detail,
7430 preset_history_delete,
7431 preset_history_clear,
7432 preset_history_latest,
7433 preset_history_diff,
7434 open_preset_folder,
7435 scan_midi_files,
7436 stop_midi_scan,
7437 midi_history_save,
7438 midi_history_get_scans,
7439 midi_history_get_detail,
7440 midi_history_delete,
7441 midi_history_clear,
7442 midi_history_latest,
7443 midi_history_diff,
7444 db_query_midi,
7445 db_midi_filter_stats,
7446 scan_pdfs,
7447 stop_pdf_scan,
7448 scan_unified,
7449 get_unified_scan_run,
7450 prepare_unified_scan,
7451 stop_unified_scan,
7452 pdf_history_save,
7453 pdf_history_get_scans,
7454 pdf_history_get_detail,
7455 pdf_history_delete,
7456 pdf_history_clear,
7457 pdf_history_latest,
7458 pdf_history_diff,
7459 open_pdf_file,
7460 pdf_metadata_get,
7461 pdf_metadata_extract_abort,
7462 pdf_metadata_extract_batch,
7463 pdf_metadata_unindexed,
7464 open_file_default,
7465 export_presets_json,
7466 export_presets_dsv,
7467 export_pdfs_json,
7468 export_pdfs_dsv,
7469 import_pdfs_json,
7470 export_toml,
7471 import_toml,
7472 export_pdf,
7473 import_presets_json,
7474 import_audio_json,
7475 import_daw_json,
7476 open_with_app,
7477 fs_list_dir,
7478 delete_file,
7479 rename_file,
7480 write_text_file,
7481 read_text_file,
7482 get_home_dir,
7483 get_process_stats,
7484 open_prefs_file,
7485 get_prefs_path,
7486 db_query_audio,
7487 db_query_plugins,
7488 db_query_daw,
7489 db_query_presets,
7490 db_audio_stats,
7491 db_daw_stats,
7492 db_preset_stats,
7493 db_query_pdfs,
7494 db_query_palette_preview,
7495 db_pdf_stats,
7496 db_audio_filter_stats,
7497 db_daw_filter_stats,
7498 db_preset_filter_stats,
7499 db_plugin_filter_stats,
7500 db_pdf_filter_stats,
7501 get_active_scan_inventory_counts,
7502 db_list_scans,
7503 db_update_bpm,
7504 db_update_key,
7505 db_update_lufs,
7506 db_update_analysis,
7507 db_backfill_audio_meta,
7508 db_get_analysis,
7509 db_unanalyzed_paths,
7510 db_audio_library_paths,
7511 db_migrate_json,
7512 db_cache_stats,
7513 db_clear_caches,
7514 db_clear_cache_table,
7515 get_app_strings,
7516 get_toast_strings,
7517 refresh_native_menu,
7518 tray_menu::update_tray_now_playing,
7519 tray_menu::tray_popover_action,
7520 tray_menu::tray_popover_resize,
7521 tray_menu::tray_popover_get_state,
7522 tray_menu::tray_popover_get_ui_theme,
7523 tray_menu::show_main_window,
7524 tray_menu::tray_popover_hide,
7525 start_file_watcher,
7526 stop_file_watcher,
7527 get_file_watcher_status,
7528 get_midi_info,
7529 ])
7530 .setup(|app| {
7531 let prefs = history::load_preferences();
7533 if let Some(win_val) = prefs.get("window") {
7534 if let Some(win) = app.get_webview_window("main") {
7535 if let Some(w) = win_val.get("width").and_then(|v| v.as_u64()) {
7536 if let Some(h) = win_val.get("height").and_then(|v| v.as_u64()) {
7537 let size = tauri::PhysicalSize::new(w as u32, h as u32);
7538 let _ = win.set_size(tauri::Size::Physical(size));
7539 }
7540 }
7541 if let Some(x) = win_val.get("x").and_then(|v| v.as_i64()) {
7542 if let Some(y) = win_val.get("y").and_then(|v| v.as_i64()) {
7543 let pos = tauri::PhysicalPosition::new(x as i32, y as i32);
7544 let _ = win.set_position(tauri::Position::Physical(pos));
7545 }
7546 }
7547 }
7548 }
7549
7550 let handle = app.handle();
7552 let ui_locale = prefs
7553 .get("uiLocale")
7554 .and_then(|v| v.as_str().map(|s| s.to_string()))
7555 .unwrap_or_else(|| "en".to_string());
7556 let strings = db::global().get_app_strings(&ui_locale).unwrap_or_default();
7557 let menu =
7558 native_menu::build_native_menu_bar(handle, &strings).map_err(|e| e.to_string())?;
7559 app.set_menu(menu).map_err(|e| e.to_string())?;
7560
7561 let handle2 = app.handle().clone();
7563 app.on_menu_event(move |_app, event| {
7564 let id = event.id().0.as_str();
7565 if let Some(win) = handle2.get_webview_window("main") {
7566 let _ = win.emit("menu-action", id);
7567 }
7568 });
7569
7570 let tray = tray_menu::create_tray(app, &strings)?;
7571 {
7572 let state = app.state::<tray_menu::TrayState>();
7573 let mut guard = state
7574 .inner
7575 .lock()
7576 .map_err(|_| "tray state mutex poisoned".to_string())?;
7577 guard.tray = Some(tray);
7578 guard.menu_strings = strings;
7579 guard.now_playing_menu_line = None;
7580 }
7581 tray_menu::start_tray_host_poll(app.handle().clone());
7585
7586 tray_popover_escape_macos::install(app.handle().clone());
7587
7588 #[cfg(target_os = "macos")]
7597 std::thread::spawn(|| {
7598 let _ = std::process::Command::new("osascript")
7604 .arg("-e")
7605 .arg("tell application \"Finder\" to get name")
7606 .stdout(std::process::Stdio::null())
7607 .stderr(std::process::Stdio::null())
7608 .spawn();
7609 });
7610
7611 Ok(())
7612 })
7613 .build(tauri::generate_context!())
7614 .expect("error while building tauri application")
7615 .run(|app, event| match event {
7616 tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => {
7617 let _ = audio_engine::shutdown_audio_engine_child();
7618 log_shutdown();
7619 }
7620 tauri::RunEvent::WindowEvent {
7640 label,
7641 event: tauri::WindowEvent::Focused(false),
7642 ..
7643 } if label == "tray-popover" => {
7644 let app_handle = app.clone();
7645 std::thread::spawn(move || {
7646 std::thread::sleep(std::time::Duration::from_millis(200));
7647 let Some(popover) = app_handle.get_webview_window("tray-popover") else {
7648 return;
7649 };
7650 if !popover.is_visible().unwrap_or(false) {
7653 return;
7654 }
7655 if popover.is_focused().unwrap_or(false) {
7656 return;
7657 }
7658 let _ = popover.hide();
7659 });
7660 }
7661 tauri::RunEvent::WindowEvent {
7662 label,
7663 event: tauri::WindowEvent::Focused(true),
7664 ..
7665 } if label != "tray-popover" => {
7666 if let Some(popover) = app.get_webview_window("tray-popover") {
7667 if popover.is_visible().unwrap_or(false) {
7668 let _ = popover.hide();
7669 }
7670 }
7671 }
7672 _ => {}
7673 });
7674}
7675
7676#[cfg(test)]
7677mod log_verbosity_tests {
7678 use super::{app_log_verbose, log_verbosity_level, should_suppress_app_log_line, LOG_VERBOSITY_LEVEL};
7679 use std::sync::atomic::{AtomicUsize, Ordering};
7680
7681 #[test]
7682 fn quiet_filter_is_opt_in_prefix_list() {
7683 LOG_VERBOSITY_LEVEL.store(0, Ordering::Relaxed);
7684 assert!(!should_suppress_app_log_line("SCAN ERROR — daw | x"));
7685 assert!(!should_suppress_app_log_line("SCAN TCC DENIED — unified | x"));
7686 LOG_VERBOSITY_LEVEL.store(1, Ordering::Relaxed);
7687 }
7688
7689 #[test]
7690 fn app_log_verbose_skips_closure_when_not_verbose() {
7691 let calls = AtomicUsize::new(0);
7692 LOG_VERBOSITY_LEVEL.store(1, Ordering::Relaxed);
7693 app_log_verbose(|| {
7694 calls.fetch_add(1, Ordering::Relaxed);
7695 "should not run".to_string()
7696 });
7697 assert_eq!(calls.load(Ordering::Relaxed), 0);
7698 LOG_VERBOSITY_LEVEL.store(2, Ordering::Relaxed);
7699 app_log_verbose(|| {
7700 calls.fetch_add(1, Ordering::Relaxed);
7701 "should run".to_string()
7702 });
7703 assert_eq!(calls.load(Ordering::Relaxed), 1);
7704 LOG_VERBOSITY_LEVEL.store(1, Ordering::Relaxed);
7705 }
7706
7707 #[test]
7708 fn log_verbosity_level_tracks_atomic() {
7709 LOG_VERBOSITY_LEVEL.store(2, Ordering::Relaxed);
7710 assert_eq!(log_verbosity_level(), 2);
7711 LOG_VERBOSITY_LEVEL.store(1, Ordering::Relaxed);
7712 }
7713}
7714
7715fn log_shutdown() {
7716 use std::sync::atomic::{AtomicBool, Ordering};
7717 static LOGGED: AtomicBool = AtomicBool::new(false);
7718 if LOGGED.swap(true, Ordering::Relaxed) {
7719 return;
7720 } let uptime = APP_START.get().map(|s| s.elapsed().as_secs()).unwrap_or(0);
7722 append_log(format!(
7723 "APP SHUTDOWN — uptime {}m {}s",
7724 uptime / 60,
7725 uptime % 60
7726 ));
7727}