Wails v3项目 GitHub Release 应用版本更新

背景

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.ymlinfo.version 及各平台打包配置保持一致:

1
2
3
// version.go
const Version = "1.2.3"
const WindowTitle = "ZGit v" + Version

自动更新仅在 production 构建 中启用(wails3 dev 不会弹更新):

1
2
3
4
5
// internal/update/enabled_prod.go  (//go:build production)
const Enabled = true

// internal/update/enabled_dev.go (//go:build !production)
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 返回结构体包含:availableenabledcurrentVersionlatestVersionreleaseNamenotesreleaseURL 等字段,供前端展示更新日志。

各平台安装策略

Windows / macOS

流程:

  1. DownloadAndInstall — 从 GitHub 下载到系统 Temp 目录
  2. 同盘 staging — 将文件复制到 exe 同目录(见下节「跨盘修复」)
  3. 启动 Wails Helper 进程 — 等待主进程退出后 Rename 替换二进制
  4. app.Quit() — 主进程退出,Helper 完成替换并拉起新版本

macOS 的 zip 内含 .app,Wails Updater 会先解压再交给 Helper;swapTarget 会定位到 .app bundle 路径。

Linux

Linux 不直接替换正在运行的二进制,而是下载 deb/rpm 后:

  1. 优先 pkexec dpkg -i / pkexec rpm -U
  2. 其次尝试 sudo
  3. 都不可用则 xdg-open 打开安装包,提示用户手动安装

安装命令在独立进程中执行,随后 app.Quit()

跳过版本

用户可在更新弹窗点击 「跳过该版本」。实现要点:

  1. 调用 app.Updater.SkipVersion(version) — 内存中生效
  2. 写入 %APPDATA%\z-git-tools\config.jsonskippedUpdateVersion — 重启后仍生效
  3. 应用启动时 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, // "psvmc/z-git-releases"
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() // deb 或 rpm
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
// staging.go — 复制到 exe 同目录,避免 Windows 跨盘 Rename 失败
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))
// 文件或 .app 目录均 copy 到 dest ...
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
// helper_restart.go — 启动 Wails Helper 替换二进制
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 逻辑。