diff --git a/internal/app/update_cleanup.go b/internal/app/update_cleanup.go new file mode 100644 index 00000000..769c09e9 --- /dev/null +++ b/internal/app/update_cleanup.go @@ -0,0 +1,329 @@ +package app + +import ( + "io" + "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"} + } + if err := os.MkdirAll(staged.StagedDir, 0o755); err != nil { + return err + } + + originalSourceDir := strings.TrimSpace(filepath.Dir(staged.FilePath)) + preparedSource, err := prepareWindowsStagedUpdateAsset(staged.FilePath, staged.StagedDir) + if err != nil { + return err + } + staged.FilePath = preparedSource + staged.InstallLogPath = buildUpdateInstallLogPath(staged.StagedDir) + + cleanupWindowsUpdateArtifacts([]string{ + originalSourceDir, + strings.TrimSpace(filepath.Dir(targetExe)), + }, map[string]struct{}{ + cleanComparablePath(targetExe): {}, + cleanComparablePath(staged.FilePath): {}, + cleanComparablePath(staged.StagedDir): {}, + }) + + scriptPath := filepath.Join(staged.StagedDir, "update.cmd") + content := buildWindowsScriptWithCleanup(staged.FilePath, targetExe, staged.StagedDir, staged.InstallLogPath, pid) + if err := os.WriteFile(scriptPath, []byte(content), 0o644); err != nil { + return err + } + + logger.Infof("启动 Windows 更新脚本:target=%s script=%s log=%s", targetExe, scriptPath, staged.InstallLogPath) + 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 +} + +func prepareWindowsStagedUpdateAsset(sourcePath string, stagedDir string) (string, error) { + sourcePath = strings.TrimSpace(sourcePath) + stagedDir = strings.TrimSpace(stagedDir) + if sourcePath == "" || stagedDir == "" { + return sourcePath, nil + } + if isUpdateAssetPathInsideStagedDir(sourcePath, stagedDir) { + return sourcePath, nil + } + if err := os.MkdirAll(stagedDir, 0o755); err != nil { + return "", err + } + targetPath := filepath.Join(stagedDir, filepath.Base(sourcePath)) + if cleanComparablePath(sourcePath) == cleanComparablePath(targetPath) { + return sourcePath, nil + } + _ = os.Remove(targetPath) + if err := os.Rename(sourcePath, targetPath); err == nil { + return targetPath, nil + } + if err := copyFileForWindowsUpdate(sourcePath, targetPath); err != nil { + return "", err + } + _ = os.Remove(sourcePath) + return targetPath, nil +} + +func copyFileForWindowsUpdate(sourcePath string, targetPath string) error { + in, err := os.Open(sourcePath) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return err + } + _, copyErr := io.Copy(out, in) + closeErr := out.Close() + if copyErr != nil { + return copyErr + } + return closeErr +} + +func cleanupWindowsUpdateArtifacts(dirs []string, keep map[string]struct{}) { + seen := map[string]struct{}{} + for _, dir := range dirs { + dir = strings.TrimSpace(dir) + if dir == "" || dir == "." { + continue + } + cleanDir := cleanComparablePath(dir) + if cleanDir == "" { + continue + } + if _, ok := seen[cleanDir]; ok { + continue + } + seen[cleanDir] = struct{}{} + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, entry := range entries { + path := filepath.Join(dir, entry.Name()) + cleanPath := cleanComparablePath(path) + if cleanPath == "" { + continue + } + if _, ok := keep[cleanPath]; ok { + continue + } + if shouldRemoveWindowsUpdateArtifact(entry.Name(), entry.IsDir()) { + if entry.IsDir() { + _ = os.RemoveAll(path) + } else { + _ = os.Remove(path) + } + } + } + } +} + +func shouldRemoveWindowsUpdateArtifact(name string, isDir bool) bool { + trimmed := strings.TrimSpace(name) + lower := strings.ToLower(trimmed) + if trimmed == "" { + return false + } + if isDir { + return strings.HasPrefix(lower, ".gonavi-update-") + } + if strings.HasPrefix(lower, "gonavi-update-") && strings.HasSuffix(lower, ".log") { + return true + } + if !strings.HasPrefix(trimmed, "GoNavi-") { + return false + } + if !strings.Contains(trimmed, "-Windows-") { + return false + } + return strings.HasSuffix(lower, ".exe") || strings.HasSuffix(lower, ".zip") +} + +func cleanComparablePath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + cleaned := filepath.Clean(path) + if cleaned == "." { + return "" + } + if stdRuntime.GOOS == "windows" { + return strings.ToLower(cleaned) + } + return cleaned +} + +func buildWindowsScriptWithCleanup(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%" +) + +: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 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_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 new file mode 100644 index 00000000..912c9953 --- /dev/null +++ b/internal/app/update_cleanup_test.go @@ -0,0 +1,113 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestShouldRemoveWindowsUpdateArtifact(t *testing.T) { + cases := []struct { + name string + isDir bool + want bool + }{ + {name: "GoNavi-dev-abc-Windows-Amd64.exe", want: true}, + {name: "GoNavi-0.8.4-Windows-Amd64.zip", want: true}, + {name: "gonavi-update-windows-123.log", want: true}, + {name: ".gonavi-update-windows-dev-dev-abc", isDir: true, want: true}, + {name: "GoNavi-dev-abc-MacOS-Arm64.dmg", want: false}, + {name: "GoNavi.exe", want: false}, + {name: "notes.log", want: false}, + } + + for _, tc := range cases { + if got := shouldRemoveWindowsUpdateArtifact(tc.name, tc.isDir); got != tc.want { + t.Fatalf("shouldRemoveWindowsUpdateArtifact(%q, %v) = %v, want %v", tc.name, tc.isDir, got, tc.want) + } + } +} + +func TestCleanupWindowsUpdateArtifactsKeepsCurrentTargetAndRemovesStalePackages(t *testing.T) { + dir := t.TempDir() + currentTarget := filepath.Join(dir, "GoNavi-dev-current-Windows-Amd64.exe") + currentPackage := filepath.Join(dir, "GoNavi-dev-new-Windows-Amd64.exe") + stalePackage := filepath.Join(dir, "GoNavi-dev-old-Windows-Amd64.exe") + staleLog := filepath.Join(dir, "gonavi-update-windows-123.log") + staleStage := filepath.Join(dir, ".gonavi-update-windows-dev-old") + otherFile := filepath.Join(dir, "notes.txt") + + for _, path := range []string{currentTarget, currentPackage, stalePackage, staleLog, otherFile} { + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatalf("WriteFile(%q) returned error: %v", path, err) + } + } + if err := os.MkdirAll(staleStage, 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + + cleanupWindowsUpdateArtifacts([]string{dir}, map[string]struct{}{ + cleanComparablePath(currentTarget): {}, + cleanComparablePath(currentPackage): {}, + }) + + for _, path := range []string{currentTarget, currentPackage, otherFile} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected %q to remain: %v", path, err) + } + } + for _, path := range []string{stalePackage, staleLog, staleStage} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected %q to be removed, stat err=%v", path, err) + } + } +} + +func TestPrepareWindowsStagedUpdateAssetMovesPackageIntoStagedDir(t *testing.T) { + dir := t.TempDir() + stagedDir := filepath.Join(dir, ".gonavi-update-windows-dev-new") + source := filepath.Join(dir, "GoNavi-dev-new-Windows-Amd64.exe") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + prepared, err := prepareWindowsStagedUpdateAsset(source, stagedDir) + if err != nil { + t.Fatalf("prepareWindowsStagedUpdateAsset returned error: %v", err) + } + want := filepath.Join(stagedDir, filepath.Base(source)) + if prepared != want { + t.Fatalf("expected prepared path %q, got %q", want, prepared) + } + if _, err := os.Stat(source); !os.IsNotExist(err) { + t.Fatalf("expected source to be moved away, stat err=%v", err) + } + if data, err := os.ReadFile(prepared); err != nil || string(data) != "payload" { + t.Fatalf("expected payload in staged file, data=%q err=%v", string(data), err) + } +} + +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, + ) + + mustContain := []string{ + `if exist "%SOURCE%" del /F /Q "%SOURCE%"`, + `del /F /Q ""%LOG_FILE%""`, + `rmdir /S /Q ""%STAGED%""`, + } + 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) + } +}