🐛 fix(update): 同步 Windows Portable 更新版本

- 版本化 Portable 更新后切换到新包文件名并清理旧文件
- 下载完成进度采用后端实际返回的版本与资产
- 补充前端竞态和 Windows PowerShell 集成回归测试
This commit is contained in:
Syngnat
2026-07-14 10:02:36 +08:00
parent c9ddd8919e
commit 5fd12d5dd8
5 changed files with 150 additions and 5 deletions

View File

@@ -167,6 +167,63 @@ describe('useAppUpdateManager', () => {
}));
});
it('uses the backend download result version for progress and cached update metadata', async () => {
backendApp.CheckForUpdates.mockResolvedValue({
success: true,
data: {
hasUpdate: true,
channel: 'dev',
currentVersion: 'dev-current',
latestVersion: 'dev-old',
assetName: 'GoNavi-dev-old-Windows-Amd64-Portable.exe',
packageType: 'portable',
installMode: 'portable',
autoRelaunch: true,
downloaded: false,
assetSize: 4096,
},
});
backendApp.DownloadUpdate.mockResolvedValue({
success: true,
data: {
info: {
hasUpdate: true,
channel: 'dev',
currentVersion: 'dev-current',
latestVersion: 'dev-new',
assetName: 'GoNavi-dev-new-Windows-Amd64-Portable.exe',
packageType: 'portable',
installMode: 'portable',
autoRelaunch: true,
},
downloadPath: 'C:\\GoNavi\\GoNavi-dev-new-Windows-Amd64-Portable.exe',
packageType: 'portable',
installMode: 'portable',
autoRelaunch: true,
},
});
renderHook();
await act(async () => {
await hook?.checkForUpdates(false);
});
await act(async () => {
await hook?.downloadUpdate(hook?.lastUpdateInfo!, false);
});
expect(hook?.lastUpdateInfo).toMatchObject({
latestVersion: 'dev-new',
assetName: 'GoNavi-dev-new-Windows-Amd64-Portable.exe',
downloaded: true,
downloadPath: 'C:\\GoNavi\\GoNavi-dev-new-Windows-Amd64-Portable.exe',
});
expect(hook?.updateDownloadProgress).toMatchObject({
version: 'dev-new',
key: 'dev:dev-new:portable:gonavi-dev-new-windows-amd64-portable.exe',
status: 'done',
});
});
it('keeps same-version Portable and MSI downloads in separate cache identities', async () => {
const portableInfo = {
hasUpdate: true,

View File

@@ -314,6 +314,7 @@ export const useAppUpdateManager = ({
const total = prev.total > 0 ? prev.total : (info.assetSize || 0);
return {
...prev,
version: downloadedInfo.latestVersion,
key: downloadedKey,
status: 'done',
percent: 100,
@@ -328,6 +329,7 @@ export const useAppUpdateManager = ({
// 下载到 100% 后停留在就绪态,由用户确认当前安装方式对应的更新动作。
setUpdateDownloadProgress((prev) => ({
...prev,
version: downloadedInfo.latestVersion,
open: prev.open || !updateUserDismissedRef.current,
status: 'done',
percent: 100,

View File

@@ -138,8 +138,25 @@ func launchWindowsUpdateWithCleanup(staged *stagedUpdate, targetExe string, pid
return nil
}
func resolveWindowsUpdateFinalTargetPath(currentTarget string, _ string) string {
return strings.TrimSpace(currentTarget)
func resolveWindowsUpdateFinalTargetPath(currentTarget string, sourcePath string) string {
currentTarget = strings.TrimSpace(currentTarget)
if currentTarget == "" {
return currentTarget
}
currentName := filepath.Base(currentTarget)
sourceName := filepath.Base(strings.TrimSpace(sourcePath))
if isVersionedWindowsUpdatePackageName(currentName) && isVersionedWindowsUpdatePackageName(sourceName) {
return filepath.Join(filepath.Dir(currentTarget), sourceName)
}
return currentTarget
}
func isVersionedWindowsUpdatePackageName(name string) bool {
trimmed := strings.TrimSpace(name)
lower := strings.ToLower(trimmed)
return strings.HasPrefix(trimmed, "GoNavi-") &&
strings.Contains(trimmed, "-Windows-") &&
strings.HasSuffix(lower, ".exe")
}
func prepareWindowsStagedUpdateAsset(sourcePath string, stagedDir string) (string, error) {

View File

@@ -120,12 +120,22 @@ func TestPrepareWindowsStagedUpdateAssetMovesPackageIntoStagedDir(t *testing.T)
}
}
func TestResolveWindowsUpdateFinalTargetPathPreservesExecutablePath(t *testing.T) {
func TestResolveWindowsUpdateFinalTargetPathUsesDownloadedVersionedPortableName(t *testing.T) {
currentTarget := filepath.Join("D:", "软件", "数据库管理工具", "GoNavi", "GoNavi-dev-f930ffe-Windows-Amd64.exe")
stagedSource := filepath.Join("C:", "Temp", "gonavi-updates", "GoNavi-0.8.5-Windows-Amd64.exe")
stagedSource := filepath.Join("C:", "Temp", "gonavi-updates", "GoNavi-dev-2d5f246-Windows-Amd64-Portable.exe")
want := filepath.Join(filepath.Dir(currentTarget), filepath.Base(stagedSource))
if got := resolveWindowsUpdateFinalTargetPath(currentTarget, stagedSource); got != want {
t.Fatalf("Windows update target = %q, want downloaded versioned path %q", got, want)
}
}
func TestResolveWindowsUpdateFinalTargetPathKeepsFixedExecutablePath(t *testing.T) {
currentTarget := filepath.Join("D:", "软件", "数据库管理工具", "GoNavi", "GoNavi.exe")
stagedSource := filepath.Join("C:", "Temp", "gonavi-updates", "GoNavi-dev-2d5f246-Windows-Amd64-Portable.exe")
if got := resolveWindowsUpdateFinalTargetPath(currentTarget, stagedSource); got != currentTarget {
t.Fatalf("Windows update target = %q, want current executable path %q", got, currentTarget)
t.Fatalf("Windows update target = %q, want fixed executable path %q", got, currentTarget)
}
}

View File

@@ -88,6 +88,65 @@ func TestWindowsPowerShellUpdaterHandlesUnicodeAndShellMetacharacters(t *testing
t.Logf("updated target at %s (%s)", targetPath, fmt.Sprintf("%x", wantHash[:8]))
}
func TestWindowsPowerShellUpdaterRenamesVersionedPortableExecutable(t *testing.T) {
root := t.TempDir()
installDir := filepath.Join(root, "install")
stagedDir := filepath.Join(root, "stage")
for _, dir := range []string{installDir, stagedDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll %q: %v", dir, err)
}
}
sourcePath := filepath.Join(stagedDir, "GoNavi-dev-new-Windows-Amd64-Portable.exe")
build := exec.Command("go", "build", "-ldflags=-H=windowsgui", "-o", sourcePath, "./testdata/windows_update_helper")
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build update helper: %v\n%s", err, output)
}
sourceData, err := os.ReadFile(sourcePath)
if err != nil {
t.Fatalf("ReadFile source: %v", err)
}
wantHash := sha256.Sum256(sourceData)
currentTargetPath := filepath.Join(installDir, "GoNavi-dev-old-Windows-Amd64.exe")
targetPath := filepath.Join(installDir, "GoNavi-dev-new-Windows-Amd64-Portable.exe")
if err := os.WriteFile(currentTargetPath, []byte("old executable"), 0o755); err != nil {
t.Fatalf("WriteFile old target: %v", err)
}
logPath := filepath.Join(stagedDir, "gonavi-update-windows-versioned.log")
scriptPath := filepath.Join(stagedDir, "update.ps1")
if err := os.WriteFile(scriptPath, []byte(buildWindowsPowerShellScript()), 0o644); err != nil {
t.Fatalf("WriteFile updater: %v", err)
}
cmd := buildWindowsLaunchCommand(scriptPath, windowsUpdateLaunchContext{
SourcePath: sourcePath,
TargetPath: targetPath,
CurrentTargetPath: currentTargetPath,
StagedDir: stagedDir,
LogPath: logPath,
PID: 2147483647,
})
if output, err := cmd.CombinedOutput(); err != nil {
logData, _ := os.ReadFile(logPath)
t.Fatalf("run versioned updater: %v\nstdout/stderr:\n%s\nlog:\n%s", err, output, logData)
}
// The helper remains alive through the updater's launch health check.
time.Sleep(7 * time.Second)
targetData, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile renamed target: %v", err)
}
if gotHash := sha256.Sum256(targetData); gotHash != wantHash {
t.Fatalf("renamed target hash = %x, want %x", gotHash, wantHash)
}
if _, err := os.Stat(currentTargetPath); !os.IsNotExist(err) {
t.Fatalf("expected old versioned executable to be removed, stat err=%v", err)
}
}
func TestWindowsPowerShellUpdaterSelectsExactTargetFilenameRecursivelyFromZip(t *testing.T) {
root := t.TempDir()
installDir := filepath.Join(root, "install")