Skip to main content

max / goingson

14.0 KB · 428 lines History Blame Raw
1 //! SyncKit cloud sync commands.
2 //!
3 //! Provides Tauri commands for authenticating with the MNW sync service
4 //! via OAuth2 PKCE flow, managing sync credentials, encryption setup,
5 //! manual sync triggers, and sync settings.
6
7 use serde::{Deserialize, Serialize};
8 use std::sync::Arc;
9 use tauri::{Emitter, State};
10 use tracing::instrument;
11 use uuid::Uuid;
12
13 use crate::oauth::callback_server::OAuthCallbackServer;
14 use crate::oauth::credentials::CredentialStore;
15 use crate::oauth::provider::{generate_code_challenge, generate_code_verifier, generate_state};
16 use crate::state::AppState;
17 use crate::sync_service;
18 use super::{ApiError, OptionApiError, ResultApiError};
19
20 // ============ Types ============
21
22 /// Response for sync_status command.
23 #[derive(Debug, Serialize)]
24 #[serde(rename_all = "camelCase")]
25 pub struct SyncStatusResponse {
26 pub configured: bool,
27 pub authenticated: bool,
28 pub server_url: Option<String>,
29 pub encryption_ready: bool,
30 pub has_server_key: Option<bool>,
31 pub device_id: Option<String>,
32 pub auto_sync_enabled: bool,
33 pub sync_interval_minutes: u32,
34 pub last_sync_at: Option<String>,
35 pub pending_changes: i64,
36 }
37
38 /// Response for sync_start_auth command.
39 #[derive(Debug, Serialize)]
40 #[serde(rename_all = "camelCase")]
41 pub struct SyncAuthStartResponse {
42 pub auth_url: String,
43 pub state: String,
44 pub port: u16,
45 }
46
47 /// Input for sync_complete_auth command.
48 #[derive(Debug, Deserialize)]
49 #[serde(rename_all = "camelCase")]
50 pub struct SyncAuthCompleteInput {
51 pub code: String,
52 pub state: String,
53 }
54
55 /// Response for sync_complete_auth command.
56 #[derive(Debug, Serialize)]
57 #[serde(rename_all = "camelCase")]
58 pub struct SyncAuthCompleteResponse {
59 pub user_id: Uuid,
60 pub app_id: Uuid,
61 }
62
63 /// Input for sync_update_settings command.
64 #[derive(Debug, Deserialize)]
65 #[serde(rename_all = "camelCase")]
66 pub struct SyncSettingsInput {
67 pub auto_sync_enabled: Option<bool>,
68 pub sync_interval_minutes: Option<u32>,
69 }
70
71 // ============ Helpers ============
72
73 /// Extract the sync client from state. Clones the Arc for use across await points.
74 fn get_sync_client(state: &AppState) -> Option<std::sync::Arc<synckit_client::SyncKitClient>> {
75 state.sync_client.read().unwrap_or_else(|e| e.into_inner()).clone()
76 }
77
78 fn require_sync_client(state: &AppState) -> Result<std::sync::Arc<synckit_client::SyncKitClient>, ApiError> {
79 get_sync_client(state).ok_or_else(|| ApiError::bad_request("Sync is not configured"))
80 }
81
82 // ============ Commands ============
83
84 /// Fetch the pricing formula for this app (no auth required, uses API key).
85 /// GoingsOn doesn't expose a cap slider — it presents a single suggested cap
86 /// to the user — but the formula is what the server now returns instead of a
87 /// tier list, so the command still hits the network on app load to populate
88 /// the subscribe banner with a real number.
89 #[tauri::command]
90 #[instrument(skip_all)]
91 pub async fn sync_get_tiers(
92 state: State<'_, Arc<AppState>>,
93 ) -> Result<synckit_client::AppPricing, ApiError> {
94 let client = require_sync_client(&state)?;
95 client.get_app_pricing()
96 .await
97 .map_api_err("Failed to fetch pricing", ApiError::external_service)
98 }
99
100 /// Returns the current sync configuration, authentication, encryption, and sync state.
101 #[tauri::command]
102 #[instrument(skip_all)]
103 pub async fn sync_status(
104 state: State<'_, Arc<AppState>>,
105 ) -> Result<SyncStatusResponse, ApiError> {
106 let (configured, authenticated, server_url, encryption_ready, has_server_key) = match get_sync_client(&state) {
107 Some(client) => {
108 let url = Some(client.config().server_url.clone());
109 let enc_ready = client.has_master_key();
110 // Use in-memory session as the source of truth for authenticated state.
111 // The keychain is for persistence across restarts; within a session,
112 // the client's session_info() reflects the latest auth state.
113 let authed = client.session_info().is_some();
114
115 let server_key = if authed {
116 client.has_server_key().await.ok()
117 } else {
118 None
119 };
120
121 (true, authed, url, enc_ready, server_key)
122 }
123 None => (false, false, None, false, None),
124 };
125
126 // Read sync state from DB — batch query + pending count in parallel
127 let (states_result, pending_changes) = tokio::join!(
128 sync_service::get_sync_states_batch(
129 &state.pool,
130 &["device_id", "auto_sync_enabled", "sync_interval_minutes", "last_sync_at"],
131 ),
132 sync_service::count_pending_changes(&state.pool),
133 );
134
135 let states = states_result.unwrap_or_default();
136 let pending_changes = pending_changes.unwrap_or(0);
137
138 let device_id = states.get("device_id").filter(|s| !s.is_empty()).cloned();
139 let auto_sync_enabled = states.get("auto_sync_enabled").map(|v| v == "1").unwrap_or(true);
140 let sync_interval_minutes = states.get("sync_interval_minutes").and_then(|v| v.parse().ok()).unwrap_or(5);
141 let last_sync_at = states.get("last_sync_at").filter(|s| !s.is_empty()).cloned();
142
143 Ok(SyncStatusResponse {
144 configured,
145 authenticated,
146 server_url,
147 encryption_ready,
148 has_server_key,
149 device_id,
150 auto_sync_enabled,
151 sync_interval_minutes,
152 last_sync_at,
153 pending_changes,
154 })
155 }
156
157 /// Starts the SyncKit OAuth2 PKCE flow.
158 #[tauri::command]
159 #[instrument(skip_all)]
160 pub async fn sync_start_auth(
161 state: State<'_, Arc<AppState>>,
162 ) -> Result<SyncAuthStartResponse, ApiError> {
163 let client = require_sync_client(&state)?;
164
165 let code_verifier = generate_code_verifier();
166 let code_challenge = generate_code_challenge(&code_verifier);
167 let csrf_state = generate_state();
168
169 let callback_server = OAuthCallbackServer::start()
170 .map_api_err("Failed to start callback server", ApiError::internal)?;
171 let port = callback_server.port();
172
173 let auth_url = client.build_authorize_url(port, &csrf_state, &code_challenge);
174
175 // Store PKCE verifier server-side (never sent to frontend)
176 {
177 let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner());
178 flows.insert(csrf_state.clone(), crate::state::PendingOAuthFlow {
179 code_verifier,
180 provider_id: "synckit".to_string(),
181 port,
182 });
183 }
184
185 Ok(SyncAuthStartResponse {
186 auth_url,
187 state: csrf_state,
188 port,
189 })
190 }
191
192 /// Completes the SyncKit OAuth2 flow by exchanging the authorization code for a JWT.
193 #[tauri::command]
194 #[instrument(skip_all)]
195 pub async fn sync_complete_auth(
196 state: State<'_, Arc<AppState>>,
197 input: SyncAuthCompleteInput,
198 ) -> Result<SyncAuthCompleteResponse, ApiError> {
199 let client = require_sync_client(&state)?;
200
201 // Look up and consume the pending flow by state token (CSRF + PKCE validation)
202 let flow = {
203 let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner());
204 flows.remove(&input.state)
205 }.ok_or_else(|| ApiError::bad_request("Invalid or expired OAuth state token"))?;
206
207 let (user_id, app_id) = client
208 .authenticate_with_code(&input.code, &flow.code_verifier, flow.port, "__internal__")
209 .await
210 .map_api_err("Token exchange failed", ApiError::external_service)?;
211
212 let session_info = client
213 .session_info()
214 .or_api_err(|| ApiError::internal("Session not available after authentication"))?;
215
216 CredentialStore::store_sync_token(&session_info.token, user_id, app_id)
217 .map_api_err("Failed to store sync token", ApiError::internal)?;
218
219 match client.try_load_key_from_keychain() {
220 Ok(true) => tracing::info!("Sync encryption key loaded from keychain"),
221 Ok(false) => tracing::debug!("No sync encryption key in keychain yet"),
222 Err(e) => tracing::warn!("Failed to load sync encryption key: {}", e),
223 }
224
225 Ok(SyncAuthCompleteResponse { user_id, app_id })
226 }
227
228 /// Disconnects from the sync service by clearing stored credentials.
229 #[tauri::command]
230 #[instrument(skip_all)]
231 pub async fn sync_disconnect(
232 _state: State<'_, Arc<AppState>>,
233 ) -> Result<bool, ApiError> {
234 CredentialStore::delete_sync_token()
235 .map_api_err("Failed to delete sync token", ApiError::internal)?;
236 Ok(true)
237 }
238
239 /// Manual sync trigger. Returns pushed/pulled counts.
240 #[tauri::command]
241 #[instrument(skip_all)]
242 pub async fn sync_now(
243 state: State<'_, Arc<AppState>>,
244 app: tauri::AppHandle,
245 ) -> Result<sync_service::SyncResult, ApiError> {
246 let client = require_sync_client(&state)?;
247
248 if client.session_info().is_none() {
249 return Err(ApiError::bad_request("Not authenticated"));
250 }
251
252 if !client.has_master_key() {
253 return Err(ApiError::bad_request("Encryption not set up"));
254 }
255
256 let _sync_guard = state.sync_lock.lock().await;
257
258 // Create initial snapshot if needed (must be inside sync_lock to avoid TOCTOU race)
259 let snapshot_done = sync_service::get_sync_state(&state.pool, "initial_snapshot_done")
260 .await
261 .unwrap_or_default();
262 if snapshot_done != "1" {
263 sync_service::create_initial_snapshot(&state.pool)
264 .await
265 .map_api_err("Failed to create initial snapshot", ApiError::internal)?;
266 }
267 let _ = app.emit("sync:status-changed", "syncing");
268 let result = match sync_service::perform_sync_with_blobs(&state.pool, &client, Some(&state.data_dir)).await {
269 Ok(r) => {
270 let _ = app.emit("sync:status-changed", "idle");
271 r
272 }
273 Err(e) => {
274 let _ = app.emit("sync:status-changed", "error");
275 return Err(ApiError::external_service(format!("Sync failed: {e}")));
276 }
277 };
278
279 if result.pulled > 0 {
280 let _ = app.emit("sync:changes-applied", ());
281 }
282
283 // Cleanup after manual sync too
284 let _ = sync_service::cleanup_changelog(&state.pool).await;
285
286 Ok(result)
287 }
288
289 /// First device: generate a new master key, encrypt with password, push to server.
290 #[tauri::command]
291 #[instrument(skip_all)]
292 pub async fn sync_setup_encryption_new(
293 state: State<'_, Arc<AppState>>,
294 password: String,
295 ) -> Result<bool, ApiError> {
296 let client = require_sync_client(&state)?;
297
298 client
299 .setup_encryption_new(&password)
300 .await
301 .map_api_err("Encryption setup failed", ApiError::external_service)?;
302
303 Ok(true)
304 }
305
306 /// Additional device: decrypt master key from server using password.
307 #[tauri::command]
308 #[instrument(skip_all)]
309 pub async fn sync_setup_encryption_existing(
310 state: State<'_, Arc<AppState>>,
311 password: String,
312 ) -> Result<bool, ApiError> {
313 let client = require_sync_client(&state)?;
314
315 client
316 .setup_encryption_existing(&password)
317 .await
318 .map_api_err("Encryption setup failed", ApiError::external_service)?;
319
320 Ok(true)
321 }
322
323 /// Update sync settings (auto_sync_enabled, sync_interval_minutes).
324 #[tauri::command]
325 #[instrument(skip_all)]
326 pub async fn sync_update_settings(
327 state: State<'_, Arc<AppState>>,
328 input: SyncSettingsInput,
329 ) -> Result<bool, ApiError> {
330 if let Some(enabled) = input.auto_sync_enabled {
331 sync_service::set_sync_state(
332 &state.pool,
333 "auto_sync_enabled",
334 if enabled { "1" } else { "0" },
335 )
336 .await
337 .map_api_err("Failed to update setting", ApiError::internal)?;
338 }
339
340 if let Some(minutes) = input.sync_interval_minutes {
341 sync_service::set_sync_state(
342 &state.pool,
343 "sync_interval_minutes",
344 &minutes.to_string(),
345 )
346 .await
347 .map_api_err("Failed to update setting", ApiError::internal)?;
348 }
349
350 Ok(true)
351 }
352
353 // ============ Subscription Commands ============
354
355 /// Returns the authenticated user's email + username (for "logged in as ..." UI).
356 #[tauri::command]
357 #[instrument(skip_all)]
358 pub async fn sync_account_info(
359 state: State<'_, Arc<AppState>>,
360 ) -> Result<synckit_client::AccountInfo, ApiError> {
361 let client = require_sync_client(&state)?;
362
363 if client.session_info().is_none() {
364 return Err(ApiError::bad_request("Not authenticated"));
365 }
366
367 client
368 .get_account_info()
369 .await
370 .map_api_err("Failed to fetch account info", ApiError::external_service)
371 }
372
373 /// Check subscription status for this user + app.
374 #[tauri::command]
375 #[instrument(skip_all)]
376 pub async fn sync_subscription_status(
377 state: State<'_, Arc<AppState>>,
378 ) -> Result<synckit_client::SubscriptionStatus, ApiError> {
379 let client = require_sync_client(&state)?;
380
381 if client.session_info().is_none() {
382 return Err(ApiError::bad_request("Not authenticated"));
383 }
384
385 client
386 .get_subscription_status()
387 .await
388 .map_api_err("Failed to check subscription", ApiError::external_service)
389 }
390
391 /// Create a Stripe Checkout session for subscribing to cloud sync.
392 /// Opens the checkout URL in the user's default browser.
393 #[tauri::command]
394 #[instrument(skip_all)]
395 pub async fn sync_subscribe(
396 state: State<'_, Arc<AppState>>,
397 interval: String,
398 ) -> Result<String, ApiError> {
399 let client = require_sync_client(&state)?;
400
401 if client.session_info().is_none() {
402 return Err(ApiError::bad_request("Not authenticated"));
403 }
404
405 // GoingsOn syncs metadata only — no blob storage — so the cap is set to
406 // the formula's minimum (10 GiB) which trips the $2/mo floor. The cap is
407 // mostly cosmetic for non-blob apps but is required by the new API.
408 let interval_enum = synckit_client::BillingInterval::from_str(&interval);
409 const GO_DEFAULT_CAP_BYTES: i64 = 10 * 1024 * 1024 * 1024;
410 let response = match client
411 .create_subscription_checkout(GO_DEFAULT_CAP_BYTES, interval_enum)
412 .await
413 {
414 Ok(r) => r,
415 Err(e) => {
416 tracing::error!(error = %e, debug = ?e, "Subscription checkout failed");
417 return Err(ApiError::external_service(format!("Failed to create checkout: {e}")));
418 }
419 };
420
421 // Open in default browser
422 if let Err(e) = open::that(&response.checkout_url) {
423 tracing::warn!(error = %e, "Failed to open browser, returning URL");
424 }
425
426 Ok(response.checkout_url)
427 }
428