🐛 fix(update): 修复 Windows 在线更新中文路径失效

- 将 Windows 更新脚本统一为 PowerShell,并通过 Unicode 环境变量传递中文与特殊字符路径
- 保留当前可执行文件路径,补充替换重试、启动健康检查、失败回滚和严格 ZIP 选择
- 更新前复用未保存 SQL 确认流程,并延长退出兜底时间
- 新增 Windows 原生集成测试并接入 dev/release 构建
This commit is contained in:
Syngnat
2026-07-10 13:31:34 +08:00
parent 06bfbc4a84
commit e1fb5b12d4
17 changed files with 1017 additions and 529 deletions

View File

@@ -516,6 +516,11 @@ jobs:
& "$env:CC" --version
& "$env:CXX" --version
- name: Test Windows Online Updater (Windows AMD64)
if: ${{ matrix.platform == 'windows/amd64' }}
shell: pwsh
run: go test ./internal/app -run '^TestWindowsPowerShellUpdater' -count=1 -timeout=3m
# ---- 生成 dev 版本号 ----
- name: Generate Dev Version
id: version

View File

@@ -472,6 +472,11 @@ jobs:
& "$env:CC" --version
& "$env:CXX" --version
- name: Test Windows Online Updater (Windows AMD64)
if: ${{ matrix.platform == 'windows/amd64' }}
shell: pwsh
run: go test ./internal/app -run '^TestWindowsPowerShellUpdater' -count=1 -timeout=3m
- name: Build
shell: bash
run: |

View File

