Files
Thing/src-tauri/src/aria2_manager.rs
T
2026-07-21 17:40:55 +08:00

1149 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &current {
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/O500ms 太短)
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())
}