max / audiofiles
4 files changed,
+196 insertions,
-159 deletions
| @@ -27,7 +27,7 @@ mod vault_setup; | |||
| 27 | 27 | use std::path::{Path, PathBuf}; | |
| 28 | 28 | use std::sync::Arc; | |
| 29 | 29 | ||
| 30 | - | use audiofiles_browser::state::{BrowserState, SharedState, SyncSetupAction, SyncSetupStatus}; | |
| 30 | + | use audiofiles_browser::state::{BrowserState, SharedState}; | |
| 31 | 31 | use audiofiles_core::vault::{self, VaultRegistry}; | |
| 32 | 32 | use audiofiles_sync::{SyncKitConfig, SyncManager}; | |
| 33 | 33 | use eframe::egui; | |
| @@ -125,7 +125,10 @@ fn main() -> eframe::Result<()> { | |||
| 125 | 125 | ||
| 126 | 126 | // ── API key persistence ── | |
| 127 | 127 | ||
| 128 | - | /// Load a saved API key from the data directory, falling back to env vars. | |
| 128 | + | /// Embedded SyncKit API key, set at build time via SYNCKIT_API_KEY env var. | |
| 129 | + | const EMBEDDED_API_KEY: Option<&str> = option_env!("SYNCKIT_API_KEY"); | |
| 130 | + | ||
| 131 | + | /// Load a saved API key from the data directory, falling back to env vars and embedded key. | |
| 129 | 132 | fn load_api_key(data_dir: &Path) -> Option<String> { | |
| 130 | 133 | // Saved key file takes priority | |
| 131 | 134 | let key_path = data_dir.join("sync_api_key"); | |
| @@ -143,10 +146,12 @@ fn load_api_key(data_dir: &Path) -> Option<String> { | |||
| 143 | 146 | ) { | |
| 144 | 147 | return Some(key); | |
| 145 | 148 | } | |
| 146 | - | None | |
| 149 | + | // Fall back to embedded key (set at build time) | |
| 150 | + | EMBEDDED_API_KEY.map(String::from) | |
| 147 | 151 | } | |
| 148 | 152 | ||
| 149 | 153 | /// Save an API key to the data directory for future launches. | |
| 154 | + | #[cfg(test)] | |
| 150 | 155 | fn save_api_key(data_dir: &Path, api_key: &str) { | |
| 151 | 156 | let key_path = data_dir.join("sync_api_key"); | |
| 152 | 157 | if let Err(e) = std::fs::write(&key_path, api_key) { | |
| @@ -169,6 +174,7 @@ fn create_sync_manager( | |||
| 169 | 174 | let db_path = data_dir.join("audiofiles.db"); | |
| 170 | 175 | let content_dir = data_dir.join("samples"); | |
| 171 | 176 | let manager = SyncManager::new(config, db_path, content_dir, runtime.clone()); | |
| 177 | + | manager.fetch_tiers(); | |
| 172 | 178 | manager.try_restore_session(); | |
| 173 | 179 | manager.start_scheduler(); | |
| 174 | 180 | Some(manager) | |
| @@ -217,8 +223,6 @@ struct AudioFilesApp { | |||
| 217 | 223 | tray: Option<tray::AppTray>, | |
| 218 | 224 | sync_manager: Option<SyncManager>, | |
| 219 | 225 | update_checker: updater::UpdateChecker, | |
| 220 | - | /// Shared slot for async API key test results from tokio tasks. | |
| 221 | - | sync_test_result: Arc<Mutex<Option<Result<String, String>>>>, | |
| 222 | 226 | /// Active MIDI input connection (dropped to disconnect). | |
| 223 | 227 | midi_connection: Option<midi::MidiConnection>, | |
| 224 | 228 | #[cfg_attr(not(target_os = "linux"), allow(dead_code))] | |
| @@ -322,7 +326,6 @@ impl AudioFilesApp { | |||
| 322 | 326 | tray, | |
| 323 | 327 | sync_manager, | |
| 324 | 328 | update_checker, | |
| 325 | - | sync_test_result: Arc::new(Mutex::new(None)), | |
| 326 | 329 | midi_connection: None, | |
| 327 | 330 | gtk_ok, | |
| 328 | 331 | _runtime: runtime, | |
| @@ -493,56 +496,6 @@ impl AudioFilesApp { | |||
| 493 | 496 | } | |
| 494 | 497 | ||
| 495 | 498 | // ── Sync setup actions (before draw, so UI sees results this frame) ── | |
| 496 | - | if let Some(ref mut browser) = self.browser { | |
| 497 | - | // Poll for completed async test | |
| 498 | - | if let Some(result) = self.sync_test_result.lock().take() { | |
| 499 | - | match result { | |
| 500 | - | Ok(app_name) => { | |
| 501 | - | browser.sync.setup_status = SyncSetupStatus::Valid { app_name }; | |
| 502 | - | } | |
| 503 | - | Err(error) => { | |
| 504 | - | browser.sync.setup_status = SyncSetupStatus::Failed { error }; | |
| 505 | - | } | |
| 506 | - | } | |
| 507 | - | } | |
| 508 | - | ||
| 509 | - | // Handle pending actions from the sync setup UI | |
| 510 | - | if let Some(action) = browser.sync.pending_action.take() { | |
| 511 | - | match action { | |
| 512 | - | SyncSetupAction::TestKey(key) => { | |
| 513 | - | let slot = self.sync_test_result.clone(); | |
| 514 | - | let server_url = SYNC_SERVER_URL.to_string(); | |
| 515 | - | self._runtime.spawn(async move { | |
| 516 | - | let result = audiofiles_sync::validate_api_key(&server_url, &key).await; | |
| 517 | - | *slot.lock() = Some( | |
| 518 | - | result.map_err(|e| e.to_string()), | |
| 519 | - | ); | |
| 520 | - | }); | |
| 521 | - | } | |
| 522 | - | SyncSetupAction::SaveKey(key) => { | |
| 523 | - | save_api_key(&self.data_dir, &key); | |
| 524 | - | let server_url = std::env::var("AF_SYNC_SERVER_URL") | |
| 525 | - | .unwrap_or_else(|_| SYNC_SERVER_URL.to_string()); | |
| 526 | - | let config = SyncKitConfig { | |
| 527 | - | server_url, | |
| 528 | - | api_key: key, | |
| 529 | - | }; | |
| 530 | - | let db_path = self.data_dir.join("audiofiles.db"); | |
| 531 | - | let content_dir = self.data_dir.join("samples"); | |
| 532 | - | let manager = SyncManager::new( | |
| 533 | - | config, | |
| 534 | - | db_path, | |
| 535 | - | content_dir, | |
| 536 | - | self._runtime.handle().clone(), | |
| 537 | - | ); | |
| 538 | - | manager.try_restore_session(); | |
| 539 | - | manager.start_scheduler(); | |
| 540 | - | self.sync_manager = Some(manager); | |
| 541 | - | } | |
| 542 | - | } | |
| 543 | - | } | |
| 544 | - | } | |
| 545 | - | ||
| 546 | 499 | // ── Vault actions ── | |
| 547 | 500 | if let Some(ref mut browser) = self.browser { | |
| 548 | 501 | if let Some(action) = browser.settings.pending_action.take() { |
| @@ -3,11 +3,24 @@ | |||
| 3 | 3 | use egui; | |
| 4 | 4 | use tracing::{debug, error, warn}; | |
| 5 | 5 | ||
| 6 | - | use audiofiles_sync::{SyncManager, SyncState, SyncStatus}; | |
| 6 | + | use audiofiles_sync::{SyncManager, SyncState, SyncStatus, TierInfo}; | |
| 7 | 7 | ||
| 8 | 8 | use crate::state::BrowserState; | |
| 9 | 9 | use crate::ui::theme; | |
| 10 | 10 | ||
| 11 | + | /// Format a tier's pricing as a human-readable string (e.g. "$3/mo or $30/year (save $6)"). | |
| 12 | + | fn format_tier_price(tier: &TierInfo) -> String { | |
| 13 | + | let monthly = tier.monthly_price_cents as f64 / 100.0; | |
| 14 | + | let annual = tier.annual_price_cents as f64 / 100.0; | |
| 15 | + | let monthly_equiv = monthly * 12.0; | |
| 16 | + | let savings = monthly_equiv - annual; | |
| 17 | + | if savings > 0.0 { | |
| 18 | + | format!("${monthly:.0}/mo or ${annual:.0}/year (save ${savings:.0})") | |
| 19 | + | } else { | |
| 20 | + | format!("${monthly:.0}/mo or ${annual:.0}/year") | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 11 | 24 | /// Draw the sync settings panel as a floating window. | |
| 12 | 25 | pub fn draw_sync_panel( | |
| 13 | 26 | ctx: &egui::Context, | |
| @@ -67,9 +80,10 @@ fn draw_subscription_section( | |||
| 67 | 80 | return; | |
| 68 | 81 | } | |
| 69 | 82 | ||
| 70 | - | // Once loaded, clear the loading flag | |
| 83 | + | // Once loaded, clear loading flags | |
| 71 | 84 | if sync_status.subscription.is_some() { | |
| 72 | 85 | state.sync.subscription_loading = false; | |
| 86 | + | state.sync.checkout_loading = false; | |
| 73 | 87 | } | |
| 74 | 88 | ||
| 75 | 89 | match &sync_status.subscription { | |
| @@ -100,53 +114,96 @@ fn draw_subscription_section( | |||
| 100 | 114 | .text(format!("{used_gb:.1} / {limit_gb:.0} GB")), | |
| 101 | 115 | ); | |
| 102 | 116 | } | |
| 117 | + | ||
| 118 | + | // Show other tiers for upgrade/downgrade | |
| 119 | + | if let Some(tiers) = &sync_status.tiers { | |
| 120 | + | let other_tiers: Vec<_> = tiers.iter().filter(|t| t.id != tier).collect(); | |
| 121 | + | if !other_tiers.is_empty() { | |
| 122 | + | ui.add_space(8.0); | |
| 123 | + | ui.label(egui::RichText::new("Change tier:").weak()); | |
| 124 | + | ui.add_space(2.0); | |
| 125 | + | ||
| 126 | + | for t in &other_tiers { | |
| 127 | + | ui.horizontal(|ui| { | |
| 128 | + | ui.label(&t.label); | |
| 129 | + | ui.label(egui::RichText::new(format_tier_price(t)).weak()); | |
| 130 | + | }); | |
| 131 | + | ui.horizontal(|ui| { | |
| 132 | + | let loading = state.sync.checkout_loading; | |
| 133 | + | if ui | |
| 134 | + | .add_enabled(!loading, egui::Button::new("Annual")) | |
| 135 | + | .clicked() | |
| 136 | + | { | |
| 137 | + | state.sync.checkout_loading = true; | |
| 138 | + | sync.change_tier(&t.id, "annual"); | |
| 139 | + | } | |
| 140 | + | if ui | |
| 141 | + | .add_enabled(!loading, egui::Button::new("Monthly")) | |
| 142 | + | .clicked() | |
| 143 | + | { | |
| 144 | + | state.sync.checkout_loading = true; | |
| 145 | + | sync.change_tier(&t.id, "monthly"); | |
| 146 | + | } | |
| 147 | + | }); | |
| 148 | + | ui.add_space(2.0); | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | ui.label( | |
| 152 | + | egui::RichText::new("Changes are prorated by Stripe.") | |
| 153 | + | .weak() | |
| 154 | + | .size(11.0), | |
| 155 | + | ); | |
| 156 | + | } | |
| 157 | + | } | |
| 103 | 158 | } | |
| 104 | 159 | _ => { | |
| 105 | 160 | // Not subscribed — show tier options | |
| 106 | - | ui.label("Choose a storage tier for audio file sync:"); | |
| 107 | - | ui.add_space(4.0); | |
| 108 | - | ||
| 109 | - | let tiers = [ | |
| 110 | - | ("light", "Light — 10 GB", "$1/mo or $10/year"), | |
| 111 | - | ("standard", "Standard — 50 GB", "$3/mo or $30/year"), | |
| 112 | - | ("large", "Large — 200 GB", "$8/mo or $80/year"), | |
| 113 | - | ]; | |
| 114 | - | ||
| 115 | - | for (tier_id, label, price) in tiers { | |
| 116 | - | ui.horizontal(|ui| { | |
| 117 | - | ui.label(label); | |
| 118 | - | ui.label(egui::RichText::new(price).weak()); | |
| 119 | - | }); | |
| 120 | - | ui.horizontal(|ui| { | |
| 121 | - | let loading = state.sync.checkout_loading; | |
| 122 | - | if ui | |
| 123 | - | .add_enabled(!loading, egui::Button::new("Annual")) | |
| 124 | - | .clicked() | |
| 125 | - | { | |
| 126 | - | state.sync.checkout_loading = true; | |
| 127 | - | sync.subscribe(tier_id, "annual"); | |
| 128 | - | } | |
| 129 | - | if ui | |
| 130 | - | .add_enabled(!loading, egui::Button::new("Monthly")) | |
| 131 | - | .clicked() | |
| 132 | - | { | |
| 133 | - | state.sync.checkout_loading = true; | |
| 134 | - | sync.subscribe(tier_id, "monthly"); | |
| 135 | - | } | |
| 136 | - | }); | |
| 137 | - | ui.add_space(2.0); | |
| 161 | + | if let Some(tiers) = &sync_status.tiers { | |
| 162 | + | ui.label("Choose a storage tier for audio file sync:"); | |
| 163 | + | ui.add_space(4.0); | |
| 164 | + | ||
| 165 | + | ui.label( | |
| 166 | + | egui::RichText::new( | |
| 167 | + | "Annual saves you money — fewer Stripe transactions means less processing fees.", | |
| 168 | + | ) | |
| 169 | + | .weak() | |
| 170 | + | .size(11.0), | |
| 171 | + | ); | |
| 172 | + | ui.add_space(4.0); | |
| 173 | + | ||
| 174 | + | for t in tiers { | |
| 175 | + | ui.horizontal(|ui| { | |
| 176 | + | ui.label(&t.label); | |
| 177 | + | ui.label(egui::RichText::new(format_tier_price(t)).weak()); | |
| 178 | + | }); | |
| 179 | + | ui.horizontal(|ui| { | |
| 180 | + | let loading = state.sync.checkout_loading; | |
| 181 | + | if ui | |
| 182 | + | .add_enabled(!loading, egui::Button::new("Annual")) | |
| 183 | + | .clicked() | |
| 184 | + | { | |
| 185 | + | state.sync.checkout_loading = true; | |
| 186 | + | sync.subscribe(&t.id, "annual"); | |
| 187 | + | } | |
| 188 | + | if ui | |
| 189 | + | .add_enabled(!loading, egui::Button::new("Monthly")) | |
| 190 | + | .clicked() | |
| 191 | + | { | |
| 192 | + | state.sync.checkout_loading = true; | |
| 193 | + | sync.subscribe(&t.id, "monthly"); | |
| 194 | + | } | |
| 195 | + | }); | |
| 196 | + | ui.add_space(2.0); | |
| 197 | + | } | |
| 198 | + | } else { | |
| 199 | + | ui.label(egui::RichText::new("Loading pricing...").weak()); | |
| 138 | 200 | } | |
| 139 | 201 | } | |
| 140 | 202 | } | |
| 141 | 203 | } | |
| 142 | 204 | ||
| 143 | - | /// Draw the sync setup panel when no SyncManager is available. | |
| 144 | - | /// | |
| 145 | - | /// Prompts the user to enter their API key, validates it against the server, | |
| 146 | - | /// and saves it for future launches. | |
| 205 | + | /// Draw a fallback panel when no SyncManager is available (no embedded API key in dev builds). | |
| 147 | 206 | pub fn draw_sync_not_configured(ctx: &egui::Context, state: &mut BrowserState) { | |
| 148 | - | use crate::state::{SyncSetupAction, SyncSetupStatus}; | |
| 149 | - | ||
| 150 | 207 | let mut open = state.sync.show_panel; | |
| 151 | 208 | egui::Window::new("Cloud Sync") | |
| 152 | 209 | .open(&mut open) | |
| @@ -155,69 +212,13 @@ pub fn draw_sync_not_configured(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 155 | 212 | .collapsible(false) | |
| 156 | 213 | .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) | |
| 157 | 214 | .show(ctx, |ui| { | |
| 158 | - | ui.label("Sync your audiofiles vault across devices via Makenot.work."); | |
| 215 | + | ui.label("Cloud sync is not available in this build."); | |
| 159 | 216 | ui.add_space(8.0); | |
| 160 | 217 | ui.label( | |
| 161 | - | egui::RichText::new( | |
| 162 | - | "Enter your SyncKit API key to get started. You can find it in your Makenot.work dashboard under SyncKit apps.", | |
| 163 | - | ) | |
| 164 | - | .small() | |
| 165 | - | .weak(), | |
| 166 | - | ); | |
| 167 | - | ||
| 168 | - | ui.add_space(4.0); | |
| 169 | - | ui.hyperlink_to( | |
| 170 | - | "Get an API key", | |
| 171 | - | "https://makenot.work/docs/synckit-api", | |
| 218 | + | egui::RichText::new("Set the SYNCKIT_API_KEY environment variable at build time to enable sync.") | |
| 219 | + | .small() | |
| 220 | + | .weak(), | |
| 172 | 221 | ); | |
| 173 | - | ||
| 174 | - | ui.add_space(12.0); | |
| 175 | - | ||
| 176 | - | // API key input + test button | |
| 177 | - | ui.horizontal(|ui| { | |
| 178 | - | ui.label("API key:"); | |
| 179 | - | ui.add( | |
| 180 | - | egui::TextEdit::singleline(&mut state.sync.api_key_input) | |
| 181 | - | .password(true) | |
| 182 | - | .desired_width(200.0) | |
| 183 | - | .hint_text("sk_..."), | |
| 184 | - | ); | |
| 185 | - | let testing = matches!(state.sync.setup_status, SyncSetupStatus::Testing); | |
| 186 | - | let enabled = !testing && !state.sync.api_key_input.trim().is_empty(); | |
| 187 | - | if ui.add_enabled(enabled, egui::Button::new("Test")).clicked() { | |
| 188 | - | let key = state.sync.api_key_input.trim().to_string(); | |
| 189 | - | state.sync.setup_status = SyncSetupStatus::Testing; | |
| 190 | - | state.sync.pending_action = Some(SyncSetupAction::TestKey(key)); | |
| 191 | - | } | |
| 192 | - | }); | |
| 193 | - | ||
| 194 | - | ui.add_space(8.0); | |
| 195 | - | ||
| 196 | - | // Status display | |
| 197 | - | match &state.sync.setup_status { | |
| 198 | - | SyncSetupStatus::Idle => {} | |
| 199 | - | SyncSetupStatus::Testing => { | |
| 200 | - | ui.horizontal(|ui| { | |
| 201 | - | ui.spinner(); | |
| 202 | - | ui.label("Validating..."); | |
| 203 | - | }); | |
| 204 | - | } | |
| 205 | - | SyncSetupStatus::Valid { app_name } => { | |
| 206 | - | ui.colored_label( | |
| 207 | - | theme::accent_green(), | |
| 208 | - | format!("Valid \u{2014} {app_name}"), | |
| 209 | - | ); | |
| 210 | - | ui.add_space(8.0); | |
| 211 | - | if ui.button("Save & Connect").clicked() { | |
| 212 | - | let key = state.sync.api_key_input.trim().to_string(); | |
| 213 | - | state.sync.pending_action = Some(SyncSetupAction::SaveKey(key)); | |
| 214 | - | state.sync.setup_status = SyncSetupStatus::Idle; | |
| 215 | - | } | |
| 216 | - | } | |
| 217 | - | SyncSetupStatus::Failed { error } => { | |
| 218 | - | ui.colored_label(theme::accent_red(), error.as_str()); | |
| 219 | - | } | |
| 220 | - | } | |
| 221 | 222 | }); | |
| 222 | 223 | state.sync.show_panel = open; | |
| 223 | 224 | } |
| @@ -46,6 +46,8 @@ pub struct SyncStatus { | |||
| 46 | 46 | pub needs_refresh: bool, | |
| 47 | 47 | /// Subscription status for blob sync tier (populated async). | |
| 48 | 48 | pub subscription: Option<synckit_client::SubscriptionStatus>, | |
| 49 | + | /// Available pricing tiers (fetched once from server). | |
| 50 | + | pub tiers: Option<Vec<synckit_client::TierInfo>>, | |
| 49 | 51 | } | |
| 50 | 52 | ||
| 51 | 53 | impl Default for SyncStatus { | |
| @@ -60,6 +62,7 @@ impl Default for SyncStatus { | |||
| 60 | 62 | sync_interval_minutes: 15, | |
| 61 | 63 | needs_refresh: false, | |
| 62 | 64 | subscription: None, | |
| 65 | + | tiers: None, | |
| 63 | 66 | } | |
| 64 | 67 | } | |
| 65 | 68 | } | |
| @@ -242,6 +245,22 @@ impl SyncManager { | |||
| 242 | 245 | } | |
| 243 | 246 | } | |
| 244 | 247 | ||
| 248 | + | /// Fetch available pricing tiers from the server (async, no JWT needed). | |
| 249 | + | pub fn fetch_tiers(&self) { | |
| 250 | + | let client = self.client.clone(); | |
| 251 | + | let status = self.status.clone(); | |
| 252 | + | self.runtime.spawn(async move { | |
| 253 | + | match client.get_available_tiers().await { | |
| 254 | + | Ok(tiers) => { | |
| 255 | + | status.lock().tiers = Some(tiers); | |
| 256 | + | } | |
| 257 | + | Err(e) => { | |
| 258 | + | tracing::debug!("Failed to fetch tiers: {e}"); | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | }); | |
| 262 | + | } | |
| 263 | + | ||
| 245 | 264 | /// Fetch subscription status from the server (async, result goes to status.subscription). | |
| 246 | 265 | pub fn fetch_subscription_status(&self) { | |
| 247 | 266 | let client = self.client.clone(); | |
| @@ -259,8 +278,10 @@ impl SyncManager { | |||
| 259 | 278 | } | |
| 260 | 279 | ||
| 261 | 280 | /// Create a Stripe checkout session and open it in the browser. | |
| 281 | + | /// Polls for subscription activation after opening checkout. | |
| 262 | 282 | pub fn subscribe(&self, tier: &str, interval: &str) { | |
| 263 | 283 | let client = self.client.clone(); | |
| 284 | + | let status = self.status.clone(); | |
| 264 | 285 | let tier = tier.to_string(); | |
| 265 | 286 | let interval = interval.to_string(); | |
| 266 | 287 | self.runtime.spawn(async move { | |
| @@ -269,6 +290,17 @@ impl SyncManager { | |||
| 269 | 290 | if let Err(e) = open::that(&resp.checkout_url) { | |
| 270 | 291 | tracing::warn!("Failed to open browser: {e}"); | |
| 271 | 292 | } | |
| 293 | + | // Poll for subscription activation (5s intervals, up to 10 minutes) | |
| 294 | + | for _ in 0..120 { | |
| 295 | + | tokio::time::sleep(std::time::Duration::from_secs(5)).await; | |
| 296 | + | if let Ok(sub) = client.get_subscription_status().await { | |
| 297 | + | if sub.active { | |
| 298 | + | status.lock().subscription = Some(sub); | |
| 299 | + | tracing::info!("Subscription activated"); | |
| 300 | + | break; | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | } | |
| 272 | 304 | } | |
| 273 | 305 | Err(e) => { | |
| 274 | 306 | tracing::error!("Failed to create checkout: {e}"); | |
| @@ -277,6 +309,26 @@ impl SyncManager { | |||
| 277 | 309 | }); | |
| 278 | 310 | } | |
| 279 | 311 | ||
| 312 | + | /// Change the tier of an existing sync subscription (Stripe prorates). | |
| 313 | + | /// Updates the local subscription status on success. | |
| 314 | + | pub fn change_tier(&self, tier: &str, interval: &str) { | |
| 315 | + | let client = self.client.clone(); | |
| 316 | + | let status = self.status.clone(); | |
| 317 | + | let tier = tier.to_string(); | |
| 318 | + | let interval = interval.to_string(); | |
| 319 | + | self.runtime.spawn(async move { | |
| 320 | + | match client.change_subscription_tier(&tier, &interval).await { | |
| 321 | + | Ok(sub) => { | |
| 322 | + | status.lock().subscription = Some(sub); | |
| 323 | + | tracing::info!(tier = %tier, "Subscription tier changed"); | |
| 324 | + | } | |
| 325 | + | Err(e) => { | |
| 326 | + | tracing::error!("Failed to change tier: {e}"); | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | }); | |
| 330 | + | } | |
| 331 | + | ||
| 280 | 332 | /// Spawn the background sync scheduler task. | |
| 281 | 333 | #[instrument(skip_all)] | |
| 282 | 334 | pub fn start_scheduler(&self) { | |
| @@ -333,4 +385,5 @@ fn load_sync_settings_into_status(db_path: &PathBuf, s: &mut SyncStatus) { | |||
| 333 | 385 | ||
| 334 | 386 | // Re-export for convenience | |
| 335 | 387 | pub use synckit_client::SyncKitConfig; | |
| 388 | + | pub use synckit_client::TierInfo; | |
| 336 | 389 | pub use synckit_client::validate_api_key; |
| @@ -3,7 +3,37 @@ | |||
| 3 | 3 | ## Status | |
| 4 | 4 | Done: All pre-beta phases + Phase 11. Active: None. Next: Vocal layer 2, sample forge (phases 10-16). | |
| 5 | 5 | ||
| 6 | - | v0.4.0. Audit grade A (Run 20, 2026-05-04). 780 tests. All remediations complete. Rust 2024 edition (2026-05-06). rand 0.9. Fixed `unsafe extern`, removed explicit `ref` in patterns. | |
| 6 | + | v0.4.0. Audit grade A- (Ultra Fuzz 1, 2026-05-09). 780 tests. Rust 2024 edition (2026-05-06). rand 0.9. 4 SERIOUS, 10 MINOR findings from 5-axis adversarial audit. Run 20 items all resolved. | |
| 7 | + | ||
| 8 | + | --- | |
| 9 | + | ||
| 10 | + | ## Ultra Fuzz Run 1 (2026-05-09) | |
| 11 | + | ||
| 12 | + | ### Sync (SERIOUS — fix before first multi-device user) | |
| 13 | + | - [x] Add `'duration', duration` to initial snapshot samples query (sync/service/state.rs:26) | |
| 14 | + | - [x] Add 5 missing columns to initial snapshot audio_analysis query (sync/service/state.rs:27) | |
| 15 | + | - [x] Exclude `unsafe_mode` from user_config sync triggers (migration 016 + snapshot filter) | |
| 16 | + | - [x] Batch `mark_cloud_only_samples` into single transaction (sync/service/state.rs:134) | |
| 17 | + | ||
| 18 | + | ### Preview decode (perf) | |
| 19 | + | - [x] Hoist SampleBuffer allocation out of decode loops (preview.rs:151,353) | |
| 20 | + | ||
| 21 | + | ### Backend contention (perf) | |
| 22 | + | - [x] Release DB lock before VP-tree index build (backend/direct.rs — load_data/build_from_data split) | |
| 23 | + | - [x] Batch `enrich_with_tags` query in export (export/mod.rs — single IN query, chunked at 500) | |
| 24 | + | ||
| 25 | + | ### UI fixes (MINOR) | |
| 26 | + | - [x] Fix `truncate_name` to use char boundaries, not byte offsets (ui/file_list_menus.rs:316) | |
| 27 | + | - [x] Fix `theme_preview_colors` key prefix: `bg.`→`background.`, `fg.`→`foreground.` (ui/theme.rs:404) | |
| 28 | + | - [x] Add macOS metadata dir filter to import dry-run count (import_workflow.rs) | |
| 29 | + | ||
| 30 | + | ### Data integrity (MINOR) | |
| 31 | + | - [x] Orphan delete re-checks with NOT EXISTS subquery (cleanup.rs:200) | |
| 32 | + | ||
| 33 | + | ### Trust model (deferred — architectural) | |
| 34 | + | - [ ] Add ed25519 signature verification on OTA update metadata (updater.rs) | |
| 35 | + | - [ ] Move API key to OS keychain via `keyring` crate (app/main.rs:131) | |
| 36 | + | - [ ] Add wall-clock timeout on Rhai script execution (rhai/engine.rs) | |
| 7 | 37 | ||
| 8 | 38 | --- | |
| 9 | 39 | ||
| @@ -26,8 +56,8 @@ AF is PWYW (suggested $15, floor $0). Metadata sync is free. Blob sync (sample f | |||
| 26 | 56 | - [x] Metadata sync remains ungated (free for all users) | |
| 27 | 57 | - [x] Subscription UI: egui tier selector (Light/Standard/Large) with Annual/Monthly buttons, storage usage progress bar | |
| 28 | 58 | - [x] Storage usage display: progress bar showing used/limit GB from subscription status | |
| 29 | - | - [ ] Tier upgrade/downgrade flow | |
| 30 | - | - [ ] Annual billing messaging: explain why annual is preferred (Stripe fee transparency) | |
| 59 | + | - [x] Tier upgrade/downgrade flow — server endpoint, Stripe proration, synckit-client method, AF UI with change buttons | |
| 60 | + | - [x] Annual billing messaging — already in sync_panel.rs:115-121 ("Annual saves you money — fewer Stripe transactions means less processing fees") + per-tier savings shown inline | |
| 31 | 61 | - [ ] Test full checkout flow against live Stripe (end-to-end: subscribe → webhook → blob sync gate passes) | |
| 32 | 62 | ||
| 33 | 63 | --- |