背景 ZImgTools 是基于 Tauri 2 + Rust + Vue 3 的图片压缩转换桌面工具。
安装包通过 GitHub Actions 构建并发布到独立仓库 psvmc/z-img-tools-releases 。
应用需要具备:
启动后自动检查 GitHub Release 是否有新版本
设置中支持手动「检查更新」
用户确认后再下载安装,支持「稍后 / 跳过该版本 / 立即更新」
下载过程在弹窗内显示进度条与已下载大小
Windows 下载 NSIS 安装包后弹出安装向导,由用户手动完成;macOS 打开 dmg;Linux 走 deb 包安装
与 Wails 内置 Updater 不同,本项目 直接使用 GitHub Releases API 检查版本,并按平台下载现有安装包(.exe / .dmg / .deb),无需 latest.json 与签名密钥,与当前 CI 发版产物一致。
整体架构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 ┌─────────────────────────────────────────────────────────────┐ │ 前端 Vue │ │ useAppUpdate / UpdateConfirmDialog / SettingsDialog │ │ listen("update:download-progress") │ └───────────────────────────┬─────────────────────────────────┘ │ invoke + event ┌───────────────────────────▼─────────────────────────────────┐ │ update 模块(Rust) │ │ check_for_update / apply_update │ └───────────────────────────┬─────────────────────────────────┘ │ ┌───────────────────┼───────────────────┐ ▼ ▼ ▼ github.rs apply.rs helper.rs (Windows) GitHub API 分块下载 + 进度事件 detached 启动 NSIS │ │ │ └───────────────────┴───────────────────┘ │ ┌───────────────────────────▼─────────────────────────────────┐ │ GitHub Release(psvmc/z-img-tools-releases) │ │ *-setup.exe / *_universal.dmg / *_amd64.deb │ └───────────────────────────────────────────────────────────────┘
核心思路:检查更新走 GitHub API ,安装逻辑自研 (Windows 下载到系统临时目录后 detached 启动 NSIS,主进程退出释放文件锁),UI 完全自定义。
版本更新脚本 应用内更新依赖 客户端版本号 与 GitHub Release tag 一致。发新版本前用脚本统一改号,再 commit、打 tag、触发 CI。脚本位于源码仓 scripts/:
1 2 3 4 5 6 7 8 scripts/ ├── set-version.bat # 版本号一键同步(入口) ├── set-version.ps1 # 版本脚本实现 ├── publish-release.bat # 发版入口(读版本、触发 GitHub Actions) └── release/ ├── ensure-release-gha.bat # 同步 workflow 到发布仓 ├── ensure-release-gha.ps1 └── github-workflow-release-all.yml
workflow 同步与三平台构建细节见 发版文 。
升级版本脚本 路径:scripts/set-version.bat(调用同目录 set-version.ps1)
发版前同步各文件版本号。无参数时显示当前版本并交互输入;也支持 1.2.0、patch、minor、major。
1 2 3 4 5 6 @echo off setlocal cd /d "%~dp0.."powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0set-version.ps1" %* exit /b %errorlevel%
用法示例:
1 2 3 4 5 scripts\set -version.bat scripts\set -version.bat 1 .2 .0 scripts\set -version.bat patch scripts\set -version.bat minor scripts\set -version.bat major
路径:scripts/set-version.ps1
读取 src-tauri/Cargo.toml 当前版本,通过 npm version --no-git-tag-version 更新 package.json / package-lock.json,并同步 Rust 与 Tauri 配置(UTF-8 读写,避免中文乱码)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 param ( [Parameter (Position = 0 )] [string ]$Target ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest$Root = Split-Path -Parent $PSScriptRoot Set-Location $Root $Utf8NoBom = New-Object System.Text.UTF8Encoding $false function Read-Utf8File ([string]$Path ) { return [System.IO.File ]::ReadAllText($Path , [System.Text.Encoding ]::UTF8) } function Write-Utf8NoBomFile ([string]$Path , [string]$Content ) { [System.IO.File ]::WriteAllText($Path , $Content , $Utf8NoBom ) } function Get-CurrentVersion { $cargo = Join-Path $Root 'src-tauri\Cargo.toml' foreach ($line in ((Read-Utf8File $cargo ) -split "`r?`n" )) { if ($line -match '^\s*version\s*=\s*"([^"]+)"\s*$' ) { return $Matches [1 ] } } throw 'Could not read version from src-tauri\Cargo.toml' } function Test-SemVer ([string]$Version ) { return $Version -match '^\d+\.\d+\.\d+$' } function Bump-SemVer ([string]$Version , [string]$Kind ) { $parts = $Version -split '\.' if ($parts .Count -ne 3 ) { throw "Invalid version format: $Version " } $major = [int ]$parts [0 ] $minor = [int ]$parts [1 ] $patch = [int ]$parts [2 ] switch ($Kind .ToLowerInvariant()) { 'major' { return '{0}.0.0' -f ($major + 1 ) } 'minor' { return '{0}.{1}.0' -f $major , ($minor + 1 ) } 'patch' { return '{0}.{1}.{2}' -f $major , $minor , ($patch + 1 ) } default { throw "Unknown bump kind: $Kind " } } } function Update-TauriConf ([string]$NewVersion ) { $path = Join-Path $Root 'src-tauri\tauri.conf.json' $content = Read-Utf8File $path $content = $content -replace '"version"\s*:\s*"\d+\.\d+\.\d+"' , ('"version": "{0}"' -f $NewVersion ) $content = [regex ]::Replace( $content , '("title"\s*:\s*"[^"]*v)\d+\.\d+\.\d+(")' , { param ($match ) $match .Groups[1 ].Value + $NewVersion + $match .Groups[2 ].Value } ) Write-Utf8NoBomFile $path $content } function Update-CargoToml ([string]$NewVersion ) { $path = Join-Path $Root 'src-tauri\Cargo.toml' $content = Read-Utf8File $path $content = $content -replace '(?m)^version\s*=\s*"\d+\.\d+\.\d+"' , ('version = "{0}"' -f $NewVersion ) Write-Utf8NoBomFile $path $content } function Update-CargoLock ([string]$NewVersion ) { $path = Join-Path $Root 'src-tauri\Cargo.lock' $content = Read-Utf8File $path $content = [regex ]::Replace( $content , '(?ms)(name = "z-img-tools"\r?\nversion = ")\d+\.\d+\.\d+(")' , { param ($match ) $match .Groups[1 ].Value + $NewVersion + $match .Groups[2 ].Value } ) Write-Utf8NoBomFile $path $content } $current = Get-CurrentVersion if (-not $Target ) { Write-Host '' Write-Host "Current version: $current " Write-Host 'Enter new version (e.g. 1.2.0, or patch / minor / major):' $Target = Read-Host 'New version' Write-Host '' } if (-not $Target -or -not $Target .Trim()) { Write-Host 'Cancelled: no version entered.' exit 1 } $Target = $Target .Trim()$kind = $Target .ToLowerInvariant()if ($kind -in @ ('patch' , 'minor' , 'major' )) { $newVersion = Bump-SemVer $current $kind } elseif (Test-SemVer $Target ) { $newVersion = $Target } else { Write-Error "Invalid target: $Target . Use semver (e.g. 1.2.0) or patch/minor/major." exit 1 } if ($newVersion -eq $current ) { Write-Host "Version unchanged: $current " exit 0 } Write-Host "Updating version: $current -> $newVersion " Write-Host '' $npm = Get-Command npm -ErrorAction SilentlyContinueif (-not $npm ) { Write-Error 'npm not found. Install Node.js to update package.json and package-lock.json.' exit 1 } Push-Location $Root try { npm version $newVersion --no-git-tag-version --allow-same-version | Out-Null if ($LASTEXITCODE -ne 0 ) { throw 'npm version failed' } } finally { Pop-Location } Update-CargoToml $newVersion Update-CargoLock $newVersion Update-TauriConf $newVersion Write-Host 'Updated files:' Write-Host ' - package.json' Write-Host ' - package-lock.json' Write-Host ' - src-tauri\Cargo.toml' Write-Host ' - src-tauri\Cargo.lock' Write-Host ' - src-tauri\tauri.conf.json' Write-Host '' Write-Host "Done. Current version: $newVersion "
脚本同步的文件:
文件
更新内容
package.json / package-lock.json
version(npm version)
src-tauri/Cargo.toml
[package] 的 version
src-tauri/Cargo.lock
z-img-tools 包版本
src-tauri/tauri.conf.json
version、窗口 title 中的 vX.Y.Z
check_for_update 读取的 current_version 来自 Cargo.toml / tauri.conf.json 打包结果;GitHub Release 的 tag_name 须与之一致(不加 v 前缀 ,如 1.2.0)。
发版入口脚本 路径:scripts/publish-release.bat
改完版本并 git push tag 到 Gitee 后执行,从 Cargo.toml 读版本(或传入 tag),同步 workflow 到发布仓并触发 GitHub Actions 构建安装包。构建完成后用户端才能检测到新版本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 @echo off setlocal EnableDelayedExpansioncd /d "%~dp0.."set "GITHUB_REPO=psvmc/z-img-tools-releases"set "GITEE_REPO=https://gitee.com/psvmc/z-img-tools-tauri.git"set "TAG=":parse_args if "%~1 "=="" goto args_doneif not defined TAG set "TAG=%~1 "shift goto parse_args:args_done if not defined TAG ( for /f "usebackq tokens=2 delims== " %%a in (`findstr /R /C:"^version " src-tauri\Cargo.toml`) do set "VER =%%a " set "VER =!VER:"=! " for /f "tokens=* delims= " %%a in ("!VER! ") do set "VER =%%a " set "TAG=!VER! " ) where gh >nul 2 >&1 || ( echo [error] Install GitHub CLI and run: gh auth login exit /b 1 ) echo .echo === ZImgTools release (GitHub Actions: Windows + Linux + macOS) ===echo Tag: %TAG% echo GitHub repo: %GITHUB_REPO% echo Gitee repo: %GITEE_REPO% echo .echo Prerequisites:echo 1 . Gitee token in GitHub secret GITEE_TOKENecho gh secret set GITEE_TOKEN -R %GITHUB_REPO% --body "YOUR_GITEE_TOKEN"echo 2 . Push tag to Gitee: git push origin %TAG% echo .set "GITHUB_REPO=%GITHUB_REPO% "call "%~dp0release\ensure-release-gha.bat"if errorlevel 1 exit /b 1 echo Triggering release-all workflow...gh workflow run release-all.yml -R "%GITHUB_REPO% " -f "tag=%TAG% " -f "gitee_repo=%GITEE_REPO% " if errorlevel 1 ( echo [error] Failed to start workflow exit /b 1 ) echo .echo Build started. Wait 10 -30 min, then check:echo https://github.com/%GITHUB_REPO% /releases/tag/%TAG% echo https://github.com/%GITHUB_REPO% /actionsecho .echo Watch: gh run watch -R %GITHUB_REPO% exit /b 0
发版完整流程 1 2 3 4 5 scripts\set -version.bat 1 .2 .0 git add -A && git commit -m "chore: bump version to 1 .2 .0 " git tag 1 .2 .0 git push origin master --tags scripts\publish-release.bat
tag 须先推到 Gitee,再执行 publish-release.bat;Cargo.toml 版本、git tag、GitHub Release tag_name 三者保持一致,客户端 semver 比较才会正确识别「有新版本」。
何时启用应用内更新 1 2 3 4 pub fn update_enabled () -> bool { !cfg! (debug_assertions) }
前端对应 import.meta.env.PROD,开发模式手动点「检查更新」会提示「开发模式下不检查更新」。
依赖 src-tauri/Cargo.toml:
1 2 reqwest = { version = "0.12" , default-features = false , features = ["rustls-tls" , "json" ] }semver = "1"
reqwest 用于请求 GitHub API 与分块下载安装包;semver 用于版本比较。
注册 Command src-tauri/src/lib.rs 注册更新命令:
1 2 3 4 5 6 7 pub mod update;.invoke_handler (tauri::generate_handler![ check_for_update, apply_update ])
main.rs 直接启动 Tauri,不再 通过重启自身 exe 进入 Helper 模式。
检查更新 数据源:
1 https://api.github.com/repos/psvmc/z-img-tools-releases/releases/latest
src-tauri/src/update/github.rs 请求并解析 Release,按平台后缀匹配安装包,并优先选择以 ZImgTools 开头的资源 (避免旧版 z-img-tools 命名干扰):
平台
资源文件名后缀
示例
Windows
-setup.exe
ZImgTools_1.2.0_x64-setup.exe
macOS
.dmg
ZImgTools_1.2.0_universal.dmg
Linux
.deb
ZImgTools_1.2.0_amd64.deb
check_for_update 命令返回结构体(camelCase 序列化给前端):
1 2 3 4 5 6 7 8 9 10 pub struct UpdateCheckResult { pub available: bool , pub enabled: bool , pub current_version: String , pub latest_version: Option <String >, pub release_name: Option <String >, pub notes: Option <String >, pub release_url: Option <String >, pub download_url: Option <String >, }
各平台安装策略 Windows 下载
保存到系统临时目录 %TEMP%,文件名 {pid}-{原文件名},例如 %TEMP%\12345-ZImgTools_1.2.0_x64-setup.exe
不会 下载到程序安装目录
使用 response.chunk() 分块写入,通过 Tauri 事件 update:download-progress 推送进度(已下载字节、总大小、百分比)
1 2 3 4 5 pub struct DownloadProgressPayload { pub downloaded: u64 , pub total: Option <u64 >, pub percent: Option <u8 >, }
安装 问题与解法(迭代后的最终方案):
问题
错误做法
正确做法
主进程占用 exe
用 ZImgTools.exe 再起 Helper 子进程
下载完成后 detached 启动安装包 ,主进程 exit(0)
弹出 cmd 黑窗 ping
cmd /c "ping ... & setup.exe"
直接 Command::new(installer).arg("/UPDATE").spawn()
装到错误目录
传 /D=current_exe.parent()
不传 /D= ,由 NSIS 从注册表 Software\psvmc\ZImgTools 恢复安装路径
先卸载再安装
未传 /UPDATE
应用内更新固定传 /UPDATE :覆盖安装、保留数据、不重复建快捷方式
安装参数(仅 GUI 更新模式,用户手动完成向导):
1 2 3 4 5 6 7 Command::new (installer_path) .arg ("/UPDATE" ) .creation_flags (CREATE_BREAKAWAY_FROM_JOB | CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS) .spawn ()?; thread::sleep (Duration::from_millis (300 )); std::process::exit (0 );
说明:
/UPDATE 时 NSIS 不会真正执行卸载旧版 ,而是在原目录覆盖 ZImgTools.exe;GUI 下仍可能短暂出现「是否先卸载」页,点下一步后会因 $UpdateMode=1 跳过卸载逻辑
不使用 /S(静默)、/P(被动)、/R(自动重启),安装完成后由用户在向导里选择是否运行
更新日志:%TEMP%\zimgtools-update-<pid>.log
macOS 下载 .dmg 后 open 打开,用户拖拽安装,随后主进程退出。
Linux 优先 pkexec dpkg -i / sudo dpkg -i 安装 deb;失败则 xdg-open 打开安装包。
跳过版本 前端 localStorage 键 zimgtools.skippedUpdateVersion。检查到新版本后若与跳过版本相同则不再弹窗;用户点「跳过该版本」时写入。
仅跳过指定版本号 ;之后发布更高版本仍会正常提醒。
前端实现 启动时检查 App.vue:
1 2 3 4 5 6 7 8 9 const dismissBlockingDialogs = ( ) => { state.showResults = false ; }; const { checkOnStartup } = useAppUpdate ({ dismissBlockingDialogs });onMounted (() => { void checkOnStartup (); });
useAppUpdate src/composables/useAppUpdate.ts 封装检查、确认、下载、安装:
1 2 3 4 5 6 7 8 const unlisten = await listen<DownloadProgressPayload >( "update:download-progress" , (event ) => { setDownloadProgress (event.payload .percent ?? null , formatDownloadStatus (event.payload )); }, ); await invoke ("apply_update" , { downloadUrl : result.downloadUrl });
启动检查失败时静默跳过
手动检查无新版本时 Toast「当前已是最新版本」
弹出更新确认前会关闭处理结果弹窗
用户点「立即更新」后弹窗切换为下载视图 (进度条 + 已下载/总大小),不再用 Toast 冒充进度
更新确认弹窗 UpdateConfirmDialog.vue 两阶段:
prompt :版本对比、更新日志,按钮「跳过该版本 | 稍后 | 立即更新」
downloading :进度条 + 状态文案(如 正在下载 2.3 MB / 3.9 MB(58%))
useUpdateConfirm.ts 中 confirm() 会切到 downloading 阶段并 resolve Promise,弹窗保持打开直至下载结束或失败。
设置入口 侧边栏底部「设置」→ SettingsDialog.vue,显示当前版本与「检查更新」按钮;点击后先关闭设置弹窗再检查。
与 CI 发版的配合 CI 与 workflow 细节详见 Gitee 源码 + GitHub Actions 三平台 Release 发布 。
Release workflow 在 scripts/release/github-workflow-release-all.yml,从 Gitee 拉 tag 源码,三平台产物上传到 psvmc/z-img-tools-releases 。
Updater 依赖 Release 资源文件名与 preferred_asset_suffix 一致 ,且 Windows 安装包以 ZImgTools 开头,workflow 产物示例:
Windows: ZImgTools_1.2.0_x64-setup.exe
macOS: ZImgTools_1.2.0_universal.dmg
Linux: ZImgTools_1.2.0_amd64.deb
Release 的 body 由 Gitee 两 tag 间 git log 生成,应用内更新弹窗会展示 notes 字段。
关键代码 后端 检查更新 Command src-tauri/src/update/mod.rs:
1 2 3 4 5 6 7 8 9 const GITHUB_REPO: &str = "psvmc/z-img-tools-releases" ;#[tauri::command] pub async fn check_for_update (app: AppHandle) -> Result <UpdateCheckResult, String > { }#[tauri::command] pub async fn apply_update (app: AppHandle, download_url: String ) -> Result <(), String > { apply::download_and_install (&app, download_url).await }
分块下载与进度事件 src-tauri/src/update/apply.rs:
1 2 3 4 5 6 7 let temp_dir = std::env::temp_dir ();let package_path = temp_dir.join (format! ("{}-{file_name}" , std::process::id ()));while let Some (chunk) = response.chunk ().await ? { file.write_all (&chunk)?; emit_download_progress (app, downloaded, total, &mut last_percent); }
Windows:启动安装包并退出 src-tauri/src/update/helper.rs:
1 2 3 4 Command::new (installer_path) .arg ("/UPDATE" ) .creation_flags (CREATE_BREAKAWAY_FROM_JOB | CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS) .spawn ()?;
前端 下载进度监听 src/composables/useAppUpdate.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 const unlisten = await listen<DownloadProgressPayload >("update:download-progress" , (event ) => { setDownloadProgress (event.payload .percent ?? null , formatDownloadStatus (event.payload )); }); try { await invoke ("apply_update" , { downloadUrl : result.downloadUrl }); setDownloadProgress (100 , "下载完成,正在打开安装程序…" ); } catch (error) { close (); showToast (`更新失败: ${message} ` , 4000 , "error" ); } finally { unlisten (); }
确认后进入下载阶段 src/components/UpdateConfirmDialog.vue:
1 2 3 4 5 6 <template v-if="isDownloading"> <p class="update-download-status">{{ state.downloadStatus }}</p> <div class="update-progress-track" role="progressbar"> <div class="update-progress-bar" :style="{ width: progressWidth }" /> </div> </template>
调试建议
场景
处理方式
开发模式不检查更新
正常,需 npm run tauri build 后测试
下载后弹出 cmd 黑窗 ping
勿用 cmd /c ping 延迟启动;应直接 spawn 安装包
下载后仍是安装包、无法启动应用
检查是否误把 setup.exe 当成主程序;安装目录下应为约 15MB 的 ZImgTools.exe
下载后应用退出但未弹出安装向导
查看 %TEMP%\zimgtools-update-*.log
新版本装到别的目录
确认 NSIS 注册表 Software\psvmc\ZImgTools 与当前安装路径一致
手动验证
将 Cargo.toml 版本改为低于 Release 的版本后构建测试
小结 ZImgTools 的自动更新基于 GitHub Releases API + 自定义安装逻辑 ,业务层补充了:
自定义更新确认 UI(含跳过版本、更新日志、下载进度条 )
按平台后缀匹配 Release 资源,优先 ZImgTools 前缀
Windows:临时目录下载 + detached 启动 NSIS /UPDATE,主进程退出释放文件锁
不再复用 exe 作 Helper、不用 cmd/ping、不传 /D=
Linux deb / macOS dmg 安装策略
与处理结果弹窗、设置弹窗的联动关闭
若在新 Tauri 项目中复用,最少需要:check_for_update / apply_update Command + 前端确认/进度弹窗 + Release 资源命名约定 ;Windows 生产环境建议:安装包 detached 启动 + /UPDATE + 主进程先退出 ,安装路径交给 NSIS 注册表恢复。