版本管理,优化

This commit is contained in:
zhongluofeng
2026-08-11 17:19:36 +08:00
parent 2f20161010
commit 6c7897bf47
33 changed files with 1361 additions and 39 deletions
+457
View File
@@ -0,0 +1,457 @@
//! 应用自更新模块。
//! 更新源为自建 Gitea`https://gitea.atie.fun/LFeng/Thing` 的 release 资产。
//! - 便携版(无 unins000.exe 且不在 Program Files):下载新 thing.exe → update.bat 覆盖重启
//! - 安装版(NSIS):下载新 setup.exe → 提权静默安装 /S
//! - ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖 {app_data}/monitor/cores/ThingHK.exe
//! mihomo 内核更新继续复用代理模块已有的 GitHub 下载机制,不在此模块处理。
use futures_util::StreamExt;
use serde::Serialize;
use specta::Type;
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Emitter, Manager};
use crate::constants::events::UPDATE_PROGRESS;
/// 发布仓库(Gitea
const GITEA_REPO: &str = "LFeng/Thing";
const GITEA_BASE: &str = "https://gitea.atie.fun";
/// release 中的一个资产
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAsset {
pub name: String,
pub size: u64,
pub browser_download_url: String,
}
/// 检查更新的结果
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCheckResult {
pub current_version: String,
pub latest_version: String,
pub has_update: bool,
/// portable | installed
pub install_type: String,
pub release_name: String,
pub release_body: String,
pub assets: Vec<UpdateAsset>,
}
/// 更新进度事件载荷(与内核安装进度同构,独立事件便于 UI 区分)
#[derive(Serialize, Clone, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateProgress {
pub stage: String,
pub percent: u8,
#[specta(type = f64)]
pub downloaded_bytes: u64,
#[specta(type = Option<f64>)]
pub total_bytes: Option<u64>,
pub message: String,
}
// ---------- 版本比较 ----------
/// 解析 vX.Y.Z 为数字元组用于比较;解析失败返回 (0,0,0)
fn parse_version(v: &str) -> (u32, u32, u32) {
let s = v.trim().trim_start_matches('v');
let mut parts = s.split('.');
let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let patch = parts
.next()
.map(|p| p.chars().take_while(|c| c.is_ascii_digit()).collect::<String>())
.and_then(|p| p.parse().ok())
.unwrap_or(0);
(major, minor, patch)
}
fn version_gt(a: &str, b: &str) -> bool {
parse_version(a) > parse_version(b)
}
// ---------- Gitea API ----------
struct LatestRelease {
tag_name: String,
name: String,
body: String,
assets: Vec<UpdateAsset>,
}
async fn fetch_latest_release() -> Result<LatestRelease, String> {
let url = format!("{}/api/v1/repos/{}/releases/latest", GITEA_BASE, GITEA_REPO);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?;
let resp = client
.get(&url)
.header("User-Agent", "thing-app")
.send()
.await
.map_err(|e| format!("请求 Gitea API 失败: {}", e))?;
if !resp.status().is_success() {
return Err(format!("Gitea API 返回 HTTP {}", resp.status()));
}
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("解析 Gitea 响应失败: {}", e))?;
let mut assets = Vec::new();
if let Some(list) = json.get("assets").and_then(|v| v.as_array()) {
for a in list {
if let (Some(name), Some(url)) = (
a.get("name").and_then(|v| v.as_str()),
a.get("browser_download_url").and_then(|v| v.as_str()),
) {
assets.push(UpdateAsset {
name: name.to_string(),
size: a.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
browser_download_url: url.to_string(),
});
}
}
}
Ok(LatestRelease {
tag_name: json.get("tag_name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
name: json.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
body: json.get("body").and_then(|v| v.as_str()).unwrap_or("").to_string(),
assets,
})
}
// ---------- 下载 / 解压 ----------
/// 下载文件到 dest,期间通过 UPDATE_PROGRESS 事件上报进度
async fn download_with_progress(app: &AppHandle, url: &str, dest: &Path) -> Result<(), String> {
let client = reqwest::Client::new();
let resp = 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;
let percent = match total {
Some(t) if t > 0 => ((downloaded as f64 / t as f64) * 100.0) as u8,
_ => 0,
};
if percent >= last_percent + 1 {
last_percent = percent;
let _ = app.emit(
UPDATE_PROGRESS,
UpdateProgress {
stage: "downloading".into(),
percent,
downloaded_bytes: downloaded,
total_bytes: total,
message: format!(
"已下载 {:.2} MB / {:.2} MB",
downloaded as f64 / 1024.0 / 1024.0,
total.unwrap_or(0) as f64 / 1024.0 / 1024.0
),
},
);
}
}
file.flush().map_err(|e| format!("flush 失败: {}", e))?;
let _ = app.emit(
UPDATE_PROGRESS,
UpdateProgress {
stage: "downloaded".into(),
percent: 100,
downloaded_bytes: downloaded,
total_bytes: total,
message: "下载完成".into(),
},
);
Ok(())
}
/// 用 zip crate 解压(纯 Rust,避免 PowerShell 执行策略问题)
fn extract_zip(zip_path: &Path, dest: &Path) -> 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(())
}
// ---------- 安装类型 / ShellExecute ----------
/// 判断当前是便携版还是安装版。
/// NSIS 安装会在程序目录生成 unins000.exeMSI 通常安装到 Program Files。
fn is_installed_version() -> bool {
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
if dir.join("unins000.exe").exists() {
return true;
}
let p = dir.to_string_lossy().to_lowercase();
if p.contains("program files") {
return true;
}
}
}
false
}
/// 通过 ShellExecuteW 启动程序/文档(绕过 Job Object,脱离主进程生命周期)
fn shell_execute(verb: &str, file: &Path, params: &str, show: i32) -> Result<(), String> {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::UI::Shell::ShellExecuteW;
let file_w: Vec<u16> = file.as_os_str().encode_wide().chain(std::iter::once(0)).collect();
let verb_w: Vec<u16> = verb.encode_utf16().chain(std::iter::once(0)).collect();
let params_w: Vec<u16> = params.encode_utf16().chain(std::iter::once(0)).collect();
let res = unsafe {
ShellExecuteW(
0 as isize,
verb_w.as_ptr(),
file_w.as_ptr(),
params_w.as_ptr(),
std::ptr::null(),
show,
)
};
if (res as isize) <= 32 {
return Err(format!("ShellExecuteW 失败 (code={})", res));
}
Ok(())
}
#[cfg(not(windows))]
{
let _ = (verb, file, params, show);
Err("仅支持 Windows".into())
}
}
/// 便携版:写 update.bat 等待主进程退出 → 覆盖 exe → 重新启动
fn apply_portable_update(new_exe: &Path) -> Result<(), String> {
let cur_exe = std::env::current_exe().map_err(|e| format!("获取当前程序路径失败: {}", e))?;
let cur_dir = cur_exe.parent().ok_or("无法确定程序目录".to_string())?;
let bat_path = cur_dir.join("update.bat");
let script = format!(
"@echo off\r\n\
:wait\r\n\
tasklist /FI \"IMAGENAME eq thing.exe\" 2>nul | findstr /i \"thing.exe\" >nul\r\n\
if not errorlevel 1 (\r\n\
ping -n 2 127.0.0.1 >nul\r\n\
goto wait\r\n\
)\r\n\
copy /y \"{new}\" \"{cur}\" >nul\r\n\
if errorlevel 1 exit /b 1\r\n\
start \"\" \"{cur}\"\r\n\
del \"{new}\" >nul 2>nul\r\n\
del \"%~f0\" >nul 2>nul\r\n",
new = new_exe.display(),
cur = cur_exe.display()
);
fs::write(&bat_path, script).map_err(|e| format!("写入更新脚本失败: {}", e))?;
// 用 cmd /c 启动 bat 并隐藏窗口;ShellExecute 启动的进程不属于本进程 Job,
// 主进程退出后 update.bat 仍能继续执行
let windir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".into());
let cmd_exe = Path::new(&windir).join("System32").join("cmd.exe");
shell_execute("open", &cmd_exe, &format!("/c \"{}\"", bat_path.display()), 0)
}
// ---------- 命令 ----------
/// 获取当前应用版本
#[tauri::command]
#[specta::specta]
pub fn app_version(app: AppHandle) -> String {
app.package_info().version.to_string()
}
/// 检查 Gitea 最新 release,返回版本对比与可用资产
#[tauri::command]
#[specta::specta]
pub async fn update_check(app: AppHandle) -> Result<UpdateCheckResult, String> {
let latest = fetch_latest_release().await?;
let latest_version = latest.tag_name.trim_start_matches('v').to_string();
let current_version = app.package_info().version.to_string();
let has_update = version_gt(&latest_version, &current_version);
Ok(UpdateCheckResult {
current_version,
latest_version,
has_update,
install_type: if is_installed_version() { "installed".into() } else { "portable".into() },
release_name: latest.name,
release_body: latest.body,
assets: latest.assets,
})
}
/// 更新应用本体。
/// 便携版:下载 thing_{v}_x64.exe → update.bat 覆盖重启;
/// 安装版:下载 thing_{v}_x64-setup.exe → 提权静默安装 /S。
/// 下载进度通过 UPDATE_PROGRESS 事件上报,调用方返回前会触发应用退出。
#[tauri::command]
#[specta::specta]
pub async fn update_install(app: AppHandle) -> Result<(), String> {
let latest = fetch_latest_release().await?;
let installed = is_installed_version();
let (target_name, target_url) = if installed {
latest
.assets
.iter()
.find(|a| a.name.ends_with("-setup.exe"))
.map(|a| (a.name.clone(), a.browser_download_url.clone()))
.ok_or("未在 release 中找到安装包 (setup.exe)".to_string())?
} else {
latest
.assets
.iter()
.find(|a| a.name.ends_with(".exe") && !a.name.contains("setup"))
.map(|a| (a.name.clone(), a.browser_download_url.clone()))
.ok_or("未在 release 中找到便携版程序 (thing.exe)".to_string())?
};
let temp_dir = std::env::temp_dir().join("thing-update");
fs::create_dir_all(&temp_dir).map_err(|e| format!("创建临时目录失败: {}", e))?;
let dest = temp_dir.join(&target_name);
download_with_progress(&app, &target_url, &dest).await?;
let _ = app.emit(
UPDATE_PROGRESS,
UpdateProgress {
stage: "applying".into(),
percent: 100,
downloaded_bytes: 0,
total_bytes: None,
message: if installed { "正在启动安装程序...".into() } else { "正在替换程序文件...".into() },
},
);
if installed {
// 提权静默安装 /S;UAC 确认期间主进程已退出,安装器可正常覆盖
shell_execute("runas", &dest, "/S", 0)?;
} else {
apply_portable_update(&dest)?;
}
// 延迟退出,确保 ShellExecute 已拉起子进程
app.exit(0);
Ok(())
}
/// 更新 ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖内核文件
#[tauri::command]
#[specta::specta]
pub async fn update_thinghk(app: AppHandle) -> Result<(), String> {
let latest = fetch_latest_release().await?;
let asset = latest
.assets
.iter()
.find(|a| a.name.starts_with("thing-hk_") && a.name.ends_with(".zip"))
.ok_or("未在 release 中找到 ThingHK 内核包".to_string())?;
// 停止监控内核(含提权模式的 /shutdown 兜底由前端先停模块),避免 exe 被占用
if let Some(monitor) = app.try_state::<crate::monitor_kernel::MonitorKernel>() {
monitor.stop_subscription(&app).await;
}
if let Some(pm) = app.try_state::<crate::process_manager::ProcessManager>() {
let _ = pm.stop("monitor");
}
// 等待进程退出释放文件句柄
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
let temp_dir = std::env::temp_dir().join("thing-update");
fs::create_dir_all(&temp_dir).map_err(|e| format!("创建临时目录失败: {}", e))?;
let zip_path = temp_dir.join(&asset.name);
download_with_progress(&app, &asset.browser_download_url, &zip_path).await?;
let _ = app.emit(
UPDATE_PROGRESS,
UpdateProgress {
stage: "extracting".into(),
percent: 95,
downloaded_bytes: 0,
total_bytes: None,
message: "正在解压内核...".into(),
},
);
let extract_dir = temp_dir.join("thinghk_extract");
let _ = fs::remove_dir_all(&extract_dir);
extract_zip(&zip_path, &extract_dir)?;
// 在解压目录中查找 ThingHK.exe
let exe_path = find_thinghk_exe(&extract_dir).ok_or("内核包中未找到 ThingHK.exe".to_string())?;
let app_data = app
.path()
.app_data_dir()
.map_err(|e| format!("获取数据目录失败: {}", e))?;
let cores_dir = app_data.join("monitor").join("cores");
fs::create_dir_all(&cores_dir).map_err(|e| format!("创建内核目录失败: {}", e))?;
fs::copy(&exe_path, cores_dir.join("ThingHK.exe"))
.map_err(|e| format!("覆盖内核文件失败(请确认监控模块已停止): {}", e))?;
// 清理临时文件
let _ = fs::remove_file(&zip_path);
let _ = fs::remove_dir_all(&extract_dir);
let _ = app.emit(
UPDATE_PROGRESS,
UpdateProgress {
stage: "done".into(),
percent: 100,
downloaded_bytes: 0,
total_bytes: None,
message: "ThingHK 内核更新完成".into(),
},
);
Ok(())
}
fn find_thinghk_exe(dir: &Path) -> Option<PathBuf> {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(found) = find_thinghk_exe(&path) {
return Some(found);
}
} else if path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.eq_ignore_ascii_case("ThingHK.exe"))
.unwrap_or(false)
{
return Some(path);
}
}
}
None
}