下载非内核

This commit is contained in:
zhongluofeng
2026-07-22 18:26:26 +08:00
parent 1e31ee8da9
commit f7d3c13f35
24 changed files with 2609 additions and 2532 deletions
+95
View File
@@ -244,6 +244,61 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.21.7"
@@ -1686,6 +1741,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.10.1"
@@ -1700,6 +1761,7 @@ dependencies = [
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@@ -2216,6 +2278,12 @@ dependencies = [
"web_atoms",
]
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.8.3"
@@ -3414,6 +3482,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -4169,6 +4248,7 @@ dependencies = [
name = "thing"
version = "0.1.0"
dependencies = [
"axum",
"chrono",
"dirs 5.0.1",
"futures-util",
@@ -4182,6 +4262,7 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tokio",
"url",
"windows-sys 0.52.0",
"winreg 0.52.0",
"zip",
@@ -4293,9 +4374,21 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
@@ -4462,6 +4555,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -4500,6 +4594,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
+3 -1
View File
@@ -27,7 +27,9 @@ serde_yaml = "0.9"
chrono = "0.4"
reqwest = { version = "0.12", features = ["json", "stream"] }
futures-util = "0.3"
tokio = { version = "1", features = ["io-util", "time"] }
tokio = { version = "1", features = ["io-util", "time", "rt-multi-thread", "macros", "sync", "fs", "net"] }
axum = "0.7"
url = "2"
zip = "2"
dirs = "5"
@@ -2,17 +2,17 @@
* Thing Extension - 后台 Service Worker
*
* 职责:
* 1. 接管浏览器下载,转发到 aria2
* 1. 接管浏览器下载,转发到 Thing 下载引擎
* 2. 资源嗅探:识别视频/音频/图片/压缩包/安装包等,存储到内存供 popup 查看
* 3. 右键菜单"使用 aria2 下载"
* 3. 右键菜单"使用 Thing 下载"
*
* 注:Service Worker 是短生命周期的,配置需持久化到 chrome.storage
*/
// ===== 默认配置 =====
const DEFAULT_CONFIG = {
rpcUrl: 'http://127.0.0.1:6800/jsonrpc',
rpcSecret: '',
serverUrl: 'http://127.0.0.1:16800',
secret: '',
interceptDownload: true,
minSize: 0,
excludeDomains: [],
@@ -301,48 +301,61 @@ async function saveConfig(config) {
await chrome.storage.local.set({ config })
}
// ===== aria2 JSON-RPC 调用 =====
async function aria2Call(method, params = []) {
// ===== Thing 下载引擎 REST API 调用 =====
async function apiRequest(path, options = {}) {
const config = await getConfig()
const rpcParams = []
if (config.rpcSecret) {
rpcParams.push(`token:${config.rpcSecret}`)
const url = config.serverUrl.replace(/\/$/, '') + path
const headers = { 'Content-Type': 'application/json' }
if (config.secret) {
headers['Authorization'] = `Bearer ${config.secret}`
}
rpcParams.push(...params)
const resp = await fetch(config.rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: Date.now().toString(),
method,
params: rpcParams
})
const resp = await fetch(url, {
...options,
headers: { ...headers, ...options.headers }
})
if (!resp.ok) {
throw new Error(`aria2 RPC HTTP ${resp.status}`)
const text = await resp.text().catch(() => '')
throw new Error(`API HTTP ${resp.status}: ${text || resp.statusText}`)
}
const data = await resp.json()
if (data.error) {
throw new Error(`aria2 RPC error: ${data.error.message} (${data.error.code})`)
}
return data.result
return resp.json()
}
/**
* 添加 URI 下载任务到 aria2
* 测试连接(GET /health
*/
async function aria2AddUri(url, filename, referer, cookies, headers) {
const options = {}
if (filename) options.out = filename
if (referer) options.referer = referer
const headerList = []
if (cookies) headerList.push(`Cookie: ${cookies}`)
if (headers && headers.length) headerList.push(...headers)
if (headerList.length) options.header = headerList
options['user-agent'] = navigator.userAgent
return aria2Call('aria2.addUri', [[url], options])
async function testConnection() {
const config = await getConfig()
const url = config.serverUrl.replace(/\/$/, '') + '/health'
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
return true
}
/**
* 添加下载任务(POST /api/downloads
*/
async function addDownload(url, filename, referer, cookies, headers) {
const body = { url }
if (filename) body.filename = filename
// 构造请求头
const headerMap = {}
headerMap['User-Agent'] = navigator.userAgent
if (referer) headerMap['Referer'] = referer
if (cookies) headerMap['Cookie'] = cookies
if (headers && headers.length) {
for (const h of headers) {
const idx = h.indexOf(':')
if (idx > 0) {
headerMap[h.slice(0, idx).trim()] = h.slice(idx + 1).trim()
}
}
}
body.headers = headerMap
const result = await apiRequest('/api/downloads', {
method: 'POST',
body: JSON.stringify(body)
})
return result.id
}
// ===== 下载拦截 =====
@@ -373,10 +386,10 @@ async function handleDownloadCreated(downloadItem) {
const filename = downloadItem.filename || ''
try {
const gid = await aria2AddUri(url, filename, downloadItem.referrer, '')
await notify('已添加到 aria2', `${filename || url}\nGID: ${gid}`)
const id = await addDownload(url, filename, downloadItem.referrer, '')
await notify('已添加到 Thing', `${filename || url}`)
} catch (e) {
await notify('aria2 添加失败', `${filename || url}\n${e.message}`)
await notify('Thing 添加失败', `${filename || url}\n${e.message}`)
try { await chrome.downloads.download({ url }) } catch { /* ignore */ }
}
}
@@ -399,7 +412,7 @@ async function notify(title, message) {
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'thing-download-link',
title: '使用 aria2 下载此链接',
title: '使用 Thing 下载此链接',
contexts: ['link']
})
chrome.contextMenus.create({
@@ -414,10 +427,10 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
const url = info.linkUrl
const filename = url.split('/').pop()?.split('?')[0] || ''
try {
const gid = await aria2AddUri(url, filename, info.pageUrl, '')
await notify('已添加到 aria2', `${filename || url}\nGID: ${gid}`)
await addDownload(url, filename, info.pageUrl, '')
await notify('已添加到 Thing', `${filename || url}`)
} catch (e) {
await notify('aria2 添加失败', `${e.message}`)
await notify('Thing 添加失败', `${e.message}`)
}
}
})
@@ -603,8 +616,8 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
return true
}
if (msg.type === 'testConnection') {
aria2Call('aria2.getVersion', [])
.then(res => sendResponse({ ok: true, version: res.version }))
testConnection()
.then(() => sendResponse({ ok: true }))
.catch(e => sendResponse({ ok: false, error: e.message }))
return true
}
@@ -629,8 +642,8 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
}
if (msg.type === 'downloadSniffed') {
const { url, filename, referer } = msg
aria2AddUri(url, filename, referer || '', '')
.then(gid => sendResponse({ ok: true, gid }))
addDownload(url, filename, referer || '', '')
.then(id => sendResponse({ ok: true, id }))
.catch(e => sendResponse({ ok: false, error: e.message }))
return true
}
@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Thing Extension",
"version": "0.2.0",
"description": "发送浏览器下载到Thing Downloaderaria2,嗅探网页资源。",
"description": "发送浏览器下载到 Thing 下载引擎,嗅探网页资源。",
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
@@ -12,7 +12,7 @@
<img src="icons/icon-48.png" alt="Thing" class="logo" />
<div class="title">
<h1>Thing Extension</h1>
<span class="subtitle">aria2 下载接管 · 资源嗅探</span>
<span class="subtitle">Thing 下载引擎 · 资源嗅探</span>
</div>
</header>
@@ -68,13 +68,13 @@
<form id="configForm">
<label class="field">
<span>RPC 地址</span>
<input type="text" id="rpcUrl" placeholder="http://127.0.0.1:6800/jsonrpc" />
<span>API 地址</span>
<input type="text" id="serverUrl" placeholder="http://127.0.0.1:16800" />
</label>
<label class="field">
<span>RPC 密钥</span>
<input type="password" id="rpcSecret" placeholder="未设置时留空" />
<span>认证密钥</span>
<input type="password" id="secret" placeholder="未设置时留空" />
</label>
<label class="checkbox">
+8 -8
View File
@@ -7,8 +7,8 @@
const $ = (id) => document.getElementById(id)
const DEFAULT_CONFIG = {
rpcUrl: 'http://127.0.0.1:6800/jsonrpc',
rpcSecret: '',
serverUrl: 'http://127.0.0.1:16800',
secret: '',
interceptDownload: true,
minSize: 0,
excludeDomains: [],
@@ -53,8 +53,8 @@ async function loadConfig() {
}
function fillForm(config) {
$('rpcUrl').value = config.rpcUrl || DEFAULT_CONFIG.rpcUrl
$('rpcSecret').value = config.rpcSecret || ''
$('serverUrl').value = config.serverUrl || DEFAULT_CONFIG.serverUrl
$('secret').value = config.secret || ''
$('interceptDownload').checked = config.interceptDownload !== false
$('sniffEnabled').checked = config.sniffEnabled !== false
$('showNotifications').checked = config.showNotifications !== false
@@ -65,8 +65,8 @@ function fillForm(config) {
function readForm() {
return {
rpcUrl: $('rpcUrl').value.trim() || DEFAULT_CONFIG.rpcUrl,
rpcSecret: $('rpcSecret').value.trim(),
serverUrl: $('serverUrl').value.trim() || DEFAULT_CONFIG.serverUrl,
secret: $('secret').value.trim(),
interceptDownload: $('interceptDownload').checked,
sniffEnabled: $('sniffEnabled').checked,
showNotifications: $('showNotifications').checked,
@@ -97,7 +97,7 @@ $('testBtn').addEventListener('click', async () => {
await send('saveConfig', { config })
const res = await send('testConnection', {})
if (res && res.ok) {
setStatus('ok', `已连接 · aria2 ${res.version}`)
setStatus('ok', '已连接 · Thing 下载引擎')
} else {
setStatus('fail', '连接失败:' + (res?.error || '未知错误'))
}
@@ -312,7 +312,7 @@ document.querySelectorAll('.sniff-tab').forEach(btn => {
setStatus('', '检测中...')
const res = await send('testConnection', {})
if (res && res.ok) {
setStatus('ok', `已连接 · aria2 ${res.version}`)
setStatus('ok', '已连接 · Thing 下载引擎')
} else {
setStatus('fail', '未连接')
}
File diff suppressed because it is too large Load Diff
+99
View File
@@ -0,0 +1,99 @@
use std::collections::HashMap;
use tauri::{AppHandle, State};
use tauri_plugin_opener::OpenerExt;
use super::engine::DownloadEngine;
use super::task::{DownloadTask, DownloaderSettings};
/// 获取所有任务
#[tauri::command]
pub fn downloader_get_tasks(engine: State<'_, DownloadEngine>) -> Vec<DownloadTask> {
engine.get_tasks()
}
/// 添加下载任务
#[tauri::command]
pub async fn downloader_add_task(
engine: State<'_, DownloadEngine>,
url: String,
filename: Option<String>,
dir: Option<String>,
headers: Option<HashMap<String, String>>,
) -> Result<String, String> {
engine.add_task(url, filename, dir, headers.unwrap_or_default()).await
}
/// 暂停任务
#[tauri::command]
pub fn downloader_pause_task(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
engine.pause_task(&id)
}
/// 恢复任务
#[tauri::command]
pub fn downloader_resume_task(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
engine.resume_task(&id)
}
/// 移除任务
#[tauri::command]
pub fn downloader_remove_task(
engine: State<'_, DownloadEngine>,
id: String,
delete_files: Option<bool>,
) -> Result<(), String> {
engine.remove_task(&id, delete_files.unwrap_or(false))
}
/// 获取设置
#[tauri::command]
pub fn downloader_get_settings(engine: State<'_, DownloadEngine>) -> DownloaderSettings {
engine.get_settings()
}
/// 保存设置
#[tauri::command]
pub fn downloader_save_settings(
engine: State<'_, DownloadEngine>,
settings: DownloaderSettings,
) -> Result<(), String> {
engine.save_settings(settings);
Ok(())
}
/// 引擎状态(始终运行中)
#[tauri::command]
pub fn downloader_status(engine: State<'_, DownloadEngine>) -> serde_json::Value {
serde_json::json!({
"running": engine.is_started(),
})
}
/// 获取扩展服务信息
#[tauri::command]
pub fn downloader_get_extension_info(engine: State<'_, DownloadEngine>) -> serde_json::Value {
let settings = engine.get_settings();
serde_json::json!({
"url": format!("http://127.0.0.1:{}/", settings.extension_port),
"port": settings.extension_port,
"secret": settings.extension_secret,
"hasSecret": !settings.extension_secret.is_empty(),
})
}
/// 用系统资源管理器打开目录
#[tauri::command]
pub fn downloader_open_dir(app: AppHandle, path: String) -> Result<(), String> {
app.opener()
.open_path(path, None::<&str>)
.map_err(|e| format!("打开目录失败: {}", e))
}
/// 用系统默认浏览器打开 URL
#[tauri::command]
pub fn downloader_open_url(app: AppHandle, url: String) -> Result<(), String> {
app.opener()
.open_url(url, None::<&str>)
.map_err(|e| format!("打开链接失败: {}", e))
}
+623
View File
@@ -0,0 +1,623 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter};
use super::http_dl::{HttpDownloader, split_segments};
use super::rate_limit::RateLimiter;
use super::storage::{EngineState, Storage};
use super::task::{DownloadTask, DownloaderSettings, Segment, TaskStatus};
/// 进度事件载荷(发给前端 download-progress 事件)
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProgressPayload {
pub id: String,
pub completed_size: u64,
pub total_size: u64,
pub speed: u64,
pub status: TaskStatus,
}
/// 完成事件载荷(发给前端 download-complete 事件)
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompletePayload {
pub id: String,
pub filename: String,
pub status: TaskStatus,
pub error: Option<String>,
}
/// 活跃下载句柄
struct TaskHandle {
cancel: Arc<AtomicBool>,
/// 每个分段的已下载字节(与 segments 一一对应)
progress: Vec<Arc<std::sync::atomic::AtomicU64>>,
/// 下载任务的 JoinHandleNone = 已完成/已取消)
join: Mutex<Option<tauri::async_runtime::JoinHandle<()>>>,
}
/// 下载引擎(进程内运行,通过 Arc 共享状态)
#[derive(Clone)]
pub struct DownloadEngine {
inner: Arc<EngineInner>,
}
struct EngineInner {
/// 所有任务状态
tasks: Mutex<HashMap<String, DownloadTask>>,
/// 活跃下载句柄
handles: Mutex<HashMap<String, TaskHandle>>,
/// 持久化存储
storage: Storage,
/// 下载设置
settings: Mutex<DownloaderSettings>,
/// 全局限速器(bytes/s0=不限)
global_limiter: Arc<RateLimiter>,
/// HTTP 下载器
http: HttpDownloader,
/// Tauri 应用句柄(用于发事件)
app_handle: AppHandle,
/// 引擎是否已启动
started: AtomicBool,
/// 持久化节流:上次保存时间
last_save: Mutex<Instant>,
}
impl DownloadEngine {
/// 创建引擎并从磁盘恢复状态
pub fn new(data_dir: PathBuf, app_handle: AppHandle) -> Self {
let storage = Storage::new(data_dir);
let state = storage.load();
// 恢复时,将 Active 任务标记为 Paused(避免自动恢复下载)
let mut tasks: HashMap<String, DownloadTask> = state
.tasks
.into_iter()
.map(|t| (t.id.clone(), t))
.collect();
for task in tasks.values_mut() {
if task.status == TaskStatus::Active {
task.status = TaskStatus::Paused;
task.speed = 0;
}
}
let settings = state.settings;
// 创建全局限速器(KB/s → bytes/s
let global_limit = if settings.global_speed_limit > 0 {
settings.global_speed_limit * 1024
} else {
0
};
let global_limiter = Arc::new(RateLimiter::new(global_limit));
let engine = Self {
inner: Arc::new(EngineInner {
tasks: Mutex::new(tasks),
handles: Mutex::new(HashMap::new()),
storage,
settings: Mutex::new(settings),
global_limiter,
http: HttpDownloader::new(),
app_handle,
started: AtomicBool::new(false),
last_save: Mutex::new(Instant::now()),
}),
};
// 启动后台持久化定时器
engine.start_save_timer();
engine.inner.started.store(true, Ordering::SeqCst);
engine
}
/// 启动后台定时持久化(每 5 秒检查一次)
fn start_save_timer(&self) {
let engine = self.clone();
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(5));
loop {
interval.tick().await;
engine.persist_throttled();
}
});
}
// ===================== 公开 API =====================
/// 添加下载任务
pub async fn add_task(
&self,
url: String,
filename: Option<String>,
dir: Option<String>,
headers: HashMap<String, String>,
) -> Result<String, String> {
// 探测资源信息
let probe = self.inner.http.probe(&url, &headers).await;
let settings = self.inner.settings.lock().unwrap().clone();
let task_dir = dir.unwrap_or_else(|| settings.download_dir.clone());
// 确定文件名
let 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()))
});
let id = self.inner.storage.next_task_id();
// 创建分段
let segments = match &probe {
Ok(p) if 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 = probe.as_ref().ok().map(|p| p.supports_resume).unwrap_or(false);
let task = DownloadTask {
id: id.clone(),
url: url.clone(),
filename: task_filename,
dir: task_dir,
status: TaskStatus::Queued,
total_size,
completed_size: 0,
speed: 0,
supports_resume,
segments: segments.clone(),
error: None,
created_at: chrono::Utc::now().timestamp_millis(),
headers,
};
// 如果探测失败,标记为 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();
tasks.insert(id.clone(), task);
}
self.persist_now();
// 如果探测成功,尝试调度
let should_schedule = {
let tasks = self.inner.tasks.lock().unwrap();
tasks.get(&id).map(|t| t.status == TaskStatus::Queued).unwrap_or(false)
};
if should_schedule {
self.schedule();
}
// 通知前端有新任务加入(扩展通过 HTTP API 添加时,前端需要刷新)
let _ = self.inner.app_handle.emit("download-added", serde_json::json!({ "id": id }));
Ok(id)
}
/// 暂停任务
pub fn pause_task(&self, id: &str) -> Result<(), String> {
// 1. 设置取消标志 + 读取进度(锁 handles)
let progress_values: Vec<u64> = {
let handles = self.inner.handles.lock().unwrap();
if let Some(handle) = handles.get(id) {
handle.cancel.store(true, Ordering::SeqCst);
handle.progress.iter().map(|p| p.load(Ordering::Relaxed)).collect()
} else {
Vec::new()
}
};
// 2. 更新任务状态 + 同步进度(锁 tasks,不嵌套锁 handles
{
let mut tasks = self.inner.tasks.lock().unwrap();
if let Some(task) = tasks.get_mut(id) {
if task.status == TaskStatus::Active || task.status == TaskStatus::Queued {
task.status = TaskStatus::Paused;
task.speed = 0;
// 同步分段进度
for (i, val) in progress_values.iter().enumerate() {
if let Some(seg) = task.segments.get_mut(i) {
seg.completed = *val;
}
}
task.recalc_completed();
}
} else {
return Err("任务不存在".to_string());
}
}
self.persist_now();
self.schedule();
Ok(())
}
/// 恢复任务
pub fn resume_task(&self, id: &str) -> Result<(), String> {
{
let mut tasks = self.inner.tasks.lock().unwrap();
if let Some(task) = tasks.get_mut(id) {
if task.status != TaskStatus::Paused && task.status != TaskStatus::Error {
return Err("任务不在可恢复状态".to_string());
}
// 如果不支持断点续传,从头开始
if !task.supports_resume {
task.completed_size = 0;
for seg in &mut task.segments {
seg.completed = 0;
}
}
task.status = TaskStatus::Queued;
task.error = None;
} else {
return Err("任务不存在".to_string());
}
}
self.persist_now();
self.schedule();
Ok(())
}
/// 移除任务
pub fn remove_task(&self, id: &str, delete_files: bool) -> Result<(), String> {
// 1. 设置取消标志 + abort join handle(锁 handles
{
let mut handles = self.inner.handles.lock().unwrap();
if let Some(handle) = handles.remove(id) {
handle.cancel.store(true, Ordering::SeqCst);
if let Ok(mut join) = handle.join.lock() {
if let Some(j) = join.take() {
j.abort();
}
}
}
}
// 2. 从任务列表移除(锁 tasks)
let task = {
let mut tasks = self.inner.tasks.lock().unwrap();
tasks.remove(id)
};
// 3. 可选删除文件(临时文件 + 最终文件)
if delete_files {
if let Some(task) = &task {
let _ = std::fs::remove_file(task.file_path());
let _ = std::fs::remove_file(task.temp_file_path());
}
}
self.persist_now();
self.schedule();
Ok(())
}
/// 获取所有任务
pub fn get_tasks(&self) -> Vec<DownloadTask> {
self.inner.tasks.lock().unwrap().values().cloned().collect()
}
/// 获取设置
pub fn get_settings(&self) -> DownloaderSettings {
self.inner.settings.lock().unwrap().clone()
}
/// 保存设置
pub fn save_settings(&self, settings: DownloaderSettings) {
// 更新全局限速器
let new_limit = if settings.global_speed_limit > 0 {
settings.global_speed_limit * 1024
} else {
0
};
self.inner.global_limiter.set_limit(new_limit);
{
let mut s = self.inner.settings.lock().unwrap();
*s = settings;
}
self.persist_now();
}
/// 引擎是否已启动
pub fn is_started(&self) -> bool {
self.inner.started.load(Ordering::SeqCst)
}
/// 退出时清理:停止所有下载、保存状态
pub fn cleanup_on_exit(&self) {
// 取消所有活跃下载
{
let handles = self.inner.handles.lock().unwrap();
for handle in handles.values() {
handle.cancel.store(true, Ordering::SeqCst);
}
}
// 将 Active 任务标记为 Paused
{
let mut tasks = self.inner.tasks.lock().unwrap();
for task in tasks.values_mut() {
if task.status == TaskStatus::Active {
task.status = TaskStatus::Paused;
task.speed = 0;
}
}
}
// 等待短暂时间让下载任务退出
std::thread::sleep(Duration::from_millis(200));
// 最终保存
self.persist_now();
}
// ===================== 内部调度逻辑 =====================
/// 调度:如果活跃任务数 < max_concurrent,启动排队任务
fn schedule(&self) {
let max_concurrent = self.inner.settings.lock().unwrap().max_concurrent as usize;
let (active_count, queued_ids) = {
let tasks = self.inner.tasks.lock().unwrap();
let active = tasks.values().filter(|t| t.status == TaskStatus::Active).count();
let mut queued: Vec<_> = tasks
.values()
.filter(|t| t.status == TaskStatus::Queued)
.collect();
queued.sort_by_key(|t| t.created_at);
(active, queued.into_iter().map(|t| t.id.clone()).collect::<Vec<_>>())
};
if active_count >= max_concurrent {
return;
}
let slots = max_concurrent.saturating_sub(active_count);
for id in queued_ids.into_iter().take(slots) {
self.start_download(id);
}
}
/// 启动单个下载任务
fn start_download(&self, id: String) {
let task = {
let mut tasks = self.inner.tasks.lock().unwrap();
match tasks.get_mut(&id) {
Some(task) if task.status == TaskStatus::Queued => {
task.status = TaskStatus::Active;
task.speed = 0;
task.clone()
}
_ => return,
}
};
// 创建取消标志和进度计数器
let cancel = Arc::new(AtomicBool::new(false));
let progress: Vec<Arc<std::sync::atomic::AtomicU64>> = task
.segments
.iter()
.map(|s| Arc::new(std::sync::atomic::AtomicU64::new(s.completed)))
.collect();
// 存储句柄
let handle = TaskHandle {
cancel: cancel.clone(),
progress: progress.iter().map(|p| p.clone()).collect(),
join: Mutex::new(None),
};
self.inner.handles.lock().unwrap().insert(id.clone(), handle);
// 生成下载 future
let engine = self.clone();
let id_clone = id.clone();
let cancel_clone = cancel.clone();
let progress_clone: Vec<Arc<std::sync::atomic::AtomicU64>> =
progress.iter().map(|p| p.clone()).collect();
let limiter = self.inner.global_limiter.clone();
let url = task.url.clone();
let headers = task.headers.clone();
let segments = task.segments.clone();
let temp_file_path = task.temp_file_path();
let final_file_path = task.file_path();
let http = self.inner.http.clone();
let join = tauri::async_runtime::spawn(async move {
let result = http
.download(&url, &headers, &segments, &temp_file_path, cancel_clone, &progress_clone, limiter)
.await;
// 下载结束,更新任务状态
let final_status = match &result {
Ok(()) => TaskStatus::Complete,
Err(e) if e == "已取消" => TaskStatus::Paused,
Err(_) => TaskStatus::Error,
};
// 下载成功后,将临时文件重命名为最终文件名
if final_status == TaskStatus::Complete {
let _ = tokio::fs::rename(&temp_file_path, &final_file_path).await;
}
// 同步最终进度到任务
{
let mut tasks = engine.inner.tasks.lock().unwrap();
if let Some(task) = tasks.get_mut(&id_clone) {
for (i, prog) in progress_clone.iter().enumerate() {
if let Some(seg) = task.segments.get_mut(i) {
seg.completed = prog.load(Ordering::Relaxed);
}
}
task.recalc_completed();
task.speed = 0;
task.status = final_status.clone();
if let Err(e) = &result {
if e != "已取消" {
task.error = Some(e.clone());
}
}
if final_status == TaskStatus::Complete {
task.completed_size = task.total_size.max(task.completed_size);
}
}
}
// 从活跃句柄中移除
engine.inner.handles.lock().unwrap().remove(&id_clone);
// 发送完成事件
let task = engine.inner.tasks.lock().unwrap().get(&id_clone).cloned();
if let Some(task) = task {
let _ = engine.inner.app_handle.emit(
"download-complete",
CompletePayload {
id: id_clone.clone(),
filename: task.filename.clone(),
status: task.status.clone(),
error: task.error.clone(),
},
);
}
// 持久化 + 调度下一个
engine.persist_now();
engine.schedule();
});
// 存储 JoinHandle
if let Some(h) = self.inner.handles.lock().unwrap().get_mut(&id) {
if let Ok(mut join_guard) = h.join.lock() {
*join_guard = Some(join);
}
}
// 启动进度监控(每 500ms 更新一次)
let engine = self.clone();
let id_monitor = id.clone();
let progress_monitor = progress;
let cancel_monitor = cancel;
tauri::async_runtime::spawn(async move {
// 初始化为当前已下载量,避免恢复下载时首次计算速度异常
let mut last_completed: u64 = progress_monitor
.iter()
.map(|p| p.load(Ordering::Relaxed))
.sum();
let mut last_time = Instant::now();
let mut interval = tokio::time::interval(Duration::from_millis(500));
interval.tick().await; // 跳过第一次立即触发
loop {
interval.tick().await;
// 如果任务已不在活跃状态,停止监控
let is_active = {
let tasks = engine.inner.tasks.lock().unwrap();
tasks.get(&id_monitor).map(|t| t.status == TaskStatus::Active).unwrap_or(false)
};
if !is_active || cancel_monitor.load(Ordering::SeqCst) {
break;
}
// 读取进度
let completed: u64 = progress_monitor
.iter()
.map(|p| p.load(Ordering::Relaxed))
.sum();
// 计算速度
let now = Instant::now();
let elapsed = now.duration_since(last_time).as_secs_f64();
let speed = if elapsed > 0.0 && completed >= last_completed {
((completed - last_completed) as f64 / elapsed) as u64
} else {
0
};
last_completed = completed;
last_time = now;
// 更新任务状态 + 发送进度事件
let total_size = {
let mut tasks = engine.inner.tasks.lock().unwrap();
if let Some(task) = tasks.get_mut(&id_monitor) {
task.completed_size = completed;
task.speed = speed;
for (i, prog) in progress_monitor.iter().enumerate() {
if let Some(seg) = task.segments.get_mut(i) {
seg.completed = prog.load(Ordering::Relaxed);
}
}
task.total_size
} else {
break;
}
};
let _ = engine.inner.app_handle.emit(
"download-progress",
ProgressPayload {
id: id_monitor.clone(),
completed_size: completed,
total_size,
speed,
status: TaskStatus::Active,
},
);
}
});
}
// ===================== 持久化 =====================
/// 节流持久化(至少间隔 3 秒)
fn persist_throttled(&self) {
let should_save = {
let last = self.inner.last_save.lock().unwrap();
last.elapsed() >= Duration::from_secs(3)
};
if should_save {
self.persist_now();
}
}
/// 立即持久化
fn persist_now(&self) {
let tasks: Vec<DownloadTask> = {
let tasks = self.inner.tasks.lock().unwrap();
tasks.values().cloned().collect()
};
let settings = self.inner.settings.lock().unwrap().clone();
let state = EngineState {
tasks,
settings,
next_id: 0, // storage.save 会从 id_counter 读取
};
self.inner.storage.save(state);
*self.inner.last_save.lock().unwrap() = Instant::now();
}
}
+475
View File
@@ -0,0 +1,475 @@
use futures_util::StreamExt;
use reqwest::Client;
use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::SeekFrom;
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncSeekExt, AsyncWriteExt};
use tokio::task::JoinSet;
use super::rate_limit::RateLimiter;
use super::task::{ProbeResult, Segment};
/// HTTP/HTTPS 下载器
#[derive(Clone)]
pub struct HttpDownloader {
client: Client,
}
impl HttpDownloader {
pub fn new() -> Self {
let client = Client::builder()
.build()
.unwrap_or_else(|_| Client::new());
Self { client }
}
/// 探测下载资源信息(大小、是否支持 Range、文件名)
/// 优先用 GET + Range: bytes=0-0(返回 206 + Content-Range),回退到 HEAD
pub async fn probe(
&self,
url: &str,
headers: &HashMap<String, String>,
) -> Result<ProbeResult, String> {
// 先尝试 Range 请求(能同时判断 Accept-Ranges 和获取大小)
let mut req = self
.client
.get(url)
.header("Range", "bytes=0-0")
.header("User-Agent", "Thing-Download-Engine/1.0");
for (k, v) in headers {
req = req.header(k, v);
}
match req.send().await {
Ok(resp) => {
let status = resp.status();
let headers_map = resp.headers().clone();
// 206 Partial Content → 支持 Range
if status.as_u16() == 206 {
let total_size = headers_map
.get("content-range")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split('/').nth(1))
.and_then(|v| v.trim().parse::<u64>().ok());
let supports_resume = true;
let filename = extract_filename(url, &headers_map);
return Ok(ProbeResult {
total_size,
supports_resume,
filename,
});
}
// 200 OK → 服务器不支持 Range 或忽略 Range 头
// 可能仍有 Content-Length
let total_size = headers_map
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.trim().parse::<u64>().ok());
// 检查 Accept-Ranges 头
let supports_resume = headers_map
.get("accept-ranges")
.and_then(|v| v.to_str().ok())
.map(|v| v.eq_ignore_ascii_case("bytes"))
.unwrap_or(false);
let filename = extract_filename(url, &headers_map);
Ok(ProbeResult {
total_size,
supports_resume,
filename,
})
}
Err(_) => {
// GET 失败,尝试 HEAD 作为回退
let mut head_req = self.client.head(url);
for (k, v) in headers {
head_req = head_req.header(k, v);
}
let resp = head_req
.send()
.await
.map_err(|e| format!("探测失败(GET 和 HEAD 均失败): {}", e))?;
let headers_map = resp.headers().clone();
let total_size = headers_map
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.trim().parse::<u64>().ok());
let supports_resume = headers_map
.get("accept-ranges")
.and_then(|v| v.to_str().ok())
.map(|v| v.eq_ignore_ascii_case("bytes"))
.unwrap_or(false);
let filename = extract_filename(url, &headers_map);
Ok(ProbeResult {
total_size,
supports_resume,
filename,
})
}
}
}
/// 执行多线程 Range 下载
///
/// - `segments`: 分段列表(已根据 probe 结果划分)
/// - `file_path`: 目标文件路径
/// - `cancel`: 取消标志
/// - `progress`: 每个分段的已下载字节(AtomicU64,与 segments 一一对应)
/// - `limiter`: 全局限速器
pub async fn download(
&self,
url: &str,
headers: &HashMap<String, String>,
segments: &[Segment],
file_path: &Path,
cancel: Arc<AtomicBool>,
progress: &[Arc<AtomicU64>],
limiter: Arc<RateLimiter>,
) -> Result<(), String> {
let total_size = segments.iter().map(|s| s.len()).sum();
// 预分配文件(若已知大小)
if total_size > 0 {
if let Some(file) = OpenOptions::new()
.create(true)
.write(true)
.open(file_path)
.ok()
{
let _ = file.set_len(total_size);
}
} else {
// 未知大小,确保文件存在
let _ = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(file_path);
}
if segments.len() <= 1 {
// 单线程下载(不支持 Range 或文件太小)
let seg = &segments[0];
let prog = &progress[0];
self.download_segment(url, headers, seg, file_path, cancel.clone(), prog.clone(), limiter)
.await?;
return Ok(());
}
// 多线程下载:每个分段一个 tokio task
let mut join_set: JoinSet<Result<(), String>> = JoinSet::new();
for seg in segments {
let url = url.to_string();
let headers = headers.clone();
let seg = seg.clone();
let cancel = cancel.clone();
// 找到对应的 progress atomic
let prog = progress
.get(seg.index as usize)
.cloned()
.unwrap_or_default();
let limiter = limiter.clone();
let file_path = file_path.to_path_buf();
let client = self.client.clone();
join_set.spawn(async move {
download_segment_with_client(
&client,
&url,
&headers,
&seg,
&file_path,
cancel,
prog,
limiter,
)
.await
});
}
// 等待所有分段完成
let mut first_error: Option<String> = None;
while let Some(res) = join_set.join_next().await {
match res {
Ok(Ok(())) => {}
Ok(Err(e)) => {
if first_error.is_none() {
first_error = Some(e);
}
// 一个分段失败,取消其他分段
cancel.store(true, Ordering::SeqCst);
}
Err(e) => {
if first_error.is_none() {
first_error = Some(format!("分段任务异常: {}", e));
}
cancel.store(true, Ordering::SeqCst);
}
}
}
if let Some(e) = first_error {
return Err(e);
}
Ok(())
}
/// 下载单个分段(实例方法,用于单线程场景)
async fn download_segment(
&self,
url: &str,
headers: &HashMap<String, String>,
seg: &Segment,
file_path: &Path,
cancel: Arc<AtomicBool>,
progress: Arc<AtomicU64>,
limiter: Arc<RateLimiter>,
) -> Result<(), String> {
download_segment_with_client(
&self.client,
url,
headers,
seg,
file_path,
cancel,
progress,
limiter,
)
.await
}
}
/// 下载单个分段的核心逻辑(可被多线程 task 调用)
async fn download_segment_with_client(
client: &Client,
url: &str,
headers: &HashMap<String, String>,
seg: &Segment,
file_path: &Path,
cancel: Arc<AtomicBool>,
progress: Arc<AtomicU64>,
limiter: Arc<RateLimiter>,
) -> Result<(), String> {
// 如果分段已完成,直接返回
if seg.is_done() {
return Ok(());
}
// 计算本次需要下载的范围(从已下载位置继续)
let range_start = seg.start + seg.completed;
let range_end = seg.end;
// start=0, end=0 表示未知大小(不支持 Range 或未探测到大小),不发送 Range 头
let unknown_size = seg.start == 0 && seg.end == 0;
// 打开文件并 seek 到正确位置
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.write(true)
.read(false)
.open(file_path)
.await
.map_err(|e| format!("打开文件失败: {}", e))?;
if !unknown_size {
file.seek(SeekFrom::Start(range_start))
.await
.map_err(|e| format!("文件定位失败: {}", e))?;
}
// 构造请求
let mut req = client
.get(url)
.header("User-Agent", "Thing-Download-Engine/1.0");
if !unknown_size {
req = req.header("Range", format!("bytes={}-{}", range_start, range_end));
}
for (k, v) in headers {
req = req.header(k, v);
}
let resp = req
.send()
.await
.map_err(|e| format!("请求失败: {}", e))?;
let status = resp.status();
if !status.is_success() && status.as_u16() != 206 {
return Err(format!("服务器返回 HTTP {}", status));
}
// 流式读取并写入文件
let mut stream = resp.bytes_stream();
let mut buf = Vec::with_capacity(64 * 1024);
let mut local_completed = seg.completed;
loop {
if cancel.load(Ordering::SeqCst) {
return Err("已取消".to_string());
}
match stream.next().await {
Some(Ok(chunk)) => {
buf.extend_from_slice(&chunk);
// 积累到 64KB 再写入(减少 I/O 次数)
if buf.len() >= 64 * 1024 {
file.write_all(&buf)
.await
.map_err(|e| format!("写入文件失败: {}", e))?;
local_completed += buf.len() as u64;
progress.store(local_completed, Ordering::Relaxed);
// 限速
limiter.consume(buf.len() as u64).await;
buf.clear();
}
}
Some(Err(e)) => {
return Err(format!("读取数据失败: {}", e));
}
None => {
// 流结束,写入剩余数据
if !buf.is_empty() {
file.write_all(&buf)
.await
.map_err(|e| format!("写入文件失败: {}", e))?;
local_completed += buf.len() as u64;
progress.store(local_completed, Ordering::Relaxed);
limiter.consume(buf.len() as u64).await;
buf.clear();
}
break;
}
}
}
file.flush()
.await
.map_err(|e| format!("刷新文件失败: {}", e))?;
Ok(())
}
/// 从 Content-Disposition 头或 URL 提取文件名
fn extract_filename(url: &str, headers: &reqwest::header::HeaderMap) -> Option<String> {
// 优先从 Content-Disposition 提取
if let Some(cd) = headers.get("content-disposition") {
if let Ok(s) = cd.to_str() {
// filename*=UTF-8''xxx 或 filename="xxx"
if let Some(name) = parse_content_disposition(s) {
return Some(name);
}
}
}
// 从 URL 提取
if let Ok(u) = url::Url::parse(url) {
let path = u.path();
if let Some(name) = path.rsplit('/').next() {
if !name.is_empty() {
return Some(
percent_decode(name),
);
}
}
}
None
}
/// 解析 Content-Disposition 头中的文件名
fn parse_content_disposition(s: &str) -> Option<String> {
// 先尝试 filename*=UTF-8''xxx
if let Some(idx) = s.find("filename*=") {
let rest = &s[idx + "filename*=".len()..];
// 格式: UTF-8''xxx 或 ISO-8859-1''xxx
if let Some(start) = rest.find("''") {
let encoded = &rest[start + 2..];
// 取到分号或结尾
let encoded = encoded.split(';').next().unwrap_or(encoded).trim();
return Some(percent_decode(encoded.trim_matches('"')));
}
}
// 再尝试 filename="xxx" 或 filename=xxx
if let Some(idx) = s.find("filename=") {
let rest = &s[idx + "filename=".len()..];
let rest = rest.trim();
if rest.starts_with('"') {
let end = rest[1..].find('"').unwrap_or(rest.len() - 1);
return Some(rest[1..1 + end].to_string());
} else {
let name = rest.split(';').next().unwrap_or(rest).trim();
if !name.is_empty() {
return Some(name.to_string());
}
}
}
None
}
/// URL 百分号解码
fn percent_decode(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(byte) = u8::from_str_radix(
std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""),
16,
) {
result.push(byte as char);
i += 3;
continue;
}
}
result.push(bytes[i] as char);
i += 1;
}
result
}
/// 将文件大小划分为 N 个分段
pub fn split_segments(total_size: u64, num_connections: u32) -> Vec<Segment> {
if total_size == 0 || num_connections == 0 {
return vec![Segment {
index: 0,
start: 0,
end: 0,
completed: 0,
}];
}
// 每个分段至少 1MB,否则减少线程数
let min_segment_size = 1024 * 1024;
let actual_connections = {
let max_by_size = (total_size / min_segment_size).max(1) as u32;
num_connections.min(max_by_size)
};
let segment_size = total_size / actual_connections as u64;
let mut segments = Vec::with_capacity(actual_connections as usize);
let mut offset = 0u64;
for i in 0..actual_connections {
let start = offset;
let end = if i == actual_connections - 1 {
total_size - 1
} else {
offset + segment_size - 1
};
segments.push(Segment {
index: i,
start,
end,
completed: 0,
});
offset = end + 1;
}
segments
}
+15
View File
@@ -0,0 +1,15 @@
pub mod commands;
pub mod engine;
pub mod http_dl;
pub mod rate_limit;
pub mod server;
pub mod storage;
pub mod task;
pub use commands::{
downloader_add_task, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks,
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task,
downloader_resume_task, downloader_save_settings, downloader_status,
};
pub use engine::DownloadEngine;
pub use server::ExtensionServer;
@@ -0,0 +1,74 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// 令牌桶限速器(字节/秒,0=不限)
///
/// 滑动窗口算法:1 秒窗口内累计消耗字节数,超出限制时 sleep 到下一窗口。
pub struct RateLimiter {
/// 限速 bytes/s0=不限),用 Arc<AtomicU64> 以便运行时动态调整
limit: Arc<AtomicU64>,
/// 当前窗口已消耗字节
consumed: AtomicU64,
/// 窗口起始时间
window_start: Mutex<std::time::Instant>,
}
impl RateLimiter {
pub fn new(limit: u64) -> Self {
Self {
limit: Arc::new(AtomicU64::new(limit)),
consumed: AtomicU64::new(0),
window_start: Mutex::new(std::time::Instant::now()),
}
}
/// 动态设置限速(bytes/s0=不限)
pub fn set_limit(&self, limit: u64) {
self.limit.store(limit, Ordering::Relaxed);
}
/// 消费指定字节数,若超出限速则 sleep 等待
pub async fn consume(&self, bytes: u64) {
let limit = self.limit.load(Ordering::Relaxed);
if limit == 0 || bytes == 0 {
return;
}
// 尝试在当前窗口消费(用作用域确保 MutexGuard 在 await 前释放)
let over_limit = {
let now = std::time::Instant::now();
let mut start = self.window_start.lock().unwrap();
let elapsed = now.duration_since(*start);
// 窗口过期,重置
if elapsed >= Duration::from_secs(1) {
self.consumed.store(0, Ordering::Relaxed);
*start = now;
}
let current = self.consumed.fetch_add(bytes, Ordering::Relaxed) + bytes;
if current > limit {
Some(current - limit)
} else {
None
}
}; // MutexGuard 在此释放
// 超出限速,计算需要等待的时间
if let Some(over) = over_limit {
let wait_ms = (over as f64 / limit as f64 * 1000.0).ceil() as u64;
let wait = Duration::from_millis(wait_ms.min(1000));
tokio::time::sleep(wait).await;
}
}
}
/// 克隆限速器的共享句柄(共享同一限速状态)
impl Clone for RateLimiter {
fn clone(&self) -> Self {
// 注意:clone 的是限速配置,不是状态。新实例独立计数。
// 全局限速器应通过引用共享,而非 clone。
Self::new(self.limit.load(Ordering::Relaxed))
}
}
+139
View File
@@ -0,0 +1,139 @@
use std::collections::HashMap;
use std::net::SocketAddr;
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use super::engine::DownloadEngine;
use super::task::DownloadTask;
/// 扩展 HTTP API 服务器
pub struct ExtensionServer;
#[derive(Serialize)]
struct HealthResponse {
ok: bool,
version: &'static str,
}
#[derive(Deserialize)]
struct CreateDownloadRequest {
url: String,
#[serde(default)]
filename: Option<String>,
#[serde(default)]
dir: Option<String>,
#[serde(default)]
headers: HashMap<String, String>,
}
#[derive(Serialize)]
struct CreateDownloadResponse {
id: String,
}
#[derive(Serialize)]
struct ErrorResponse {
error: String,
}
impl ExtensionServer {
/// 启动 HTTP API 服务器(绑定到 127.0.0.1:port
pub async fn start(engine: DownloadEngine, port: u16, secret: String) {
let addr: SocketAddr = format!("127.0.0.1:{}", port).parse().expect("无效端口");
let app = Router::new()
.route("/health", get(health))
.route("/api/downloads", post(create_download).get(list_downloads))
.route("/api/downloads/:id", axum::routing::delete(remove_download))
.with_state(AppState { engine, secret });
let listener = match tokio::net::TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => {
eprintln!("[download_engine] 扩展 HTTP 服务启动失败 ({}): {}", addr, e);
return;
}
};
eprintln!("[download_engine] 扩展 HTTP 服务已启动: http://{}", addr);
if let Err(e) = axum::serve(listener, app).await {
eprintln!("[download_engine] 扩展 HTTP 服务异常: {}", e);
}
}
}
#[derive(Clone)]
struct AppState {
engine: DownloadEngine,
secret: String,
}
/// 鉴权检查:如果配置了 secret,校验 Bearer token
fn check_auth(headers: &HeaderMap, secret: &str) -> bool {
if secret.is_empty() {
return true;
}
if let Some(auth) = headers.get("authorization") {
if let Ok(s) = auth.to_str() {
if let Some(token) = s.strip_prefix("Bearer ") {
return token == secret;
}
}
}
false
}
async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
let _ = state; // 不需要鉴权
Json(HealthResponse {
ok: true,
version: "thing-download-engine/1.0",
})
}
async fn create_download(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreateDownloadRequest>,
) -> Result<Json<CreateDownloadResponse>, (StatusCode, Json<ErrorResponse>)> {
if !check_auth(&headers, &state.secret) {
return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() })));
}
match state.engine.add_task(req.url, req.filename, req.dir, req.headers).await {
Ok(id) => Ok(Json(CreateDownloadResponse { id })),
Err(e) => Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))),
}
}
async fn list_downloads(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Vec<DownloadTask>>, (StatusCode, Json<ErrorResponse>)> {
if !check_auth(&headers, &state.secret) {
return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() })));
}
Ok(Json(state.engine.get_tasks()))
}
async fn remove_download(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
if !check_auth(&headers, &state.secret) {
return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() })));
}
match state.engine.remove_task(&id, false) {
Ok(()) => Ok(StatusCode::NO_CONTENT),
Err(e) => Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: e }))),
}
}
+82
View File
@@ -0,0 +1,82 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use super::task::{DownloadTask, DownloaderSettings};
/// 引擎持久化状态(序列化到 engine_state.json
#[derive(Serialize, Deserialize)]
pub struct EngineState {
/// 所有任务(active + queued + paused + complete + error
#[serde(default)]
pub tasks: Vec<DownloadTask>,
/// 下载设置
#[serde(default)]
pub settings: DownloaderSettings,
/// 自增 ID 计数器(不序列化为 hex,存原始数字)
#[serde(default)]
pub next_id: u64,
}
impl Default for EngineState {
fn default() -> Self {
Self {
tasks: Vec::new(),
settings: DownloaderSettings::default(),
next_id: 1,
}
}
}
/// 存储管理器:负责加载/保存引擎状态到 JSON 文件
pub struct Storage {
state_path: PathBuf,
/// ID 计数器(内存中维护,与 EngineState.next_id 同步)
pub id_counter: AtomicU64,
}
impl Storage {
pub fn new(data_dir: PathBuf) -> Self {
let state_path = data_dir.join("engine_state.json");
let existing = Self::load_raw(&state_path);
let next_id = existing.as_ref().map(|s| s.next_id).unwrap_or(1);
Self {
state_path,
id_counter: AtomicU64::new(next_id),
}
}
/// 生成下一个任务 ID(16 位 hex 字符串)
pub fn next_task_id(&self) -> String {
let id = self.id_counter.fetch_add(1, Ordering::SeqCst);
format!("{:016x}", id)
}
/// 从磁盘加载状态
fn load_raw(path: &PathBuf) -> Option<EngineState> {
let raw = fs::read_to_string(path).ok()?;
serde_json::from_str::<EngineState>(&raw).ok()
}
/// 加载完整状态(若文件不存在返回默认值)
pub fn load(&self) -> EngineState {
Self::load_raw(&self.state_path).unwrap_or_default()
}
/// 保存状态到磁盘
pub fn save(&self, mut state: EngineState) {
// 同步 ID 计数器
state.next_id = self.id_counter.load(Ordering::SeqCst);
match serde_json::to_string_pretty(&state) {
Ok(json) => {
if let Err(e) = fs::write(&self.state_path, json) {
eprintln!("[download_engine] 保存状态失败: {}", e);
}
}
Err(e) => {
eprintln!("[download_engine] 序列化状态失败: {}", e);
}
}
}
}
+177
View File
@@ -0,0 +1,177 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// 任务状态
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum TaskStatus {
/// 排队等待(并发数已满)
Queued,
/// 下载中
Active,
/// 已暂停
Paused,
/// 已完成
Complete,
/// 错误
Error,
}
/// 下载分段(多线程 Range 下载 / 断点续传用)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Segment {
/// 分段索引
pub index: u32,
/// 起始字节(含)
pub start: u64,
/// 结束字节(含)
pub end: u64,
/// 已下载字节
pub completed: u64,
}
impl Segment {
/// 该分段总长度
pub fn len(&self) -> u64 {
self.end.saturating_sub(self.start) + 1
}
/// 是否已完成
pub fn is_done(&self) -> bool {
self.completed >= self.len()
}
}
/// 下载任务
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadTask {
/// 任务 ID(自增 hex 字符串)
pub id: String,
/// 下载地址
pub url: String,
/// 文件名
pub filename: String,
/// 保存目录(绝对路径)
pub dir: String,
/// 状态
pub status: TaskStatus,
/// 文件总大小(字节),0=未知
pub total_size: u64,
/// 已下载字节
pub completed_size: u64,
/// 当前下载速度 bytes/s
pub speed: u64,
/// 服务器是否支持断点续传
pub supports_resume: bool,
/// 分段信息
#[serde(default)]
pub segments: Vec<Segment>,
/// 错误信息
#[serde(default)]
pub error: Option<String>,
/// 创建时间(Unix 时间戳,毫秒)
pub created_at: i64,
/// 自定义请求头(Cookie / Referer 等)
#[serde(default)]
pub headers: HashMap<String, String>,
}
impl DownloadTask {
/// 文件完整路径(最终文件名)
pub fn file_path(&self) -> std::path::PathBuf {
std::path::PathBuf::from(&self.dir).join(&self.filename)
}
/// 下载临时文件路径(下载未完成时使用,完成后重命名为 file_path)
pub fn temp_file_path(&self) -> std::path::PathBuf {
std::path::PathBuf::from(&self.dir).join(format!("{}.thingdl", self.filename))
}
/// 更新已下载总量(聚合所有分段)
pub fn recalc_completed(&mut self) {
if self.segments.is_empty() {
return;
}
self.completed_size = self.segments.iter().map(|s| s.completed).sum();
}
}
/// 下载设置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloaderSettings {
/// 下载目录
#[serde(default = "default_download_dir")]
pub download_dir: String,
/// 最大同时下载数
#[serde(default = "default_max_concurrent")]
pub max_concurrent: u32,
/// 单任务最大连接数(多线程分段数)
#[serde(default = "default_max_connections")]
pub max_connections: u32,
/// 断点续传
#[serde(default = "default_true")]
pub continue_download: bool,
/// 全局速度限制 KB/s(0=不限)
#[serde(default)]
pub global_speed_limit: u64,
/// 扩展 HTTP API 端口
#[serde(default = "default_extension_port")]
pub extension_port: u16,
/// 扩展认证密钥(空=不认证)
#[serde(default)]
pub extension_secret: String,
/// 删除任务时是否同时删除已下载的文件
#[serde(default)]
pub delete_files_on_remove: bool,
}
fn default_max_concurrent() -> u32 {
5
}
fn default_max_connections() -> u32 {
8
}
fn default_true() -> bool {
true
}
fn default_extension_port() -> u16 {
16800
}
fn default_download_dir() -> String {
dirs::download_dir()
.or_else(|| {
std::env::var("USERPROFILE")
.ok()
.map(|p| std::path::PathBuf::from(p).join("Downloads"))
})
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "downloads".to_string())
}
impl Default for DownloaderSettings {
fn default() -> Self {
Self {
download_dir: default_download_dir(),
max_concurrent: default_max_concurrent(),
max_connections: default_max_connections(),
continue_download: true,
global_speed_limit: 0,
extension_port: default_extension_port(),
extension_secret: String::new(),
delete_files_on_remove: false,
}
}
}
/// HEAD/Range 探测结果
pub struct ProbeResult {
/// 文件大小(字节),None=未知
pub total_size: Option<u64>,
/// 是否支持 Range 请求
pub supports_resume: bool,
/// 从 Content-Disposition 或 URL 推断的文件名
pub filename: Option<String>,
}
+35 -49
View File
@@ -1,17 +1,15 @@
use tauri::Manager;
mod aria2_manager;
mod download_engine;
mod logger;
mod mihomo_manager;
mod process_manager;
use aria2_manager::{
Aria2Manager, downloader_add_uri, downloader_change_global_option, downloader_check_kernel_update,
downloader_get_active, downloader_get_global_stat, downloader_get_rpc_info, downloader_get_settings,
downloader_get_status, downloader_get_stopped, downloader_get_waiting, downloader_install_kernel,
downloader_kernel_info, downloader_open_dir, downloader_open_url, downloader_pause, downloader_remove, downloader_restart, downloader_save_settings,
downloader_start, downloader_status, downloader_stop, downloader_unpause, downloader_update_kernel,
downloader_version,
use download_engine::{
DownloadEngine, ExtensionServer,
downloader_add_task, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks,
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task,
downloader_resume_task, downloader_save_settings, downloader_status,
};
use logger::{
clear_logs, get_log_info, get_logs, log_message, LogManager,
@@ -37,13 +35,13 @@ fn greet(name: &str) -> String {
fn quit_app(
state: tauri::State<'_, ProcessManager>,
mihomo: tauri::State<'_, MihomoManager>,
aria2: tauri::State<'_, Aria2Manager>,
engine: tauri::State<'_, DownloadEngine>,
app: tauri::AppHandle,
) {
// 退出前清理系统代理,避免遗留导致网络问题
mihomo.cleanup_on_exit();
// 退出前让 aria2 优雅关闭(保存 session2s 超时)
aria2.cleanup_on_exit();
// 退出前保存下载引擎状态
engine.cleanup_on_exit();
// 停止所有子进程(同步 kill + 带超时的 wait,确保进程真正终止)
state.stop_all();
// 通过 app.exit 触发 RunEvent::ExitRequested,统一退出路径
@@ -93,28 +91,15 @@ pub fn run() {
proxy_set_system_proxy,
proxy_clear_system_proxy,
proxy_get_system_proxy,
downloader_get_tasks,
downloader_add_task,
downloader_pause_task,
downloader_resume_task,
downloader_remove_task,
downloader_get_settings,
downloader_save_settings,
downloader_kernel_info,
downloader_check_kernel_update,
downloader_update_kernel,
downloader_install_kernel,
downloader_status,
downloader_start,
downloader_stop,
downloader_restart,
downloader_version,
downloader_get_global_stat,
downloader_get_active,
downloader_get_waiting,
downloader_get_stopped,
downloader_get_status,
downloader_add_uri,
downloader_pause,
downloader_unpause,
downloader_remove,
downloader_change_global_option,
downloader_get_rpc_info,
downloader_get_extension_info,
downloader_open_dir,
downloader_open_url
])
@@ -132,12 +117,25 @@ pub fn run() {
.path()
.app_data_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("."));
let mihomo = MihomoManager::new(app_data_dir.clone());
app.manage(mihomo);
// 初始化 Aria2Manager,数据目录: {app_data_dir}/downloader/
let aria2 = Aria2Manager::new(app_data_dir);
app.manage(aria2);
// 初始化 DownloadEngine,数据目录: {app_data_dir}/downloader/
let engine = DownloadEngine::new(
app_data_dir.join("downloader"),
app.handle().clone(),
);
let settings = engine.get_settings();
app.manage(engine.clone());
// 启动扩展 HTTP API 服务器
let server_engine = engine.clone();
let server_port = settings.extension_port;
let server_secret = settings.extension_secret.clone();
tauri::async_runtime::spawn(async move {
ExtensionServer::start(server_engine, server_port, server_secret).await;
});
let open = tauri::menu::MenuItem::with_id(app, "open", "设置", true, None::<&str>)?;
let quit = tauri::menu::MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
@@ -155,13 +153,11 @@ pub fn run() {
}
}
"quit" => {
// 退出前清理系统代理 + 优雅关闭 aria2 + 停止所有子进程
// 直接调用 cleanup + stop_all + exitquit_app 命令是给前端用的)
if let Some(mihomo) = app.try_state::<MihomoManager>() {
mihomo.cleanup_on_exit();
}
if let Some(aria2) = app.try_state::<Aria2Manager>() {
aria2.cleanup_on_exit();
if let Some(engine) = app.try_state::<DownloadEngine>() {
engine.cleanup_on_exit();
}
if let Some(pm) = app.try_state::<ProcessManager>() {
pm.stop_all();
@@ -195,13 +191,6 @@ pub fn run() {
}
}
// 应用启动时自动启动 aria2(如果用户在设置中开启了自动启动)
if let Some(aria2) = app.try_state::<Aria2Manager>() {
if let Some(pm) = app.try_state::<ProcessManager>() {
aria2.auto_start_on_launch(app.handle(), &pm);
}
}
Ok(())
})
.on_window_event(|window, event| {
@@ -213,15 +202,12 @@ pub fn run() {
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app, event| {
// 退出请求兜底:捕获所有退出路径(app.exit、窗口全部关闭、系统信号等)
// 确保 mihomo/aria2 子进程在任何情况下都被清理
// 注:quit_app 命令和托盘菜单已主动调用 cleanup,这里作为二次保险
if let tauri::RunEvent::ExitRequested { .. } = event {
if let Some(mihomo) = app.try_state::<MihomoManager>() {
mihomo.cleanup_on_exit();
}
if let Some(aria2) = app.try_state::<Aria2Manager>() {
aria2.cleanup_on_exit();
if let Some(engine) = app.try_state::<DownloadEngine>() {
engine.cleanup_on_exit();
}
if let Some(pm) = app.try_state::<ProcessManager>() {
pm.stop_all();
+31 -4
View File
@@ -25,7 +25,9 @@ const toNavModule = (meta: ModuleMeta): NavModule => ({
icon: meta.icon
})
const activeModule = ref('proxy')
/** 上次激活的模块 IDlocalStorage 持久化) */
const LAST_MODULE_KEY = 'thing_last_module'
const activeModule = ref('')
const activeComponent = shallowRef<Component | null>(null)
@@ -64,11 +66,13 @@ const handleModuleChange = (moduleId: string) => {
prevConfig?.lifecycle?.onDeactivate?.()
activeModule.value = moduleId
localStorage.setItem(LAST_MODULE_KEY, moduleId)
loadModule(moduleId)
}
const handleSearch = (moduleId: string) => {
activeModule.value = moduleId
localStorage.setItem(LAST_MODULE_KEY, moduleId)
loadModule(moduleId)
}
@@ -81,6 +85,8 @@ const getFallbackModule = () => {
}
watch(() => appStore.enabledModules.length, () => {
// 启动期间 activeModule 尚未确定,跳过
if (!activeModule.value) return
const enabledIds = appStore.enabledModules.map(m => m.id)
if (activeModule.value !== 'settings' && !enabledIds.includes(activeModule.value)) {
const fallback = getFallbackModule()
@@ -89,9 +95,30 @@ watch(() => appStore.enabledModules.length, () => {
}
})
onMounted(() => {
loadModule(activeModule.value)
appStore.init().catch(e => console.error('App init error:', e))
/** 计算启动时应打开的默认模块:优先上次记忆,其次排序第一个 */
const resolveDefaultModule = (): string => {
const enabledIds = appStore.enabledModules.map(m => m.id)
const allMetas = moduleRegistry.getAllMetas()
// 尝试上次记忆的模块
const lastModule = localStorage.getItem(LAST_MODULE_KEY)
if (lastModule) {
const meta = allMetas.find(m => m.id === lastModule)
if (meta && (meta.builtin || enabledIds.includes(lastModule))) {
return lastModule
}
}
// 回退到排序第一个可用模块(settings 除外)
const first = availableModules.value.find(m => m.id !== 'settings')
return first?.id || 'settings'
}
onMounted(async () => {
await appStore.init().catch(e => console.error('App init error:', e))
const defaultModule = resolveDefaultModule()
activeModule.value = defaultModule
loadModule(defaultModule)
})
</script>
+42 -10
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onUnmounted } from 'vue'
import { Search, Minus, Square, X, Settings, ChevronRight } from '@lucide/vue'
import { Input } from '@/components/ui/input'
import { getCurrentWindow } from '@tauri-apps/api/window'
@@ -72,17 +72,46 @@ const maximize = async () => {
// 期间用 CSS 覆盖 :hover 样式,避免按钮残留高亮(webview 隐藏时 mouseleave 不触发)
const hoverSuppressed = ref(true)
// 恢复 hover 的定时器引用,便于在鼠标进入时提前恢复
let restoreHoverTimer: ReturnType<typeof setTimeout> | null = null
/** 窗口重新显示后恢复 hover 状态 */
const restoreHover = (delay = 200) => {
if (restoreHoverTimer) clearTimeout(restoreHoverTimer)
restoreHoverTimer = setTimeout(() => {
hoverSuppressed.value = false
}, delay)
}
/** 鼠标移动时恢复 hover(比定时器更可靠,webview 隐藏时 mouseleave 不触发,:hover 被冻结) */
const handleFirstMouseMove = () => {
if (hoverSuppressed.value) {
hoverSuppressed.value = false
if (restoreHoverTimer) {
clearTimeout(restoreHoverTimer)
restoreHoverTimer = null
}
}
window.removeEventListener('mousemove', handleFirstMouseMove)
}
/** 注册鼠标移动监听(窗口即将隐藏时调用,为下次显示做准备) */
const armMouseMoveRestore = () => {
window.removeEventListener('mousemove', handleFirstMouseMove)
window.addEventListener('mousemove', handleFirstMouseMove)
}
const close = async () => {
// 隐藏前立即抑制 hover,避免冻结的 :hover 状态残留到下次显示
hoverSuppressed.value = true
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
// 注册鼠标移动监听,窗口重新显示后第一次移动即恢复 hover
armMouseMoveRestore()
await tauriWindow?.hide()
}
// 窗口重新获得焦点时,先保持抑制(防止冻结的 hover 显示),
// 短暂延迟后恢复 hover(让真实鼠标位置重新接管)
if (tauriWindow) {
tauriWindow.onFocusChanged(({ payload: focused }) => {
if (focused) {
@@ -90,10 +119,10 @@ if (tauriWindow) {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
// 100ms 后恢复 hover,足够让浏览器重置伪类状态
setTimeout(() => {
hoverSuppressed.value = false
}, 100)
// 200ms 后恢复 hover,足够让浏览器重置伪类状态
restoreHover(200)
// 同时注册鼠标移动监听作为后备(定时器可能不够,鼠标移动更可靠)
armMouseMoveRestore()
} else {
hoverSuppressed.value = true
}
@@ -101,9 +130,12 @@ if (tauriWindow) {
}
// 初始时窗口已显示,恢复 hover
setTimeout(() => {
hoverSuppressed.value = false
}, 200)
restoreHover(300)
onUnmounted(() => {
window.removeEventListener('mousemove', handleFirstMouseMove)
if (restoreHoverTimer) clearTimeout(restoreHoverTimer)
})
const handleBlur = () => {
setTimeout(() => {
File diff suppressed because it is too large Load Diff
+7 -29
View File
@@ -1,12 +1,11 @@
import type { ModuleConfig } from '@/types/module'
import type { SearchIndexItem } from '@/stores/searchIndex'
import { invoke } from '@tauri-apps/api/core'
const searchItems: SearchIndexItem[] = [
{
title: '下载任务',
description: '查看与管理下载任务',
keywords: ['下载', 'download', '任务', 'task', 'aria2']
keywords: ['下载', 'download', '任务', 'task']
},
{
title: '添加下载',
@@ -15,8 +14,8 @@ const searchItems: SearchIndexItem[] = [
},
{
title: '下载设置',
description: '配置下载目录、速度限制与 RPC',
keywords: ['设置', 'setting', 'rpc', '速度', '端口', '目录']
description: '配置下载目录、并发数与速度限制',
keywords: ['设置', 'setting', '速度', '目录', '并发']
},
{
title: '浏览器扩展',
@@ -29,39 +28,18 @@ export const moduleConfig: ModuleConfig = {
id: 'downloader',
name: '下载器',
icon: 'downloader',
description: '基于 aria2 的多线程 HTTP 下载管理',
description: '多线程 HTTP 下载管理',
category: 'network',
defaultEnabled: true,
loader: () => import('./DownloaderModule.vue'),
searchItems,
// 进程由 Aria2Manager 通过 ProcessManager 统一管理(id='downloader'),
// executable/args 在运行时由后端确定,此处仅声明 hasProcess 以便禁用时自动停止。
process: {
name: 'aria2c',
executable: '',
autoStart: false,
restartOnCrash: true,
maxRestarts: 3
},
// 下载引擎在进程内运行,无需外部进程管理
lifecycle: {
// 启用模块时若用户开启了"自动启动",则随模块启用而运行 aria2
onEnable: async () => {
try {
const s = await invoke<{ autoStart?: boolean }>('downloader_get_settings')
if (s.autoStart) {
await invoke('downloader_start')
}
} catch {
/* 忽略:可能内核未安装 */
}
// 引擎在应用启动时已自动运行,模块启用时无需额外操作
},
// 禁用模块时停止 aria2 进程(cleanup_on_exit 会在应用退出时调用)
onDisable: async () => {
try {
await invoke('downloader_stop')
} catch {
/* 忽略:可能进程未运行 */
}
// 模块禁用时不停止引擎(引擎在应用退出时统一清理)
}
},
order: 50
+29 -14
View File
@@ -322,6 +322,7 @@ const init = async () => {
startAutoSwitch()
}
}
store.initialized = true
}
onMounted(() => {
@@ -883,12 +884,19 @@ const saveSettingsForm = async () => {
<CardContent class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<span class="text-muted-foreground">状态</span>
<Badge v-if="store.kernel?.exists" variant="default" class="gap-1 bg-emerald-500 hover:bg-emerald-500">
<Check class="size-3" />已安装
</Badge>
<Badge v-else variant="destructive" class="gap-1">
<AlertCircle class="size-3" />未安装
</Badge>
<template v-if="!store.initialized">
<span class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 class="size-3 animate-spin" />加载中...
</span>
</template>
<template v-else>
<Badge v-if="store.kernel?.exists" variant="default" class="gap-1 bg-emerald-500 hover:bg-emerald-500">
<Check class="size-3" />已安装
</Badge>
<Badge v-else variant="destructive" class="gap-1">
<AlertCircle class="size-3" />未安装
</Badge>
</template>
</div>
<div v-if="store.kernel?.exists" class="flex items-center justify-between">
<span class="text-muted-foreground">当前版本</span>
@@ -933,9 +941,9 @@ const saveSettingsForm = async () => {
</div>
</div>
<!-- 首次安装区块仅在内核未安装且不在安装中时显示 -->
<!-- 首次安装区块仅在内核未安装且不在安装中且已初始化时显示 -->
<div
v-if="!store.kernel?.exists && !store.installProgress"
v-if="store.initialized && !store.kernel?.exists && !store.installProgress"
class="space-y-2 pt-2 border-t"
>
<div class="space-y-1.5">
@@ -1035,12 +1043,19 @@ const saveSettingsForm = async () => {
<CardContent class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<span class="text-muted-foreground">mihomo</span>
<span v-if="running" class="flex items-center gap-1 text-emerald-500">
<span class="size-2 rounded-full bg-emerald-500" />运行中
</span>
<span v-else class="flex items-center gap-1 text-muted-foreground">
<span class="size-2 rounded-full bg-muted-foreground" />已停止
</span>
<template v-if="!store.initialized">
<span class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 class="size-3 animate-spin" />加载中...
</span>
</template>
<template v-else>
<span v-if="running" class="flex items-center gap-1 text-emerald-500">
<span class="size-2 rounded-full bg-emerald-500" />运行中
</span>
<span v-else class="flex items-center gap-1 text-muted-foreground">
<span class="size-2 rounded-full bg-muted-foreground" />已停止
</span>
</template>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground">PID</span>
+162 -321
View File
@@ -8,285 +8,141 @@ const logger = createLogger('downloader')
// ===== 与 Rust 端对应的数据结构(camelCase =====
export type TaskStatus = 'queued' | 'active' | 'paused' | 'complete' | 'error'
export interface Segment {
index: number
start: number
end: number
completed: number
}
export interface DownloadTask {
id: string
url: string
filename: string
dir: string
status: TaskStatus
totalSize: number
completedSize: number
speed: number
supportsResume: boolean
segments: Segment[]
error: string | null
createdAt: number
headers: Record<string, string>
}
export interface DownloaderSettings {
rpcPort: number
rpcSecret: string
downloadDir: string
maxConcurrent: number
maxConnectionPerServer: number
split: number
maxConnections: number
continueDownload: boolean
autoStart: boolean
speedLimit: number
kernelMirrors: string[]
}
/** 内核安装进度事件载荷,对应 Rust 端 InstallProgress */
export interface InstallProgress {
/** downloading | extracting | replacing | done | error */
stage: string
percent: number
downloadedBytes: number
totalBytes: number | null
message: string
}
export interface KernelInfo {
path: string
exists: boolean
version: string | null
}
export interface KernelUpdateInfo {
currentVersion: string | null
latestVersion: string
downloadUrl: string
hasUpdate: boolean
globalSpeedLimit: number
extensionPort: number
extensionSecret: string
deleteFilesOnRemove: boolean
}
export interface DownloaderStatus {
running: boolean
pid: number | null
restartCount: number
}
export interface RpcInfo {
rpcUrl: string
rpcSecret: string
extensionPath: string | null
export interface ExtensionInfo {
url: string
port: number
secret: string
hasSecret: boolean
}
/** aria2 任务文件信息 */
export interface Aria2File {
path: string
length: number
completedLength: number
selected: boolean
/** 下载进度事件载荷 */
interface ProgressPayload {
id: string
completedSize: number
totalSize: number
speed: number
status: TaskStatus
}
/** aria2 任务状态(tellActive/tellWaiting/tellStopped 返回项) */
export interface Aria2Task {
gid: string
status: 'active' | 'waiting' | 'paused' | 'complete' | 'removed' | 'error' | string
totalLength: string
completedLength: string
downloadSpeed: string
uploadSpeed: string
connections: string
dir: string
files?: Aria2File[]
bittorrent?: { info?: { name?: string } } | null
errorCode?: string
errorMessage?: string
}
/** 全局统计 */
export interface GlobalStat {
downloadSpeed: string
uploadSpeed: string
numActive: string
numWaiting: string
numStopped: string
numStoppedTotal: string
}
export interface Aria2Version {
version: string
enabledFeatures?: string[]
/** 下载完成事件载荷 */
interface CompletePayload {
id: string
filename: string
status: TaskStatus
error: string | null
}
export const useDownloaderStore = defineStore('downloader', () => {
const kernel = ref<KernelInfo | null>(null)
const status = ref<DownloaderStatus>({ running: false, pid: null, restartCount: 0 })
const version = ref<string>('')
const tasks = ref<DownloadTask[]>([])
const status = ref<DownloaderStatus>({ running: false })
const settings = ref<DownloaderSettings | null>(null)
const rpcInfo = ref<RpcInfo | null>(null)
const extensionInfo = ref<ExtensionInfo | null>(null)
const activeTasks = ref<Aria2Task[]>([])
const waitingTasks = ref<Aria2Task[]>([])
const stoppedTasks = ref<Aria2Task[]>([])
const globalStat = ref<GlobalStat | null>(null)
// ===== 任务历史持久化(localStorage =====
// 即使 aria2 未启动,也能展示最近一次的任务快照
const HISTORY_KEY = 'thing.downloader.taskHistory'
const HISTORY_MAX = 200 // 最多保留 200 条历史记录
/** 将当前任务快照保存到 localStorage(合并 active+waiting+stopped,按 gid 去重) */
const persistHistory = () => {
try {
const map = new Map<string, Aria2Task>()
// 先读已有历史,作为基底
const raw = localStorage.getItem(HISTORY_KEY)
if (raw) {
const existing: Aria2Task[] = JSON.parse(raw)
for (const t of existing) map.set(t.gid, t)
}
// 用最新任务覆盖(active/waiting/stopped 都是最新的)
for (const t of activeTasks.value) map.set(t.gid, t)
for (const t of waitingTasks.value) map.set(t.gid, t)
for (const t of stoppedTasks.value) map.set(t.gid, t)
// 限制条数:优先保留 stopped(已完成/错误),其次 waiting,最后 active
const all = Array.from(map.values())
const priority = { complete: 0, error: 0, removed: 1, active: 2, waiting: 2, paused: 2 } as Record<string, number>
all.sort((a, b) => (priority[a.status] ?? 3) - (priority[b.status] ?? 3))
const trimmed = all.slice(0, HISTORY_MAX)
localStorage.setItem(HISTORY_KEY, JSON.stringify(trimmed))
} catch (e) {
logger.error('保存任务历史失败: ' + e)
}
}
/** 从 localStorage 加载任务历史,填充到 stoppedTasks(作为历史展示) */
const loadHistory = () => {
try {
const raw = localStorage.getItem(HISTORY_KEY)
if (!raw) return
const history: Aria2Task[] = JSON.parse(raw)
if (!Array.isArray(history)) return
// 仅在没有实时任务时填充(避免覆盖实时数据)
if (stoppedTasks.value.length === 0) {
stoppedTasks.value = history
}
} catch (e) {
logger.error('加载任务历史失败: ' + e)
}
}
// ===== 内核安装进度 =====
const installing = ref(false)
const installProgress = ref<InstallProgress | null>(null)
// 事件监听器
let progressUnlisten: UnlistenFn | null = null
let completeUnlisten: UnlistenFn | null = null
let addedUnlisten: UnlistenFn | null = null
/** 内核信息(同时尝试从 resource 提取到 cores/ */
const refreshKernel = async () => {
// ===== 任务列表 =====
const refreshTasks = async () => {
try {
kernel.value = await invoke<KernelInfo>('downloader_kernel_info')
tasks.value = await invoke<DownloadTask[]>('downloader_get_tasks')
} catch (e) {
logger.error('获取内核信息失败: ' + e)
logger.error('获取任务列表失败: ' + e)
}
return kernel.value
return tasks.value
}
/** 刷新进程状态 */
const refreshStatus = async () => {
try {
status.value = await invoke<DownloaderStatus>('downloader_status')
} catch (e) {
logger.error('获取进程状态失败: ' + e)
}
return status.value
}
const start = async () => {
await invoke('downloader_start')
await refreshStatus()
}
const stop = async () => {
await invoke('downloader_stop')
await refreshStatus()
}
const restart = async () => {
await invoke('downloader_restart')
await refreshStatus()
}
/** 等待 aria2 RPC 就绪(轮询 version 接口,最多等 10 秒) */
const waitForApi = async (timeoutMs = 10000): Promise<boolean> => {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
try {
await invoke<Aria2Version>('downloader_version')
return true
} catch {
await new Promise((r) => setTimeout(r, 500))
}
}
return false
}
/** 获取 aria2 版本(仅运行时可用) */
const refreshVersion = async () => {
try {
const v = await invoke<Aria2Version>('downloader_version')
version.value = v.version
} catch {
version.value = ''
/** 根据进度事件更新单个任务(不触发完整刷新) */
const updateTaskProgress = (payload: ProgressPayload) => {
const task = tasks.value.find((t) => t.id === payload.id)
if (task) {
task.completedSize = payload.completedSize
task.totalSize = payload.totalSize
task.speed = payload.speed
task.status = payload.status
}
}
// ---------- 任务列表 ----------
const refreshActive = async () => {
try {
const res = await invoke<Aria2Task[]>('downloader_get_active')
activeTasks.value = res ?? []
} catch (e) {
logger.error('获取活跃任务失败: ' + e)
}
return activeTasks.value
/** 下载完成事件处理 */
const handleTaskComplete = async (_payload: CompletePayload) => {
// 刷新任务列表以获取最终状态
await refreshTasks()
}
const refreshWaiting = async () => {
try {
const res = await invoke<Aria2Task[]>('downloader_get_waiting')
waitingTasks.value = res ?? []
} catch (e) {
logger.error('获取等待任务失败: ' + e)
}
return waitingTasks.value
// ===== 任务操作 =====
const addTask = async (
url: string,
filename?: string,
dir?: string,
headers?: Record<string, string>
): Promise<string> => {
const id = await invoke<string>('downloader_add_task', {
url,
filename: filename || null,
dir: dir || null,
headers: headers || null
})
await refreshTasks()
return id
}
const refreshStopped = async () => {
try {
const res = await invoke<Aria2Task[]>('downloader_get_stopped')
stoppedTasks.value = res ?? []
} catch (e) {
logger.error('获取已完成任务失败: ' + e)
}
return stoppedTasks.value
const pauseTask = async (id: string) => {
await invoke('downloader_pause_task', { id })
await refreshTasks()
}
/** 刷新全部任务(active + waiting + stopped */
const refreshAllTasks = async () => {
await Promise.all([refreshActive(), refreshWaiting(), refreshStopped()])
// 刷新后持久化历史快照
persistHistory()
const resumeTask = async (id: string) => {
await invoke('downloader_resume_task', { id })
await refreshTasks()
}
const refreshGlobalStat = async () => {
try {
globalStat.value = await invoke<GlobalStat>('downloader_get_global_stat')
} catch (e) {
logger.error('获取全局统计失败: ' + e)
}
return globalStat.value
const removeTask = async (id: string, deleteFiles = false) => {
await invoke('downloader_remove_task', { id, deleteFiles })
await refreshTasks()
}
// ---------- 任务操作 ----------
const addUri = async (uris: string[], options?: Record<string, unknown>) => {
const opts = options ? (JSON.parse(JSON.stringify(options)) as unknown) : undefined
return await invoke<string>('downloader_add_uri', { uris, options: opts })
}
const pauseTask = async (gid: string) => {
await invoke('downloader_pause', { gid })
}
const unpauseTask = async (gid: string) => {
await invoke('downloader_unpause', { gid })
}
const removeTask = async (gid: string) => {
await invoke('downloader_remove', { gid })
}
const changeGlobalOption = async (options: Record<string, string>) => {
await invoke('downloader_change_global_option', { options })
}
// ---------- 设置 ----------
// ===== 设置 =====
const loadSettings = async () => {
settings.value = await invoke<DownloaderSettings>('downloader_get_settings')
return settings.value
@@ -297,107 +153,92 @@ export const useDownloaderStore = defineStore('downloader', () => {
settings.value = s
}
// ---------- RPC 信息 ----------
const loadRpcInfo = async () => {
rpcInfo.value = await invoke<RpcInfo>('downloader_get_rpc_info')
return rpcInfo.value
}
// ---------- 内核更新 / 安装 ----------
const checkKernelUpdate = async (): Promise<KernelUpdateInfo> => {
return await invoke<KernelUpdateInfo>('downloader_check_kernel_update')
}
const updateKernel = async (mirrorPrefix: string = '') => {
await invoke('downloader_update_kernel', { mirrorPrefix })
await refreshKernel()
}
/**
* 首次安装内核:调用后端 install_kernel,监听 downloader-kernel-install-progress 事件更新进度
* @param mirrorPrefix 镜像源前缀(空串=GitHub 直连)
*/
const installKernel = async (mirrorPrefix: string = ''): Promise<void> => {
if (installing.value) return
installing.value = true
installProgress.value = {
stage: 'downloading',
percent: 0,
downloadedBytes: 0,
totalBytes: null,
message: '准备开始下载...'
// ===== 状态 =====
const refreshStatus = async () => {
try {
status.value = await invoke<DownloaderStatus>('downloader_status')
} catch (e) {
logger.error('获取状态失败: ' + e)
}
return status.value
}
// ===== 扩展信息 =====
const loadExtensionInfo = async () => {
extensionInfo.value = await invoke<ExtensionInfo>('downloader_get_extension_info')
return extensionInfo.value
}
// ===== 事件监听 =====
const startEventListeners = async () => {
if (progressUnlisten && completeUnlisten && addedUnlisten) return
if (!progressUnlisten) {
progressUnlisten = await listen<InstallProgress>('downloader-kernel-install-progress', (e) => {
installProgress.value = e.payload
progressUnlisten = await listen<ProgressPayload>('download-progress', (e) => {
updateTaskProgress(e.payload)
})
}
try {
await invoke('downloader_install_kernel', { mirrorPrefix })
await refreshKernel()
} catch (e) {
logger.error('内核安装失败: ' + e)
throw e
} finally {
installing.value = false
if (progressUnlisten) {
progressUnlisten()
progressUnlisten = null
}
if (!completeUnlisten) {
completeUnlisten = await listen<CompletePayload>('download-complete', (e) => {
handleTaskComplete(e.payload)
})
}
if (!addedUnlisten) {
addedUnlisten = await listen<{ id: string }>('download-added', () => {
// 新任务加入(可能是扩展通过 HTTP API 添加的),刷新任务列表
refreshTasks()
})
}
}
const clearInstallProgress = () => {
installProgress.value = null
const stopEventListeners = () => {
if (progressUnlisten) {
progressUnlisten()
progressUnlisten = null
}
if (completeUnlisten) {
completeUnlisten()
completeUnlisten = null
}
if (addedUnlisten) {
addedUnlisten()
addedUnlisten = null
}
}
/** 用系统资源管理器打开目录(Rust 端绕过 opener scope 限制) */
// ===== 初始化 =====
const init = async () => {
await Promise.all([refreshStatus(), loadSettings(), loadExtensionInfo(), refreshTasks()])
await startEventListeners()
}
// ===== 工具函数 =====
const openDir = (path: string) => invoke<void>('downloader_open_dir', { path })
return {
// state
kernel,
tasks,
status,
version,
settings,
rpcInfo,
activeTasks,
waitingTasks,
stoppedTasks,
globalStat,
installing,
installProgress,
// kernel & process
refreshKernel,
refreshStatus,
start,
stop,
restart,
waitForApi,
refreshVersion,
extensionInfo,
// tasks
refreshActive,
refreshWaiting,
refreshStopped,
refreshAllTasks,
refreshGlobalStat,
addUri,
refreshTasks,
addTask,
pauseTask,
unpauseTask,
resumeTask,
removeTask,
changeGlobalOption,
// settings
loadSettings,
saveSettings,
// rpc info
loadRpcInfo,
// kernel update / install
checkKernelUpdate,
updateKernel,
installKernel,
clearInstallProgress,
openDir,
loadHistory,
persistHistory
// status
refreshStatus,
// extension
loadExtensionInfo,
// events
startEventListeners,
stopEventListeners,
// init
init,
// utils
openDir
}
})
+4
View File
@@ -98,6 +98,9 @@ export const useProxyStore = defineStore('proxy', () => {
const settings = ref<ProxySettings | null>(null)
const systemProxy = ref(false)
/** 是否已完成首次加载(避免初始 null/false 导致闪烁误导状态) */
const initialized = ref(false)
// ===== 内核安装进度 =====
const installing = ref(false)
const installProgress = ref<InstallProgress | null>(null)
@@ -325,6 +328,7 @@ export const useProxyStore = defineStore('proxy', () => {
proxies,
settings,
systemProxy,
initialized,
installing,
installProgress,
// kernel & process
+9
View File
@@ -17,6 +17,15 @@ export default defineConfig(async () => ({
},
},
// 单入口:主窗口
build: {
rollupOptions: {
input: {
main: fileURLToPath(new URL("./index.html", import.meta.url)),
},
},
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors