feat(sync): begin_oauth_link command; pump/test dispatch via build_sync_target (T9.9/9.10)
This commit is contained in:
+153
-36
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user