Skip to main content

max / audiofiles

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