🐛 fix(update): 支持 Windows 便携版 ZIP 自动更新

- 将便携版更新资产切换为普通 ZIP,并兼容复用已下载包
- 解压后映射版本化目标文件名,保留旧版 EXE 更新兼容
- 将内嵌 PowerShell 更新脚本策略收紧为 RemoteSigned
- 补充 ZIP、目标路径与启动参数回归测试
This commit is contained in:
Syngnat
2026-07-23 13:07:03 +08:00
parent 57108d5b71
commit 3ac390f0dc
11 changed files with 112 additions and 17 deletions

View File

@@ -969,7 +969,7 @@ func expectedAssetNameForExecutableAndInstallMode(goos, goarch, version, executa
switch goos { switch goos {
case "windows": case "windows":
suffix := "-Portable.exe" suffix := "-Portable.zip"
if installMode == updateInstallModeMSI { if installMode == updateInstallModeMSI {
suffix = "-Installer.msi" suffix = "-Installer.msi"
} else if installMode != updateInstallModePortable { } else if installMode != updateInstallModePortable {
@@ -1862,7 +1862,7 @@ func buildWindowsLaunchCommand(scriptPath string, context windowsUpdateLaunchCon
"-NoProfile", "-NoProfile",
"-NonInteractive", "-NonInteractive",
"-ExecutionPolicy", "-ExecutionPolicy",
"Bypass", windowsUpdatePowerShellExecutionPolicy,
"-File", "-File",
scriptPath, scriptPath,
) )

View File

@@ -595,6 +595,40 @@ func TestResolveReusableStagedUpdateForPlatformDoesNotReuseCurrentWindowsExeInsi
} }
} }
func TestResolveReusableStagedUpdateForPlatformReusesPortableZipInsideStagedDir(t *testing.T) {
preferredWorkspaceDir := t.TempDir()
legacyWorkspaceDir := t.TempDir()
info := UpdateInfo{
Channel: string(updateChannelLatest),
LatestVersion: "0.8.5",
AssetName: "GoNavi-0.8.5-Windows-Amd64-Portable.zip",
AssetSize: 8,
InstallMode: string(updateInstallModePortable),
PackageType: string(updatePackageTypePortable),
AutoRelaunch: true,
}
stagedDir := filepath.Join(
legacyWorkspaceDir,
buildUpdateStageDirNameForPlatform("windows", info.Channel, info.LatestVersion),
)
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
assetPath := filepath.Join(stagedDir, info.AssetName)
if err := os.WriteFile(assetPath, []byte("12345678"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
reused := resolveReusableStagedUpdateForPlatform("windows", preferredWorkspaceDir, legacyWorkspaceDir, info, nil)
if reused == nil {
t.Fatal("expected staged portable ZIP to be reused")
}
if reused.FilePath != assetPath || reused.PackageType != updatePackageTypePortable {
t.Fatalf("unexpected reused portable ZIP: %#v", reused)
}
}
func TestDownloadUpdateUsesCurrentLanguageForBackendMessage(t *testing.T) { func TestDownloadUpdateUsesCurrentLanguageForBackendMessage(t *testing.T) {
app := NewApp() app := NewApp()
app.SetLanguage("en-US") app.SetLanguage("en-US")
@@ -797,13 +831,13 @@ func TestExpectedAssetNameForExecutableUsesWindowsPortableSuffix(t *testing.T) {
name: "amd64 release", name: "amd64 release",
goarch: "amd64", goarch: "amd64",
version: "v1.2.3", version: "v1.2.3",
want: "GoNavi-1.2.3-Windows-Amd64-Portable.exe", want: "GoNavi-1.2.3-Windows-Amd64-Portable.zip",
}, },
{ {
name: "arm64 dev", name: "arm64 dev",
goarch: "arm64", goarch: "arm64",
version: "dev-a1b2c3d", version: "dev-a1b2c3d",
want: "GoNavi-dev-a1b2c3d-Windows-Arm64-Portable.exe", want: "GoNavi-dev-a1b2c3d-Windows-Arm64-Portable.zip",
}, },
} }

View File

@@ -129,7 +129,7 @@ func TestBuildWindowsLaunchCommandUsesHiddenPowerShellFile(t *testing.T) {
"-NoProfile", "-NoProfile",
"-NonInteractive", "-NonInteractive",
"-ExecutionPolicy", "-ExecutionPolicy",
"Bypass", "RemoteSigned",
"-File", "-File",
scriptPath, scriptPath,
} }

