//! 内核(mihomo.exe)安装 / 更新 / 版本查询。 //! 子模块通过 `impl super::MihomoManager` 为管理器追加方法,可访问父模块私有字段。 use std::fs; use std::io::{Read, Write}; use std::path::PathBuf; use std::sync::atomic::Ordering; use tauri::{AppHandle, Emitter, Manager}; use crate::constants::events::KERNEL_INSTALL_PROGRESS; use super::{InstallProgress, KernelInfo, KernelUpdateInfo, MihomoManager}; /// 用户主动取消下载的标记错误信息(前端据此静默处理,不弹错误 toast) const KERNEL_CANCELLED: &str = "下载已取消"; impl MihomoManager { // ---------- 内核 ---------- 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("-v"); // 隐藏控制台窗口(mihomo.exe -v 也会弹窗) crate::process_manager::setup_creation_flags(&mut cmd); // 重定向 stdio,避免继承主进程控制台 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("Mihomo Meta") || l.contains("mihomo")) .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/mihomo.exe", tauri::path::BaseDirectory::Resource) { if res.exists() { fs::copy(&res, &kernel).map_err(|e| format!("复制内核失败: {}", e))?; } } } Ok(self.kernel_info()) } /// 检查 GitHub 上的最新 mihomo 版本 /// 策略:优先用 API(能拿到完整资产列表,命名变化时更健壮), /// 失败时回退到重定向解析(不受 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) => { crate::logger::log_warn("mihomo", &format!("API 查询失败,回退到重定向解析: {}", api_err)); self.fetch_latest_via_redirect().await } } } /// 通过 GitHub API 查询最新版本(受 rate limit 限制:未认证 60次/小时/IP) async fn fetch_latest_via_api(&self) -> Result { let resp = self .client .get("https://api.github.com/repos/MetaCubeX/mihomo/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(); // 按字符边界截断,避免切在多字节字符中间导致 panic let preview: String = body.chars().take(300).collect(); return Err(format!( "GitHub API 返回 HTTP {}:{}{}", status.as_u16(), preview, if body.chars().count() > 300 { "..." } else { "" } )); } 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(); let assets_arr = resp.get("assets").and_then(|a| a.as_array()); // 收集所有 windows amd64 zip 候选资产(排除 compatible/arm64/386) let candidates: Vec<(String, String)> = assets_arr .map(|assets| { assets.iter().filter_map(|asset| { let name = asset.get("name")?.as_str()?; let url = asset.get("browser_download_url")?.as_str()?; if name.starts_with("mihomo-windows-amd64-") && name.ends_with(".zip") && !name.contains("compatible") && !name.contains("arm64") && !name.contains("386") { Some((name.to_string(), url.to_string())) } else { None } }).collect() }) .unwrap_or_default(); // 按优先级匹配:v3 标准 > v3-go124 > v3-go123 > v3 其他 > v2 > v1 > 旧命名 let download_url = candidates .iter().find(|(n, _)| n.contains("-v3-v") && !n.contains("-go")) .or_else(|| candidates.iter().find(|(n, _)| n.contains("-v3-go124-"))) .or_else(|| candidates.iter().find(|(n, _)| n.contains("-v3-go123-"))) .or_else(|| candidates.iter().find(|(n, _)| n.contains("-v3-go"))) .or_else(|| candidates.iter().find(|(n, _)| n.contains("-v2-v"))) .or_else(|| candidates.iter().find(|(n, _)| n.contains("-v1-v"))) .or_else(|| candidates.iter().find(|(n, _)| { !n.contains("-v1-") && !n.contains("-v2-") && !n.contains("-v3-") })) .map(|(_, u)| u.clone()) .ok_or_else(|| { let candidates_str = candidates.iter() .map(|(n, _)| n.as_str()) .collect::>() .join(", "); format!("API 未找到适用的 Windows amd64 内核资产。候选:[{}]", candidates_str) })?; Ok(self.build_update_info(latest_version, download_url)) } /// 通过 releases/latest 重定向解析版本号(不受 API rate limit 限制) /// 访问 https://github.com/MetaCubeX/mihomo/releases/latest 会 302 到 /// https://github.com/MetaCubeX/mihomo/releases/tag/v1.19.13 /// 从最终 URL 提取版本号后,按 v1.19+ 稳定命名规则构造下载 URL async fn fetch_latest_via_redirect(&self) -> Result { let resp = self .client .get("https://github.com/MetaCubeX/mihomo/releases/latest") .header("User-Agent", "thing-app") .timeout(std::time::Duration::from_secs(15)) .send() .await .map_err(|e| format!("请求 GitHub releases 页面失败: {}", e))?; // 从重定向后的最终 URL 提取版本号 let final_url = resp.url().to_string(); let latest_version = final_url .rsplit('/') .next() .filter(|s| s.starts_with('v') && s.chars().any(|c| c == '.')) .ok_or_else(|| format!("无法从重定向 URL 提取版本号: {}", final_url))? .to_string(); // 构造下载 URL:mihomo v1.19+ 稳定使用 -v3-vX.X.X.zip 命名(CPU level v3) let download_url = format!( "https://github.com/MetaCubeX/mihomo/releases/download/{}/mihomo-windows-amd64-v3-{}.zip", latest_version, latest_version ); crate::logger::log_info( "mihomo", &format!("重定向解析成功: version={}, url={}", latest_version, download_url), ); Ok(self.build_update_info(latest_version, download_url)) } /// 根据最新版本和下载 URL 构造更新信息(含当前版本比较) fn build_update_info(&self, latest_version: String, download_url: String) -> KernelUpdateInfo { let current = self.kernel_info().version; let has_update = match ¤t { Some(c) => { let cur_ver = c .split_whitespace() .find(|s| s.starts_with('v') && s.chars().filter(|c| *c == '.').count() >= 2) .unwrap_or(""); cur_ver != latest_version && !latest_version.is_empty() } None => true, }; KernelUpdateInfo { current_version: current, latest_version, download_url, has_update, } } /// 应用内核更新:下载阶段已由下载模块完成,本方法仅做 need_stop → 解压 → 替换。 /// zip_path: 下载模块下载完成的 zip 文件路径。 /// 任何阶段失败都会 emit error 事件,避免前端进度卡住。 pub async fn apply_kernel_update(&self, app: &AppHandle, zip_path: PathBuf) -> Result { self.kernel_cancel.store(false, Ordering::SeqCst); let _ = self.kernel_cancel_tx.send(false); let result = self.apply_kernel_inner(app, zip_path).await; if let Err(ref e) = result { if e != KERNEL_CANCELLED { let _ = app.emit( KERNEL_INSTALL_PROGRESS, InstallProgress { stage: "error".into(), percent: 0, downloaded_bytes: 0, total_bytes: None, message: e.clone(), }, ); } } self.kernel_cancel.store(false, Ordering::SeqCst); let _ = self.kernel_cancel_tx.send(false); result } async fn apply_kernel_inner(&self, app: &AppHandle, zip_path: PathBuf) -> Result { let extract_dir = self.cores_dir().join("mihomo-update-tmp"); // 检查 zip 文件是否存在 if !zip_path.exists() { return Err(format!("下载文件不存在: {}", zip_path.display())); } // 解压替换前需要等待前端确认 mihomo 已停止(否则 exe 文件被占用) if self.kernel_cancel.load(Ordering::SeqCst) { return Err(KERNEL_CANCELLED.to_string()); } let _ = app.emit( KERNEL_INSTALL_PROGRESS, InstallProgress { stage: "need_stop".into(), percent: 90, downloaded_bytes: 0, total_bytes: None, message: "需要停止 mihomo 才能继续安装".into(), }, ); // 创建 oneshot 通道等待前端确认 let (tx, mut rx) = tokio::sync::oneshot::channel::<()>(); *self.install_confirm.lock().unwrap() = Some(tx); let mut cancel_rx = self.kernel_cancel_tx.subscribe(); loop { if self.kernel_cancel.load(Ordering::SeqCst) { *self.install_confirm.lock().unwrap() = None; return Err(KERNEL_CANCELLED.to_string()); } tokio::select! { _ = &mut rx => break, _ = cancel_rx.changed() => {} } } // 解压阶段 let _ = app.emit( 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( 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( 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("mihomo.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( 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) } /// 用 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 文件 /// mihomo zip 内的 exe 名字不固定(可能含版本号、CPU level 等), /// 策略:收集所有 .exe,优先返回名字含 "mihomo" 的,否则返回第一个 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; } // 优先选名字含 mihomo 的 exes.iter() .find(|p| p.file_name().and_then(|n| n.to_str()).map(|s| s.to_lowercase().contains("mihomo")).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); } } } } }