背景
ZGit 是基于 Wails v3 + Go + Vue 3 的 Git 桌面工具。
安装包通过 GitHub Actions 构建并发布到独立仓库 psvmc/z-git-releases。
应用需要具备:
- 启动后自动检查 GitHub Release 是否有新版本
- 系统设置中支持手动「检查更新」
- 用户确认后再下载安装,支持「稍后 / 跳过该版本 / 立即更新」
- Windows / macOS 尽量做到一键替换并重启;Linux 走 deb/rpm 包安装
本文记录 ZGit 项目中的具体实现方式,便于后续维护或在其它 Wails v3 项目中复用。
整体架构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| ┌─────────────────────────────────────────────────────────────┐ │ 前端 Vue │ │ useAppUpdate / UpdateConfirmDialog / SystemSettingsDialog │ └───────────────────────────┬─────────────────────────────────┘ │ Wails Bindings ┌───────────────────────────▼─────────────────────────────────┐ │ UpdateService(Go Service) │ │ CheckForUpdate / ApplyUpdate / SkipUpdateVersion │ └───────────────────────────┬─────────────────────────────────┘ │ ┌───────────────────────────▼─────────────────────────────────┐ │ Wails v3 app.Updater + GitHub Provider │ │ GET .../releases/latest → 下载 → 校验 → 替换二进制 │ └───────────────────────────┬─────────────────────────────────┘ │ ┌───────────────────────────▼─────────────────────────────────┐ │ GitHub Release(psvmc/z-git-releases) │ │ ZGit-windows-amd64.exe / ZGit-macos-universal.zip / .deb/.rpm │ └───────────────────────────────────────────────────────────────┘
|
核心思路:下载与替换逻辑交给 Wails 内置 Updater,业务 UI 自己实现(Window: WindowNone 不用框架自带小窗),通过 UpdateService 暴露给前端。
版本号与构建标签
当前版本定义在 version.go,需与 build/config.yml 的 info.version 及各平台打包配置保持一致:
1 2 3
| const Version = "1.2.3" const WindowTitle = "ZGit v" + Version
|
自动更新仅在 production 构建 中启用(wails3 dev 不会弹更新):
1 2 3 4 5
| const Enabled = true
const Enabled = false
|
Release 构建需带 -tags production,与项目 build/windows/Taskfile.yml 等配置一致。
初始化 Updater
main.go 中注册 UpdateService,并在启动时初始化 Updater:
1 2 3 4 5
| if err := update.InitUpdater(app, Version); err != nil { log.Fatal(err) } updateService := services.NewUpdateService(app, Version) app.RegisterService(application.NewService(updateService))
|
internal/update/setup.go 使用 GitHub Releases Provider,数据源为:
1
| const GitHubRepository = "psvmc/z-git-releases"
|
等价于请求:
1
| https://api.github.com/repos/psvmc/z-git-releases/releases/latest
|
初始化时关闭框架自带更新窗口,UI 完全自定义:
1 2 3 4 5
| app.Updater.Init(updater.Config{ CurrentVersion: currentVersion, Providers: []updater.Provider{provider}, Window: updater.WindowNone, })
|
按平台匹配 Release 资源
GitHub Release 上各平台产物文件名固定,通过自定义 AssetMatcher 精确匹配,避免默认规则误选:
| 平台 |
资源文件名 |
| Windows amd64 |
ZGit-windows-amd64.exe |
| Windows 386 |
ZGit-windows-386.exe |
| macOS |
ZGit-macos-universal.zip |
| Linux deb 系 |
ZGit.deb |
| Linux rpm 系 |
ZGit.rpm |
Linux 根据 /etc/os-release 等判断优先 deb 还是 rpm(internal/update/asset.go)。
UpdateService 对外方法
| 方法 |
作用 |
GetCurrentVersion() |
返回当前运行版本 |
CheckForUpdate() |
调用 app.Updater.Check,有新版本则返回 release 信息 |
ApplyUpdate() |
下载并安装,各平台策略见下节 |
SkipUpdateVersion(version) |
跳过指定版本,持久化到本地配置 |
CheckForUpdate 返回结构体包含:available、enabled、currentVersion、latestVersion、releaseName、notes、releaseURL 等字段,供前端展示更新日志。
各平台安装策略
Windows / macOS
流程:
DownloadAndInstall — 从 GitHub 下载到系统 Temp 目录
- 同盘 staging — 将文件复制到 exe 同目录(见下节「跨盘修复」)
- 启动 Wails Helper 进程 — 等待主进程退出后
Rename 替换二进制
app.Quit() — 主进程退出,Helper 完成替换并拉起新版本
macOS 的 zip 内含 .app,Wails Updater 会先解压再交给 Helper;swapTarget 会定位到 .app bundle 路径。
Linux
Linux 不直接替换正在运行的二进制,而是下载 deb/rpm 后:
- 优先
pkexec dpkg -i / pkexec rpm -U
- 其次尝试
sudo
- 都不可用则
xdg-open 打开安装包,提示用户手动安装
安装命令在独立进程中执行,随后 app.Quit()。
跳过版本
用户可在更新弹窗点击 「跳过该版本」。实现要点:
- 调用
app.Updater.SkipVersion(version) — 内存中生效
- 写入
%APPDATA%\z-git-tools\config.json 的 skippedUpdateVersion — 重启后仍生效
- 应用启动时
applySkippedVersion 读配置并恢复 Skip 状态
仅跳过指定版本号;若之后发布更高版本(如跳过 1.2.2,后来有 1.2.3),仍会正常提醒。
前端实现
启动时检查
App.vue 在恢复标签页与批量刷新完成后触发:
1 2 3 4 5
| onMounted(async () => { await loadSettings() await repo.restoreSavedTabs() void checkOnStartup() })
|
useAppUpdate.ts 封装检查逻辑;有新版本时弹出 UpdateConfirmDialog,无新版本时启动检查静默跳过(手动检查才 Toast「已是最新」)。
更新确认弹窗
UpdateConfirmDialog.vue(680px 宽)展示:
- 版本对比:
v1.2.1 → v1.2.3
- Release 标题
- 可滚动更新日志区域(最高 320px)
- 三个按钮:跳过该版本 | 稍后 | 立即更新
弹出更新确认前会关闭 批量刷新结果弹窗;系统设置里点「检查更新」会先 关闭设置弹窗 再检查。
系统设置入口
SystemSettingsDialog.vue 增加「软件更新」区块,显示当前版本与「检查更新」按钮,复用同一套 useAppUpdate 逻辑。
与 CI 发版的配合
Release 由 GitHub Actions 构建(workflow 在 scripts/release/github-workflow-release-all.yml),从 Gitee 拉 tag 源码,三平台产物上传到 psvmc/z-git-releases。
发版流程(简要):
1 2 3
| git tag 1.2.3 git push origin 1.2.3 scripts\publish-release.bat
|
Updater 依赖 Release 资源文件名与 AssetMatcher 一致,发版 workflow 中 copy 产物时需保持命名,例如:
- Windows:
ZGit-windows-amd64.exe
- macOS:
ZGit-macos-universal.zip
- Linux:
ZGit.deb / ZGit.rpm
关键代码
后端
注册服务
main.go:
1 2 3 4 5
| if err := update.InitUpdater(app, Version); err != nil { log.Fatal(err) } updateService := services.NewUpdateService(app, Version) app.RegisterService(application.NewService(updateService))
|
初始化 GitHub Provider
internal/update/setup.go:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| func InitUpdater(app *application.App, currentVersion string) error { if !Enabled { return nil }
provider, err := github.New(github.Config{ Repository: GitHubRepository, AssetMatcher: ZGitAssetMatcher, }) if err != nil { return fmt.Errorf("init github update provider: %w", err) }
if err := app.Updater.Init(updater.Config{ CurrentVersion: currentVersion, Providers: []updater.Provider{provider}, Window: updater.WindowNone, }); err != nil { return err }
applySkippedVersion(app) return nil }
|
按平台匹配 Release 资源
internal/update/asset.go:
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
| func PreferredAssetName(platform, arch string) string { switch platform { case "windows": if arch == "386" { return "ZGit-windows-386.exe" } return "ZGit-windows-amd64.exe" case "darwin": return "ZGit-macos-universal.zip" case "linux": return linuxPackageAssetName() default: return "" } }
func ZGitAssetMatcher(req updater.CheckRequest, assets []github.ReleaseAsset) int { platform := req.Platform if platform == "" { platform = runtime.GOOS } arch := req.Arch if arch == "" { arch = runtime.GOARCH }
preferred := PreferredAssetName(platform, arch) for i, asset := range assets { if asset.Name == preferred { return i } } return -1 }
|
暴露给前端的 Service
services/update_service.go:
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
| func (s *UpdateService) CheckForUpdate() (models.UpdateCheckResult, error) { result := models.UpdateCheckResult{ CurrentVersion: s.currentVersion, Enabled: update.Enabled, } if !update.Enabled { return result, nil }
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel()
rel, err := s.app.Updater.Check(ctx) if err != nil { return result, fmt.Errorf("检查更新失败: %w", err) } if rel == nil { return result, nil }
result.Available = true result.LatestVersion = rel.Version result.ReleaseName = rel.Name result.Notes = rel.Notes if rel.Metadata != nil { if url, ok := rel.Metadata["github.release.htmlURL"].(string); ok { result.ReleaseURL = url } } return result, nil }
func (s *UpdateService) SkipUpdateVersion(version string) error { version = strings.TrimSpace(version) if version == "" { return fmt.Errorf("版本号不能为空") } s.app.Updater.SkipVersion(version) return s.store.SetSkippedUpdateVersion(version) }
|
Windows / macOS 安装
services/update_apply_other.go:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| func (s *UpdateService) applyPlatformUpdate(ctx context.Context) error { if err := s.app.Updater.DownloadAndInstall(ctx); err != nil { return fmt.Errorf("下载或安装更新失败: %w", err) }
staged := s.app.Updater.DownloadedPath() if staged == "" { return fmt.Errorf("未找到已下载的更新包") }
if err := update.ApplyStagedUpdate(staged); err != nil { return fmt.Errorf("应用更新失败: %w", err) }
s.app.Quit() return nil }
|
Linux 安装 deb/rpm
services/update_apply_linux.go:
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
| func (s *UpdateService) applyPlatformUpdate(ctx context.Context) error { if err := s.app.Updater.DownloadAndInstall(ctx); err != nil { return fmt.Errorf("下载更新包失败: %w", err) }
packagePath := s.app.Updater.DownloadedPath() if packagePath == "" { return fmt.Errorf("未找到已下载的更新包") }
if err := installLinuxPackage(ctx, packagePath); err != nil { return err }
s.app.Quit() return nil }
func installLinuxPackage(ctx context.Context, packagePath string) error { ext := filepath.Ext(packagePath) switch ext { case ".deb": return runLinuxInstaller(ctx, []string{"dpkg", "-i", packagePath}, packagePath) case ".rpm": return runLinuxInstaller(ctx, []string{"rpm", "-U", packagePath}, packagePath) default: return openPackageWithDesktop(packagePath) } }
|
跨盘 staging 与 Helper
internal/update/staging.go:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| func relocateNextToExecutable(exePath, stagedPath string) (string, error) { info, err := os.Stat(stagedPath) if err != nil { return "", fmt.Errorf("stat staged artifact: %w", err) }
exeDir := filepath.Dir(exePath) stagingDir := filepath.Join(exeDir, fmt.Sprintf(".zgit-update-%d", os.Getpid())) if err := os.MkdirAll(stagingDir, 0o755); err != nil { return "", fmt.Errorf("create staging dir: %w", err) }
dest := filepath.Join(stagingDir, filepath.Base(stagedPath)) return dest, nil }
|
internal/update/helper_restart.go:
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
| func ApplyStagedUpdate(stagedPath string) error { self, err := os.Executable() if err != nil { return fmt.Errorf("resolve executable: %w", err) }
localPath, err := relocateNextToExecutable(self, stagedPath) if err != nil { return err }
target := swapTarget(self) logPath := filepath.Join(os.TempDir(), fmt.Sprintf("wails-update-%d.log", os.Getpid())) return spawnUpdaterHelper(self, target, localPath, os.Getpid(), logPath) }
func spawnUpdaterHelper(self, target, newPath string, pid int, logPath string) error { cmd := exec.Command(self) cmd.Env = append(os.Environ(), "WAILS_UPDATER_HELPER=1", "WAILS_UPDATER_HELPER_TARGET="+target, "WAILS_UPDATER_HELPER_NEW="+newPath, "WAILS_UPDATER_HELPER_PID="+strconv.Itoa(pid), "WAILS_UPDATER_HELPER_LOG="+logPath, ) applyDetachAttrs(cmd) return cmd.Start() }
|
启动时恢复跳过版本
internal/update/skip.go:
1 2 3 4 5 6 7 8 9
| func applySkippedVersion(app *application.App) { store, err := config.NewStore() if err != nil { return } if version := store.GetSkippedUpdateVersion(); version != "" { app.Updater.SkipVersion(version) } }
|
配置写入 %APPDATA%\z-git-tools\config.json:
1 2 3
| { "skippedUpdateVersion": "1.2.2" }
|
前端
检查、确认、安装
frontend/src/composables/useAppUpdate.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| const promptAndApply = async (result: UpdateCheckResult) => { options.dismissBlockingDialogs?.() const choice = await promptUpdateConfirm(result)
if (choice === 'skip') { await UpdateService.SkipUpdateVersion(result.latestVersion!) toast.info(`已跳过 v${result.latestVersion},该版本不再提醒`) return }
if (choice !== 'confirm') { return }
await loading.run('正在下载并安装更新...', () => UpdateService.ApplyUpdate()) }
const checkOnStartup = async () => { const result = await UpdateService.CheckForUpdate() await handleCheckResult(result, false) }
|
更新确认弹窗
frontend/src/composables/useUpdateConfirm.ts:
1 2 3 4 5 6 7 8
| export type UpdatePromptChoice = 'confirm' | 'later' | 'skip'
const prompt = (result: UpdateCheckResult): Promise<UpdatePromptChoice> => new Promise((resolve) => { state.result = result state.show = true resolvePrompt = resolve })
|
frontend/src/components/dialogs/UpdateConfirmDialog.vue 底部按钮:
1 2 3 4 5 6 7
| <NSpace justify="space-between" align="center" class="update-footer"> <NButton secondary @click="skip">跳过该版本</NButton> <NSpace :size="12"> <NButton secondary @click="cancel">稍后</NButton> <NButton type="primary" @click="confirm">立即更新</NButton> </NSpace> </NSpace>
|
更新日志区域使用可滚动 <pre>,弹窗宽度 680px、max-height: 320px。
启动时检查
frontend/src/App.vue:
1 2 3 4 5 6 7 8 9 10 11
| const dismissBlockingDialogs = () => { showBatchRefreshResult.value = false }
const { checkOnStartup } = useAppUpdate({ dismissBlockingDialogs })
onMounted(async () => { await loadSettings() await repo.restoreSavedTabs() void checkOnStartup() })
|
系统设置里点击「检查更新」时先 show.value = false 关闭设置弹窗,再调用 checkForUpdate()(frontend/src/components/dialogs/SystemSettingsDialog.vue)。
调试建议
| 场景 |
处理方式 |
| 开发模式不检查更新 |
正常,需 production 构建 |
| 下载成功但未替换 |
查看 %TEMP%\wails-update-*.log |
| 跨盘 Rename 失败 |
确认已包含 relocateNextToExecutable 逻辑 |
| 手动验证 |
将 version.go 改为低于 Release 的版本后构建测试 |
小结
ZGit 的自动更新基于 Wails v3 内置 Updater + GitHub Releases Provider,业务层补充了:
- 自定义更新确认 UI(含跳过版本、更新日志)
- 按平台精确匹配 Release 资源
- Windows 跨盘 staging 修复
- Linux deb/rpm 安装策略
- 跳过版本持久化
- 与批量刷新、系统设置等弹窗的联动关闭
若在新项目中复用,最少需要:InitUpdater + UpdateService + 前端确认弹窗 + Release 资源命名约定;Windows 生产环境建议保留同盘 staging 逻辑。