//! SyncKit cloud sync commands. //! //! Provides Tauri commands for authenticating with the MNW sync service //! via OAuth2 PKCE flow, managing sync credentials, encryption setup, //! manual sync triggers, and sync settings. use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::{Emitter, State}; use tracing::instrument; use uuid::Uuid; use crate::oauth::callback_server::OAuthCallbackServer; use crate::oauth::credentials::CredentialStore; use crate::oauth::provider::{generate_code_challenge, generate_code_verifier, generate_state}; use crate::state::AppState; use crate::sync_service; use super::{ApiError, OptionApiError, ResultApiError}; // ============ Types ============ /// Response for sync_status command. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SyncStatusResponse { pub configured: bool, pub authenticated: bool, pub server_url: Option, pub encryption_ready: bool, pub has_server_key: Option, pub device_id: Option, pub auto_sync_enabled: bool, pub sync_interval_minutes: u32, pub last_sync_at: Option, pub pending_changes: i64, } /// Response for sync_start_auth command. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SyncAuthStartResponse { pub auth_url: String, pub state: String, pub port: u16, } /// Input for sync_complete_auth command. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SyncAuthCompleteInput { pub code: String, pub state: String, } /// Response for sync_complete_auth command. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SyncAuthCompleteResponse { pub user_id: Uuid, pub app_id: Uuid, } /// Input for sync_update_settings command. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SyncSettingsInput { pub auto_sync_enabled: Option, pub sync_interval_minutes: Option, } // ============ Helpers ============ /// Extract the sync client from state. Clones the Arc for use across await points. fn get_sync_client(state: &AppState) -> Option> { state.sync_client.read().unwrap_or_else(|e| e.into_inner()).clone() } fn require_sync_client(state: &AppState) -> Result, ApiError> { get_sync_client(state).ok_or_else(|| ApiError::bad_request("Sync is not configured")) } // ============ Commands ============ /// Fetch the pricing formula for this app (no auth required, uses API key). /// GoingsOn doesn't expose a cap slider — it presents a single suggested cap /// to the user — but the formula is what the server now returns instead of a /// tier list, so the command still hits the network on app load to populate /// the subscribe banner with a real number. #[tauri::command] #[instrument(skip_all)] pub async fn sync_get_tiers( state: State<'_, Arc>, ) -> Result { let client = require_sync_client(&state)?; client.get_app_pricing() .await .map_api_err("Failed to fetch pricing", ApiError::external_service) } /// Returns the current sync configuration, authentication, encryption, and sync state. #[tauri::command] #[instrument(skip_all)] pub async fn sync_status( state: State<'_, Arc>, ) -> Result { let (configured, authenticated, server_url, encryption_ready, has_server_key) = match get_sync_client(&state) { Some(client) => { let url = Some(client.config().server_url.clone()); let enc_ready = client.has_master_key(); // Use in-memory session as the source of truth for authenticated state. // The keychain is for persistence across restarts; within a session, // the client's session_info() reflects the latest auth state. let authed = client.session_info().is_some(); let server_key = if authed { client.has_server_key().await.ok() } else { None }; (true, authed, url, enc_ready, server_key) } None => (false, false, None, false, None), }; // Read sync state from DB — batch query + pending count in parallel let (states_result, pending_changes) = tokio::join!( sync_service::get_sync_states_batch( &state.pool, &["device_id", "auto_sync_enabled", "sync_interval_minutes", "last_sync_at"], ), sync_service::count_pending_changes(&state.pool), ); let states = states_result.unwrap_or_default(); let pending_changes = pending_changes.unwrap_or(0); let device_id = states.get("device_id").filter(|s| !s.is_empty()).cloned(); let auto_sync_enabled = states.get("auto_sync_enabled").map(|v| v == "1").unwrap_or(true); let sync_interval_minutes = states.get("sync_interval_minutes").and_then(|v| v.parse().ok()).unwrap_or(5); let last_sync_at = states.get("last_sync_at").filter(|s| !s.is_empty()).cloned(); Ok(SyncStatusResponse { configured, authenticated, server_url, encryption_ready, has_server_key, device_id, auto_sync_enabled, sync_interval_minutes, last_sync_at, pending_changes, }) } /// Starts the SyncKit OAuth2 PKCE flow. #[tauri::command] #[instrument(skip_all)] pub async fn sync_start_auth( state: State<'_, Arc>, ) -> Result { let client = require_sync_client(&state)?; let code_verifier = generate_code_verifier(); let code_challenge = generate_code_challenge(&code_verifier); let csrf_state = generate_state(); let callback_server = OAuthCallbackServer::start() .map_api_err("Failed to start callback server", ApiError::internal)?; let port = callback_server.port(); let auth_url = client.build_authorize_url(port, &csrf_state, &code_challenge); // Store PKCE verifier server-side (never sent to frontend) { let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner()); flows.insert(csrf_state.clone(), crate::state::PendingOAuthFlow { code_verifier, provider_id: "synckit".to_string(), port, }); } Ok(SyncAuthStartResponse { auth_url, state: csrf_state, port, }) } /// Completes the SyncKit OAuth2 flow by exchanging the authorization code for a JWT. #[tauri::command] #[instrument(skip_all)] pub async fn sync_complete_auth( state: State<'_, Arc>, input: SyncAuthCompleteInput, ) -> Result { let client = require_sync_client(&state)?; // Look up and consume the pending flow by state token (CSRF + PKCE validation) let flow = { let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner()); flows.remove(&input.state) }.ok_or_else(|| ApiError::bad_request("Invalid or expired OAuth state token"))?; let (user_id, app_id) = client .authenticate_with_code(&input.code, &flow.code_verifier, flow.port, "__internal__") .await .map_api_err("Token exchange failed", ApiError::external_service)?; let session_info = client .session_info() .or_api_err(|| ApiError::internal("Session not available after authentication"))?; CredentialStore::store_sync_token(&session_info.token, user_id, app_id) .map_api_err("Failed to store sync token", ApiError::internal)?; match client.try_load_key_from_keychain() { Ok(true) => tracing::info!("Sync encryption key loaded from keychain"), Ok(false) => tracing::debug!("No sync encryption key in keychain yet"), Err(e) => tracing::warn!("Failed to load sync encryption key: {}", e), } Ok(SyncAuthCompleteResponse { user_id, app_id }) } /// Disconnects from the sync service by clearing stored credentials. #[tauri::command] #[instrument(skip_all)] pub async fn sync_disconnect( _state: State<'_, Arc>, ) -> Result { CredentialStore::delete_sync_token() .map_api_err("Failed to delete sync token", ApiError::internal)?; Ok(true) } /// Manual sync trigger. Returns pushed/pulled counts. #[tauri::command] #[instrument(skip_all)] pub async fn sync_now( state: State<'_, Arc>, app: tauri::AppHandle, ) -> Result { let client = require_sync_client(&state)?; if client.session_info().is_none() { return Err(ApiError::bad_request("Not authenticated")); } if !client.has_master_key() { return Err(ApiError::bad_request("Encryption not set up")); } let _sync_guard = state.sync_lock.lock().await; // Create initial snapshot if needed (must be inside sync_lock to avoid TOCTOU race) let snapshot_done = sync_service::get_sync_state(&state.pool, "initial_snapshot_done") .await .unwrap_or_default(); if snapshot_done != "1" { sync_service::create_initial_snapshot(&state.pool) .await .map_api_err("Failed to create initial snapshot", ApiError::internal)?; } let _ = app.emit("sync:status-changed", "syncing"); let result = match sync_service::perform_sync_with_blobs(&state.pool, &client, Some(&state.data_dir)).await { Ok(r) => { let _ = app.emit("sync:status-changed", "idle"); r } Err(e) => { let _ = app.emit("sync:status-changed", "error"); return Err(ApiError::external_service(format!("Sync failed: {e}"))); } }; if result.pulled > 0 { let _ = app.emit("sync:changes-applied", ()); } // Cleanup after manual sync too let _ = sync_service::cleanup_changelog(&state.pool).await; Ok(result) } /// First device: generate a new master key, encrypt with password, push to server. #[tauri::command] #[instrument(skip_all)] pub async fn sync_setup_encryption_new( state: State<'_, Arc>, password: String, ) -> Result { let client = require_sync_client(&state)?; client .setup_encryption_new(&password) .await .map_api_err("Encryption setup failed", ApiError::external_service)?; Ok(true) } /// Additional device: decrypt master key from server using password. #[tauri::command] #[instrument(skip_all)] pub async fn sync_setup_encryption_existing( state: State<'_, Arc>, password: String, ) -> Result { let client = require_sync_client(&state)?; client .setup_encryption_existing(&password) .await .map_api_err("Encryption setup failed", ApiError::external_service)?; Ok(true) } /// Update sync settings (auto_sync_enabled, sync_interval_minutes). #[tauri::command] #[instrument(skip_all)] pub async fn sync_update_settings( state: State<'_, Arc>, input: SyncSettingsInput, ) -> Result { if let Some(enabled) = input.auto_sync_enabled { sync_service::set_sync_state( &state.pool, "auto_sync_enabled", if enabled { "1" } else { "0" }, ) .await .map_api_err("Failed to update setting", ApiError::internal)?; } if let Some(minutes) = input.sync_interval_minutes { sync_service::set_sync_state( &state.pool, "sync_interval_minutes", &minutes.to_string(), ) .await .map_api_err("Failed to update setting", ApiError::internal)?; } Ok(true) } // ============ Subscription Commands ============ /// Returns the authenticated user's email + username (for "logged in as ..." UI). #[tauri::command] #[instrument(skip_all)] pub async fn sync_account_info( state: State<'_, Arc>, ) -> Result { let client = require_sync_client(&state)?; if client.session_info().is_none() { return Err(ApiError::bad_request("Not authenticated")); } client .get_account_info() .await .map_api_err("Failed to fetch account info", ApiError::external_service) } /// Check subscription status for this user + app. #[tauri::command] #[instrument(skip_all)] pub async fn sync_subscription_status( state: State<'_, Arc>, ) -> Result { let client = require_sync_client(&state)?; if client.session_info().is_none() { return Err(ApiError::bad_request("Not authenticated")); } client .get_subscription_status() .await .map_api_err("Failed to check subscription", ApiError::external_service) } /// Create a Stripe Checkout session for subscribing to cloud sync. /// Opens the checkout URL in the user's default browser. #[tauri::command] #[instrument(skip_all)] pub async fn sync_subscribe( state: State<'_, Arc>, interval: String, ) -> Result { let client = require_sync_client(&state)?; if client.session_info().is_none() { return Err(ApiError::bad_request("Not authenticated")); } // GoingsOn syncs metadata only — no blob storage — so the cap is set to // the formula's minimum (10 GiB) which trips the $2/mo floor. The cap is // mostly cosmetic for non-blob apps but is required by the new API. let interval_enum = synckit_client::BillingInterval::from_str(&interval); const GO_DEFAULT_CAP_BYTES: i64 = 10 * 1024 * 1024 * 1024; let response = match client .create_subscription_checkout(GO_DEFAULT_CAP_BYTES, interval_enum) .await { Ok(r) => r, Err(e) => { tracing::error!(error = %e, debug = ?e, "Subscription checkout failed"); return Err(ApiError::external_service(format!("Failed to create checkout: {e}"))); } }; // Open in default browser if let Err(e) = open::that(&response.checkout_url) { tracing::warn!(error = %e, "Failed to open browser, returning URL"); } Ok(response.checkout_url) }