feat: improve upload resilience and release 0.1.23
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "desktop-client",
|
||||
"private": true,
|
||||
"version": "0.1.22",
|
||||
"version": "0.1.23",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
2
desktop-client/src-tauri/Cargo.lock
generated
2
desktop-client/src-tauri/Cargo.lock
generated
@@ -693,7 +693,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "desktop-client"
|
||||
version = "0.1.22"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "desktop-client"
|
||||
version = "0.1.22"
|
||||
version = "0.1.23"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -11,11 +11,14 @@ use std::io::{Read, Seek, SeekFrom};
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tauri::Emitter;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
const RESUMABLE_CHUNK_MAX_RETRIES: u32 = 3;
|
||||
const RESUMABLE_CHUNK_RETRY_BASE_DELAY_MS: u64 = 900;
|
||||
|
||||
struct ApiState {
|
||||
client: reqwest::Client,
|
||||
@@ -165,6 +168,36 @@ fn build_desktop_client_meta() -> (String, String, String) {
|
||||
(platform, device_name, device_id)
|
||||
}
|
||||
|
||||
fn build_upload_file_fingerprint(meta: &fs::Metadata) -> Option<String> {
|
||||
let size = meta.len();
|
||||
let modified_ms = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|ts| ts.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
let fingerprint = format!("v1:size:{}:mtime:{}", size, modified_ms);
|
||||
if fingerprint.len() > 120 {
|
||||
None
|
||||
} else {
|
||||
Some(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_retryable_upload_status(status: u16) -> bool {
|
||||
matches!(status, 408 | 425 | 429 | 500 | 502 | 503 | 504)
|
||||
}
|
||||
|
||||
fn is_retryable_transport_error(err: &reqwest::Error) -> bool {
|
||||
err.is_timeout() || err.is_connect() || err.is_request() || err.is_body()
|
||||
}
|
||||
|
||||
fn build_chunk_retry_delay(attempt: u32) -> Duration {
|
||||
let multiplier = 2_u64.saturating_pow(attempt.min(5));
|
||||
let ms = RESUMABLE_CHUNK_RETRY_BASE_DELAY_MS.saturating_mul(multiplier).min(15_000);
|
||||
Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
fn fallback_json(status: StatusCode, text: &str) -> Value {
|
||||
let mut data = Map::new();
|
||||
data.insert("success".to_string(), Value::Bool(status.is_success()));
|
||||
@@ -624,6 +657,21 @@ async fn api_logout(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn api_refresh_token(
|
||||
state: tauri::State<'_, ApiState>,
|
||||
base_url: String,
|
||||
) -> Result<BridgeResponse, String> {
|
||||
request_json(
|
||||
&state.client,
|
||||
Method::POST,
|
||||
join_api_url(&base_url, "/api/refresh-token"),
|
||||
Some(Value::Object(Map::new())),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn api_search_files(
|
||||
state: tauri::State<'_, ApiState>,
|
||||
@@ -1325,6 +1373,7 @@ async fn api_upload_file_resumable(
|
||||
if file_size == 0 {
|
||||
return Err("空文件不支持分片上传".to_string());
|
||||
}
|
||||
let file_fingerprint = build_upload_file_fingerprint(&metadata);
|
||||
|
||||
let file_name = source_path
|
||||
.file_name()
|
||||
@@ -1350,6 +1399,9 @@ async fn api_upload_file_resumable(
|
||||
"chunk_size".to_string(),
|
||||
Value::Number(serde_json::Number::from(effective_chunk)),
|
||||
);
|
||||
if let Some(hash) = file_fingerprint.clone() {
|
||||
init_body.insert("file_hash".to_string(), Value::String(hash));
|
||||
}
|
||||
|
||||
let init_resp = request_json(
|
||||
&state.client,
|
||||
@@ -1426,32 +1478,67 @@ async fn api_upload_file_resumable(
|
||||
.map_err(|err| format!("读取分片失败: {}", err))?;
|
||||
|
||||
let chunk_part_name = format!("{}.part{}", file_name, chunk_index);
|
||||
let multipart = reqwest::multipart::Form::new()
|
||||
.text("session_id", session_id.clone())
|
||||
.text("chunk_index", chunk_index.to_string())
|
||||
.part(
|
||||
"chunk",
|
||||
reqwest::multipart::Part::bytes(buf).file_name(chunk_part_name),
|
||||
);
|
||||
let mut chunk_done = false;
|
||||
for attempt in 0..=RESUMABLE_CHUNK_MAX_RETRIES {
|
||||
let multipart = reqwest::multipart::Form::new()
|
||||
.text("session_id", session_id.clone())
|
||||
.text("chunk_index", chunk_index.to_string())
|
||||
.part(
|
||||
"chunk",
|
||||
reqwest::multipart::Part::bytes(buf.clone()).file_name(chunk_part_name.clone()),
|
||||
);
|
||||
|
||||
let mut request = state
|
||||
.client
|
||||
.post(join_api_url(&base_url, "/api/upload/resumable/chunk"))
|
||||
.header("Accept", "application/json")
|
||||
.timeout(Duration::from_secs(60 * 10))
|
||||
.multipart(multipart);
|
||||
if let Some(token) = csrf_token.clone() {
|
||||
request = request.header("X-CSRF-Token", token);
|
||||
}
|
||||
let mut request = state
|
||||
.client
|
||||
.post(join_api_url(&base_url, "/api/upload/resumable/chunk"))
|
||||
.header("Accept", "application/json")
|
||||
.timeout(Duration::from_secs(60 * 10))
|
||||
.multipart(multipart);
|
||||
if let Some(token) = csrf_token.clone() {
|
||||
request = request.header("X-CSRF-Token", token);
|
||||
}
|
||||
|
||||
let chunk_resp = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("上传分片失败: {}", err))?;
|
||||
let chunk_bridge = parse_response_as_bridge(chunk_resp).await?;
|
||||
if !chunk_bridge.ok || !chunk_bridge.data.get("success").and_then(Value::as_bool).unwrap_or(false) {
|
||||
let chunk_bridge = match request.send().await {
|
||||
Ok(chunk_resp) => parse_response_as_bridge(chunk_resp).await?,
|
||||
Err(err) => {
|
||||
if attempt < RESUMABLE_CHUNK_MAX_RETRIES && is_retryable_transport_error(&err) {
|
||||
thread::sleep(build_chunk_retry_delay(attempt));
|
||||
continue;
|
||||
}
|
||||
return Err(format!("上传分片失败: {}", err));
|
||||
}
|
||||
};
|
||||
|
||||
let chunk_success = chunk_bridge.ok
|
||||
&& chunk_bridge
|
||||
.data
|
||||
.get("success")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
if chunk_success {
|
||||
chunk_done = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let message = chunk_bridge
|
||||
.data
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let retryable_status = is_retryable_upload_status(chunk_bridge.status);
|
||||
let retryable_message = message.contains("超时")
|
||||
|| message.to_lowercase().contains("timeout")
|
||||
|| message.contains("稍后重试");
|
||||
if attempt < RESUMABLE_CHUNK_MAX_RETRIES && (retryable_status || retryable_message) {
|
||||
thread::sleep(build_chunk_retry_delay(attempt));
|
||||
continue;
|
||||
}
|
||||
return Ok(chunk_bridge);
|
||||
}
|
||||
if !chunk_done {
|
||||
return Err("上传分片失败,请重试".to_string());
|
||||
}
|
||||
|
||||
uploaded_bytes = uploaded_bytes.saturating_add(read_size as u64).min(file_size);
|
||||
if let Some(ref id) = task_id {
|
||||
@@ -1464,14 +1551,21 @@ async fn api_upload_file_resumable(
|
||||
|
||||
let mut complete_body = Map::new();
|
||||
complete_body.insert("session_id".to_string(), Value::String(session_id));
|
||||
let complete_resp = request_json(
|
||||
&state.client,
|
||||
Method::POST,
|
||||
join_api_url(&base_url, "/api/upload/resumable/complete"),
|
||||
Some(Value::Object(complete_body)),
|
||||
csrf_token,
|
||||
)
|
||||
.await?;
|
||||
let mut complete_request = state
|
||||
.client
|
||||
.post(join_api_url(&base_url, "/api/upload/resumable/complete"))
|
||||
.header("Accept", "application/json")
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(Duration::from_secs(60 * 20))
|
||||
.json(&Value::Object(complete_body));
|
||||
if let Some(token) = csrf_token {
|
||||
complete_request = complete_request.header("X-CSRF-Token", token);
|
||||
}
|
||||
let complete_raw = complete_request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("完成分片上传失败: {}", err))?;
|
||||
let complete_resp = parse_response_as_bridge(complete_raw).await?;
|
||||
|
||||
if complete_resp.ok && complete_resp.data.get("success").and_then(Value::as_bool).unwrap_or(false) {
|
||||
if let Some(ref id) = task_id {
|
||||
@@ -1503,9 +1597,9 @@ async fn api_upload_file(
|
||||
if !source_path.is_file() {
|
||||
return Err("仅支持上传文件,不支持文件夹".to_string());
|
||||
}
|
||||
let file_size = fs::metadata(&source_path)
|
||||
.map(|meta| meta.len())
|
||||
.unwrap_or(0);
|
||||
let file_meta = fs::metadata(&source_path).map_err(|err| format!("读取文件信息失败: {}", err))?;
|
||||
let file_size = file_meta.len();
|
||||
let file_fingerprint = build_upload_file_fingerprint(&file_meta);
|
||||
|
||||
let file_name = source_path
|
||||
.file_name()
|
||||
@@ -1534,9 +1628,12 @@ async fn api_upload_file(
|
||||
.map_err(|err| format!("读取文件失败: {}", err))?
|
||||
.file_name(file_name);
|
||||
|
||||
let multipart = reqwest::multipart::Form::new()
|
||||
let mut multipart = reqwest::multipart::Form::new()
|
||||
.text("path", normalized_target)
|
||||
.part("file", file_part);
|
||||
if let Some(hash) = file_fingerprint {
|
||||
multipart = multipart.text("file_hash", hash);
|
||||
}
|
||||
|
||||
let mut request = state
|
||||
.client
|
||||
@@ -1581,7 +1678,7 @@ async fn api_upload_file(
|
||||
pub fn run() {
|
||||
let client = reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.timeout(Duration::from_secs(90))
|
||||
.build()
|
||||
.expect("failed to build reqwest client");
|
||||
|
||||
@@ -1599,6 +1696,7 @@ pub fn run() {
|
||||
api_kick_online_device,
|
||||
api_list_files,
|
||||
api_logout,
|
||||
api_refresh_token,
|
||||
api_search_files,
|
||||
api_mkdir,
|
||||
api_rename_file,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "玩玩云",
|
||||
"version": "0.1.22",
|
||||
"version": "0.1.23",
|
||||
"identifier": "cn.workyai.wanwancloud.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -153,7 +153,7 @@ const syncState = reactive({
|
||||
nextRunAt: "",
|
||||
});
|
||||
const updateState = reactive({
|
||||
currentVersion: "0.1.22",
|
||||
currentVersion: "0.1.23",
|
||||
latestVersion: "",
|
||||
available: false,
|
||||
mandatory: false,
|
||||
@@ -241,6 +241,7 @@ let unlistenNativeDownloadProgress: UnlistenFn | null = null;
|
||||
let unlistenNativeUploadProgress: UnlistenFn | null = null;
|
||||
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let hasCheckedUpdateAfterAuth = false;
|
||||
let authRefreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
const toast = reactive({
|
||||
visible: false,
|
||||
@@ -806,12 +807,40 @@ function toggleFileSortOrder() {
|
||||
fileViewState.sortOrder = fileViewState.sortOrder === "asc" ? "desc" : "asc";
|
||||
}
|
||||
|
||||
async function invokeBridge(command: string, payload: Record<string, any>) {
|
||||
function isAuthBridgeCommand(command: string) {
|
||||
return command === "api_login" || command === "api_refresh_token";
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
if (authRefreshPromise) {
|
||||
return authRefreshPromise;
|
||||
}
|
||||
authRefreshPromise = (async () => {
|
||||
try {
|
||||
const response = await invoke<BridgeResponse>("api_refresh_token", {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
});
|
||||
return Boolean(response.ok && response.data?.success);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
authRefreshPromise = null;
|
||||
}
|
||||
})();
|
||||
return authRefreshPromise;
|
||||
}
|
||||
|
||||
async function invokeBridge(
|
||||
command: string,
|
||||
payload: Record<string, any>,
|
||||
allowAuthRetry = true,
|
||||
) {
|
||||
let response: BridgeResponse;
|
||||
try {
|
||||
return await invoke<BridgeResponse>(command, payload);
|
||||
response = await invoke<BridgeResponse>(command, payload);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
response = {
|
||||
ok: false,
|
||||
status: 0,
|
||||
data: {
|
||||
@@ -820,6 +849,20 @@ async function invokeBridge(command: string, payload: Record<string, any>) {
|
||||
},
|
||||
} satisfies BridgeResponse;
|
||||
}
|
||||
|
||||
if (
|
||||
response.status === 401
|
||||
&& allowAuthRetry
|
||||
&& !isAuthBridgeCommand(command)
|
||||
&& authenticated.value
|
||||
) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) {
|
||||
return invokeBridge(command, payload, false);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function initClientVersion() {
|
||||
@@ -1106,34 +1149,78 @@ async function chooseSyncDirectory() {
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFileWithResume(filePath: string, targetPath: string, taskId?: string) {
|
||||
const resumableResponse = await invokeBridge("api_upload_file_resumable", {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
filePath,
|
||||
targetPath,
|
||||
chunkSize: 4 * 1024 * 1024,
|
||||
taskId: taskId || null,
|
||||
});
|
||||
|
||||
if (resumableResponse.ok && resumableResponse.data?.success) {
|
||||
return resumableResponse;
|
||||
function isRetryableUploadResponse(response: BridgeResponse) {
|
||||
const status = Number(response.status || 0);
|
||||
if ([0, 408, 425, 429, 500, 502, 503, 504].includes(status)) {
|
||||
return true;
|
||||
}
|
||||
const message = String(response.data?.message || "").toLowerCase();
|
||||
return (
|
||||
message.includes("timeout")
|
||||
|| message.includes("timed out")
|
||||
|| message.includes("network")
|
||||
|| message.includes("connection")
|
||||
|| message.includes("超时")
|
||||
|| message.includes("稍后重试")
|
||||
);
|
||||
}
|
||||
|
||||
const message = String(resumableResponse.data?.message || "");
|
||||
if (
|
||||
message.includes("当前存储模式不支持分片上传")
|
||||
|| message.includes("分片上传会话")
|
||||
|| message.includes("上传会话")
|
||||
) {
|
||||
return await invokeBridge("api_upload_file", {
|
||||
async function waitMs(ms: number) {
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
||||
}
|
||||
|
||||
async function uploadFileWithResume(filePath: string, targetPath: string, taskId?: string) {
|
||||
const maxAttempts = 3;
|
||||
let lastResponse: BridgeResponse | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
const resumableResponse = await invokeBridge("api_upload_file_resumable", {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
filePath,
|
||||
targetPath,
|
||||
chunkSize: 4 * 1024 * 1024,
|
||||
taskId: taskId || null,
|
||||
});
|
||||
|
||||
if (resumableResponse.ok && resumableResponse.data?.success) {
|
||||
return resumableResponse;
|
||||
}
|
||||
|
||||
const message = String(resumableResponse.data?.message || "");
|
||||
if (
|
||||
message.includes("当前存储模式不支持分片上传")
|
||||
|| message.includes("分片上传会话")
|
||||
|| message.includes("上传会话")
|
||||
) {
|
||||
const fallbackResponse = await invokeBridge("api_upload_file", {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
filePath,
|
||||
targetPath,
|
||||
taskId: taskId || null,
|
||||
});
|
||||
if (fallbackResponse.ok && fallbackResponse.data?.success) {
|
||||
return fallbackResponse;
|
||||
}
|
||||
lastResponse = fallbackResponse;
|
||||
} else {
|
||||
lastResponse = resumableResponse;
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts - 1 && lastResponse && isRetryableUploadResponse(lastResponse)) {
|
||||
await waitMs(600 * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return resumableResponse;
|
||||
return lastResponse || {
|
||||
ok: false,
|
||||
status: 0,
|
||||
data: {
|
||||
success: false,
|
||||
message: "上传失败",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rebuildSyncScheduler() {
|
||||
|
||||
Reference in New Issue
Block a user