Skip to main content

max / audiofiles

24.9 KB · 699 lines History Blame Raw
1 //! Sync settings panel: egui Window overlay with 4 states matching the SyncKit flow.
2
3 use egui;
4 use tracing::{error, warn};
5
6 use audiofiles_sync::{AppPricing, BillingInterval, SyncManager, SyncState, SyncStatus};
7
8 use crate::state::{BrowserState, ConfirmAction};
9 use crate::ui::theme;
10 use crate::ui::widgets;
11
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 }
22 }
23
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)
28 } else {
29 format!("{} GiB", gib)
30 }
31 }
32
33 /// Draw the sync settings panel as a floating window.
34 pub fn draw_sync_panel(
35 ctx: &egui::Context,
36 state: &mut BrowserState,
37 sync: &SyncManager,
38 ) {
39 // Consume any pending disconnect set by execute_confirmed_action last frame.
40 // The confirm dispatcher in bulk_ops.rs runs without a SyncManager handle,
41 // so the actual sync.disconnect() lands here.
42 if state.sync.pending_disconnect {
43 state.sync.pending_disconnect = false;
44 sync.disconnect();
45 state.status = "Disconnected from cloud sync.".to_string();
46 }
47
48 // Drop the cached auth URL once we've left the Authenticating state. It's
49 // only meaningful while the Copy URL fallback is on screen, and lingering
50 // would leak the PKCE state into a stale display if the user reopens the
51 // panel later.
52 if !matches!(sync.status().state, SyncState::Authenticating)
53 && state.sync.auth_url.is_some()
54 {
55 state.sync.auth_url = None;
56 }
57
58 // Drop the per-VFS storage cache when the panel closes so reopening fetches
59 // fresh numbers (the user may have imported/deleted between sessions).
60 if !state.sync.show_panel {
61 state.sync.vfs_storage_fetched = false;
62 state.sync.vfs_storage_cache.clear();
63 }
64
65 let mut open = state.sync.show_panel;
66 widgets::modal_window_with_open(
67 ctx,
68 "Cloud Sync",
69 Some(&mut open),
70 false,
71 Some(360.0),
72 |ui| {
73 let status = sync.status();
74
75 match &status.state {
76 SyncState::Disconnected => {
77 draw_disconnected(ui, state, sync);
78 }
79 SyncState::Authenticating => {
80 draw_authenticating(ui, state, sync);
81 }
82 SyncState::NeedsEncryption { has_server_key } => {
83 draw_needs_encryption(ui, state, sync, *has_server_key);
84 }
85 SyncState::Ready | SyncState::Syncing => {
86 draw_ready(ui, state, sync, &status);
87 }
88 }
89
90 // Error banner with Retry + Dismiss. Retry is only meaningful in
91 // Ready/Syncing state (calls sync_now); in other states the user's
92 // primary action is already on screen (Connect, Set Password), so
93 // Retry hides itself and Dismiss is the only escape.
94 if let Some(err) = status.last_error.clone() {
95 ui.add_space(theme::space::SM);
96 ui.separator();
97 ui.add_space(theme::space::SM);
98 egui::Frame::new()
99 .fill(theme::bg_tertiary())
100 .corner_radius(egui::CornerRadius::same(4))
101 .inner_margin(egui::Margin::same(8))
102 .show(ui, |ui| {
103 ui.label(egui::RichText::new(err).color(theme::accent_red()));
104 ui.add_space(theme::space::SM);
105 ui.horizontal(|ui| {
106 let retryable = matches!(
107 status.state,
108 SyncState::Ready | SyncState::Syncing,
109 );
110 if retryable
111 && widgets::secondary_button(ui, "Retry").clicked()
112 {
113 sync.clear_last_error();
114 sync.sync_now();
115 }
116 if widgets::secondary_button(ui, "Dismiss").clicked() {
117 sync.clear_last_error();
118 }
119 });
120 });
121 }
122 },
123 );
124 state.sync.show_panel = open;
125 }
126
127 /// Draw the subscription status/purchase section for blob sync.
128 fn draw_subscription_section(
129 ui: &mut egui::Ui,
130 state: &mut BrowserState,
131 sync: &SyncManager,
132 ) {
133 let sync_status = sync.status();
134
135 // Loading-flag timeout: if a fetch or checkout never resolves, the panel
136 // would otherwise spin "Checking subscription..." (or grey out every
137 // Subscribe button) forever. After 30s without a response, clear the flag
138 // so the user can retry. The status message is the only feedback channel
139 // for this surface — see C-3's wiring of the same pattern.
140 const LOADING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
141 if let Some(at) = state.sync.subscription_loading_at {
142 if at.elapsed() >= LOADING_TIMEOUT && sync_status.subscription.is_none() {
143 state.sync.subscription_loading = false;
144 state.sync.subscription_loading_at = None;
145 state.status = "Subscription check timed out. Click Retry to try again.".to_string();
146 }
147 }
148 if let Some(at) = state.sync.checkout_loading_at {
149 if at.elapsed() >= LOADING_TIMEOUT {
150 state.sync.checkout_loading = false;
151 state.sync.checkout_loading_at = None;
152 state.status = "Checkout timed out. The browser tab may have closed; try again.".to_string();
153 }
154 }
155
156 // Trigger initial fetch if not yet loaded
157 if sync_status.subscription.is_none() && !state.sync.subscription_loading {
158 state.sync.subscription_loading = true;
159 state.sync.subscription_loading_at = Some(std::time::Instant::now());
160 sync.fetch_subscription_status();
161 }
162
163 if state.sync.subscription_loading && sync_status.subscription.is_none() {
164 ui.horizontal(|ui| {
165 ui.label(egui::RichText::new("Checking subscription...").weak());
166 if ui.small_button("Retry").clicked() {
167 state.sync.subscription_loading_at = Some(std::time::Instant::now());
168 sync.fetch_subscription_status();
169 }
170 });
171 return;
172 }
173
174 // Once loaded, clear loading flags
175 if sync_status.subscription.is_some() {
176 state.sync.subscription_loading = false;
177 state.sync.subscription_loading_at = None;
178 state.sync.checkout_loading = false;
179 state.sync.checkout_loading_at = None;
180 }
181
182 match &sync_status.subscription {
183 Some(sub) if sub.active => {
184 let limit = sub.storage_limit_bytes.unwrap_or(0);
185 let used = sub.storage_used_bytes.unwrap_or(0);
186 let interval = sub.interval.as_deref().unwrap_or("monthly");
187
188 ui.label(format!(
189 "Subscribed: {} ({})",
190 format_cap(limit),
191 interval,
192 ));
193
194 if limit > 0 {
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);
198 ui.add(
199 egui::ProgressBar::new(fraction)
200 .text(format!("{used_gb:.1} / {limit_gb:.0} GiB")),
201 );
202 }
203
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 }
214
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);
223 }
224 }
225 }
226 _ => {
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);
231 ui.label(
232 egui::RichText::new(
233 "Annual is 2 months free — fewer Stripe fees, so we pass the savings on.",
234 )
235 .weak()
236 .size(11.0),
237 );
238 ui.add_space(theme::space::SM);
239
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);
262 }
263 } else {
264 ui.label(egui::RichText::new("Loading pricing...").weak());
265 }
266 }
267 }
268 }
269
270 /// Draw a fallback panel when no SyncManager is available (no embedded API key in dev builds).
271 pub fn draw_sync_not_configured(ctx: &egui::Context, state: &mut BrowserState) {
272 let mut open = state.sync.show_panel;
273 widgets::modal_window_with_open(
274 ctx,
275 "Cloud Sync",
276 Some(&mut open),
277 false,
278 Some(380.0),
279 |ui| {
280 ui.label("Cloud sync is unavailable.");
281 ui.add_space(theme::space::MD);
282 ui.label(
283 egui::RichText::new("Open a vault and ensure your license or trial is active to enable sync.")
284 .small()
285 .weak(),
286 );
287 },
288 );
289 state.sync.show_panel = open;
290 }
291
292 /// Disconnected state: invite user to connect.
293 fn draw_disconnected(
294 ui: &mut egui::Ui,
295 state: &mut BrowserState,
296 sync: &SyncManager,
297 ) {
298 ui.label("Connect your audiofiles vault to Makenot.work for cross-device sync.");
299 ui.add_space(theme::space::MD);
300 ui.label(
301 egui::RichText::new("Metadata (tags, vault structure, analysis) syncs automatically. Audio file sync is per-vault opt-in.")
302 .small()
303 .weak(),
304 );
305 ui.add_space(theme::space::LG);
306 if ui.button("Connect").clicked() {
307 match sync.start_auth() {
308 Ok(auth_url) => {
309 #[cfg(target_os = "macos")]
310 let _ = std::process::Command::new("open").arg(&auth_url).spawn();
311 #[cfg(target_os = "linux")]
312 let _ = std::process::Command::new("xdg-open").arg(&auth_url).spawn();
313 #[cfg(target_os = "windows")]
314 let _ = std::process::Command::new("cmd").args(["/c", "start", &auth_url]).spawn();
315 state.sync.auth_code_input.clear();
316 // Cache for the Authenticating screen's "Copy URL" fallback —
317 // the browser may have failed to open silently.
318 state.sync.auth_url = Some(auth_url);
319 }
320 Err(e) => {
321 error!("Failed to start auth: {e}");
322 state.status = format!("Sync connect failed: {e}");
323 }
324 }
325 }
326 }
327
328 /// Authenticating state: waiting for OAuth callback, with Cancel + Copy URL
329 /// escape hatches. Without these the user could be trapped here indefinitely
330 /// when the browser doesn't open, OAuth fails server-side, or they change
331 /// their mind — closing the window doesn't move the underlying state.
332 fn draw_authenticating(
333 ui: &mut egui::Ui,
334 state: &mut BrowserState,
335 sync: &SyncManager,
336 ) {
337 ui.horizontal(|ui| {
338 ui.label("Waiting for authentication in your browser...");
339 ui.spinner();
340 });
341 ui.add_space(theme::space::MD);
342 ui.label(
343 egui::RichText::new("The app will update automatically once you sign in.")
344 .small()
345 .weak(),
346 );
347
348 // Copy-URL fallback: the browser may have failed to open silently (wrong
349 // default browser, headless system, popup blocker on a Tauri host).
350 if let Some(ref url) = state.sync.auth_url.clone() {
351 ui.add_space(theme::space::MD);
352 ui.separator();
353 ui.add_space(theme::space::SM);
354 ui.label(
355 egui::RichText::new("Browser didn't open? Copy this URL into a browser manually.")
356 .small()
357 .weak(),
358 );
359 ui.add_space(theme::space::XS);
360 ui.horizontal(|ui| {
361 // Read-only truncated URL display + Copy button. The URL itself is
362 // long (OAuth + PKCE + state) so truncation is necessary.
363 let mut shown = url.clone();
364 ui.add(
365 egui::TextEdit::singleline(&mut shown)
366 .desired_width(ui.available_width() - 70.0),
367 );
368 if ui.button("Copy").clicked() {
369 ui.ctx().copy_text(url.clone());
370 state.status = "Copied auth URL.".to_string();
371 }
372 });
373 }
374
375 ui.add_space(theme::space::LG);
376 if ui.button("Cancel").clicked() {
377 sync.cancel_auth();
378 state.sync.auth_url = None;
379 state.status = "Sync connection cancelled.".to_string();
380 }
381 }
382
383 /// NeedsEncryption state: password setup.
384 fn draw_needs_encryption(
385 ui: &mut egui::Ui,
386 state: &mut BrowserState,
387 sync: &SyncManager,
388 has_server_key: bool,
389 ) {
390 if has_server_key {
391 ui.label("Enter your encryption password to unlock this device.");
392 ui.add_space(theme::space::MD);
393 ui.label(
394 egui::RichText::new("This is the password you set when you first connected.")
395 .small()
396 .weak(),
397 );
398 } else {
399 ui.label("Set an encryption password to protect your synced data.");
400 ui.add_space(theme::space::MD);
401 ui.label(
402 egui::RichText::new("All data is encrypted before leaving your device.")
403 .small()
404 .weak(),
405 );
406 ui.add_space(theme::space::SM);
407 // Warning banner: a typo in the next field permanently re-encrypts the
408 // cloud blob under a key no one will ever re-derive. The confirm field
409 // below is the only guard, so the copy needs to outweigh the form.
410 widgets::warning_banner(
411 ui,
412 "Remember this password. It cannot be recovered, and any data already in your cloud blob will be unreadable if you forget it.",
413 );
414 }
415
416 ui.add_space(theme::space::LG);
417 ui.horizontal(|ui| {
418 ui.label("Password:");
419 ui.add(
420 egui::TextEdit::singleline(&mut state.sync.encryption_input)
421 .password(true)
422 .desired_width(200.0),
423 );
424 });
425
426 // First-time setup: confirm field + length gate. The unlock path doesn't
427 // need confirmation — a typo there is recoverable (just re-enter).
428 let (can_submit, hint): (bool, Option<&str>) = if has_server_key {
429 (!state.sync.encryption_input.is_empty(), None)
430 } else {
431 ui.add_space(theme::space::SM);
432 ui.horizontal(|ui| {
433 ui.label("Confirm: ");
434 ui.add(
435 egui::TextEdit::singleline(&mut state.sync.encryption_confirm_input)
436 .password(true)
437 .desired_width(200.0),
438 );
439 });
440 let pw = &state.sync.encryption_input;
441 let confirm = &state.sync.encryption_confirm_input;
442 if pw.is_empty() {
443 (false, None)
444 } else if pw.len() < 8 {
445 (false, Some("Password must be at least 8 characters."))
446 } else if confirm.is_empty() {
447 (false, None)
448 } else if pw != confirm {
449 (false, Some("Passwords don't match."))
450 } else {
451 (true, None)
452 }
453 };
454
455 if let Some(msg) = hint {
456 ui.add_space(theme::space::XS);
457 ui.label(
458 egui::RichText::new(msg)
459 .small()
460 .color(theme::text_muted()),
461 );
462 }
463
464 ui.add_space(theme::space::MD);
465 let button_label = if has_server_key {
466 "Unlock"
467 } else {
468 "Set Password"
469 };
470 if ui
471 .add_enabled(can_submit, egui::Button::new(button_label))
472 .clicked()
473 {
474 let password = state.sync.encryption_input.clone();
475 state.sync.encryption_input.clear();
476 state.sync.encryption_confirm_input.clear();
477 sync.setup_encryption(password, !has_server_key);
478 }
479 }
480
481 /// Ready state: status display, controls, per-VFS sync toggles.
482 fn draw_ready(
483 ui: &mut egui::Ui,
484 state: &mut BrowserState,
485 sync: &SyncManager,
486 status: &SyncStatus,
487 ) {
488 // Status info
489 ui.horizontal(|ui| {
490 let state_label = match status.state {
491 SyncState::Syncing => "Syncing...",
492 _ => "Connected",
493 };
494 ui.label(state_label);
495
496 if status.state == SyncState::Syncing {
497 ui.spinner();
498 }
499 });
500
501 if let Some(ref last) = status.last_sync_at {
502 ui.label(
503 egui::RichText::new(format!("Last sync: {last}"))
504 .small()
505 .weak(),
506 );
507 }
508
509 if status.pending_changes > 0 {
510 ui.label(format!("{} pending changes", status.pending_changes));
511 }
512
513 ui.add_space(theme::space::MD);
514
515 // Sync Now button
516 let syncing = status.state == SyncState::Syncing;
517 if ui
518 .add_enabled(!syncing, egui::Button::new("Sync Now"))
519 .clicked()
520 {
521 sync.sync_now();
522 }
523
524 ui.add_space(theme::space::MD);
525 ui.separator();
526
527 // Auto-sync settings — collapsed by default so the Ready view reads as
528 // status-first; the user expands when they want to tune cadence (p-6).
529 egui::CollapsingHeader::new(egui::RichText::new("Auto-sync").strong())
530 .id_salt("sync_auto_section")
531 .default_open(false)
532 .show(ui, |ui| {
533 let mut auto_sync = status.auto_sync_enabled;
534 if ui.checkbox(&mut auto_sync, "Auto-sync").changed() {
535 sync.update_settings(Some(auto_sync), None);
536 }
537
538 if auto_sync {
539 ui.horizontal(|ui| {
540 ui.label("Interval:");
541 let intervals = [5u32, 15, 30, 60];
542 let current = status.sync_interval_minutes;
543 // If the persisted interval falls outside the canonical list (a
544 // legacy config or manual DB edit), render an extra pill marked
545 // active so the value is visible (m-15). Clicking a canonical pill
546 // replaces it as usual.
547 let custom = if !intervals.contains(&current) {
548 Some(current)
549 } else {
550 None
551 };
552 if let Some(c) = custom {
553 let label = format!("{c}m (custom)");
554 let _ = ui.selectable_label(true, label);
555 }
556 for mins in intervals {
557 let label = if mins == 60 {
558 "1h".to_string()
559 } else {
560 format!("{mins}m")
561 };
562 if ui
563 .selectable_label(current == mins, label)
564 .clicked()
565 {
566 sync.update_settings(None, Some(mins));
567 }
568 }
569 });
570 }
571 }); // end Auto-sync CollapsingHeader
572
573 // Audio file cloud sync — also collapsed by default. Subscription state
574 // and per-vault toggles read as a single configuration group rather than
575 // three separator-delimited slices (p-6).
576 egui::CollapsingHeader::new(egui::RichText::new("Audio file cloud sync").strong())
577 .id_salt("sync_audio_section")
578 .default_open(false)
579 .show(ui, |ui| {
580 ui.label(
581 egui::RichText::new("Metadata always syncs free. Audio file sync requires a subscription.")
582 .small()
583 .weak(),
584 );
585 ui.add_space(theme::space::SM);
586
587 draw_subscription_section(ui, state, sync);
588
589 ui.add_space(theme::space::MD);
590
591 // Per-VFS "Sync audio files" toggles (only show if subscribed)
592 let subscribed = sync
593 .status()
594 .subscription
595 .as_ref()
596 .is_some_and(|s| s.active);
597
598 if subscribed {
599 // Populate the per-VFS storage cache on the first frame the section
600 // renders. Queries are SQLite-cheap (single indexed SELECT each), but
601 // we still want to avoid running them every frame at 60Hz.
602 if !state.sync.vfs_storage_fetched {
603 for vfs in state.vfs_list.clone().iter() {
604 if let Ok(stats) = state.backend.vfs_storage_stats(vfs.id) {
605 state.sync.vfs_storage_cache.insert(vfs.id.as_i64(), stats);
606 }
607 }
608 state.sync.vfs_storage_fetched = true;
609 }
610 let vfs_list = state.vfs_list.clone();
611 for vfs in vfs_list.iter() {
612 let mut sync_files = vfs.sync_files;
613 if ui.checkbox(&mut sync_files, &vfs.name).changed() {
614 if let Err(e) = state.backend.set_vfs_sync_files(vfs.id, sync_files) {
615 warn!("Failed to update sync_files for VFS {}: {e}", vfs.name);
616 } else {
617 state.refresh_vfs_list();
618 }
619 }
620 // Size hint under each checkbox. Makes the choice concrete: a user
621 // toggling "Library" on now sees "12.4 GB across 4,820 samples"
622 // instead of agreeing to upload an abstract amount.
623 if let Some((count, bytes)) = state.sync.vfs_storage_cache.get(&vfs.id.as_i64()) {
624 let count_str = if *count == 1 { "1 sample".to_string() } else { format!("{count} samples") };
625 ui.label(
626 egui::RichText::new(format!(" {} across {}", widgets::format_bytes(*bytes), count_str))
627 .small()
628 .color(theme::text_muted()),
629 );
630 }
631 }
632 }
633 }); // end Audio file cloud sync CollapsingHeader
634
635 ui.add_space(theme::space::LG);
636 ui.separator();
637
638 // Disconnect button — always confirmed. Detail line surfaces pending
639 // changes (if any) and the encryption-password requirement on reconnect.
640 if widgets::danger_button(ui, "Disconnect").clicked() {
641 state.pending_confirm = Some(ConfirmAction::DisconnectSync {
642 pending_changes: status.pending_changes,
643 });
644 }
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 }
699