//! audiofiles standalone desktop app. //! //! Launches an eframe window with the shared egui browser UI and a cpal audio //! output stream for sample preview playback. Requires a valid license key //! before the browser is accessible — the activation result is cached locally //! so the app works offline after the first activation. //! //! ## Why immediate-mode GUI (egui) instead of Tauri/webview //! //! - **Waveform rendering:** Scrolling and zooming a 10-minute waveform at 60fps needs //! GPU-backed drawing, not DOM layout. egui's painter gives direct control over vertex //! buffers — no JS/CSS performance cliff for large datasets. //! - **No JS dependency:** The entire app is a single Rust binary. No Node.js build step, //! no npm dependencies, no webview security surface. //! - **Drag-out FFI:** Native drag-and-drop into DAWs requires platform pasteboard APIs //! (NSPasteboardItem on macOS, OLE on Windows). A webview can't initiate OS-level drags //! with file promises. mod activation; mod audio; mod license; mod midi; mod preferences; mod tray; pub mod updater; mod vault_setup; use std::path::{Path, PathBuf}; use std::sync::Arc; use audiofiles_browser::state::{BrowserState, SharedState}; use audiofiles_browser::ui::theme; use audiofiles_core::vault::{self, VaultRegistry}; use audiofiles_sync::{SyncKitConfig, SyncManager}; use eframe::egui; use eframe::egui::ViewportCommand; use parking_lot::Mutex; use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; /// Default SyncKit server URL for all audiofiles installations. const SYNC_SERVER_URL: &str = "https://makenot.work"; /// Launch the audiofiles standalone app. /// /// Initialises tracing, resolves the platform data directory, starts a cpal /// audio output stream for sample preview, and opens an eframe window running /// the shared egui browser UI. fn main() -> eframe::Result<()> { // GTK must be initialized before tray-icon (libappindicator) on Linux. // Non-fatal: tray icon won't work but the app remains usable. #[cfg(target_os = "linux")] let gtk_ok = gtk::init().is_ok(); #[cfg(not(target_os = "linux"))] let gtk_ok = false; tracing_subscriber::registry() .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| { "audiofiles_app=info,audiofiles_browser=debug,audiofiles_sync=debug,audiofiles_core=info,warn".into() })) .with(tracing_subscriber::fmt::layer()) .init(); let config_dir = dirs::config_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("audiofiles"); // Tokio runtime for sync operations let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() .build() .expect("failed to start tokio runtime"); // Load user preferences (controls the network-touching update checker). let prefs = preferences::Preferences::load(&config_dir); // OTA update checker (runs in background on the tokio runtime). The task // is always spawned but gated on the pref — toggling the About-modal // checkbox flips the gate via `set_enabled` without restart. if !prefs.check_for_updates { tracing::info!("Update checks disabled by preferences (toggle in About to enable)"); } let update_checker = updater::UpdateChecker::new(runtime.handle(), prefs.check_for_updates); let shared = Arc::new(SharedState::new()); // Start cpal audio output stream let _stream = match audio::start_output_stream(shared.clone()) { Ok((stream, device_rate, device_name)) => { shared.device_sample_rate.store(device_rate, std::sync::atomic::Ordering::Relaxed); *shared.preview_device_name.lock() = Some(device_name); Some(stream) } Err(e) => { tracing::error!("Failed to start audio output: {e}"); None } }; // Create system tray icon (non-fatal if it fails) let app_tray = match tray::AppTray::new() { Ok(t) => Some(t), Err(e) => { tracing::warn!("Failed to create system tray: {e}"); None } }; let icon = egui::IconData { rgba: include_bytes!("../icon_256x256.rgba").to_vec(), width: 256, height: 256, }; let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("audiofiles") .with_icon(icon) .with_inner_size([900.0, 600.0]) .with_min_inner_size([600.0, 400.0]) .with_drag_and_drop(true), ..Default::default() }; eframe::run_native( "audiofiles", options, Box::new(move |cc| { audiofiles_browser::ui::theme::setup_fonts(&cc.egui_ctx); Ok(Box::new(AudioFilesApp::new( config_dir, shared, app_tray, update_checker, prefs, runtime, gtk_ok, ))) }), ) } // ── API key persistence ── /// Bundled synckit.toml, embedded at compile time from the project root. const SYNCKIT_TOML: &str = include_str!("../../../synckit.toml"); /// Extract the api_key value from the bundled synckit.toml. fn parse_synckit_toml_key() -> Option { let table: std::collections::HashMap = toml::from_str(SYNCKIT_TOML).ok()?; table.get("api_key").filter(|s| !s.is_empty()).cloned() } /// Load a saved API key from the data directory, falling back to env vars and bundled toml. fn load_api_key(data_dir: &Path) -> Option { // Saved key file takes priority let key_path = data_dir.join("sync_api_key"); if let Ok(key) = std::fs::read_to_string(&key_path) { let key = key.trim().to_string(); if !key.is_empty() { tracing::info!("Loaded SyncKit API key from {}", key_path.display()); return Some(key); } } // Fall back to env vars (for development / CI) if let (Ok(_url), Ok(key)) = ( std::env::var("AF_SYNC_SERVER_URL"), std::env::var("AF_SYNC_API_KEY"), ) { return Some(key); } // Fall back to bundled synckit.toml parse_synckit_toml_key()} /// Save an API key to the data directory for future launches. #[cfg(test)] fn save_api_key(data_dir: &Path, api_key: &str) { let key_path = data_dir.join("sync_api_key"); if let Err(e) = std::fs::write(&key_path, api_key) { tracing::error!("Failed to save API key to {}: {e}", key_path.display()); } } /// Create a SyncManager from a saved or env-provided API key. fn create_sync_manager( data_dir: &Path, runtime: &tokio::runtime::Handle, ) -> Option { let api_key = load_api_key(data_dir)?; let server_url = std::env::var("AF_SYNC_SERVER_URL") .unwrap_or_else(|_| SYNC_SERVER_URL.to_string()); let config = SyncKitConfig { server_url, api_key, }; let db_path = data_dir.join("audiofiles.db"); let content_dir = data_dir.join("samples"); let manager = SyncManager::new(config, db_path, content_dir, runtime.clone()); manager.fetch_pricing(); manager.try_restore_session(); manager.start_scheduler(); Some(manager) } // ── App ── /// Which screen the app is showing. #[derive(Debug, PartialEq)] enum AppScreen { /// License activation gate — no browser access until a valid key is entered. Activation, /// First-open vault location picker (shown after activation if no registry exists). VaultSetup, /// Normal browser UI. Browser, } /// Determine the initial screen based on vault registry, license status, and trial. /// /// This is the pure decision logic extracted from `AudioFilesApp::new()` so it /// can be tested without constructing the full app. fn resolve_initial_screen( vault_registry: &Option, license_status: &license::LicenseStatus, has_trial: bool, ) -> AppScreen { let licensed_or_trial = matches!(license_status, license::LicenseStatus::Licensed(_)) || has_trial; match (vault_registry, licensed_or_trial) { (Some(_), true) => AppScreen::Browser, (Some(_), false) => AppScreen::Activation, (None, true) => AppScreen::VaultSetup, (None, false) => AppScreen::Activation, } } struct AudioFilesApp { screen: AppScreen, browser: Option, error: Option, /// Global config directory (license, machine_id, vaults.json). config_dir: PathBuf, /// Active vault directory (audiofiles.db + samples/). data_dir: PathBuf, shared: Arc, tray: Option, sync_manager: Option, update_checker: updater::UpdateChecker, prefs: preferences::Preferences, /// Whether the About modal is currently visible. Toggled by Cmd/Ctrl+I or /// the About button on activation / vault setup / DB-error screens. show_about: bool, /// Active MIDI input connection (dropped to disconnect). midi_connection: Option, #[cfg_attr(not(target_os = "linux"), allow(dead_code))] gtk_ok: bool, _runtime: tokio::runtime::Runtime, // ── Vault state ── vault_registry: Option, vault_setup_path: Option, vault_setup_name: String, // ── License activation state ── machine_id: String, license_key_input: String, activation_result: license::ActivationResult, activation_error: Option, activating: bool, license_cache: Option, trial_state: Option, } impl AudioFilesApp { fn new( config_dir: PathBuf, shared: Arc, tray: Option, update_checker: updater::UpdateChecker, prefs: preferences::Preferences, runtime: tokio::runtime::Runtime, gtk_ok: bool, ) -> Self { let _ = std::fs::create_dir_all(&config_dir); let default_vault = vault::default_vault_path(); // Migrate license/machine_id from default vault to config_dir if needed. vault_setup::migrate_license_to_config(&config_dir, &default_vault); let machine_id = license::get_or_create_machine_id(&config_dir); let license_status = license::load_license(&config_dir); let trial_state = license::load_trial(&config_dir); license::touch_trial(&config_dir); // Load (or create) the vault registry let vault_registry = match vault::load_registry() { Ok(reg) => reg, Err(e) => { tracing::warn!("Failed to load vault registry: {e}"); None } }; let has_active_trial = trial_state.as_ref().is_some_and(|t| license::trial_days_remaining(t) > 0); let screen = resolve_initial_screen(&vault_registry, &license_status, has_active_trial); let licensed_or_trial = matches!(&license_status, license::LicenseStatus::Licensed(_)) || has_active_trial; let (data_dir, browser, error, sync_manager, license_cache) = match (&vault_registry, &license_status) { // Registry exists and user is licensed → open the active vault (Some(reg), license::LicenseStatus::Licensed(cache)) => { let data_dir = reg.active.clone(); let _ = std::fs::create_dir_all(&data_dir); let sync_manager = create_sync_manager(&data_dir, runtime.handle()); let (browser, error) = init_browser(&data_dir, shared.clone(), &vault_setup::vault_name_for_path(reg, &data_dir)); (data_dir, browser, error, sync_manager, Some(cache.clone())) } // Registry exists, unlicensed but in trial → open the active vault (Some(reg), license::LicenseStatus::Unlicensed) if has_active_trial => { let data_dir = reg.active.clone(); let _ = std::fs::create_dir_all(&data_dir); let sync_manager = create_sync_manager(&data_dir, runtime.handle()); let (browser, error) = init_browser(&data_dir, shared.clone(), &vault_setup::vault_name_for_path(reg, &data_dir)); (data_dir, browser, error, sync_manager, None) } // Registry exists but unlicensed (deactivated and reactivated) (Some(reg), license::LicenseStatus::Unlicensed) => { tracing::info!("No valid license, showing activation screen"); (reg.active.clone(), None, None, None, None) } // No registry + licensed → vault setup (existing user upgrading) (None, license::LicenseStatus::Licensed(cache)) => { tracing::info!("Licensed but no vault registry, showing vault setup"); (default_vault.clone(), None, None, None, Some(cache.clone())) } // No registry + unlicensed → activation first (or vault setup if trial) (None, license::LicenseStatus::Unlicensed) => { if licensed_or_trial { tracing::info!("Trial mode, showing vault setup"); } else { tracing::info!("No license, showing activation screen"); } (default_vault.clone(), None, None, None, None) } }; let mut app = Self { screen, browser, error, config_dir, data_dir, shared, tray, sync_manager, update_checker, prefs, show_about: false, midi_connection: None, gtk_ok, _runtime: runtime, vault_registry, vault_setup_path: None, vault_setup_name: "Library".to_string(), machine_id, license_key_input: String::new(), activation_result: Arc::new(Mutex::new(None)), activation_error: None, activating: false, license_cache, trial_state, }; app.sync_vault_list_to_browser(); app.sync_license_to_browser(); app } /// Initialise the browser after successful activation. fn activate_browser(&mut self) { let _ = std::fs::create_dir_all(&self.data_dir); self.sync_manager = create_sync_manager(&self.data_dir, self._runtime.handle()); let vault_name = self.vault_registry.as_ref() .map(|r| vault_setup::vault_name_for_path(r, &self.data_dir)) .unwrap_or_else(|| "Library".to_string()); let (browser, error) = init_browser(&self.data_dir, self.shared.clone(), &vault_name); self.browser = browser; self.error = error; self.screen = AppScreen::Browser; self.sync_vault_list_to_browser(); self.sync_license_to_browser(); // Read loose_files from the vault's DB and run integrity check. if let Some(ref mut browser) = self.browser { // Runtime half of the unsafe_mode -> loose_files rename. The // schema-only half (sync-trigger rewrite) lives in MIGRATION_017. // We copy the legacy row here, on the vault-open path, so it // runs exactly once per vault DB. Idempotent: once `loose_files` // is set, this branch never fires again. The retired // `unsafe_mode` row is deleted via `delete_config`. Safe to // remove this block once every active vault has been opened at // least once after this release. let loose = match browser.backend.get_config("loose_files") { Ok(Some(v)) => Some(v), _ => match browser.backend.get_config("unsafe_mode") { Ok(Some(v)) => { let _ = browser.backend.set_config("loose_files", &v); let _ = browser.backend.delete_config("unsafe_mode"); Some(v) } _ => None, }, }; browser.settings.is_loose_files = loose.is_some_and(|v| v == "1"); browser.check_loose_files_integrity(); } } } /// Create a BrowserState, returning (Some(browser), None) on success or /// (None, Some(error)) on failure. fn init_browser(data_dir: &Path, shared: Arc, vault_name: &str) -> (Option, Option) { let sample_rate = shared.device_sample_rate.load(std::sync::atomic::Ordering::Relaxed) as f32; match BrowserState::new(data_dir, shared, sample_rate, vault_name) { Ok(mut browser) => { for arg in std::env::args().skip(1) { let path = PathBuf::from(&arg); if path.exists() { browser.import_path(&path); } } (Some(browser), None) } Err(e) => { tracing::error!("Failed to init browser: {e}"); (None, Some(format!("{e}"))) } } } impl eframe::App for AudioFilesApp { #[allow(unused_variables)] fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); let ctx = &ctx; // Apply the audiofiles theme every frame, before any screen draws. The // browser draw path also applies it, but onboarding (Activation / Vault // setup) renders before a browser exists — without this, first-run paints // on stock egui dark and then visibly snaps to the theme once the browser // loads. Applying here uses the global theme (the audiofiles default until // the browser loads a saved choice), so every screen is themed from frame 1. theme::apply_theme(ctx); // Pump GTK events so libappindicator (tray) stays responsive on Linux. #[cfg(target_os = "linux")] if self.gtk_ok { while gtk::events_pending() { gtk::main_iteration_do(false); } } // Cmd/Ctrl+I toggles the About modal. Works on every screen so a // confused user always has one keystroke to "who made this". ctx.input_mut(|i| { // Cmd+I and Cmd+, both toggle About. Cmd+, is the macOS-native // "Preferences" shortcut; About is the closest thing audiofiles // has to a preferences screen (only the update-check toggle). if i.consume_shortcut(&egui::KeyboardShortcut::new( egui::Modifiers::COMMAND, egui::Key::I, )) || i.consume_shortcut(&egui::KeyboardShortcut::new( egui::Modifiers::COMMAND, egui::Key::Comma, )) { self.show_about = !self.show_about; } }); match self.screen { AppScreen::Activation => { self.draw_activation_screen(ui); } AppScreen::VaultSetup => { self.draw_vault_setup_screen(ui); } AppScreen::Browser => { self.update_browser(ui); } } // About modal — drawn last so it overlays the screen content. self.draw_about_modal(ctx); // Show update notification overlay (bottom-right) — user must consent if self.update_checker.should_show() { let (version, notes, download_url) = { let s = self.update_checker.status.lock(); (s.version.clone(), s.notes.clone(), s.download_url.clone()) }; egui::Area::new(egui::Id::new("update-banner")) .anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-12.0, -12.0)) .order(egui::Order::Foreground) .show(ctx, |ui| { egui::Frame::popup(ui.style()) .inner_margin(12.0) .show(ui, |ui| { ui.set_max_width(280.0); ui.strong(format!("Update Available: v{}", version)); if !notes.is_empty() { ui.label(¬es); } ui.add_space(theme::space::SM); ui.horizontal(|ui| { if ui.button("Download").clicked() && crate::updater::is_trusted_download_url(&download_url) { let _ = open::that(&download_url); } if ui.button("Not Now").clicked() { self.update_checker.dismiss(); } }); }); }); } } } impl AudioFilesApp { /// All browser-mode update logic (tray, sync, drops, draw). fn update_browser(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); let ctx = &ctx; // Poll tray menu events if let Some(ref tray) = self.tray && let Some(action) = tray.poll() { match action { tray::TrayAction::ShowWindow => { ctx.send_viewport_cmd(ViewportCommand::Focus); } tray::TrayAction::TogglePlayback => { if let Some(ref mut browser) = self.browser { browser.toggle_preview(); } } tray::TrayAction::Quit => { ctx.send_viewport_cmd(ViewportCommand::Close); } } } // Update tray tooltip based on playback state if let Some(ref tray) = self.tray && let Some(ref browser) = self.browser { let playing = browser.shared.preview.lock().playing; if playing { tray.set_tooltip(&browser.status); } else { tray.set_tooltip("audiofiles"); } } // Check if sync pulled remote changes → refresh browser contents if let Some(ref sync) = self.sync_manager && sync.status().needs_refresh { if let Some(ref mut browser) = self.browser { browser.refresh_vfs_list(); browser.refresh_contents(); } sync.clear_needs_refresh(); } // ── Sync setup actions (before draw, so UI sees results this frame) ── // ── Vault actions ── if let Some(ref mut browser) = self.browser && let Some(action) = browser.settings.pending_action.take() { use audiofiles_browser::state::VaultAction; match action { VaultAction::SwitchVault(path) => { self.switch_vault(path); return; } VaultAction::CreateVault { name, path, loose_files } => { let switch_path = path.clone(); if self.with_vault_registry(|reg| vault::create_vault(reg, &name, &path)) { self.switch_vault(switch_path); if loose_files && let Some(ref mut browser) = self.browser { let _ = browser.backend.set_config("loose_files", "1"); browser.settings.is_loose_files = true; } return; } } VaultAction::AddExistingVault { name, path } => { self.with_vault_registry(|reg| vault::add_existing_vault(reg, &name, &path)); } VaultAction::RemoveVault(path) => { self.with_vault_registry(|reg| vault::remove_vault(reg, &path)); } VaultAction::RenameVault { path, new_name } => { self.with_vault_registry(|reg| vault::rename_vault(reg, &path, &new_name)); } VaultAction::RelocateVault { old_path, new_path } => { // If we're repointing the active vault, switch to the new // path so the open browser picks up the new location. let was_active = self .vault_registry .as_ref() .map(|r| r.active == old_path) .unwrap_or(false); let ok = self.with_vault_registry(|reg| { vault::relocate_vault(reg, &old_path, &new_path) }); if ok && was_active { self.switch_vault(new_path); return; } } VaultAction::ScanStorage => { browser.settings.storage_scanning = true; match browser.backend.storage_stats() { Ok(stats) => { browser.settings.storage_cache = Some(stats); browser.settings.storage_cache_at = Some( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0), ); } Err(e) => browser.status = format!("Storage scan failed: {e}"), } browser.settings.storage_scanning = false; } VaultAction::DeactivateLicense => { self.deactivate(); return; } } } // ── VFS Mirror: sync if dirty ── if let Some(ref mut browser) = self.browser { browser.sync_mirror_if_dirty(); } // ── MIDI actions ── if let Some(ref mut browser) = self.browser { use audiofiles_browser::state::MidiAction; if let Some(action) = browser.midi_pending_action.take() { match action { MidiAction::RefreshPorts => { browser.midi_state.available_ports = midi::list_input_ports(); } MidiAction::Connect(idx) => { match midi::connect(idx, self.shared.clone()) { Ok(conn) => { let name = browser.midi_state.available_ports .get(idx) .cloned() .unwrap_or_else(|| format!("Port {idx}")); browser.midi_state.connected_port = Some(idx); browser.midi_state.connected_port_name = Some(name); self.midi_connection = Some(conn); } Err(e) => { tracing::error!("MIDI connect failed: {e}"); browser.midi_state.connected_port = None; browser.midi_state.connected_port_name = None; } } } MidiAction::Disconnect => { self.midi_connection = None; browser.midi_state.connected_port = None; browser.midi_state.connected_port_name = None; } } } // Drain MIDI note events from the audio callback into the GUI state let mut midi_notes = self.shared.midi_recent_notes.lock(); browser.midi_state.recent_notes.append(&mut midi_notes); // Keep at most 8 recent notes let len = browser.midi_state.recent_notes.len(); if len > 8 { browser.midi_state.recent_notes.drain(..len - 8); } } // Handle dropped files (drag-and-drop import) let (hovered_count, dropped): (usize, Vec) = ctx.input(|i| { let hovered = i.raw.hovered_files.len(); let paths = i .raw .dropped_files .iter() .filter_map(|f| { tracing::debug!("Dropped file event: path={:?} name={}", f.path, f.name); f.path.clone() }) .collect(); (hovered, paths) }); if hovered_count > 0 { tracing::debug!("Files hovering over window: {hovered_count}"); } if let Some(ref mut browser) = self.browser { if let Some(vfs_id) = browser.current_vfs_id() { for path in dropped { if path.is_dir() { let strategy = audiofiles_browser::import::ImportStrategy::MergeIntoVfs { vfs_id, parent_id: browser.current_dir, }; browser.start_folder_import(path, strategy); } else { browser.import_path(&path); } } } audiofiles_browser::editor::draw_browser(ui, browser, self.sync_manager.as_ref()); // Toolbar Help menu → About entry. Browser sets `about_requested`; // we consume it and flip our own modal flag. if browser.about_requested { browser.about_requested = false; self.show_about = true; } // Drop target indicator: while files are hovering, paint a clear // border on top of the whole window plus a centered label. This is // the "yes, dropping here will work" feedback the OS doesn't give // us on Linux/Windows. Rendered as a foreground layer so it sits // above panel chrome but doesn't intercept clicks. if hovered_count > 0 { let screen = ctx.content_rect(); let rect = screen.shrink(theme::space::MD); let painter = ctx.layer_painter(egui::LayerId::new( egui::Order::Foreground, egui::Id::new("drop_overlay"), )); painter.rect_stroke( rect, 4.0, egui::Stroke::new(2.0, theme::accent_blue()), egui::StrokeKind::Inside, ); let label = if hovered_count == 1 { "Drop to import".to_string() } else { format!("Drop to import {hovered_count} items") }; let label_pos = egui::pos2(rect.center().x, rect.top() + 32.0); // Background pill keeps the label readable on any theme. let bg_rect = egui::Rect::from_center_size(label_pos, egui::vec2(280.0, 36.0)); painter.rect_filled(bg_rect, 8.0, theme::bg_tertiary()); painter.text( label_pos, egui::Align2::CENTER_CENTER, &label, egui::FontId::proportional(18.0), theme::accent_blue(), ); } } else { self.draw_db_error_screen(ui); } } } impl AudioFilesApp { /// Render the "vault failed to open" recovery surface. Replaces the prior /// dead-end label with explicit recovery actions: Retry the same path, /// Choose a different vault, or open the data folder for manual triage. fn draw_db_error_screen(&mut self, ui: &mut egui::Ui) { egui::CentralPanel::default().show_inside(ui, |ui| { ui.add_space(48.0); ui.vertical_centered(|ui| { ui.heading("audiofiles"); ui.add_space(8.0); ui.label("Couldn't open this vault."); if let Some(ref err) = self.error { ui.add_space(4.0); ui.label( egui::RichText::new(err) .small() .color(audiofiles_browser::ui::theme::text_muted()), ); } ui.add_space(16.0); ui.label( egui::RichText::new(format!("Vault location: {}", self.data_dir.display())) .small() .color(audiofiles_browser::ui::theme::text_muted()), ); ui.add_space(16.0); ui.horizontal(|ui| { ui.add_space(ui.available_width() / 2.0 - 200.0); if ui.button("Try again").clicked() { let vault_name = self .vault_registry .as_ref() .map(|r| vault_setup::vault_name_for_path(r, &self.data_dir)) .unwrap_or_else(|| "Library".to_string()); let (browser, error) = init_browser(&self.data_dir, self.shared.clone(), &vault_name); self.browser = browser; self.error = error; } if ui.button("Choose a different location").clicked() { self.screen = AppScreen::VaultSetup; self.error = None; } if ui.button(reveal_folder_label()).clicked() { reveal_in_file_manager(&self.data_dir); } }); ui.add_space(24.0); if ui.small_button("About audiofiles").clicked() { self.show_about = true; } }); }); } /// Render the About modal: version, attribution, contact, license, and the /// network-touching update-check toggle (the only user-visible network /// surface besides license activation). fn draw_about_modal(&mut self, ctx: &egui::Context) { if !self.show_about { return; } let mut open = true; let mut updated_check_pref = self.prefs.check_for_updates; egui::Window::new("About audiofiles") .open(&mut open) .resizable(false) .collapsible(false) .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) .show(ctx, |ui| { ui.set_max_width(360.0); ui.vertical_centered(|ui| { ui.heading("audiofiles"); ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); }); ui.add_space(8.0); ui.label("Made by Make Creative, LLC."); ui.horizontal(|ui| { ui.label("Contact:"); ui.hyperlink_to("info@makenot.work", "mailto:info@makenot.work"); }); ui.horizontal(|ui| { ui.label("Web:"); ui.hyperlink_to("makenot.work", "https://makenot.work"); }); ui.label("License: PolyForm Noncommercial 1.0.0."); ui.add_space(12.0); ui.separator(); ui.add_space(8.0); ui.strong("Network"); ui.checkbox( &mut updated_check_pref, "Check makenot.work for updates", ); ui.label( egui::RichText::new( "When enabled, the app contacts makenot.work on launch and every 6 hours \ to check for a newer version. It sends only the current version, OS, and \ architecture. License activation is always user-initiated.", ) .small() .color(audiofiles_browser::ui::theme::text_muted()), ); ui.add_space(8.0); ui.label( egui::RichText::new(format!( "Preferences file: {}", self.config_dir.join("preferences.json").display() )) .small() .color(audiofiles_browser::ui::theme::text_muted()), ); ui.add_space(12.0); ui.vertical_centered(|ui| { if ui.button("Close").clicked() { self.show_about = false; } }); }); if updated_check_pref != self.prefs.check_for_updates { self.prefs.check_for_updates = updated_check_pref; self.prefs.save(&self.config_dir); // Apply at runtime via the watch gate. Enabling triggers an // immediate check; disabling parks the loop without tearing it // down (cheap, instant, no restart). self.update_checker.set_enabled(updated_check_pref); } if !open { self.show_about = false; } } } /// Platform-specific label for the "open this folder in the OS file manager" /// action. Mirrors the convention used in `file_list_menus.rs::reveal_label`. fn reveal_folder_label() -> &'static str { #[cfg(target_os = "macos")] { "Show in Finder" } #[cfg(target_os = "windows")] { "Show in Explorer" } #[cfg(target_os = "linux")] { "Open folder" } } /// Open `path` in the native file manager. Errors are silently dropped — the /// user can fall back to the displayed path string. fn reveal_in_file_manager(path: &Path) { #[cfg(target_os = "macos")] let _ = std::process::Command::new("open").arg(path).spawn(); #[cfg(target_os = "windows")] let _ = std::process::Command::new("explorer").arg(path).spawn(); #[cfg(target_os = "linux")] let _ = std::process::Command::new("xdg-open").arg(path).spawn(); } #[cfg(test)] mod tests { use super::*; #[test] fn load_api_key_from_file() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("sync_api_key"), "test-key-123").unwrap(); let result = load_api_key(dir.path()); assert_eq!(result, Some("test-key-123".to_string())); } #[test] fn load_api_key_trims_whitespace() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("sync_api_key"), " key-with-spaces \n").unwrap(); let result = load_api_key(dir.path()); assert_eq!(result, Some("key-with-spaces".to_string())); } #[test] fn load_api_key_empty_file_falls_back_to_bundled() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("sync_api_key"), "").unwrap(); // Empty file → falls through to bundled synckit.toml key if std::env::var("AF_SYNC_API_KEY").is_err() { let key = load_api_key(dir.path()); assert_eq!(key, parse_synckit_toml_key()); } } #[test] fn load_api_key_whitespace_only_falls_back_to_bundled() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("sync_api_key"), " \n ").unwrap(); if std::env::var("AF_SYNC_API_KEY").is_err() { let key = load_api_key(dir.path()); assert_eq!(key, parse_synckit_toml_key()); } } #[test] fn load_api_key_no_file_falls_back_to_bundled() { let dir = tempfile::tempdir().unwrap(); if std::env::var("AF_SYNC_API_KEY").is_err() { let key = load_api_key(dir.path()); assert_eq!(key, parse_synckit_toml_key()); } } #[test] fn save_api_key_creates_file() { let dir = tempfile::tempdir().unwrap(); save_api_key(dir.path(), "saved-key"); let content = std::fs::read_to_string(dir.path().join("sync_api_key")).unwrap(); assert_eq!(content, "saved-key"); } #[test] fn save_and_load_roundtrip() { let dir = tempfile::tempdir().unwrap(); save_api_key(dir.path(), "roundtrip-key"); let result = load_api_key(dir.path()); assert_eq!(result, Some("roundtrip-key".to_string())); } // ── Initial screen resolution ── fn make_license_cache() -> license::LicenseCache { license::LicenseCache { key_code: "bright-castle-forest-river-falcon".to_string(), machine_id: "test-machine".to_string(), activated_at: "2026-04-01T00:00:00Z".to_string(), } } fn make_registry(dir: &Path) -> VaultRegistry { VaultRegistry { vaults: vec![vault::VaultEntry { name: "Library".to_string(), path: dir.to_path_buf(), }], active: dir.to_path_buf(), } } #[test] fn initial_screen_licensed_with_registry() { let dir = tempfile::tempdir().unwrap(); let reg = Some(make_registry(dir.path())); let status = license::LicenseStatus::Licensed(make_license_cache()); assert_eq!(resolve_initial_screen(®, &status, false), AppScreen::Browser); } #[test] fn initial_screen_licensed_without_registry() { let status = license::LicenseStatus::Licensed(make_license_cache()); assert_eq!(resolve_initial_screen(&None, &status, false), AppScreen::VaultSetup); } #[test] fn initial_screen_unlicensed_with_registry() { let dir = tempfile::tempdir().unwrap(); let reg = Some(make_registry(dir.path())); let status = license::LicenseStatus::Unlicensed; assert_eq!(resolve_initial_screen(®, &status, false), AppScreen::Activation); } #[test] fn initial_screen_unlicensed_without_registry() { let status = license::LicenseStatus::Unlicensed; assert_eq!(resolve_initial_screen(&None, &status, false), AppScreen::Activation); } #[test] fn initial_screen_trial_with_registry() { let dir = tempfile::tempdir().unwrap(); let reg = Some(make_registry(dir.path())); let status = license::LicenseStatus::Unlicensed; assert_eq!(resolve_initial_screen(®, &status, true), AppScreen::Browser); } #[test] fn initial_screen_trial_without_registry() { let status = license::LicenseStatus::Unlicensed; assert_eq!(resolve_initial_screen(&None, &status, true), AppScreen::VaultSetup); } // ── License migration ── #[test] fn migrate_license_copies_files() { let src = tempfile::tempdir().unwrap(); let dst = tempfile::tempdir().unwrap(); std::fs::write(src.path().join("license.json"), r#"{"key_code":"k","machine_id":"m","activated_at":"t"}"#).unwrap(); std::fs::write(src.path().join("machine_id"), "mid-123").unwrap(); vault_setup::migrate_license_to_config(dst.path(), src.path()); assert_eq!( std::fs::read_to_string(dst.path().join("license.json")).unwrap(), r#"{"key_code":"k","machine_id":"m","activated_at":"t"}"# ); assert_eq!( std::fs::read_to_string(dst.path().join("machine_id")).unwrap(), "mid-123" ); } #[test] fn migrate_license_skips_when_same_dir() { let dir = tempfile::tempdir().unwrap(); // Should not panic or overwrite — same source and dest vault_setup::migrate_license_to_config(dir.path(), dir.path()); } #[test] fn migrate_license_does_not_overwrite_existing() { let src = tempfile::tempdir().unwrap(); let dst = tempfile::tempdir().unwrap(); std::fs::write(src.path().join("license.json"), "old").unwrap(); std::fs::write(dst.path().join("license.json"), "existing").unwrap(); vault_setup::migrate_license_to_config(dst.path(), src.path()); // Destination file should be unchanged assert_eq!( std::fs::read_to_string(dst.path().join("license.json")).unwrap(), "existing" ); } #[test] fn migrate_license_handles_missing_source() { let src = tempfile::tempdir().unwrap(); let dst = tempfile::tempdir().unwrap(); // No files in source — should not create anything in dest vault_setup::migrate_license_to_config(dst.path(), src.path()); assert!(!dst.path().join("license.json").exists()); assert!(!dst.path().join("machine_id").exists()); } // ── Key masking ── #[test] fn mask_key_five_words() { assert_eq!( activation::mask_key("bright-castle-forest-river-falcon"), "bright-...-falcon" ); } #[test] fn mask_key_two_words() { assert_eq!(activation::mask_key("alpha-beta"), "alpha-...-beta"); } #[test] fn mask_key_single_word() { assert_eq!(activation::mask_key("onlyoneword"), "***"); } // ── Vault name lookup ── #[test] fn vault_name_for_path_found() { let dir = tempfile::tempdir().unwrap(); let reg = make_registry(dir.path()); assert_eq!(vault_setup::vault_name_for_path(®, dir.path()), "Library"); } #[test] fn vault_name_for_path_not_found() { let dir = tempfile::tempdir().unwrap(); let reg = make_registry(dir.path()); assert_eq!(vault_setup::vault_name_for_path(®, Path::new("/nonexistent")), "Library"); } }