feat(sync): begin_oauth_link command; pump/test dispatch via build_sync_target (T9.9/9.10)

This commit is contained in:
iamdoubz
2026-07-03 07:54:29 -05:00
parent 27541dd171
commit 575118ceba
+153 -36
View File
@@ -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<serde_json::Value> {
Err(not_implemented("begin_oauth_link"))
pub async fn begin_oauth_link(
app: AppHandle,
state: State<'_, AppState>,
kind: String,
) -> WaResult<serde_json::Value> {
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<dyn SyncTarget>, Option<String>) =
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<u64, crate::sync::SyncError> {
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;