细节调整及优化(26.8.3)
This commit is contained in:
@@ -64,6 +64,8 @@ pub struct ProgressPayload {
|
||||
pub total_size: u64,
|
||||
pub speed: u64,
|
||||
pub status: TaskStatus,
|
||||
/// 每个分段的已下载字节(与任务 segments 一一对应,供前端详情弹窗实时展示)
|
||||
pub segments: Vec<u64>,
|
||||
}
|
||||
|
||||
/// 完成事件载荷(发给前端 download-complete 事件)
|
||||
@@ -107,6 +109,9 @@ struct EngineInner {
|
||||
global_limiter: Arc<RateLimiter>,
|
||||
/// HTTP 下载器
|
||||
http: HttpDownloader,
|
||||
/// 下载是否使用代理(true=尊重系统代理,false=强制直连)
|
||||
/// 独立于 settings 存储,避免下载过程中反复锁 settings;save_settings 时同步更新
|
||||
use_proxy: AtomicBool,
|
||||
/// Tauri 应用句柄(用于发事件)
|
||||
app_handle: AppHandle,
|
||||
/// 引擎是否已启动
|
||||
@@ -144,6 +149,8 @@ impl DownloadEngine {
|
||||
0
|
||||
};
|
||||
let global_limiter = Arc::new(RateLimiter::new(global_limit));
|
||||
// 在 settings 移入 Mutex 前读取代理开关
|
||||
let use_proxy = settings.use_proxy;
|
||||
|
||||
let engine = Self {
|
||||
inner: Arc::new(EngineInner {
|
||||
@@ -153,6 +160,7 @@ impl DownloadEngine {
|
||||
settings: Mutex::new(settings),
|
||||
global_limiter,
|
||||
http: HttpDownloader::new(),
|
||||
use_proxy: AtomicBool::new(use_proxy),
|
||||
app_handle,
|
||||
started: AtomicBool::new(false),
|
||||
next_gen: AtomicU64::new(0),
|
||||
@@ -188,7 +196,8 @@ impl DownloadEngine {
|
||||
dir: Option<&str>,
|
||||
headers: &HashMap<String, String>,
|
||||
) -> (Result<ProbeResult, String>, DuplicateKind, Option<ExistingTaskInfo>) {
|
||||
let probe = self.inner.http.probe(url, headers).await;
|
||||
let use_proxy = self.inner.use_proxy.load(Ordering::SeqCst);
|
||||
let probe = self.inner.http.probe(url, headers, use_proxy).await;
|
||||
let settings = self.inner.settings.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let task_dir = dir.map(|d| d.to_string()).unwrap_or_else(|| settings.download_dir.clone());
|
||||
|
||||
@@ -250,11 +259,30 @@ impl DownloadEngine {
|
||||
}
|
||||
|
||||
/// 生成不冲突的文件名(同名时追加 (1)、(2)...)
|
||||
/// 冲突来源:磁盘已有同名最终文件、已存在同名临时文件(其他任务正在下载该名)、
|
||||
/// 以及任务列表中已有任务占用同名(即使最终文件尚未落盘,避免共用同一 .thingdl 临时文件)
|
||||
fn generate_unique_filename(&self, dir: &str, filename: &str) -> String {
|
||||
let path = PathBuf::from(dir).join(filename);
|
||||
if !path.exists() {
|
||||
// 任务列表中已占用的文件名(同一目录下)
|
||||
let taken: std::collections::HashSet<String> = {
|
||||
let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
|
||||
tasks
|
||||
.values()
|
||||
.filter(|t| t.dir == dir)
|
||||
.map(|t| t.filename.clone())
|
||||
.collect()
|
||||
};
|
||||
|
||||
// 磁盘最终文件 / 临时文件 / 任务占用,三者任一冲突即视为被占用
|
||||
let used = |name: &str| {
|
||||
taken.contains(name)
|
||||
|| PathBuf::from(dir).join(name).exists()
|
||||
|| PathBuf::from(dir).join(format!("{}.thingdl", name)).exists()
|
||||
};
|
||||
|
||||
if !used(filename) {
|
||||
return filename.to_string();
|
||||
}
|
||||
let path = PathBuf::from(dir).join(filename);
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("download");
|
||||
let ext = path.extension().and_then(|s| s.to_str());
|
||||
for i in 1..1000 {
|
||||
@@ -262,8 +290,7 @@ impl DownloadEngine {
|
||||
Some(e) => format!("{} ({}).{}", stem, i, e),
|
||||
None => format!("{} ({})", stem, i),
|
||||
};
|
||||
let new_path = PathBuf::from(dir).join(&new_name);
|
||||
if !new_path.exists() {
|
||||
if !used(&new_name) {
|
||||
return new_name;
|
||||
}
|
||||
}
|
||||
@@ -285,7 +312,8 @@ impl DownloadEngine {
|
||||
auto_rename: bool,
|
||||
) -> Result<String, String> {
|
||||
// 探测资源信息
|
||||
let probe = self.inner.http.probe(&url, &headers).await;
|
||||
let use_proxy = self.inner.use_proxy.load(Ordering::SeqCst);
|
||||
let probe = self.inner.http.probe(&url, &headers, use_proxy).await;
|
||||
|
||||
let settings = self.inner.settings.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let task_dir = dir.unwrap_or_else(|| settings.download_dir.clone());
|
||||
@@ -419,8 +447,8 @@ impl DownloadEngine {
|
||||
if task.status != TaskStatus::Paused && task.status != TaskStatus::Error {
|
||||
return Err("任务不在可恢复状态".to_string());
|
||||
}
|
||||
// 如果不支持断点续传,从头开始
|
||||
if !task.supports_resume {
|
||||
// 如果不支持断点续传,或文件大小未知(无法定位续传起点,需从头下载),从头开始
|
||||
if !task.supports_resume || task.total_size == 0 {
|
||||
task.completed_size = 0;
|
||||
for seg in &mut task.segments {
|
||||
seg.completed = 0;
|
||||
@@ -491,6 +519,11 @@ impl DownloadEngine {
|
||||
};
|
||||
self.inner.global_limiter.set_limit(new_limit);
|
||||
|
||||
// 同步代理开关(新任务/探测立即生效,正在下载的任务不受影响)
|
||||
self.inner
|
||||
.use_proxy
|
||||
.store(settings.use_proxy, Ordering::SeqCst);
|
||||
|
||||
{
|
||||
let mut s = self.inner.settings.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*s = settings;
|
||||
@@ -613,11 +646,12 @@ impl DownloadEngine {
|
||||
let temp_file_path = task.temp_file_path();
|
||||
let final_file_path = task.file_path();
|
||||
let http = self.inner.http.clone();
|
||||
let use_proxy = self.inner.use_proxy.load(Ordering::SeqCst);
|
||||
|
||||
let join = tauri::async_runtime::spawn(async move {
|
||||
let my_gen = gen;
|
||||
let result = http
|
||||
.download(&url, &headers, &segments, &temp_file_path, cancel_clone, &progress_clone, limiter)
|
||||
.download(&url, &headers, &segments, &temp_file_path, cancel_clone, &progress_clone, limiter, use_proxy)
|
||||
.await;
|
||||
|
||||
// 代际守卫:仅最新代际的任务能更新状态 / 移除句柄 / 发完成事件。
|
||||
@@ -748,6 +782,11 @@ impl DownloadEngine {
|
||||
.iter()
|
||||
.map(|p| p.load(Ordering::Relaxed))
|
||||
.sum();
|
||||
// 各分段实时进度(供前端详情弹窗分段条展示)
|
||||
let segment_completed: Vec<u64> = progress_monitor
|
||||
.iter()
|
||||
.map(|p| p.load(Ordering::Relaxed))
|
||||
.collect();
|
||||
|
||||
// 计算速度
|
||||
let now = Instant::now();
|
||||
@@ -788,6 +827,7 @@ impl DownloadEngine {
|
||||
total_size,
|
||||
speed,
|
||||
status: live_status,
|
||||
segments: segment_completed,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,15 +21,35 @@ const READ_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3
|
||||
/// HTTP/HTTPS 下载器
|
||||
#[derive(Clone)]
|
||||
pub struct HttpDownloader {
|
||||
client: Client,
|
||||
/// 默认客户端:尊重系统代理(reqwest 默认行为,mihomo 开启系统代理时经其转发)
|
||||
system_client: Client,
|
||||
/// 直连客户端:强制禁用系统代理(no_proxy)
|
||||
direct_client: Client,
|
||||
}
|
||||
|
||||
impl HttpDownloader {
|
||||
pub fn new() -> Self {
|
||||
let client = Client::builder()
|
||||
let system_client = Client::builder()
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
Self { client }
|
||||
let direct_client = Client::builder()
|
||||
// 强制直连:即使系统代理已开启,下载也不经过代理
|
||||
.no_proxy()
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
Self {
|
||||
system_client,
|
||||
direct_client,
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 use_proxy 选择客户端
|
||||
fn client(&self, use_proxy: bool) -> &Client {
|
||||
if use_proxy {
|
||||
&self.system_client
|
||||
} else {
|
||||
&self.direct_client
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测下载资源信息(大小、是否支持 Range、文件名)
|
||||
@@ -38,10 +58,11 @@ impl HttpDownloader {
|
||||
&self,
|
||||
url: &str,
|
||||
headers: &HashMap<String, String>,
|
||||
use_proxy: bool,
|
||||
) -> Result<ProbeResult, String> {
|
||||
let client = self.client(use_proxy);
|
||||
// 先尝试 Range 请求(能同时判断 Accept-Ranges 和获取大小)
|
||||
let mut req = self
|
||||
.client
|
||||
let mut req = client
|
||||
.get(url)
|
||||
.header("Range", "bytes=0-0")
|
||||
.header("User-Agent", "Thing-Download-Engine/1.0");
|
||||
@@ -97,7 +118,7 @@ impl HttpDownloader {
|
||||
}
|
||||
Err(_) => {
|
||||
// GET 失败,尝试 HEAD 作为回退
|
||||
let mut head_req = self.client.head(url);
|
||||
let mut head_req = client.head(url);
|
||||
for (k, v) in headers {
|
||||
head_req = head_req.header(k, v);
|
||||
}
|
||||
@@ -132,6 +153,7 @@ impl HttpDownloader {
|
||||
/// - `cancel`: 取消标志
|
||||
/// - `progress`: 每个分段的已下载字节(AtomicU64,与 segments 一一对应)
|
||||
/// - `limiter`: 全局限速器
|
||||
/// - `use_proxy`: 是否使用系统代理(false=强制直连)
|
||||
pub async fn download(
|
||||
&self,
|
||||
url: &str,
|
||||
@@ -141,7 +163,9 @@ impl HttpDownloader {
|
||||
cancel: Arc<AtomicBool>,
|
||||
progress: &[Arc<AtomicU64>],
|
||||
limiter: Arc<RateLimiter>,
|
||||
use_proxy: bool,
|
||||
) -> Result<(), String> {
|
||||
let client = self.client(use_proxy);
|
||||
let total_size = segments.iter().map(|s| s.len()).sum();
|
||||
|
||||
// 预分配文件(若已知大小)
|
||||
@@ -167,7 +191,7 @@ impl HttpDownloader {
|
||||
// 单线程下载(不支持 Range 或文件太小)
|
||||
let seg = &segments[0];
|
||||
let prog = &progress[0];
|
||||
self.download_segment(url, headers, seg, file_path, cancel.clone(), prog.clone(), limiter)
|
||||
self.download_segment(url, headers, seg, file_path, cancel.clone(), prog.clone(), limiter, client)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -187,7 +211,7 @@ impl HttpDownloader {
|
||||
.unwrap_or_default();
|
||||
let limiter = limiter.clone();
|
||||
let file_path = file_path.to_path_buf();
|
||||
let client = self.client.clone();
|
||||
let client = client.clone();
|
||||
|
||||
join_set.spawn(async move {
|
||||
download_segment_with_client(
|
||||
@@ -241,9 +265,10 @@ impl HttpDownloader {
|
||||
cancel: Arc<AtomicBool>,
|
||||
progress: Arc<AtomicU64>,
|
||||
limiter: Arc<RateLimiter>,
|
||||
client: &Client,
|
||||
) -> Result<(), String> {
|
||||
download_segment_with_client(
|
||||
&self.client,
|
||||
client,
|
||||
url,
|
||||
headers,
|
||||
seg,
|
||||
@@ -366,6 +391,15 @@ async fn download_segment_with_client(
|
||||
limiter.consume(buf.len() as u64).await;
|
||||
buf.clear();
|
||||
}
|
||||
// 校验:已知大小的分段若流提前结束(收到的字节数不足分段长度),
|
||||
// 说明服务器提前断开或返回不完整内容,不能标记为完成,否则文件会被截断
|
||||
if !unknown_size && local_completed < seg.len() {
|
||||
return Err(format!(
|
||||
"文件不完整:已接收 {} / {} 字节,服务器提前结束连接",
|
||||
local_completed,
|
||||
seg.len()
|
||||
));
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
|
||||
@@ -9,10 +9,10 @@ use axum::{
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::engine::DownloadEngine;
|
||||
use super::task::DownloadTask;
|
||||
use super::task::{DownloadTask, TaskStatus};
|
||||
|
||||
/// 扩展 HTTP API 服务器
|
||||
pub struct ExtensionServer;
|
||||
@@ -114,6 +114,14 @@ async fn create_download(
|
||||
return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() })));
|
||||
}
|
||||
|
||||
// 去重:同 URL 已有非终态任务(活跃/排队/暂停)时直接返回既有任务,
|
||||
// 避免浏览器重复转发同一下载造成重复下载
|
||||
if let Some(existing) = state.engine.get_tasks().into_iter().find(|t| {
|
||||
matches!(t.status, TaskStatus::Active | TaskStatus::Queued | TaskStatus::Paused) && t.url == req.url
|
||||
}) {
|
||||
return Ok(Json(CreateDownloadResponse { id: existing.id }));
|
||||
}
|
||||
|
||||
match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true).await {
|
||||
Ok(id) => {
|
||||
// 浏览器扩展发起下载:置前主窗口并通知前端跳到下载画面(替代原桌面通知)
|
||||
@@ -146,7 +154,11 @@ async fn remove_download(
|
||||
return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() })));
|
||||
}
|
||||
match state.engine.remove_task(&id, false) {
|
||||
Ok(()) => Ok(StatusCode::NO_CONTENT),
|
||||
Ok(()) => {
|
||||
// 通知前端刷新任务列表(扩展删除时前端无从感知,否则列表残留已删除任务)
|
||||
let _ = state.app_handle.emit(crate::constants::events::DOWNLOAD_REMOVED, serde_json::json!({ "id": id }));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
Err(e) => Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: e }))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,17 @@ impl Segment {
|
||||
pub fn len(&self) -> u64 {
|
||||
self.end.saturating_sub(self.start) + 1
|
||||
}
|
||||
/// 是否为未知大小哨兵段(start=0, end=0,表示不支持 Range 或未探测到大小)
|
||||
pub fn is_unknown_size(&self) -> bool {
|
||||
self.start == 0 && self.end == 0
|
||||
}
|
||||
/// 是否已完成
|
||||
pub fn is_done(&self) -> bool {
|
||||
// 未知大小段无法用长度判断是否完成,由流结束(Ok(None))判定;
|
||||
// 若按 len()=1 判断,暂停/恢复后 completed>=1 会误判为已完成,导致文件被截断
|
||||
if self.is_unknown_size() {
|
||||
return false;
|
||||
}
|
||||
self.completed >= self.len()
|
||||
}
|
||||
}
|
||||
@@ -129,6 +138,9 @@ pub struct DownloaderSettings {
|
||||
/// 添加下载前检查重复(URL 或文件名重复时询问)
|
||||
#[serde(default = "default_true")]
|
||||
pub check_duplicate: bool,
|
||||
/// 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连
|
||||
#[serde(default = "default_true")]
|
||||
pub use_proxy: bool,
|
||||
}
|
||||
|
||||
fn default_max_concurrent() -> u32 {
|
||||
@@ -167,6 +179,7 @@ impl Default for DownloaderSettings {
|
||||
extension_secret: String::new(),
|
||||
delete_files_on_remove: false,
|
||||
check_duplicate: true,
|
||||
use_proxy: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user