mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-31 18:59:44 +08:00
✨ feat(update): 支持 Windows MSI 与 Portable 分流更新
- 根据当前安装标记精确选择 MSI 或 Portable 更新包 - MSI 更新下载到用户缓存并通过 msiexec 保留原目录安装重启 - 下载与安装前校验包类型并隔离不同安装方式的缓存 - 补充安装模式、脚本执行和更新清理测试
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
108
internal/app/update_install_mode.go
Normal file
108
internal/app/update_install_mode.go
Normal 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
|
||||
}
|
||||
92
internal/app/update_install_mode_test.go
Normal file
92
internal/app/update_install_mode_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
151
internal/app/windows_msi_update.ps1
Normal file
151
internal/app/windows_msi_update.ps1
Normal 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
|
||||
}
|
||||
50
internal/app/windows_msi_update_script.go
Normal file
50
internal/app/windows_msi_update_script.go
Normal 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
|
||||
}
|
||||
89
internal/app/windows_msi_update_script_test.go
Normal file
89
internal/app/windows_msi_update_script_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user