Skip to main content

max / audiofiles

44.5 KB · 1140 lines History Blame Raw
1 //! audiofiles standalone desktop app.
2 //!
3 //! Launches an eframe window with the shared egui browser UI and a cpal audio
4 //! output stream for sample preview playback. Requires a valid license key
5 //! before the browser is accessible — the activation result is cached locally
6 //! so the app works offline after the first activation.
7 //!
8 //! ## Why immediate-mode GUI (egui) instead of Tauri/webview
9 //!
10 //! - **Waveform rendering:** Scrolling and zooming a 10-minute waveform at 60fps needs
11 //! GPU-backed drawing, not DOM layout. egui's painter gives direct control over vertex
12 //! buffers — no JS/CSS performance cliff for large datasets.
13 //! - **No JS dependency:** The entire app is a single Rust binary. No Node.js build step,
14 //! no npm dependencies, no webview security surface.
15 //! - **Drag-out FFI:** Native drag-and-drop into DAWs requires platform pasteboard APIs
16 //! (NSPasteboardItem on macOS, OLE on Windows). A webview can't initiate OS-level drags
17 //! with file promises.
18
19 mod activation;
20 mod audio;
21 mod license;
22 mod midi;
23 mod preferences;
24 mod tray;
25 pub mod updater;
26 mod vault_setup;
27
28 use std::path::{Path, PathBuf};
29 use std::sync::Arc;
30
31 use audiofiles_browser::state::{BrowserState, SharedState};
32 use audiofiles_browser::ui::theme;
33 use audiofiles_core::vault::{self, VaultRegistry};
34 use audiofiles_sync::{SyncKitConfig, SyncManager};
35 use eframe::egui;
36 use eframe::egui::ViewportCommand;
37 use parking_lot::Mutex;
38 use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
39
40 /// Default SyncKit server URL for all audiofiles installations.
41 const SYNC_SERVER_URL: &str = "https://makenot.work";
42
43 /// Launch the audiofiles standalone app.
44 ///
45 /// Initialises tracing, resolves the platform data directory, starts a cpal
46 /// audio output stream for sample preview, and opens an eframe window running
47 /// the shared egui browser UI.
48 fn main() -> eframe::Result<()> {
49 // GTK must be initialized before tray-icon (libappindicator) on Linux.
50 // Non-fatal: tray icon won't work but the app remains usable.
51 #[cfg(target_os = "linux")]
52 let gtk_ok = gtk::init().is_ok();
53 #[cfg(not(target_os = "linux"))]
54 let gtk_ok = false;
55
56 tracing_subscriber::registry()
57 .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| {
58 "audiofiles_app=info,audiofiles_browser=debug,audiofiles_sync=debug,audiofiles_core=info,warn".into()
59 }))
60 .with(tracing_subscriber::fmt::layer())
61 .init();
62
63 let config_dir = dirs::config_dir()
64 .unwrap_or_else(|| PathBuf::from("."))
65 .join("audiofiles");
66
67 // Tokio runtime for sync operations
68 let runtime = tokio::runtime::Builder::new_multi_thread()
69 .worker_threads(2)
70 .enable_all()
71 .build()
72 .expect("failed to start tokio runtime");
73
74 // Load user preferences (controls the network-touching update checker).
75 let prefs = preferences::Preferences::load(&config_dir);
76
77 // OTA update checker (runs in background on the tokio runtime). Only
78 // spawned if the user hasn't opted out — preserves the "no silent
79 // network without consent" rule.
80 let update_checker = if prefs.check_for_updates {
81 updater::UpdateChecker::new(runtime.handle())
82 } else {
83 tracing::info!("Update checks disabled by preferences");
84 updater::UpdateChecker::disabled()
85 };
86
87 let shared = Arc::new(SharedState::new());
88
89 // Start cpal audio output stream
90 let _stream = match audio::start_output_stream(shared.clone()) {
91 Ok((stream, device_rate, device_name)) => {
92 shared.device_sample_rate.store(device_rate, std::sync::atomic::Ordering::Relaxed);
93 *shared.preview_device_name.lock() = Some(device_name);
94 Some(stream)
95 }
96 Err(e) => {
97 tracing::error!("Failed to start audio output: {e}");
98 None
99 }
100 };
101
102 // Create system tray icon (non-fatal if it fails)
103 let app_tray = match tray::AppTray::new() {
104 Ok(t) => Some(t),
105 Err(e) => {
106 tracing::warn!("Failed to create system tray: {e}");
107 None
108 }
109 };
110
111 let icon = egui::IconData {
112 rgba: include_bytes!("../icon_256x256.rgba").to_vec(),
113 width: 256,
114 height: 256,
115 };
116
117 let options = eframe::NativeOptions {
118 viewport: egui::ViewportBuilder::default()
119 .with_title("audiofiles")
120 .with_icon(icon)
121 .with_inner_size([900.0, 600.0])
122 .with_min_inner_size([600.0, 400.0])
123 .with_drag_and_drop(true),
124 ..Default::default()
125 };
126
127 eframe::run_native(
128 "audiofiles",
129 options,
130 Box::new(move |cc| {
131 audiofiles_browser::ui::theme::setup_fonts(&cc.egui_ctx);
132 Ok(Box::new(AudioFilesApp::new(
133 config_dir, shared, app_tray, update_checker, prefs, runtime, gtk_ok,
134 )))
135 }),
136 )
137 }
138
139 // ── API key persistence ──
140
141 /// Bundled synckit.toml, embedded at compile time from the project root.
142 const SYNCKIT_TOML: &str = include_str!("../../../synckit.toml");
143
144 /// Extract the api_key value from the bundled synckit.toml.
145 fn parse_synckit_toml_key() -> Option<&'static str> {
146 for line in SYNCKIT_TOML.lines() {
147 let line = line.trim();
148 if let Some(rest) = line.strip_prefix("api_key") {
149 let rest = rest.trim_start();
150 if let Some(rest) = rest.strip_prefix('=') {
151 let rest = rest.trim();
152 let rest = rest.trim_matches('"');
153 if !rest.is_empty() {
154 return Some(rest);
155 }
156 }
157 }
158 }
159 None
160 }
161
162 /// Load a saved API key from the data directory, falling back to env vars and bundled toml.
163 fn load_api_key(data_dir: &Path) -> Option<String> {
164 // Saved key file takes priority
165 let key_path = data_dir.join("sync_api_key");
166 if let Ok(key) = std::fs::read_to_string(&key_path) {
167 let key = key.trim().to_string();
168 if !key.is_empty() {
169 tracing::info!("Loaded SyncKit API key from {}", key_path.display());
170 return Some(key);
171 }
172 }
173 // Fall back to env vars (for development / CI)
174 if let (Ok(_url), Ok(key)) = (
175 std::env::var("AF_SYNC_SERVER_URL"),
176 std::env::var("AF_SYNC_API_KEY"),
177 ) {
178 return Some(key);
179 }
180 // Fall back to bundled synckit.toml
181 parse_synckit_toml_key().map(String::from)
182 }
183
184 /// Save an API key to the data directory for future launches.
185 #[cfg(test)]
186 fn save_api_key(data_dir: &Path, api_key: &str) {
187 let key_path = data_dir.join("sync_api_key");
188 if let Err(e) = std::fs::write(&key_path, api_key) {
189 tracing::error!("Failed to save API key to {}: {e}", key_path.display());
190 }
191 }
192
193 /// Create a SyncManager from a saved or env-provided API key.
194 fn create_sync_manager(
195 data_dir: &Path,
196 runtime: &tokio::runtime::Handle,
197 ) -> Option<SyncManager> {
198 let api_key = load_api_key(data_dir)?;
199 let server_url = std::env::var("AF_SYNC_SERVER_URL")
200 .unwrap_or_else(|_| SYNC_SERVER_URL.to_string());
201 let config = SyncKitConfig {
202 server_url,
203 api_key,
204 };
205 let db_path = data_dir.join("audiofiles.db");
206 let content_dir = data_dir.join("samples");
207 let manager = SyncManager::new(config, db_path, content_dir, runtime.clone());
208 manager.fetch_pricing();
209 manager.try_restore_session();
210 manager.start_scheduler();
211 Some(manager)
212 }
213
214 // ── App ──
215
216 /// Which screen the app is showing.
217 #[derive(Debug, PartialEq)]
218 enum AppScreen {
219 /// License activation gate — no browser access until a valid key is entered.
220 Activation,
221 /// First-open vault location picker (shown after activation if no registry exists).
222 VaultSetup,
223 /// Normal browser UI.
224 Browser,
225 }
226
227 /// Determine the initial screen based on vault registry, license status, and trial.
228 ///
229 /// This is the pure decision logic extracted from `AudioFilesApp::new()` so it
230 /// can be tested without constructing the full app.
231 fn resolve_initial_screen(
232 vault_registry: &Option<VaultRegistry>,
233 license_status: &license::LicenseStatus,
234 has_trial: bool,
235 ) -> AppScreen {
236 let licensed_or_trial = matches!(license_status, license::LicenseStatus::Licensed(_)) || has_trial;
237 match (vault_registry, licensed_or_trial) {
238 (Some(_), true) => AppScreen::Browser,
239 (Some(_), false) => AppScreen::Activation,
240 (None, true) => AppScreen::VaultSetup,
241 (None, false) => AppScreen::Activation,
242 }
243 }
244
245 struct AudioFilesApp {
246 screen: AppScreen,
247 browser: Option<BrowserState>,
248 error: Option<String>,
249 /// Global config directory (license, machine_id, vaults.json).
250 config_dir: PathBuf,
251 /// Active vault directory (audiofiles.db + samples/).
252 data_dir: PathBuf,
253 shared: Arc<SharedState>,
254 tray: Option<tray::AppTray>,
255 sync_manager: Option<SyncManager>,
256 update_checker: updater::UpdateChecker,
257 prefs: preferences::Preferences,
258 /// Whether the About modal is currently visible. Toggled by Cmd/Ctrl+I or
259 /// the About button on activation / vault setup / DB-error screens.
260 show_about: bool,
261 /// Active MIDI input connection (dropped to disconnect).
262 midi_connection: Option<midi::MidiConnection>,
263 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
264 gtk_ok: bool,
265 _runtime: tokio::runtime::Runtime,
266
267 // ── Vault state ──
268 vault_registry: Option<VaultRegistry>,
269 vault_setup_path: Option<PathBuf>,
270 vault_setup_name: String,
271
272 // ── License activation state ──
273 machine_id: String,
274 license_key_input: String,
275 activation_result: license::ActivationResult,
276 activation_error: Option<license::ActivationError>,
277 activating: bool,
278 license_cache: Option<license::LicenseCache>,
279 trial_state: Option<license::TrialState>,
280 }
281
282 impl AudioFilesApp {
283 fn new(
284 config_dir: PathBuf,
285 shared: Arc<SharedState>,
286 tray: Option<tray::AppTray>,
287 update_checker: updater::UpdateChecker,
288 prefs: preferences::Preferences,
289 runtime: tokio::runtime::Runtime,
290 gtk_ok: bool,
291 ) -> Self {
292 let _ = std::fs::create_dir_all(&config_dir);
293 let default_vault = vault::default_vault_path();
294
295 // Migrate license/machine_id from default vault to config_dir if needed.
296 vault_setup::migrate_license_to_config(&config_dir, &default_vault);
297
298 let machine_id = license::get_or_create_machine_id(&config_dir);
299 let license_status = license::load_license(&config_dir);
300 let trial_state = license::load_trial(&config_dir);
301 license::touch_trial(&config_dir);
302
303 // Load (or create) the vault registry
304 let vault_registry = match vault::load_registry() {
305 Ok(reg) => reg,
306 Err(e) => {
307 tracing::warn!("Failed to load vault registry: {e}");
308 None
309 }
310 };
311
312 let has_active_trial = trial_state.as_ref().is_some_and(|t| license::trial_days_remaining(t) > 0);
313 let screen = resolve_initial_screen(&vault_registry, &license_status, has_active_trial);
314
315 let licensed_or_trial = matches!(&license_status, license::LicenseStatus::Licensed(_)) || has_active_trial;
316
317 let (data_dir, browser, error, sync_manager, license_cache) =
318 match (&vault_registry, &license_status) {
319 // Registry exists and user is licensed → open the active vault
320 (Some(reg), license::LicenseStatus::Licensed(cache)) => {
321 let data_dir = reg.active.clone();
322 let _ = std::fs::create_dir_all(&data_dir);
323 let sync_manager = create_sync_manager(&data_dir, runtime.handle());
324 let (browser, error) = init_browser(&data_dir, shared.clone(), &vault_setup::vault_name_for_path(reg, &data_dir));
325 (data_dir, browser, error, sync_manager, Some(cache.clone()))
326 }
327 // Registry exists, unlicensed but in trial → open the active vault
328 (Some(reg), license::LicenseStatus::Unlicensed) if has_active_trial => {
329 let data_dir = reg.active.clone();
330 let _ = std::fs::create_dir_all(&data_dir);
331 let sync_manager = create_sync_manager(&data_dir, runtime.handle());
332 let (browser, error) = init_browser(&data_dir, shared.clone(), &vault_setup::vault_name_for_path(reg, &data_dir));
333 (data_dir, browser, error, sync_manager, None)
334 }
335 // Registry exists but unlicensed (deactivated and reactivated)
336 (Some(reg), license::LicenseStatus::Unlicensed) => {
337 tracing::info!("No valid license, showing activation screen");
338 (reg.active.clone(), None, None, None, None)
339 }
340 // No registry + licensed → vault setup (existing user upgrading)
341 (None, license::LicenseStatus::Licensed(cache)) => {
342 tracing::info!("Licensed but no vault registry, showing vault setup");
343 (default_vault.clone(), None, None, None, Some(cache.clone()))
344 }
345 // No registry + unlicensed → activation first (or vault setup if trial)
346 (None, license::LicenseStatus::Unlicensed) => {
347 if licensed_or_trial {
348 tracing::info!("Trial mode, showing vault setup");
349 } else {
350 tracing::info!("No license, showing activation screen");
351 }
352 (default_vault.clone(), None, None, None, None)
353 }
354 };
355
356 let mut app = Self {
357 screen,
358 browser,
359 error,
360 config_dir,
361 data_dir,
362 shared,
363 tray,
364 sync_manager,
365 update_checker,
366 prefs,
367 show_about: false,
368 midi_connection: None,
369 gtk_ok,
370 _runtime: runtime,
371 vault_registry,
372 vault_setup_path: None,
373 vault_setup_name: "Library".to_string(),
374 machine_id,
375 license_key_input: String::new(),
376 activation_result: Arc::new(Mutex::new(None)),
377 activation_error: None,
378 activating: false,
379 license_cache,
380 trial_state,
381 };
382 app.sync_vault_list_to_browser();
383 app.sync_license_to_browser();
384 app
385 }
386
387 /// Initialise the browser after successful activation.
388 fn activate_browser(&mut self) {
389 let _ = std::fs::create_dir_all(&self.data_dir);
390 self.sync_manager = create_sync_manager(&self.data_dir, self._runtime.handle());
391 let vault_name = self.vault_registry.as_ref()
392 .map(|r| vault_setup::vault_name_for_path(r, &self.data_dir))
393 .unwrap_or_else(|| "Library".to_string());
394 let (browser, error) = init_browser(&self.data_dir, self.shared.clone(), &vault_name);
395 self.browser = browser;
396 self.error = error;
397 self.screen = AppScreen::Browser;
398 self.sync_vault_list_to_browser();
399 self.sync_license_to_browser();
400 // Read loose_files from the vault's DB and run integrity check.
401 if let Some(ref mut browser) = self.browser {
402 // Runtime half of the unsafe_mode -> loose_files rename. The
403 // schema-only half (sync-trigger rewrite) lives in MIGRATION_017.
404 // We copy the legacy row here, on the vault-open path, so it
405 // runs exactly once per vault DB. Idempotent: once `loose_files`
406 // is set, this branch never fires again. The retired
407 // `unsafe_mode` row is deleted via `delete_config`. Safe to
408 // remove this block once every active vault has been opened at
409 // least once after this release.
410 let loose = match browser.backend.get_config("loose_files") {
411 Ok(Some(v)) => Some(v),
412 _ => match browser.backend.get_config("unsafe_mode") {
413 Ok(Some(v)) => {
414 let _ = browser.backend.set_config("loose_files", &v);
415 let _ = browser.backend.delete_config("unsafe_mode");
416 Some(v)
417 }
418 _ => None,
419 },
420 };
421 browser.settings.is_loose_files = loose.is_some_and(|v| v == "1");
422 browser.check_loose_files_integrity();
423 }
424 }
425 }
426
427 /// Create a BrowserState, returning (Some(browser), None) on success or
428 /// (None, Some(error)) on failure.
429 fn init_browser(data_dir: &Path, shared: Arc<SharedState>, vault_name: &str) -> (Option<BrowserState>, Option<String>) {
430 let sample_rate = shared.device_sample_rate.load(std::sync::atomic::Ordering::Relaxed) as f32;
431 match BrowserState::new(data_dir, shared, sample_rate, vault_name) {
432 Ok(mut browser) => {
433 for arg in std::env::args().skip(1) {
434 let path = PathBuf::from(&arg);
435 if path.exists() {
436 browser.import_path(&path);
437 }
438 }
439 (Some(browser), None)
440 }
441 Err(e) => {
442 tracing::error!("Failed to init browser: {e}");
443 (None, Some(format!("{e}")))
444 }
445 }
446 }
447
448 impl eframe::App for AudioFilesApp {
449 #[allow(unused_variables)]
450 fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
451 // Pump GTK events so libappindicator (tray) stays responsive on Linux.
452 #[cfg(target_os = "linux")]
453 if self.gtk_ok {
454 while gtk::events_pending() {
455 gtk::main_iteration_do(false);
456 }
457 }
458
459 // Cmd/Ctrl+I toggles the About modal. Works on every screen so a
460 // confused user always has one keystroke to "who made this".
461 ctx.input_mut(|i| {
462 if i.consume_shortcut(&egui::KeyboardShortcut::new(
463 egui::Modifiers::COMMAND,
464 egui::Key::I,
465 )) {
466 self.show_about = !self.show_about;
467 }
468 });
469
470 match self.screen {
471 AppScreen::Activation => {
472 self.draw_activation_screen(ctx);
473 }
474 AppScreen::VaultSetup => {
475 self.draw_vault_setup_screen(ctx);
476 }
477 AppScreen::Browser => {
478 self.update_browser(ctx);
479 }
480 }
481
482 // About modal — drawn last so it overlays the screen content.
483 self.draw_about_modal(ctx);
484
485 // Show update notification overlay (bottom-right) — user must consent
486 if self.update_checker.should_show() {
487 let (version, notes, download_url) = {
488 let s = self.update_checker.status.lock();
489 (s.version.clone(), s.notes.clone(), s.download_url.clone())
490 };
491 egui::Area::new(egui::Id::new("update-banner"))
492 .anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-12.0, -12.0))
493 .order(egui::Order::Foreground)
494 .show(ctx, |ui| {
495 egui::Frame::popup(ui.style())
496 .inner_margin(12.0)
497 .show(ui, |ui| {
498 ui.set_max_width(280.0);
499 ui.strong(format!("Update Available: v{}", version));
500 if !notes.is_empty() {
501 ui.label(&notes);
502 }
503 ui.add_space(theme::space::SM);
504 ui.horizontal(|ui| {
505 if ui.button("Download").clicked()
506 && crate::updater::is_trusted_download_url(&download_url)
507 {
508 let _ = open::that(&download_url);
509 }
510 if ui.button("Not Now").clicked() {
511 self.update_checker.dismiss();
512 }
513 });
514 });
515 });
516 }
517 }
518 }
519
520 impl AudioFilesApp {
521 /// All browser-mode update logic (tray, sync, drops, draw).
522 fn update_browser(&mut self, ctx: &egui::Context) {
523 // Poll tray menu events
524 if let Some(ref tray) = self.tray {
525 if let Some(action) = tray.poll() {
526 match action {
527 tray::TrayAction::ShowWindow => {
528 ctx.send_viewport_cmd(ViewportCommand::Focus);
529 }
530 tray::TrayAction::TogglePlayback => {
531 if let Some(ref mut browser) = self.browser {
532 browser.toggle_preview();
533 }
534 }
535 tray::TrayAction::Quit => {
536 ctx.send_viewport_cmd(ViewportCommand::Close);
537 }
538 }
539 }
540 }
541
542 // Update tray tooltip based on playback state
543 if let Some(ref tray) = self.tray {
544 if let Some(ref browser) = self.browser {
545 let playing = browser.shared.preview.lock().playing;
546 if playing {
547 tray.set_tooltip(&browser.status);
548 } else {
549 tray.set_tooltip("audiofiles");
550 }
551 }
552 }
553
554 // Check if sync pulled remote changes → refresh browser contents
555 if let Some(ref sync) = self.sync_manager {
556 if sync.status().needs_refresh {
557 if let Some(ref mut browser) = self.browser {
558 browser.refresh_vfs_list();
559 browser.refresh_contents();
560 }
561 sync.clear_needs_refresh();
562 }
563 }
564
565 // ── Sync setup actions (before draw, so UI sees results this frame) ──
566 // ── Vault actions ──
567 if let Some(ref mut browser) = self.browser {
568 if let Some(action) = browser.settings.pending_action.take() {
569 use audiofiles_browser::state::VaultAction;
570 match action {
571 VaultAction::SwitchVault(path) => {
572 self.switch_vault(path);
573 return;
574 }
575 VaultAction::CreateVault { name, path, loose_files } => {
576 let switch_path = path.clone();
577 if self.with_vault_registry(|reg| vault::create_vault(reg, &name, &path)) {
578 self.switch_vault(switch_path);
579 if loose_files {
580 if let Some(ref mut browser) = self.browser {
581 let _ = browser.backend.set_config("loose_files", "1");
582 browser.settings.is_loose_files = true;
583 }
584 }
585 return;
586 }
587 }
588 VaultAction::AddExistingVault { name, path } => {
589 self.with_vault_registry(|reg| vault::add_existing_vault(reg, &name, &path));
590 }
591 VaultAction::RemoveVault(path) => {
592 self.with_vault_registry(|reg| vault::remove_vault(reg, &path));
593 }
594 VaultAction::RenameVault { path, new_name } => {
595 self.with_vault_registry(|reg| vault::rename_vault(reg, &path, &new_name));
596 }
597 VaultAction::RelocateVault { old_path, new_path } => {
598 // If we're repointing the active vault, switch to the new
599 // path so the open browser picks up the new location.
600 let was_active = self
601 .vault_registry
602 .as_ref()
603 .map(|r| r.active == old_path)
604 .unwrap_or(false);
605 let ok = self.with_vault_registry(|reg| {
606 vault::relocate_vault(reg, &old_path, &new_path)
607 });
608 if ok && was_active {
609 self.switch_vault(new_path);
610 return;
611 }
612 }
613 VaultAction::ScanStorage => {
614 browser.settings.storage_scanning = true;
615 match browser.backend.storage_stats() {
616 Ok(stats) => {
617 browser.settings.storage_cache = Some(stats);
618 browser.settings.storage_cache_at = Some(
619 std::time::SystemTime::now()
620 .duration_since(std::time::UNIX_EPOCH)
621 .map(|d| d.as_secs() as i64)
622 .unwrap_or(0),
623 );
624 }
625 Err(e) => browser.status = format!("Storage scan failed: {e}"),
626 }
627 browser.settings.storage_scanning = false;
628 }
629 VaultAction::DeactivateLicense => {
630 self.deactivate();
631 return;
632 }
633 }
634 }
635 }
636
637 // ── VFS Mirror: sync if dirty ──
638 if let Some(ref mut browser) = self.browser {
639 browser.sync_mirror_if_dirty();
640 }
641
642 // ── MIDI actions ──
643 if let Some(ref mut browser) = self.browser {
644 use audiofiles_browser::state::MidiAction;
645
646 if let Some(action) = browser.midi_pending_action.take() {
647 match action {
648 MidiAction::RefreshPorts => {
649 browser.midi_state.available_ports = midi::list_input_ports();
650 }
651 MidiAction::Connect(idx) => {
652 match midi::connect(idx, self.shared.clone()) {
653 Ok(conn) => {
654 let name = browser.midi_state.available_ports
655 .get(idx)
656 .cloned()
657 .unwrap_or_else(|| format!("Port {idx}"));
658 browser.midi_state.connected_port = Some(idx);
659 browser.midi_state.connected_port_name = Some(name);
660 self.midi_connection = Some(conn);
661 }
662 Err(e) => {
663 tracing::error!("MIDI connect failed: {e}");
664 browser.midi_state.connected_port = None;
665 browser.midi_state.connected_port_name = None;
666 }
667 }
668 }
669 MidiAction::Disconnect => {
670 self.midi_connection = None;
671 browser.midi_state.connected_port = None;
672 browser.midi_state.connected_port_name = None;
673 }
674 }
675 }
676
677 // Drain MIDI note events from the audio callback into the GUI state
678 let mut midi_notes = self.shared.midi_recent_notes.lock();
679 browser.midi_state.recent_notes.append(&mut midi_notes);
680 // Keep at most 8 recent notes
681 let len = browser.midi_state.recent_notes.len();
682 if len > 8 {
683 browser.midi_state.recent_notes.drain(..len - 8);
684 }
685 }
686
687 // Handle dropped files (drag-and-drop import)
688 let (hovered_count, dropped): (usize, Vec<PathBuf>) = ctx.input(|i| {
689 let hovered = i.raw.hovered_files.len();
690 let paths = i
691 .raw
692 .dropped_files
693 .iter()
694 .filter_map(|f| {
695 tracing::debug!("Dropped file event: path={:?} name={}", f.path, f.name);
696 f.path.clone()
697 })
698 .collect();
699 (hovered, paths)
700 });
701 if hovered_count > 0 {
702 tracing::debug!("Files hovering over window: {hovered_count}");
703 }
704
705 if let Some(ref mut browser) = self.browser {
706 if let Some(vfs_id) = browser.current_vfs_id() {
707 for path in dropped {
708 if path.is_dir() {
709 let strategy = audiofiles_browser::import::ImportStrategy::MergeIntoVfs {
710 vfs_id,
711 parent_id: browser.current_dir,
712 };
713 browser.start_folder_import(path, strategy);
714 } else {
715 browser.import_path(&path);
716 }
717 }
718 }
719 audiofiles_browser::editor::draw_browser(ctx, browser, self.sync_manager.as_ref());
720
721 // Drop target indicator: while files are hovering, paint a clear
722 // border on top of the whole window plus a centered label. This is
723 // the "yes, dropping here will work" feedback the OS doesn't give
724 // us on Linux/Windows. Rendered as a foreground layer so it sits
725 // above panel chrome but doesn't intercept clicks.
726 if hovered_count > 0 {
727 let screen = ctx.screen_rect();
728 let rect = screen.shrink(theme::space::MD);
729 let painter = ctx.layer_painter(egui::LayerId::new(
730 egui::Order::Foreground,
731 egui::Id::new("drop_overlay"),
732 ));
733 painter.rect_stroke(
734 rect,
735 4.0,
736 egui::Stroke::new(2.0, theme::accent_blue()),
737 egui::StrokeKind::Inside,
738 );
739 let label = if hovered_count == 1 {
740 "Drop to import".to_string()
741 } else {
742 format!("Drop to import {hovered_count} items")
743 };
744 let label_pos = egui::pos2(rect.center().x, rect.top() + 32.0);
745 // Background pill keeps the label readable on any theme.
746 let bg_rect = egui::Rect::from_center_size(label_pos, egui::vec2(280.0, 36.0));
747 painter.rect_filled(bg_rect, 8.0, theme::bg_tertiary());
748 painter.text(
749 label_pos,
750 egui::Align2::CENTER_CENTER,
751 &label,
752 egui::FontId::proportional(18.0),
753 theme::accent_blue(),
754 );
755 }
756 } else {
757 self.draw_db_error_screen(ctx);
758 }
759 }
760 }
761
762 impl AudioFilesApp {
763 /// Render the "vault failed to open" recovery surface. Replaces the prior
764 /// dead-end label with explicit recovery actions: Retry the same path,
765 /// Choose a different vault, or open the data folder for manual triage.
766 fn draw_db_error_screen(&mut self, ctx: &egui::Context) {
767 egui::CentralPanel::default().show(ctx, |ui| {
768 ui.add_space(48.0);
769 ui.vertical_centered(|ui| {
770 ui.heading("audiofiles");
771 ui.add_space(8.0);
772 ui.label("Couldn't open this vault.");
773 if let Some(ref err) = self.error {
774 ui.add_space(4.0);
775 ui.label(
776 egui::RichText::new(err)
777 .small()
778 .color(audiofiles_browser::ui::theme::text_muted()),
779 );
780 }
781 ui.add_space(16.0);
782 ui.label(
783 egui::RichText::new(format!("Vault location: {}", self.data_dir.display()))
784 .small()
785 .color(audiofiles_browser::ui::theme::text_muted()),
786 );
787 ui.add_space(16.0);
788
789 ui.horizontal(|ui| {
790 ui.add_space(ui.available_width() / 2.0 - 200.0);
791 if ui.button("Try again").clicked() {
792 let vault_name = self
793 .vault_registry
794 .as_ref()
795 .map(|r| vault_setup::vault_name_for_path(r, &self.data_dir))
796 .unwrap_or_else(|| "Library".to_string());
797 let (browser, error) =
798 init_browser(&self.data_dir, self.shared.clone(), &vault_name);
799 self.browser = browser;
800 self.error = error;
801 }
802 if ui.button("Choose a different location").clicked() {
803 self.screen = AppScreen::VaultSetup;
804 self.error = None;
805 }
806 if ui.button(reveal_folder_label()).clicked() {
807 reveal_in_file_manager(&self.data_dir);
808 }
809 });
810 ui.add_space(24.0);
811 if ui.small_button("About audiofiles").clicked() {
812 self.show_about = true;
813 }
814 });
815 });
816 }
817
818 /// Render the About modal: version, attribution, contact, license, and the
819 /// network-touching update-check toggle (the only user-visible network
820 /// surface besides license activation).
821 fn draw_about_modal(&mut self, ctx: &egui::Context) {
822 if !self.show_about {
823 return;
824 }
825 let mut open = true;
826 let mut updated_check_pref = self.prefs.check_for_updates;
827 egui::Window::new("About audiofiles")
828 .open(&mut open)
829 .resizable(false)
830 .collapsible(false)
831 .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
832 .show(ctx, |ui| {
833 ui.set_max_width(360.0);
834 ui.vertical_centered(|ui| {
835 ui.heading("audiofiles");
836 ui.label(format!("Version {}", env!("CARGO_PKG_VERSION")));
837 });
838 ui.add_space(8.0);
839 ui.label("Made by Make Creative, LLC.");
840 ui.horizontal(|ui| {
841 ui.label("Contact:");
842 ui.hyperlink_to("info@makenot.work", "mailto:info@makenot.work");
843 });
844 ui.horizontal(|ui| {
845 ui.label("Web:");
846 ui.hyperlink_to("makenot.work", "https://makenot.work");
847 });
848 ui.label("License: PolyForm Noncommercial 1.0.0.");
849 ui.add_space(12.0);
850 ui.separator();
851 ui.add_space(8.0);
852 ui.strong("Network");
853 ui.checkbox(
854 &mut updated_check_pref,
855 "Check makenot.work for updates",
856 );
857 ui.label(
858 egui::RichText::new(
859 "When enabled, the app contacts makenot.work on launch and every 6 hours \
860 to check for a newer version. It sends only the current version, OS, and \
861 architecture. License activation is always user-initiated.",
862 )
863 .small()
864 .color(audiofiles_browser::ui::theme::text_muted()),
865 );
866 ui.add_space(8.0);
867 ui.label(
868 egui::RichText::new(format!(
869 "Preferences file: {}",
870 self.config_dir.join("preferences.json").display()
871 ))
872 .small()
873 .color(audiofiles_browser::ui::theme::text_muted()),
874 );
875 ui.add_space(12.0);
876 ui.vertical_centered(|ui| {
877 if ui.button("Close").clicked() {
878 self.show_about = false;
879 }
880 });
881 });
882 if updated_check_pref != self.prefs.check_for_updates {
883 self.prefs.check_for_updates = updated_check_pref;
884 self.prefs.save(&self.config_dir);
885 // The change takes effect on next launch — we don't tear down the
886 // already-spawned tokio task at runtime.
887 }
888 if !open {
889 self.show_about = false;
890 }
891 }
892 }
893
894 /// Platform-specific label for the "open this folder in the OS file manager"
895 /// action. Mirrors the convention used in `file_list_menus.rs::reveal_label`.
896 fn reveal_folder_label() -> &'static str {
897 #[cfg(target_os = "macos")]
898 {
899 "Show in Finder"
900 }
901 #[cfg(target_os = "windows")]
902 {
903 "Show in Explorer"
904 }
905 #[cfg(target_os = "linux")]
906 {
907 "Open folder"
908 }
909 }
910
911 /// Open `path` in the native file manager. Errors are silently dropped — the
912 /// user can fall back to the displayed path string.
913 fn reveal_in_file_manager(path: &Path) {
914 #[cfg(target_os = "macos")]
915 let _ = std::process::Command::new("open").arg(path).spawn();
916 #[cfg(target_os = "windows")]
917 let _ = std::process::Command::new("explorer").arg(path).spawn();
918 #[cfg(target_os = "linux")]
919 let _ = std::process::Command::new("xdg-open").arg(path).spawn();
920 }
921
922 #[cfg(test)]
923 mod tests {
924 use super::*;
925
926 #[test]
927 fn load_api_key_from_file() {
928 let dir = tempfile::tempdir().unwrap();
929 std::fs::write(dir.path().join("sync_api_key"), "test-key-123").unwrap();
930 let result = load_api_key(dir.path());
931 assert_eq!(result, Some("test-key-123".to_string()));
932 }
933
934 #[test]
935 fn load_api_key_trims_whitespace() {
936 let dir = tempfile::tempdir().unwrap();
937 std::fs::write(dir.path().join("sync_api_key"), " key-with-spaces \n").unwrap();
938 let result = load_api_key(dir.path());
939 assert_eq!(result, Some("key-with-spaces".to_string()));
940 }
941
942 #[test]
943 fn load_api_key_empty_file_falls_back_to_bundled() {
944 let dir = tempfile::tempdir().unwrap();
945 std::fs::write(dir.path().join("sync_api_key"), "").unwrap();
946 // Empty file → falls through to bundled synckit.toml key
947 if std::env::var("AF_SYNC_API_KEY").is_err() {
948 let key = load_api_key(dir.path());
949 assert_eq!(key, parse_synckit_toml_key().map(String::from));
950 }
951 }
952
953 #[test]
954 fn load_api_key_whitespace_only_falls_back_to_bundled() {
955 let dir = tempfile::tempdir().unwrap();
956 std::fs::write(dir.path().join("sync_api_key"), " \n ").unwrap();
957 if std::env::var("AF_SYNC_API_KEY").is_err() {
958 let key = load_api_key(dir.path());
959 assert_eq!(key, parse_synckit_toml_key().map(String::from));
960 }
961 }
962
963 #[test]
964 fn load_api_key_no_file_falls_back_to_bundled() {
965 let dir = tempfile::tempdir().unwrap();
966 if std::env::var("AF_SYNC_API_KEY").is_err() {
967 let key = load_api_key(dir.path());
968 assert_eq!(key, parse_synckit_toml_key().map(String::from));
969 }
970 }
971
972 #[test]
973 fn save_api_key_creates_file() {
974 let dir = tempfile::tempdir().unwrap();
975 save_api_key(dir.path(), "saved-key");
976 let content = std::fs::read_to_string(dir.path().join("sync_api_key")).unwrap();
977 assert_eq!(content, "saved-key");
978 }
979
980 #[test]
981 fn save_and_load_roundtrip() {
982 let dir = tempfile::tempdir().unwrap();
983 save_api_key(dir.path(), "roundtrip-key");
984 let result = load_api_key(dir.path());
985 assert_eq!(result, Some("roundtrip-key".to_string()));
986 }
987
988 // ── Initial screen resolution ──
989
990 fn make_license_cache() -> license::LicenseCache {
991 license::LicenseCache {
992 key_code: "bright-castle-forest-river-falcon".to_string(),
993 machine_id: "test-machine".to_string(),
994 activated_at: "2026-04-01T00:00:00Z".to_string(),
995 }
996 }
997
998 fn make_registry(dir: &Path) -> VaultRegistry {
999 VaultRegistry {
1000 vaults: vec![vault::VaultEntry {
1001 name: "Library".to_string(),
1002 path: dir.to_path_buf(),
1003 }],
1004 active: dir.to_path_buf(),
1005 }
1006 }
1007
1008 #[test]
1009 fn initial_screen_licensed_with_registry() {
1010 let dir = tempfile::tempdir().unwrap();
1011 let reg = Some(make_registry(dir.path()));
1012 let status = license::LicenseStatus::Licensed(make_license_cache());
1013 assert_eq!(resolve_initial_screen(&reg, &status, false), AppScreen::Browser);
1014 }
1015
1016 #[test]
1017 fn initial_screen_licensed_without_registry() {
1018 let status = license::LicenseStatus::Licensed(make_license_cache());
1019 assert_eq!(resolve_initial_screen(&None, &status, false), AppScreen::VaultSetup);
1020 }
1021
1022 #[test]
1023 fn initial_screen_unlicensed_with_registry() {
1024 let dir = tempfile::tempdir().unwrap();
1025 let reg = Some(make_registry(dir.path()));
1026 let status = license::LicenseStatus::Unlicensed;
1027 assert_eq!(resolve_initial_screen(&reg, &status, false), AppScreen::Activation);
1028 }
1029
1030 #[test]
1031 fn initial_screen_unlicensed_without_registry() {
1032 let status = license::LicenseStatus::Unlicensed;
1033 assert_eq!(resolve_initial_screen(&None, &status, false), AppScreen::Activation);
1034 }
1035
1036 #[test]
1037 fn initial_screen_trial_with_registry() {
1038 let dir = tempfile::tempdir().unwrap();
1039 let reg = Some(make_registry(dir.path()));
1040 let status = license::LicenseStatus::Unlicensed;
1041 assert_eq!(resolve_initial_screen(&reg, &status, true), AppScreen::Browser);
1042 }
1043
1044 #[test]
1045 fn initial_screen_trial_without_registry() {
1046 let status = license::LicenseStatus::Unlicensed;
1047 assert_eq!(resolve_initial_screen(&None, &status, true), AppScreen::VaultSetup);
1048 }
1049
1050 // ── License migration ──
1051
1052 #[test]
1053 fn migrate_license_copies_files() {
1054 let src = tempfile::tempdir().unwrap();
1055 let dst = tempfile::tempdir().unwrap();
1056 std::fs::write(src.path().join("license.json"), r#"{"key_code":"k","machine_id":"m","activated_at":"t"}"#).unwrap();
1057 std::fs::write(src.path().join("machine_id"), "mid-123").unwrap();
1058
1059 vault_setup::migrate_license_to_config(dst.path(), src.path());
1060
1061 assert_eq!(
1062 std::fs::read_to_string(dst.path().join("license.json")).unwrap(),
1063 r#"{"key_code":"k","machine_id":"m","activated_at":"t"}"#
1064 );
1065 assert_eq!(
1066 std::fs::read_to_string(dst.path().join("machine_id")).unwrap(),
1067 "mid-123"
1068 );
1069 }
1070
1071 #[test]
1072 fn migrate_license_skips_when_same_dir() {
1073 let dir = tempfile::tempdir().unwrap();
1074 // Should not panic or overwrite — same source and dest
1075 vault_setup::migrate_license_to_config(dir.path(), dir.path());
1076 }
1077
1078 #[test]
1079 fn migrate_license_does_not_overwrite_existing() {
1080 let src = tempfile::tempdir().unwrap();
1081 let dst = tempfile::tempdir().unwrap();
1082 std::fs::write(src.path().join("license.json"), "old").unwrap();
1083 std::fs::write(dst.path().join("license.json"), "existing").unwrap();
1084
1085 vault_setup::migrate_license_to_config(dst.path(), src.path());
1086
1087 // Destination file should be unchanged
1088 assert_eq!(
1089 std::fs::read_to_string(dst.path().join("license.json")).unwrap(),
1090 "existing"
1091 );
1092 }
1093
1094 #[test]
1095 fn migrate_license_handles_missing_source() {
1096 let src = tempfile::tempdir().unwrap();
1097 let dst = tempfile::tempdir().unwrap();
1098 // No files in source — should not create anything in dest
1099 vault_setup::migrate_license_to_config(dst.path(), src.path());
1100 assert!(!dst.path().join("license.json").exists());
1101 assert!(!dst.path().join("machine_id").exists());
1102 }
1103
1104 // ── Key masking ──
1105
1106 #[test]
1107 fn mask_key_five_words() {
1108 assert_eq!(
1109 activation::mask_key("bright-castle-forest-river-falcon"),
1110 "bright-...-falcon"
1111 );
1112 }
1113
1114 #[test]
1115 fn mask_key_two_words() {
1116 assert_eq!(activation::mask_key("alpha-beta"), "alpha-...-beta");
1117 }
1118
1119 #[test]
1120 fn mask_key_single_word() {
1121 assert_eq!(activation::mask_key("onlyoneword"), "***");
1122 }
1123
1124 // ── Vault name lookup ──
1125
1126 #[test]
1127 fn vault_name_for_path_found() {
1128 let dir = tempfile::tempdir().unwrap();
1129 let reg = make_registry(dir.path());
1130 assert_eq!(vault_setup::vault_name_for_path(&reg, dir.path()), "Library");
1131 }
1132
1133 #[test]
1134 fn vault_name_for_path_not_found() {
1135 let dir = tempfile::tempdir().unwrap();
1136 let reg = make_registry(dir.path());
1137 assert_eq!(vault_setup::vault_name_for_path(&reg, Path::new("/nonexistent")), "Library");
1138 }
1139 }
1140