@@ -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 = [ 0 u8 ; 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 , & 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
}