From d48c7d06d66a358e6c25e02e6592dd35d6f0c99b Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 27 Jul 2026 00:14:03 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(updater):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=8A=B6=E6=80=81=E7=AB=9E=E6=80=81=E4=B8=8E=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E8=BF=9B=E7=A8=8B=E5=9B=9E=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/background_command.go | 22 ++ internal/app/background_command_test.go | 38 +++ internal/app/methods_driver.go | 6 +- internal/app/methods_update.go | 115 ++++++-- internal/app/methods_update_test.go | 354 ++++++++++++++++++++++++ internal/app/update_channel_state.go | 1 + shared/i18n/de-DE.json | 1 + shared/i18n/en-US.json | 1 + shared/i18n/ja-JP.json | 1 + shared/i18n/ru-RU.json | 1 + shared/i18n/zh-CN.json | 1 + shared/i18n/zh-TW.json | 1 + 12 files changed, 517 insertions(+), 25 deletions(-) create mode 100644 internal/app/background_command.go create mode 100644 internal/app/background_command_test.go diff --git a/internal/app/background_command.go b/internal/app/background_command.go new file mode 100644 index 00000000..0c2e3e9d --- /dev/null +++ b/internal/app/background_command.go @@ -0,0 +1,22 @@ +package app + +import ( + "errors" + "os/exec" +) + +func startBackgroundCommand(cmd *exec.Cmd, onExit func(error)) error { + if cmd == nil { + return errors.New("background command is nil") + } + if err := cmd.Start(); err != nil { + return err + } + go func() { + err := cmd.Wait() + if onExit != nil { + onExit(err) + } + }() + return nil +} diff --git a/internal/app/background_command_test.go b/internal/app/background_command_test.go new file mode 100644 index 00000000..bea50761 --- /dev/null +++ b/internal/app/background_command_test.go @@ -0,0 +1,38 @@ +package app + +import ( + "os" + "os/exec" + "testing" + "time" +) + +func TestStartBackgroundCommandReapsExitedProcess(t *testing.T) { + cmd := exec.Command(os.Args[0], "-test.run=^TestBackgroundCommandHelperProcess$") + cmd.Env = append(os.Environ(), "GO_WANT_BACKGROUND_COMMAND_HELPER=1") + + reaped := make(chan error, 1) + if err := startBackgroundCommand(cmd, func(err error) { + reaped <- err + }); err != nil { + t.Fatalf("startBackgroundCommand returned error: %v", err) + } + + select { + case err := <-reaped: + if err != nil { + t.Fatalf("background helper exited with error: %v", err) + } + if cmd.ProcessState == nil || !cmd.ProcessState.Exited() { + t.Fatalf("expected exited helper to be reaped, got state %#v", cmd.ProcessState) + } + case <-time.After(5 * time.Second): + t.Fatal("background helper exited but was not reaped") + } +} + +func TestBackgroundCommandHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_BACKGROUND_COMMAND_HELPER") != "1" { + return + } +} diff --git a/internal/app/methods_driver.go b/internal/app/methods_driver.go index 127df468..37dc03ca 100644 --- a/internal/app/methods_driver.go +++ b/internal/app/methods_driver.go @@ -1033,7 +1033,11 @@ func (a *App) OpenDriverDownloadDirectory(directory string) connection.QueryResu default: return connection.QueryResult{Success: false, Message: a.appText("driver_manager.backend.error.open_directory_unsupported", map[string]any{"platform": stdRuntime.GOOS})} } - if err := cmd.Start(); err != nil { + if err := startBackgroundCommand(cmd, func(waitErr error) { + if waitErr != nil { + logger.Warnf("打开驱动目录的后台进程退出异常:%v", waitErr) + } + }); err != nil { logger.Error(err, "打开驱动目录失败") return connection.QueryResult{Success: false, Message: a.appText("driver_manager.backend.error.open_directory_failed", map[string]any{"detail": err.Error()})} } diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index f266531c..ef82cae8 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -68,6 +68,7 @@ type updateState struct { lastCheck *UpdateInfo downloading bool staged *stagedUpdate + revision uint64 } type UpdateInfo struct { @@ -133,6 +134,22 @@ type stagedUpdate struct { UpdateHandoffEventName string } +func snapshotStagedUpdate(current *stagedUpdate) *stagedUpdate { + if current == nil { + return nil + } + snapshot := *current + return &snapshot +} + +func snapshotUpdateInfo(current *UpdateInfo) *UpdateInfo { + if current == nil { + return nil + } + snapshot := *current + return &snapshot +} + type updatePathCandidate struct { workspaceDir string stagedDir string @@ -193,7 +210,12 @@ func (a *App) CheckForUpdatesSilently() connection.QueryResult { func (a *App) checkForUpdates(logFailure bool, forceNetwork bool) connection.QueryResult { a.ensurePersistedGlobalProxyRuntime() + a.updateMu.Lock() channel := a.currentUpdateChannel() + expectedRevision := a.updateState.revision + currentStaged := snapshotStagedUpdate(a.updateState.staged) + a.updateMu.Unlock() + info, err := fetchLatestUpdateInfoWithOptions(channel, forceNetwork) if err != nil { if logFailure { @@ -202,11 +224,6 @@ func (a *App) checkForUpdates(logFailure bool, forceNetwork bool) connection.Que return connection.QueryResult{Success: false, Message: a.localizedUpdateError(err)} } - var currentStaged *stagedUpdate - a.updateMu.Lock() - currentStaged = a.updateState.staged - a.updateMu.Unlock() - if info.HasUpdate { reusable := resolveReusableStagedUpdate(info, currentStaged) if reusable != nil { @@ -220,10 +237,12 @@ func (a *App) checkForUpdates(logFailure bool, forceNetwork bool) connection.Que currentStaged = nil } - a.updateMu.Lock() - a.updateState.lastCheck = &info - a.updateState.staged = currentStaged - a.updateMu.Unlock() + if !a.publishUpdateCheckSnapshot(expectedRevision, info, currentStaged) { + return connection.QueryResult{ + Success: false, + Message: a.appText("app.update.backend.message.check_stale", nil), + } + } msg := a.appText("app.update.backend.message.latest", nil) if info.HasUpdate { @@ -232,6 +251,18 @@ func (a *App) checkForUpdates(logFailure bool, forceNetwork bool) connection.Que return connection.QueryResult{Success: true, Message: msg, Data: info} } +func (a *App) publishUpdateCheckSnapshot(expectedRevision uint64, info UpdateInfo, staged *stagedUpdate) bool { + a.updateMu.Lock() + defer a.updateMu.Unlock() + if a.updateState.revision != expectedRevision { + return false + } + a.updateState.lastCheck = snapshotUpdateInfo(&info) + a.updateState.staged = snapshotStagedUpdate(staged) + a.updateState.revision++ + return true +} + func (a *App) GetAppInfo() connection.QueryResult { info := AppInfo{ Version: getCurrentVersion(), @@ -251,7 +282,7 @@ func (a *App) DownloadUpdate() connection.QueryResult { a.updateMu.Unlock() return connection.QueryResult{Success: false, Message: a.appText("app.update.backend.message.download_in_progress", nil)} } - info := a.updateState.lastCheck + info := snapshotUpdateInfo(a.updateState.lastCheck) if info == nil { a.updateMu.Unlock() return connection.QueryResult{Success: false, Message: a.appText("app.update.backend.message.check_first", nil)} @@ -273,14 +304,16 @@ func (a *App) DownloadUpdate() connection.QueryResult { a.updateMu.Unlock() return connection.QueryResult{Success: false, Message: a.localizedUpdateError(err)} } - staged := resolveReusableStagedUpdate(*info, a.updateState.staged) + staged := resolveReusableStagedUpdate(*info, snapshotStagedUpdate(a.updateState.staged)) if staged != nil { a.updateState.staged = staged + a.updateState.revision++ a.updateMu.Unlock() return connection.QueryResult{Success: true, Message: a.appText("app.update.backend.message.package_already_downloaded", nil), Data: buildUpdateDownloadResult(*info, staged)} } a.updateState.staged = nil a.updateState.downloading = true + a.updateState.revision++ a.updateMu.Unlock() a.emitUpdateDownloadProgress("start", 0, info.AssetSize, "") @@ -295,14 +328,26 @@ func (a *App) DownloadUpdate() connection.QueryResult { func (a *App) InstallUpdateAndRestart(closeAllWindowsInstancesConfirmed bool) connection.QueryResult { a.updateMu.Lock() - staged := a.updateState.staged - if staged != nil && strings.TrimSpace(staged.InstallLogPath) == "" { - staged.InstallLogPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath)) - } + staged := snapshotStagedUpdate(a.updateState.staged) a.updateMu.Unlock() if staged == nil { return connection.QueryResult{Success: false, Message: a.appText("app.update.backend.message.no_downloaded_package", nil)} } + if strings.TrimSpace(staged.InstallLogPath) == "" { + staged.InstallLogPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath)) + } + installTarget := "" + if stdRuntime.GOOS == "windows" { + installTarget = strings.TrimSpace(updateResolveInstallTarget()) + if installTarget == "" { + return connection.QueryResult{ + Success: false, + Message: a.appText("app.update.backend.message.install_launch_failed", map[string]any{ + "detail": a.appText("app.update.backend.error.install_target_unresolved", nil), + }), + } + } + } if err := validateUpdatePackageForCurrentInstallMode(stdRuntime.GOOS, staged.InstallMode, staged.PackageType, staged.FilePath); err != nil { return connection.QueryResult{ Success: false, @@ -312,7 +357,6 @@ func (a *App) InstallUpdateAndRestart(closeAllWindowsInstancesConfirmed bool) co } } if stdRuntime.GOOS == "windows" { - installTarget := updateResolveInstallTarget() maintenanceLease, err := updateAcquireWindowsMaintenance(installTarget) if err != nil { return connection.QueryResult{ @@ -446,7 +490,7 @@ func (a *App) quitForUpdate() { func (a *App) OpenDownloadedUpdateDirectory() connection.QueryResult { a.updateMu.Lock() - staged := a.updateState.staged + staged := snapshotStagedUpdate(a.updateState.staged) a.updateMu.Unlock() if staged == nil { return connection.QueryResult{Success: false, Message: a.appText("app.update.backend.message.no_downloaded_package", nil)} @@ -474,7 +518,11 @@ func (a *App) OpenDownloadedUpdateDirectory() connection.QueryResult { default: return connection.QueryResult{Success: false, Message: a.appText("app.update.backend.message.open_directory_unsupported", map[string]any{"platform": stdRuntime.GOOS})} } - if err := cmd.Start(); err != nil { + if err := startBackgroundCommand(cmd, func(waitErr error) { + if waitErr != nil { + logger.Warnf("打开更新目录的后台进程退出异常:%v", waitErr) + } + }); err != nil { logger.Error(err, "打开更新目录失败") return connection.QueryResult{Success: false, Message: a.appText("app.update.backend.message.open_directory_failed", map[string]any{"detail": err.Error()})} } @@ -574,6 +622,7 @@ func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult { info.DownloadPath = assetPath a.updateMu.Lock() a.updateState.staged = staged + a.updateState.revision++ a.updateMu.Unlock() a.emitUpdateDownloadProgress("done", info.AssetSize, info.AssetSize, "") @@ -1732,17 +1781,36 @@ func resolveReusableStagedUpdateForPlatform(goos string, preferredWorkspaceDir s } func resolveUpdateInstallTarget() string { - exePath, err := os.Executable() + exePath, err := resolveExecutablePath(os.Executable, filepath.EvalSymlinks) if err != nil { return "" } - exePath, _ = filepath.EvalSymlinks(exePath) if stdRuntime.GOOS == "darwin" { return resolveMacUpdateTarget(exePath) } return exePath } +func resolveExecutablePath( + executable func() (string, error), + evalSymlinks func(string) (string, error), +) (string, error) { + exePath, err := executable() + if err != nil { + return "", err + } + exePath = strings.TrimSpace(exePath) + if exePath == "" { + return "", localizedUpdateError{key: "app.update.backend.error.install_target_unresolved"} + } + if resolved, evalErr := evalSymlinks(exePath); evalErr == nil { + if resolved = strings.TrimSpace(resolved); resolved != "" { + exePath = resolved + } + } + return exePath, nil +} + func ensureWindowsUpdateTargetWritable(targetExe string) error { targetExe = strings.TrimSpace(targetExe) targetDir := strings.TrimSpace(filepath.Dir(targetExe)) @@ -1795,11 +1863,10 @@ func (a *App) emitUpdateDownloadProgress(status string, downloaded, total int64, } func launchUpdateScript(staged *stagedUpdate) error { - exePath, err := os.Executable() - if err != nil { - return err + exePath, err := resolveExecutablePath(os.Executable, filepath.EvalSymlinks) + if err != nil || strings.TrimSpace(exePath) == "" { + return localizedUpdateError{key: "app.update.backend.error.install_target_unresolved"} } - exePath, _ = filepath.EvalSymlinks(exePath) pid := os.Getpid() switch stdRuntime.GOOS { diff --git a/internal/app/methods_update_test.go b/internal/app/methods_update_test.go index 6de746f1..7892d29f 100644 --- a/internal/app/methods_update_test.go +++ b/internal/app/methods_update_test.go @@ -497,6 +497,299 @@ func TestResolveReusableStagedUpdateDoesNotReuseDifferentChannelPackage(t *testi } } +func TestCheckForUpdatesDoesNotMutatePublishedStagedUpdate(t *testing.T) { + app := NewApp() + app.configDir = t.TempDir() + t.Setenv("GONAVI_DATA_ROOT", t.TempDir()) + + installMode := updateResolveInstallMode() + packageType := resolveUpdatePackageType(stdRuntime.GOOS, installMode) + assetName, err := expectedAssetNameForInstallMode(stdRuntime.GOOS, stdRuntime.GOARCH, "v0.8.6", installMode) + if err != nil { + t.Fatalf("expectedAssetNameForInstallMode returned error: %v", err) + } + assetPath := filepath.Join(t.TempDir(), assetName) + if err := os.WriteFile(assetPath, []byte("12345678"), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + published := &stagedUpdate{ + Channel: updateChannelLatest, + Version: "0.8.6", + AssetName: assetName, + FilePath: assetPath, + StagedDir: filepath.Dir(assetPath), + InstallMode: installMode, + PackageType: packageType, + AutoRelaunch: true, + } + app.updateState.staged = published + + originalVersion := AppVersion + AppVersion = "0.8.5" + t.Cleanup(func() { + AppVersion = originalVersion + }) + restoreStatic := swapUpdateFetchStaticManifest(func(updateChannel) (*githubRelease, error) { + return nil, errors.New("static manifest unavailable in test") + }) + defer restoreStatic() + restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) { + return &githubRelease{ + TagName: "v0.8.6", + Name: "v0.8.6", + HTMLURL: "https://example.com/releases/v0.8.6", + Assets: []githubAsset{{ + Name: assetName, + BrowserDownloadURL: "https://example.com/" + assetName, + Digest: "sha256:" + strings.Repeat("a", 64), + Size: 8, + }}, + }, nil + }) + defer restoreRelease() + + result := app.CheckForUpdates() + if !result.Success { + t.Fatalf("CheckForUpdates returned failure: %#v", result) + } + if published.InstallLogPath != "" { + t.Fatalf("published staged update was mutated outside updateMu: %#v", published) + } + if app.updateState.staged == published { + t.Fatal("expected refreshed update state to publish an immutable staged snapshot") + } + if app.updateState.staged == nil || app.updateState.staged.InstallLogPath == "" { + t.Fatalf("expected refreshed snapshot to include install log path, got %#v", app.updateState.staged) + } +} + +func TestPublishUpdateCheckSnapshotRejectsStaleRevision(t *testing.T) { + app := NewApp() + downloaded := &stagedUpdate{ + Channel: updateChannelLatest, + Version: "0.8.7", + AssetName: "downloaded.zip", + FilePath: filepath.Join(t.TempDir(), "downloaded.zip"), + InstallMode: updateInstallModePortable, + PackageType: updatePackageTypePortable, + } + app.updateState.staged = downloaded + app.updateState.revision = 2 + + published := app.publishUpdateCheckSnapshot(1, UpdateInfo{ + Channel: string(updateChannelLatest), + LatestVersion: "0.8.6", + }, &stagedUpdate{ + Channel: updateChannelLatest, + Version: "0.8.6", + FilePath: filepath.Join(t.TempDir(), "stale.zip"), + AssetName: "stale.zip", + }) + if published { + t.Fatal("stale update check unexpectedly overwrote newer state") + } + if app.updateState.staged != downloaded { + t.Fatalf("newer downloaded package was replaced: %#v", app.updateState.staged) + } + if app.updateState.lastCheck != nil { + t.Fatalf("stale check published lastCheck: %#v", app.updateState.lastCheck) + } + if app.updateState.revision != 2 { + t.Fatalf("stale publish changed revision to %d", app.updateState.revision) + } +} + +func TestCheckForUpdatesRejectsResultWhenStateChangesDuringFetch(t *testing.T) { + app := NewApp() + app.configDir = t.TempDir() + app.SetLanguage("en-US") + t.Setenv("GONAVI_DATA_ROOT", t.TempDir()) + + installMode := updateResolveInstallMode() + assetName, err := expectedAssetNameForInstallMode(stdRuntime.GOOS, stdRuntime.GOARCH, "v0.8.6", installMode) + if err != nil { + t.Fatalf("expectedAssetNameForInstallMode returned error: %v", err) + } + originalVersion := AppVersion + AppVersion = "0.8.5" + t.Cleanup(func() { + AppVersion = originalVersion + }) + + var mutateOnce sync.Once + restoreStatic := swapUpdateFetchStaticManifest(func(updateChannel) (*githubRelease, error) { + mutateOnce.Do(func() { + app.updateMu.Lock() + app.updateState.revision++ + app.updateMu.Unlock() + }) + return &githubRelease{ + TagName: "v0.8.6", + Name: "v0.8.6", + HTMLURL: "https://example.com/releases/v0.8.6", + Assets: []githubAsset{{ + Name: assetName, + BrowserDownloadURL: "https://example.com/" + assetName, + Digest: "sha256:" + strings.Repeat("a", 64), + Size: 8, + }}, + }, nil + }) + defer restoreStatic() + + result := app.CheckForUpdates() + if result.Success { + t.Fatalf("stale update check unexpectedly succeeded: %#v", result) + } + if !strings.Contains(result.Message, "state changed") { + t.Fatalf("expected stale-state message, got %q", result.Message) + } + if app.updateState.lastCheck != nil || app.updateState.staged != nil { + t.Fatalf("stale check changed update state: %#v", app.updateState) + } +} + +func TestCheckForUpdatesDoesNotRaceWithInstallSnapshot(t *testing.T) { + if stdRuntime.GOOS != "windows" { + t.Skip("windows-only updater concurrency coverage") + } + + app := NewApp() + app.configDir = t.TempDir() + app.SetLanguage("en-US") + t.Setenv("GONAVI_DATA_ROOT", t.TempDir()) + + stagedDir := t.TempDir() + assetName := "GoNavi-0.8.6-Windows-Amd64-Portable.zip" + assetPath := filepath.Join(stagedDir, assetName) + if err := os.WriteFile(assetPath, []byte("12345678"), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + published := &stagedUpdate{ + Channel: updateChannelLatest, + Version: "0.8.6", + AssetName: assetName, + FilePath: assetPath, + StagedDir: stagedDir, + InstallLogPath: filepath.Join(stagedDir, "install.log"), + InstallMode: updateInstallModePortable, + PackageType: updatePackageTypePortable, + AutoRelaunch: true, + } + app.updateState.staged = published + + originalVersion := AppVersion + originalResolveInstallTarget := updateResolveInstallTarget + originalResolveInstallMode := updateResolveInstallMode + originalAcquireMaintenance := updateAcquireWindowsMaintenance + originalFindOtherInstances := updateFindOtherWindowsInstances + originalLaunchInstallScript := updateLaunchInstallScript + t.Cleanup(func() { + AppVersion = originalVersion + updateResolveInstallTarget = originalResolveInstallTarget + updateResolveInstallMode = originalResolveInstallMode + updateAcquireWindowsMaintenance = originalAcquireMaintenance + updateFindOtherWindowsInstances = originalFindOtherInstances + updateLaunchInstallScript = originalLaunchInstallScript + }) + AppVersion = "0.8.5" + updateResolveInstallTarget = func() string { + return filepath.Join(stagedDir, "GoNavi.exe") + } + updateResolveInstallMode = func() updateInstallMode { return updateInstallModePortable } + updateAcquireWindowsMaintenance = func(string) (windowsUpdateMaintenanceLease, error) { + return windowsUpdateMaintenanceLease{}, nil + } + updateFindOtherWindowsInstances = func([]string, int) ([]windowsUpdateProcess, error) { + return nil, nil + } + + installStarted := make(chan struct{}) + startMutation := make(chan struct{}) + checkDone := make(chan struct{}) + launcherErr := errors.New("stop after snapshot race probe") + updateLaunchInstallScript = func(staged *stagedUpdate) error { + close(installStarted) + <-startMutation + for { + select { + case <-checkDone: + return launcherErr + default: + staged.FilePath = assetPath + staged.InstallLogPath = filepath.Join(stagedDir, "install.log") + stdRuntime.Gosched() + } + } + } + + restoreStatic := swapUpdateFetchStaticManifest(func(updateChannel) (*githubRelease, error) { + <-installStarted + close(startMutation) + return &githubRelease{ + TagName: "v0.8.6", + Name: "v0.8.6", + HTMLURL: "https://example.com/releases/v0.8.6", + Assets: []githubAsset{{ + Name: assetName, + BrowserDownloadURL: "https://example.com/" + assetName, + Digest: "sha256:" + strings.Repeat("a", 64), + Size: 8, + }}, + }, nil + }) + defer restoreStatic() + + installResult := make(chan connection.QueryResult, 1) + go func() { + installResult <- app.InstallUpdateAndRestart(true) + }() + + checkResult := app.CheckForUpdates() + close(checkDone) + if !checkResult.Success { + t.Fatalf("CheckForUpdates returned failure: %#v", checkResult) + } + result := <-installResult + if result.Success || !strings.Contains(result.Message, launcherErr.Error()) { + t.Fatalf("expected injected installer failure, got %#v", result) + } + if published.FilePath != assetPath || published.InstallLogPath != filepath.Join(stagedDir, "install.log") { + t.Fatalf("published staged update changed during concurrent check/install: %#v", published) + } +} + +func TestResolveExecutablePathKeepsOriginalWhenEvalSymlinksFails(t *testing.T) { + original := filepath.Join(t.TempDir(), "GoNavi.exe") + cases := []struct { + name string + eval func(string) (string, error) + }{ + { + name: "evaluation fails", + eval: func(string) (string, error) { return "", errors.New("broken symlink") }, + }, + { + name: "evaluation is empty", + eval: func(string) (string, error) { return "", nil }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := resolveExecutablePath( + func() (string, error) { return original, nil }, + tc.eval, + ) + if err != nil { + t.Fatalf("resolveExecutablePath returned error: %v", err) + } + if got != original { + t.Fatalf("resolveExecutablePath = %q, want original %q", got, original) + } + }) + } +} + func TestResolveReusableStagedUpdateForPlatformSkipsLegacyWindowsExeStagedAsset(t *testing.T) { preferredWorkspaceDir := t.TempDir() legacyWorkspaceDir := t.TempDir() @@ -705,6 +998,67 @@ func TestInstallUpdateAndRestartFailsBeforeLaunchWhenWindowsTargetDirIsNotWritab } } +func TestInstallUpdateAndRestartRejectsUnresolvedWindowsTargetBeforeMaintenance(t *testing.T) { + if stdRuntime.GOOS != "windows" { + t.Skip("windows-only install target validation") + } + + stagedDir := t.TempDir() + assetPath := filepath.Join(stagedDir, "GoNavi-0.8.6-Windows-Amd64-Portable.zip") + if err := os.WriteFile(assetPath, []byte("12345678"), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + app := NewApp() + app.SetLanguage("en-US") + app.updateState.staged = &stagedUpdate{ + Channel: updateChannelLatest, + Version: "0.8.6", + AssetName: filepath.Base(assetPath), + FilePath: assetPath, + StagedDir: stagedDir, + InstallMode: updateInstallModePortable, + PackageType: updatePackageTypePortable, + AutoRelaunch: true, + } + + originalResolveInstallTarget := updateResolveInstallTarget + originalResolveInstallMode := updateResolveInstallMode + originalAcquireMaintenance := updateAcquireWindowsMaintenance + originalLaunchInstallScript := updateLaunchInstallScript + t.Cleanup(func() { + updateResolveInstallTarget = originalResolveInstallTarget + updateResolveInstallMode = originalResolveInstallMode + updateAcquireWindowsMaintenance = originalAcquireMaintenance + updateLaunchInstallScript = originalLaunchInstallScript + }) + updateResolveInstallTarget = func() string { return "" } + updateResolveInstallMode = func() updateInstallMode { return updateInstallModePortable } + maintenanceCalled := false + updateAcquireWindowsMaintenance = func(string) (windowsUpdateMaintenanceLease, error) { + maintenanceCalled = true + return windowsUpdateMaintenanceLease{}, nil + } + launched := false + updateLaunchInstallScript = func(*stagedUpdate) error { + launched = true + return nil + } + + result := app.InstallUpdateAndRestart(true) + if result.Success { + t.Fatalf("expected unresolved install target failure, got %#v", result) + } + if maintenanceCalled { + t.Fatal("maintenance must not be acquired for an unresolved install target") + } + if launched { + t.Fatal("installer must not launch for an unresolved install target") + } + if !strings.Contains(result.Message, "Unable to determine") { + t.Fatalf("expected localized unresolved target detail, got %q", result.Message) + } +} + func TestInstallUpdateAndRestartMSISkipsPortableTargetWriteProbe(t *testing.T) { if stdRuntime.GOOS != "windows" { t.Skip("windows-only MSI launch validation") diff --git a/internal/app/update_channel_state.go b/internal/app/update_channel_state.go index af8f4369..a2d0fd0f 100644 --- a/internal/app/update_channel_state.go +++ b/internal/app/update_channel_state.go @@ -148,6 +148,7 @@ func (a *App) SetUpdateChannel(channel string) connection.QueryResult { a.updateState.lastCheck = nil a.updateState.staged = nil + a.updateState.revision++ return connection.QueryResult{ Success: true, diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 2a860864..95591e33 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -3145,6 +3145,7 @@ "app.update.backend.message.channel_change_failed": "Speichern des Update-Kanals fehlgeschlagen: {{detail}}", "app.update.backend.message.channel_changed": "Update-Kanal gewechselt zu: {{channel}}", "app.update.backend.message.check_first": "Prüfen Sie zuerst auf Updates", + "app.update.backend.message.check_stale": "Der Updatestatus hat sich während der Prüfung geändert. Prüfen Sie erneut.", "app.update.backend.message.checksum_failed": "Prüfsumme des Updatepakets ist fehlgeschlagen. Versuchen Sie es erneut.", "app.update.backend.message.checksum_missing": "Prüfsumme des Updatepakets fehlt (SHA256SUMS)", "app.update.backend.message.create_workspace_failed": "Update-Arbeitsverzeichnis konnte im App-Verzeichnis nicht erstellt werden: {{path}}", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 104f8e08..23cda08d 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -3145,6 +3145,7 @@ "app.update.backend.message.channel_change_failed": "Failed to save update channel: {{detail}}", "app.update.backend.message.channel_changed": "Update channel switched to: {{channel}}", "app.update.backend.message.check_first": "Check for updates first", + "app.update.backend.message.check_stale": "Update state changed while checking. Check again.", "app.update.backend.message.checksum_failed": "Update package checksum failed. Try again.", "app.update.backend.message.checksum_missing": "Update package checksum is missing (SHA256SUMS)", "app.update.backend.message.create_workspace_failed": "Cannot create update workspace directory in the app directory: {{path}}", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 37a9a401..528da7b7 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -3145,6 +3145,7 @@ "app.update.backend.message.channel_change_failed": "更新チャネルの保存に失敗しました: {{detail}}", "app.update.backend.message.channel_changed": "更新チャネルを切り替えました: {{channel}}", "app.update.backend.message.check_first": "先に更新を確認してください", + "app.update.backend.message.check_stale": "確認中に更新状態が変更されました。もう一度確認してください。", "app.update.backend.message.checksum_failed": "更新パッケージのチェックサム検証に失敗しました。もう一度お試しください。", "app.update.backend.message.checksum_missing": "更新パッケージのチェックサムがありません (SHA256SUMS)", "app.update.backend.message.create_workspace_failed": "アプリディレクトリ内に更新作業ディレクトリを作成できません: {{path}}", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index 90bc8f97..837ddc6f 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -3145,6 +3145,7 @@ "app.update.backend.message.channel_change_failed": "Не удалось сохранить канал обновления: {{detail}}", "app.update.backend.message.channel_changed": "Канал обновления переключен на: {{channel}}", "app.update.backend.message.check_first": "Сначала проверьте обновления", + "app.update.backend.message.check_stale": "Состояние обновления изменилось во время проверки. Проверьте ещё раз.", "app.update.backend.message.checksum_failed": "Проверка контрольной суммы пакета обновления не пройдена. Повторите попытку.", "app.update.backend.message.checksum_missing": "Отсутствует контрольная сумма пакета обновления (SHA256SUMS)", "app.update.backend.message.create_workspace_failed": "Не удалось создать рабочий каталог обновления в каталоге приложения: {{path}}", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 7af65165..7b4e6f44 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -3145,6 +3145,7 @@ "app.update.backend.message.channel_change_failed": "保存更新通道失败:{{detail}}", "app.update.backend.message.channel_changed": "更新通道已切换为:{{channel}}", "app.update.backend.message.check_first": "请先检查更新", + "app.update.backend.message.check_stale": "检查期间更新状态已发生变化,请重新检查", "app.update.backend.message.checksum_failed": "更新包校验失败,请重试", "app.update.backend.message.checksum_missing": "缺少更新包校验值(SHA256SUMS)", "app.update.backend.message.create_workspace_failed": "无法在应用目录创建更新工作目录:{{path}}", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 05da5855..1dfdeda5 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -3145,6 +3145,7 @@ "app.update.backend.message.channel_change_failed": "保存更新通道失敗:{{detail}}", "app.update.backend.message.channel_changed": "更新通道已切換為:{{channel}}", "app.update.backend.message.check_first": "請先檢查更新", + "app.update.backend.message.check_stale": "檢查期間更新狀態已變更,請重新檢查", "app.update.backend.message.checksum_failed": "更新套件校驗失敗,請重試", "app.update.backend.message.checksum_missing": "缺少更新套件校驗值(SHA256SUMS)", "app.update.backend.message.create_workspace_failed": "無法在應用程式目錄建立更新工作目錄:{{path}}",