//! 应用自更新模块。 //! 更新源为自建 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>>, /// 取消标志 + 唤醒通道(前端调 cancel 命令时置位,apply 等待循环立即返回) cancel_flag: AtomicBool, cancel_tx: watch::Sender, cancel_rx: watch::Receiver, } 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, } /// 更新进度事件载荷(与内核安装进度同构,独立事件便于 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)] pub total_bytes: Option, 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::()) .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, } async fn fetch_latest_release() -> Result { 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 = file.as_os_str().encode_wide().chain(std::iter::once(0)).collect(); let verb_w: Vec = verb.encode_utf16().chain(std::iter::once(0)).collect(); let params_w: Vec = 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 { 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 { 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 }