diff --git a/internal/app/application_quit_test.go b/internal/app/application_quit_test.go index 16408664..66c2c37b 100644 --- a/internal/app/application_quit_test.go +++ b/internal/app/application_quit_test.go @@ -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) diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index 3f41e0df..31e74789 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -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) } diff --git a/internal/app/methods_update_test.go b/internal/app/methods_update_test.go index 27b7036b..f9c3ae90 100644 --- a/internal/app/methods_update_test.go +++ b/internal/app/methods_update_test.go @@ -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() diff --git a/internal/app/update_channel_state.go b/internal/app/update_channel_state.go index de82feaf..af8f4369 100644 --- a/internal/app/update_channel_state.go +++ b/internal/app/update_channel_state.go @@ -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()), }, } } diff --git a/internal/app/update_cleanup.go b/internal/app/update_cleanup.go index 7fedf5b5..d655fa15 100644 --- a/internal/app/update_cleanup.go +++ b/internal/app/update_cleanup.go @@ -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 { diff --git a/internal/app/update_cleanup_test.go b/internal/app/update_cleanup_test.go index 75cfea63..e95b5e0a 100644 --- a/internal/app/update_cleanup_test.go +++ b/internal/app/update_cleanup_test.go @@ -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") diff --git a/internal/app/update_install_mode.go b/internal/app/update_install_mode.go new file mode 100644 index 00000000..172982c7 --- /dev/null +++ b/internal/app/update_install_mode.go @@ -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 +} diff --git a/internal/app/update_install_mode_test.go b/internal/app/update_install_mode_test.go new file mode 100644 index 00000000..ca30eb90 --- /dev/null +++ b/internal/app/update_install_mode_test.go @@ -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") + } +} diff --git a/internal/app/windows_msi_update.ps1 b/internal/app/windows_msi_update.ps1 new file mode 100644 index 00000000..3bc4d0d1 --- /dev/null +++ b/internal/app/windows_msi_update.ps1 @@ -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 +} diff --git a/internal/app/windows_msi_update_script.go b/internal/app/windows_msi_update_script.go new file mode 100644 index 00000000..6d3c2569 --- /dev/null +++ b/internal/app/windows_msi_update_script.go @@ -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 +} diff --git a/internal/app/windows_msi_update_script_test.go b/internal/app/windows_msi_update_script_test.go new file mode 100644 index 00000000..7a0c2d14 --- /dev/null +++ b/internal/app/windows_msi_update_script_test.go @@ -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) + } +}