1149 lines
38 KiB
Rust
1149 lines
38 KiB
Rust
use futures_util::StreamExt;
|
||
use reqwest::Client;
|
||
use serde::{Deserialize, Serialize};
|
||
use std::fs;
|
||
use std::io::{Read, Write};
|
||
use std::path::PathBuf;
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use tauri::{AppHandle, Emitter, Manager};
|
||
use tauri::path::BaseDirectory;
|
||
|
||
use crate::process_manager::{
|
||
ProcessInfo, ProcessManager, ProcessStatus, StartProcessParams, setup_creation_flags,
|
||
};
|
||
|
||
// ===================== 数据结构 =====================
|
||
|
||
#[derive(Serialize, Deserialize, Clone)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct DownloaderSettings {
|
||
/// RPC 端口,默认 6800
|
||
#[serde(default = "default_rpc_port")]
|
||
pub rpc_port: u16,
|
||
/// RPC 密钥(可空;为空时不启用 secret 鉴权)
|
||
#[serde(default)]
|
||
pub rpc_secret: String,
|
||
/// 下载目录(默认系统 Downloads 目录)
|
||
#[serde(default = "default_download_dir")]
|
||
pub download_dir: String,
|
||
/// 最大并发任务数
|
||
#[serde(default = "default_max_concurrent")]
|
||
pub max_concurrent: u32,
|
||
/// 单服务器最大连接数
|
||
#[serde(default = "default_max_connection_per_server")]
|
||
pub max_connection_per_server: u32,
|
||
/// 分片数
|
||
#[serde(default = "default_split")]
|
||
pub split: u32,
|
||
/// 断点续传
|
||
#[serde(default = "default_true")]
|
||
pub continue_download: bool,
|
||
/// 随应用启动自动启动 aria2
|
||
#[serde(default)]
|
||
pub auto_start: bool,
|
||
/// 全局速度限制 KB/s(0=不限)
|
||
#[serde(default)]
|
||
pub speed_limit: u64,
|
||
/// 内核下载镜像源前缀列表(空串=直连 GitHub)
|
||
#[serde(default = "default_kernel_mirrors")]
|
||
pub kernel_mirrors: Vec<String>,
|
||
}
|
||
|
||
fn default_rpc_port() -> u16 { 6800 }
|
||
fn default_max_concurrent() -> u32 { 5 }
|
||
fn default_max_connection_per_server() -> u32 { 16 }
|
||
fn default_split() -> u32 { 16 }
|
||
fn default_true() -> bool { true }
|
||
|
||
/// 默认下载目录:优先系统 Downloads,回退到 %USERPROFILE%\Downloads
|
||
fn default_download_dir() -> String {
|
||
dirs::download_dir()
|
||
.or_else(|| {
|
||
std::env::var("USERPROFILE")
|
||
.ok()
|
||
.map(|p| PathBuf::from(p).join("Downloads"))
|
||
})
|
||
.map(|p| p.to_string_lossy().to_string())
|
||
.unwrap_or_else(|| "downloads".to_string())
|
||
}
|
||
|
||
/// 默认镜像源:空串=直连 GitHub 优先,后续为公益镜像
|
||
fn default_kernel_mirrors() -> Vec<String> {
|
||
vec![
|
||
String::new(),
|
||
"https://ghproxy.net/".into(),
|
||
"https://gh-proxy.com/".into(),
|
||
"https://ghfast.top/".into(),
|
||
]
|
||
}
|
||
|
||
impl Default for DownloaderSettings {
|
||
fn default() -> Self {
|
||
Self {
|
||
rpc_port: 6800,
|
||
rpc_secret: String::new(),
|
||
download_dir: default_download_dir(),
|
||
max_concurrent: 5,
|
||
max_connection_per_server: 16,
|
||
split: 16,
|
||
continue_download: true,
|
||
auto_start: false,
|
||
speed_limit: 0,
|
||
kernel_mirrors: default_kernel_mirrors(),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Serialize, Clone)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct KernelInfo {
|
||
pub path: String,
|
||
pub exists: bool,
|
||
pub version: Option<String>,
|
||
}
|
||
|
||
#[derive(Serialize, Clone)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct KernelUpdateInfo {
|
||
pub current_version: Option<String>,
|
||
pub latest_version: String,
|
||
pub download_url: String,
|
||
pub has_update: bool,
|
||
}
|
||
|
||
#[derive(Serialize, Clone)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct DownloaderStatus {
|
||
pub running: bool,
|
||
pub pid: Option<u32>,
|
||
pub restart_count: u32,
|
||
}
|
||
|
||
/// 内核安装进度事件载荷
|
||
/// - stage: downloading | extracting | replacing | done | error
|
||
#[derive(Serialize, Clone)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct InstallProgress {
|
||
pub stage: String,
|
||
pub percent: u8,
|
||
pub downloaded_bytes: u64,
|
||
pub total_bytes: Option<u64>,
|
||
pub message: String,
|
||
}
|
||
|
||
/// RPC 连接信息(前端展示/复制用,浏览器扩展对接用)
|
||
#[derive(Serialize, Clone)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct RpcInfo {
|
||
pub rpc_url: String,
|
||
pub rpc_secret: String,
|
||
/// 内置扩展所在目录(开发阶段手动加载)
|
||
pub extension_path: Option<String>,
|
||
}
|
||
|
||
// ===================== Aria2Manager =====================
|
||
|
||
pub struct Aria2Manager {
|
||
root: PathBuf,
|
||
client: Client,
|
||
/// JSON-RPC 请求 id 自增器
|
||
rpc_id: AtomicU64,
|
||
}
|
||
|
||
impl Aria2Manager {
|
||
pub fn new(app_data_dir: PathBuf) -> Self {
|
||
let root = app_data_dir.join("downloader");
|
||
for d in ["cores", "aria2", "downloads", "logs"] {
|
||
fs::create_dir_all(root.join(d)).ok();
|
||
}
|
||
Self {
|
||
root,
|
||
client: Client::builder()
|
||
.build()
|
||
.unwrap_or_else(|_| Client::new()),
|
||
rpc_id: AtomicU64::new(1),
|
||
}
|
||
}
|
||
|
||
fn cores_dir(&self) -> PathBuf {
|
||
self.root.join("cores")
|
||
}
|
||
pub fn kernel_path(&self) -> PathBuf {
|
||
self.cores_dir().join("aria2c.exe")
|
||
}
|
||
fn aria2_dir(&self) -> PathBuf {
|
||
self.root.join("aria2")
|
||
}
|
||
fn config_path(&self) -> PathBuf {
|
||
self.aria2_dir().join("aria2.conf")
|
||
}
|
||
fn session_path(&self) -> PathBuf {
|
||
self.aria2_dir().join("aria2.session")
|
||
}
|
||
#[allow(dead_code)]
|
||
fn logs_dir(&self) -> PathBuf {
|
||
self.root.join("logs")
|
||
}
|
||
fn settings_path(&self) -> PathBuf {
|
||
self.root.join("settings.json")
|
||
}
|
||
|
||
// ---------- 设置 ----------
|
||
pub fn load_settings(&self) -> DownloaderSettings {
|
||
fs::read_to_string(self.settings_path())
|
||
.ok()
|
||
.and_then(|s| serde_json::from_str::<DownloaderSettings>(&s).ok())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
pub fn save_settings(&self, settings: &DownloaderSettings) -> Result<(), String> {
|
||
let s = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?;
|
||
fs::write(self.settings_path(), s).map_err(|e| e.to_string())
|
||
}
|
||
|
||
// ---------- 内核 ----------
|
||
pub fn kernel_info(&self) -> KernelInfo {
|
||
let path = self.kernel_path();
|
||
let exists = path.exists();
|
||
let version = if exists {
|
||
let mut cmd = std::process::Command::new(&path);
|
||
cmd.arg("--version");
|
||
// 隐藏控制台窗口
|
||
setup_creation_flags(&mut cmd);
|
||
cmd.stdout(std::process::Stdio::piped())
|
||
.stderr(std::process::Stdio::null())
|
||
.stdin(std::process::Stdio::null());
|
||
cmd.output()
|
||
.ok()
|
||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||
.and_then(|s| {
|
||
s.lines()
|
||
.find(|l| l.contains("aria2"))
|
||
.map(|l| l.trim().to_string())
|
||
})
|
||
} else {
|
||
None
|
||
};
|
||
KernelInfo {
|
||
path: path.to_string_lossy().to_string(),
|
||
exists,
|
||
version,
|
||
}
|
||
}
|
||
|
||
/// 确保内核就位:若 cores/ 无内核,尝试从 resource 目录复制
|
||
pub fn prepare_kernel(&self, app: &AppHandle) -> Result<KernelInfo, String> {
|
||
let kernel = self.kernel_path();
|
||
if !kernel.exists() {
|
||
if let Ok(res) = app.path().resolve("binaries/aria2c.exe", BaseDirectory::Resource) {
|
||
if res.exists() {
|
||
fs::copy(&res, &kernel).map_err(|e| format!("复制内核失败: {}", e))?;
|
||
}
|
||
}
|
||
}
|
||
Ok(self.kernel_info())
|
||
}
|
||
|
||
/// 检查 GitHub 上的最新 aria2 版本
|
||
/// 策略:优先用 API,失败时回退到重定向解析(不受 rate limit 限制)
|
||
pub async fn check_kernel_update(&self) -> Result<KernelUpdateInfo, String> {
|
||
match self.fetch_latest_via_api().await {
|
||
Ok(info) => Ok(info),
|
||
Err(api_err) => {
|
||
eprintln!("[aria2] API 查询失败,回退到重定向解析: {}", api_err);
|
||
self.fetch_latest_via_redirect().await
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 通过 GitHub API 查询最新版本(未认证 60次/小时/IP)
|
||
async fn fetch_latest_via_api(&self) -> Result<KernelUpdateInfo, String> {
|
||
let resp = self
|
||
.client
|
||
.get("https://api.github.com/repos/aria2/aria2/releases/latest")
|
||
.header("User-Agent", "thing-app")
|
||
.timeout(std::time::Duration::from_secs(15))
|
||
.send()
|
||
.await
|
||
.map_err(|e| format!("请求 GitHub API 失败: {}", e))?;
|
||
let status = resp.status();
|
||
if !status.is_success() {
|
||
let body = resp.text().await.unwrap_or_default();
|
||
return Err(format!(
|
||
"GitHub API 返回 HTTP {}:{}",
|
||
status.as_u16(),
|
||
if body.len() > 300 { format!("{}...", &body[..300]) } else { body }
|
||
));
|
||
}
|
||
let resp: serde_json::Value = resp
|
||
.json()
|
||
.await
|
||
.map_err(|e| format!("解析 GitHub 响应失败: {}", e))?;
|
||
|
||
let latest_version = resp
|
||
.get("tag_name")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("unknown")
|
||
.to_string();
|
||
|
||
// aria2 资产命名:aria2-{version}-win-64bit-build1.zip
|
||
// tag_name 通常是 release-1.37.0,需要转换为 1.37.0
|
||
let ver_no_prefix = latest_version
|
||
.strip_prefix("release-")
|
||
.unwrap_or(&latest_version)
|
||
.to_string();
|
||
|
||
let assets_arr = resp.get("assets").and_then(|a| a.as_array());
|
||
|
||
// 匹配 win-64bit zip(排除 32bit)
|
||
let download_url = assets_arr
|
||
.and_then(|assets| {
|
||
assets.iter().find_map(|asset| {
|
||
let name = asset.get("name")?.as_str()?;
|
||
let url = asset.get("browser_download_url")?.as_str()?;
|
||
if name.contains("win-64bit") && name.ends_with(".zip") {
|
||
Some(url.to_string())
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
})
|
||
.ok_or_else(|| "API 未找到适用的 Windows 64bit 内核资产".to_string())?;
|
||
|
||
Ok(self.build_update_info(ver_no_prefix, download_url))
|
||
}
|
||
|
||
/// 通过 releases/latest 重定向解析版本号
|
||
async fn fetch_latest_via_redirect(&self) -> Result<KernelUpdateInfo, String> {
|
||
let resp = self
|
||
.client
|
||
.get("https://github.com/aria2/aria2/releases/latest")
|
||
.header("User-Agent", "thing-app")
|
||
.timeout(std::time::Duration::from_secs(15))
|
||
.send()
|
||
.await
|
||
.map_err(|e| format!("请求 GitHub releases 页面失败: {}", e))?;
|
||
|
||
let final_url = resp.url().to_string();
|
||
// 重定向 URL 形如 .../tag/release-1.37.0
|
||
let raw_version = final_url
|
||
.rsplit('/')
|
||
.next()
|
||
.ok_or_else(|| format!("无法从重定向 URL 提取版本号: {}", final_url))?
|
||
.to_string();
|
||
let latest_version = raw_version
|
||
.strip_prefix("release-")
|
||
.unwrap_or(&raw_version)
|
||
.to_string();
|
||
|
||
// 按稳定命名规则构造下载 URL
|
||
let download_url = format!(
|
||
"https://github.com/aria2/aria2/releases/download/release-{}/aria2-{}-win-64bit-build1.zip",
|
||
latest_version, latest_version
|
||
);
|
||
|
||
eprintln!(
|
||
"[aria2] 重定向解析成功: version={}, url={}",
|
||
latest_version, download_url
|
||
);
|
||
Ok(self.build_update_info(latest_version, download_url))
|
||
}
|
||
|
||
fn build_update_info(&self, latest_version: String, download_url: String) -> KernelUpdateInfo {
|
||
let current = self.kernel_info().version.clone();
|
||
let has_update = match ¤t {
|
||
Some(c) => {
|
||
// 当前版本字符串形如 "aria2 version 1.37.0" 或 "1.37.0"
|
||
let cur_ver = c
|
||
.split_whitespace()
|
||
.find(|s| s.chars().any(|c| c == '.') && s.chars().any(|c| c.is_ascii_digit()))
|
||
.unwrap_or("");
|
||
cur_ver != latest_version && !latest_version.is_empty()
|
||
}
|
||
None => true,
|
||
};
|
||
KernelUpdateInfo {
|
||
current_version: current,
|
||
latest_version,
|
||
download_url,
|
||
has_update,
|
||
}
|
||
}
|
||
|
||
/// 下载并安装内核(首次安装与更新共用此方法)
|
||
pub async fn install_kernel(
|
||
&self,
|
||
app: &AppHandle,
|
||
mirror_prefix: String,
|
||
) -> Result<KernelInfo, String> {
|
||
let result = self.install_kernel_inner(app, mirror_prefix).await;
|
||
if let Err(ref e) = result {
|
||
let _ = app.emit(
|
||
"downloader-kernel-install-progress",
|
||
InstallProgress {
|
||
stage: "error".into(),
|
||
percent: 0,
|
||
downloaded_bytes: 0,
|
||
total_bytes: None,
|
||
message: e.clone(),
|
||
},
|
||
);
|
||
}
|
||
result
|
||
}
|
||
|
||
async fn install_kernel_inner(
|
||
&self,
|
||
app: &AppHandle,
|
||
mirror_prefix: String,
|
||
) -> Result<KernelInfo, String> {
|
||
let info = self.check_kernel_update().await?;
|
||
let zip_path = self.cores_dir().join("aria2-update.zip");
|
||
let extract_dir = self.cores_dir().join("aria2-update-tmp");
|
||
|
||
let url = if mirror_prefix.is_empty() {
|
||
info.download_url.clone()
|
||
} else {
|
||
format!("{}{}", mirror_prefix, info.download_url)
|
||
};
|
||
let label = if mirror_prefix.is_empty() {
|
||
"GitHub 直连".to_string()
|
||
} else {
|
||
mirror_prefix.clone()
|
||
};
|
||
let _ = app.emit(
|
||
"downloader-kernel-install-progress",
|
||
InstallProgress {
|
||
stage: "downloading".into(),
|
||
percent: 0,
|
||
downloaded_bytes: 0,
|
||
total_bytes: None,
|
||
message: format!("正在下载:{}", label),
|
||
},
|
||
);
|
||
|
||
match self.download_with_progress(app, &url, &zip_path).await {
|
||
Ok(()) => {}
|
||
Err(e) => {
|
||
let msg = format!("下载失败({}):{}", label, e);
|
||
let _ = app.emit(
|
||
"downloader-kernel-install-progress",
|
||
InstallProgress {
|
||
stage: "error".into(),
|
||
percent: 0,
|
||
downloaded_bytes: 0,
|
||
total_bytes: None,
|
||
message: msg.clone(),
|
||
},
|
||
);
|
||
let _ = fs::remove_file(&zip_path);
|
||
return Err(msg);
|
||
}
|
||
}
|
||
|
||
// 解压阶段
|
||
let _ = app.emit(
|
||
"downloader-kernel-install-progress",
|
||
InstallProgress {
|
||
stage: "extracting".into(),
|
||
percent: 92,
|
||
downloaded_bytes: 0,
|
||
total_bytes: None,
|
||
message: "正在解压...".into(),
|
||
},
|
||
);
|
||
if extract_dir.exists() {
|
||
fs::remove_dir_all(&extract_dir).ok();
|
||
}
|
||
fs::create_dir_all(&extract_dir).map_err(|e| e.to_string())?;
|
||
if let Err(e) = self.extract_zip(&zip_path, &extract_dir) {
|
||
let _ = app.emit(
|
||
"downloader-kernel-install-progress",
|
||
InstallProgress {
|
||
stage: "error".into(),
|
||
percent: 0,
|
||
downloaded_bytes: 0,
|
||
total_bytes: None,
|
||
message: format!("解压失败:{}", e),
|
||
},
|
||
);
|
||
return Err(e);
|
||
}
|
||
|
||
// 在解压目录中递归查找 .exe 文件
|
||
let new_exe = self
|
||
.find_exe_in_dir(&extract_dir)
|
||
.ok_or_else(|| "解压后未找到任何 .exe 文件".to_string())?;
|
||
|
||
// 替换阶段
|
||
let _ = app.emit(
|
||
"downloader-kernel-install-progress",
|
||
InstallProgress {
|
||
stage: "replacing".into(),
|
||
percent: 96,
|
||
downloaded_bytes: 0,
|
||
total_bytes: None,
|
||
message: "正在安装...".into(),
|
||
},
|
||
);
|
||
let kernel = self.kernel_path();
|
||
if kernel.exists() {
|
||
let bak = self.cores_dir().join("aria2c.exe.bak");
|
||
fs::remove_file(&bak).ok();
|
||
fs::rename(&kernel, &bak).map_err(|e| format!("备份旧内核失败: {}", e))?;
|
||
}
|
||
fs::rename(&new_exe, &kernel).map_err(|e| format!("替换内核失败: {}", e))?;
|
||
|
||
// 清理临时文件
|
||
fs::remove_file(&zip_path).ok();
|
||
fs::remove_dir_all(&extract_dir).ok();
|
||
|
||
let final_info = self.kernel_info();
|
||
let _ = app.emit(
|
||
"downloader-kernel-install-progress",
|
||
InstallProgress {
|
||
stage: "done".into(),
|
||
percent: 100,
|
||
downloaded_bytes: 0,
|
||
total_bytes: None,
|
||
message: format!(
|
||
"安装完成 ({})",
|
||
final_info.version.as_deref().unwrap_or("unknown")
|
||
),
|
||
},
|
||
);
|
||
Ok(final_info)
|
||
}
|
||
|
||
/// 流式下载并实时推送进度事件
|
||
async fn download_with_progress(
|
||
&self,
|
||
app: &AppHandle,
|
||
url: &str,
|
||
dest: &PathBuf,
|
||
) -> Result<(), String> {
|
||
let resp = self
|
||
.client
|
||
.get(url)
|
||
.header("User-Agent", "thing-app")
|
||
.timeout(std::time::Duration::from_secs(30))
|
||
.send()
|
||
.await
|
||
.map_err(|e| format!("请求失败: {}", e))?;
|
||
if !resp.status().is_success() {
|
||
return Err(format!("HTTP {}", resp.status()));
|
||
}
|
||
let total = resp.content_length();
|
||
let mut stream = resp.bytes_stream();
|
||
let mut file = fs::File::create(dest).map_err(|e| format!("创建文件失败: {}", e))?;
|
||
let mut downloaded: u64 = 0;
|
||
let mut last_percent: u8 = 0;
|
||
while let Some(chunk) = stream.next().await {
|
||
let chunk = chunk.map_err(|e| format!("读取流失败: {}", e))?;
|
||
file.write_all(&chunk).map_err(|e| format!("写入文件失败: {}", e))?;
|
||
downloaded += chunk.len() as u64;
|
||
// 下载占总进度的 0-90%
|
||
let percent = match total {
|
||
Some(t) if t > 0 => ((downloaded as f64 / t as f64) * 90.0) as u8,
|
||
_ => 0,
|
||
};
|
||
// 仅在变化超过 1% 时 emit,避免事件轰炸
|
||
if percent >= last_percent + 1 {
|
||
last_percent = percent;
|
||
let _ = app.emit(
|
||
"downloader-kernel-install-progress",
|
||
InstallProgress {
|
||
stage: "downloading".into(),
|
||
percent,
|
||
downloaded_bytes: downloaded,
|
||
total_bytes: total,
|
||
message: format!("已下载 {:.2} MB", downloaded as f64 / 1024.0 / 1024.0),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
file.flush().map_err(|e| format!("flush 失败: {}", e))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// 用 zip crate 解压(纯 Rust,避免 PowerShell 执行策略问题)
|
||
fn extract_zip(&self, zip_path: &PathBuf, dest: &PathBuf) -> Result<(), String> {
|
||
let file = fs::File::open(zip_path).map_err(|e| format!("打开 zip 失败: {}", e))?;
|
||
let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("读取 zip 失败: {}", e))?;
|
||
for i in 0..archive.len() {
|
||
let mut entry = archive
|
||
.by_index(i)
|
||
.map_err(|e| format!("读取条目失败: {}", e))?;
|
||
let outpath = match entry.enclosed_name() {
|
||
Some(p) => dest.join(p),
|
||
None => continue,
|
||
};
|
||
if entry.is_dir() {
|
||
fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;
|
||
} else {
|
||
if let Some(parent) = outpath.parent() {
|
||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||
}
|
||
let mut outfile = fs::File::create(&outpath).map_err(|e| e.to_string())?;
|
||
let mut buf = [0u8; 8192];
|
||
loop {
|
||
let n = entry.read(&mut buf).map_err(|e| e.to_string())?;
|
||
if n == 0 {
|
||
break;
|
||
}
|
||
outfile.write_all(&buf[..n]).map_err(|e| e.to_string())?;
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 递归查找目录中的 .exe 文件
|
||
/// aria2 zip 内的 exe 名字不固定(可能含版本号),策略:
|
||
/// 收集所有 .exe,优先返回名字含 "aria2" 的,否则返回第一个
|
||
fn find_exe_in_dir(&self, dir: &PathBuf) -> Option<PathBuf> {
|
||
let mut exes: Vec<PathBuf> = Vec::new();
|
||
self.collect_exes(dir, &mut exes);
|
||
if exes.is_empty() {
|
||
return None;
|
||
}
|
||
exes.iter()
|
||
.find(|p| {
|
||
p.file_name()
|
||
.and_then(|n| n.to_str())
|
||
.map(|s| s.to_lowercase().contains("aria2"))
|
||
.unwrap_or(false)
|
||
})
|
||
.or_else(|| exes.first())
|
||
.cloned()
|
||
}
|
||
|
||
fn collect_exes(&self, dir: &PathBuf, out: &mut Vec<PathBuf>) {
|
||
if let Ok(entries) = fs::read_dir(dir) {
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if path.is_dir() {
|
||
self.collect_exes(&path, out);
|
||
} else if path
|
||
.extension()
|
||
.and_then(|e| e.to_str())
|
||
.map(|s| s.eq_ignore_ascii_case("exe"))
|
||
.unwrap_or(false)
|
||
{
|
||
out.push(path);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- 配置生成 ----------
|
||
/// 生成 aria2.conf
|
||
pub fn generate_config(&self) -> Result<(), String> {
|
||
let settings = self.load_settings();
|
||
|
||
// 确保下载目录存在
|
||
let download_dir = if settings.download_dir.is_empty() {
|
||
default_download_dir()
|
||
} else {
|
||
settings.download_dir.clone()
|
||
};
|
||
fs::create_dir_all(&download_dir).ok();
|
||
|
||
let log_file = self.logs_dir().join("aria2.log");
|
||
// 空字符串的 secret 不写入配置,避免 aria2 启动参数异常
|
||
let secret_line = if settings.rpc_secret.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!("rpc-secret={}\n", settings.rpc_secret)
|
||
};
|
||
// 速度限制:0=不限,否则按 K 作单位
|
||
let speed_limit_line = if settings.speed_limit == 0 {
|
||
String::new()
|
||
} else {
|
||
format!("max-overall-download-limit={}K\n", settings.speed_limit)
|
||
};
|
||
|
||
let conf = format!(
|
||
"# 由 Thing 自动生成,请勿手动编辑\n\
|
||
enable-rpc=true\n\
|
||
rpc-listen-all=false\n\
|
||
rpc-allow-origin-all=true\n\
|
||
rpc-listen-port={rpc_port}\n\
|
||
{secret_line}\
|
||
dir={download_dir}\n\
|
||
max-concurrent-downloads={max_concurrent}\n\
|
||
max-connection-per-server={max_conn}\n\
|
||
split={split}\n\
|
||
continue={cont}\n\
|
||
min-split-size=1M\n\
|
||
file-allocation=none\n\
|
||
{speed_limit_line}\
|
||
log={log_file}\n\
|
||
log-level=warn\n",
|
||
rpc_port = settings.rpc_port,
|
||
secret_line = secret_line,
|
||
download_dir = download_dir.replace('\\', "/"),
|
||
max_concurrent = settings.max_concurrent,
|
||
max_conn = settings.max_connection_per_server,
|
||
split = settings.split,
|
||
cont = if settings.continue_download { "true" } else { "false" },
|
||
speed_limit_line = speed_limit_line,
|
||
log_file = log_file.to_string_lossy().replace('\\', "/"),
|
||
);
|
||
|
||
fs::write(self.config_path(), conf).map_err(|e| e.to_string())?;
|
||
|
||
// 确保 session 文件存在(aria2 --input-file 要求文件存在)
|
||
if !self.session_path().exists() {
|
||
fs::write(self.session_path(), "").map_err(|e| e.to_string())?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 构建启动 aria2 所需的进程参数
|
||
pub fn prepare_for_start(&self, app: &AppHandle) -> Result<StartProcessParams, String> {
|
||
let info = self.prepare_kernel(app)?;
|
||
if !info.exists {
|
||
return Err(format!(
|
||
"aria2 内核未安装。请将 aria2c.exe 放置到 src-tauri/binaries/ 后重新运行,或直接放到:\n{}",
|
||
self.cores_dir().to_string_lossy()
|
||
));
|
||
}
|
||
self.generate_config()?;
|
||
Ok(StartProcessParams {
|
||
id: "downloader".into(),
|
||
executable: self.kernel_path().to_string_lossy().to_string(),
|
||
args: vec![
|
||
"--conf-path".into(),
|
||
self.config_path().to_string_lossy().to_string(),
|
||
"--input-file".into(),
|
||
self.session_path().to_string_lossy().to_string(),
|
||
"--save-session".into(),
|
||
self.session_path().to_string_lossy().to_string(),
|
||
"--save-session-interval=30".into(),
|
||
],
|
||
cwd: Some(self.aria2_dir().to_string_lossy().to_string()),
|
||
name: "aria2c".into(),
|
||
restart_on_crash: true,
|
||
max_restarts: 3,
|
||
})
|
||
}
|
||
|
||
/// 应用启动时检查是否需要自动启动 aria2
|
||
pub fn auto_start_on_launch(&self, app: &AppHandle, pm: &ProcessManager) {
|
||
let settings = self.load_settings();
|
||
if !settings.auto_start {
|
||
return;
|
||
}
|
||
match self.prepare_for_start(app) {
|
||
Ok(params) => {
|
||
if let Err(e) = pm.start(params) {
|
||
eprintln!("[aria2] 自动启动失败: {}", e);
|
||
}
|
||
}
|
||
Err(e) => {
|
||
eprintln!("[aria2] 自动启动跳过: {}", e);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 应用退出时清理:尝试优雅关闭(保存 session),失败则强制 kill
|
||
pub fn cleanup_on_exit(&self) {
|
||
// 尝试通过 RPC 调用 aria2.shutdown 让 aria2 优雅退出(保存 session)
|
||
// 使用阻塞的 reqwest::blocking 避免在同步函数中引入 async 复杂度
|
||
// 失败也不影响后续 stop_all
|
||
let rt = match tokio::runtime::Handle::try_current() {
|
||
Ok(h) => h,
|
||
Err(_) => return,
|
||
};
|
||
let _ = rt.block_on(async {
|
||
// 给 2 秒超时,让 aria2 有足够时间保存 session
|
||
// (aria2 保存 session 可能涉及磁盘 I/O,500ms 太短)
|
||
let _ = tokio::time::timeout(
|
||
std::time::Duration::from_millis(2000),
|
||
self.rpc_call("aria2.shutdown", vec![]),
|
||
)
|
||
.await;
|
||
});
|
||
}
|
||
|
||
// ---------- RPC 信息 ----------
|
||
pub fn get_rpc_info(&self, app: &AppHandle) -> RpcInfo {
|
||
let s = self.load_settings();
|
||
let rpc_url = format!("http://127.0.0.1:{}/jsonrpc", s.rpc_port);
|
||
let extension_path = app
|
||
.path()
|
||
.resolve("resources/thing-extension", BaseDirectory::Resource)
|
||
.ok()
|
||
.map(|p| p.to_string_lossy().to_string());
|
||
RpcInfo {
|
||
rpc_url,
|
||
rpc_secret: s.rpc_secret,
|
||
extension_path,
|
||
}
|
||
}
|
||
|
||
// ---------- JSON-RPC 封装 ----------
|
||
fn rpc_url(&self) -> String {
|
||
let s = self.load_settings();
|
||
format!("http://127.0.0.1:{}/jsonrpc", s.rpc_port)
|
||
}
|
||
|
||
/// 调用 aria2 JSON-RPC 方法
|
||
/// params[0] 自动插入 "token:<secret>"(若有 secret)
|
||
pub async fn rpc_call(
|
||
&self,
|
||
method: &str,
|
||
params: Vec<serde_json::Value>,
|
||
) -> Result<serde_json::Value, String> {
|
||
let s = self.load_settings();
|
||
let mut full_params: Vec<serde_json::Value> = Vec::with_capacity(params.len() + 1);
|
||
if !s.rpc_secret.is_empty() {
|
||
full_params.push(serde_json::json!(format!("token:{}", s.rpc_secret)));
|
||
}
|
||
full_params.extend(params);
|
||
|
||
let id = self.rpc_id.fetch_add(1, Ordering::SeqCst);
|
||
let body = serde_json::json!({
|
||
"jsonrpc": "2.0",
|
||
"id": id,
|
||
"method": method,
|
||
"params": full_params,
|
||
});
|
||
|
||
let resp = self
|
||
.client
|
||
.post(self.rpc_url())
|
||
.json(&body)
|
||
.timeout(std::time::Duration::from_secs(10))
|
||
.send()
|
||
.await
|
||
.map_err(|e| format!("请求 aria2 RPC 失败: {}", e))?;
|
||
|
||
if !resp.status().is_success() {
|
||
return Err(format!("aria2 RPC 错误: HTTP {}", resp.status()));
|
||
}
|
||
|
||
let v: serde_json::Value = resp
|
||
.json()
|
||
.await
|
||
.map_err(|e| format!("解析 aria2 RPC 响应失败: {}", e))?;
|
||
|
||
if let Some(err) = v.get("error") {
|
||
return Err(format!(
|
||
"aria2 RPC 错误: code={}, message={}",
|
||
err.get("code").and_then(|c| c.as_i64()).unwrap_or(0),
|
||
err.get("message").and_then(|m| m.as_str()).unwrap_or("")
|
||
));
|
||
}
|
||
|
||
Ok(v.get("result").cloned().unwrap_or(serde_json::Value::Null))
|
||
}
|
||
|
||
pub async fn get_version(&self) -> Result<serde_json::Value, String> {
|
||
self.rpc_call("aria2.getVersion", vec![]).await
|
||
}
|
||
|
||
pub async fn get_global_stat(&self) -> Result<serde_json::Value, String> {
|
||
self.rpc_call("aria2.getGlobalStat", vec![]).await
|
||
}
|
||
|
||
pub async fn tell_active(&self) -> Result<serde_json::Value, String> {
|
||
self.rpc_call("aria2.tellActive", vec![serde_json::json!(["gid", "status", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "connections", "files", "dir", "bittorrent"])])
|
||
.await
|
||
}
|
||
|
||
pub async fn tell_waiting(&self, offset: i64, num: u32) -> Result<serde_json::Value, String> {
|
||
self.rpc_call(
|
||
"aria2.tellWaiting",
|
||
vec![serde_json::json!(offset), serde_json::json!(num), serde_json::json!(["gid", "status", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "connections", "files", "dir", "bittorrent"])],
|
||
)
|
||
.await
|
||
}
|
||
|
||
pub async fn tell_stopped(&self, offset: i64, num: u32) -> Result<serde_json::Value, String> {
|
||
self.rpc_call(
|
||
"aria2.tellStopped",
|
||
vec![serde_json::json!(offset), serde_json::json!(num), serde_json::json!(["gid", "status", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "connections", "files", "dir", "bittorrent"])],
|
||
)
|
||
.await
|
||
}
|
||
|
||
pub async fn tell_status(&self, gid: &str) -> Result<serde_json::Value, String> {
|
||
self.rpc_call(
|
||
"aria2.tellStatus",
|
||
vec![serde_json::json!(gid), serde_json::json!(["gid", "status", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "connections", "files", "dir", "bittorrent", "errorCode", "errorMessage"])],
|
||
)
|
||
.await
|
||
}
|
||
|
||
pub async fn add_uri(
|
||
&self,
|
||
uris: Vec<String>,
|
||
options: Option<serde_json::Value>,
|
||
) -> Result<String, String> {
|
||
let uris_arr: Vec<serde_json::Value> = uris.into_iter().map(serde_json::Value::String).collect();
|
||
let mut params: Vec<serde_json::Value> = vec![serde_json::json!(uris_arr)];
|
||
if let Some(o) = options {
|
||
params.push(o);
|
||
}
|
||
let result = self.rpc_call("aria2.addUri", params).await?;
|
||
result
|
||
.as_str()
|
||
.map(|s| s.to_string())
|
||
.ok_or_else(|| "aria2.addUri 返回非字符串 gid".to_string())
|
||
}
|
||
|
||
pub async fn pause(&self, gid: &str) -> Result<(), String> {
|
||
let _ = self.rpc_call("aria2.pause", vec![serde_json::json!(gid)]).await?;
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn unpause(&self, gid: &str) -> Result<(), String> {
|
||
let _ = self
|
||
.rpc_call("aria2.unpause", vec![serde_json::json!(gid)])
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn remove(&self, gid: &str) -> Result<(), String> {
|
||
let _ = self.rpc_call("aria2.remove", vec![serde_json::json!(gid)]).await?;
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn change_global_option(
|
||
&self,
|
||
options: serde_json::Value,
|
||
) -> Result<(), String> {
|
||
let _ = self
|
||
.rpc_call("aria2.changeGlobalOption", vec![options])
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
// ===================== Tauri 命令 =====================
|
||
|
||
#[tauri::command]
|
||
pub fn downloader_get_settings(state: tauri::State<'_, Aria2Manager>) -> DownloaderSettings {
|
||
state.load_settings()
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn downloader_save_settings(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
settings: DownloaderSettings,
|
||
) -> Result<(), String> {
|
||
state.save_settings(&settings)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn downloader_kernel_info(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
app: AppHandle,
|
||
) -> Result<KernelInfo, String> {
|
||
state.prepare_kernel(&app)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn downloader_check_kernel_update(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
) -> Result<KernelUpdateInfo, String> {
|
||
state.check_kernel_update().await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn downloader_update_kernel(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
app: AppHandle,
|
||
mirror_prefix: Option<String>,
|
||
) -> Result<KernelInfo, String> {
|
||
state.install_kernel(&app, mirror_prefix.unwrap_or_default()).await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn downloader_install_kernel(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
app: AppHandle,
|
||
mirror_prefix: Option<String>,
|
||
) -> Result<KernelInfo, String> {
|
||
state.install_kernel(&app, mirror_prefix.unwrap_or_default()).await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn downloader_status(pm: tauri::State<'_, ProcessManager>) -> DownloaderStatus {
|
||
match pm.get_status("downloader") {
|
||
Some(p) => DownloaderStatus {
|
||
running: matches!(p.status, ProcessStatus::Running),
|
||
pid: p.pid,
|
||
restart_count: p.restart_count,
|
||
},
|
||
None => DownloaderStatus {
|
||
running: false,
|
||
pid: None,
|
||
restart_count: 0,
|
||
},
|
||
}
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn downloader_start(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
pm: tauri::State<'_, ProcessManager>,
|
||
app: AppHandle,
|
||
) -> Result<ProcessInfo, String> {
|
||
let params = state.prepare_for_start(&app)?;
|
||
pm.start(params)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn downloader_stop(pm: tauri::State<'_, ProcessManager>) -> Result<(), String> {
|
||
pm.stop("downloader")
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn downloader_restart(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
pm: tauri::State<'_, ProcessManager>,
|
||
app: AppHandle,
|
||
) -> Result<ProcessInfo, String> {
|
||
let _ = pm.stop("downloader");
|
||
// 等待 TCP 端口释放(Windows 上 kill 后端口释放有延迟)
|
||
tauri::async_runtime::spawn_blocking(|| {
|
||
std::thread::sleep(std::time::Duration::from_millis(800));
|
||
})
|
||
.await
|
||
.map_err(|e| format!("sleep 失败: {}", e))?;
|
||
let params = state.prepare_for_start(&app)?;
|
||
pm.start(params)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn downloader_version(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
) -> Result<serde_json::Value, String> {
|
||
state.get_version().await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn downloader_get_global_stat(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
) -> Result<serde_json::Value, String> {
|
||
state.get_global_stat().await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn downloader_get_active(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
) -> Result<serde_json::Value, String> {
|
||
state.tell_active().await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn downloader_get_waiting(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
offset: Option<i64>,
|
||
num: Option<u32>,
|
||
) -> Result<serde_json::Value, String> {
|
||
state.tell_waiting(offset.unwrap_or(0), num.unwrap_or(100)).await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn downloader_get_stopped(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
offset: Option<i64>,
|
||
num: Option<u32>,
|
||
) -> Result<serde_json::Value, String> {
|
||
state.tell_stopped(offset.unwrap_or(0), num.unwrap_or(100)).await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn downloader_get_status(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
gid: String,
|
||
) -> Result<serde_json::Value, String> {
|
||
state.tell_status(&gid).await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn downloader_add_uri(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
uris: Vec<String>,
|
||
options: Option<serde_json::Value>,
|
||
) -> Result<String, String> {
|
||
state.add_uri(uris, options).await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn downloader_pause(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
gid: String,
|
||
) -> Result<(), String> {
|
||
state.pause(&gid).await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn downloader_unpause(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
gid: String,
|
||
) -> Result<(), String> {
|
||
state.unpause(&gid).await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn downloader_remove(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
gid: String,
|
||
) -> Result<(), String> {
|
||
state.remove(&gid).await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn downloader_change_global_option(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
options: serde_json::Value,
|
||
) -> Result<(), String> {
|
||
state.change_global_option(options).await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn downloader_get_rpc_info(
|
||
state: tauri::State<'_, Aria2Manager>,
|
||
app: AppHandle,
|
||
) -> RpcInfo {
|
||
state.get_rpc_info(&app)
|
||
}
|
||
|
||
/// 打开目录(用系统资源管理器)。
|
||
///
|
||
/// 用 `OpenerExt::opener().open_path()` 而非 IPC `open_path` 命令,绕过 opener scope 限制
|
||
/// (capabilities 的 `opener:allow-open-path` 默认无 scope,会拒绝任何路径)。
|
||
#[tauri::command]
|
||
pub fn downloader_open_dir(app: AppHandle, path: String) -> Result<(), String> {
|
||
let p = std::path::Path::new(&path);
|
||
if !p.exists() {
|
||
return Err(format!("路径不存在: {}", path));
|
||
}
|
||
use tauri_plugin_opener::OpenerExt;
|
||
app.opener()
|
||
.open_path(path, None::<&str>)
|
||
.map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// 在系统默认浏览器中打开指定 URL。
|
||
///
|
||
/// 用 `OpenerExt::opener().open_url()` 打开外链,绕过前端 opener scope 限制。
|
||
/// 仅允许 http/https 协议。
|
||
#[tauri::command]
|
||
pub fn downloader_open_url(app: AppHandle, url: String) -> Result<(), String> {
|
||
// 简单协议校验,防止 file:// 等被误用
|
||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||
return Err(format!("仅支持 http/https 链接: {}", url));
|
||
}
|
||
use tauri_plugin_opener::OpenerExt;
|
||
app.opener()
|
||
.open_url(url, None::<&str>)
|
||
.map_err(|e| e.to_string())
|
||
}
|