mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-13 00:13:33 +08:00
🐛 fix(update): 修复 Windows 在线更新中文路径失效
- 将 Windows 更新脚本统一为 PowerShell,并通过 Unicode 环境变量传递中文与特殊字符路径 - 保留当前可执行文件路径,补充替换重试、启动健康检查、失败回滚和严格 ZIP 选择 - 更新前复用未保存 SQL 确认流程,并延长退出兜底时间 - 新增 Windows 原生集成测试并接入 dev/release 构建
This commit is contained in:
5
.github/workflows/dev-build.yml
vendored
5
.github/workflows/dev-build.yml
vendored
@@ -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
|
||||
|
||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -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: |
|
||||
|
||||
@@ -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' ? [
|
||||
|
||||
38
frontend/src/App.update-quit-guard.test.ts
Normal file
38
frontend/src/App.update-quit-guard.test.ts
Normal 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(');
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
7
internal/app/testdata/windows_update_helper/main.go
vendored
Normal file
7
internal/app/testdata/windows_update_helper/main.go
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "time"
|
||||
|
||||
func main() {
|
||||
time.Sleep(6 * time.Second)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
276
internal/app/windows_update.ps1
Normal file
276
internal/app/windows_update.ps1
Normal 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
|
||||
}
|
||||
243
internal/app/windows_update_integration_windows_test.go
Normal file
243
internal/app/windows_update_integration_windows_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
23
internal/app/windows_update_script.go
Normal file
23
internal/app/windows_update_script.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user