fix: harden WebDAV sync — async credentials, consolidated command, Onyx subfolder

This commit is contained in:
Tristan Michael 2026-04-03 10:11:46 -07:00 committed by GitButler
parent 9e57f1df3c
commit 58f37b08d6
3 changed files with 69 additions and 33 deletions

View file

@ -99,13 +99,6 @@ fn repo_mut(state: &mut AppState) -> Result<&mut TaskRepository, String> {
state.repo.as_mut().ok_or_else(|| "Repository not initialized".to_string()) state.repo.as_mut().ok_or_else(|| "Repository not initialized".to_string())
} }
// ── Debug ───────────────────────────────────────────────────────────
#[tauri::command]
fn log_debug(msg: String) {
eprintln!("[frontend] {msg}");
}
// ── Config commands ────────────────────────────────────────────────── // ── Config commands ──────────────────────────────────────────────────
#[tauri::command] #[tauri::command]
@ -473,19 +466,27 @@ fn add_webdav_workspace(
} }
#[tauri::command] #[tauri::command]
fn store_credentials( async fn store_credentials(
domain: String, domain: String,
username: String, username: String,
password: String, password: String,
) -> Result<(), String> { ) -> Result<(), String> {
tokio::task::spawn_blocking(move || {
webdav::store_credentials(&domain, &username, &password).map_err(|e| e.to_string()) webdav::store_credentials(&domain, &username, &password).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
} }
#[tauri::command] #[tauri::command]
fn load_credentials(domain: String) -> Result<(String, String), String> { async fn load_credentials(domain: String) -> Result<(String, String), String> {
tokio::task::spawn_blocking(move || {
webdav::load_credentials(&domain) webdav::load_credentials(&domain)
.map(|(u, p)| ((*u).clone(), (*p).clone())) .map(|(u, p)| ((*u).clone(), (*p).clone()))
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
} }
#[tauri::command] #[tauri::command]
@ -505,21 +506,39 @@ async fn test_webdav_connection(
#[tauri::command] #[tauri::command]
async fn sync_workspace( async fn sync_workspace(
workspace_name: String, workspace_name: String,
workspace_path: String,
webdav_url: String,
username: String,
password: String,
mode: String, mode: String,
state: State<'_, Mutex<AppState>>, state: State<'_, Mutex<AppState>>,
) -> Result<SyncResult, String> { ) -> Result<SyncResult, String> {
// Step 1: read config
let (workspace_path, webdav_url) = {
let s = lock_state(&state)?;
let ws = s.config.workspaces.get(&workspace_name)
.ok_or("Workspace not found")?;
(ws.path.clone(), ws.webdav_url.clone().ok_or("No WebDAV URL configured")?)
};
// Step 2: load credentials
let domain = webdav_url
.split("://")
.nth(1)
.and_then(|rest| rest.split('/').next())
.unwrap_or("")
.to_string();
let (username, password) = tokio::task::spawn_blocking(move || {
webdav::load_credentials(&domain)
.map(|(u, p)| ((*u).clone(), (*p).clone()))
.map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())??;
let sync_mode = match mode.as_str() { let sync_mode = match mode.as_str() {
"push" => SyncMode::Push, "push" => SyncMode::Push,
"pull" => SyncMode::Pull, "pull" => SyncMode::Pull,
_ => SyncMode::Full, _ => SyncMode::Full,
}; };
eprintln!("[sync] starting sync: workspace={workspace_name} path={workspace_path} url={webdav_url} mode={mode}");
let result = sync::sync_workspace( let result = sync::sync_workspace(
&PathBuf::from(&workspace_path), &workspace_path,
&webdav_url, &webdav_url,
&username, &username,
&password, &password,
@ -527,22 +546,14 @@ async fn sync_workspace(
None, None,
) )
.await .await
.map_err(|e| { .map_err(|e| e.to_string())?;
eprintln!("[sync] sync_workspace error: {e}");
e.to_string()
})?;
eprintln!("[sync] sync complete: uploaded={} downloaded={} errors={}", result.uploaded, result.downloaded, result.errors.len());
// Persist last_sync timestamp to config
{ {
eprintln!("[sync] acquiring state lock...");
let mut s = lock_state(&state)?; let mut s = lock_state(&state)?;
eprintln!("[sync] lock acquired, saving config...");
if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) { if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) {
ws.last_sync = Some(Utc::now()); ws.last_sync = Some(Utc::now());
} }
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?; s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?;
eprintln!("[sync] config saved");
} }
Ok(result.into()) Ok(result.into())
@ -632,7 +643,6 @@ pub fn run() {
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
log_debug,
get_config, get_config,
save_config, save_config,
add_workspace, add_workspace,

View file

@ -510,7 +510,28 @@ pub async fn sync_workspace(
mode: SyncMode, mode: SyncMode,
on_progress: Option<ProgressCallback>, on_progress: Option<ProgressCallback>,
) -> Result<SyncResult> { ) -> Result<SyncResult> {
let client = WebDavClient::new(webdav_url, username, password)?; // Wrap entire sync in a hard timeout — reqwest's built-in timeout
// doesn't reliably fire on Windows native TLS when the server is unreachable.
match tokio::time::timeout(
crate::webdav::REQUEST_TIMEOUT * 2,
sync_workspace_inner(workspace_path, webdav_url, username, password, mode, on_progress),
).await {
Ok(result) => result,
Err(_) => Err(Error::WebDav("Sync timed out — server may be unreachable".into())),
}
}
async fn sync_workspace_inner(
workspace_path: &Path,
webdav_url: &str,
username: &str,
password: &str,
mode: SyncMode,
on_progress: Option<ProgressCallback>,
) -> Result<SyncResult> {
// Sync into an "Onyx" subfolder so we don't scan the user's entire cloud storage
let sync_url = format!("{}/Onyx", webdav_url.trim_end_matches('/'));
let client = WebDavClient::new(&sync_url, username, password)?;
let mut sync_state = SyncState::load(workspace_path); let mut sync_state = SyncState::load(workspace_path);
let queue = OfflineQueue::load(workspace_path); let queue = OfflineQueue::load(workspace_path);
let mut result = SyncResult::default(); let mut result = SyncResult::default();
@ -521,7 +542,8 @@ pub async fn sync_workspace(
} }
}; };
// Ensure remote root exists // Ensure remote Onyx folder exists (creates it on first sync)
client.create_dir("").await.ok();
client.test_connection().await?; client.test_connection().await?;
// Scan local files // Scan local files

View file

@ -1,7 +1,11 @@
use reqwest::Client; use reqwest::Client;
use zeroize::Zeroizing; use zeroize::Zeroizing;
use std::time::Duration;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
/// Hard timeout for any WebDAV network operation.
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
/// Information about a file on the remote WebDAV server. /// Information about a file on the remote WebDAV server.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RemoteFileInfo { pub struct RemoteFileInfo {
@ -32,8 +36,8 @@ impl WebDavClient {
let base_url = base_url.trim_end_matches('/').to_string(); let base_url = base_url.trim_end_matches('/').to_string();
Self { Self {
_client: Client::builder() _client: Client::builder()
.timeout(std::time::Duration::from_secs(30)) .timeout(Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10)) .connect_timeout(Duration::from_secs(10))
.build() .build()
.unwrap_or_else(|_| Client::new()), .unwrap_or_else(|_| Client::new()),
_base_url: base_url, _base_url: base_url,