Files
Thing/src-tauri/src/mihomo_manager/kernel.rs
T

407 lines
16 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.
//! 内核(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<KernelInfo, String> {
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<KernelUpdateInfo, String> {
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<KernelUpdateInfo, String> {
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::<Vec<_>>()
.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<KernelUpdateInfo, String> {
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();
// 构造下载 URLmihomo 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 &current {
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<KernelInfo, String> {
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<KernelInfo, String> {
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<PathBuf> {
let mut exes: Vec<PathBuf> = 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<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);
}
}
}
}
}