//! Sync settings panel: egui Window overlay with 4 states matching the SyncKit flow. use egui; use tracing::{error, warn}; use audiofiles_sync::{AppPricing, BillingInterval, SyncManager, SyncState, SyncStatus}; use crate::state::{BrowserState, ConfirmAction}; use crate::ui::theme; use crate::ui::widgets; const GIB: i64 = 1024 * 1024 * 1024; fn format_cents(cents: i64) -> String { let dollars = cents / 100; let pennies = cents % 100; if pennies == 0 { format!("${dollars}") } else { format!("${dollars}.{pennies:02}") } } fn format_cap(cap_bytes: i64) -> String { let gib = cap_bytes / GIB; if gib >= 1024 { format!("{:.1} TiB", gib as f64 / 1024.0) } else { format!("{} GiB", gib) } } /// Draw the sync settings panel as a floating window. pub fn draw_sync_panel( ctx: &egui::Context, state: &mut BrowserState, sync: &SyncManager, ) { // Consume any pending disconnect set by execute_confirmed_action last frame. // The confirm dispatcher in bulk_ops.rs runs without a SyncManager handle, // so the actual sync.disconnect() lands here. if state.sync.pending_disconnect { state.sync.pending_disconnect = false; sync.disconnect(); state.status = "Disconnected from cloud sync.".to_string(); } // Drop the cached auth URL once we've left the Authenticating state. It's // only meaningful while the Copy URL fallback is on screen, and lingering // would leak the PKCE state into a stale display if the user reopens the // panel later. if !matches!(sync.status().state, SyncState::Authenticating) && state.sync.auth_url.is_some() { state.sync.auth_url = None; } // Drop the per-VFS storage cache when the panel closes so reopening fetches // fresh numbers (the user may have imported/deleted between sessions). if !state.sync.show_panel { state.sync.vfs_storage_fetched = false; state.sync.vfs_storage_cache.clear(); } let mut open = state.sync.show_panel; widgets::modal_window_with_open( ctx, "Cloud Sync", Some(&mut open), false, Some(360.0), |ui| { let status = sync.status(); match &status.state { SyncState::Disconnected => { draw_disconnected(ui, state, sync); } SyncState::Authenticating => { draw_authenticating(ui, state, sync); } SyncState::NeedsEncryption { has_server_key } => { draw_needs_encryption(ui, state, sync, *has_server_key); } SyncState::Ready | SyncState::Syncing => { draw_ready(ui, state, sync, &status); } } // Error banner with Retry + Dismiss. Retry is only meaningful in // Ready/Syncing state (calls sync_now); in other states the user's // primary action is already on screen (Connect, Set Password), so // Retry hides itself and Dismiss is the only escape. if let Some(err) = status.last_error.clone() { ui.add_space(theme::space::SM); ui.separator(); ui.add_space(theme::space::SM); egui::Frame::new() .fill(theme::bg_tertiary()) .corner_radius(egui::CornerRadius::same(4)) .inner_margin(egui::Margin::same(8)) .show(ui, |ui| { ui.label(egui::RichText::new(err).color(theme::accent_red())); ui.add_space(theme::space::SM); ui.horizontal(|ui| { let retryable = matches!( status.state, SyncState::Ready | SyncState::Syncing, ); if retryable && widgets::secondary_button(ui, "Retry").clicked() { sync.clear_last_error(); sync.sync_now(); } if widgets::secondary_button(ui, "Dismiss").clicked() { sync.clear_last_error(); } }); }); } }, ); state.sync.show_panel = open; } /// Draw the subscription status/purchase section for blob sync. fn draw_subscription_section( ui: &mut egui::Ui, state: &mut BrowserState, sync: &SyncManager, ) { let sync_status = sync.status(); // Loading-flag timeout: if a fetch or checkout never resolves, the panel // would otherwise spin "Checking subscription..." (or grey out every // Subscribe button) forever. After 30s without a response, clear the flag // so the user can retry. The status message is the only feedback channel // for this surface — see C-3's wiring of the same pattern. const LOADING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); if let Some(at) = state.sync.subscription_loading_at && at.elapsed() >= LOADING_TIMEOUT && sync_status.subscription.is_none() { state.sync.subscription_loading = false; state.sync.subscription_loading_at = None; state.status = "Subscription check timed out. Click Retry to try again.".to_string(); } if let Some(at) = state.sync.checkout_loading_at && at.elapsed() >= LOADING_TIMEOUT { state.sync.checkout_loading = false; state.sync.checkout_loading_at = None; state.status = "Checkout timed out. The browser tab may have closed; try again.".to_string(); } // If the checkout / cap-change call reported an error, the sync manager surfaces // it in `last_error` (shown by the error banner above). Clear the checkout // loading flag immediately so the Subscribe / cap-change button re-enables for a // retry, rather than staying greyed out until the 30s timeout above. (Only the // checkout flag — subscription fetch failures don't set `last_error`, so a // general sync error must not interrupt the "Checking subscription..." spinner.) if sync_status.last_error.is_some() && state.sync.checkout_loading { state.sync.checkout_loading = false; state.sync.checkout_loading_at = None; } // Trigger initial fetch if not yet loaded if sync_status.subscription.is_none() && !state.sync.subscription_loading { state.sync.subscription_loading = true; state.sync.subscription_loading_at = Some(std::time::Instant::now()); sync.fetch_subscription_status(); } if state.sync.subscription_loading && sync_status.subscription.is_none() { ui.horizontal(|ui| { ui.label(egui::RichText::new("Checking subscription...").weak()); if ui.small_button("Retry").clicked() { state.sync.subscription_loading_at = Some(std::time::Instant::now()); sync.fetch_subscription_status(); } }); return; } // Once loaded, clear loading flags if sync_status.subscription.is_some() { state.sync.subscription_loading = false; state.sync.subscription_loading_at = None; state.sync.checkout_loading = false; state.sync.checkout_loading_at = None; } match &sync_status.subscription { Some(sub) if sub.active => { let limit = sub.storage_limit_bytes.unwrap_or(0); let used = sub.storage_used_bytes.unwrap_or(0); let interval = sub.interval.as_deref().unwrap_or("monthly"); ui.label(format!( "Subscribed: {} ({})", format_cap(limit), interval, )); if limit > 0 { let used_gb = used as f64 / GIB as f64; let limit_gb = limit as f64 / GIB as f64; let fraction = (used as f32) / (limit as f32); ui.add( egui::ProgressBar::new(fraction) .text(format!("{used_gb:.1} / {limit_gb:.0} GiB")), ); } if let Some(pending) = sub.pending_storage_limit_bytes { ui.add_space(theme::space::XS); ui.label( egui::RichText::new(format!( "Pending: cap changes to {} at next renewal.", format_cap(pending) )) .weak(), ); } // Cap-change slider for subscribed users. if let Some(pricing) = &sync_status.pricing { let pricing = pricing.clone(); let interval_enum = BillingInterval::from_str(interval); ui.add_space(theme::space::MD); ui.label(egui::RichText::new("Adjust cap (takes effect next cycle):").weak()); if let Some(cap) = draw_cap_picker(ui, state, &pricing, interval_enum, "Update cap") { sync.queue_cap_change(cap); } } } _ => { if let Some(pricing) = &sync_status.pricing { let pricing = pricing.clone(); ui.label("Pick a storage cap for audio file sync:"); ui.add_space(theme::space::XS); ui.label( egui::RichText::new( "Annual is 2 months free — fewer Stripe fees, so we pass the savings on.", ) .weak() .size(11.0), ); ui.add_space(theme::space::SM); // One cap slider, then annual/monthly checkout buttons for that // single chosen cap — two priced choices, not two sliders that // secretly share a value. let cap_bytes = draw_cap_slider(ui, state, &pricing); ui.add_space(theme::space::SM); if state.sync.checkout_loading { ui.horizontal(|ui| { ui.spinner(); ui.label(egui::RichText::new("Opening browser...").weak()); }); } else { let annual = format_cents(pricing.quote_cents(cap_bytes, BillingInterval::Annual)); let monthly = format_cents(pricing.quote_cents(cap_bytes, BillingInterval::Monthly)); ui.horizontal(|ui| { if widgets::primary_button(ui, &format!("Subscribe annual \u{2014} {annual}/yr")).clicked() { state.sync.checkout_loading = true; state.sync.checkout_loading_at = Some(std::time::Instant::now()); sync.subscribe(cap_bytes, BillingInterval::Annual); } if widgets::secondary_button(ui, &format!("Monthly \u{2014} {monthly}/mo")).clicked() { state.sync.checkout_loading = true; state.sync.checkout_loading_at = Some(std::time::Instant::now()); sync.subscribe(cap_bytes, BillingInterval::Monthly); } }); } } else { ui.label(egui::RichText::new("Loading pricing...").weak()); } } } } /// Draw a fallback panel when no SyncManager is available (no embedded API key in dev builds). pub fn draw_sync_not_configured(ctx: &egui::Context, state: &mut BrowserState) { let mut open = state.sync.show_panel; widgets::modal_window_with_open( ctx, "Cloud Sync", Some(&mut open), false, Some(380.0), |ui| { ui.label("Cloud sync is unavailable."); ui.add_space(theme::space::MD); ui.label( egui::RichText::new("Open a vault and ensure your license or trial is active to enable sync.") .small() .weak(), ); }, ); state.sync.show_panel = open; } /// Disconnected state: invite user to connect. fn draw_disconnected( ui: &mut egui::Ui, state: &mut BrowserState, sync: &SyncManager, ) { ui.label("Connect your audiofiles vault to Makenot.work for cross-device sync."); ui.add_space(theme::space::MD); ui.label( egui::RichText::new("Metadata (tags, vault structure, analysis) syncs automatically. Audio file sync is per-vault opt-in.") .small() .weak(), ); ui.add_space(theme::space::LG); if ui.button("Connect").clicked() { match sync.start_auth() { Ok(auth_url) => { #[cfg(target_os = "macos")] let _ = std::process::Command::new("open").arg(&auth_url).spawn(); #[cfg(target_os = "linux")] let _ = std::process::Command::new("xdg-open").arg(&auth_url).spawn(); #[cfg(target_os = "windows")] let _ = std::process::Command::new("cmd").args(["/c", "start", &auth_url]).spawn(); state.sync.auth_code_input.clear(); // Cache for the Authenticating screen's "Copy URL" fallback — // the browser may have failed to open silently. state.sync.auth_url = Some(auth_url); } Err(e) => { error!("Failed to start auth: {e}"); state.status = format!("Sync connect failed: {e}"); } } } } /// Authenticating state: waiting for OAuth callback, with Cancel + Copy URL /// escape hatches. Without these the user could be trapped here indefinitely /// when the browser doesn't open, OAuth fails server-side, or they change /// their mind — closing the window doesn't move the underlying state. fn draw_authenticating( ui: &mut egui::Ui, state: &mut BrowserState, sync: &SyncManager, ) { ui.horizontal(|ui| { ui.label("Waiting for authentication in your browser..."); ui.spinner(); }); ui.add_space(theme::space::MD); ui.label( egui::RichText::new("The app will update automatically once you sign in.") .small() .weak(), ); // Copy-URL fallback: the browser may have failed to open silently (wrong // default browser, headless system, popup blocker on a Tauri host). if let Some(ref url) = state.sync.auth_url.clone() { ui.add_space(theme::space::MD); ui.separator(); ui.add_space(theme::space::SM); ui.label( egui::RichText::new("Browser didn't open? Copy this URL into a browser manually.") .small() .weak(), ); ui.add_space(theme::space::XS); ui.horizontal(|ui| { // Read-only truncated URL display + Copy button. The URL itself is // long (OAuth + PKCE + state) so truncation is necessary. let mut shown = url.clone(); ui.add( egui::TextEdit::singleline(&mut shown) .desired_width(ui.available_width() - 70.0), ); if ui.button("Copy").clicked() { ui.ctx().copy_text(url.clone()); state.status = "Copied auth URL.".to_string(); } }); } ui.add_space(theme::space::LG); if ui.button("Cancel").clicked() { sync.cancel_auth(); state.sync.auth_url = None; state.status = "Sync connection cancelled.".to_string(); } } /// NeedsEncryption state: password setup. fn draw_needs_encryption( ui: &mut egui::Ui, state: &mut BrowserState, sync: &SyncManager, has_server_key: bool, ) { if has_server_key { ui.label("Enter your encryption password to unlock this device."); ui.add_space(theme::space::MD); ui.label( egui::RichText::new("This is the password you set when you first connected.") .small() .weak(), ); } else { ui.label("Set an encryption password to protect your synced data."); ui.add_space(theme::space::MD); ui.label( egui::RichText::new( "Your sample audio, filenames, tags, folder structure, \ and analysis are encrypted before leaving your device. \ The server sees row identifiers (opaque hashes) and \ change timestamps.", ) .small() .weak(), ); ui.add_space(theme::space::SM); // Warning banner: a typo in the next field permanently re-encrypts the // cloud blob under a key no one will ever re-derive. The confirm field // below is the only guard, so the copy needs to outweigh the form. widgets::warning_banner( ui, "Remember this password. It cannot be recovered, and any data already in your cloud blob will be unreadable if you forget it.", ); } ui.add_space(theme::space::LG); ui.horizontal(|ui| { ui.label("Password:"); ui.add( egui::TextEdit::singleline(&mut state.sync.encryption_input) .password(true) .desired_width(200.0), ); }); // First-time setup: confirm field + length gate. The unlock path doesn't // need confirmation — a typo there is recoverable (just re-enter). let (can_submit, hint): (bool, Option<&str>) = if has_server_key { (!state.sync.encryption_input.is_empty(), None) } else { ui.add_space(theme::space::SM); ui.horizontal(|ui| { ui.label("Confirm: "); ui.add( egui::TextEdit::singleline(&mut state.sync.encryption_confirm_input) .password(true) .desired_width(200.0), ); }); let pw = &state.sync.encryption_input; let confirm = &state.sync.encryption_confirm_input; if pw.is_empty() { (false, None) } else if pw.len() < 8 { (false, Some("Password must be at least 8 characters.")) } else if confirm.is_empty() { (false, None) } else if pw != confirm { (false, Some("Passwords don't match.")) } else { (true, None) } }; if let Some(msg) = hint { ui.add_space(theme::space::XS); ui.label( egui::RichText::new(msg) .small() .color(theme::text_muted()), ); } ui.add_space(theme::space::MD); let button_label = if has_server_key { "Unlock" } else { "Set Password" }; if ui .add_enabled(can_submit, egui::Button::new(button_label)) .clicked() { let password = state.sync.encryption_input.clone(); state.sync.encryption_input.clear(); state.sync.encryption_confirm_input.clear(); sync.setup_encryption(password, !has_server_key); } } /// Ready state: status display, controls, per-VFS sync toggles. fn draw_ready( ui: &mut egui::Ui, state: &mut BrowserState, sync: &SyncManager, status: &SyncStatus, ) { // Status info ui.horizontal(|ui| { let state_label = match status.state { SyncState::Syncing => "Syncing...", _ => "Connected", }; ui.label(state_label); if status.state == SyncState::Syncing { ui.spinner(); } }); if let Some(ref last) = status.last_sync_at { ui.label( egui::RichText::new(format!("Last sync: {last}")) .small() .weak(), ); } if status.pending_changes > 0 { ui.label(format!("{} pending changes", status.pending_changes)); } ui.add_space(theme::space::MD); // Sync Now button let syncing = status.state == SyncState::Syncing; if ui .add_enabled(!syncing, egui::Button::new("Sync Now")) .clicked() { sync.sync_now(); } ui.add_space(theme::space::MD); ui.separator(); // Auto-sync settings — collapsed by default so the Ready view reads as // status-first; the user expands when they want to tune cadence (p-6). egui::CollapsingHeader::new(egui::RichText::new("Auto-sync").strong()) .id_salt("sync_auto_section") .default_open(false) .show(ui, |ui| { let mut auto_sync = status.auto_sync_enabled; if ui.checkbox(&mut auto_sync, "Auto-sync").changed() { sync.update_settings(Some(auto_sync), None); } if auto_sync { ui.horizontal(|ui| { ui.label("Interval:"); let intervals = [5u32, 15, 30, 60]; let current = status.sync_interval_minutes; // If the persisted interval falls outside the canonical list (a // legacy config or manual DB edit), render an extra pill marked // active so the value is visible (m-15). Clicking a canonical pill // replaces it as usual. let custom = if !intervals.contains(¤t) { Some(current) } else { None }; if let Some(c) = custom { let label = format!("{c}m (custom)"); let _ = ui.selectable_label(true, label); } for mins in intervals { let label = if mins == 60 { "1h".to_string() } else { format!("{mins}m") }; if ui .selectable_label(current == mins, label) .clicked() { sync.update_settings(None, Some(mins)); } } }); } }); // end Auto-sync CollapsingHeader // Audio file cloud sync — also collapsed by default. Subscription state // and per-vault toggles read as a single configuration group rather than // three separator-delimited slices (p-6). egui::CollapsingHeader::new(egui::RichText::new("Audio file cloud sync").strong()) .id_salt("sync_audio_section") .default_open(false) .show(ui, |ui| { ui.label( egui::RichText::new("Metadata always syncs free. Audio file sync requires a subscription.") .small() .weak(), ); ui.add_space(theme::space::SM); draw_subscription_section(ui, state, sync); ui.add_space(theme::space::MD); // Per-VFS "Sync audio files" toggles (only show if subscribed) let subscribed = sync .status() .subscription .as_ref() .is_some_and(|s| s.active); if subscribed { // Populate the per-VFS storage cache on the first frame the section // renders. Queries are SQLite-cheap (single indexed SELECT each), but // we still want to avoid running them every frame at 60Hz. if !state.sync.vfs_storage_fetched { for vfs in state.vfs_list.clone().iter() { if let Ok(stats) = state.backend.vfs_storage_stats(vfs.id) { state.sync.vfs_storage_cache.insert(vfs.id.as_i64(), stats); } } state.sync.vfs_storage_fetched = true; } let vfs_list = state.vfs_list.clone(); for vfs in vfs_list.iter() { let mut sync_files = vfs.sync_files; if ui.checkbox(&mut sync_files, &vfs.name).changed() { if let Err(e) = state.backend.set_vfs_sync_files(vfs.id, sync_files) { warn!("Failed to update sync_files for VFS {}: {e}", vfs.name); } else { state.refresh_vfs_list(); } } // Size hint under each checkbox. Makes the choice concrete: a user // toggling "Library" on now sees "12.4 GB across 4,820 samples" // instead of agreeing to upload an abstract amount. if let Some((count, bytes)) = state.sync.vfs_storage_cache.get(&vfs.id.as_i64()) { let count_str = if *count == 1 { "1 sample".to_string() } else { format!("{count} samples") }; ui.label( egui::RichText::new(format!(" {} across {}", widgets::format_bytes(*bytes), count_str)) .small() .color(theme::text_muted()), ); } } } }); // end Audio file cloud sync CollapsingHeader ui.add_space(theme::space::LG); ui.separator(); // Disconnect button — always confirmed. Detail line surfaces pending // changes (if any) and the encryption-password requirement on reconnect. if widgets::danger_button(ui, "Disconnect").clicked() { state.pending_confirm = Some(ConfirmAction::DisconnectSync { pending_changes: status.pending_changes, }); } } /// Draw just the storage-cap slider (GiB, logarithmic) plus a cap-size label, /// clamping the working value to the pricing range. Returns the chosen cap in /// bytes. Used by the subscribe view (one slider feeding two checkout buttons). fn draw_cap_slider(ui: &mut egui::Ui, state: &mut BrowserState, pricing: &AppPricing) -> i64 { let min_gib = (pricing.min_cap_bytes / GIB).max(1); let max_gib = (pricing.max_cap_bytes / GIB).max(min_gib); state.sync.cap_picker_gib = state.sync.cap_picker_gib.clamp(min_gib, max_gib); ui.add( egui::Slider::new(&mut state.sync.cap_picker_gib, min_gib..=max_gib) .logarithmic(true) .text("GiB"), ); let cap_bytes = state.sync.cap_picker_gib * GIB; ui.label(egui::RichText::new(format_cap(cap_bytes)).strong()); cap_bytes } /// Cap-picker widget: slider in GiB + live price preview + action button. /// Used both for initial subscribe and for queuing a cap change on an active /// subscription. The slider's working value lives on `BrowserState::sync` so /// it survives frames; returns `Some(cap_bytes)` on the frame the button is /// clicked so the caller can fire the action (the helper avoids touching /// `state` further itself, sidestepping borrow conflicts with action closures). fn draw_cap_picker( ui: &mut egui::Ui, state: &mut BrowserState, pricing: &AppPricing, interval: BillingInterval, button_label: &str, ) -> Option { let min_gib = (pricing.min_cap_bytes / GIB).max(1); let max_gib = (pricing.max_cap_bytes / GIB).max(min_gib); if state.sync.cap_picker_gib < min_gib { state.sync.cap_picker_gib = min_gib; } if state.sync.cap_picker_gib > max_gib { state.sync.cap_picker_gib = max_gib; } ui.add( egui::Slider::new(&mut state.sync.cap_picker_gib, min_gib..=max_gib) .logarithmic(true) .text("GiB"), ); let cap_bytes = state.sync.cap_picker_gib * GIB; let price_cents = pricing.quote_cents(cap_bytes, interval); let interval_word = match interval { BillingInterval::Monthly => "month", BillingInterval::Annual => "year", }; ui.label(format!( "{} → {}/{}", format_cap(cap_bytes), format_cents(price_cents), interval_word, )); let loading = state.sync.checkout_loading; if ui .add_enabled_ui(!loading, |ui| widgets::primary_button(ui, button_label)) .inner .clicked() { Some(cap_bytes) } else { None } }