feat(desktop): align requested 4/5/6/8 with resume queue batch and one-click update
This commit is contained in:
@@ -5,6 +5,7 @@ use serde_json::{Map, Value};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
@@ -108,6 +109,35 @@ fn alloc_download_path(download_dir: &Path, preferred_name: &str) -> PathBuf {
|
||||
first
|
||||
}
|
||||
|
||||
fn build_download_resume_temp_path(download_dir: &Path, preferred_name: &str, url: &str) -> PathBuf {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
use std::hash::{Hash, Hasher};
|
||||
preferred_name.hash(&mut hasher);
|
||||
url.hash(&mut hasher);
|
||||
let digest = format!("{:016x}", hasher.finish());
|
||||
let safe_name = sanitize_file_name(preferred_name);
|
||||
let temp_name = format!(".{}.{}.part", safe_name, digest);
|
||||
download_dir.join(temp_name)
|
||||
}
|
||||
|
||||
async fn parse_response_as_bridge(response: reqwest::Response) -> Result<BridgeResponse, String> {
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("读取响应失败: {}", err))?;
|
||||
let data = match serde_json::from_str::<Value>(&text) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => fallback_json(status, &text),
|
||||
};
|
||||
|
||||
Ok(BridgeResponse {
|
||||
ok: status.is_success(),
|
||||
status: status.as_u16(),
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
async fn request_json(
|
||||
client: &reqwest::Client,
|
||||
method: Method,
|
||||
@@ -528,22 +558,6 @@ async fn api_native_download(
|
||||
return Err("下载地址不能为空".to_string());
|
||||
}
|
||||
|
||||
let response = state
|
||||
.client
|
||||
.get(&trimmed_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("下载请求失败: {}", err))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Ok(BridgeResponse {
|
||||
ok: false,
|
||||
status: status.as_u16(),
|
||||
data: fallback_json(status, "下载失败"),
|
||||
});
|
||||
}
|
||||
|
||||
let preferred_name = file_name
|
||||
.as_deref()
|
||||
.map(|name| name.trim())
|
||||
@@ -556,11 +570,75 @@ async fn api_native_download(
|
||||
.map_err(|err| format!("创建下载目录失败: {}", err))?;
|
||||
}
|
||||
|
||||
let save_path = alloc_download_path(&download_dir, preferred_name);
|
||||
let mut target_file =
|
||||
fs::File::create(&save_path).map_err(|err| format!("创建文件失败: {}", err))?;
|
||||
let resume_temp_path = build_download_resume_temp_path(&download_dir, preferred_name, &trimmed_url);
|
||||
let existing_size = if resume_temp_path.exists() {
|
||||
fs::metadata(&resume_temp_path)
|
||||
.ok()
|
||||
.map(|meta| meta.len())
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let mut downloaded_bytes: u64 = 0;
|
||||
let mut request = state.client.get(&trimmed_url);
|
||||
if existing_size > 0 {
|
||||
request = request.header("Range", format!("bytes={}-", existing_size));
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("下载请求失败: {}", err))?;
|
||||
let status = response.status();
|
||||
|
||||
if status == reqwest::StatusCode::RANGE_NOT_SATISFIABLE && existing_size > 0 {
|
||||
let save_path = alloc_download_path(&download_dir, preferred_name);
|
||||
fs::rename(&resume_temp_path, &save_path)
|
||||
.map_err(|err| format!("完成断点下载失败: {}", err))?;
|
||||
let mut data = Map::new();
|
||||
data.insert("success".to_string(), Value::Bool(true));
|
||||
data.insert(
|
||||
"savePath".to_string(),
|
||||
Value::String(save_path.to_string_lossy().to_string()),
|
||||
);
|
||||
data.insert(
|
||||
"downloadedBytes".to_string(),
|
||||
Value::Number(serde_json::Number::from(existing_size)),
|
||||
);
|
||||
data.insert(
|
||||
"resumedBytes".to_string(),
|
||||
Value::Number(serde_json::Number::from(existing_size)),
|
||||
);
|
||||
return Ok(BridgeResponse {
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: Value::Object(data),
|
||||
});
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
return Ok(BridgeResponse {
|
||||
ok: false,
|
||||
status: status.as_u16(),
|
||||
data: fallback_json(status, "下载失败"),
|
||||
});
|
||||
}
|
||||
|
||||
let append_mode = existing_size > 0 && status == reqwest::StatusCode::PARTIAL_CONTENT;
|
||||
if !append_mode && resume_temp_path.exists() {
|
||||
fs::remove_file(&resume_temp_path)
|
||||
.map_err(|err| format!("重置断点下载文件失败: {}", err))?;
|
||||
}
|
||||
|
||||
let mut target_file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.append(append_mode)
|
||||
.truncate(!append_mode)
|
||||
.open(&resume_temp_path)
|
||||
.map_err(|err| format!("创建文件失败: {}", err))?;
|
||||
|
||||
let mut downloaded_bytes: u64 = if append_mode { existing_size } else { 0 };
|
||||
let mut stream = response;
|
||||
while let Some(chunk) = stream
|
||||
.chunk()
|
||||
@@ -577,6 +655,10 @@ async fn api_native_download(
|
||||
.flush()
|
||||
.map_err(|err| format!("刷新文件失败: {}", err))?;
|
||||
|
||||
let save_path = alloc_download_path(&download_dir, preferred_name);
|
||||
fs::rename(&resume_temp_path, &save_path)
|
||||
.map_err(|err| format!("保存下载文件失败: {}", err))?;
|
||||
|
||||
let mut data = Map::new();
|
||||
data.insert("success".to_string(), Value::Bool(true));
|
||||
data.insert(
|
||||
@@ -587,6 +669,10 @@ async fn api_native_download(
|
||||
"downloadedBytes".to_string(),
|
||||
Value::Number(serde_json::Number::from(downloaded_bytes)),
|
||||
);
|
||||
data.insert(
|
||||
"resumedBytes".to_string(),
|
||||
Value::Number(serde_json::Number::from(if append_mode { existing_size } else { 0 })),
|
||||
);
|
||||
|
||||
Ok(BridgeResponse {
|
||||
ok: true,
|
||||
@@ -711,6 +797,162 @@ async fn api_list_local_files(dir_path: String) -> Result<BridgeResponse, String
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn api_upload_file_resumable(
|
||||
state: tauri::State<'_, ApiState>,
|
||||
base_url: String,
|
||||
file_path: String,
|
||||
target_path: String,
|
||||
chunk_size: Option<u64>,
|
||||
) -> Result<BridgeResponse, String> {
|
||||
let trimmed_path = file_path.trim().to_string();
|
||||
if trimmed_path.is_empty() {
|
||||
return Err("上传文件路径不能为空".to_string());
|
||||
}
|
||||
|
||||
let source_path = PathBuf::from(&trimmed_path);
|
||||
if !source_path.exists() {
|
||||
return Err("上传文件不存在".to_string());
|
||||
}
|
||||
if !source_path.is_file() {
|
||||
return Err("仅支持上传文件,不支持文件夹".to_string());
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(&source_path).map_err(|err| format!("读取文件信息失败: {}", err))?;
|
||||
let file_size = metadata.len();
|
||||
if file_size == 0 {
|
||||
return Err("空文件不支持分片上传".to_string());
|
||||
}
|
||||
|
||||
let file_name = source_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| name.to_string())
|
||||
.ok_or_else(|| "无法识别文件名".to_string())?;
|
||||
let normalized_target = if target_path.trim().is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
target_path
|
||||
};
|
||||
let effective_chunk = chunk_size.unwrap_or(4 * 1024 * 1024).clamp(256 * 1024, 32 * 1024 * 1024);
|
||||
|
||||
let csrf_token = fetch_csrf_token(&state.client, &base_url).await?;
|
||||
let mut init_body = Map::new();
|
||||
init_body.insert("filename".to_string(), Value::String(file_name.clone()));
|
||||
init_body.insert("path".to_string(), Value::String(normalized_target));
|
||||
init_body.insert(
|
||||
"size".to_string(),
|
||||
Value::Number(serde_json::Number::from(file_size)),
|
||||
);
|
||||
init_body.insert(
|
||||
"chunk_size".to_string(),
|
||||
Value::Number(serde_json::Number::from(effective_chunk)),
|
||||
);
|
||||
|
||||
let init_resp = request_json(
|
||||
&state.client,
|
||||
Method::POST,
|
||||
join_api_url(&base_url, "/api/upload/resumable/init"),
|
||||
Some(Value::Object(init_body)),
|
||||
csrf_token.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !init_resp.ok || !init_resp.data.get("success").and_then(Value::as_bool).unwrap_or(false) {
|
||||
return Ok(init_resp);
|
||||
}
|
||||
|
||||
let session_id = init_resp
|
||||
.data
|
||||
.get("session_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.ok_or_else(|| "分片上传会话创建失败".to_string())?;
|
||||
let server_chunk_size = init_resp
|
||||
.data
|
||||
.get("chunk_size")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(effective_chunk)
|
||||
.max(1);
|
||||
let total_chunks = init_resp
|
||||
.data
|
||||
.get("total_chunks")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or_else(|| ((file_size + server_chunk_size - 1) / server_chunk_size).max(1));
|
||||
let uploaded_chunks: std::collections::HashSet<u64> = init_resp
|
||||
.data
|
||||
.get("uploaded_chunks")
|
||||
.and_then(Value::as_array)
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(Value::as_u64)
|
||||
.collect::<std::collections::HashSet<u64>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut source = fs::File::open(&source_path).map_err(|err| format!("打开文件失败: {}", err))?;
|
||||
for chunk_index in 0..total_chunks {
|
||||
if uploaded_chunks.contains(&chunk_index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let offset = chunk_index * server_chunk_size;
|
||||
let remaining = file_size.saturating_sub(offset);
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
let read_size = std::cmp::min(remaining, server_chunk_size) as usize;
|
||||
|
||||
source
|
||||
.seek(SeekFrom::Start(offset))
|
||||
.map_err(|err| format!("读取分片失败: {}", err))?;
|
||||
let mut buf = vec![0_u8; read_size];
|
||||
source
|
||||
.read_exact(&mut buf)
|
||||
.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 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) {
|
||||
return Ok(chunk_bridge);
|
||||
}
|
||||
}
|
||||
|
||||
let mut complete_body = Map::new();
|
||||
complete_body.insert("session_id".to_string(), Value::String(session_id));
|
||||
request_json(
|
||||
&state.client,
|
||||
Method::POST,
|
||||
join_api_url(&base_url, "/api/upload/resumable/complete"),
|
||||
Some(Value::Object(complete_body)),
|
||||
csrf_token,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn api_upload_file(
|
||||
state: tauri::State<'_, ApiState>,
|
||||
@@ -820,6 +1062,7 @@ pub fn run() {
|
||||
api_native_download,
|
||||
api_check_client_update,
|
||||
api_list_local_files,
|
||||
api_upload_file_resumable,
|
||||
api_upload_file
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
Reference in New Issue
Block a user