Skip to main content

max / audiofiles

Embed SyncKit API key, dynamic tiers, tier upgrade/downgrade - Add EMBEDDED_API_KEY via include_str from synckit.toml pattern - Replace API key entry panel with dev-build fallback message - Remove API key test/save action handling from app loop - SyncManager.fetch_tiers() on startup, tiers stored in SyncStatus - Subscription UI renders tiers dynamically from server - Tier upgrade/downgrade: server endpoint, Stripe proration, UI - Mark stale todo items as done (sync indicator, annual messaging) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-11 18:38 UTC
Commit: 9d9447f943b3224b2cd5c0ff4586eb272750cd1e
Parent: 139ef9b
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;
M docs/todo.md +33 -3
@@ -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 ---