feat(update): 支持 Windows MSI 与 Portable 分流更新

- 根据当前安装标记精确选择 MSI 或 Portable 更新包
- MSI 更新下载到用户缓存并通过 msiexec 保留原目录安装重启
- 下载与安装前校验包类型并隔离不同安装方式的缓存
- 补充安装模式、脚本执行和更新清理测试
This commit is contained in:
Syngnat
2026-07-13 09:24:13 +08:00
parent 4d90ed776f
commit 6e2e3afc85
11 changed files with 828 additions and 28 deletions

View File

@@ -95,8 +95,11 @@ func TestInstallUpdateAndRestartAllowsGuardedCloseBeforeFallbackExit(t *testing.
app.ctx = context.Background()
stagedDir := t.TempDir()
app.updateState.staged = &stagedUpdate{
FilePath: stagedDir + "/GoNavi-update.exe",
StagedDir: stagedDir,
FilePath: stagedDir + "/GoNavi-update.exe",
StagedDir: stagedDir,
InstallMode: updateInstallModePortable,
PackageType: updatePackageTypePortable,
AutoRelaunch: true,
}
events := make(chan string, 3)

View File

@@ -52,6 +52,7 @@ var (
updateFetchReleaseSHA256 = fetchReleaseSHA256
updateLogCheckError = func(err error) { logger.Error(err, "检查更新失败") }
updateResolveInstallTarget = resolveUpdateInstallTarget
updateResolveInstallMode = resolveCurrentUpdateInstallMode
updateLaunchInstallScript = launchUpdateScript
updateQuitSleep = time.Sleep
updateExitProcess = os.Exit
@@ -78,6 +79,9 @@ type UpdateInfo struct {
SHA256 string `json:"sha256"`
Downloaded bool `json:"downloaded"`
DownloadPath string `json:"downloadPath,omitempty"`
InstallMode string `json:"installMode"`
PackageType string `json:"packageType,omitempty"`
AutoRelaunch bool `json:"autoRelaunch"`
}
type AppInfo struct {
@@ -96,6 +100,8 @@ type updateDownloadResult struct {
InstallLogPath string `json:"installLogPath,omitempty"`
InstallTarget string `json:"installTarget,omitempty"`
Platform string `json:"platform"`
InstallMode string `json:"installMode"`
PackageType string `json:"packageType"`
AutoRelaunch bool `json:"autoRelaunch"`
}
@@ -114,6 +120,9 @@ type stagedUpdate struct {
FilePath string
StagedDir string
InstallLogPath string
InstallMode updateInstallMode
PackageType updatePackageType
AutoRelaunch bool
}
type updatePathCandidate struct {
@@ -242,6 +251,15 @@ func (a *App) DownloadUpdate() connection.QueryResult {
a.updateMu.Unlock()
return connection.QueryResult{Success: false, Message: a.appText("app.update.backend.message.no_update_package", nil)}
}
if err := validateUpdatePackageForCurrentInstallMode(
stdRuntime.GOOS,
updateInstallMode(info.InstallMode),
updatePackageType(info.PackageType),
info.AssetName,
); err != nil {
a.updateMu.Unlock()
return connection.QueryResult{Success: false, Message: a.localizedUpdateError(err)}
}
staged := resolveReusableStagedUpdate(*info, a.updateState.staged)
if staged != nil {
a.updateState.staged = staged
@@ -272,8 +290,16 @@ func (a *App) InstallUpdateAndRestart() connection.QueryResult {
if staged == nil {
return connection.QueryResult{Success: false, Message: a.appText("app.update.backend.message.no_downloaded_package", nil)}
}
if err := validateUpdatePackageForCurrentInstallMode(stdRuntime.GOOS, staged.InstallMode, staged.PackageType, staged.FilePath); err != nil {
return connection.QueryResult{
Success: false,
Message: a.appText("app.update.backend.message.install_launch_failed", map[string]any{
"detail": a.localizedUpdateError(err),
}),
}
}
if stdRuntime.GOOS == "windows" {
if stdRuntime.GOOS == "windows" && staged.InstallMode == updateInstallModePortable {
if err := ensureWindowsUpdateTargetWritable(updateResolveInstallTarget()); err != nil {
return connection.QueryResult{
Success: false,
@@ -298,7 +324,10 @@ func (a *App) InstallUpdateAndRestart() connection.QueryResult {
Success: false,
Message: msg,
Data: map[string]any{
"logPath": staged.InstallLogPath,
"logPath": staged.InstallLogPath,
"installMode": string(staged.InstallMode),
"packageType": string(staged.PackageType),
"autoRelaunch": staged.AutoRelaunch,
},
}
}
@@ -313,7 +342,10 @@ func (a *App) InstallUpdateAndRestart() connection.QueryResult {
Success: true,
Message: msg,
Data: map[string]any{
"logPath": staged.InstallLogPath,
"logPath": staged.InstallLogPath,
"installMode": string(staged.InstallMode),
"packageType": string(staged.PackageType),
"autoRelaunch": staged.AutoRelaunch,
},
}
}
@@ -370,7 +402,7 @@ func (a *App) OpenDownloadedUpdateDirectory() connection.QueryResult {
}
func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
workspaceDir := strings.TrimSpace(resolveUpdateWorkspaceDir(info.LatestVersion))
workspaceDir := strings.TrimSpace(resolveUpdateWorkspaceDirForInstallMode(info.LatestVersion, updateInstallMode(info.InstallMode)))
if workspaceDir == "" {
message := a.appText("app.update.backend.message.app_directory_unresolved_download", nil)
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, message)
@@ -450,6 +482,9 @@ func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
FilePath: assetPath,
StagedDir: stagedDir,
InstallLogPath: buildUpdateInstallLogPath(workspaceDir),
InstallMode: updateInstallMode(info.InstallMode),
PackageType: updatePackageType(info.PackageType),
AutoRelaunch: info.AutoRelaunch,
}
info.Downloaded = true
info.DownloadPath = assetPath
@@ -469,6 +504,15 @@ func fetchLatestUpdateInfoWithOptions(channel updateChannel, forceNetwork bool)
if channel != updateChannelDev {
channel = updateChannelLatest
}
installMode := updateResolveInstallMode()
packageType := resolveUpdatePackageType(stdRuntime.GOOS, installMode)
if stdRuntime.GOOS == "windows" && packageType == "" {
return UpdateInfo{}, localizedUpdateError{
key: "app.update.backend.error.online_update_unsupported",
params: map[string]any{"platform": stdRuntime.GOOS + "/" + stdRuntime.GOARCH + "/" + string(installMode)},
}
}
// 优先静态 latest.json不占 api.github.com 配额)→ GitHub API → 磁盘缓存
release, err := fetchReleaseForChannelPreferringStatic(channel, forceNetwork)
if err != nil {
@@ -496,6 +540,9 @@ func fetchLatestUpdateInfoWithOptions(channel updateChannel, forceNetwork bool)
ReleaseName: release.Name,
ReleasePublishedAt: strings.TrimSpace(release.PublishedAt),
ReleaseNotesURL: release.HTMLURL,
InstallMode: string(installMode),
PackageType: string(packageType),
AutoRelaunch: true,
}, nil
}
@@ -503,7 +550,7 @@ func fetchLatestUpdateInfoWithOptions(channel updateChannel, forceNetwork bool)
if assetVersion == "" || strings.EqualFold(normalizeVersion(assetVersion), updateDevReleaseTag) {
assetVersion = latestVersion
}
assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, assetVersion)
assetName, err := expectedAssetNameForInstallMode(stdRuntime.GOOS, stdRuntime.GOARCH, assetVersion, installMode)
if err != nil {
return UpdateInfo{}, err
}
@@ -536,6 +583,9 @@ func fetchLatestUpdateInfoWithOptions(channel updateChannel, forceNetwork bool)
AssetAPIURL: strings.TrimSpace(asset.URL),
AssetSize: asset.Size,
SHA256: sha256Value,
InstallMode: string(installMode),
PackageType: string(packageType),
AutoRelaunch: true,
}, nil
}
@@ -783,6 +833,14 @@ func classifyGitHubUpdateHTTPError(status int, body []byte, headers http.Header,
}
func expectedAssetName(goos, goarch, version string) (string, error) {
installMode := updateInstallModeUnknown
if strings.EqualFold(strings.TrimSpace(goos), "windows") {
installMode = updateResolveInstallMode()
}
return expectedAssetNameForInstallMode(goos, goarch, version, installMode)
}
func expectedAssetNameForInstallMode(goos, goarch, version string, installMode updateInstallMode) (string, error) {
executablePath := ""
if goos == "linux" {
if path, err := os.Executable(); err == nil {
@@ -792,10 +850,14 @@ func expectedAssetName(goos, goarch, version string) (string, error) {
executablePath = path
}
}
return expectedAssetNameForExecutable(goos, goarch, version, executablePath)
return expectedAssetNameForExecutableAndInstallMode(goos, goarch, version, executablePath, installMode)
}
func expectedAssetNameForExecutable(goos, goarch, version, executablePath string) (string, error) {
return expectedAssetNameForExecutableAndInstallMode(goos, goarch, version, executablePath, updateInstallModePortable)
}
func expectedAssetNameForExecutableAndInstallMode(goos, goarch, version, executablePath string, installMode updateInstallMode) (string, error) {
version = strings.TrimSpace(version)
version = strings.TrimPrefix(version, "v")
version = strings.TrimPrefix(version, "V")
@@ -805,11 +867,20 @@ func expectedAssetNameForExecutable(goos, goarch, version, executablePath string
switch goos {
case "windows":
suffix := "-Portable.exe"
if installMode == updateInstallModeMSI {
suffix = "-Installer.msi"
} else if installMode != updateInstallModePortable {
return "", localizedUpdateError{
key: "app.update.backend.error.online_update_unsupported",
params: map[string]any{"platform": goos + "/" + goarch + "/" + string(installMode)},
}
}
if goarch == "amd64" {
return fmt.Sprintf("GoNavi-%s-Windows-Amd64.exe", version), nil
return fmt.Sprintf("GoNavi-%s-Windows-Amd64%s", version, suffix), nil
}
if goarch == "arm64" {
return fmt.Sprintf("GoNavi-%s-Windows-Arm64.exe", version), nil
return fmt.Sprintf("GoNavi-%s-Windows-Arm64%s", version, suffix), nil
}
case "darwin":
if goarch == "amd64" {
@@ -1141,11 +1212,16 @@ func buildUpdateDownloadResult(info UpdateInfo, staged *stagedUpdate) updateDown
Info: info,
Platform: stdRuntime.GOOS,
InstallTarget: resolveUpdateInstallTarget(),
AutoRelaunch: true,
InstallMode: info.InstallMode,
PackageType: info.PackageType,
AutoRelaunch: info.AutoRelaunch,
}
if staged != nil {
result.DownloadPath = staged.FilePath
result.InstallLogPath = staged.InstallLogPath
result.InstallMode = string(staged.InstallMode)
result.PackageType = string(staged.PackageType)
result.AutoRelaunch = staged.AutoRelaunch
}
return result
}
@@ -1277,8 +1353,23 @@ func resolveLegacyUpdateWorkspaceDir() string {
}
func resolveUpdateWorkspaceDir(version string) string {
return resolveUpdateWorkspaceDirForInstallMode(version, updateResolveInstallMode())
}
func resolveUpdateWorkspaceDirForInstallMode(version string, installMode updateInstallMode) string {
cacheDir, _ := os.UserCacheDir()
return resolveUpdateWorkspaceDirForPlatform(
stdRuntime.GOOS,
version,
installMode,
updateResolveInstallTarget(),
cacheDir,
)
}
func resolveUpdateWorkspaceDirForPlatform(goos string, version string, installMode updateInstallMode, installTarget string, userCacheDir string) string {
// macOS 更新包继续保存在桌面版本目录根级,方便用户直接处理 DMG。
if stdRuntime.GOOS == "darwin" {
if goos == "darwin" {
homeDir, err := os.UserHomeDir()
if err == nil && strings.TrimSpace(homeDir) != "" {
desktopDir := filepath.Join(homeDir, "Desktop")
@@ -1287,9 +1378,15 @@ func resolveUpdateWorkspaceDir(version string) string {
}
}
}
if goos == "windows" && installMode == updateInstallModeMSI {
if strings.TrimSpace(userCacheDir) != "" {
return filepath.Join(userCacheDir, "GoNavi", "updates")
}
return resolveLegacyUpdateWorkspaceDir()
}
// Windows / Linux 更新包优先落到当前应用运行目录,方便用户直接找到下载产物。
targetPath := strings.TrimSpace(updateResolveInstallTarget())
targetPath := strings.TrimSpace(installTarget)
if targetPath != "" {
targetDir := strings.TrimSpace(filepath.Dir(targetPath))
if targetDir != "" && targetDir != "." {
@@ -1429,7 +1526,7 @@ func isExistingDownloadedAsset(filePath string, expectedSize int64) bool {
func resolveReusableStagedUpdate(info UpdateInfo, current *stagedUpdate) *stagedUpdate {
return resolveReusableStagedUpdateForPlatform(
stdRuntime.GOOS,
resolveUpdateWorkspaceDir(strings.TrimSpace(info.LatestVersion)),
resolveUpdateWorkspaceDirForInstallMode(strings.TrimSpace(info.LatestVersion), updateInstallMode(info.InstallMode)),
resolveLegacyUpdateWorkspaceDir(),
info,
current,
@@ -1453,7 +1550,10 @@ func resolveReusableStagedUpdateForPlatform(goos string, preferredWorkspaceDir s
if currentChannel == "" {
currentChannel = updateChannelLatest
}
if currentChannel == channel && strings.TrimSpace(current.Version) == version {
if currentChannel == channel && strings.TrimSpace(current.Version) == version &&
strings.TrimSpace(current.AssetName) == assetName &&
current.InstallMode == updateInstallMode(info.InstallMode) &&
current.PackageType == updatePackageType(info.PackageType) {
currentPath := strings.TrimSpace(current.FilePath)
if isExistingDownloadedAsset(currentPath, info.AssetSize) {
if !allowStagedDirReuse && isUpdateAssetPathInsideStagedDir(currentPath, current.StagedDir) {
@@ -1463,6 +1563,10 @@ func resolveReusableStagedUpdateForPlatform(goos string, preferredWorkspaceDir s
current.InstallLogPath = buildUpdateInstallLogPath(filepath.Dir(currentPath))
}
current.Channel = channel
current.AssetName = assetName
current.InstallMode = updateInstallMode(info.InstallMode)
current.PackageType = updatePackageType(info.PackageType)
current.AutoRelaunch = info.AutoRelaunch
return current
}
}
@@ -1488,6 +1592,9 @@ func resolveReusableStagedUpdateForPlatform(goos string, preferredWorkspaceDir s
FilePath: candidate.assetPath,
StagedDir: candidate.stagedDir,
InstallLogPath: buildUpdateInstallLogPath(candidate.workspaceDir),
InstallMode: updateInstallMode(info.InstallMode),
PackageType: updatePackageType(info.PackageType),
AutoRelaunch: info.AutoRelaunch,
}
}
@@ -1581,6 +1688,9 @@ func launchUpdateScript(staged *stagedUpdate) error {
}
func launchWindowsUpdate(staged *stagedUpdate, targetExe string, pid int) error {
if staged != nil && staged.InstallMode == updateInstallModeMSI && staged.PackageType == updatePackageTypeMSI {
return launchWindowsMSIUpdate(staged, targetExe, pid)
}
return launchWindowsUpdateWithCleanup(staged, targetExe, pid)
}

View File

@@ -74,6 +74,11 @@ func TestFetchLatestUpdateInfoSkipsChecksumWhenCurrentVersionIsAlreadyLatest(t *
if info.LatestVersion != "0.6.5" || info.CurrentVersion != "0.6.5" {
t.Fatalf("unexpected version info: %#v", info)
}
if info.InstallMode != string(updateResolveInstallMode()) ||
info.PackageType != string(resolveUpdatePackageType(stdRuntime.GOOS, updateResolveInstallMode())) ||
!info.AutoRelaunch {
t.Fatalf("expected no-update result to include install contract, got %#v", info)
}
}
func TestFetchLatestUpdateInfoUsesAssetDigestWhenUpdateIsAvailable(t *testing.T) {
@@ -498,7 +503,7 @@ func TestResolveReusableStagedUpdateForPlatformSkipsLegacyWindowsExeStagedAsset(
info := UpdateInfo{
Channel: string(updateChannelLatest),
LatestVersion: "0.8.4",
AssetName: "GoNavi-0.8.4-Windows-Amd64.exe",
AssetName: "GoNavi-0.8.4-Windows-Amd64-Portable.exe",
AssetSize: 8,
}
@@ -526,7 +531,7 @@ func TestResolveReusableStagedUpdateForPlatformPrefersWindowsExeInInstallDirecto
info := UpdateInfo{
Channel: string(updateChannelLatest),
LatestVersion: "0.8.4",
AssetName: "GoNavi-0.8.4-Windows-Amd64.exe",
AssetName: "GoNavi-0.8.4-Windows-Amd64-Portable.exe",
AssetSize: 8,
}
@@ -562,7 +567,7 @@ func TestResolveReusableStagedUpdateForPlatformDoesNotReuseCurrentWindowsExeInsi
info := UpdateInfo{
Channel: string(updateChannelLatest),
LatestVersion: "0.8.4",
AssetName: "GoNavi-0.8.4-Windows-Amd64.exe",
AssetName: "GoNavi-0.8.4-Windows-Amd64-Portable.exe",
AssetSize: 8,
}
@@ -620,18 +625,21 @@ func TestInstallUpdateAndRestartFailsBeforeLaunchWhenWindowsTargetDirIsNotWritab
}
stagedDir := t.TempDir()
assetPath := filepath.Join(stagedDir, "GoNavi-0.8.2-Windows-Amd64.exe")
assetPath := filepath.Join(stagedDir, "GoNavi-0.8.2-Windows-Amd64-Portable.exe")
if err := os.WriteFile(assetPath, []byte("12345678"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
app := NewApp()
app.updateState.staged = &stagedUpdate{
Channel: updateChannelLatest,
Version: "0.8.2",
AssetName: filepath.Base(assetPath),
FilePath: assetPath,
StagedDir: stagedDir,
Channel: updateChannelLatest,
Version: "0.8.2",
AssetName: filepath.Base(assetPath),
FilePath: assetPath,
StagedDir: stagedDir,
InstallMode: updateInstallModePortable,
PackageType: updatePackageTypePortable,
AutoRelaunch: true,
}
originalResolveInstallTarget := updateResolveInstallTarget
@@ -663,6 +671,55 @@ func TestInstallUpdateAndRestartFailsBeforeLaunchWhenWindowsTargetDirIsNotWritab
}
}
func TestInstallUpdateAndRestartMSISkipsPortableTargetWriteProbe(t *testing.T) {
if stdRuntime.GOOS != "windows" {
t.Skip("windows-only MSI launch validation")
}
stagedDir := t.TempDir()
assetPath := filepath.Join(stagedDir, "GoNavi-0.8.2-Windows-Amd64-Installer.msi")
if err := os.WriteFile(assetPath, []byte("12345678"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
app := NewApp()
app.updateState.staged = &stagedUpdate{
Channel: updateChannelLatest,
Version: "0.8.2",
AssetName: filepath.Base(assetPath),
FilePath: assetPath,
StagedDir: stagedDir,
InstallMode: updateInstallModeMSI,
PackageType: updatePackageTypeMSI,
AutoRelaunch: true,
}
originalResolveInstallTarget := updateResolveInstallTarget
originalResolveInstallMode := updateResolveInstallMode
originalLaunchInstallScript := updateLaunchInstallScript
t.Cleanup(func() {
updateResolveInstallTarget = originalResolveInstallTarget
updateResolveInstallMode = originalResolveInstallMode
updateLaunchInstallScript = originalLaunchInstallScript
})
updateResolveInstallTarget = func() string {
return filepath.Join(stagedDir, "missing", "GoNavi.exe")
}
updateResolveInstallMode = func() updateInstallMode { return updateInstallModeMSI }
launched := false
updateLaunchInstallScript = func(*stagedUpdate) error {
launched = true
return errors.New("stop after MSI launcher reached")
}
result := app.InstallUpdateAndRestart()
if result.Success {
t.Fatalf("expected injected launcher error, got %#v", result)
}
if !launched {
t.Fatal("expected MSI launcher to run without probing target directory writability")
}
}
func TestResolveUpdateWorkspaceDirPrefersCurrentInstallDirectory(t *testing.T) {
if stdRuntime.GOOS == "darwin" {
t.Skip("macOS keeps update downloads on Desktop")
@@ -716,7 +773,8 @@ func TestShouldWindowsUpdateLaunchDownloadedAssetDirectly(t *testing.T) {
assetPath string
want bool
}{
{assetPath: `C:\GoNavi\GoNavi-dev-93dc696-Windows-Amd64.exe`, want: true},
{assetPath: `C:\GoNavi\GoNavi-dev-93dc696-Windows-Amd64-Portable.exe`, want: true},
{assetPath: `C:\GoNavi\GoNavi-dev-93dc696-Windows-Amd64-Installer.msi`, want: false},
{assetPath: `C:\GoNavi\GoNavi-0.8.2-Windows-Amd64.zip`, want: false},
{assetPath: "", want: false},
}
@@ -728,6 +786,40 @@ func TestShouldWindowsUpdateLaunchDownloadedAssetDirectly(t *testing.T) {
}
}
func TestExpectedAssetNameForExecutableUsesWindowsPortableSuffix(t *testing.T) {
cases := []struct {
name string
goarch string
version string
want string
}{
{
name: "amd64 release",
goarch: "amd64",
version: "v1.2.3",
want: "GoNavi-1.2.3-Windows-Amd64-Portable.exe",
},
{
name: "arm64 dev",
goarch: "arm64",
version: "dev-a1b2c3d",
want: "GoNavi-dev-a1b2c3d-Windows-Arm64-Portable.exe",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := expectedAssetNameForExecutable("windows", tc.goarch, tc.version, "")
if err != nil {
t.Fatalf("expectedAssetNameForExecutable returned error: %v", err)
}
if got != tc.want {
t.Fatalf("expectedAssetNameForExecutable() = %q, want %q", got, tc.want)
}
})
}
}
func TestBuildWindowsPowerShellScriptReplacesTargetWithDownloadedExe(t *testing.T) {
script := buildWindowsPowerShellScript()

View File

@@ -115,7 +115,8 @@ func (a *App) GetUpdateChannel() connection.QueryResult {
Success: true,
Message: "OK",
Data: map[string]any{
"channel": string(channel),
"channel": string(channel),
"installMode": string(updateResolveInstallMode()),
},
}
}
@@ -152,7 +153,8 @@ func (a *App) SetUpdateChannel(channel string) connection.QueryResult {
Success: true,
Message: a.appText("app.update.backend.message.channel_changed", map[string]any{"channel": string(normalized)}),
Data: map[string]any{
"channel": string(normalized),
"channel": string(normalized),
"installMode": string(updateResolveInstallMode()),
},
}
}

View File

@@ -1,6 +1,7 @@
package app
import (
"fmt"
"io"
"os"
"path/filepath"
@@ -10,6 +11,76 @@ import (
"GoNavi-Wails/internal/logger"
)
func launchWindowsMSIUpdate(staged *stagedUpdate, targetExe string, pid int) error {
if staged == nil {
return localizedUpdateError{key: "app.update.backend.message.no_downloaded_package"}
}
if !isUpdatePackageCompatibleWithInstallMode("windows", staged.InstallMode, staged.PackageType, staged.FilePath) {
return localizedUpdateError{
key: "app.update.backend.error.online_update_unsupported",
params: map[string]any{"platform": "windows/" + string(staged.InstallMode) + "/" + string(staged.PackageType)},
}
}
if err := os.MkdirAll(staged.StagedDir, 0o755); err != nil {
return err
}
originalSourceDir := strings.TrimSpace(filepath.Dir(staged.FilePath))
preparedSource, err := prepareWindowsStagedUpdateAsset(staged.FilePath, staged.StagedDir)
if err != nil {
return err
}
staged.FilePath = preparedSource
staged.InstallLogPath = buildUpdateInstallLogPath(staged.StagedDir)
msiLogPath := strings.TrimSuffix(staged.InstallLogPath, filepath.Ext(staged.InstallLogPath)) + "-msi.log"
cleanupWindowsUpdateArtifacts([]string{
originalSourceDir,
strings.TrimSpace(filepath.Dir(staged.StagedDir)),
}, map[string]struct{}{
cleanComparablePath(staged.FilePath): {},
cleanComparablePath(staged.StagedDir): {},
})
scriptPath := filepath.Join(staged.StagedDir, "update-msi.ps1")
if err := os.WriteFile(scriptPath, []byte(buildWindowsMSIUpdatePowerShellScript()), 0o644); err != nil {
return err
}
msiExecPath := resolveWindowsMSIExecPath(os.Getenv)
context := windowsMSIUpdateLaunchContext{
SourcePath: staged.FilePath,
TargetPath: strings.TrimSpace(targetExe),
StagedDir: staged.StagedDir,
LogPath: staged.InstallLogPath,
MSILogPath: msiLogPath,
MSIExecPath: msiExecPath,
PID: pid,
}
logger.Infof("启动 Windows MSI 更新器target=%s script=%s log=%s msi_log=%s package=%s", targetExe, scriptPath, staged.InstallLogPath, msiLogPath, staged.FilePath)
cmd := buildWindowsMSILaunchCommand(scriptPath, context)
if err := cmd.Start(); err != nil {
return fmt.Errorf("start Windows MSI updater: %w", err)
}
if cmd.Process != nil {
if err := cmd.Process.Release(); err != nil {
logger.Warnf("释放 Windows MSI 更新脚本进程句柄失败:%v", err)
}
}
return nil
}
func resolveWindowsMSIExecPath(getenv func(string) string) string {
if getenv != nil {
if overridden := strings.TrimSpace(getenv("GONAVI_UPDATE_MSIEXEC_PATH")); overridden != "" {
return overridden
}
if systemRoot := strings.TrimSpace(getenv("SystemRoot")); systemRoot != "" {
return filepath.Join(systemRoot, "System32", "msiexec.exe")
}
}
return filepath.Join(`C:\Windows`, "System32", "msiexec.exe")
}
func launchWindowsUpdateWithCleanup(staged *stagedUpdate, targetExe string, pid int) error {
if staged == nil {
return localizedUpdateError{key: "app.update.backend.message.no_downloaded_package"}
@@ -173,7 +244,7 @@ func shouldRemoveWindowsUpdateArtifact(name string, isDir bool) bool {
if !strings.Contains(trimmed, "-Windows-") {
return false
}
return strings.HasSuffix(lower, ".exe") || strings.HasSuffix(lower, ".zip")
return strings.HasSuffix(lower, ".exe") || strings.HasSuffix(lower, ".msi") || strings.HasSuffix(lower, ".zip")
}
func cleanComparablePath(path string) string {

View File

@@ -14,6 +14,7 @@ func TestShouldRemoveWindowsUpdateArtifact(t *testing.T) {
want bool
}{
{name: "GoNavi-dev-abc-Windows-Amd64.exe", want: true},
{name: "GoNavi-dev-abc-Windows-Amd64-Installer.msi", want: true},
{name: "GoNavi-0.8.4-Windows-Amd64.zip", want: true},
{name: "gonavi-update-windows-123.log", want: true},
{name: ".gonavi-update-windows-dev-dev-abc", isDir: true, want: true},
@@ -29,6 +30,37 @@ func TestShouldRemoveWindowsUpdateArtifact(t *testing.T) {
}
}
func TestResolveReusableStagedUpdateDoesNotReuseDifferentWindowsPackageType(t *testing.T) {
tempDir := t.TempDir()
assetPath := filepath.Join(tempDir, "GoNavi-0.8.5-Windows-Amd64-Installer.msi")
if err := os.WriteFile(assetPath, []byte("12345678"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
info := UpdateInfo{
Channel: string(updateChannelLatest),
LatestVersion: "0.8.5",
AssetName: filepath.Base(assetPath),
AssetSize: 8,
InstallMode: string(updateInstallModeMSI),
PackageType: string(updatePackageTypeMSI),
AutoRelaunch: true,
}
current := &stagedUpdate{
Channel: updateChannelLatest,
Version: info.LatestVersion,
AssetName: info.AssetName,
FilePath: assetPath,
InstallMode: updateInstallModePortable,
PackageType: updatePackageTypePortable,
AutoRelaunch: true,
}
reused := resolveReusableStagedUpdateForPlatform("windows", "", "", info, current)
if reused != nil {
t.Fatalf("expected package type mismatch not to reuse current staged update, got %#v", reused)
}
}
func TestCleanupWindowsUpdateArtifactsKeepsCurrentTargetAndRemovesStalePackages(t *testing.T) {
dir := t.TempDir()
currentTarget := filepath.Join(dir, "GoNavi-dev-current-Windows-Amd64.exe")

View File

@@ -0,0 +1,108 @@
package app
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
)
const windowsMSIInstallMarker = ".gonavi-msi-install"
type updateInstallMode string
const (
updateInstallModeUnknown updateInstallMode = "unknown"
updateInstallModePortable updateInstallMode = "portable"
updateInstallModeMSI updateInstallMode = "msi"
)
type updatePackageType string
const (
updatePackageTypePortable updatePackageType = "portable"
updatePackageTypeMSI updatePackageType = "msi"
updatePackageTypeDMG updatePackageType = "dmg"
updatePackageTypeArchive updatePackageType = "archive"
)
func resolveCurrentUpdateInstallMode() updateInstallMode {
return resolveUpdateInstallModeForExecutable(runtime.GOOS, updateResolveInstallTarget())
}
func resolveUpdateInstallModeForExecutable(goos string, executablePath string) updateInstallMode {
if !strings.EqualFold(strings.TrimSpace(goos), "windows") {
return updateInstallModeUnknown
}
executablePath = strings.TrimSpace(executablePath)
if executablePath == "" {
return updateInstallModeUnknown
}
markerPath := filepath.Join(filepath.Dir(executablePath), windowsMSIInstallMarker)
info, err := os.Stat(markerPath)
if err == nil {
if info.IsDir() {
return updateInstallModeUnknown
}
return updateInstallModeMSI
}
if errors.Is(err, os.ErrNotExist) {
return updateInstallModePortable
}
return updateInstallModeUnknown
}
func resolveUpdatePackageType(goos string, installMode updateInstallMode) updatePackageType {
switch strings.ToLower(strings.TrimSpace(goos)) {
case "windows":
if installMode == updateInstallModeMSI {
return updatePackageTypeMSI
}
if installMode == updateInstallModePortable {
return updatePackageTypePortable
}
case "darwin":
return updatePackageTypeDMG
case "linux":
return updatePackageTypeArchive
}
return ""
}
func isUpdatePackageCompatibleWithInstallMode(goos string, installMode updateInstallMode, packageType updatePackageType, assetPath string) bool {
if !strings.EqualFold(strings.TrimSpace(goos), "windows") {
return true
}
extension := strings.ToLower(strings.TrimSpace(filepath.Ext(strings.TrimSpace(assetPath))))
switch installMode {
case updateInstallModeMSI:
return packageType == updatePackageTypeMSI && extension == ".msi"
case updateInstallModePortable:
return packageType == updatePackageTypePortable && extension == ".exe"
default:
return false
}
}
func validateUpdatePackageForCurrentInstallMode(goos string, declaredMode updateInstallMode, packageType updatePackageType, assetPath string) error {
if !strings.EqualFold(strings.TrimSpace(goos), "windows") {
return nil
}
currentMode := updateResolveInstallMode()
if currentMode == updateInstallModeUnknown || declaredMode != currentMode ||
!isUpdatePackageCompatibleWithInstallMode(goos, currentMode, packageType, assetPath) {
return localizedUpdateError{
key: "app.update.backend.error.online_update_unsupported",
params: map[string]any{
"platform": strings.Join([]string{
strings.TrimSpace(goos),
string(currentMode),
string(declaredMode),
string(packageType),
}, "/"),
},
}
}
return nil
}

View File

@@ -0,0 +1,92 @@
package app
import (
"os"
"path/filepath"
"testing"
)
func TestResolveUpdateInstallModeForExecutableUsesSiblingMSIMarker(t *testing.T) {
installDir := t.TempDir()
executablePath := filepath.Join(installDir, "GoNavi.exe")
if got := resolveUpdateInstallModeForExecutable("windows", executablePath); got != updateInstallModePortable {
t.Fatalf("install mode without marker = %q, want portable", got)
}
markerPath := filepath.Join(installDir, windowsMSIInstallMarker)
if err := os.WriteFile(markerPath, []byte("msi\n"), 0o644); err != nil {
t.Fatalf("WriteFile marker: %v", err)
}
if got := resolveUpdateInstallModeForExecutable("windows", executablePath); got != updateInstallModeMSI {
t.Fatalf("install mode with marker = %q, want msi", got)
}
if got := resolveUpdateInstallModeForExecutable("linux", executablePath); got != updateInstallModeUnknown {
t.Fatalf("non-Windows install mode = %q, want unknown", got)
}
}
func TestResolveUpdateInstallModeForExecutableRejectsMarkerDirectory(t *testing.T) {
installDir := t.TempDir()
if err := os.Mkdir(filepath.Join(installDir, windowsMSIInstallMarker), 0o755); err != nil {
t.Fatalf("Mkdir marker: %v", err)
}
if got := resolveUpdateInstallModeForExecutable("windows", filepath.Join(installDir, "GoNavi.exe")); got != updateInstallModeUnknown {
t.Fatalf("install mode with invalid marker directory = %q, want unknown", got)
}
}
func TestExpectedAssetNameForWindowsInstallMode(t *testing.T) {
cases := []struct {
name string
arch string
installMode updateInstallMode
want string
}{
{name: "amd64 portable", arch: "amd64", installMode: updateInstallModePortable, want: "GoNavi-1.2.3-Windows-Amd64-Portable.exe"},
{name: "amd64 msi", arch: "amd64", installMode: updateInstallModeMSI, want: "GoNavi-1.2.3-Windows-Amd64-Installer.msi"},
{name: "arm64 portable", arch: "arm64", installMode: updateInstallModePortable, want: "GoNavi-1.2.3-Windows-Arm64-Portable.exe"},
{name: "arm64 msi", arch: "arm64", installMode: updateInstallModeMSI, want: "GoNavi-1.2.3-Windows-Arm64-Installer.msi"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := expectedAssetNameForExecutableAndInstallMode("windows", tc.arch, "v1.2.3", "", tc.installMode)
if err != nil {
t.Fatalf("expectedAssetNameForExecutableAndInstallMode: %v", err)
}
if got != tc.want {
t.Fatalf("asset name = %q, want %q", got, tc.want)
}
})
}
}
func TestResolveUpdateWorkspaceDirForPlatformSeparatesMSIFromInstallDirectory(t *testing.T) {
installTarget := filepath.Join("C:\\Program Files", "GoNavi", "GoNavi.exe")
userCacheDir := filepath.Join("C:\\Users", "tester", "AppData", "Local")
msiDir := resolveUpdateWorkspaceDirForPlatform("windows", "1.2.3", updateInstallModeMSI, installTarget, userCacheDir)
wantMSIDir := filepath.Join(userCacheDir, "GoNavi", "updates")
if msiDir != wantMSIDir {
t.Fatalf("MSI workspace = %q, want %q", msiDir, wantMSIDir)
}
portableDir := resolveUpdateWorkspaceDirForPlatform("windows", "1.2.3", updateInstallModePortable, installTarget, userCacheDir)
if portableDir != filepath.Dir(installTarget) {
t.Fatalf("portable workspace = %q, want install directory %q", portableDir, filepath.Dir(installTarget))
}
}
func TestValidateUpdatePackageForCurrentInstallModeRejectsModeAndSuffixMismatch(t *testing.T) {
originalResolveMode := updateResolveInstallMode
t.Cleanup(func() { updateResolveInstallMode = originalResolveMode })
updateResolveInstallMode = func() updateInstallMode { return updateInstallModeMSI }
if err := validateUpdatePackageForCurrentInstallMode("windows", updateInstallModeMSI, updatePackageTypeMSI, `C:\\tmp\\GoNavi-Installer.msi`); err != nil {
t.Fatalf("valid MSI package rejected: %v", err)
}
if err := validateUpdatePackageForCurrentInstallMode("windows", updateInstallModePortable, updatePackageTypePortable, `C:\\tmp\\GoNavi-Portable.exe`); err == nil {
t.Fatal("expected changed install mode to be rejected")
}
if err := validateUpdatePackageForCurrentInstallMode("windows", updateInstallModeMSI, updatePackageTypeMSI, `C:\\tmp\\GoNavi-Installer.exe`); err == nil {
t.Fatal("expected invalid MSI suffix to be rejected")
}
}

View File

@@ -0,0 +1,151 @@
$ErrorActionPreference = 'Stop'
$Source = $env:GONAVI_UPDATE_SOURCE
$Target = $env:GONAVI_UPDATE_TARGET
$StagedDir = $env:GONAVI_UPDATE_STAGED_DIR
$LogPath = $env:GONAVI_UPDATE_LOG_PATH
$MSILogPath = $env:GONAVI_UPDATE_MSI_LOG_PATH
$MSIExecPath = $env:GONAVI_UPDATE_MSIEXEC_PATH
$HostProcessId = 0
$HostExited = $false
$InstallSucceeded = $false
$LaunchSucceeded = $false
function Write-UpdateLog {
param([string]$Message)
if ([string]::IsNullOrWhiteSpace($LogPath)) {
return
}
try {
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'
Add-Content -LiteralPath $LogPath -Value "[$timestamp] $Message" -Encoding UTF8
} catch {
# Logging must never hide the original updater error.
}
}
function Quote-NativeArgument {
param([string]$Value)
return '"' + $Value.Replace('"', '\"') + '"'
}
function Remove-UpdateArtifact {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) {
return
}
try {
if (Test-Path -LiteralPath $Path) {
Remove-Item -LiteralPath $Path -Force -Recurse
}
} catch {
Write-UpdateLog ("cleanup failed for " + $Path + ": " + $_.Exception.Message)
}
}
try {
foreach ($requiredPath in @($Source, $Target, $StagedDir, $LogPath, $MSILogPath, $MSIExecPath)) {
if ([string]::IsNullOrWhiteSpace($requiredPath)) {
throw 'missing required MSI updater path'
}
}
if (-not [int]::TryParse($env:GONAVI_UPDATE_PID, [ref]$HostProcessId) -or $HostProcessId -le 0) {
throw 'invalid host process id'
}
if (-not (Test-Path -LiteralPath $Source -PathType Leaf)) {
throw 'MSI package not found'
}
if (-not [string]::Equals([IO.Path]::GetExtension($Source), '.msi', [StringComparison]::OrdinalIgnoreCase)) {
throw 'update package is not an MSI file'
}
if (-not (Test-Path -LiteralPath $MSIExecPath -PathType Leaf)) {
throw 'msiexec.exe not found'
}
$TargetDir = [IO.Path]::GetDirectoryName($Target)
if ([string]::IsNullOrWhiteSpace($TargetDir) -or -not (Test-Path -LiteralPath $TargetDir -PathType Container)) {
throw 'target directory does not exist'
}
Write-UpdateLog 'MSI updater started'
$waitedSeconds = 0
while (Get-Process -Id $HostProcessId -ErrorAction SilentlyContinue) {
if ($waitedSeconds -ge 90) {
throw 'host process still running after 90 seconds'
}
Start-Sleep -Seconds 1
$waitedSeconds++
}
$HostExited = $true
Write-UpdateLog 'host process exited'
Start-Sleep -Seconds 2
$MsiArguments = @(
'/i',
(Quote-NativeArgument $Source),
('INSTALLFOLDER=' + (Quote-NativeArgument $TargetDir)),
'/passive',
'/norestart',
'/L*v',
(Quote-NativeArgument $MSILogPath)
)
Write-UpdateLog ("launching msiexec; verbose log: " + $MSILogPath)
$InstallerProcess = Start-Process -FilePath $MSIExecPath -Verb RunAs -ArgumentList $MsiArguments -Wait -PassThru -ErrorAction Stop
$InstallerExitCode = $InstallerProcess.ExitCode
Write-UpdateLog ("msiexec exit code: " + $InstallerExitCode)
if ($InstallerExitCode -notin @(0, 1641, 3010)) {
throw ("msiexec failed with exit code " + $InstallerExitCode)
}
$InstallSucceeded = $true
if (-not (Test-Path -LiteralPath $Target -PathType Leaf)) {
throw 'installed application executable not found'
}
Write-UpdateLog ("launching installed application: " + $Target)
$NewProcess = Start-Process -FilePath $Target -WorkingDirectory $TargetDir -PassThru -ErrorAction Stop
Start-Sleep -Milliseconds 1500
$NewProcess.Refresh()
if ($NewProcess.HasExited) {
throw 'updated application exited immediately after launch'
}
$LaunchSucceeded = $true
Remove-UpdateArtifact $Source
Write-UpdateLog 'MSI update finished'
$CleanupCommand = 'Start-Sleep -Seconds 2; Remove-Item -LiteralPath $env:GONAVI_UPDATE_STAGED_DIR -Recurse -Force -ErrorAction SilentlyContinue'
$EncodedCleanupCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($CleanupCommand))
$CleanupWorkingDirectory = [IO.Path]::GetTempPath()
try {
Start-Process -FilePath 'powershell.exe' -WorkingDirectory $CleanupWorkingDirectory -WindowStyle Hidden -ArgumentList @(
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-EncodedCommand',
$EncodedCleanupCommand
) -ErrorAction Stop | Out-Null
} catch {
Write-UpdateLog ("cleanup scheduler failed: " + $_.Exception.Message)
}
exit 0
} catch {
Write-UpdateLog ("MSI updater failed: " + $_.Exception.Message)
Write-UpdateLog 'MSI package retained for manual install'
if ($HostExited -and -not $LaunchSucceeded -and (Test-Path -LiteralPath $Target -PathType Leaf)) {
try {
$TargetDir = [IO.Path]::GetDirectoryName($Target)
Start-Process -FilePath $Target -WorkingDirectory $TargetDir -ErrorAction Stop | Out-Null
if ($InstallSucceeded) {
Write-UpdateLog 'installed application relaunched after updater failure'
} else {
Write-UpdateLog 'previous application relaunched after MSI failure'
}
} catch {
Write-UpdateLog ("application relaunch failed: " + $_.Exception.Message)
}
}
exit 1
}

View File

@@ -0,0 +1,50 @@
package app
import (
_ "embed"
"os/exec"
"strconv"
"strings"
)
//go:embed windows_msi_update.ps1
var windowsMSIUpdatePowerShellScript string
type windowsMSIUpdateLaunchContext struct {
SourcePath string
TargetPath string
StagedDir string
LogPath string
MSILogPath string
MSIExecPath string
PID int
}
func buildWindowsMSIUpdatePowerShellScript() string {
normalized := strings.ReplaceAll(windowsMSIUpdatePowerShellScript, "\r\n", "\n")
return strings.ReplaceAll(normalized, "\n", "\r\n")
}
func buildWindowsMSILaunchCommand(scriptPath string, context windowsMSIUpdateLaunchContext) *exec.Cmd {
cmd := exec.Command(
"powershell.exe",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
scriptPath,
)
cmd.Dir = context.StagedDir
cmd.Env = append(cmd.Environ(),
"GONAVI_UPDATE_SOURCE="+context.SourcePath,
"GONAVI_UPDATE_TARGET="+context.TargetPath,
"GONAVI_UPDATE_STAGED_DIR="+context.StagedDir,
"GONAVI_UPDATE_LOG_PATH="+context.LogPath,
"GONAVI_UPDATE_MSI_LOG_PATH="+context.MSILogPath,
"GONAVI_UPDATE_MSIEXEC_PATH="+context.MSIExecPath,
"GONAVI_UPDATE_PID="+strconv.Itoa(context.PID),
)
configureWindowsUpdateCommand(cmd)
return cmd
}

View File

@@ -0,0 +1,89 @@
package app
import (
"path/filepath"
"strings"
"testing"
)
func TestBuildWindowsMSIUpdatePowerShellScriptInstallsRelaunchesAndCleans(t *testing.T) {
script := buildWindowsMSIUpdatePowerShellScript()
mustContain := []string{
`while (Get-Process -Id $HostProcessId -ErrorAction SilentlyContinue)`,
`Start-Process -FilePath $MSIExecPath -Verb RunAs`,
`'INSTALLFOLDER=' + (Quote-NativeArgument $TargetDir)`,
`'/passive'`,
`'/norestart'`,
`'/L*v'`,
`$InstallerExitCode -notin @(0, 1641, 3010)`,
`Start-Process -FilePath $Target -WorkingDirectory $TargetDir`,
`Remove-UpdateArtifact $Source`,
`MSI package retained for manual install`,
`previous application relaunched after MSI failure`,
}
for _, token := range mustContain {
if !strings.Contains(script, token) {
t.Fatalf("MSI updater missing %q\n%s", token, script)
}
}
if strings.Index(script, `Remove-UpdateArtifact $Source`) < strings.Index(script, `Start-Process -FilePath $Target -WorkingDirectory $TargetDir`) {
t.Fatalf("MSI package must be removed only after relaunch\n%s", script)
}
for _, r := range script {
if r > 0x7f {
t.Fatalf("MSI updater must remain ASCII-only, found %q", r)
}
}
}
func TestBuildWindowsMSILaunchCommandPreservesPathsInEnvironment(t *testing.T) {
context := windowsMSIUpdateLaunchContext{
SourcePath: `C:\Users\tester\AppData\Local\GoNavi 100%\GoNavi-Installer.msi`,
TargetPath: `D:\software ! 100% & portable\GoNavi.exe`,
StagedDir: `C:\Users\tester\AppData\Local\GoNavi 100%\stage`,
LogPath: `C:\Users\tester\AppData\Local\GoNavi 100%\stage\update.log`,
MSILogPath: `C:\Users\tester\AppData\Local\GoNavi 100%\stage\msi.log`,
MSIExecPath: `C:\Windows\System32\msiexec.exe`,
PID: 12345,
}
cmd := buildWindowsMSILaunchCommand(filepath.Join(context.StagedDir, "update-msi.ps1"), context)
want := map[string]string{
"GONAVI_UPDATE_SOURCE": context.SourcePath,
"GONAVI_UPDATE_TARGET": context.TargetPath,
"GONAVI_UPDATE_STAGED_DIR": context.StagedDir,
"GONAVI_UPDATE_LOG_PATH": context.LogPath,
"GONAVI_UPDATE_MSI_LOG_PATH": context.MSILogPath,
"GONAVI_UPDATE_MSIEXEC_PATH": context.MSIExecPath,
"GONAVI_UPDATE_PID": "12345",
}
got := make(map[string]string, len(want))
for _, item := range cmd.Env {
name, value, ok := strings.Cut(item, "=")
if ok {
if _, exists := want[name]; exists {
got[name] = value
}
}
}
for name, value := range want {
if got[name] != value {
t.Fatalf("environment %s = %q, want %q", name, got[name], value)
}
}
}
func TestResolveWindowsMSIExecPathPrefersExplicitOverride(t *testing.T) {
values := map[string]string{
"GONAVI_UPDATE_MSIEXEC_PATH": `D:\\tools\\fake-msiexec.exe`,
"SystemRoot": `C:\\Windows`,
}
got := resolveWindowsMSIExecPath(func(name string) string { return values[name] })
if got != values["GONAVI_UPDATE_MSIEXEC_PATH"] {
t.Fatalf("msiexec path = %q, want override %q", got, values["GONAVI_UPDATE_MSIEXEC_PATH"])
}
delete(values, "GONAVI_UPDATE_MSIEXEC_PATH")
wantSystem := filepath.Join(values["SystemRoot"], "System32", "msiexec.exe")
if got := resolveWindowsMSIExecPath(func(name string) string { return values[name] }); got != wantSystem {
t.Fatalf("msiexec path = %q, want SystemRoot path %q", got, wantSystem)
}
}