@@ -167,6 +167,7 @@ const MIN_UI_SCALE = 0.8;
const MAX_UI_SCALE = 1.25;
const MIN_FONT_SIZE = 12;
const MAX_FONT_SIZE = 20;
type ApplicationQuitConfirmedAction = () => Promise<boolean>;
/** 设置页 Slider 底部预设刻度 */
const UI_SCALE_SLIDER_MARKS: Record<number, string> = {
0.8: '80%',
@@ -2320,12 +2321,34 @@ function App() {
}
}, [t]);
const handleApplicationQuitRequest = useCallback(async () => {
const handleApplicationQuitRequest = useCallback(async (confirmedAction?: ApplicationQuitConfirmedAction) => {
if (applicationQuitHandlingRef.current) {
return;
}
applicationQuitHandlingRef.current = true;
const runConfirmedAction = async (): Promise<boolean> => {
let accepted = false;
try {
if (confirmedAction) {
accepted = await confirmedAction();
} else {
await forceQuitApplication();
accepted = true;
}
} catch (error) {
resetApplicationQuitRequest();
message.error(t('app.quit.message.quit_failed', {
detail: error instanceof Error ? error.message : String(error),
}));
return false;
}
if (!accepted) {
resetApplicationQuitRequest();
}
return accepted;
};
let targets;
try {
targets = await collectApplicationQuitUnsavedSQLTargets(tabs, savedQueries);
@@ -2338,14 +2361,7 @@ function App() {
}
if (targets.length === 0) {
try {
await forceQuitApplication();
} catch (error) {
resetApplicationQuitRequest();
message.error(t('app.quit.message.quit_failed', {
detail: error instanceof Error ? error.message : String(error),
}));
}
await runConfirmedAction();
return;
}
@@ -2367,12 +2383,7 @@ function App() {
onClick={() => {
destroyConfirm?.();
applicationQuitConfirmRef.current = null;
void forceQuitApplication().catch((error) => {
resetApplicationQuitRequest();
message.error(t('app.quit.message.quit_failed', {
detail: error instanceof Error ? error.message : String(error),
}));
});
void runConfirmedAction();
}}
>
{t('app.quit.unsaved_sql.confirm_exit')}
@@ -2388,7 +2399,6 @@ function App() {
try {
await saveApplicationQuitUnsavedSQLTargets(targets, saveQuery);
message.success(t('app.quit.unsaved_sql.saved'));
await forceQuitApplication();
} catch (error) {
resetApplicationQuitRequest();
message.error(t('app.quit.unsaved_sql.save_failed_cancel_exit', {
@@ -2396,12 +2406,17 @@ function App() {
}));
throw error;
}
await runConfirmedAction();
},
});
destroyConfirm = confirmRef.destroy;
applicationQuitConfirmRef.current = confirmRef;
}, [forceQuitApplication, resetApplicationQuitRequest, saveQuery, savedQueries, t, tabs]);
const handleInstallUpdateRequest = useCallback(async () => {
await handleApplicationQuitRequest(handleInstallFromProgress);
}, [handleApplicationQuitRequest, handleInstallFromProgress]);
useEffect(() => {
const offBeforeClose = EventsOn('app:before-close-request', () => {
void handleApplicationQuitRequest();
@@ -4297,7 +4312,12 @@ function App() {
</Button>
) : null,
isLatestUpdateDownloaded ? (
<Button key="restart-to-update" type="primary" icon={<DownloadOutlined />} onClick={handleInstallFromProgress}>
<Button
key="restart-to-update"
type="primary"
icon={<DownloadOutlined />}
onClick={() => { void handleInstallUpdateRequest(); }}
>
{t('app.about.action.restart_to_update')}
</Button>
) : null,
@@ -8077,7 +8097,7 @@ function App() {
<Button key="open-install-directory" onClick={openDownloadedUpdateDirectory}>
{t('app.about.action.open_install_directory')}
</Button>,
<Button key="restart" type="primary" onClick={handleInstallFromProgress}>
<Button key="restart" type="primary" onClick={() => { void handleInstallUpdateRequest(); }}>
{t('app.about.action.restart_to_update')}
</Button>
] : (updateDownloadProgress.status === 'error' ? [

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const appSource = readFileSync(
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
'utf8',
);
describe('restart-to-update unsaved SQL guard', () => {
it('runs the confirmed update action through every application quit path', () => {
expect(appSource).toContain('type ApplicationQuitConfirmedAction = () => Promise<boolean>;');
expect(appSource).toContain('const handleApplicationQuitRequest = useCallback(async (confirmedAction?: ApplicationQuitConfirmedAction) => {');
expect(appSource).toContain('const runConfirmedAction = async (): Promise<boolean> => {');
const runnerStart = appSource.indexOf('const runConfirmedAction = async (): Promise<boolean> => {');
const runnerEnd = appSource.indexOf('\n let targets;', runnerStart);
const runnerSource = appSource.slice(runnerStart, runnerEnd);
expect(runnerSource).toContain('accepted = await confirmedAction();');
expect(runnerSource).toContain('await forceQuitApplication();\n accepted = true;');
expect(runnerSource).toContain('} catch (error) {\n resetApplicationQuitRequest();');
expect(runnerSource).toContain('if (!accepted) {\n resetApplicationQuitRequest();');
expect(runnerSource).toContain('return accepted;');
expect(appSource).toContain('if (targets.length === 0) {\n await runConfirmedAction();');
expect(appSource).toContain('void runConfirmedAction();');
const saveIndex = appSource.indexOf('await saveApplicationQuitUnsavedSQLTargets(targets, saveQuery);');
const actionAfterSaveIndex = appSource.indexOf('await runConfirmedAction();', saveIndex);
expect(saveIndex).toBeGreaterThan(-1);
expect(actionAfterSaveIndex).toBeGreaterThan(saveIndex);
});
it('uses a no-argument wrapper for both restart-to-update buttons', () => {
expect(appSource).toContain('const handleInstallUpdateRequest = useCallback(async () => {');
expect(appSource).toContain('await handleApplicationQuitRequest(handleInstallFromProgress);');
expect(appSource.match(/void handleInstallUpdateRequest\(\);/g)).toHaveLength(2);
expect(appSource).not.toContain('onClick={handleInstallFromProgress}');
expect(appSource).not.toContain('handleApplicationQuitRequest(handleInstallFromProgress(');
});
});

View File

@@ -221,11 +221,60 @@ describe('useAppUpdateManager', () => {
await hook?.checkForUpdates(false);
});
let accepted = false;
await act(async () => {
await hook?.handleInstallFromProgress();
accepted = await hook!.handleInstallFromProgress();
});
expect(backendApp.InstallUpdateAndRestart).toHaveBeenCalledTimes(1);
expect(accepted).toBe(true);
});
it('returns false when the backend rejects restart-to-update', async () => {
backendApp.CheckForUpdates.mockResolvedValue({
success: true,
data: {
hasUpdate: true,
currentVersion: '0.8.1',
latestVersion: '0.8.2',
downloaded: true,
assetSize: 2048,
},
});
backendApp.InstallUpdateAndRestart.mockResolvedValue({
success: false,
message: 'unable-to-start-updater',
});
renderHook();
await act(async () => {
await hook?.checkForUpdates(false);
});
let accepted = true;
await act(async () => {
accepted = await hook!.handleInstallFromProgress();
});
expect(accepted).toBe(false);
expect(backendApp.InstallUpdateAndRestart).toHaveBeenCalledTimes(1);
expect(hook?.updateDownloadProgress.status).toBe('error');
expect(messageApi.error).toHaveBeenCalledWith(
'app.about.message.install_failed_with_error:unable-to-start-updater',
);
});
it('returns false without calling the backend when no update is ready', async () => {
renderHook();
let accepted = true;
await act(async () => {
accepted = await hook!.handleInstallFromProgress();
});
expect(accepted).toBe(false);
expect(backendApp.InstallUpdateAndRestart).not.toHaveBeenCalled();
});
it('switches update channel and re-checks against the selected channel', async () => {

View File

@@ -319,11 +319,11 @@ export const useAppUpdateManager = ({
const canShowProgressEntry = (isLatestUpdateDownloaded || isBackgroundProgressForLatestUpdate)
&& updateInstallTriggeredVersionRef.current !== (lastUpdateKey || null);
const handleInstallFromProgress = useCallback(async () => {
const handleInstallFromProgress = useCallback(async (): Promise<boolean> => {
const canInstall = updateDownloadProgress.status === 'done'
|| (Boolean(lastUpdateInfo?.hasUpdate) && (Boolean(lastUpdateInfo?.downloaded) || updateDownloadedVersionRef.current === lastUpdateKey));
if (!canInstall) {
return;
return false;
}
// 点击后进入「正在应用并重启」态,再拉起安装脚本并退出
setUpdateDownloadProgress((prev) => ({
@@ -347,7 +347,7 @@ export const useAppUpdateManager = ({
message: res?.message || t('common.unknown'),
}));
void message.error(t('app.about.message.install_failed_with_error', { error: res?.message || t('common.unknown') }));
return;
return false;
}
updateInstallTriggeredVersionRef.current = lastUpdateKey || null;
// 后端会 Quit此处保持弹窗文案避免用户误以为失败
@@ -358,6 +358,7 @@ export const useAppUpdateManager = ({
percent: 100,
message: t('app.about.download_progress.restarting'),
}));
return true;
}, [lastUpdateInfo, lastUpdateKey, t, updateDownloadProgress.status]);
const openDownloadedUpdateDirectory = useCallback(async () => {

View File

@@ -3,6 +3,7 @@ package app
import (
"context"
"testing"
"time"
)
func TestApplicationBeforeCloseEmitsPromptOnceUntilCancelled(t *testing.T) {
@@ -73,3 +74,88 @@ func TestForceQuitApplicationAllowsNextCloseRequest(t *testing.T) {
t.Fatal("expected next close request to be allowed after force quit")
}
}
func TestInstallUpdateAndRestartAllowsGuardedCloseBeforeFallbackExit(t *testing.T) {
originalEmit := emitApplicationBeforeCloseRequest
originalQuit := quitApplicationRuntime
originalResolveInstallTarget := updateResolveInstallTarget
originalLaunchInstallScript := updateLaunchInstallScript
originalSleep := updateQuitSleep
originalExit := updateExitProcess
t.Cleanup(func() {
emitApplicationBeforeCloseRequest = originalEmit
quitApplicationRuntime = originalQuit
updateResolveInstallTarget = originalResolveInstallTarget
updateLaunchInstallScript = originalLaunchInstallScript
updateQuitSleep = originalSleep
updateExitProcess = originalExit
})
app := NewAppWithSecretStore(nil)
app.ctx = context.Background()
stagedDir := t.TempDir()
app.updateState.staged = &stagedUpdate{
FilePath: stagedDir + "/GoNavi-update.exe",
StagedDir: stagedDir,
}
events := make(chan string, 3)
promptEvents := make(chan struct{}, 1)
guardResults := make(chan bool, 1)
sleepDurations := make(chan time.Duration, 2)
exitCodes := make(chan int, 1)
emitApplicationBeforeCloseRequest = func(context.Context, string, ...interface{}) {
promptEvents <- struct{}{}
}
quitApplicationRuntime = func(ctx context.Context) {
events <- "quit"
guardResults <- app.beforeClose(ctx)
}
updateResolveInstallTarget = func() string {
return stagedDir + "/GoNavi.exe"
}
updateLaunchInstallScript = func(*stagedUpdate) error {
events <- "installer"
return nil
}
updateQuitSleep = func(duration time.Duration) {
sleepDurations <- duration
}
updateExitProcess = func(code int) {
events <- "exit"
exitCodes <- code
}
result := app.InstallUpdateAndRestart()
if !result.Success {
t.Fatalf("expected update installation to start, got %#v", result)
}
for index, want := range []string{"installer", "quit", "exit"} {
select {
case got := <-events:
if got != want {
t.Fatalf("event %d = %q, want %q", index, got, want)
}
case <-time.After(time.Second):
t.Fatalf("timed out waiting for %q event", want)
}
}
if prevented := <-guardResults; prevented {
t.Fatal("expected updater-controlled close to bypass the normal close prompt")
}
select {
case <-promptEvents:
t.Fatal("expected no before-close prompt during update restart")
default:
}
if exitCode := <-exitCodes; exitCode != 0 {
t.Fatalf("expected fallback exit code 0, got %d", exitCode)
}
for index, want := range []time.Duration{300 * time.Millisecond, 35 * time.Second} {
if got := <-sleepDurations; got != want {
t.Fatalf("quit sleep %d = %s, want %s", index, got, want)
}
}
}

View File

@@ -23,8 +23,6 @@ import (
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
"GoNavi-Wails/internal/uievents"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
const (
@@ -34,6 +32,8 @@ const (
updateChecksumAsset = "SHA256SUMS"
updateDownloadProgressEvent = "update:download-progress"
updateNetworkRetryDelay = 250 * time.Millisecond
updateQuitRequestDelay = 300 * time.Millisecond
updateQuitForceExitDelay = 35 * time.Second
updateReleaseCacheTTL = 10 * time.Minute
updateGitHubAPIVersion = "2022-11-28"
updateHTTPBodySnippetLimit = 240
@@ -53,6 +53,8 @@ var (
updateLogCheckError = func(err error) { logger.Error(err, "检查更新失败") }
updateResolveInstallTarget = resolveUpdateInstallTarget
updateLaunchInstallScript = launchUpdateScript
updateQuitSleep = time.Sleep
updateExitProcess = os.Exit
)
type updateState struct {
@@ -301,13 +303,7 @@ func (a *App) InstallUpdateAndRestart() connection.QueryResult {
}
}
go func() {
time.Sleep(300 * time.Millisecond)
wailsRuntime.Quit(a.ctx)
// 兜底退出,避免某些平台/窗口状态下 Quit 未真正结束进程,导致更新脚本一直等待。
time.Sleep(2 * time.Second)
os.Exit(0)
}()
go a.quitForUpdate()
msg := a.appText("app.update.backend.message.install_started", nil)
if staged.InstallLogPath != "" {
@@ -322,6 +318,14 @@ func (a *App) InstallUpdateAndRestart() connection.QueryResult {
}
}
func (a *App) quitForUpdate() {
updateQuitSleep(updateQuitRequestDelay)
a.ForceQuitApplication()
// Leave enough time for shutdown transaction rollback before forcing the process down.
updateQuitSleep(updateQuitForceExitDelay)
updateExitProcess(0)
}
func (a *App) OpenDownloadedUpdateDirectory() connection.QueryResult {
a.updateMu.Lock()
staged := a.updateState.staged
@@ -1577,31 +1581,7 @@ func launchUpdateScript(staged *stagedUpdate) error {
}
func launchWindowsUpdate(staged *stagedUpdate, targetExe string, pid int) error {
if err := os.MkdirAll(staged.StagedDir, 0o755); err != nil {
return err
}
scriptPath := filepath.Join(staged.StagedDir, "update.cmd")
logPath := strings.TrimSpace(staged.InstallLogPath)
if logPath == "" {
logPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath))
staged.InstallLogPath = logPath
}
content := buildWindowsScript(staged.FilePath, targetExe, staged.StagedDir, logPath, pid)
if err := os.WriteFile(scriptPath, []byte(content), 0o644); err != nil {
return err
}
logger.Infof("启动 Windows 更新脚本target=%s script=%s log=%s", targetExe, scriptPath, logPath)
cmd := buildWindowsLaunchCommand(scriptPath)
if err := cmd.Start(); err != nil {
return err
}
if cmd.Process != nil {
if err := cmd.Process.Release(); err != nil {
logger.Warnf("释放 Windows 更新脚本进程句柄失败:%v", err)
}
}
return nil
return launchWindowsUpdateWithCleanup(staged, targetExe, pid)
}
func launchMacUpdate(staged *stagedUpdate, targetExe string, pid int) error {
@@ -1655,154 +1635,25 @@ func launchLinuxUpdate(staged *stagedUpdate, targetExe string, pid int) error {
return nil
}
func buildWindowsScript(source, target, stagedDir, logPath string, pid int) string {
script := `@echo off
setlocal EnableExtensions EnableDelayedExpansion
set "SOURCE=__GONAVI_UPDATE_SOURCE__"
set "TARGET=__GONAVI_UPDATE_TARGET__"
set "TARGET_OLD=%TARGET%.old"
set "STAGED=__GONAVI_UPDATE_STAGED__"
set "LOG_FILE=__GONAVI_UPDATE_LOG__"
set PID=__GONAVI_UPDATE_PID__
set /a WAIT_PID_SECONDS=0
call :log updater started
if not exist "%SOURCE%" (
call :log source file not found: %SOURCE%
exit /b 1
)
for %%I in ("%TARGET%") do set "TARGET_NAME=%%~nxI"
for %%I in ("%TARGET%") do set "TARGET_DIR=%%~dpI"
for %%I in ("%SOURCE%") do set "SOURCE_EXT=%%~xI"
set "SOURCE_EXE="
if /I "%SOURCE_EXT%"==".zip" (
set "EXTRACT_DIR=%STAGED%\_extract"
if exist "%EXTRACT_DIR%" (
rmdir /S /Q "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
)
mkdir "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
powershell -NoProfile -ExecutionPolicy Bypass -Command "$src=$env:SOURCE; $dst=$env:EXTRACT_DIR; Expand-Archive -LiteralPath $src -DestinationPath $dst -Force" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL! NEQ 0 (
call :log expand zip failed: %SOURCE%
exit /b 1
)
if exist "%EXTRACT_DIR%\%TARGET_NAME%" (
set "SOURCE_EXE=%EXTRACT_DIR%\%TARGET_NAME%"
) else (
for /R "%EXTRACT_DIR%" %%F in (*.exe) do (
if not defined SOURCE_EXE (
set "SOURCE_EXE=%%~fF"
)
)
)
if not defined SOURCE_EXE (
call :log no executable found in portable zip: %SOURCE%
exit /b 1
)
) else (
set "SOURCE_EXE=%SOURCE%"
)
for %%I in ("%SOURCE_EXE%") do set "SOURCE_DIR=%%~dpI"
:waitloop
tasklist /FI "PID eq %PID%" | find "%PID%" >nul
if %ERRORLEVEL%==0 (
if !WAIT_PID_SECONDS! GEQ 90 (
call :log host process still running after !WAIT_PID_SECONDS! seconds, aborting update
exit /b 1
)
timeout /t 1 /nobreak >nul
set /a WAIT_PID_SECONDS+=1
goto waitloop
)
call :log host process exited
rem -- Win10 needs extra time for kernel to release exe file handles --
timeout /t 3 /nobreak >nul
call :log cooldown finished, starting file replace
:replace_binary
if /I "%SOURCE_EXE%"=="%TARGET%" (
call :log downloaded executable already at target path, skip replace
goto move_done
)
set /a RETRY=0
:move_retry
call :log attempt !RETRY!: trying rename-then-copy strategy
move /Y "%TARGET%" "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL!==0 (
copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL!==0 (
del /F /Q "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
goto move_done
)
call :log copy after rename failed, restoring old file
move /Y "%TARGET_OLD%" "%TARGET%" >> "%LOG_FILE%" 2>&1
)
call :log rename strategy failed, trying direct move
move /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL%==0 goto move_done
copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL%==0 goto move_done
set /a RETRY+=1
if !RETRY! LSS 15 (
set /a WAIT=1
if !RETRY! GEQ 3 set /a WAIT=2
if !RETRY! GEQ 6 set /a WAIT=3
if !RETRY! GEQ 9 set /a WAIT=5
call :log waiting !WAIT! seconds before retry
timeout /t !WAIT! /nobreak >nul
goto move_retry
)
call :log replace failed after retries (portable mode, no elevation): check directory write permission or file lock
exit /b 1
:move_done
del /F /Q "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
rem 先重启,成功后再删安装包,避免失败时 latest 包被删、用户只剩旧 dev 可运行
call :log launching target: %TARGET%
start "" /D "%TARGET_DIR%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL% NEQ 0 (
call :log cmd start failed, trying powershell Start-Process
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%TARGET%' -WorkingDirectory '%TARGET_DIR%'" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL! NEQ 0 (
call :log relaunch failed - package kept for manual install: %SOURCE%
exit /b 1
)
)
rem relaunch 已发出后再清理下载包与 staging
if exist "%SOURCE%" (
if /I not "%SOURCE%"=="%TARGET%" (
if /I not "%SOURCE_EXE%"=="%TARGET%" (
del /F /Q "%SOURCE%" >> "%LOG_FILE%" 2>&1
)
)
)
rmdir /S /Q "%STAGED%" >> "%LOG_FILE%" 2>&1
call :log update finished
exit /b 0
:log
echo [%date% %time%] %*>>"%LOG_FILE%"
exit /b 0
`
return strings.NewReplacer(
"__GONAVI_UPDATE_SOURCE__", source,
"__GONAVI_UPDATE_TARGET__", target,
"__GONAVI_UPDATE_STAGED__", stagedDir,
"__GONAVI_UPDATE_LOG__", logPath,
"__GONAVI_UPDATE_PID__", strconv.Itoa(pid),
).Replace(strings.ReplaceAll(script, "\n", "\r\n"))
}
func buildWindowsLaunchCommand(scriptPath string) *exec.Cmd {
cmd := exec.Command("cmd.exe", "/D", "/C", "call", scriptPath)
func buildWindowsLaunchCommand(scriptPath string, context windowsUpdateLaunchContext) *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_CURRENT_TARGET="+context.CurrentTargetPath,
"GONAVI_UPDATE_STAGED_DIR="+context.StagedDir,
"GONAVI_UPDATE_LOG_PATH="+context.LogPath,
"GONAVI_UPDATE_PID="+strconv.Itoa(context.PID),
)
configureWindowsUpdateCommand(cmd)
return cmd
}

View File

@@ -728,22 +728,15 @@ func TestShouldWindowsUpdateLaunchDownloadedAssetDirectly(t *testing.T) {
}
}
func TestBuildWindowsScriptReplacesTargetWithDownloadedExe(t *testing.T) {
script := buildWindowsScript(
`C:\GoNavi\GoNavi-dev-93dc696-Windows-Amd64.exe`,
`C:\GoNavi\GoNavi-dev-00d70d2-Windows-Amd64.exe`,
`C:\Users\tester\AppData\Local\Temp\gonavi-updates\.gonavi-update-windows-dev-dev-93dc696`,
`C:\Users\tester\AppData\Local\Temp\gonavi-updates\gonavi-update-windows.log`,
12345,
)
func TestBuildWindowsPowerShellScriptReplacesTargetWithDownloadedExe(t *testing.T) {
script := buildWindowsPowerShellScript()
mustContain := []string{
`:replace_binary`,
`move /Y "%TARGET%" "%TARGET_OLD%"`,
`copy /Y "%SOURCE_EXE%" "%TARGET%"`,
`start "" /D "%TARGET_DIR%" "%TARGET%"`,
`Move-Item -LiteralPath $Target -Destination $TargetOld -Force`,
`Copy-Item -LiteralPath $SourceExe -Destination $Target -Force`,
`Start-Process -FilePath $Target -WorkingDirectory $TargetDir`,
`package kept for manual install`,
`del /F /Q "%SOURCE%"`,
`Remove-UpdateArtifact $Source`,
}
for _, want := range mustContain {
if !strings.Contains(script, want) {
@@ -751,14 +744,11 @@ func TestBuildWindowsScriptReplacesTargetWithDownloadedExe(t *testing.T) {
}
}
// relaunch 必须在删除安装包之前
startIdx := strings.Index(script, `start "" /D "%TARGET_DIR%" "%TARGET%"`)
delIdx := strings.LastIndex(script, `del /F /Q "%SOURCE%"`)
startIdx := strings.Index(script, `Start-Process -FilePath $Target -WorkingDirectory $TargetDir`)
delIdx := strings.LastIndex(script, `Remove-UpdateArtifact $Source`)
if startIdx < 0 || delIdx < 0 || delIdx < startIdx {
t.Fatalf("source package must be deleted only after relaunch attempt (start=%d del=%d)", startIdx, delIdx)
}
if strings.Contains(script, "launch_downloaded_exe") {
t.Fatalf("windows update script should not launch downloaded exe side-by-side\nscript:\n%s", script)
}
}
func TestExpectedAssetNameForExecutableUsesLinuxWebKit41Suffix(t *testing.T) {

View File

@@ -5,7 +5,10 @@ package app
import "testing"
func TestBuildWindowsLaunchCommandHidesConsoleWindow(t *testing.T) {
cmd := buildWindowsLaunchCommand(`C:\tmp\gonavi-update\update.cmd`)
cmd := buildWindowsLaunchCommand(
`C:\tmp\gonavi-update\update.ps1`,
windowsUpdateLaunchContext{StagedDir: `C:\tmp\gonavi-update`},
)
if cmd.SysProcAttr == nil {
t.Fatalf("expected Windows update launcher to configure SysProcAttr")

View File

@@ -1,144 +1,132 @@
package app
import (
"os/exec"
"strings"
"testing"
)
func TestBuildWindowsScriptKeepsBatchForSyntax(t *testing.T) {
script := buildWindowsScript(
`C:\tmp\GoNavi-v0.4.0-windows-amd64.zip`,
`C:\Program Files\GoNavi\GoNavi.exe`,
`C:\Program Files\GoNavi\.gonavi-update-windows-v0.4.0`,
`C:\Program Files\GoNavi\logs\update-install.log`,
13579,
)
func TestBuildWindowsPowerShellScriptUsesLiteralPathOperations(t *testing.T) {
script := buildWindowsPowerShellScript()
mustContain := []string{
`for %%I in ("%TARGET%") do set "TARGET_NAME=%%~nxI"`,
`for %%I in ("%SOURCE%") do set "SOURCE_EXT=%%~xI"`,
`for /R "%EXTRACT_DIR%" %%F in (*.exe) do (`,
`set "SOURCE_EXE=%%~fF"`,
`$Source = $env:GONAVI_UPDATE_SOURCE`,
`$Target = $env:GONAVI_UPDATE_TARGET`,
`Test-Path -LiteralPath $Source -PathType Leaf`,
`Expand-Archive -LiteralPath $Source -DestinationPath $ExtractDir -Force`,
`Move-Item -LiteralPath $Target -Destination $TargetOld -Force`,
`Copy-Item -LiteralPath $SourceExe -Destination $Target -Force`,
`Start-Process -FilePath $Target -WorkingDirectory $TargetDir`,
}
for _, want := range mustContain {
if !strings.Contains(script, want) {
t.Fatalf("windows update script missing required token: %s\nscript:\n%s", want, script)
}
}
mustNotContain := []string{
`for %I in ("%TARGET%") do set "TARGET_NAME=%~nxI"`,
`for %I in ("%SOURCE%") do set "SOURCE_EXT=%~xI"`,
`for /R "%EXTRACT_DIR%" %F in (*.exe) do (`,
`set "SOURCE_EXE=%~fF"`,
}
for _, bad := range mustNotContain {
if strings.Contains(script, bad) {
t.Fatalf("windows update script contains invalid batch syntax: %s\nscript:\n%s", bad, script)
t.Fatalf("Windows PowerShell updater missing required token: %s\n%s", want, script)
}
}
}
func TestBuildWindowsScriptWin10Fixes(t *testing.T) {
script := buildWindowsScript(
`C:\tmp\GoNavi-v0.5.0-windows-amd64.exe`,
`C:\Program Files\GoNavi\GoNavi.exe`,
`C:\Program Files\GoNavi\.gonavi-update-windows-v0.5.0`,
`C:\Program Files\GoNavi\logs\update-install.log`,
99999,
)
func TestBuildWindowsPowerShellScriptWaitsRetriesAndRollsBack(t *testing.T) {
script := buildWindowsPowerShellScript()
// 验证 Win10 关键修复点
win10Fixes := []struct {
desc string
token string
}{
{"cooldown after process exit", `timeout /t 3 /nobreak >nul`},
{"cooldown log", `call :log cooldown finished, starting file replace`},
{"rename-before-replace strategy", `move /Y "%TARGET%" "%TARGET_OLD%"`},
{"copy after rename", `copy /Y "%SOURCE_EXE%" "%TARGET%"`},
{"restore on copy failure", `move /Y "%TARGET_OLD%" "%TARGET%"`},
{"direct move fallback", `call :log rename strategy failed, trying direct move`},
{"exponential backoff tier 1", `if !RETRY! GEQ 3 set /a WAIT=2`},
{"exponential backoff tier 2", `if !RETRY! GEQ 6 set /a WAIT=3`},
{"exponential backoff tier 3", `if !RETRY! GEQ 9 set /a WAIT=5`},
{"retry limit 15", `if !RETRY! LSS 15`},
{"host exit wait timeout", `if !WAIT_PID_SECONDS! GEQ 90 (`},
{"cleanup old file", `del /F /Q "%TARGET_OLD%"`},
mustContain := []string{
`while (Get-Process -Id $HostProcessId -ErrorAction SilentlyContinue)`,
`$waitedSeconds -ge 90`,
`Start-Sleep -Seconds 3`,
`for ($attempt = 0; $attempt -lt 15; $attempt++)`,
`if ($PreviousTargetBackedUp -or $TargetWriteStarted)`,
`previous executable backup is missing`,
`Restore-PreviousTarget`,
`if ($NewProcess.HasExited)`,
`package kept for manual install`,
`previous application relaunched after update failure`,
}
for _, fix := range win10Fixes {
if !strings.Contains(script, fix.token) {
t.Errorf("Win10 fix missing [%s]: expected token: %s", fix.desc, fix.token)
for _, want := range mustContain {
if !strings.Contains(script, want) {
t.Fatalf("Windows PowerShell updater missing reliability token: %s\n%s", want, script)
}
}
}
func TestBuildWindowsScriptUsesCRLFLineEndings(t *testing.T) {
script := buildWindowsScript(
`C:\tmp\GoNavi-v0.5.0-windows-amd64.exe`,
`C:\Program Files\GoNavi\GoNavi.exe`,
`C:\Program Files\GoNavi\.gonavi-update-windows-v0.5.0`,
`C:\Program Files\GoNavi\logs\update-install.log`,
99999,
)
func TestBuildWindowsPowerShellScriptSelectsPortableExecutableByStrictPriority(t *testing.T) {
script := buildWindowsPowerShellScript()
priorityTokens := []string{
`$TargetFileName = [IO.Path]::GetFileName($TargetPath)`,
`$ExactTargetMatches = @($ExecutableCandidates | Where-Object`,
`$PackageExecutableName = [IO.Path]::GetFileNameWithoutExtension($PackagePath) + '.exe'`,
`$PackageNameMatches = @($ExecutableCandidates | Where-Object`,
`$GoNaviMatches = @($ExecutableCandidates | Where-Object`,
`if ($ExecutableCandidates.Count -eq 1)`,
`throw ("ambiguous portable zip: found "`,
}
lastIndex := -1
for _, token := range priorityTokens {
index := strings.Index(script, token)
if index < 0 {
t.Fatalf("Windows PowerShell updater missing strict ZIP selection token %q\n%s", token, script)
}
if index <= lastIndex {
t.Fatalf("Windows PowerShell ZIP selection token %q is out of priority order\n%s", token, script)
}
lastIndex = index
}
if strings.Contains(script, `Select-Object -First 1`) {
t.Fatalf("Windows PowerShell updater must not choose an arbitrary executable from a ZIP\n%s", script)
}
if !strings.Contains(script, `package retained for manual install`) {
t.Fatalf("ambiguous ZIP failure must explain that the package is retained\n%s", script)
}
}
func TestBuildWindowsPowerShellScriptUsesCRLFLineEndings(t *testing.T) {
script := buildWindowsPowerShellScript()
if !strings.Contains(script, "\r\n") {
t.Fatalf("windows update script should use CRLF line endings")
t.Fatal("Windows PowerShell updater should use CRLF line endings")
}
if strings.Contains(script, "@echo off\nsetlocal") {
t.Fatalf("windows update script should not contain LF-only line endings")
if strings.Contains(script, "$ErrorActionPreference = 'Stop'\n\n") {
t.Fatal("Windows PowerShell updater should not contain LF-only line endings")
}
}
func TestBuildWindowsScriptUsesDelayedErrorlevelInsideBlocks(t *testing.T) {
script := buildWindowsScript(
`C:\tmp\GoNavi-v0.5.0-windows-amd64.zip`,
`C:\Program Files\GoNavi\GoNavi.exe`,
`C:\Program Files\GoNavi\.gonavi-update-windows-v0.5.0`,
`C:\Program Files\GoNavi\logs\update-install.log`,
99999,
)
for _, token := range []string{
`if !ERRORLEVEL! NEQ 0 (`,
`powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%TARGET%' -WorkingDirectory '%TARGET_DIR%'" >> "%LOG_FILE%" 2>&1`,
`set "TARGET_OLD=%TARGET%.old"`,
func TestBuildWindowsPowerShellScriptAvoidsCmdParsing(t *testing.T) {
script := buildWindowsPowerShellScript()
for _, bad := range []string{
`cmd.exe`,
`EnableDelayedExpansion`,
`__GONAVI_UPDATE_`,
`-FilePath '%TARGET%'`,
} {
if !strings.Contains(script, token) {
t.Fatalf("windows update script missing token: %s\nscript:\n%s", token, script)
if strings.Contains(script, bad) {
t.Fatalf("Windows PowerShell updater must not contain legacy cmd token %q\n%s", bad, script)
}
}
for _, r := range script {
if r > 0x7f {
t.Fatalf("Windows PowerShell updater must remain ASCII-only, found %q", r)
}
}
}
func TestBuildWindowsScriptRelaunchUsesTargetDirectory(t *testing.T) {
script := buildWindowsScript(
`C:\tmp\GoNavi-v0.5.0-windows-amd64.exe`,
`C:\Program Files\GoNavi\GoNavi.exe`,
`C:\Program Files\GoNavi\.gonavi-update-windows-v0.5.0`,
`C:\Program Files\GoNavi\logs\update-install.log`,
99999,
)
for _, token := range []string{
`for %%I in ("%TARGET%") do set "TARGET_DIR=%%~dpI"`,
`start "" /D "%TARGET_DIR%" "%TARGET%" >> "%LOG_FILE%" 2>&1`,
`Start-Process -FilePath '%TARGET%' -WorkingDirectory '%TARGET_DIR%'`,
} {
if !strings.Contains(script, token) {
t.Fatalf("windows update relaunch missing token: %s\nscript:\n%s", token, script)
}
func TestBuildWindowsLaunchCommandUsesHiddenPowerShellFile(t *testing.T) {
context := windowsUpdateLaunchContext{
SourcePath: `C:\tmp\GoNavi-0.8.5-Windows-Amd64.exe`,
TargetPath: `C:\GoNavi\GoNavi.exe`,
CurrentTargetPath: `C:\GoNavi\GoNavi.exe`,
StagedDir: `C:\tmp\gonavi-update`,
LogPath: `C:\tmp\gonavi-update\update.log`,
PID: 12345,
}
}
scriptPath := `C:\tmp\gonavi-update\update.ps1`
cmd := buildWindowsLaunchCommand(scriptPath, context)
func TestBuildWindowsLaunchCommandUsesDirectHiddenCall(t *testing.T) {
cmd := buildWindowsLaunchCommand(`C:\tmp\gonavi-update\update.cmd`)
if !strings.EqualFold(cmd.Args[0], cmd.Path) && !strings.HasSuffix(strings.ToLower(cmd.Path), `\cmd.exe`) {
t.Fatalf("unexpected command path: %s", cmd.Path)
want := []string{
"powershell.exe",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
scriptPath,
}
want := []string{"cmd.exe", "/D", "/C", "call", `C:\tmp\gonavi-update\update.cmd`}
if len(cmd.Args) != len(want) {
t.Fatalf("unexpected arg length: got %d want %d, args=%v", len(cmd.Args), len(want), cmd.Args)
}
@@ -148,5 +136,3 @@ func TestBuildWindowsLaunchCommandUsesDirectHiddenCall(t *testing.T) {
}
}
}
var _ = exec.ErrNotFound

View File

@@ -0,0 +1,7 @@
package main
import "time"
func main() {
time.Sleep(6 * time.Second)
}

View File

@@ -5,30 +5,11 @@ import (
"os"
"path/filepath"
stdRuntime "runtime"
"strconv"
"strings"
"GoNavi-Wails/internal/logger"
)
func init() {
updateLaunchInstallScript = launchUpdateScriptWithCleanup
}
func launchUpdateScriptWithCleanup(staged *stagedUpdate) error {
exePath, err := os.Executable()
if err != nil {
return err
}
exePath, _ = filepath.EvalSymlinks(exePath)
pid := os.Getpid()
if stdRuntime.GOOS == "windows" {
return launchWindowsUpdateWithCleanup(staged, exePath, pid)
}
return launchUpdateScript(staged)
}
func launchWindowsUpdateWithCleanup(staged *stagedUpdate, targetExe string, pid int) error {
if staged == nil {
return localizedUpdateError{key: "app.update.backend.message.no_downloaded_package"}
@@ -49,6 +30,7 @@ func launchWindowsUpdateWithCleanup(staged *stagedUpdate, targetExe string, pid
cleanupWindowsUpdateArtifacts([]string{
originalSourceDir,
strings.TrimSpace(filepath.Dir(staged.StagedDir)),
strings.TrimSpace(filepath.Dir(currentTargetExe)),
strings.TrimSpace(filepath.Dir(finalTargetExe)),
}, map[string]struct{}{
@@ -58,14 +40,22 @@ func launchWindowsUpdateWithCleanup(staged *stagedUpdate, targetExe string, pid
cleanComparablePath(staged.StagedDir): {},
})
scriptPath := filepath.Join(staged.StagedDir, "update.cmd")
content := buildWindowsScriptWithCurrentTargetCleanup(staged.FilePath, finalTargetExe, currentTargetExe, staged.StagedDir, staged.InstallLogPath, pid)
scriptPath := filepath.Join(staged.StagedDir, "update.ps1")
content := buildWindowsPowerShellScript()
if err := os.WriteFile(scriptPath, []byte(content), 0o644); err != nil {
return err
}
logger.Infof("启动 Windows 更新脚本current=%s target=%s script=%s log=%s", currentTargetExe, finalTargetExe, scriptPath, staged.InstallLogPath)
cmd := buildWindowsLaunchCommand(scriptPath)
launchContext := windowsUpdateLaunchContext{
SourcePath: staged.FilePath,
TargetPath: finalTargetExe,
CurrentTargetPath: currentTargetExe,
StagedDir: staged.StagedDir,
LogPath: staged.InstallLogPath,
PID: pid,
}
logger.Infof("启动 Windows PowerShell 更新器current=%s target=%s script=%s log=%s", currentTargetExe, finalTargetExe, scriptPath, staged.InstallLogPath)
cmd := buildWindowsLaunchCommand(scriptPath, launchContext)
if err := cmd.Start(); err != nil {
return err
}
@@ -77,25 +67,8 @@ func launchWindowsUpdateWithCleanup(staged *stagedUpdate, targetExe string, pid
return nil
}
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 resolveWindowsUpdateFinalTargetPath(currentTarget string, _ string) string {
return strings.TrimSpace(currentTarget)
}
func prepareWindowsStagedUpdateAsset(sourcePath string, stagedDir string) (string, error) {
@@ -217,147 +190,3 @@ func cleanComparablePath(path string) string {
}
return cleaned
}
func buildWindowsScriptWithCleanup(source, target, stagedDir, logPath string, pid int) string {
return buildWindowsScriptWithCurrentTargetCleanup(source, target, target, stagedDir, logPath, pid)
}
func buildWindowsScriptWithCurrentTargetCleanup(source, target, currentTarget, stagedDir, logPath string, pid int) string {
script := `@echo off
setlocal EnableExtensions EnableDelayedExpansion
set "SOURCE=__GONAVI_UPDATE_SOURCE__"
set "TARGET=__GONAVI_UPDATE_TARGET__"
set "CURRENT_TARGET=__GONAVI_CURRENT_TARGET__"
set "TARGET_OLD=%TARGET%.old"
set "STAGED=__GONAVI_UPDATE_STAGED__"
set "LOG_FILE=__GONAVI_UPDATE_LOG__"
set PID=__GONAVI_UPDATE_PID__
set /a WAIT_PID_SECONDS=0
call :log updater started
if not exist "%SOURCE%" (
call :log source file not found: %SOURCE%
exit /b 1
)
for %%I in ("%TARGET%") do set "TARGET_NAME=%%~nxI"
for %%I in ("%TARGET%") do set "TARGET_DIR=%%~dpI"
for %%I in ("%SOURCE%") do set "SOURCE_EXT=%%~xI"
set "SOURCE_EXE="
if /I "%SOURCE_EXT%"==".zip" (
set "EXTRACT_DIR=%STAGED%\_extract"
if exist "%EXTRACT_DIR%" (
rmdir /S /Q "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
)
mkdir "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
powershell -NoProfile -ExecutionPolicy Bypass -Command "$src=$env:SOURCE; $dst=$env:EXTRACT_DIR; Expand-Archive -LiteralPath $src -DestinationPath $dst -Force" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL! NEQ 0 (
call :log expand zip failed: %SOURCE%
exit /b 1
)
if exist "%EXTRACT_DIR%\%TARGET_NAME%" (
set "SOURCE_EXE=%EXTRACT_DIR%\%TARGET_NAME%"
) else (
for /R "%EXTRACT_DIR%" %%F in (*.exe) do (
if not defined SOURCE_EXE (
set "SOURCE_EXE=%%~fF"
)
)
)
if not defined SOURCE_EXE (
call :log no executable found in portable zip: %SOURCE%
exit /b 1
)
) else (
set "SOURCE_EXE=%SOURCE%"
)
:waitloop
tasklist /FI "PID eq %PID%" | find "%PID%" >nul
if %ERRORLEVEL%==0 (
if !WAIT_PID_SECONDS! GEQ 90 (
call :log host process still running after !WAIT_PID_SECONDS! seconds, aborting update
exit /b 1
)
timeout /t 1 /nobreak >nul
set /a WAIT_PID_SECONDS+=1
goto waitloop
)
call :log host process exited
timeout /t 3 /nobreak >nul
call :log cooldown finished, starting file replace
:replace_binary
if /I "%SOURCE_EXE%"=="%TARGET%" (
call :log downloaded executable already at target path, skip replace
goto move_done
)
set /a RETRY=0
:move_retry
call :log attempt !RETRY!: trying rename-then-copy strategy
move /Y "%TARGET%" "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL!==0 (
copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL!==0 (
del /F /Q "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
goto move_done
)
call :log copy after rename failed, restoring old file
move /Y "%TARGET_OLD%" "%TARGET%" >> "%LOG_FILE%" 2>&1
)
call :log rename strategy failed, trying direct move
move /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL%==0 goto move_done
copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL%==0 goto move_done
set /a RETRY+=1
if !RETRY! LSS 15 (
set /a WAIT=1
if !RETRY! GEQ 3 set /a WAIT=2
if !RETRY! GEQ 6 set /a WAIT=3
if !RETRY! GEQ 9 set /a WAIT=5
call :log waiting !WAIT! seconds before retry
timeout /t !WAIT! /nobreak >nul
goto move_retry
)
call :log replace failed after retries (portable mode, no elevation): check directory write permission or file lock
exit /b 1
:move_done
del /F /Q "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
if /I not "%CURRENT_TARGET%"=="%TARGET%" (
if exist "%CURRENT_TARGET%" del /F /Q "%CURRENT_TARGET%" >> "%LOG_FILE%" 2>&1
)
if exist "%SOURCE%" del /F /Q "%SOURCE%" >> "%LOG_FILE%" 2>&1
start "" /D "%TARGET_DIR%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL% NEQ 0 (
call :log cmd start failed, trying powershell Start-Process
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%TARGET%' -WorkingDirectory '%TARGET_DIR%'" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL! NEQ 0 (
call :log relaunch failed
exit /b 1
)
)
call :log update finished
start "" /MIN cmd.exe /D /C "timeout /t 2 /nobreak >nul & del /F /Q ""%LOG_FILE%"" >nul 2>&1 & rmdir /S /Q ""%STAGED%"" >nul 2>&1"
exit /b 0
:log
echo [%date% %time%] %*>>"%LOG_FILE%"
exit /b 0
`
return strings.NewReplacer(
"__GONAVI_UPDATE_SOURCE__", source,
"__GONAVI_UPDATE_TARGET__", target,
"__GONAVI_CURRENT_TARGET__", currentTarget,
"__GONAVI_UPDATE_STAGED__", stagedDir,
"__GONAVI_UPDATE_LOG__", logPath,
"__GONAVI_UPDATE_PID__", strconv.Itoa(pid),
).Replace(strings.ReplaceAll(script, "\n", "\r\n"))
}

View File

@@ -88,26 +88,102 @@ func TestPrepareWindowsStagedUpdateAssetMovesPackageIntoStagedDir(t *testing.T)
}
}
func TestBuildWindowsScriptWithCleanupRemovesLogAndStagedDirectoryAfterSuccess(t *testing.T) {
script := buildWindowsScriptWithCleanup(
`C:\Users\tester\AppData\Local\Temp\gonavi-updates\.gonavi-update-windows-dev-new\GoNavi-dev-new-Windows-Amd64.exe`,
`C:\Users\tester\Desktop\GoNavi-dev-current-Windows-Amd64.exe`,
`C:\Users\tester\AppData\Local\Temp\gonavi-updates\.gonavi-update-windows-dev-new`,
`C:\Users\tester\AppData\Local\Temp\gonavi-updates\.gonavi-update-windows-dev-new\gonavi-update-windows-123.log`,
12345,
)
func TestResolveWindowsUpdateFinalTargetPathPreservesExecutablePath(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")
if got := resolveWindowsUpdateFinalTargetPath(currentTarget, stagedSource); got != currentTarget {
t.Fatalf("Windows update target = %q, want current executable path %q", got, currentTarget)
}
}
func TestBuildWindowsPowerShellScriptSchedulesStagedDirectoryCleanupAfterSuccess(t *testing.T) {
script := buildWindowsPowerShellScript()
mustContain := []string{
`if exist "%SOURCE%" del /F /Q "%SOURCE%"`,
`del /F /Q ""%LOG_FILE%""`,
`rmdir /S /Q ""%STAGED%""`,
`Write-UpdateLog 'update finished'`,
`$CleanupCommand = 'Start-Sleep -Seconds 2; Remove-Item -LiteralPath $env:GONAVI_UPDATE_STAGED_DIR`,
`$CleanupWorkingDirectory = [IO.Path]::GetTempPath()`,
`-EncodedCommand`,
}
for _, token := range mustContain {
if !strings.Contains(script, token) {
t.Fatalf("expected script to contain %q\n%s", token, script)
}
}
if strings.Contains(script, `rmdir /S /Q "%STAGED%" >> "%LOG_FILE%"`) {
t.Fatalf("script should not synchronously remove staged dir while logging to it\n%s", script)
if strings.Contains(script, `cmd.exe`) {
t.Fatalf("PowerShell updater must not route cleanup through cmd.exe\n%s", script)
}
}
func TestBuildWindowsPowerShellScriptDoesNotEmbedUnicodeRuntimePaths(t *testing.T) {
source := `C:\Users\tester\AppData\Local\Temp\gonavi-updates\.gonavi-update-windows-latest-0.8.5\GoNavi-0.8.5-Windows-Amd64.exe`
target := `D:\软件\数据库管理工具\GoNavi\GoNavi.exe`
stagedDir := `C:\Users\tester\AppData\Local\Temp\gonavi-updates\.gonavi-update-windows-latest-0.8.5`
logPath := filepath.Join(stagedDir, "gonavi-update-windows.log")
script := buildWindowsPowerShellScript()
for _, path := range []string{source, target, stagedDir, logPath} {
if strings.Contains(script, path) {
t.Fatalf("Windows PowerShell updater must not embed runtime path %q because legacy cmd.exe decoding would corrupt it\n%s", path, script)
}
}
for _, r := range script {
if r > 0x7f {
t.Fatalf("Windows PowerShell updater must stay ASCII-only, found %q\n%s", r, script)
}
}
}
func TestBuildWindowsPowerShellScriptRelaunchesBeforeDeletingFallbacks(t *testing.T) {
script := buildWindowsPowerShellScript()
startIdx := strings.Index(script, `$NewProcess = Start-Process -FilePath $Target`)
deleteCurrentIdx := strings.Index(script, `Remove-UpdateArtifact $CurrentTarget`)
deleteSourceIdx := strings.Index(script, `Remove-UpdateArtifact $Source`)
if startIdx < 0 || deleteCurrentIdx < 0 || deleteSourceIdx < 0 {
t.Fatalf("expected relaunch and cleanup commands in script (start=%d current=%d source=%d)\n%s", startIdx, deleteCurrentIdx, deleteSourceIdx, script)
}
if deleteCurrentIdx < startIdx || deleteSourceIdx < startIdx {
t.Fatalf("fallback files must be deleted only after relaunch (start=%d current=%d source=%d)\n%s", startIdx, deleteCurrentIdx, deleteSourceIdx, script)
}
}
func TestBuildWindowsLaunchCommandPreservesSpecialPathsInEnvironment(t *testing.T) {
context := windowsUpdateLaunchContext{
SourcePath: `C:\Users\tester\AppData\Local\Temp\GoNavi %TEMP%\GoNavi-0.8.5-Windows-Amd64.exe`,
TargetPath: `D:\软件 ! 100% & (便携版)\O'Brien\GoNavi.exe`,
CurrentTargetPath: `D:\软件 ! 100% & (便携版)\O'Brien\GoNavi-dev-f930ffe.exe`,
StagedDir: `C:\Users\tester\AppData\Local\Temp\GoNavi %TEMP%\stage`,
LogPath: `C:\Users\tester\AppData\Local\Temp\GoNavi %TEMP%\stage\update.log`,
PID: 12345,
}
cmd := buildWindowsLaunchCommand(filepath.Join(context.StagedDir, "update.ps1"), context)
wantEnvironment := map[string]string{
"GONAVI_UPDATE_SOURCE": context.SourcePath,
"GONAVI_UPDATE_TARGET": context.TargetPath,
"GONAVI_UPDATE_CURRENT_TARGET": context.CurrentTargetPath,
"GONAVI_UPDATE_STAGED_DIR": context.StagedDir,
"GONAVI_UPDATE_LOG_PATH": context.LogPath,
"GONAVI_UPDATE_PID": "12345",
}
gotEnvironment := make(map[string]string, len(wantEnvironment))
for _, item := range cmd.Env {
name, value, ok := strings.Cut(item, "=")
if !ok {
continue
}
if _, exists := wantEnvironment[name]; exists {
gotEnvironment[name] = value
}
}
for name, want := range wantEnvironment {
if got := gotEnvironment[name]; got != want {
t.Fatalf("update environment %s = %q, want %q", name, got, want)
}
}
if cmd.Dir != context.StagedDir {
t.Fatalf("update command directory = %q, want %q", cmd.Dir, context.StagedDir)
}
}

View File

@@ -0,0 +1,276 @@
$ErrorActionPreference = 'Stop'
$Source = $env:GONAVI_UPDATE_SOURCE
$Target = $env:GONAVI_UPDATE_TARGET
$CurrentTarget = $env:GONAVI_UPDATE_CURRENT_TARGET
$StagedDir = $env:GONAVI_UPDATE_STAGED_DIR
$LogPath = $env:GONAVI_UPDATE_LOG_PATH
$HostProcessId = 0
$TargetOld = $null
$ReplacementPrepared = $false
$PreviousTargetBackedUp = $false
$TargetWriteStarted = $false
$LaunchSucceeded = $false
$HostExited = $false
$SourceMatchesTarget = $false
$RollbackSucceeded = $true
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 Test-SamePath {
param(
[string]$Left,
[string]$Right
)
if ([string]::IsNullOrWhiteSpace($Left) -or [string]::IsNullOrWhiteSpace($Right)) {
return $false
}
return [StringComparer]::OrdinalIgnoreCase.Equals(
[IO.Path]::GetFullPath($Left),
[IO.Path]::GetFullPath($Right)
)
}
function Restore-PreviousTarget {
try {
if ($PreviousTargetBackedUp -and -not (Test-Path -LiteralPath $TargetOld -PathType Leaf)) {
Write-UpdateLog 'rollback failed: previous executable backup is missing'
return $false
}
if (($TargetWriteStarted -or $ReplacementPrepared) -and (Test-Path -LiteralPath $Target -PathType Leaf)) {
Remove-Item -LiteralPath $Target -Force
}
if ($PreviousTargetBackedUp -and (Test-Path -LiteralPath $TargetOld -PathType Leaf)) {
Move-Item -LiteralPath $TargetOld -Destination $Target -Force
}
return $true
} catch {
Write-UpdateLog ("rollback failed: " + $_.Exception.Message)
return $false
}
}
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)
}
}
function Select-PortableExecutable {
param(
[string]$ExtractDir,
[string]$TargetPath,
[string]$PackagePath
)
$ExecutableCandidates = @(Get-ChildItem -LiteralPath $ExtractDir -Recurse -File |
Where-Object { $_.Extension -ieq '.exe' })
if ($ExecutableCandidates.Count -eq 0) {
throw 'no executable found in portable zip; package retained for manual install'
}
$TargetFileName = [IO.Path]::GetFileName($TargetPath)
$ExactTargetMatches = @($ExecutableCandidates | Where-Object {
[string]::Equals($_.Name, $TargetFileName, [StringComparison]::OrdinalIgnoreCase)
})
if ($ExactTargetMatches.Count -eq 1) {
return $ExactTargetMatches[0].FullName
}
if ($ExactTargetMatches.Count -gt 1) {
throw 'ambiguous portable zip: multiple executables match current target filename; package retained for manual install'
}
$PackageExecutableName = [IO.Path]::GetFileNameWithoutExtension($PackagePath) + '.exe'
$PackageNameMatches = @($ExecutableCandidates | Where-Object {
[string]::Equals($_.Name, $PackageExecutableName, [StringComparison]::OrdinalIgnoreCase)
})
if ($PackageNameMatches.Count -eq 1) {
return $PackageNameMatches[0].FullName
}
if ($PackageNameMatches.Count -gt 1) {
throw 'ambiguous portable zip: multiple executables match package filename; package retained for manual install'
}
$GoNaviMatches = @($ExecutableCandidates | Where-Object {
$_.Name.StartsWith('GoNavi', [StringComparison]::OrdinalIgnoreCase)
})
if ($GoNaviMatches.Count -eq 1) {
return $GoNaviMatches[0].FullName
}
if ($ExecutableCandidates.Count -eq 1) {
return $ExecutableCandidates[0].FullName
}
throw ("ambiguous portable zip: found " + $ExecutableCandidates.Count + " executable candidates; package retained for manual install")
}
try {
foreach ($requiredPath in @($Source, $Target, $CurrentTarget, $StagedDir, $LogPath)) {
if ([string]::IsNullOrWhiteSpace($requiredPath)) {
throw 'missing required updater path'
}
}
if (-not [int]::TryParse($env:GONAVI_UPDATE_PID, [ref]$HostProcessId) -or $HostProcessId -le 0) {
throw 'invalid host process id'
}
$TargetOld = $Target + '.old'
$TargetDir = [IO.Path]::GetDirectoryName($Target)
if ([string]::IsNullOrWhiteSpace($TargetDir) -or -not (Test-Path -LiteralPath $TargetDir -PathType Container)) {
throw 'target directory does not exist'
}
if (-not (Test-Path -LiteralPath $Source -PathType Leaf)) {
throw 'source file not found'
}
Write-UpdateLog '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 3
Write-UpdateLog 'cooldown finished, starting file replace'
$SourceExe = $Source
if ([string]::Equals([IO.Path]::GetExtension($Source), '.zip', [StringComparison]::OrdinalIgnoreCase)) {
$ExtractDir = [IO.Path]::Combine($StagedDir, '_extract')
if (Test-Path -LiteralPath $ExtractDir) {
Remove-Item -LiteralPath $ExtractDir -Recurse -Force
}
[IO.Directory]::CreateDirectory($ExtractDir) | Out-Null
Expand-Archive -LiteralPath $Source -DestinationPath $ExtractDir -Force
$SourceExe = Select-PortableExecutable -ExtractDir $ExtractDir -TargetPath $Target -PackagePath $Source
Write-UpdateLog ("selected portable executable: " + $SourceExe)
}
$SourceMatchesTarget = Test-SamePath $SourceExe $Target
if ($SourceMatchesTarget) {
Write-UpdateLog 'downloaded executable already at target path, skipping replace'
$ReplacementPrepared = $true
} else {
for ($attempt = 0; $attempt -lt 15; $attempt++) {
$PreviousTargetBackedUp = $false
$TargetWriteStarted = $false
try {
if (Test-Path -LiteralPath $TargetOld) {
Remove-Item -LiteralPath $TargetOld -Force
}
if (Test-Path -LiteralPath $Target -PathType Leaf) {
Move-Item -LiteralPath $Target -Destination $TargetOld -Force
$PreviousTargetBackedUp = $true
}
$TargetWriteStarted = $true
Copy-Item -LiteralPath $SourceExe -Destination $Target -Force
$ReplacementPrepared = $true
break
} catch {
Write-UpdateLog ("replace attempt " + ($attempt + 1) + " failed: " + $_.Exception.Message)
if ($PreviousTargetBackedUp -or $TargetWriteStarted) {
$RollbackSucceeded = Restore-PreviousTarget
if (-not $RollbackSucceeded) {
throw 'replace failed and previous executable could not be restored'
}
}
if ($attempt -ge 14) {
break
}
$waitSeconds = 1
if ($attempt -ge 8) {
$waitSeconds = 5
} elseif ($attempt -ge 5) {
$waitSeconds = 3
} elseif ($attempt -ge 2) {
$waitSeconds = 2
}
Start-Sleep -Seconds $waitSeconds
}
}
}
if (-not $ReplacementPrepared) {
throw 'replace failed after retries; package kept for manual install'
}
Write-UpdateLog ("launching target: " + $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 $TargetOld
if (-not (Test-SamePath $CurrentTarget $Target)) {
Remove-UpdateArtifact $CurrentTarget
}
if (-not (Test-SamePath $Source $Target)) {
Remove-UpdateArtifact $Source
}
Write-UpdateLog '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 ("updater failed: " + $_.Exception.Message)
if ($ReplacementPrepared -and -not $LaunchSucceeded -and -not $SourceMatchesTarget) {
$RollbackSucceeded = Restore-PreviousTarget
}
if ($HostExited -and -not $LaunchSucceeded -and -not $SourceMatchesTarget -and $RollbackSucceeded) {
try {
if (Test-Path -LiteralPath $CurrentTarget -PathType Leaf) {
$CurrentTargetDir = [IO.Path]::GetDirectoryName($CurrentTarget)
Start-Process -FilePath $CurrentTarget -WorkingDirectory $CurrentTargetDir -ErrorAction Stop | Out-Null
Write-UpdateLog 'previous application relaunched after update failure'
}
} catch {
Write-UpdateLog ("previous application relaunch failed: " + $_.Exception.Message)
}
}
exit 1
}

View File

@@ -0,0 +1,243 @@
//go:build windows
package app
import (
"archive/zip"
"crypto/sha256"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestWindowsPowerShellUpdaterHandlesUnicodeAndShellMetacharacters(t *testing.T) {
root := t.TempDir()
installDir := filepath.Join(root, `软件 ! 100% & (便携版)`, `O'Brien`)
stagedDir := filepath.Join(root, `literal-%TEMP%-stage`)
if err := os.MkdirAll(installDir, 0o755); err != nil {
t.Fatalf("MkdirAll install dir: %v", err)
}
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
t.Fatalf("MkdirAll staged dir: %v", err)
}
sourcePath := filepath.Join(stagedDir, "GoNavi-0.8.5-Windows-Amd64.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)
targetPath := filepath.Join(installDir, "GoNavi.exe")
if err := os.WriteFile(targetPath, []byte("old executable"), 0o755); err != nil {
t.Fatalf("WriteFile old target: %v", err)
}
logPath := filepath.Join(stagedDir, "gonavi-update-windows-test.log")
scriptPath := filepath.Join(stagedDir, "update.ps1")
if err := os.WriteFile(scriptPath, []byte(buildWindowsPowerShellScript()), 0o644); err != nil {
t.Fatalf("WriteFile updater: %v", err)
}
context := windowsUpdateLaunchContext{
SourcePath: sourcePath,
TargetPath: targetPath,
CurrentTargetPath: targetPath,
StagedDir: stagedDir,
LogPath: logPath,
PID: 2147483647,
}
cmd := buildWindowsLaunchCommand(scriptPath, context)
if output, err := cmd.CombinedOutput(); err != nil {
logData, _ := os.ReadFile(logPath)
t.Fatalf("run updater: %v\nstdout/stderr:\n%s\nlog:\n%s", err, output, logData)
}
// The helper remains alive long enough for the updater's launch health check.
// Wait for it to exit before the test temp directory is removed on Windows.
time.Sleep(7 * time.Second)
targetData, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile updated target: %v", err)
}
if gotHash := sha256.Sum256(targetData); gotHash != wantHash {
t.Fatalf("updated target hash = %x, want %x", gotHash, wantHash)
}
if _, err := os.Stat(targetPath + ".old"); !os.IsNotExist(err) {
t.Fatalf("expected rollback executable to be cleaned, stat err=%v", err)
}
deadline := time.Now().Add(5 * time.Second)
for {
_, err := os.Stat(stagedDir)
if os.IsNotExist(err) {
break
}
if time.Now().After(deadline) {
t.Fatalf("staged directory was not cleaned: %s", stagedDir)
}
time.Sleep(100 * time.Millisecond)
}
t.Logf("updated target at %s (%s)", targetPath, fmt.Sprintf("%x", wantHash[:8]))
}
func TestWindowsPowerShellUpdaterSelectsExactTargetFilenameRecursivelyFromZip(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)
}
}
helperPath := filepath.Join(root, "update-helper.exe")
build := exec.Command("go", "build", "-ldflags=-H=windowsgui", "-o", helperPath, "./testdata/windows_update_helper")
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build update helper: %v\n%s", err, output)
}
helperData, err := os.ReadFile(helperPath)
if err != nil {
t.Fatalf("ReadFile helper: %v", err)
}
wantHash := sha256.Sum256(helperData)
sourcePath := filepath.Join(stagedDir, "portable.zip")
writeWindowsUpdateTestZip(t, sourcePath, []windowsUpdateZipEntry{
{Name: "nested/GoNavi.exe", Data: helperData},
{Name: "portable.exe", Data: []byte("package-name distractor")},
{Name: "tools/GoNavi-helper.exe", Data: []byte("GoNavi-name distractor")},
})
targetPath := filepath.Join(installDir, "GoNavi.exe")
if err := os.WriteFile(targetPath, []byte("old executable"), 0o755); err != nil {
t.Fatalf("WriteFile old target: %v", err)
}
logPath := filepath.Join(stagedDir, "gonavi-update-windows-zip.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: targetPath,
StagedDir: stagedDir,
LogPath: logPath,
PID: 2147483647,
})
if output, err := cmd.CombinedOutput(); err != nil {
logData, _ := os.ReadFile(logPath)
t.Fatalf("run ZIP updater: %v\nstdout/stderr:\n%s\nlog:\n%s", err, output, logData)
}
// The selected helper remains alive through the launch health check.
time.Sleep(7 * time.Second)
targetData, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile updated target: %v", err)
}
if gotHash := sha256.Sum256(targetData); gotHash != wantHash {
t.Fatalf("updated target hash = %x, want exact target-name candidate hash %x", gotHash, wantHash)
}
}
func TestWindowsPowerShellUpdaterRejectsAmbiguousZipAndRetainsPackage(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, "portable.zip")
writeWindowsUpdateTestZip(t, sourcePath, []windowsUpdateZipEntry{
{Name: "tools/alpha.exe", Data: []byte("alpha")},
{Name: "nested/beta.exe", Data: []byte("beta")},
})
targetPath := filepath.Join(installDir, "Application.exe")
oldTargetData := []byte("old executable")
if err := os.WriteFile(targetPath, oldTargetData, 0o755); err != nil {
t.Fatalf("WriteFile old target: %v", err)
}
logPath := filepath.Join(stagedDir, "gonavi-update-windows-ambiguous.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: targetPath,
StagedDir: stagedDir,
LogPath: logPath,
PID: 2147483647,
})
if output, err := cmd.CombinedOutput(); err == nil {
t.Fatalf("ambiguous ZIP updater unexpectedly succeeded\n%s", output)
}
if _, err := os.Stat(sourcePath); err != nil {
t.Fatalf("ambiguous update package must be retained: %v", err)
}
targetData, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile target after ambiguous update: %v", err)
}
if string(targetData) != string(oldTargetData) {
t.Fatalf("target changed after ambiguous ZIP selection: got %q want %q", targetData, oldTargetData)
}
logData, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("ReadFile updater log: %v", err)
}
if !strings.Contains(string(logData), "ambiguous portable zip") ||
!strings.Contains(string(logData), "package retained for manual install") {
t.Fatalf("expected ambiguous retained-package error in updater log:\n%s", logData)
}
}
type windowsUpdateZipEntry struct {
Name string
Data []byte
}
func writeWindowsUpdateTestZip(t *testing.T, path string, entries []windowsUpdateZipEntry) {
t.Helper()
archive, err := os.Create(path)
if err != nil {
t.Fatalf("Create ZIP %q: %v", path, err)
}
writer := zip.NewWriter(archive)
for _, entry := range entries {
file, createErr := writer.Create(entry.Name)
if createErr != nil {
_ = writer.Close()
_ = archive.Close()
t.Fatalf("Create ZIP entry %q: %v", entry.Name, createErr)
}
if _, writeErr := file.Write(entry.Data); writeErr != nil {
_ = writer.Close()
_ = archive.Close()
t.Fatalf("Write ZIP entry %q: %v", entry.Name, writeErr)
}
}
if err := writer.Close(); err != nil {
_ = archive.Close()
t.Fatalf("Close ZIP writer: %v", err)
}
if err := archive.Close(); err != nil {
t.Fatalf("Close ZIP file: %v", err)
}
}

View File

@@ -0,0 +1,23 @@
package app
import (
_ "embed"
"strings"
)
//go:embed windows_update.ps1
var windowsUpdatePowerShellScript string
type windowsUpdateLaunchContext struct {
SourcePath string
TargetPath string
CurrentTargetPath string
StagedDir string
LogPath string
PID int
}
func buildWindowsPowerShellScript() string {
normalized := strings.ReplaceAll(windowsUpdatePowerShellScript, "\r\n", "\n")
return strings.ReplaceAll(normalized, "\n", "\r\n")
}