From 575118cebae2c2318c07f368d01b263bf57c1edd Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Fri, 3 Jul 2026 07:54:29 -0500 Subject: [PATCH] feat(sync): begin_oauth_link command; pump/test dispatch via build_sync_target (T9.9/9.10) --- src-tauri/src/commands.rs | 189 ++++++++++++++++++++++++++++++-------- 1 file changed, 153 insertions(+), 36 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index bc8937e..e67a976 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1917,10 +1917,121 @@ pub async fn remove_sync_target(state: State<'_, AppState>, id: String) -> WaRes .map_err(|e| WaError::new("sync", e.to_string())) } -/// Begin OAuth 2.0 PKCE linking for a secondary target (loopback redirect). FR-SYNC-9. +fn oauth_display_name(kind: &str) -> String { + match kind { + "onedrive" => "OneDrive", + "dropbox" => "Dropbox", + "box" => "Box", + other => other, + } + .to_string() +} + +/// Begin OAuth 2.0 Authorization Code + PKCE linking for a secondary target +/// (loopback redirect). Returns the `authUrl` for the frontend to open; the flow +/// finishes in the background and emits `sync://linked` (T9.9, FR-SYNC-9). #[tauri::command] -pub async fn begin_oauth_link(_kind: String) -> WaResult { - Err(not_implemented("begin_oauth_link")) +pub async fn begin_oauth_link( + app: AppHandle, + state: State<'_, AppState>, + kind: String, +) -> WaResult { + use crate::sync::oauth; + + let provider = oauth::provider_for(&kind) + .ok_or_else(|| WaError::new("sync", format!("unknown OAuth provider '{kind}'")))?; + // Requires an app registration; clear error until a client id is configured. + let client_id = provider + .client_id() + .map_err(|e| WaError::new("sync", e.to_string()))?; + + let pkce = oauth::pkce_pair(); + let csrf_state = uuid::Uuid::new_v4().to_string(); + let loopback = + oauth::LoopbackRedirect::bind().map_err(|e| WaError::new("sync", e.to_string()))?; + let redirect_uri = loopback.redirect_uri.clone(); + let auth_url = oauth::build_auth_url( + &provider, + &client_id, + &redirect_uri, + &pkce.challenge, + &csrf_state, + ) + .map_err(|e| WaError::new("sync", e.to_string()))?; + + // Finish the handshake off-thread: wait for the redirect, exchange the code, + // store tokens, and create the target. The frontend opens `authUrl` and + // listens for `sync://linked`. + let store = state.store.clone(); + let app_bg = app.clone(); + let verifier = pkce.verifier; + tauri::async_runtime::spawn(async move { + let fail = |msg: String| { + let _ = app_bg.emit( + "sync://linked", + serde_json::json!({ "ok": false, "kind": kind, "error": msg }), + ); + }; + + // Blocking single-shot accept of the browser redirect. + let code = + match tauri::async_runtime::spawn_blocking(move || loopback.wait_for_code(&csrf_state)) + .await + { + Ok(Ok(code)) => code, + _ => return fail("authorization was cancelled or failed".into()), + }; + + let tokens = match oauth::exchange_code( + &provider, + &client_id, + &code, + &verifier, + &redirect_uri, + ) + .await + { + Ok(t) => t, + Err(e) => return fail(e.to_string()), + }; + + let id = uuid::Uuid::new_v4().to_string(); + let credential_ref = format!("wa-sync-{id}"); + let Ok(json) = serde_json::to_string(&tokens) else { + return fail("could not serialize tokens".into()); + }; + if let Err(e) = crate::sync::credentials::set(&credential_ref, &json) { + return fail(e.to_string()); + } + let row = SyncTargetRow { + id, + name: oauth_display_name(&kind), + kind: kind.clone(), + provider_hint: None, + base_url: None, + remote_base_path: "/WhispAssist".to_string(), + username: None, + credential_ref, + enabled: true, + upload_transcript: true, + upload_notes: true, + upload_summary: true, + upload_recording: false, + trigger_on_finalize: true, + allow_plaintext_lan: false, + encrypt_before_upload: false, + created_at: now_unix(), + }; + if let Err(e) = store.add_sync_target(row).await { + return fail(e.to_string()); + } + let _ = app_bg.emit( + "sync://linked", + serde_json::json!({ "ok": true, "kind": kind.clone() }), + ); + }); + + Ok(serde_json::json!({ "authUrl": auth_url })) } #[tauri::command] @@ -1973,38 +2084,42 @@ pub async fn test_sync_target( use crate::sync::SyncTarget; let hints = provider_setup_hints(config.provider_hint.as_deref()); - let (target, temp_ref) = if let Some(id) = config.id.clone() { - let row = state - .store - .get_sync_target(&id) - .await - .map_err(|e| WaError::new("sync", e.to_string()))?; - (crate::sync::WebDavTarget::from_row(&row), None) - } else { - // Unsaved target: stash the secret under a temp credential_ref so the same - // resolve-at-use path works, then clean it up after the test. - let base_url = config - .base_url - .clone() - .ok_or_else(|| WaError::new("sync", "test requires a base_url"))?; - let temp_ref = format!("wa-sync-test-{}", uuid::Uuid::new_v4()); - if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) { - crate::sync::credentials::set(&temp_ref, secret) + let (target, temp_ref): (Box, Option) = + if let Some(id) = config.id.clone() { + // Existing target (any kind) — dispatch to its concrete impl. + let row = state + .store + .get_sync_target(&id) + .await .map_err(|e| WaError::new("sync", e.to_string()))?; - } - let target = crate::sync::WebDavTarget { - base_url, - remote_base_path: config - .remote_base_path + let target = crate::sync::build_sync_target(&row) + .map_err(|e| WaError::new("sync", e.to_string()))?; + (target, None) + } else { + // Unsaved WebDAV target: stash the secret under a temp credential_ref so + // the same resolve-at-use path works, then clean it up after the test. + let base_url = config + .base_url .clone() - .unwrap_or_else(|| "/WhispAssist".to_string()), - username: config.username.clone().unwrap_or_default(), - credential_ref: temp_ref.clone(), - third_party: config.kind.as_deref().unwrap_or("webdav") != "webdav", - allow_plaintext_lan: config.allow_plaintext_lan.unwrap_or(false), + .ok_or_else(|| WaError::new("sync", "test requires a base_url"))?; + let temp_ref = format!("wa-sync-test-{}", uuid::Uuid::new_v4()); + if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) { + crate::sync::credentials::set(&temp_ref, secret) + .map_err(|e| WaError::new("sync", e.to_string()))?; + } + let target = crate::sync::WebDavTarget { + base_url, + remote_base_path: config + .remote_base_path + .clone() + .unwrap_or_else(|| "/WhispAssist".to_string()), + username: config.username.clone().unwrap_or_default(), + credential_ref: temp_ref.clone(), + third_party: config.kind.as_deref().unwrap_or("webdav") != "webdav", + allow_plaintext_lan: config.allow_plaintext_lan.unwrap_or(false), + }; + (Box::new(target), Some(temp_ref)) }; - (target, Some(temp_ref)) - }; let result = target.test().await; if let Some(temp_ref) = temp_ref { @@ -2152,10 +2267,9 @@ pub(crate) async fn enqueue_meeting_sync( /// Upload one job's file: ensure the remote dir, PUT, report bytes sent. async fn upload_job( - target: &crate::sync::WebDavTarget, + target: &dyn crate::sync::SyncTarget, row: &crate::storage::SyncJobRow, ) -> Result { - use crate::sync::SyncTarget; let remote_dir = row .remote_path .rsplit_once('/') @@ -2196,8 +2310,11 @@ pub(crate) async fn pump_sync(app: &AppHandle, store: &dyn crate::storage::Store let _ = store.update_sync_job(job.clone()).await; emit_sync_job(app, &job); - let webdav = crate::sync::WebDavTarget::from_row(&target); - match upload_job(&webdav, &job).await { + let outcome = match crate::sync::build_sync_target(&target) { + Ok(t) => upload_job(t.as_ref(), &job).await, + Err(e) => Err(e), + }; + match outcome { Ok(sent) => { job.status = "done".to_string(); job.bytes_sent = sent as i64;