View File

@@ -173,8 +173,9 @@ func resolveWindowsUpdateFinalTargetPath(currentTarget string, sourcePath string
} }
currentName := filepath.Base(currentTarget) currentName := filepath.Base(currentTarget)
sourceName := filepath.Base(strings.TrimSpace(sourcePath)) sourceName := filepath.Base(strings.TrimSpace(sourcePath))
if isVersionedWindowsUpdatePackageName(currentName) && isVersionedWindowsUpdatePackageName(sourceName) { sourceExecutableName := resolveVersionedWindowsUpdateExecutableName(sourceName)
return filepath.Join(filepath.Dir(currentTarget), sourceName) if isVersionedWindowsUpdatePackageName(currentName) && sourceExecutableName != "" {
return filepath.Join(filepath.Dir(currentTarget), sourceExecutableName)
} }
return currentTarget return currentTarget
} }
@@ -187,6 +188,22 @@ func isVersionedWindowsUpdatePackageName(name string) bool {
strings.HasSuffix(lower, ".exe") strings.HasSuffix(lower, ".exe")
} }
func resolveVersionedWindowsUpdateExecutableName(name string) string {
trimmed := strings.TrimSpace(name)
if !strings.HasPrefix(trimmed, "GoNavi-") || !strings.Contains(trimmed, "-Windows-") {
return ""
}
extension := filepath.Ext(trimmed)
switch strings.ToLower(extension) {
case ".exe":
return trimmed
case ".zip":
return strings.TrimSuffix(trimmed, extension) + ".exe"
default:
return ""
}
}
func prepareWindowsStagedUpdateAsset(sourcePath string, stagedDir string) (string, error) { func prepareWindowsStagedUpdateAsset(sourcePath string, stagedDir string) (string, error) {
sourcePath = strings.TrimSpace(sourcePath) sourcePath = strings.TrimSpace(sourcePath)
stagedDir = strings.TrimSpace(stagedDir) stagedDir = strings.TrimSpace(stagedDir)

View File

@@ -15,7 +15,7 @@ func TestShouldRemoveWindowsUpdateArtifact(t *testing.T) {
}{ }{
{name: "GoNavi-dev-abc-Windows-Amd64.exe", want: true}, {name: "GoNavi-dev-abc-Windows-Amd64.exe", want: true},
{name: "GoNavi-dev-abc-Windows-Amd64-Installer.msi", want: true}, {name: "GoNavi-dev-abc-Windows-Amd64-Installer.msi", want: true},
{name: "GoNavi-0.8.4-Windows-Amd64.zip", want: true}, {name: "GoNavi-0.8.4-Windows-Amd64-Portable.zip", want: true},
{name: "gonavi-update-windows-123.log", want: true}, {name: "gonavi-update-windows-123.log", want: true},
{name: ".gonavi-update-windows-dev-dev-abc", isDir: true, want: true}, {name: ".gonavi-update-windows-dev-dev-abc", isDir: true, want: true},
{name: "GoNavi-dev-abc-MacOS-Arm64.dmg", want: false}, {name: "GoNavi-dev-abc-MacOS-Arm64.dmg", want: false},
@@ -120,19 +120,29 @@ func TestPrepareWindowsStagedUpdateAssetMovesPackageIntoStagedDir(t *testing.T)
} }
} }
func TestResolveWindowsUpdateFinalTargetPathUsesDownloadedVersionedPortableName(t *testing.T) { func TestResolveWindowsUpdateFinalTargetPathMapsDownloadedPortableZipToExe(t *testing.T) {
currentTarget := filepath.Join("D:", "软件", "数据库管理工具", "GoNavi", "GoNavi-dev-f930ffe-Windows-Amd64.exe") currentTarget := filepath.Join("D:", "软件", "数据库管理工具", "GoNavi", "GoNavi-dev-f930ffe-Windows-Amd64.exe")
stagedSource := filepath.Join("C:", "Temp", "gonavi-updates", "GoNavi-dev-2d5f246-Windows-Amd64-Portable.exe") stagedSource := filepath.Join("C:", "Temp", "gonavi-updates", "GoNavi-dev-2d5f246-Windows-Amd64-Portable.zip")
want := filepath.Join(filepath.Dir(currentTarget), filepath.Base(stagedSource)) want := filepath.Join(filepath.Dir(currentTarget), "GoNavi-dev-2d5f246-Windows-Amd64-Portable.exe")
if got := resolveWindowsUpdateFinalTargetPath(currentTarget, stagedSource); got != want { if got := resolveWindowsUpdateFinalTargetPath(currentTarget, stagedSource); got != want {
t.Fatalf("Windows update target = %q, want downloaded versioned path %q", got, want) t.Fatalf("Windows update target = %q, want downloaded versioned path %q", got, want)
} }
} }
func TestResolveWindowsUpdateFinalTargetPathKeepsLegacyPortableExeName(t *testing.T) {
currentTarget := filepath.Join("D:", "软件", "数据库管理工具", "GoNavi", "GoNavi-dev-f930ffe-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 legacy executable path %q", got, want)
}
}
func TestResolveWindowsUpdateFinalTargetPathKeepsFixedExecutablePath(t *testing.T) { func TestResolveWindowsUpdateFinalTargetPathKeepsFixedExecutablePath(t *testing.T) {
currentTarget := filepath.Join("D:", "软件", "数据库管理工具", "GoNavi", "GoNavi.exe") currentTarget := filepath.Join("D:", "软件", "数据库管理工具", "GoNavi", "GoNavi.exe")
stagedSource := filepath.Join("C:", "Temp", "gonavi-updates", "GoNavi-dev-2d5f246-Windows-Amd64-Portable.exe") stagedSource := filepath.Join("C:", "Temp", "gonavi-updates", "GoNavi-dev-2d5f246-Windows-Amd64-Portable.zip")
if got := resolveWindowsUpdateFinalTargetPath(currentTarget, stagedSource); got != currentTarget { if got := resolveWindowsUpdateFinalTargetPath(currentTarget, stagedSource); got != currentTarget {
t.Fatalf("Windows update target = %q, want fixed executable path %q", got, currentTarget) t.Fatalf("Windows update target = %q, want fixed executable path %q", got, currentTarget)

View File

@@ -85,7 +85,7 @@ func isUpdatePackageCompatibleWithInstallMode(goos string, installMode updateIns
case updateInstallModeMSI: case updateInstallModeMSI:
return packageType == updatePackageTypeMSI && extension == ".msi" return packageType == updatePackageTypeMSI && extension == ".msi"
case updateInstallModePortable: case updateInstallModePortable:
return packageType == updatePackageTypePortable && extension == ".exe" return packageType == updatePackageTypePortable && (extension == ".zip" || extension == ".exe")
default: default:
return false return false
} }

View File

@@ -42,9 +42,9 @@ func TestExpectedAssetNameForWindowsInstallMode(t *testing.T) {
installMode updateInstallMode installMode updateInstallMode
want string want string
}{ }{
{name: "amd64 portable", arch: "amd64", installMode: updateInstallModePortable, want: "GoNavi-1.2.3-Windows-Amd64-Portable.exe"}, {name: "amd64 portable", arch: "amd64", installMode: updateInstallModePortable, want: "GoNavi-1.2.3-Windows-Amd64-Portable.zip"},
{name: "amd64 msi", arch: "amd64", installMode: updateInstallModeMSI, want: "GoNavi-1.2.3-Windows-Amd64-Installer.msi"}, {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 portable", arch: "arm64", installMode: updateInstallModePortable, want: "GoNavi-1.2.3-Windows-Arm64-Portable.zip"},
{name: "arm64 msi", arch: "arm64", installMode: updateInstallModeMSI, want: "GoNavi-1.2.3-Windows-Arm64-Installer.msi"}, {name: "arm64 msi", arch: "arm64", installMode: updateInstallModeMSI, want: "GoNavi-1.2.3-Windows-Arm64-Installer.msi"},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -90,3 +90,17 @@ func TestValidateUpdatePackageForCurrentInstallModeRejectsModeAndSuffixMismatch(
t.Fatal("expected invalid MSI suffix to be rejected") t.Fatal("expected invalid MSI suffix to be rejected")
} }
} }
func TestPortableUpdatePackageAcceptsZipAndLegacyExe(t *testing.T) {
for _, assetPath := range []string{
`C:\\tmp\\GoNavi-Portable.zip`,
`C:\\tmp\\GoNavi-Portable.exe`,
} {
if !isUpdatePackageCompatibleWithInstallMode("windows", updateInstallModePortable, updatePackageTypePortable, assetPath) {
t.Fatalf("valid portable package rejected: %s", assetPath)
}
}
if isUpdatePackageCompatibleWithInstallMode("windows", updateInstallModePortable, updatePackageTypePortable, `C:\\tmp\\GoNavi-Installer.msi`) {
t.Fatal("expected MSI suffix to be rejected for portable mode")
}
}

View File

@@ -37,7 +37,7 @@ func buildWindowsMSILaunchCommand(scriptPath string, context windowsMSIUpdateLau
"-NoProfile", "-NoProfile",
"-NonInteractive", "-NonInteractive",
"-ExecutionPolicy", "-ExecutionPolicy",
"Bypass", windowsUpdatePowerShellExecutionPolicy,
"-File", "-File",
scriptPath, scriptPath,
) )

View File

@@ -79,6 +79,23 @@ func TestBuildWindowsMSILaunchCommandPreservesPathsInEnvironment(t *testing.T) {
PID: 12345, PID: 12345,
} }
cmd := buildWindowsMSILaunchCommand(filepath.Join(context.StagedDir, "update-msi.ps1"), context) cmd := buildWindowsMSILaunchCommand(filepath.Join(context.StagedDir, "update-msi.ps1"), context)
wantArgs := []string{
"powershell.exe",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"RemoteSigned",
"-File",
filepath.Join(context.StagedDir, "update-msi.ps1"),
}
if len(cmd.Args) != len(wantArgs) {
t.Fatalf("unexpected arg length: got %d want %d, args=%v", len(cmd.Args), len(wantArgs), cmd.Args)
}
for index := range wantArgs {
if cmd.Args[index] != wantArgs[index] {
t.Fatalf("unexpected arg[%d]: got %q want %q", index, cmd.Args[index], wantArgs[index])
}
}
want := map[string]string{ want := map[string]string{
"GONAVI_UPDATE_SOURCE": context.SourcePath, "GONAVI_UPDATE_SOURCE": context.SourcePath,
"GONAVI_UPDATE_TARGET": context.TargetPath, "GONAVI_UPDATE_TARGET": context.TargetPath,

View File

@@ -161,7 +161,7 @@ if (-not (Test-SameFilePath $restoredMatchingShortcut.TargetPath $target)) {
t.Fatal(err) t.Fatal(err)
} }
command := exec.Command(powerShell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath) command := exec.Command(powerShell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "RemoteSigned", "-File", scriptPath)
command.Env = append(os.Environ(), command.Env = append(os.Environ(),
"GONAVI_TEST_TARGET="+targetPath, "GONAVI_TEST_TARGET="+targetPath,
"GONAVI_TEST_FOREIGN_TARGET="+foreignTargetPath, "GONAVI_TEST_FOREIGN_TARGET="+foreignTargetPath,

View File

@@ -8,6 +8,9 @@ import (
//go:embed windows_update.ps1 //go:embed windows_update.ps1
var windowsUpdatePowerShellScript string var windowsUpdatePowerShellScript string
// The updater writes these embedded scripts locally, so RemoteSigned runs them without disabling policy checks.
const windowsUpdatePowerShellExecutionPolicy = "RemoteSigned"
type windowsUpdateLaunchContext struct { type windowsUpdateLaunchContext struct {
SourcePath string SourcePath string
TargetPath string TargetPath string