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
Commit: c18d7e15596fa2a213087d1ade4fb41413e7f175
Parent: 0bb39b5
12 files changed, +528 insertions, -161 deletions
M Cargo.lock +1 -1
@@ -4944,7 +4944,7 @@ dependencies = [
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",
@@ -158,6 +158,14 @@ impl AudioFilesApp {
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 @@ mod activation;
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 @@ fn main() -> eframe::Result<()> {
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 @@ fn main() -> eframe::Result<()> {
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 @@ fn create_sync_manager(
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 @@ struct AudioFilesApp {
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 @@ impl AudioFilesApp {
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 @@ impl AudioFilesApp {
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 @@ impl AudioFilesApp {
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 @@ impl eframe::App for AudioFilesApp {
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 @@ impl eframe::App for AudioFilesApp {
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,14 +754,169 @@ impl AudioFilesApp {
721 754 );
722 755 }
723 756 } else {
724 - egui::CentralPanel::default().show(ctx, |ui| {
757 + self.draw_db_error_screen(ctx);
758 + }
759 + }
760 + }
761 +
762 + impl AudioFilesApp {
763 + /// Render the "vault failed to open" recovery surface. Replaces the prior
764 + /// dead-end label with explicit recovery actions: Retry the same path,
765 + /// Choose a different vault, or open the data folder for manual triage.
766 + fn draw_db_error_screen(&mut self, ctx: &egui::Context) {
767 + egui::CentralPanel::default().show(ctx, |ui| {
768 + ui.add_space(48.0);
769 + ui.vertical_centered(|ui| {
725 770 ui.heading("audiofiles");
771 + ui.add_space(8.0);
772 + ui.label("Couldn't open this vault.");
726 773 if let Some(ref err) = self.error {
727 - ui.label(format!("Error: could not initialize database.\n{err}"));
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;
728 813 }
729 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.
730 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"
731 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();
732 920 }
733 921
734 922 #[cfg(test)]
@@ -0,0 +1,116 @@
1 + //! User preferences persisted to the config directory.
2 + //!
3 + //! Lives at `config_dir/preferences.json`. Only options that need to survive
4 + //! across launches and that the user can change at runtime belong here.
5 + //! Build-time settings stay in `synckit.toml` / env vars.
6 +
7 + use std::path::{Path, PathBuf};
8 +
9 + use serde::{Deserialize, Serialize};
10 +
11 + const PREFERENCES_FILENAME: &str = "preferences.json";
12 +
13 + fn default_check_for_updates() -> bool {
14 + true
15 + }
16 +
17 + #[derive(Debug, Clone, Serialize, Deserialize)]
18 + pub struct Preferences {
19 + /// Whether the OTA update checker is allowed to contact makenot.work.
20 + /// Defaults to `true`. Users on metered networks or in privacy-sensitive
21 + /// environments can disable from the About box.
22 + #[serde(default = "default_check_for_updates")]
23 + pub check_for_updates: bool,
24 + }
25 +
26 + impl Default for Preferences {
27 + fn default() -> Self {
28 + Self {
29 + check_for_updates: default_check_for_updates(),
30 + }
31 + }
32 + }
33 +
34 + fn path_for(config_dir: &Path) -> PathBuf {
35 + config_dir.join(PREFERENCES_FILENAME)
36 + }
37 +
38 + impl Preferences {
39 + pub fn load(config_dir: &Path) -> Self {
40 + let path = path_for(config_dir);
41 + match std::fs::read_to_string(&path) {
42 + Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| {
43 + tracing::warn!("Failed to parse {}: {e}; using defaults", path.display());
44 + Preferences::default()
45 + }),
46 + Err(_) => Preferences::default(),
47 + }
48 + }
49 +
50 + pub fn save(&self, config_dir: &Path) {
51 + let path = path_for(config_dir);
52 + match serde_json::to_string_pretty(self) {
53 + Ok(s) => {
54 + if let Err(e) = std::fs::write(&path, s) {
55 + tracing::warn!("Failed to write {}: {e}", path.display());
56 + }
57 + }
58 + Err(e) => tracing::warn!("Failed to serialise preferences: {e}"),
59 + }
60 + }
61 + }
62 +
63 + #[cfg(test)]
64 + mod tests {
65 + use super::*;
66 +
67 + #[test]
68 + fn default_enables_update_checks() {
69 + let p = Preferences::default();
70 + assert!(p.check_for_updates);
71 + }
72 +
73 + #[test]
74 + fn missing_file_returns_defaults() {
75 + let dir = tempfile::tempdir().unwrap();
76 + let p = Preferences::load(dir.path());
77 + assert!(p.check_for_updates);
78 + }
79 +
80 + #[test]
81 + fn save_then_load_roundtrip() {
82 + let dir = tempfile::tempdir().unwrap();
83 + let p = Preferences { check_for_updates: false };
84 + p.save(dir.path());
85 + let loaded = Preferences::load(dir.path());
86 + assert!(!loaded.check_for_updates);
87 + }
88 +
89 + #[test]
90 + fn unknown_field_in_file_does_not_break_load() {
91 + let dir = tempfile::tempdir().unwrap();
92 + std::fs::write(
93 + dir.path().join(PREFERENCES_FILENAME),
94 + r#"{"check_for_updates": false, "future_field": 42}"#,
95 + )
96 + .unwrap();
97 + let loaded = Preferences::load(dir.path());
98 + assert!(!loaded.check_for_updates);
99 + }
100 +
101 + #[test]
102 + fn missing_check_for_updates_field_defaults_to_true() {
103 + let dir = tempfile::tempdir().unwrap();
104 + std::fs::write(dir.path().join(PREFERENCES_FILENAME), r#"{}"#).unwrap();
105 + let loaded = Preferences::load(dir.path());
106 + assert!(loaded.check_for_updates);
107 + }
108 +
109 + #[test]
110 + fn corrupt_file_falls_back_to_defaults() {
111 + let dir = tempfile::tempdir().unwrap();
112 + std::fs::write(dir.path().join(PREFERENCES_FILENAME), "{not json").unwrap();
113 + let loaded = Preferences::load(dir.path());
114 + assert!(loaded.check_for_updates);
115 + }
116 + }
@@ -61,6 +61,15 @@ impl UpdateChecker {
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;
@@ -325,6 +325,10 @@ pub struct SyncUiState {
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 @@ impl Default for SyncUiState {
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 @@ fn draw_name_column(
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,
@@ -3,34 +3,30 @@
3 3 use egui;
4 4 use tracing::{error, warn};
5 5
6 - use audiofiles_sync::{SyncManager, SyncState, SyncStatus, TierInfo};
6 + use audiofiles_sync::{AppPricing, BillingInterval, SyncManager, SyncState, SyncStatus};
7 7
8 8 use crate::state::{BrowserState, ConfirmAction};
9 9 use crate::ui::theme;
10 10 use crate::ui::widgets;
11 11
12 - /// Capitalize the first character of a tier id without byte-slicing — the
13 - /// previous `tier[..1].to_uppercase()` form panicked on empty strings and on
14 - /// any multi-byte first character. Defence in depth: the server should only
15 - /// ever return ASCII tier ids, but a per-frame panic costs the whole panel.
16 - fn capitalize_tier(tier: &str) -> String {
17 - let mut chars = tier.chars();
18 - match chars.next() {
19 - Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
20 - None => String::new(),
12 + const GIB: i64 = 1024 * 1024 * 1024;
13 +
14 + fn format_cents(cents: i64) -> String {
15 + let dollars = cents / 100;
16 + let pennies = cents % 100;
17 + if pennies == 0 {
18 + format!("${dollars}")
19 + } else {
20 + format!("${dollars}.{pennies:02}")
21 21 }
22 22 }
23 23
24 - /// Format a tier's pricing as a human-readable string (e.g. "$3/mo or $30/year (save $6)").
25 - fn format_tier_price(tier: &TierInfo) -> String {
26 - let monthly = tier.monthly_price_cents as f64 / 100.0;
27 - let annual = tier.annual_price_cents as f64 / 100.0;
28 - let monthly_equiv = monthly * 12.0;
29 - let savings = monthly_equiv - annual;
30 - if savings > 0.0 {
31 - format!("${monthly:.0}/mo or ${annual:.0}/year (save ${savings:.0})")
24 + fn format_cap(cap_bytes: i64) -> String {
25 + let gib = cap_bytes / GIB;
26 + if gib >= 1024 {
27 + format!("{:.1} TiB", gib as f64 / 1024.0)
32 28 } else {
33 - format!("${monthly:.0}/mo or ${annual:.0}/year")
29 + format!("{} GiB", gib)
34 30 }
35 31 }
36 32
@@ -185,123 +181,84 @@ fn draw_subscription_section(
185 181
186 182 match &sync_status.subscription {
187 183 Some(sub) if sub.active => {
188 - // Show tier + usage
189 - let tier = sub.tier.as_deref().unwrap_or("standard");
190 184 let limit = sub.storage_limit_bytes.unwrap_or(0);
191 185 let used = sub.storage_used_bytes.unwrap_or(0);
186 + let interval = sub.interval.as_deref().unwrap_or("monthly");
192 187
193 - ui.horizontal(|ui| {
194 - ui.label(format!(
195 - "Subscribed: {} tier",
196 - capitalize_tier(tier),
197 - ));
198 - });
188 + ui.label(format!(
189 + "Subscribed: {} ({})",
190 + format_cap(limit),
191 + interval,
192 + ));
199 193
200 194 if limit > 0 {
201 - let used_gb = used as f64 / (1024.0 * 1024.0 * 1024.0);
202 - let limit_gb = limit as f64 / (1024.0 * 1024.0 * 1024.0);
203 - let fraction = if limit > 0 {
204 - (used as f32) / (limit as f32)
205 - } else {
206 - 0.0
207 - };
208 -
195 + let used_gb = used as f64 / GIB as f64;
196 + let limit_gb = limit as f64 / GIB as f64;
197 + let fraction = (used as f32) / (limit as f32);
209 198 ui.add(
210 199 egui::ProgressBar::new(fraction)
211 - .text(format!("{used_gb:.1} / {limit_gb:.0} GB")),
200 + .text(format!("{used_gb:.1} / {limit_gb:.0} GiB")),
212 201 );
213 202 }
214 203
215 - // Show other tiers for upgrade/downgrade
216 - if let Some(tiers) = &sync_status.tiers {
217 - let other_tiers: Vec<_> = tiers.iter().filter(|t| t.id != tier).collect();
218 - if !other_tiers.is_empty() {
219 - ui.add_space(theme::space::MD);
220 - ui.label(egui::RichText::new("Change tier:").weak());
221 - ui.add_space(theme::space::XS);
204 + if let Some(pending) = sub.pending_storage_limit_bytes {
205 + ui.add_space(theme::space::XS);
206 + ui.label(
207 + egui::RichText::new(format!(
208 + "Pending: cap changes to {} at next renewal.",
209 + format_cap(pending)
210 + ))
211 + .weak(),
212 + );
213 + }
222 214
223 - for t in &other_tiers {
224 - ui.horizontal(|ui| {
225 - ui.label(&t.label);
226 - ui.label(egui::RichText::new(format_tier_price(t)).weak());
227 - });
228 - ui.horizontal(|ui| {
229 - // Annual is the recommended option (see prose
230 - // above about Stripe fees) — render as primary;
231 - // Monthly stays secondary.
232 - let loading = state.sync.checkout_loading;
233 - if ui
234 - .add_enabled_ui(!loading, |ui| widgets::primary_button(ui, "Annual"))
235 - .inner
236 - .clicked()
237 - {
238 - state.sync.checkout_loading = true;
239 - state.sync.checkout_loading_at = Some(std::time::Instant::now());
240 - sync.change_tier(&t.id, "annual");
241 - }
242 - if ui
243 - .add_enabled_ui(!loading, |ui| widgets::secondary_button(ui, "Monthly"))
244 - .inner
245 - .clicked()
246 - {
247 - state.sync.checkout_loading = true;
248 - state.sync.checkout_loading_at = Some(std::time::Instant::now());
249 - sync.change_tier(&t.id, "monthly");
250 - }
251 - });
252 - ui.add_space(theme::space::XS);
253 - }
254 -
255 - ui.label(
256 - egui::RichText::new("Changes are prorated by Stripe.")
257 - .weak()
258 - .size(11.0),
259 - );
215 + // Cap-change slider for subscribed users.
216 + if let Some(pricing) = &sync_status.pricing {
217 + let pricing = pricing.clone();
218 + let interval_enum = BillingInterval::from_str(interval);
219 + ui.add_space(theme::space::MD);
220 + ui.label(egui::RichText::new("Adjust cap (takes effect next cycle):").weak());
221 + if let Some(cap) = draw_cap_picker(ui, state, &pricing, interval_enum, "Update cap") {
222 + sync.queue_cap_change(cap);
260 223 }
261 224 }
262 225 }
263 226 _ => {
264 - // Not subscribed — show tier options
265 - if let Some(tiers) = &sync_status.tiers {
266 - ui.label("Choose a storage tier for audio file sync:");
267 - ui.add_space(theme::space::SM);
268 -
227 + if let Some(pricing) = &sync_status.pricing {
228 + let pricing = pricing.clone();
229 + ui.label("Pick a storage cap for audio file sync:");
230 + ui.add_space(theme::space::XS);
269 231 ui.label(
270 232 egui::RichText::new(
271 - "Annual saves you money — fewer Stripe transactions means less processing fees.",
233 + "Annual is 2 months free — fewer Stripe fees, so we pass the savings on.",
272 234 )
273 235 .weak()
274 236 .size(11.0),
275 237 );
276 238 ui.add_space(theme::space::SM);
277 239
278 - for t in tiers {
279 - ui.horizontal(|ui| {
280 - ui.label(&t.label);
281 - ui.label(egui::RichText::new(format_tier_price(t)).weak());
282 - });
283 - ui.horizontal(|ui| {
284 - let loading = state.sync.checkout_loading;
285 - if ui
286 - .add_enabled_ui(!loading, |ui| widgets::primary_button(ui, "Annual"))
287 - .inner
288 - .clicked()
289 - {
290 - state.sync.checkout_loading = true;
291 - state.sync.checkout_loading_at = Some(std::time::Instant::now());
292 - sync.subscribe(&t.id, "annual");
293 - }
294 - if ui
295 - .add_enabled_ui(!loading, |ui| widgets::secondary_button(ui, "Monthly"))
296 - .inner
297 - .clicked()
298 - {
299 - state.sync.checkout_loading = true;
300 - state.sync.checkout_loading_at = Some(std::time::Instant::now());
301 - sync.subscribe(&t.id, "monthly");
302 - }
303 - });
304 - ui.add_space(theme::space::XS);
240 + if let Some(cap) = draw_cap_picker(
241 + ui,
242 + state,
243 + &pricing,
244 + BillingInterval::Annual,
245 + "Subscribe (annual)",
246 + ) {
247 + state.sync.checkout_loading = true;
248 + state.sync.checkout_loading_at = Some(std::time::Instant::now());
249 + sync.subscribe(cap, BillingInterval::Annual);
250 + }
251 + ui.add_space(theme::space::XS);
252 + if let Some(cap) = draw_cap_picker(
253 + ui,
254 + state,
255 + &pricing,
256 + BillingInterval::Monthly,
257 + "Subscribe (monthly)",
258 + ) {
259 + state.sync.checkout_loading = true;
260 + state.sync.checkout_loading_at = Some(std::time::Instant::now());
261 + sync.subscribe(cap, BillingInterval::Monthly);
305 262 }
306 263 } else {
307 264 ui.label(egui::RichText::new("Loading pricing...").weak());
@@ -320,10 +277,10 @@ pub fn draw_sync_not_configured(ctx: &egui::Context, state: &mut BrowserState) {
320 277 false,
321 278 Some(380.0),
322 279 |ui| {
323 - ui.label("Cloud sync is not available in this build.");
280 + ui.label("Cloud sync is unavailable.");
324 281 ui.add_space(theme::space::MD);
325 282 ui.label(
326 - egui::RichText::new("Set the SYNCKIT_API_KEY environment variable at build time to enable sync.")
283 + egui::RichText::new("Open a vault and ensure your license or trial is active to enable sync.")
327 284 .small()
328 285 .weak(),
329 286 );
@@ -686,3 +643,56 @@ fn draw_ready(
686 643 });
687 644 }
688 645 }
646 +
647 + /// Cap-picker widget: slider in GiB + live price preview + action button.
648 + /// Used both for initial subscribe and for queuing a cap change on an active
649 + /// subscription. The slider's working value lives on `BrowserState::sync` so
650 + /// it survives frames; returns `Some(cap_bytes)` on the frame the button is
651 + /// clicked so the caller can fire the action (the helper avoids touching
652 + /// `state` further itself, sidestepping borrow conflicts with action closures).
653 + fn draw_cap_picker(
654 + ui: &mut egui::Ui,
655 + state: &mut BrowserState,
656 + pricing: &AppPricing,
657 + interval: BillingInterval,
658 + button_label: &str,
659 + ) -> Option<i64> {
660 + let min_gib = (pricing.min_cap_bytes / GIB).max(1);
661 + let max_gib = (pricing.max_cap_bytes / GIB).max(min_gib);
662 + if state.sync.cap_picker_gib < min_gib {
663 + state.sync.cap_picker_gib = min_gib;
664 + }
665 + if state.sync.cap_picker_gib > max_gib {
666 + state.sync.cap_picker_gib = max_gib;
667 + }
668 +
669 + ui.add(
670 + egui::Slider::new(&mut state.sync.cap_picker_gib, min_gib..=max_gib)
671 + .logarithmic(true)
672 + .text("GiB"),
673 + );
674 +
675 + let cap_bytes = state.sync.cap_picker_gib * GIB;
676 + let price_cents = pricing.quote_cents(cap_bytes, interval);
677 + let interval_word = match interval {
678 + BillingInterval::Monthly => "month",
679 + BillingInterval::Annual => "year",
680 + };
681 + ui.label(format!(
682 + "{} → {}/{}",
683 + format_cap(cap_bytes),
684 + format_cents(price_cents),
685 + interval_word,
686 + ));
687 +
688 + let loading = state.sync.checkout_loading;
689 + if ui
690 + .add_enabled_ui(!loading, |ui| widgets::primary_button(ui, button_label))
691 + .inner
692 + .clicked()
693 + {
694 + Some(cap_bytes)
695 + } else {
696 + None
697 + }
698 + }
@@ -440,7 +440,7 @@ fn draw_breadcrumb(
440 440 None => egui::RichText::new(&sync_label),
441 441 };
442 442 if ui
443 - .add_sized([96.0, 0.0], egui::Button::new(label_text))
443 + .add(egui::Button::new(label_text).min_size(egui::vec2(96.0, 0.0)))
444 444 .on_hover_text(&sync_tooltip)
445 445 .clicked()
446 446 {
@@ -12,6 +12,33 @@ use super::{convert, decode, encode, encode_aiff};
12 12 use super::{ExportConfig, ExportFormat, ExportItem, ExportSummary};
13 13 use tracing::instrument;
14 14
15 + /// If `dest` already exists on disk, return `dest` with a `_1`, `_2`, ...
16 + /// suffix inserted before the extension until a free path is found. Prevents
17 + /// silent overwrite of existing files in the user-chosen export directory
18 + /// (e.g. re-exporting into a folder that contains older masters of the same
19 + /// name).
20 + fn resolve_collision(dest: &Path) -> PathBuf {
21 + if !dest.exists() {
22 + return dest.to_path_buf();
23 + }
24 + let parent = dest.parent().unwrap_or_else(|| Path::new(""));
25 + let stem = dest.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
26 + let ext = dest.extension().and_then(|s| s.to_str());
27 + for n in 1..=9999 {
28 + let candidate_name = match ext {
29 + Some(e) => format!("{stem}_{n}.{e}"),
30 + None => format!("{stem}_{n}"),
31 + };
32 + let candidate = parent.join(candidate_name);
33 + if !candidate.exists() {
34 + return candidate;
35 + }
36 + }
37 + // 9999 collisions in one export dir is absurd; just return the last
38 + // candidate and let the copy fail / overwrite (extremely unreachable).
39 + parent.join(format!("{stem}_overflow"))
40 + }
41 +
15 42 /// Write a metadata sidecar JSON file alongside an exported sample.
16 43 fn write_sidecar(dest: &Path, item: &ExportItem) -> Result<()> {
17 44 let sidecar_path = PathBuf::from(format!("{}.audiofiles.json", dest.display()));
@@ -107,6 +134,9 @@ pub fn run_export(
107 134 fs::create_dir_all(parent).map_err(|e| io_err(parent, e))?;
108 135 }
109 136
137 + // Avoid silently overwriting existing files in the user's export dir.
138 + let dest = resolve_collision(&dest);
139 +
110 140 if let Err(e) = export_single_item(&source, &dest, item, config) {
111 141 errors.push((item.name.clone(), e.to_string()));
112 142 continue;
@@ -49,8 +49,9 @@ pub struct SyncStatus {
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 @@ impl Default for SyncStatus {
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 @@ impl SyncManager {
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 @@ impl SyncManager {
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 @@ fn load_sync_settings_into_status(db_path: &PathBuf, s: &mut SyncStatus) {
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;
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"