507 lines
18 KiB
Rust
507 lines
18 KiB
Rust
//! 应用自更新模块。
|
||
//! 更新源为自建 Gitea:`https://gitea.atie.fun/LFeng/Thing` 的 release 资产。
|
||
//! - 便携版(无 unins000.exe 且不在 Program Files):下载新 thing.exe → update.bat 覆盖重启
|
||
//! - 安装版(NSIS):下载新 setup.exe → 提权静默安装 /S
|
||
//! - ThingHK 内核:下载由前端下载模块完成 → apply 命令 need_stop 等待确认 → 解压覆盖
|
||
//! {app_data}/monitor/cores/ThingHK.exe(与代理模块 mihomo 内核更新同模式)
|
||
use serde::Serialize;
|
||
use specta::Type;
|
||
use std::fs;
|
||
use std::io::{Read, Write};
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::{atomic::{AtomicBool, Ordering}, Mutex};
|
||
use tauri::{AppHandle, Emitter, Manager};
|
||
use tokio::sync::{oneshot, watch};
|
||
|
||
use crate::constants::events::UPDATE_PROGRESS;
|
||
|
||
/// 用户主动取消 ThingHK 更新的标记错误信息(前端据此静默处理,不弹错误 toast)
|
||
const THINGHK_UPDATE_CANCELLED: &str = "更新已取消";
|
||
|
||
/// ThingHK 内核更新的跨命令状态:apply 过程中 need_stop 阶段等待前端确认。
|
||
/// 与 MihomoManager 的 install_confirm/kernel_cancel 同构。
|
||
pub struct ThinghkUpdateState {
|
||
/// need_stop 等待阶段的确认通道(前端调 confirm 命令时唤醒 apply 继续)
|
||
confirm_tx: Mutex<Option<oneshot::Sender<()>>>,
|
||
/// 取消标志 + 唤醒通道(前端调 cancel 命令时置位,apply 等待循环立即返回)
|
||
cancel_flag: AtomicBool,
|
||
cancel_tx: watch::Sender<bool>,
|
||
cancel_rx: watch::Receiver<bool>,
|
||
}
|
||
|
||
impl ThinghkUpdateState {
|
||
pub fn new() -> Self {
|
||
let (tx, rx) = watch::channel(false);
|
||
Self {
|
||
confirm_tx: Mutex::new(None),
|
||
cancel_flag: AtomicBool::new(false),
|
||
cancel_tx: tx,
|
||
cancel_rx: rx,
|
||
}
|
||
}
|
||
|
||
/// 前端已停止监控内核,唤醒 apply 继续解压替换
|
||
fn confirm(&self) {
|
||
if let Some(tx) = self.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()).take() {
|
||
let _ = tx.send(());
|
||
}
|
||
}
|
||
|
||
/// 取消更新:置位取消标志并唤醒 apply 等待循环
|
||
fn cancel(&self) {
|
||
self.cancel_flag.store(true, Ordering::SeqCst);
|
||
let _ = self.cancel_tx.send(true);
|
||
}
|
||
|
||
/// 进入新的 apply 流程前复位取消标志
|
||
fn reset(&self) {
|
||
self.cancel_flag.store(false, Ordering::SeqCst);
|
||
let _ = self.cancel_tx.send(false);
|
||
*self.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||
}
|
||
}
|
||
|
||
/// 发布仓库(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,
|
||
})
|
||
}
|
||
|
||
// ---------- 解压 ----------
|
||
// ThingHK 内核更新包由前端下载模块负责下载(同 mihomo),此处仅解压替换。
|
||
|
||
/// 用 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 / 安装版 thing_{v}_x64-setup.exe)。
|
||
/// 便携版:copy 到临时目录 → update.bat 覆盖重启;
|
||
/// 安装版:copy 到临时目录 → 提权静默安装 /S。
|
||
/// 调用返回前会触发应用退出。
|
||
#[tauri::command]
|
||
#[specta::specta]
|
||
pub async fn update_install(app: AppHandle, downloaded_path: String) -> Result<(), String> {
|
||
let src = PathBuf::from(&downloaded_path);
|
||
if !src.exists() {
|
||
return Err(format!("下载文件不存在: {}", downloaded_path));
|
||
}
|
||
let installed = is_installed_version();
|
||
// copy 到临时目录:与 update.bat / 安装器解耦,随后即可删除下载目录中的源文件
|
||
let temp_dir = std::env::temp_dir().join("thing-update");
|
||
fs::create_dir_all(&temp_dir).map_err(|e| format!("创建临时目录失败: {}", e))?;
|
||
let file_name = src
|
||
.file_name()
|
||
.map(|n| n.to_string_lossy().to_string())
|
||
.unwrap_or_else(|| "thing_update.exe".into());
|
||
let dest = temp_dir.join(&file_name);
|
||
fs::copy(&src, &dest).map_err(|e| format!("复制安装包到临时目录失败: {}", e))?;
|
||
// 临时副本就绪后清理下载目录中的源文件(失败不影响更新流程)
|
||
let _ = fs::remove_file(&src);
|
||
|
||
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 内核更新:下载阶段已由下载模块完成,本命令仅做
|
||
/// need_stop(等待前端停止监控内核并确认)→ 解压 → 替换。
|
||
/// 进度通过 UPDATE_PROGRESS 事件上报,前端据 need_stop 弹出确认对话框。
|
||
#[tauri::command]
|
||
#[specta::specta]
|
||
pub async fn update_thinghk_apply(
|
||
app: AppHandle,
|
||
state: tauri::State<'_, ThinghkUpdateState>,
|
||
zip_path: String,
|
||
) -> Result<(), String> {
|
||
state.reset();
|
||
let result = update_thinghk_apply_inner(&app, &state, PathBuf::from(&zip_path)).await;
|
||
if let Err(ref e) = result {
|
||
// 取消是用户主动行为,静默返回即可;其余失败 emit error 阶段避免前端进度卡死
|
||
if e != THINGHK_UPDATE_CANCELLED {
|
||
let _ = app.emit(
|
||
UPDATE_PROGRESS,
|
||
UpdateProgress {
|
||
stage: "error".into(),
|
||
percent: 0,
|
||
downloaded_bytes: 0,
|
||
total_bytes: None,
|
||
message: e.clone(),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
result
|
||
}
|
||
|
||
async fn update_thinghk_apply_inner(
|
||
app: &AppHandle,
|
||
state: &ThinghkUpdateState,
|
||
zip_path: PathBuf,
|
||
) -> Result<(), String> {
|
||
if !zip_path.exists() {
|
||
return Err(format!("下载文件不存在: {}", zip_path.display()));
|
||
}
|
||
|
||
// need_stop:等待前端停止监控内核并确认(exe 被占用会导致覆盖失败)。
|
||
// 确认/取消由 confirm/cancel 命令跨命令唤醒(与 mihomo need_stop 同构)。
|
||
let _ = app.emit(
|
||
UPDATE_PROGRESS,
|
||
UpdateProgress {
|
||
stage: "need_stop".into(),
|
||
percent: 90,
|
||
downloaded_bytes: 0,
|
||
total_bytes: None,
|
||
message: "需要停止监控内核才能继续安装".into(),
|
||
},
|
||
);
|
||
let (tx, mut rx) = oneshot::channel::<()>();
|
||
*state.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx);
|
||
let mut cancel_rx = state.cancel_rx.clone();
|
||
loop {
|
||
if state.cancel_flag.load(Ordering::SeqCst) {
|
||
*state.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||
return Err(THINGHK_UPDATE_CANCELLED.to_string());
|
||
}
|
||
tokio::select! {
|
||
_ = &mut rx => break,
|
||
_ = cancel_rx.changed() => {}
|
||
}
|
||
}
|
||
|
||
// 解压阶段
|
||
let _ = app.emit(
|
||
UPDATE_PROGRESS,
|
||
UpdateProgress {
|
||
stage: "extracting".into(),
|
||
percent: 95,
|
||
downloaded_bytes: 0,
|
||
total_bytes: None,
|
||
message: "正在解压内核...".into(),
|
||
},
|
||
);
|
||
let temp_dir = std::env::temp_dir().join("thing-update");
|
||
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(())
|
||
}
|
||
|
||
/// 前端已停止监控内核,确认继续解压替换(唤醒 need_stop 等待)
|
||
#[tauri::command]
|
||
#[specta::specta]
|
||
pub fn update_thinghk_confirm(state: tauri::State<'_, ThinghkUpdateState>) -> Result<(), String> {
|
||
state.confirm();
|
||
Ok(())
|
||
}
|
||
|
||
/// 取消 ThingHK 内核更新(need_stop 等待阶段有效:唤醒 apply 以「已取消」返回,zip 保留便于重试)
|
||
#[tauri::command]
|
||
#[specta::specta]
|
||
pub fn update_thinghk_cancel(state: tauri::State<'_, ThinghkUpdateState>) -> Result<(), String> {
|
||
state.cancel();
|
||
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
|
||
}
|