mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-08 21:11:44 +08:00
Compare commits
7 Commits
fix/ob-ora
...
fix/update
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cecefce84d | ||
|
|
2406c1ff71 | ||
|
|
fbb23f0b9a | ||
|
|
016e2bc70e | ||
|
|
070c2328e0 | ||
|
|
c117da42e4 | ||
|
|
950856d86f |
363
internal/app/update_cleanup.go
Normal file
363
internal/app/update_cleanup.go
Normal file
@@ -0,0 +1,363 @@
|
||||
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
|
||||
}
|
||||
|
||||
currentTargetExe := strings.TrimSpace(targetExe)
|
||||
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)
|
||||
finalTargetExe := resolveWindowsUpdateFinalTargetPath(currentTargetExe, staged.FilePath)
|
||||
|
||||
cleanupWindowsUpdateArtifacts([]string{
|
||||
originalSourceDir,
|
||||
strings.TrimSpace(filepath.Dir(currentTargetExe)),
|
||||
strings.TrimSpace(filepath.Dir(finalTargetExe)),
|
||||
}, map[string]struct{}{
|
||||
cleanComparablePath(currentTargetExe): {},
|
||||
cleanComparablePath(finalTargetExe): {},
|
||||
cleanComparablePath(staged.FilePath): {},
|
||||
cleanComparablePath(staged.StagedDir): {},
|
||||
})
|
||||
|
||||
scriptPath := filepath.Join(staged.StagedDir, "update.cmd")
|
||||
content := buildWindowsScriptWithCurrentTargetCleanup(staged.FilePath, finalTargetExe, currentTargetExe, staged.StagedDir, staged.InstallLogPath, pid)
|
||||
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)
|
||||
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 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 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 {
|
||||
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"))
|
||||
}
|
||||
113
internal/app/update_cleanup_test.go
Normal file
113
internal/app/update_cleanup_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user