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, } 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 { 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, } #[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct KernelUpdateInfo { pub current_version: Option, 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, 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, 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, } // ===================== 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::(&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 { 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 { 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 { 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 { 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 { 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 { 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 { let mut exes: Vec = 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) { 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 { 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) pub async fn rpc_call( &self, method: &str, params: Vec, ) -> Result { let s = self.load_settings(); let mut full_params: Vec = 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 { self.rpc_call("aria2.getVersion", vec![]).await } pub async fn get_global_stat(&self) -> Result { self.rpc_call("aria2.getGlobalStat", vec![]).await } pub async fn tell_active(&self) -> Result { 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 { 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 { 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 { 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, options: Option, ) -> Result { let uris_arr: Vec = uris.into_iter().map(serde_json::Value::String).collect(); let mut params: Vec = 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 { state.prepare_kernel(&app) } #[tauri::command] pub async fn downloader_check_kernel_update( state: tauri::State<'_, Aria2Manager>, ) -> Result { state.check_kernel_update().await } #[tauri::command] pub async fn downloader_update_kernel( state: tauri::State<'_, Aria2Manager>, app: AppHandle, mirror_prefix: Option, ) -> Result { 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, ) -> Result { 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 { 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 { 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 { state.get_version().await } #[tauri::command] pub async fn downloader_get_global_stat( state: tauri::State<'_, Aria2Manager>, ) -> Result { state.get_global_stat().await } #[tauri::command] pub async fn downloader_get_active( state: tauri::State<'_, Aria2Manager>, ) -> Result { state.tell_active().await } #[tauri::command] pub async fn downloader_get_waiting( state: tauri::State<'_, Aria2Manager>, offset: Option, num: Option, ) -> Result { 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, num: Option, ) -> Result { 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 { state.tell_status(&gid).await } #[tauri::command] pub async fn downloader_add_uri( state: tauri::State<'_, Aria2Manager>, uris: Vec, options: Option, ) -> Result { 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()) }