版本管理,优化
This commit is contained in:
@@ -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.exe;MSI 通常安装到 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, ¤t_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
|
||||
}
|
||||
Reference in New Issue
Block a user