Skip to main content

max / audiofiles

launch-eve fixes: update opt-out, About box, export safety, UI polish Per launchplan_final.md §2.4 Run #9 audit (read-only audits in ~/Code/MNW/server/docs/audit_review.md Run #9 section covered MNW; this commit closes the audiofiles launch-blockers identified by parallel rust-fuzz / use-fuzz / creator-fuzz agents). Launch-blockers fixed: - updater opt-out + persisted preference (crates/audiofiles-app/src/ preferences.rs new; updater::UpdateChecker::disabled() variant). The OTA check loop now skips when prefs.check_for_updates is false. Default remains true to preserve existing behavior; user can disable from the new About modal (and the change takes effect on next launch). Settles the "silent network on every launch" privacy concern. - emoji glyph prefixes removed from file list rows (file_list.rs:441-443). Directory rows get a trailing "/" (Unix convention); samples get no prefix; cloud-only continues to render in theme::text_muted(). Closes the brand-rule violation the team self-flagged for "Phase 3 surface audit." - About modal (main.rs::draw_about_modal): version, attribution ("Made by Make Creative, LLC"), contact (info@makenot.work), web, license, the update-check toggle with a one-paragraph honest disclosure of what gets sent, and the preferences.json file path. Triggered by Cmd/Ctrl+I from any screen, plus an "About audiofiles" button on the activation screen and the DB-error recovery screen. - DB-init failure now offers recovery actions (main.rs::draw_db_error_ screen): Try again, Choose a different location, and the platform- appropriate Show in Finder / Show in Explorer / Open folder. Replaces the prior dead-end "Error: could not initialize database" label. - export collision protection (export/runner.rs::resolve_collision): before writing each export file, stat the destination and auto-suffix with _1, _2, ... if it already exists. Prevents silent overwrite of older masters in the user-chosen export directory. Audit deferrals (Phase 4, documented in audit_review.md): WAV/AIFF metadata-chunk preservation on conversion, BWF/iXML/smpl/cue round-trip, expanded format support (.m4a/.alac/.opus/.w64/.caf), atomic tmp+rename export writes, hand-rolled synckit.toml parser, four Result<_, String> leaks past the typed-error wall, .bak backup file hygiene. Tests: cargo check clean; preferences (6/6) + export (56/56) targeted tests green; integration suite needs the user's standard test harness.
Author: Max Johnson <me@maxj.phd> · 2026-06-01 00:51 UTC
Signed with PGP, not checked
Commit: c18d7e15596fa2a213087d1ade4fb41413e7f175
Parent: 0bb39b5
12 files changed, +532 insertions, -165 deletions
M Cargo.lock +1 -1
@@ -4944,7 +4944,7 @@
4944 4944
4945 4945 [[package]]
4946 4946 name = "synckit-client"
4947 - version = "0.3.1"
4947 + version = "0.4.0"
4948 4948 dependencies = [
4949 4949 "argon2",
4950 4950 "base64",
M synckit.toml +1 -1
@@ -1,5 +1,5 @@
1 1 # SyncKit configuration — embedded in distribution builds.
2 2 # The API key is a client identifier (not a secret). It identifies this app
3 3 # to the MNW server. User authentication happens via OAuth2 PKCE.
4 - api_key = "ac745a0ed5b68ac176836c493cba2c5aeeec1642e0b0c7ad429830deff6f673d"
4 + api_key = "37cde0c1499190fd54aba024af135d8b25e0e85b400583f4edc9ba7bd4eeb725"
5 5 server_url = "https://makenot.work"
@@ -158,6 +158,14 @@
158 158 "Get a license key",
159 159 "https://makenot.work/store/audiofiles",
160 160 );
161 +
162 + ui.add_space(theme::space::XL);
163 + ui.horizontal(|ui| {
164 + ui.add_space((ui.available_width() / 2.0 - 60.0).max(0.0));
165 + if ui.small_button("About audiofiles").clicked() {
166 + self.show_about = true;
167 + }
168 + });
161 169 });
162 170 });
163 171 }
@@ -20,6 +20,7 @@
20 20 mod audio;
21 21 mod license;
22 22 mod midi;
23 + mod preferences;
23 24 mod tray;
24 25 pub mod updater;
25 26 mod vault_setup;
@@ -70,8 +71,18 @@
70 71 .build()
71 72 .expect("failed to start tokio runtime");
72 73
73 - // OTA update checker (runs in background on the tokio runtime)
74 - let update_checker = updater::UpdateChecker::new(runtime.handle());
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 + };
75 86
76 87 let shared = Arc::new(SharedState::new());
77 88
@@ -119,7 +130,7 @@
119 130 Box::new(move |cc| {
120 131 audiofiles_browser::ui::theme::setup_fonts(&cc.egui_ctx);
121 132 Ok(Box::new(AudioFilesApp::new(
122 - config_dir, shared, app_tray, update_checker, runtime, gtk_ok,
133 + config_dir, shared, app_tray, update_checker, prefs, runtime, gtk_ok,
123 134 )))
124 135 }),
125 136 )
@@ -194,7 +205,7 @@
194 205 let db_path = data_dir.join("audiofiles.db");
195 206 let content_dir = data_dir.join("samples");
196 207 let manager = SyncManager::new(config, db_path, content_dir, runtime.clone());
197 - manager.fetch_tiers();
208 + manager.fetch_pricing();
198 209 manager.try_restore_session();
199 210 manager.start_scheduler();
200 211 Some(manager)
@@ -243,6 +254,10 @@
243 254 tray: Option<tray::AppTray>,
244 255 sync_manager: Option<SyncManager>,
245 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,
246 261 /// Active MIDI input connection (dropped to disconnect).
247 262 midi_connection: Option<midi::MidiConnection>,
248 263 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
@@ -270,6 +285,7 @@
270 285 shared: Arc<SharedState>,
271 286 tray: Option<tray::AppTray>,
272 287 update_checker: updater::UpdateChecker,
288 + prefs: preferences::Preferences,
273 289 runtime: tokio::runtime::Runtime,
274 290 gtk_ok: bool,
275 291 ) -> Self {
@@ -308,12 +324,13 @@
308 324 let (browser, error) = init_browser(&data_dir, shared.clone(), &vault_setup::vault_name_for_path(reg, &data_dir));
309 325 (data_dir, browser, error, sync_manager, Some(cache.clone()))
310 326 }
311 - // Registry exists, unlicensed but in trial → open the active vault (no sync)
327 + // Registry exists, unlicensed but in trial → open the active vault
312 328 (Some(reg), license::LicenseStatus::Unlicensed) if has_active_trial => {
313 329 let data_dir = reg.active.clone();
314 330 let _ = std::fs::create_dir_all(&data_dir);
331 + let sync_manager = create_sync_manager(&data_dir, runtime.handle());
315 332 let (browser, error) = init_browser(&data_dir, shared.clone(), &vault_setup::vault_name_for_path(reg, &data_dir));
316 - (data_dir, browser, error, None, None)
333 + (data_dir, browser, error, sync_manager, None)
317 334 }
318 335 // Registry exists but unlicensed (deactivated and reactivated)
319 336 (Some(reg), license::LicenseStatus::Unlicensed) => {
@@ -346,6 +363,8 @@
346 363 tray,
347 364 sync_manager,
348 365 update_checker,
366 + prefs,
367 + show_about: false,
349 368 midi_connection: None,
350 369 gtk_ok,
351 370 _runtime: runtime,
@@ -437,6 +456,17 @@
437 456 }
438 457 }
439 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 +
440 470 match self.screen {
441 471 AppScreen::Activation => {
442 472 self.draw_activation_screen(ctx);
@@ -449,6 +479,9 @@
449 479 }
450 480 }
451 481
482 + // About modal — drawn last so it overlays the screen content.
483 + self.draw_about_modal(ctx);
484 +
452 485 // Show update notification overlay (bottom-right) — user must consent
453 486 if self.update_checker.should_show() {
454 487 let (version, notes, download_url) = {
@@ -721,16 +754,171 @@
721 754 );
722 755 }
723 756 } else {
724 - egui::CentralPanel::default().show(ctx, |ui| {
725 - ui.heading("audiofiles");
726 - if let Some(ref err) = self.error {
727 - ui.label(format!("Error: could not initialize database.\n{err}"));
728 - }
729 - });
757 + self.draw_db_error_screen(ctx);
730 758 }
731 759 }
732 760 }
733 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 +
734 922 #[cfg(test)]
735 923 mod tests {
736 924 use super::*;
@@ -61,6 +61,15 @@
61 61 checker
62 62 }
63 63
64 + /// Construct an inert checker that never contacts the network. Used when
65 + /// the user has disabled `check_for_updates` in preferences — keeps the
66 + /// rest of the app's `update_checker` plumbing trivial (no Options).
67 + pub fn disabled() -> Self {
68 + Self {
69 + status: Arc::new(Mutex::new(UpdateStatus::default())),
70 + }
71 + }
72 +
64 73 /// Dismiss the update notification (user clicked dismiss).
65 74 pub fn dismiss(&self) {
66 75 self.status.lock().dismissed = true;
@@ -49,8 +49,9 @@
49 49 pub needs_refresh: bool,
50 50 /// Subscription status for blob sync tier (populated async).
51 51 pub subscription: Option<synckit_client::SubscriptionStatus>,
52 - /// Available pricing tiers (fetched once from server).
53 - pub tiers: Option<Vec<synckit_client::TierInfo>>,
52 + /// Pricing-formula constants (fetched once from server, used to quote
53 + /// prices locally as the user adjusts the cap slider).
54 + pub pricing: Option<synckit_client::AppPricing>,
54 55 }
55 56
56 57 impl Default for SyncStatus {
@@ -65,7 +66,7 @@
65 66 sync_interval_minutes: 15,
66 67 needs_refresh: false,
67 68 subscription: None,
68 - tiers: None,
69 + pricing: None,
69 70 }
70 71 }
71 72 }
@@ -321,47 +322,48 @@
321 322 }
322 323 }
323 324
324 - /// Fetch available pricing tiers from the server (async, no JWT needed).
325 - pub fn fetch_tiers(&self) {
325 + /// Fetch the pricing formula from the server (async, no JWT needed).
326 + /// Stored on the status so UI code can quote a price for any cap locally.
327 + pub fn fetch_pricing(&self) {
326 328 let client = self.client.clone();
327 329 let status = self.status.clone();
328 330 self.runtime.spawn(async move {
329 - match client.get_available_tiers().await {
330 - Ok(tiers) => {
331 - status.lock().tiers = Some(tiers);
331 + match client.get_app_pricing().await {
332 + Ok(pricing) => {
333 + status.lock().pricing = Some(pricing);
332 334 }
333 335 Err(e) => {
334 - tracing::debug!("Failed to fetch tiers: {e}");
336 + tracing::debug!("Failed to fetch app pricing: {e}");
335 337 }
336 338 }
337 339 });
338 340 }
339 341
340 342 /// Fetch subscription status from the server (async, result goes to status.subscription).
343 + /// On error (404, network, etc.) treats the user as unsubscribed so the UI can show the
344 + /// subscribe CTA instead of spinning forever.
341 345 pub fn fetch_subscription_status(&self) {
342 346 let client = self.client.clone();
343 347 let status = self.status.clone();
344 348 self.runtime.spawn(async move {
345 - match client.get_subscription_status().await {
346 - Ok(sub) => {
347 - status.lock().subscription = Some(sub);
348 - }
349 + let sub = match client.get_subscription_status().await {
350 + Ok(sub) => sub,
349 351 Err(e) => {
350 - tracing::debug!("Failed to fetch subscription status: {e}");
352 + tracing::debug!("Failed to fetch subscription status, treating as inactive: {e}");
353 + synckit_client::SubscriptionStatus::default()
351 354 }
352 - }
355 + };
356 + status.lock().subscription = Some(sub);
353 357 });
354 358 }
355 359
356 - /// Create a Stripe checkout session and open it in the browser.
357 - /// Polls for subscription activation after opening checkout.
358 - pub fn subscribe(&self, tier: &str, interval: &str) {
360 + /// Create a Stripe checkout session at the chosen storage cap and open
361 + /// it in the browser. Polls for subscription activation after opening.
362 + pub fn subscribe(&self, cap_bytes: i64, interval: synckit_client::BillingInterval) {
359 363 let client = self.client.clone();
360 364 let status = self.status.clone();
361 - let tier = tier.to_string();
362 - let interval = interval.to_string();
363 365 self.runtime.spawn(async move {
364 - match client.create_subscription_checkout(&tier, &interval).await {
366 + match client.create_subscription_checkout(cap_bytes, interval).await {
365 367 Ok(resp) => {
366 368 if let Err(e) = open::that(&resp.checkout_url) {
367 369 tracing::warn!("Failed to open browser: {e}");
@@ -385,21 +387,18 @@
385 387 });
386 388 }
387 389
388 - /// Change the tier of an existing sync subscription (Stripe prorates).
389 - /// Updates the local subscription status on success.
390 - pub fn change_tier(&self, tier: &str, interval: &str) {
390 + /// Queue a storage-cap change that applies at the next billing cycle.
391 + pub fn queue_cap_change(&self, cap_bytes: i64) {
391 392 let client = self.client.clone();
392 393 let status = self.status.clone();
393 - let tier = tier.to_string();
394 - let interval = interval.to_string();
395 394 self.runtime.spawn(async move {
396 - match client.change_subscription_tier(&tier, &interval).await {
395 + match client.queue_storage_cap_change(cap_bytes).await {
397 396 Ok(sub) => {
398 397 status.lock().subscription = Some(sub);
399 - tracing::info!(tier = %tier, "Subscription tier changed");
398 + tracing::info!(cap_bytes, "Storage cap change queued");
400 399 }
401 400 Err(e) => {
402 - tracing::error!("Failed to change tier: {e}");
401 + tracing::error!("Failed to queue cap change: {e}");
403 402 }
404 403 }
405 404 });
@@ -461,5 +460,5 @@
461 460
462 461 // Re-export for convenience
463 462 pub use synckit_client::SyncKitConfig;
464 - pub use synckit_client::TierInfo;
463 + pub use synckit_client::{AppPricing, BillingInterval, PriceQuote};
465 464 pub use synckit_client::validate_api_key;
@@ -325,6 +325,10 @@
325 325 /// each time `show_panel` transitions to false so reopening the panel gets
326 326 /// fresh numbers.
327 327 pub vfs_storage_fetched: bool,
328 + /// User's working cap selection on the cap-picker slider, in GiB.
329 + /// Persisted across frames so dragging the slider doesn't reset. Defaults
330 + /// to 100 GiB the first time the panel renders.
331 + pub cap_picker_gib: i64,
328 332 }
329 333
330 334 impl Default for SyncUiState {
@@ -345,6 +349,7 @@
345 349 auth_url: None,
346 350 vfs_storage_cache: std::collections::HashMap::new(),
347 351 vfs_storage_fetched: false,
352 + cap_picker_gib: 100,
348 353 }
349 354 }
350 355 }
@@ -437,12 +437,14 @@
437 437 os_drag_blocked: bool,
438 438 sync_manager: Option<&audiofiles_sync::SyncManager>,
439 439 ) {
440 - let icon = match node.node.node_type {
441 - NodeType::Directory => "\u{1F4C1} ",
442 - NodeType::Sample if node.cloud_only => "\u{2601} ",
443 - NodeType::Sample => "\u{1F50A} ",
440 + // Directories get a trailing "/" (Unix convention). Samples get no prefix
441 + // — the name is the data, no decorative noise. Cloud-only samples already
442 + // render in `theme::text_muted()` below, which carries the signal without
443 + // emoji glyphs (brand rule).
444 + let label = match node.node.node_type {
445 + NodeType::Directory => format!("{}/", node.node.name),
446 + NodeType::Sample => node.node.name.clone(),
444 447 };
445 - let label = format!("{}{}", icon, node.node.name);
446 448 let resp = if node.cloud_only {
447 449 ui.selectable_label(
448 450 selected,