滚动截图,细节调整

This commit is contained in:
zhongluofeng
2026-09-04 19:56:23 +08:00
parent 6b9f71da08
commit 27ad5d89a5
34 changed files with 3766 additions and 559 deletions
+231 -70
View File
@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
@@ -115,8 +115,8 @@ struct EngineInner {
http: HttpDownloader,
/// BitTorrent 下载器(封装 librqbit 全局会话)
torrent: TorrentDownloader,
/// 下载是否使用代理(true=尊重系统代理false=强制直连)
/// 独立于 settings 存储,避免下载过程中反复锁 settingssave_settings 时同步更新
/// 下载是否使用代理(true=尝试走本地 mihomo 代理(mihomo 运行时才走)false=强制直连)
/// 不再依赖系统代理。独立于 settings 存储,避免下载过程中反复锁 settingssave_settings 时同步更新
use_proxy: AtomicBool,
/// Tauri 应用句柄(用于发事件)
app_handle: AppHandle,
@@ -126,6 +126,11 @@ struct EngineInner {
next_gen: AtomicU64,
/// 持久化节流:上次保存时间
last_save: Mutex<Instant>,
/// mihomo 代理地址解析缓存(含解析时刻),避免每个任务反复探测控制端
mihomo_proxy_cache: Mutex<Option<(Instant, Option<String>)>>,
/// HTTP 任务元数据后台探测中(占位任务已建但尚未完成探测/分段):
/// 期间 schedule 跳过此类任务,防止用"未知大小/单段"提前启动;探测完成后移除并重新调度
http_pending: Mutex<HashSet<String>>,
}
impl DownloadEngine {
@@ -161,7 +166,7 @@ impl DownloadEngine {
let use_proxy = settings.use_proxy;
// BT 会话默认目录(下载目录,每个任务用 output_folder 覆盖)
// 解析 BT 代理地址(若开启且代理模块可用)
let bt_socks = bt_proxy_addr(&app_handle, settings.bt_use_proxy);
let bt_socks = bt_proxy_addr(&app_handle, settings.use_proxy);
let torrent = TorrentDownloader::new(PathBuf::from(&settings.download_dir));
// 同步 BT 专属设置(上传限速 / 监听端口 / 代理)
torrent.set_settings(settings.bt_upload_limit_kb, settings.bt_listen_port, bt_socks);
@@ -180,6 +185,8 @@ impl DownloadEngine {
started: AtomicBool::new(false),
next_gen: AtomicU64::new(0),
last_save: Mutex::new(Instant::now()),
mihomo_proxy_cache: Mutex::new(None),
http_pending: Mutex::new(HashSet::new()),
}),
};
@@ -227,6 +234,7 @@ impl DownloadEngine {
}
let use_proxy = self.inner.use_proxy.load(Ordering::SeqCst);
self.ensure_mihomo_proxy().await;
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());
@@ -439,50 +447,50 @@ impl DownloadEngine {
return self.add_bt_task(url, dir, only_files).await;
}
// 探测资源信息
let use_proxy = self.inner.use_proxy.load(Ordering::SeqCst);
let probe = self.inner.http.probe(&url, &headers, use_proxy).await;
// HTTP 任务采用"立即占位 + 后台探测"
// 点击添加后立即返回 id 并让任务出现在列表,探测/分段/调度全部放后台,
// 避免添加操作阻塞在慢速探测(如 mihomo 未就绪/网络不通)上,也防止用户因"没反应"多次点击。
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());
let task_dir = dir.clone().unwrap_or_else(|| settings.download_dir.clone());
// 确定文件名
let mut task_filename = filename
.or_else(|| probe.as_ref().ok().and_then(|p| p.filename.clone()))
.unwrap_or_else(|| {
url.split('?')
.next()
.and_then(|u| u.rsplit('/').next())
.filter(|n| !n.is_empty())
.map(|n| n.to_string())
.unwrap_or_else(|| format!("download_{}", chrono::Utc::now().timestamp()))
});
// 自动重命名:若磁盘已存在同名文件,追加 (1)、(2)...
if auto_rename {
task_filename = self.generate_unique_filename(&task_dir, &task_filename);
// 1) 后端 URL 去重:同 URL 且非终态任务已存在 → 直接复用既有 id,不重复创建。
// 接住多次点击/重复转发造成的重复添加。
if settings.check_duplicate {
let dup_id = {
let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
tasks
.values()
.find(|t| {
t.protocol == TaskProtocol::Http
&& t.url == url
&& matches!(
t.status,
TaskStatus::Queued | TaskStatus::Active | TaskStatus::Paused
)
})
.map(|t| t.id.clone())
};
if let Some(id) = dup_id {
return Ok(id);
}
}
let id = self.inner.storage.next_task_id();
// 创建分段(受 continue_download 设置控制:关闭时强制单线程、不支持续传)
let resume_enabled = settings.continue_download;
let segments = match &probe {
Ok(p) if resume_enabled && p.supports_resume && p.total_size.map(|s| s > 0).unwrap_or(false) => {
split_segments(p.total_size.unwrap(), settings.max_connections)
}
_ => vec![Segment {
index: 0,
start: 0,
end: 0,
completed: 0,
}],
};
let total_size = probe.as_ref().ok().and_then(|p| p.total_size).unwrap_or(0);
let supports_resume = resume_enabled
&& probe.as_ref().ok().map(|p| p.supports_resume).unwrap_or(false);
// 2) 占位文件名:优先用调用方指定,否则先用 URL 推导,探测完成后用 Content-Disposition 修正
let mut task_filename = filename.clone().unwrap_or_else(|| {
url.split('?')
.next()
.and_then(|u| u.rsplit('/').next())
.filter(|n| !n.is_empty())
.map(|n| n.to_string())
.unwrap_or_else(|| format!("download_{}", chrono::Utc::now().timestamp()))
});
if auto_rename {
task_filename = self.generate_unique_filename(&task_dir, &task_filename);
}
// 3) 立即插入占位任务:Queued(未探测完成前 schedule 会跳过),未知大小单段
let task = DownloadTask {
id: id.clone(),
url: url.clone(),
@@ -493,47 +501,128 @@ impl DownloadEngine {
bt_files: Vec::new(),
bt_metadata_ready: false,
status: TaskStatus::Queued,
total_size,
total_size: 0,
completed_size: 0,
speed: 0,
supports_resume,
segments: segments.clone(),
supports_resume: false,
segments: vec![Segment {
index: 0,
start: 0,
end: 0,
completed: 0,
}],
error: None,
created_at: chrono::Utc::now().timestamp_millis(),
headers,
headers: headers.clone(),
};
// 如果探测失败,标记为 Error
let task = match probe {
Ok(_) => task,
Err(e) => DownloadTask {
status: TaskStatus::Error,
error: Some(e),
..task
},
};
{
let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
tasks.insert(id.clone(), task);
}
// 标记该任务元数据探测中,避免被并发调度提前启动
self.inner
.http_pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(id.clone());
self.persist_now();
self.emit_task_added(&id);
// 如果探测成功,尝试调度
let should_schedule = {
let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
tasks.get(&id).map(|t| t.status == TaskStatus::Queued).unwrap_or(false)
};
if should_schedule {
self.schedule();
}
// 4) 后台探测/分段/调度:不阻塞本函数,点击添加后立即返回
let engine = self.clone();
let rid = id.clone();
let raw_url = url.clone();
let raw_headers = headers.clone();
let use_proxy = self.inner.use_proxy.load(Ordering::SeqCst);
tauri::async_runtime::spawn(async move {
engine.ensure_mihomo_proxy().await;
let probe = engine.inner.http.probe(&raw_url, &raw_headers, use_proxy).await;
// 通知前端有新任务加入(扩展通过 HTTP API 添加时,前端需要刷新)
let _ = self.inner.app_handle.emit(crate::constants::events::DOWNLOAD_ADDED, serde_json::json!({ "id": id }));
// 探测结束,解除 pending 门控
engine
.inner
.http_pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&rid);
let mut should_schedule = false;
// 先在锁内应用非文件名字段,并取出目录/探测文件名(释放锁后再做唯一化命名,
// 因为 generate_unique_filename 内部会再锁 tasks,持锁调用会死锁)
let rename_from: Option<(String, Option<String>)> = {
let mut tasks = engine.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
match tasks.get_mut(&rid) {
Some(t) => match &probe {
Ok(p) => {
t.total_size = p.total_size.unwrap_or(0);
let resume = settings.continue_download
&& p.supports_resume
&& p.total_size.map(|s| s > 0).unwrap_or(false);
t.supports_resume = resume;
t.segments = if resume {
split_segments(p.total_size.unwrap(), settings.max_connections)
} else {
vec![Segment {
index: 0,
start: 0,
end: p.total_size.map(|s| s.saturating_sub(1)).unwrap_or(0),
completed: 0,
}]
};
if t.status == TaskStatus::Queued {
should_schedule = true;
}
// 调用方未指定文件名时,才后续用 Content-Disposition 修正
if filename.is_none() {
Some((t.dir.clone(), p.filename.clone()))
} else {
None
}
}
Err(e) => {
if t.status == TaskStatus::Queued {
t.status = TaskStatus::Error;
t.error = Some(e.clone());
}
None
}
},
None => None,
}
};
// 释放 tasks 锁后,若探测返回了真实文件名,重新做唯一化并写回
if let Some((dir_owned, Some(real_name))) = rename_from {
let final_name = if auto_rename {
engine.generate_unique_filename(&dir_owned, &real_name)
} else {
real_name
};
let mut tasks = engine.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
if let Some(t) = tasks.get_mut(&rid) {
t.filename = final_name;
}
}
engine.persist_now();
// 探测完成:刷新大小/文件名/分段/错误态到前端
engine.emit_task_added(&rid);
if should_schedule {
engine.schedule();
}
});
Ok(id)
}
/// 通知前端任务已新增/元数据已更新(用于刷新任务列表)
fn emit_task_added(&self, id: &str) {
let _ = self.inner.app_handle.emit(
crate::constants::events::DOWNLOAD_ADDED,
serde_json::json!({ "id": id }),
);
}
/// 添加 BitTorrent 任务(磁力链 / 本地 .torrent)— 全异步添加。
/// 立即创建占位任务并**立即返回** id(不阻塞);加入会话与元数据解析全在后台进行。
/// 注意:librqbit 的 add_torrent 对磁力会在元数据就绪前阻塞返回,因此绝不能在此 await。
@@ -1157,8 +1246,9 @@ impl DownloadEngine {
.use_proxy
.store(settings.use_proxy, Ordering::SeqCst);
// 同步 BT 专属设置(上传限速 / 监听端口 / 代理;监听端口、代理首次创建会话时生效)
let bt_socks = bt_proxy_addr(&self.inner.app_handle, settings.bt_use_proxy);
// 同步 BT 专属设置(上传限速 / 监听端口 / 代理;监听端口、代理首次创建会话时生效)
// BT 是否走代理跟随下载设置 use_proxybt_use_proxy 不再独立控制)
let bt_socks = bt_proxy_addr(&self.inner.app_handle, settings.use_proxy);
self.inner
.torrent
.set_settings(settings.bt_upload_limit_kb, settings.bt_listen_port, bt_socks);
@@ -1175,6 +1265,68 @@ impl DownloadEngine {
self.inner.started.load(Ordering::SeqCst)
}
/// 读取代理模块(mihomo)配置:mixed_port 与 external_controllersettings.json)。
/// 无配置/端口非法返回 None。
fn mihomo_proxy_config(&self) -> Option<(u16, String)> {
let dir = self.inner.app_handle.path().app_data_dir().ok()?;
let p = dir.join("proxy").join("settings.json");
let s = std::fs::read_to_string(p).ok()?;
let v: serde_json::Value = serde_json::from_str(&s).ok()?;
let port = v.get("mixedPort").and_then(|x| x.as_u64())?;
if port == 0 || port > 65535 {
return None;
}
let controller = v
.get("externalController")
.and_then(|x| x.as_str())
.unwrap_or("127.0.0.1:9090")
.to_string();
Some((port as u16, controller))
}
/// 解析当前 mihomo 显式代理地址(带 TTL 缓存):
/// 代理开关开启 & mihomo 控制端在线 → `http://127.0.0.1:{mixed_port}`
/// 否则 → None(直连)。不再依赖系统代理。
async fn mihomo_proxy(&self) -> Option<String> {
// 先查缓存(短锁,不跨 await 持有)
{
let cache = self
.inner
.mihomo_proxy_cache
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some((at, addr)) = cache.as_ref() {
if at.elapsed() < Duration::from_secs(5) {
return addr.clone();
}
}
}
// 重新解析:读配置端口 + 探测控制端确认 mihomo 运行
let addr = match self.mihomo_proxy_config() {
Some((port, controller)) => {
if self.inner.http.probe_controller(&controller).await {
Some(format!("http://127.0.0.1:{}", port))
} else {
None
}
}
None => None,
};
if let Ok(mut cache) = self.inner.mihomo_proxy_cache.lock() {
*cache = Some((Instant::now(), addr.clone()));
}
addr
}
/// 下载/探测前,把解析出的 mihomo 代理地址应用到 HTTP 下载器。
/// 代理开关关闭时强制直连。
async fn ensure_mihomo_proxy(&self) {
let use_proxy = self.inner.use_proxy.load(Ordering::SeqCst);
let proxy = if use_proxy { self.mihomo_proxy().await } else { None };
self.inner.http.configure_mihomo_proxy(proxy);
}
/// 退出时清理:停止所有下载、保存状态
pub fn cleanup_on_exit(&self) {
// 取消所有活跃下载(HTTP 设 cancel 标志;BT 中止等待 future
@@ -1219,13 +1371,20 @@ impl DownloadEngine {
let (active_count, queued_ids) = {
let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
let pending = self.inner.http_pending.lock().unwrap_or_else(|e| e.into_inner());
let active = tasks.values().filter(|t| t.status == TaskStatus::Active).count();
let mut queued: Vec<_> = tasks
.values()
// 跳过元数据未就绪的 BT 任务(异步添加后台解析中,等待用户勾选文件)
// 及 HTTP 占位任务(后台探测未完成,未得到大小/分段前不得提前启动)
.filter(|t| {
t.status == TaskStatus::Queued
&& (t.protocol != TaskProtocol::BitTorrent || t.bt_metadata_ready)
if t.status != TaskStatus::Queued {
return false;
}
if t.protocol == TaskProtocol::BitTorrent {
return t.bt_metadata_ready;
}
!pending.contains(&t.id)
})
.collect();
queued.sort_by_key(|t| t.created_at);
@@ -1473,6 +1632,8 @@ impl DownloadEngine {
let join = tauri::async_runtime::spawn(async move {
let my_gen = gen;
// 下载前应用当前 mihomo 代理(engine 与 http 共享同一 mihomo 客户端状态)
engine.ensure_mihomo_proxy().await;
let result = http
.download(&url, &headers, &segments, &temp_file_path, cancel_clone, &progress_clone, limiter, use_proxy)
.await;