From e1fb5b12d44fe25deb6d5b7fa90b7ae81603bd7c Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 10 Jul 2026 13:31:34 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(update):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20Windows=20=E5=9C=A8=E7=BA=BF=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E8=B7=AF=E5=BE=84=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 Windows 更新脚本统一为 PowerShell,并通过 Unicode 环境变量传递中文与特殊字符路径 - 保留当前可执行文件路径,补充替换重试、启动健康检查、失败回滚和严格 ZIP 选择 - 更新前复用未保存 SQL 确认流程,并延长退出兜底时间 - 新增 Windows 原生集成测试并接入 dev/release 构建 --- .github/workflows/dev-build.yml | 5 + .github/workflows/release.yml | 5 + frontend/src/App.tsx | 56 ++-- frontend/src/App.update-quit-guard.test.ts | 38 +++ .../src/hooks/useAppUpdateManager.test.tsx | 51 +++- frontend/src/hooks/useAppUpdateManager.ts | 7 +- internal/app/application_quit_test.go | 86 ++++++ internal/app/methods_update.go | 215 +++----------- internal/app/methods_update_test.go | 26 +- .../methods_update_windows_process_test.go | 5 +- .../app/methods_update_windows_script_test.go | 200 ++++++------- .../testdata/windows_update_helper/main.go | 7 + internal/app/update_cleanup.go | 201 +------------ internal/app/update_cleanup_test.go | 102 ++++++- internal/app/windows_update.ps1 | 276 ++++++++++++++++++ ...windows_update_integration_windows_test.go | 243 +++++++++++++++ internal/app/windows_update_script.go | 23 ++ 17 files changed, 1017 insertions(+), 529 deletions(-) create mode 100644 frontend/src/App.update-quit-guard.test.ts create mode 100644 internal/app/testdata/windows_update_helper/main.go create mode 100644 internal/app/windows_update.ps1 create mode 100644 internal/app/windows_update_integration_windows_test.go create mode 100644 internal/app/windows_update_script.go diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index 0a8a1d56..40d7e409 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4cb837e4..d1ec4e8a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3ab5e641..a9c8ff99 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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; /** 设置页 Slider 底部预设刻度 */ const UI_SCALE_SLIDER_MARKS: Record = { 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 => { + 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() { ) : null, isLatestUpdateDownloaded ? ( - ) : null, @@ -8077,7 +8097,7 @@ function App() { , - ] : (updateDownloadProgress.status === 'error' ? [ diff --git a/frontend/src/App.update-quit-guard.test.ts b/frontend/src/App.update-quit-guard.test.ts new file mode 100644 index 00000000..26e3a7f5 --- /dev/null +++ b/frontend/src/App.update-quit-guard.test.ts @@ -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;'); + expect(appSource).toContain('const handleApplicationQuitRequest = useCallback(async (confirmedAction?: ApplicationQuitConfirmedAction) => {'); + expect(appSource).toContain('const runConfirmedAction = async (): Promise => {'); + const runnerStart = appSource.indexOf('const runConfirmedAction = async (): Promise => {'); + 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('); + }); +}); diff --git a/frontend/src/hooks/useAppUpdateManager.test.tsx b/frontend/src/hooks/useAppUpdateManager.test.tsx index b77fd290..372efaf4 100644 --- a/frontend/src/hooks/useAppUpdateManager.test.tsx +++ b/frontend/src/hooks/useAppUpdateManager.test.tsx @@ -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 () => { diff --git a/frontend/src/hooks/useAppUpdateManager.ts b/frontend/src/hooks/useAppUpdateManager.ts index 65195856..6afae319 100644 --- a/frontend/src/hooks/useAppUpdateManager.ts +++ b/frontend/src/hooks/useAppUpdateManager.ts @@ -319,11 +319,11 @@ export const useAppUpdateManager = ({ const canShowProgressEntry = (isLatestUpdateDownloaded || isBackgroundProgressForLatestUpdate) && updateInstallTriggeredVersionRef.current !== (lastUpdateKey || null); - const handleInstallFromProgress = useCallback(async () => { + const handleInstallFromProgress = useCallback(async (): Promise => { 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 () => { diff --git a/internal/app/application_quit_test.go b/internal/app/application_quit_test.go index 350a246f..16408664 100644 --- a/internal/app/application_quit_test.go +++ b/internal/app/application_quit_test.go @@ -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) + } + } +} diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index 8fc5e991..3f41e0df 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -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 } diff --git a/internal/app/methods_update_test.go b/internal/app/methods_update_test.go index 8b5c7e30..27b7036b 100644 --- a/internal/app/methods_update_test.go +++ b/internal/app/methods_update_test.go @@ -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) { diff --git a/internal/app/methods_update_windows_process_test.go b/internal/app/methods_update_windows_process_test.go index b55e590d..052ebe52 100644 --- a/internal/app/methods_update_windows_process_test.go +++ b/internal/app/methods_update_windows_process_test.go @@ -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") diff --git a/internal/app/methods_update_windows_script_test.go b/internal/app/methods_update_windows_script_test.go index d3f58c55..ed21f400 100644 --- a/internal/app/methods_update_windows_script_test.go +++ b/internal/app/methods_update_windows_script_test.go @@ -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 diff --git a/internal/app/testdata/windows_update_helper/main.go b/internal/app/testdata/windows_update_helper/main.go new file mode 100644 index 00000000..cf261530 --- /dev/null +++ b/internal/app/testdata/windows_update_helper/main.go @@ -0,0 +1,7 @@ +package main + +import "time" + +func main() { + time.Sleep(6 * time.Second) +} diff --git a/internal/app/update_cleanup.go b/internal/app/update_cleanup.go index 38c91d2a..7fedf5b5 100644 --- a/internal/app/update_cleanup.go +++ b/internal/app/update_cleanup.go @@ -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")) -} diff --git a/internal/app/update_cleanup_test.go b/internal/app/update_cleanup_test.go index 912c9953..75cfea63 100644 --- a/internal/app/update_cleanup_test.go +++ b/internal/app/update_cleanup_test.go @@ -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) } } diff --git a/internal/app/windows_update.ps1 b/internal/app/windows_update.ps1 new file mode 100644 index 00000000..659b231e --- /dev/null +++ b/internal/app/windows_update.ps1 @@ -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 +} diff --git a/internal/app/windows_update_integration_windows_test.go b/internal/app/windows_update_integration_windows_test.go new file mode 100644 index 00000000..2b6d1f78 --- /dev/null +++ b/internal/app/windows_update_integration_windows_test.go @@ -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) + } +} diff --git a/internal/app/windows_update_script.go b/internal/app/windows_update_script.go new file mode 100644 index 00000000..628083e0 --- /dev/null +++ b/internal/app/windows_update_script.go @@ -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") +}