From 6c7897bf47c3fd641cdf8b080d16d6efd2d10587 Mon Sep 17 00:00:00 2001 From: zhongluofeng Date: Tue, 11 Aug 2026 17:19:36 +0800 Subject: [PATCH] =?UTF-8?q?=E7=89=88=E6=9C=AC=E7=AE=A1=E7=90=86=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + package.json | 2 +- scripts/release.bat | 61 +++ scripts/release.ps1 | 166 +++++++ src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/constants.rs | 2 + src-tauri/src/lib.rs | 8 + src-tauri/src/updater/mod.rs | 457 ++++++++++++++++++++ src-tauri/tauri.conf.json | 2 +- src/components/layout/TitleBar.vue | 20 +- src/lib/bindings.ts | 32 ++ src/lib/constants.ts | 2 + src/lib/use-module-tabs.ts | 27 +- src/main.ts | 3 + src/modules/clipboard/ClipboardModule.vue | 2 +- src/modules/clipboard/index.ts | 39 +- src/modules/downloader/DownloaderModule.vue | 2 +- src/modules/downloader/index.ts | 30 +- src/modules/monitor/MonitorModule.vue | 2 +- src/modules/monitor/index.ts | 51 ++- src/modules/proxy/ProxyModule.vue | 2 +- src/modules/proxy/index.ts | 96 +++- src/modules/quickpanel/index.ts | 25 ++ src/modules/screenshot/ScreenshotModule.vue | 2 +- src/modules/screenshot/index.ts | 39 +- src/modules/settings/GeneralSettings.vue | 227 +++++++++- src/modules/settings/index.ts | 25 ++ src/stores/appStore.ts | 3 +- src/stores/moduleTabsStore.ts | 24 + src/stores/searchIndex.ts | 2 + src/stores/searchStore.ts | 29 +- src/style.css | 11 + 33 files changed, 1361 insertions(+), 39 deletions(-) create mode 100644 scripts/release.bat create mode 100644 scripts/release.ps1 create mode 100644 src-tauri/src/updater/mod.rs diff --git a/.gitignore b/.gitignore index a547bf3..e589482 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* +# 发布暂存目录 +release_stage/ + node_modules dist dist-ssr diff --git a/package.json b/package.json index e530838..d5e18a6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "thing", "private": true, - "version": "0.1.0", + "version": "26.8.1", "type": "module", "scripts": { "dev": "vite", diff --git a/scripts/release.bat b/scripts/release.bat new file mode 100644 index 0000000..2e5e081 --- /dev/null +++ b/scripts/release.bat @@ -0,0 +1,61 @@ +@echo off +chcp 65001 >nul +setlocal enabledelayedexpansion + +set "SCRIPT=%~dp0release.ps1" + +echo ================================================ +echo Thing Build Pubilsh +echo 发布目标:https://gitea.atie.fun/LFeng/Thing +echo ================================================ +echo. + +:: ---------- 版本号 ---------- +set "VERSION=" +set /p "VERSION=请输入版本号(如 26.8.1,直接回车沿用当前版本): " +set "VER_ARG=" +if not "%VERSION%"=="" set "VER_ARG=-Version %VERSION%" + +:: ---------- 发布模式 ---------- +echo. +echo [1] Build + Pubilsh Gitea +echo [2] Build Only +echo [3] Pubilsh Gitea +set "MODE=" +set /p "MODE=请输入数字选择(回车默认 1): " +if "%MODE%"=="" set "MODE=1" + +set "EXTRA=" +if "%MODE%"=="2" ( + set "EXTRA=-SkipPush" +) else if "%MODE%"=="3" ( + set "EXTRA=-SkipBuild" +) + +:: ---------- Gitea Token(仅完整发布模式需要) ---------- +if "%MODE%"=="1" if not defined GITEA_TOKEN ( + echo. + echo 未检测到环境变量 GITEA_TOKEN,上传到 Gitea 需要它。 + echo 可在此临时输入(仅本次会话生效),留空则自动改为"只构建不上传"。 + set /p "GITEA_TOKEN=请输入 Gitea Token: " + if "!GITEA_TOKEN!"=="" ( + set "EXTRA=-SkipPush" + set "MODE=2" + echo [提示] 已切换为"只构建并整理产物,不上传"。 + ) +) + +echo. +echo Start:release.ps1 %VER_ARG% %EXTRA% +echo ------------------------------------------------ +powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT%" %VER_ARG% %EXTRA% +set "RESULT=%ERRORLEVEL%" + +echo. +if "%RESULT%"=="0" ( + echo 执行完成。 +) else ( + echo 执行出错,请查看上方日志。 +) +echo. +pause diff --git a/scripts/release.ps1 b/scripts/release.ps1 new file mode 100644 index 0000000..f1f35bb --- /dev/null +++ b/scripts/release.ps1 @@ -0,0 +1,166 @@ +# ============================================================ +# Thing 构建发布脚本(目标:自建 Gitea release) +# +# 用法: +# .\scripts\release.ps1 -Version 0.2.0 # 同步版本号 + 构建 + 发布 +# .\scripts\release.ps1 -Version 0.2.0 -SkipBuild # 复用现有构建产物,直接发布 +# .\scripts\release.ps1 -Version 0.2.0 -SkipPush # 只构建+整理产物,不上传 +# +# 发布产物(上传到 https://gitea.atie.fun/LFeng/Thing 的 v{Version} release): +# thing_{v}_x64.exe 便携免安装版(无内核) +# thing_{v}_x64.msi 安装版 MSI(去掉 tauri 默认的 _en-US 后缀) +# thing_{v}_x64-setup.exe 安装版 NSIS +# thing-hk_{v}.zip ThingHK 硬件监控内核(mihomo 继续走代理模块内置的 GitHub 下载) +# +# 前提: +# - Gitea token 已配置为环境变量 GITEA_TOKEN(-SkipPush 时不需要) +# - 可选 -NotesPath 指定 release notes 文件(Markdown 文本), +# 缺省时使用 scripts/release-notes.md(若存在),否则用简单占位文本 +# ============================================================ + +param( + [string]$Version = '', + [switch]$SkipBuild, + [switch]$SkipPush, + [string]$NotesPath = '' +) + +$ErrorActionPreference = 'Stop' + +$Root = Split-Path -Parent $PSScriptRoot +$TargetDir = Join-Path $Root 'src-tauri\target\release' +$BundleDir = Join-Path $TargetDir 'bundle' +$RepoOwner = 'LFeng' +$RepoName = 'Thing' +$ApiBase = 'https://gitea.atie.fun/api/v1' +$Product = 'thing' + +function Write-Step([string]$msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan } + +# ---------- 0. 版本号 ---------- +if ([string]::IsNullOrWhiteSpace($Version)) { + $conf = Get-Content (Join-Path $Root 'src-tauri\tauri.conf.json') -Raw | ConvertFrom-Json + $Version = $conf.version + Write-Step "未指定 -Version,沿用现有版本 $Version" +} +# 校验 X.Y.Z 格式 +if ($Version -notmatch '^\d+\.\d+\.\d+$') { + throw "版本号格式错误(应为 X.Y.Z):$Version" +} +Write-Step "发布版本:v$Version" + +# ---------- 1. 同步版本号到三处 ---------- +$tauriConf = Join-Path $Root 'src-tauri\tauri.conf.json' +$cargoToml = Join-Path $Root 'src-tauri\Cargo.toml' +$pkgJson = Join-Path $Root 'package.json' + +$t = Get-Content $tauriConf -Raw +$t = $t -replace '("version"\s*:\s*")[^"]*(")', "`${1}$Version`${2}" +[System.IO.File]::WriteAllText($tauriConf, $t, (New-Object System.Text.UTF8Encoding($false))) + +$c = Get-Content $cargoToml -Raw +$c = $c -replace '(?m)^(version\s*=\s*")[^"]*(")', "`${1}$Version`${2}" +[System.IO.File]::WriteAllText($cargoToml, $c, (New-Object System.Text.UTF8Encoding($false))) + +$p = Get-Content $pkgJson -Raw +$p = $p -replace '("version"\s*:\s*")[^"]*(")', "`${1}$Version`${2}" +[System.IO.File]::WriteAllText($pkgJson, $p, (New-Object System.Text.UTF8Encoding($false))) +Write-Step "版本号已同步:tauri.conf.json / Cargo.toml / package.json" + +# ---------- 2. 构建 ---------- +if (-not $SkipBuild) { + Write-Step '开始构建(bun run tauri build)...' + Push-Location $Root + try { bun run tauri build } + finally { Pop-Location } + if ($LASTEXITCODE -ne 0) { throw 'tauri build 失败' } +} else { + Write-Step '跳过构建,复用现有产物' +} + +# ---------- 3. 整理产物 ---------- +# 版本号此时已解析,再确定暂存目录 +$StageDir = Join-Path $Root "release_stage\$Version" +New-Item -ItemType Directory -Force -Path $StageDir | Out-Null + +$exeSrc = Join-Path $TargetDir "$Product.exe" +$msiSrc = Join-Path $BundleDir "msi\${Product}_${Version}_x64_en-US.msi" +$nsisSrc = Join-Path $BundleDir "nsis\${Product}_${Version}_x64-setup.exe" +$hkSrc = Join-Path $Root 'src-tauri\binaries\ThingHK.exe' + +$exeOut = Join-Path $StageDir "${Product}_${Version}_x64.exe" +$msiOut = Join-Path $StageDir "${Product}_${Version}_x64.msi" +$nsisOut = Join-Path $StageDir "${Product}_${Version}_x64-setup.exe" +$hkZip = Join-Path $StageDir "thing-hk_${Version}.zip" + +if (Test-Path $exeSrc) { Copy-Item $exeSrc $exeOut } else { Write-Warning "缺少便携版:$exeSrc" } +if (Test-Path $msiSrc) { Copy-Item $msiSrc $msiOut } else { Write-Warning "缺少 MSI(已跳过重命名):$msiSrc" } +if (Test-Path $nsisSrc) { Copy-Item $nsisSrc $nsisOut } else { Write-Warning "缺少 NSIS:$nsisSrc" } + +if (Test-Path $hkSrc) { + $tmp = Join-Path $env:TEMP "thinghk_$([guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Force -Path $tmp | Out-Null + Copy-Item $hkSrc (Join-Path $tmp 'ThingHK.exe') + Compress-Archive -Path (Join-Path $tmp 'ThingHK.exe') -DestinationPath $hkZip -Force + Remove-Item -Recurse -Force $tmp + Write-Step "ThingHK 内核包:$hkZip" +} else { + Write-Warning "缺少 ThingHK.exe:$hkSrc" +} + +Write-Step "产物已整理到:$StageDir" +Get-ChildItem $StageDir | Select-Object Name, @{n='Size(MB)';e={[math]::Round($_.Length/1MB,1)}} | Format-Table -AutoSize + +# ---------- 4. 上传到 Gitea ---------- +if ($SkipPush) { + Write-Step '已跳过上传(-SkipPush)' + exit 0 +} + +$token = $env:GITEA_TOKEN +if ([string]::IsNullOrWhiteSpace($token)) { + throw '未设置环境变量 GITEA_TOKEN,无法发布到 Gitea(或使用 -SkipPush 跳过上传)' +} +$auth = @{ Authorization = "token $token" } + +# 4.1 release notes +if (-not [string]::IsNullOrWhiteSpace($NotesPath)) { + $body = Get-Content $NotesPath -Raw +} elseif (Test-Path (Join-Path $PSScriptRoot 'release-notes.md')) { + $body = Get-Content (Join-Path $PSScriptRoot 'release-notes.md') -Raw +} else { + $body = "Thing v$Version" +} + +# 4.2 创建 release(已存在同名 tag 则复用) +Write-Step "创建 release v$Version ..." +$releaseUrl = "$ApiBase/repos/$RepoOwner/$RepoName/releases" +$releasePayload = @{ + tag_name = "v$Version" + name = "Thing v$Version" + body = $body + draft = $false + prerelease = $false +} | ConvertTo-Json + +try { + $release = Invoke-RestMethod -Method Post -Uri $releaseUrl -Headers $auth -ContentType 'application/json' -Body $releasePayload +} catch { + # tag 已存在:尝试用该 tag 查找现有 release,后续资产上传会追加 + Write-Warning "创建 release 失败,尝试复用已有 release:$_" + $release = Invoke-RestMethod -Method Get -Uri "$releaseUrl/tags/v$Version" -Headers $auth +} +$releaseId = $release.id +Write-Step "release id=$releaseId" + +# 4.3 逐个上传资产 +$assets = @($exeOut, $msiOut, $nsisOut, $hkZip) | Where-Object { Test-Path $_ } +foreach ($file in $assets) { + $name = Split-Path $file -Leaf + Write-Step "上传 $name ..." + $assetUrl = "$releaseUrl/$releaseId/assets?name=$([uri]::EscapeDataString($name))" + $resp = Invoke-RestMethod -Method Post -Uri $assetUrl -Headers $auth -ContentType 'application/octet-stream' -InFile $file + Write-Host " -> $($resp.browser_download_url)" -ForegroundColor Green +} + +Write-Step "发布完成:https://gitea.atie.fun/$RepoOwner/$RepoName/releases/tag/v$Version" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 24fa4b6..e035e56 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4743,7 +4743,7 @@ dependencies = [ [[package]] name = "thing" -version = "0.1.0" +version = "26.8.1" dependencies = [ "axum", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f92af5e..333151e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thing" -version = "0.1.0" +version = "26.8.1" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/constants.rs b/src-tauri/src/constants.rs index 4860c21..a13f61c 100644 --- a/src-tauri/src/constants.rs +++ b/src-tauri/src/constants.rs @@ -46,6 +46,8 @@ pub mod events { pub const SCREENSHOT_PIN_SHORTCUT: &str = "screenshot-pin-shortcut"; // 内核安装进度 pub const KERNEL_INSTALL_PROGRESS: &str = "kernel-install-progress"; + // 应用更新进度 + pub const UPDATE_PROGRESS: &str = "update-progress"; // 进程与下载 pub const PROCESS_STATUS_CHANGED: &str = "process-status-changed"; pub const DOWNLOAD_ADDED: &str = "download-added"; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7ded8a3..a3035a8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,7 @@ mod setup; mod shortcut; mod snap_fix; mod tray_menu; +mod updater; mod win32_util; use download_engine::{ @@ -77,6 +78,7 @@ use quickpanel::{ quickpanel_unregister_shortcut, }; use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready}; +use updater::{app_version, update_check, update_install, update_thinghk}; #[tauri::command] fn quit_app(app: tauri::AppHandle) { @@ -101,6 +103,8 @@ fn export_bindings() { // 生成命令失败时直接 throw,与原生 invoke 一致,前端无需解包 helper .error_handling(ErrorHandlingMode::Throw) .commands(collect_commands![ + // 应用更新(4) + app_version, update_check, update_install, update_thinghk, // proxy(20) proxy_activate_profile, proxy_check_kernel_update, proxy_clear_system_proxy, proxy_close_connection, proxy_delete_profile, proxy_get_settings, @@ -163,6 +167,10 @@ pub fn run() { .manage(ProcessManager::new()) .invoke_handler(tauri::generate_handler![ quit_app, + app_version, + update_check, + update_install, + update_thinghk, process_start, process_stop, process_status, diff --git a/src-tauri/src/updater/mod.rs b/src-tauri/src/updater/mod.rs new file mode 100644 index 0000000..3d675dd --- /dev/null +++ b/src-tauri/src/updater/mod.rs @@ -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, +} + +/// 更新进度事件载荷(与内核安装进度同构,独立事件便于 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, + }) +} + +// ---------- 下载 / 解压 ---------- + +/// 下载文件到 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 = 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 → 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::() { + monitor.stop_subscription(&app).await; + } + if let Some(pm) = app.try_state::() { + 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 { + 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 +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8348778..f0b9a1b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "thing", - "version": "0.1.0", + "version": "26.8.1", "identifier": "thing.lfeng.me", "build": { "beforeDevCommand": "bun run dev", diff --git a/src/components/layout/TitleBar.vue b/src/components/layout/TitleBar.vue index fc717ab..de5c8c7 100644 --- a/src/components/layout/TitleBar.vue +++ b/src/components/layout/TitleBar.vue @@ -42,6 +42,11 @@ const hasSearchContent = computed(() => { return searchQuery.value.trim().length > 0 }) +/** 模块 id → 名称 查找表(用于搜索结果中标注所属模块) */ +const moduleNameById = computed(() => { + return new Map(props.modules.map(m => [m.id, m.name])) +}) + const handleSearchSelect = (moduleId: string) => { emit('search', moduleId) searchQuery.value = '' @@ -50,6 +55,10 @@ const handleSearchSelect = (moduleId: string) => { const handleSettingSelect = (item: SearchItem) => { emit('search', item.moduleId) + // 记录待跳转 tab:模块挂载后由 useModuleTabs 自动切换(模块已挂载时同样生效) + if (item.tab) { + tabsStore.setPendingTab(item.moduleId, item.tab) + } if (item.action) { item.action() } @@ -323,14 +332,19 @@ const handleBlur = () => { class="w-full px-3 py-2 text-left text-sm hover:bg-accent transition-colors flex items-center gap-2" @click="handleSettingSelect(item)" > - +
- {{ item.title }} +
+ {{ item.title }} + + {{ moduleNameById.get(item.moduleId) || item.moduleId }} + +
{{ item.description }}
- + diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index b44c015..22d9afd 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -4,6 +4,19 @@ import { invoke as __TAURI_INVOKE } from "@tauri-apps/api/core"; /** Commands */ export const commands = { + /** 获取当前应用版本 */ + appVersion: () => __TAURI_INVOKE("app_version"), + /** 检查 Gitea 最新 release,返回版本对比与可用资产 */ + updateCheck: () => __TAURI_INVOKE("update_check"), + /** + * 更新应用本体。 + * 便携版:下载 thing_{v}_x64.exe → update.bat 覆盖重启; + * 安装版:下载 thing_{v}_x64-setup.exe → 提权静默安装 /S。 + * 下载进度通过 UPDATE_PROGRESS 事件上报,调用方返回前会触发应用退出。 + */ + updateInstall: () => __TAURI_INVOKE("update_install"), + /** 更新 ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖内核文件 */ + updateThinghk: () => __TAURI_INVOKE("update_thinghk"), proxyActivateProfile: (id: string) => __TAURI_INVOKE("proxy_activate_profile", { id }), proxyCheckKernelUpdate: () => __TAURI_INVOKE("proxy_check_kernel_update"), proxyClearSystemProxy: () => __TAURI_INVOKE("proxy_clear_system_proxy"), @@ -480,6 +493,25 @@ export type TaskStatus = /** 错误 */ "error"; +/** release 中的一个资产 */ +export type UpdateAsset = { + name: string, + size: number, + browserDownloadUrl: string, +}; + +/** 检查更新的结果 */ +export type UpdateCheckResult = { + currentVersion: string, + latestVersion: string, + hasUpdate: boolean, + /** portable | installed */ + installType: string, + releaseName: string, + releaseBody: string, + assets: UpdateAsset[], +}; + /** 窗口信息(窗口拾取 / 枚举) */ export type WindowInfo = { hwnd: number, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 5afd642..ac43708 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -38,6 +38,8 @@ export const EVENTS = { screenshotExported: 'screenshot-exported', // 内核安装进度 kernelInstallProgress: 'kernel-install-progress', + // 应用更新进度 + updateProgress: 'update-progress', // 监控 OSD osdStateUpdate: 'osd-state-update', osdContentSize: 'osd-content-size', diff --git a/src/lib/use-module-tabs.ts b/src/lib/use-module-tabs.ts index 904c13c..f2a5498 100644 --- a/src/lib/use-module-tabs.ts +++ b/src/lib/use-module-tabs.ts @@ -12,12 +12,15 @@ import { useModuleTabsStore, type ModuleTab } from '@/stores/moduleTabsStore' * ```ts * // 模块