feat(desktop): align requested 4/5/6/8 with resume queue batch and one-click update

This commit is contained in:
2026-02-18 20:26:16 +08:00
parent 32a66e6c77
commit c668c88f7f
2 changed files with 711 additions and 74 deletions

View File

@@ -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!())