fix(update): 统一更新缓存与成功清理目录 (#771)

统一 macOS、Windows 和 Linux 更新包到用户缓存目录,失败时回退系统临时目录。成功更新后清理整个
GoNavi/updates,失败时保留安装包、脚本和日志,并补充跨平台路径与清理测试。
This commit is contained in:
Syngnat
2026-07-28 22:40:21 +08:00
committed by GitHub
13 changed files with 512 additions and 231 deletions

View File

@@ -124,6 +124,7 @@ type stagedUpdate struct {
Channel updateChannel
Version string
AssetName string
WorkspaceDir string
FilePath string
StagedDir string
InstallLogPath string
@@ -334,7 +335,7 @@ func (a *App) InstallUpdateAndRestart(closeAllWindowsInstancesConfirmed bool) co
return connection.QueryResult{Success: false, Message: a.appText("app.update.backend.message.no_downloaded_package", nil)}
}
if strings.TrimSpace(staged.InstallLogPath) == "" {
staged.InstallLogPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath))
staged.InstallLogPath = buildUpdateInstallLogPath(staged.WorkspaceDir)
}
installTarget := ""
if stdRuntime.GOOS == "windows" {
@@ -536,37 +537,15 @@ func (a *App) OpenDownloadedUpdateDirectory() connection.QueryResult {
}
func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
workspaceDir := strings.TrimSpace(resolveUpdateWorkspaceDirForInstallMode(info.LatestVersion, updateInstallMode(info.InstallMode)))
if workspaceDir == "" {
message := a.appText("app.update.backend.message.app_directory_unresolved_download", nil)
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, message)
return connection.QueryResult{Success: false, Message: message}
}
if err := os.MkdirAll(workspaceDir, 0o755); err != nil {
errMsg := a.appText("app.update.backend.message.app_directory_unavailable", map[string]any{"path": workspaceDir})
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg)
return connection.QueryResult{Success: false, Message: errMsg}
}
// 使用版本号命名的工作目录,便于识别和调试
stagedDir := resolveUpdateStagedDir(workspaceDir, info.Channel, info.LatestVersion)
stageBaseDir := filepath.Dir(stagedDir)
// 清理可能残留的旧目录(上次下载失败后未清理)
// Windows 上文件可能被杀毒软件/索引服务占用,需要重试
for retry := 0; retry < 5; retry++ {
err := os.RemoveAll(stagedDir)
if err == nil {
break
workspaceCandidates := resolveUpdateWorkspaceDirCandidatesForInstallMode(info.LatestVersion, updateInstallMode(info.InstallMode))
workspaceDir, stagedDir, prepareErr := prepareUpdateWorkspaceAndStagingDirs(workspaceCandidates, info.Channel, info.LatestVersion)
if prepareErr != nil {
preferredDir := strings.TrimSpace(resolveUpdateWorkspaceDirForInstallMode(info.LatestVersion, updateInstallMode(info.InstallMode)))
if preferredDir == "" {
preferredDir = os.TempDir()
}
if retry < 4 {
time.Sleep(time.Duration(retry+1) * 500 * time.Millisecond)
} else {
// 最后一次仍然失败,换一个带时间戳的目录名避免冲突
stagedDir = filepath.Join(stageBaseDir, fmt.Sprintf("%s-%d", buildUpdateStageDirName(info.Channel, info.LatestVersion), time.Now().UnixNano()))
}
}
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
errMsg := a.appText("app.update.backend.message.create_workspace_failed", map[string]any{"path": stagedDir})
logger.Error(prepareErr, "创建更新工作区失败")
errMsg := a.appText("app.update.backend.message.create_workspace_failed", map[string]any{"path": preferredDir})
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg)
return connection.QueryResult{Success: false, Message: errMsg}
}
@@ -611,6 +590,7 @@ func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
Channel: updateChannel(info.Channel),
Version: info.LatestVersion,
AssetName: info.AssetName,
WorkspaceDir: workspaceDir,
FilePath: assetPath,
StagedDir: stagedDir,
InstallLogPath: buildUpdateInstallLogPath(workspaceDir),
@@ -1521,16 +1501,12 @@ func sanitizeVersionForPath(version string) string {
}
result := strings.Trim(builder.String(), "-")
if result == "" {
if result == "" || result == "." || result == ".." {
return "latest"
}
return result
}
func resolveLegacyUpdateWorkspaceDir() string {
return filepath.Join(os.TempDir(), "gonavi-updates")
}
func resolveUpdateWorkspaceDir(version string) string {
return resolveUpdateWorkspaceDirForInstallMode(version, updateResolveInstallMode())
}
@@ -1541,39 +1517,81 @@ func resolveUpdateWorkspaceDirForInstallMode(version string, installMode updateI
stdRuntime.GOOS,
version,
installMode,
updateResolveInstallTarget(),
"",
cacheDir,
)
}
func resolveUpdateWorkspaceDirForPlatform(goos string, version string, installMode updateInstallMode, installTarget string, userCacheDir string) string {
// macOS 更新包继续保存在桌面版本目录根级,方便用户直接处理 DMG。
if goos == "darwin" {
homeDir, err := os.UserHomeDir()
if err == nil && strings.TrimSpace(homeDir) != "" {
desktopDir := filepath.Join(homeDir, "Desktop")
if st, statErr := os.Stat(desktopDir); statErr == nil && st.IsDir() {
return filepath.Join(desktopDir, fmt.Sprintf("GoNavi-%s", sanitizeVersionForPath(version)))
func resolveUpdateWorkspaceDirCandidatesForInstallMode(version string, installMode updateInstallMode) []string {
preferredDir := resolveUpdateWorkspaceDirForInstallMode(version, installMode)
fallbackDir := resolveUpdateWorkspaceDirForPlatform(stdRuntime.GOOS, version, installMode, "", "")
candidates := make([]string, 0, 2)
seen := make(map[string]struct{}, 2)
for _, candidate := range []string{preferredDir, fallbackDir} {
candidate = strings.TrimSpace(candidate)
if candidate == "" {
continue
}
key := normalizeUpdatePathForPrefixCheck(candidate)
if stdRuntime.GOOS == "windows" {
key = strings.ToLower(key)
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
candidates = append(candidates, candidate)
}
return candidates
}
func resolveUpdateWorkspaceDirForPlatform(_ string, version string, _ updateInstallMode, _ string, userCacheDir string) string {
baseDir := strings.TrimSpace(userCacheDir)
if baseDir == "" {
baseDir = strings.TrimSpace(os.TempDir())
}
if baseDir == "" {
return ""
}
return filepath.Join(baseDir, "GoNavi", "updates", sanitizeVersionForPath(version))
}
func prepareUpdateWorkspaceAndStagingDirs(workspaceCandidates []string, channel string, version string) (string, string, error) {
var prepareErrors []error
for _, candidate := range workspaceCandidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" {
continue
}
if err := os.MkdirAll(candidate, 0o755); err != nil {
prepareErrors = append(prepareErrors, fmt.Errorf("create %s: %w", candidate, err))
continue
}
stagedDir := resolveUpdateStagedDir(candidate, channel, version)
stageBaseDir := filepath.Dir(stagedDir)
// Windows 上文件可能被杀毒软件或索引服务短暂占用,需要重试。
for retry := 0; retry < 5; retry++ {
err := os.RemoveAll(stagedDir)
if err == nil {
break
}
if retry < 4 {
time.Sleep(time.Duration(retry+1) * 500 * time.Millisecond)
} else {
stagedDir = filepath.Join(stageBaseDir, fmt.Sprintf("%s-%d", buildUpdateStageDirName(channel, version), time.Now().UnixNano()))
}
}
}
if goos == "windows" && installMode == updateInstallModeMSI {
if strings.TrimSpace(userCacheDir) != "" {
return filepath.Join(userCacheDir, "GoNavi", "updates")
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
prepareErrors = append(prepareErrors, fmt.Errorf("create %s: %w", stagedDir, err))
continue
}
return resolveLegacyUpdateWorkspaceDir()
return candidate, stagedDir, nil
}
// Windows / Linux 更新包优先落到当前应用运行目录,方便用户直接找到下载产物。
targetPath := strings.TrimSpace(installTarget)
if targetPath != "" {
targetDir := strings.TrimSpace(filepath.Dir(targetPath))
if targetDir != "" && targetDir != "." {
return targetDir
}
if len(prepareErrors) == 0 {
return "", "", errors.New("no update workspace candidates")
}
return resolveLegacyUpdateWorkspaceDir()
return "", "", errors.Join(prepareErrors...)
}
func resolveUpdateAssetPath(workspaceDir string, stagedDir string, assetName string) string {
@@ -1599,17 +1617,12 @@ func resolveUpdateStagedDir(workspaceDir string, channel string, version string)
func resolveUpdateStagedDirForPlatform(goos string, workspaceDir string, channel string, version string) string {
baseDir := strings.TrimSpace(workspaceDir)
if strings.EqualFold(strings.TrimSpace(goos), "windows") || baseDir == "" {
baseDir = resolveLegacyUpdateWorkspaceDir()
if baseDir == "" {
return ""
}
return filepath.Join(baseDir, buildUpdateStageDirNameForPlatform(goos, channel, version))
}
func shouldReuseUpdateAssetFromStagedDirForPlatform(goos string, assetName string) bool {
return !(strings.EqualFold(strings.TrimSpace(goos), "windows") &&
shouldWindowsUpdateLaunchDownloadedAssetDirectly(assetName))
}
func normalizeUpdatePathForPrefixCheck(path string) string {
normalized := strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")
normalized = filepath.ToSlash(filepath.Clean(normalized))
@@ -1619,6 +1632,132 @@ func normalizeUpdatePathForPrefixCheck(path string) string {
return strings.TrimRight(normalized, "/")
}
func updatePathsEqualForPlatform(goos string, left string, right string) bool {
left = normalizeUpdatePathForPrefixCheck(left)
right = normalizeUpdatePathForPrefixCheck(right)
if left == "" || right == "" {
return false
}
if strings.EqualFold(strings.TrimSpace(goos), "windows") {
return strings.EqualFold(left, right)
}
return left == right
}
func absoluteUpdatePath(path string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
return "", errors.New("path is empty")
}
cleaned := filepath.Clean(path)
if cleaned == "." {
return "", errors.New("path resolves to current directory")
}
absPath, err := filepath.Abs(cleaned)
if err != nil {
return "", err
}
return filepath.Clean(absPath), nil
}
func isUpdatePathStrictlyInsideDir(path string, dir string) bool {
absPath, err := absoluteUpdatePath(path)
if err != nil {
return false
}
absDir, err := absoluteUpdatePath(dir)
if err != nil {
return false
}
relPath, err := filepath.Rel(absDir, absPath)
if err != nil || relPath == "." || filepath.IsAbs(relPath) {
return false
}
return relPath != ".." && !strings.HasPrefix(relPath, ".."+string(filepath.Separator))
}
func isDirectChildUpdatePath(path string, parentDir string) bool {
absPath, err := absoluteUpdatePath(path)
if err != nil {
return false
}
absParent, err := absoluteUpdatePath(parentDir)
if err != nil {
return false
}
relPath, err := filepath.Rel(absParent, absPath)
if err != nil || relPath == "." || relPath == ".." || filepath.IsAbs(relPath) {
return false
}
return filepath.Dir(relPath) == "."
}
func allowedUpdateRootDirs() []string {
cacheDir, _ := os.UserCacheDir()
baseDirs := []string{cacheDir, os.TempDir()}
roots := make([]string, 0, len(baseDirs))
seen := make(map[string]struct{}, len(baseDirs))
for _, baseDir := range baseDirs {
baseDir = strings.TrimSpace(baseDir)
if baseDir == "" {
continue
}
rootDir := filepath.Join(baseDir, "GoNavi", "updates")
key := normalizeUpdatePathForPrefixCheck(rootDir)
if stdRuntime.GOOS == "windows" {
key = strings.ToLower(key)
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
roots = append(roots, rootDir)
}
return roots
}
func validateStagedUpdateWorkspace(staged *stagedUpdate) error {
if staged == nil {
return errors.New("staged update is nil")
}
workspaceDir := strings.TrimSpace(staged.WorkspaceDir)
version := strings.TrimSpace(staged.Version)
if workspaceDir == "" || version == "" {
return errors.New("update workspace or version is empty")
}
if filepath.Base(filepath.Clean(workspaceDir)) != sanitizeVersionForPath(version) {
return fmt.Errorf("update workspace does not match version %q", version)
}
allowed := false
for _, rootDir := range allowedUpdateRootDirs() {
if isDirectChildUpdatePath(workspaceDir, rootDir) {
allowed = true
break
}
}
if !allowed {
return fmt.Errorf("update workspace %q is outside the cache roots", workspaceDir)
}
for label, path := range map[string]string{
"package": staged.FilePath,
"staging": staged.StagedDir,
"log": staged.InstallLogPath,
} {
if !isUpdatePathStrictlyInsideDir(path, workspaceDir) {
return fmt.Errorf("%s path %q is outside update workspace %q", label, path, workspaceDir)
}
}
return nil
}
func resolveUpdateCleanupDir(workspaceDir string) string {
workspaceDir = strings.TrimSpace(workspaceDir)
if workspaceDir == "" {
return ""
}
return filepath.Dir(filepath.Clean(workspaceDir))
}
func isUpdateAssetPathInsideStagedDir(filePath string, stagedDir string) bool {
normalizedFilePath := normalizeUpdatePathForPrefixCheck(filePath)
normalizedStagedDir := normalizeUpdatePathForPrefixCheck(stagedDir)
@@ -1628,20 +1767,13 @@ func isUpdateAssetPathInsideStagedDir(filePath string, stagedDir string) bool {
return normalizedFilePath == normalizedStagedDir || strings.HasPrefix(normalizedFilePath, normalizedStagedDir+"/")
}
func buildReusableUpdatePathCandidatesForPlatform(goos string, preferredWorkspaceDir string, legacyWorkspaceDir string, channel string, version string, assetName string) []updatePathCandidate {
func buildReusableUpdatePathCandidatesForPlatform(goos string, preferredWorkspaceDir string, fallbackWorkspaceDir string, channel string, version string, assetName string) []updatePathCandidate {
preferredWorkspaceDir = strings.TrimSpace(preferredWorkspaceDir)
legacyWorkspaceDir = strings.TrimSpace(legacyWorkspaceDir)
fallbackWorkspaceDir = strings.TrimSpace(fallbackWorkspaceDir)
assetName = strings.TrimSpace(assetName)
preferredStagedDir := resolveUpdateStagedDirForPlatform(goos, preferredWorkspaceDir, channel, version)
stagedDirNames := []string{
buildUpdateStageDirNameForPlatform(goos, channel, version),
fmt.Sprintf(".gonavi-update-%s-%s", strings.TrimSpace(strings.ToLower(goos)), version),
}
workspaceCandidates := []string{preferredWorkspaceDir, legacyWorkspaceDir}
stageBaseCandidates := []string{preferredWorkspaceDir, legacyWorkspaceDir}
workspaceCandidates := []string{preferredWorkspaceDir, fallbackWorkspaceDir}
seenWorkspace := make(map[string]struct{}, len(workspaceCandidates))
seenStageBase := make(map[string]struct{}, len(stageBaseCandidates))
candidates := make([]updatePathCandidate, 0, 8)
candidates := make([]updatePathCandidate, 0, len(workspaceCandidates))
for _, workspaceDir := range workspaceCandidates {
workspaceDir = strings.TrimSpace(workspaceDir)
@@ -1655,35 +1787,11 @@ func buildReusableUpdatePathCandidatesForPlatform(goos string, preferredWorkspac
if shouldStoreUpdateAssetInWorkspaceRoot(goos) {
candidates = append(candidates, updatePathCandidate{
workspaceDir: workspaceDir,
stagedDir: preferredStagedDir,
stagedDir: resolveUpdateStagedDirForPlatform(goos, workspaceDir, channel, version),
assetPath: filepath.Join(workspaceDir, assetName),
})
}
}
if !shouldReuseUpdateAssetFromStagedDirForPlatform(goos, assetName) {
return candidates
}
for _, stageBaseDir := range stageBaseCandidates {
stageBaseDir = strings.TrimSpace(stageBaseDir)
if stageBaseDir == "" {
continue
}
if _, exists := seenStageBase[stageBaseDir]; exists {
continue
}
seenStageBase[stageBaseDir] = struct{}{}
for _, stagedDirName := range stagedDirNames {
stagedDir := filepath.Join(stageBaseDir, stagedDirName)
candidates = append(candidates, updatePathCandidate{
workspaceDir: stageBaseDir,
stagedDir: stagedDir,
assetPath: filepath.Join(stagedDir, assetName),
})
}
}
return candidates
}
@@ -1703,16 +1811,25 @@ func isExistingDownloadedAsset(filePath string, expectedSize int64) bool {
}
func resolveReusableStagedUpdate(info UpdateInfo, current *stagedUpdate) *stagedUpdate {
workspaceDirs := resolveUpdateWorkspaceDirCandidatesForInstallMode(strings.TrimSpace(info.LatestVersion), updateInstallMode(info.InstallMode))
preferredWorkspaceDir := ""
fallbackWorkspaceDir := ""
if len(workspaceDirs) > 0 {
preferredWorkspaceDir = workspaceDirs[0]
}
if len(workspaceDirs) > 1 {
fallbackWorkspaceDir = workspaceDirs[1]
}
return resolveReusableStagedUpdateForPlatform(
stdRuntime.GOOS,
resolveUpdateWorkspaceDirForInstallMode(strings.TrimSpace(info.LatestVersion), updateInstallMode(info.InstallMode)),
resolveLegacyUpdateWorkspaceDir(),
preferredWorkspaceDir,
fallbackWorkspaceDir,
info,
current,
)
}
func resolveReusableStagedUpdateForPlatform(goos string, preferredWorkspaceDir string, legacyWorkspaceDir string, info UpdateInfo, current *stagedUpdate) *stagedUpdate {
func resolveReusableStagedUpdateForPlatform(goos string, preferredWorkspaceDir string, fallbackWorkspaceDir string, info UpdateInfo, current *stagedUpdate) *stagedUpdate {
channel, err := normalizeUpdateChannel(info.Channel)
if err != nil {
channel = updateChannelLatest
@@ -1722,7 +1839,14 @@ func resolveReusableStagedUpdateForPlatform(goos string, preferredWorkspaceDir s
if version == "" || assetName == "" {
return nil
}
allowStagedDirReuse := shouldReuseUpdateAssetFromStagedDirForPlatform(goos, assetName)
candidates := buildReusableUpdatePathCandidatesForPlatform(
goos,
preferredWorkspaceDir,
fallbackWorkspaceDir,
string(channel),
version,
assetName,
)
if current != nil {
currentChannel := current.Channel
@@ -1735,11 +1859,14 @@ func resolveReusableStagedUpdateForPlatform(goos string, preferredWorkspaceDir s
current.PackageType == updatePackageType(info.PackageType) {
currentPath := strings.TrimSpace(current.FilePath)
if isExistingDownloadedAsset(currentPath, info.AssetSize) {
if !allowStagedDirReuse && isUpdateAssetPathInsideStagedDir(currentPath, current.StagedDir) {
current = nil
} else {
if strings.TrimSpace(current.InstallLogPath) == "" {
current.InstallLogPath = buildUpdateInstallLogPath(filepath.Dir(currentPath))
for _, candidate := range candidates {
if !updatePathsEqualForPlatform(goos, currentPath, candidate.assetPath) ||
!isUpdatePathStrictlyInsideDir(current.StagedDir, candidate.workspaceDir) {
continue
}
current.WorkspaceDir = candidate.workspaceDir
if !isUpdatePathStrictlyInsideDir(current.InstallLogPath, candidate.workspaceDir) {
current.InstallLogPath = buildUpdateInstallLogPath(candidate.workspaceDir)
}
current.Channel = channel
current.AssetName = assetName
@@ -1752,14 +1879,6 @@ func resolveReusableStagedUpdateForPlatform(goos string, preferredWorkspaceDir s
}
}
candidates := buildReusableUpdatePathCandidatesForPlatform(
goos,
preferredWorkspaceDir,
legacyWorkspaceDir,
string(channel),
version,
assetName,
)
for _, candidate := range candidates {
if !isExistingDownloadedAsset(candidate.assetPath, info.AssetSize) {
continue
@@ -1768,6 +1887,7 @@ func resolveReusableStagedUpdateForPlatform(goos string, preferredWorkspaceDir s
Channel: channel,
Version: version,
AssetName: assetName,
WorkspaceDir: candidate.workspaceDir,
FilePath: candidate.assetPath,
StagedDir: candidate.stagedDir,
InstallLogPath: buildUpdateInstallLogPath(candidate.workspaceDir),
@@ -1838,10 +1958,6 @@ func ensureWindowsUpdateTargetWritable(targetExe string) error {
return nil
}
func shouldWindowsUpdateLaunchDownloadedAssetDirectly(assetPath string) bool {
return strings.EqualFold(strings.TrimSpace(filepath.Ext(strings.TrimSpace(assetPath))), ".exe")
}
func (a *App) emitUpdateDownloadProgress(status string, downloaded, total int64, message string) {
if a.ctx == nil {
return
@@ -1863,6 +1979,15 @@ func (a *App) emitUpdateDownloadProgress(status string, downloaded, total int64,
}
func launchUpdateScript(staged *stagedUpdate) error {
if staged == nil {
return localizedUpdateError{key: "app.update.backend.message.no_downloaded_package"}
}
if strings.TrimSpace(staged.InstallLogPath) == "" {
staged.InstallLogPath = buildUpdateInstallLogPath(staged.WorkspaceDir)
}
if err := validateStagedUpdateWorkspace(staged); err != nil {
return fmt.Errorf("invalid update workspace: %w", err)
}
exePath, err := resolveExecutablePath(os.Executable, filepath.EvalSymlinks)
if err != nil || strings.TrimSpace(exePath) == "" {
return localizedUpdateError{key: "app.update.backend.error.install_target_unresolved"}
@@ -1908,12 +2033,12 @@ func launchMacUpdate(staged *stagedUpdate, targetExe string, pid int) error {
}
logPath := strings.TrimSpace(staged.InstallLogPath)
if logPath == "" {
logPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath))
logPath = buildUpdateInstallLogPath(staged.WorkspaceDir)
staged.InstallLogPath = logPath
}
scriptPath := filepath.Join(staged.StagedDir, "update.sh")
content := buildMacScript(staged.FilePath, targetApp, staged.StagedDir, mountDir, logPath, pid)
content := buildMacScript(staged.FilePath, targetApp, resolveUpdateCleanupDir(staged.WorkspaceDir), staged.StagedDir, mountDir, logPath, pid)
if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil {
return err
}
@@ -1935,7 +2060,7 @@ func launchMacUpdate(staged *stagedUpdate, targetExe string, pid int) error {
func launchLinuxUpdate(staged *stagedUpdate, targetExe string, pid int) error {
scriptPath := filepath.Join(staged.StagedDir, "update.sh")
content := buildLinuxScript(staged.FilePath, targetExe, staged.StagedDir, pid)
content := buildLinuxScript(staged.FilePath, targetExe, resolveUpdateCleanupDir(staged.WorkspaceDir), staged.StagedDir, staged.InstallLogPath, pid)
if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil {
return err
}
@@ -1966,6 +2091,7 @@ func buildWindowsLaunchCommand(scriptPath string, context windowsUpdateLaunchCon
"GONAVI_UPDATE_SOURCE="+context.SourcePath,
"GONAVI_UPDATE_TARGET="+context.TargetPath,
"GONAVI_UPDATE_CURRENT_TARGET="+context.CurrentTargetPath,
"GONAVI_UPDATE_ROOT_DIR="+context.UpdatesDir,
"GONAVI_UPDATE_STAGED_DIR="+context.StagedDir,
"GONAVI_UPDATE_LOG_PATH="+context.LogPath,
"GONAVI_UPDATE_MAINTENANCE_EVENT_NAME="+context.MaintenanceEventName,
@@ -1976,12 +2102,13 @@ func buildWindowsLaunchCommand(scriptPath string, context windowsUpdateLaunchCon
return cmd
}
func buildMacScript(packagePath, targetApp, stagedDir, mountDir, logPath string, pid int) string {
func buildMacScript(packagePath, targetApp, updatesDir, stagedDir, mountDir, logPath string, pid int) string {
return fmt.Sprintf(`#!/bin/bash
set -uo pipefail
PID=%d
PACKAGE="%s"
TARGET_APP="%s"
UPDATES_DIR="%s"
STAGED="%s"
MOUNT_DIR="%s"
LOG_FILE="%s"
@@ -2134,7 +2261,7 @@ relaunch_app() {
fi
log "open failed, trying binary launch: $TARGET_APP/$APP_BIN_REL"
if [ -x "$TARGET_APP/$APP_BIN_REL" ]; then
nohup "$TARGET_APP/$APP_BIN_REL" >>"$LOG_FILE" 2>&1 &
nohup "$TARGET_APP/$APP_BIN_REL" >/dev/null 2>&1 &
log "relaunch via binary pid=$!"
return 0
fi
@@ -2195,41 +2322,68 @@ if ! relaunch_app; then
exit 1
fi
# relaunch 已发出:再删安装包(用户已不需要 dmg/zip
/bin/rm -f "$PACKAGE" >>"$LOG_FILE" 2>&1 || true
log "relaunch requested; package cleaned if possible"
exit 0
`, pid, packagePath, targetApp, stagedDir, mountDir, logPath)
# 成功日志必须先落盘;删除工作区后不再写日志。
log "relaunch requested; removing updates directory"
cd /
exec /bin/rm -rf "$UPDATES_DIR"
`, pid, packagePath, targetApp, updatesDir, stagedDir, mountDir, logPath)
}
func buildLinuxScript(tarPath, targetExe, stagedDir string, pid int) string {
func buildLinuxScript(tarPath, targetExe, updatesDir, stagedDir, logPath string, pid int) string {
return fmt.Sprintf(`#!/bin/bash
set -e
PID=%d
ARCHIVE="%s"
TARGET="%s"
UPDATES_DIR="%s"
STAGED="%s"
LOG_FILE="%s"
UPDATE_TMP_DIR=""
log() {
echo "[$(date '+%%Y-%%m-%%d %%H:%%M:%%S')] $*" >> "$LOG_FILE" 2>/dev/null || true
}
cleanup_tmp() {
if [ -n "$UPDATE_TMP_DIR" ]; then
rm -rf "$UPDATE_TMP_DIR"
fi
}
trap cleanup_tmp EXIT
log "updater started archive=$ARCHIVE target=$TARGET pid=$PID"
while kill -0 $PID 2>/dev/null; do
sleep 1
done
TMPDIR=$(mktemp -d)
tar -xzf "$ARCHIVE" -C "$TMPDIR"
UPDATE_TMP_DIR=$(mktemp -d)
tar -xzf "$ARCHIVE" -C "$UPDATE_TMP_DIR"
TARGET_NAME="$(basename "$TARGET")"
NEWBIN="$TMPDIR/$TARGET_NAME"
NEWBIN="$UPDATE_TMP_DIR/$TARGET_NAME"
if [ ! -f "$NEWBIN" ]; then
NEWBIN=$(find "$TMPDIR" -type f -name "$TARGET_NAME" | head -n 1)
NEWBIN=$(find "$UPDATE_TMP_DIR" -type f -name "$TARGET_NAME" | head -n 1)
fi
if [ -z "$NEWBIN" ] || [ ! -f "$NEWBIN" ]; then
NEWBIN=$(find "$TMPDIR" -type f -name "GoNavi" | head -n 1)
NEWBIN=$(find "$UPDATE_TMP_DIR" -type f -name "GoNavi" | head -n 1)
fi
if [ -z "$NEWBIN" ] || [ ! -f "$NEWBIN" ]; then
exit 1
fi
cp -f "$NEWBIN" "$TARGET"
chmod +x "$TARGET"
rm -rf "$TMPDIR" "$ARCHIVE" "$STAGED"
"$TARGET" &
`, pid, tarPath, targetExe, stagedDir)
cleanup_tmp
UPDATE_TMP_DIR=""
"$TARGET" >/dev/null 2>&1 &
NEW_PID=$!
sleep 1
if ! kill -0 "$NEW_PID" 2>/dev/null; then
log "updated application exited immediately after launch; updates directory retained"
exit 1
fi
log "updated application relaunched; removing updates directory"
trap - EXIT
cd /
exec rm -rf "$UPDATES_DIR"
`, pid, tarPath, targetExe, updatesDir, stagedDir, logPath)
}
func detectMacAppPath(exePath string) string {

View File

@@ -7,11 +7,12 @@ import (
func TestBuildMacScriptContainsHardeningGuards(t *testing.T) {
script := buildMacScript(
"/tmp/GoNavi-1.2.3-MacOS-Arm64.dmg",
"/tmp/GoNavi/updates/1.2.3/GoNavi-1.2.3-MacOS-Arm64.dmg",
"/Applications/GoNavi.app",
"/tmp/stage",
"/tmp/stage/mnt",
"/tmp/gonavi-update-macos.log",
"/tmp/GoNavi/updates",
"/tmp/GoNavi/updates/1.2.3/stage",
"/tmp/GoNavi/updates/1.2.3/stage/mnt",
"/tmp/GoNavi/updates/1.2.3/gonavi-update-macos.log",
4242,
)
@@ -24,12 +25,13 @@ func TestBuildMacScriptContainsHardeningGuards(t *testing.T) {
"run_admin_replace",
"relaunch_app",
`open -n "$TARGET_APP"`,
`nohup "$TARGET_APP/$APP_BIN_REL" >/dev/null 2>&1 &`,
// 安装包扩展名分支
"dmg)",
"zip)",
// relaunch 成功后再删安装包,失败则保留
// relaunch 成功后删除整个 updates 目录,失败则保留
"package kept for manual install",
`/bin/rm -f "$PACKAGE"`,
`exec /bin/rm -rf "$UPDATES_DIR"`,
}
for _, token := range mustContain {
if !strings.Contains(script, token) {
@@ -39,13 +41,16 @@ func TestBuildMacScriptContainsHardeningGuards(t *testing.T) {
if strings.Contains(script, `rm -rf "$MOUNT_DIR" "$DMG" "$STAGED"`) {
t.Fatal("mac update script must not delete STAGED while the script may still be running from it")
}
// 确保不会在 relaunch 之前无条件删除安装包
rmIdx := strings.Index(script, `/bin/rm -f "$PACKAGE"`)
// 确保不会在 relaunch 之前删除 updates 目录。
rmIdx := strings.Index(script, `exec /bin/rm -rf "$UPDATES_DIR"`)
relaunchIdx := strings.Index(script, "if ! relaunch_app; then")
if rmIdx < 0 || relaunchIdx < 0 || rmIdx < relaunchIdx {
t.Fatalf("package cleanup must happen only after relaunch attempt (rmIdx=%d relaunchIdx=%d)", rmIdx, relaunchIdx)
t.Fatalf("updates cleanup must happen only after relaunch attempt (rmIdx=%d relaunchIdx=%d)", rmIdx, relaunchIdx)
}
if !strings.Contains(script, "/tmp/GoNavi-1.2.3-MacOS-Arm64.dmg") {
if strings.Contains(script[rmIdx+len(`exec /bin/rm -rf "$UPDATES_DIR"`):], `log "`) {
t.Fatal("mac update script must not write installation logs after deleting the updates directory")
}
if !strings.Contains(script, "/tmp/GoNavi/updates/1.2.3/GoNavi-1.2.3-MacOS-Arm64.dmg") {
t.Fatal("expected package path embedded in script")
}
if !strings.Contains(script, "/Applications/GoNavi.app") {

View File

@@ -3,6 +3,7 @@ package app
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
@@ -11,6 +12,7 @@ import (
"strings"
"sync"
"testing"
"time"
"GoNavi-Wails/internal/connection"
)
@@ -504,20 +506,28 @@ func TestCheckForUpdatesDoesNotMutatePublishedStagedUpdate(t *testing.T) {
installMode := updateResolveInstallMode()
packageType := resolveUpdatePackageType(stdRuntime.GOOS, installMode)
assetName, err := expectedAssetNameForInstallMode(stdRuntime.GOOS, stdRuntime.GOARCH, "v0.8.6", installMode)
latestVersion := fmt.Sprintf("0.8.6-test-%d", time.Now().UnixNano())
assetName, err := expectedAssetNameForInstallMode(stdRuntime.GOOS, stdRuntime.GOARCH, "v"+latestVersion, installMode)
if err != nil {
t.Fatalf("expectedAssetNameForInstallMode returned error: %v", err)
}
assetPath := filepath.Join(t.TempDir(), assetName)
workspaceDir := resolveUpdateWorkspaceDirForPlatform(stdRuntime.GOOS, latestVersion, installMode, "", "")
t.Cleanup(func() { _ = os.RemoveAll(workspaceDir) })
stagedDir := resolveUpdateStagedDirForPlatform(stdRuntime.GOOS, workspaceDir, string(updateChannelLatest), latestVersion)
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
t.Fatalf("MkdirAll staged directory: %v", err)
}
assetPath := filepath.Join(workspaceDir, assetName)
if err := os.WriteFile(assetPath, []byte("12345678"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
published := &stagedUpdate{
Channel: updateChannelLatest,
Version: "0.8.6",
Version: latestVersion,
AssetName: assetName,
WorkspaceDir: workspaceDir,
FilePath: assetPath,
StagedDir: filepath.Dir(assetPath),
StagedDir: stagedDir,
InstallMode: installMode,
PackageType: packageType,
AutoRelaunch: true,
@@ -535,9 +545,9 @@ func TestCheckForUpdatesDoesNotMutatePublishedStagedUpdate(t *testing.T) {
defer restoreStatic()
restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) {
return &githubRelease{
TagName: "v0.8.6",
Name: "v0.8.6",
HTMLURL: "https://example.com/releases/v0.8.6",
TagName: "v" + latestVersion,
Name: "v" + latestVersion,
HTMLURL: "https://example.com/releases/v" + latestVersion,
Assets: []githubAsset{{
Name: assetName,
BrowserDownloadURL: "https://example.com/" + assetName,
@@ -818,7 +828,7 @@ func TestResolveReusableStagedUpdateForPlatformSkipsLegacyWindowsExeStagedAsset(
}
}
func TestResolveReusableStagedUpdateForPlatformPrefersWindowsExeInInstallDirectory(t *testing.T) {
func TestResolveReusableStagedUpdateForPlatformPrefersAssetInCacheWorkspace(t *testing.T) {
preferredWorkspaceDir := t.TempDir()
legacyWorkspaceDir := t.TempDir()
info := UpdateInfo{
@@ -847,10 +857,13 @@ func TestResolveReusableStagedUpdateForPlatformPrefersWindowsExeInInstallDirecto
reused := resolveReusableStagedUpdateForPlatform("windows", preferredWorkspaceDir, legacyWorkspaceDir, info, nil)
if reused == nil {
t.Fatal("expected install-directory windows exe to be reused")
t.Fatal("expected cache workspace windows exe to be reused")
}
if reused.FilePath != preferredAssetPath {
t.Fatalf("expected preferred install-directory asset %q, got %q", preferredAssetPath, reused.FilePath)
t.Fatalf("expected preferred cache asset %q, got %q", preferredAssetPath, reused.FilePath)
}
if reused.WorkspaceDir != preferredWorkspaceDir {
t.Fatalf("expected workspace %q, got %q", preferredWorkspaceDir, reused.WorkspaceDir)
}
}
@@ -888,7 +901,7 @@ func TestResolveReusableStagedUpdateForPlatformDoesNotReuseCurrentWindowsExeInsi
}
}
func TestResolveReusableStagedUpdateForPlatformReusesPortableZipInsideStagedDir(t *testing.T) {
func TestResolveReusableStagedUpdateForPlatformReusesPortableZipFromFallbackWorkspaceRoot(t *testing.T) {
preferredWorkspaceDir := t.TempDir()
legacyWorkspaceDir := t.TempDir()
info := UpdateInfo{
@@ -901,23 +914,16 @@ func TestResolveReusableStagedUpdateForPlatformReusesPortableZipInsideStagedDir(
AutoRelaunch: true,
}
stagedDir := filepath.Join(
legacyWorkspaceDir,
buildUpdateStageDirNameForPlatform("windows", info.Channel, info.LatestVersion),
)
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
assetPath := filepath.Join(stagedDir, info.AssetName)
assetPath := filepath.Join(legacyWorkspaceDir, info.AssetName)
if err := os.WriteFile(assetPath, []byte("12345678"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
reused := resolveReusableStagedUpdateForPlatform("windows", preferredWorkspaceDir, legacyWorkspaceDir, info, nil)
if reused == nil {
t.Fatal("expected staged portable ZIP to be reused")
t.Fatal("expected fallback workspace portable ZIP to be reused")
}
if reused.FilePath != assetPath || reused.PackageType != updatePackageTypePortable {
if reused.FilePath != assetPath || reused.WorkspaceDir != legacyWorkspaceDir || reused.PackageType != updatePackageTypePortable {
t.Fatalf("unexpected reused portable ZIP: %#v", reused)
}
}
@@ -1108,24 +1114,23 @@ func TestInstallUpdateAndRestartMSISkipsPortableTargetWriteProbe(t *testing.T) {
}
}
func TestResolveUpdateWorkspaceDirPrefersCurrentInstallDirectory(t *testing.T) {
if stdRuntime.GOOS == "darwin" {
t.Skip("macOS keeps update downloads on Desktop")
func TestResolveUpdateWorkspaceDirUsesVersionedUserCacheDirectory(t *testing.T) {
cacheDir, err := os.UserCacheDir()
if err != nil || strings.TrimSpace(cacheDir) == "" {
t.Skip("user cache directory is unavailable")
}
targetDir := t.TempDir()
originalResolveInstallTarget := updateResolveInstallTarget
t.Cleanup(func() {
updateResolveInstallTarget = originalResolveInstallTarget
})
updateResolveInstallTarget = func() string {
return filepath.Join(targetDir, "GoNavi.exe")
}
got := resolveUpdateWorkspaceDir("0.8.2")
if got != targetDir {
t.Fatalf("expected workspace dir %q, got %q", targetDir, got)
want := filepath.Join(cacheDir, "GoNavi", "updates", "0.8.2")
if got != want {
t.Fatalf("expected workspace dir %q, got %q", want, got)
}
}
func TestSanitizeVersionForPathRejectsDotSegments(t *testing.T) {
for _, version := range []string{"", ".", "..", " / "} {
if got := sanitizeVersionForPath(version); got != "latest" {
t.Fatalf("sanitizeVersionForPath(%q) = %q, want latest", version, got)
}
}
}
@@ -1147,30 +1152,95 @@ func TestShouldStoreUpdateAssetInWorkspaceRoot(t *testing.T) {
}
}
func TestResolveUpdateStagedDirForPlatformUsesLegacyWorkspaceOnWindows(t *testing.T) {
func TestResolveUpdateStagedDirForPlatformStaysInsideWorkspaceOnWindows(t *testing.T) {
workspaceDir := filepath.Join("C:\\GoNavi", "app")
got := resolveUpdateStagedDirForPlatform("windows", workspaceDir, "dev", "dev-93dc696")
want := filepath.Join(resolveLegacyUpdateWorkspaceDir(), buildUpdateStageDirNameForPlatform("windows", "dev", "dev-93dc696"))
want := filepath.Join(workspaceDir, buildUpdateStageDirNameForPlatform("windows", "dev", "dev-93dc696"))
if got != want {
t.Fatalf("expected windows staged dir %q, got %q", want, got)
}
}
func TestShouldWindowsUpdateLaunchDownloadedAssetDirectly(t *testing.T) {
cases := []struct {
assetPath string
want bool
}{
{assetPath: `C:\GoNavi\GoNavi-dev-93dc696-Windows-Amd64-Portable.exe`, want: true},
{assetPath: `C:\GoNavi\GoNavi-dev-93dc696-Windows-Amd64-Installer.msi`, want: false},
{assetPath: `C:\GoNavi\GoNavi-0.8.2-Windows-Amd64.zip`, want: false},
{assetPath: "", want: false},
func TestPrepareUpdateWorkspaceAndStagingDirsFallsBackWhenPreferredIsUnavailable(t *testing.T) {
rootDir := t.TempDir()
preferredDir := filepath.Join(rootDir, "unavailable")
if err := os.WriteFile(preferredDir, []byte("not a directory"), 0o644); err != nil {
t.Fatalf("WriteFile preferred path: %v", err)
}
fallbackDir := filepath.Join(rootDir, "GoNavi", "updates", "1.2.3")
workspaceDir, stagedDir, err := prepareUpdateWorkspaceAndStagingDirs(
[]string{preferredDir, fallbackDir},
string(updateChannelLatest),
"1.2.3",
)
if err != nil {
t.Fatalf("prepareUpdateWorkspaceAndStagingDirs returned error: %v", err)
}
if workspaceDir != fallbackDir {
t.Fatalf("workspace = %q, want fallback %q", workspaceDir, fallbackDir)
}
if !isUpdatePathStrictlyInsideDir(stagedDir, fallbackDir) {
t.Fatalf("staging directory %q must be inside fallback workspace %q", stagedDir, fallbackDir)
}
if stat, err := os.Stat(stagedDir); err != nil || !stat.IsDir() {
t.Fatalf("fallback staging directory was not created: stat=%v err=%v", stat, err)
}
}
func TestValidateStagedUpdateWorkspaceAllowsVersionDirectoryUnderTempRoot(t *testing.T) {
workspaceDir := filepath.Join(os.TempDir(), "GoNavi", "updates", "1.2.3")
staged := &stagedUpdate{
Version: "1.2.3",
WorkspaceDir: workspaceDir,
FilePath: filepath.Join(workspaceDir, "GoNavi-1.2.3.dmg"),
StagedDir: filepath.Join(workspaceDir, ".gonavi-update-darwin-latest-1.2.3"),
InstallLogPath: filepath.Join(workspaceDir, "gonavi-update-macos.log"),
}
if err := validateStagedUpdateWorkspace(staged); err != nil {
t.Fatalf("valid update workspace rejected: %v", err)
}
wantCleanupDir := filepath.Join(os.TempDir(), "GoNavi", "updates")
if got := resolveUpdateCleanupDir(staged.WorkspaceDir); got != wantCleanupDir {
t.Fatalf("cleanup directory = %q, want entire updates directory %q", got, wantCleanupDir)
}
}
func TestValidateStagedUpdateWorkspaceRejectsUnsafeCleanupTargets(t *testing.T) {
updateRoot := filepath.Join(os.TempDir(), "GoNavi", "updates")
validWorkspace := filepath.Join(updateRoot, "1.2.3")
newStaged := func(workspaceDir string) *stagedUpdate {
return &stagedUpdate{
Version: "1.2.3",
WorkspaceDir: workspaceDir,
FilePath: filepath.Join(workspaceDir, "GoNavi-1.2.3.dmg"),
StagedDir: filepath.Join(workspaceDir, "stage"),
InstallLogPath: filepath.Join(workspaceDir, "update.log"),
}
}
cases := []struct {
name string
staged *stagedUpdate
}{
{name: "update root itself", staged: newStaged(updateRoot)},
{name: "nested version directory", staged: newStaged(filepath.Join(updateRoot, "nested", "1.2.3"))},
{name: "desktop directory", staged: newStaged(filepath.Join(os.TempDir(), "Desktop", "GoNavi-1.2.3"))},
{name: "empty workspace", staged: newStaged("")},
}
outsidePackage := newStaged(validWorkspace)
outsidePackage.FilePath = filepath.Join(os.TempDir(), "GoNavi-1.2.3.dmg")
cases = append(cases, struct {
name string
staged *stagedUpdate
}{name: "package outside workspace", staged: outsidePackage})
for _, tc := range cases {
if got := shouldWindowsUpdateLaunchDownloadedAssetDirectly(tc.assetPath); got != tc.want {
t.Fatalf("shouldWindowsUpdateLaunchDownloadedAssetDirectly(%q) = %v, want %v", tc.assetPath, got, tc.want)
}
t.Run(tc.name, func(t *testing.T) {
if err := validateStagedUpdateWorkspace(tc.staged); err == nil {
t.Fatalf("unsafe workspace accepted: %#v", tc.staged)
}
})
}
}
@@ -1267,23 +1337,32 @@ func TestExpectedAssetNameForExecutableSupportsLinuxArm64(t *testing.T) {
func TestBuildLinuxScriptPrefersTargetExecutableBasename(t *testing.T) {
script := buildLinuxScript(
"/tmp/GoNavi-0.6.5-Linux-Amd64-WebKit41.tar.gz",
"/tmp/GoNavi/updates/0.6.5/GoNavi-0.6.5-Linux-Amd64-WebKit41.tar.gz",
"/opt/GoNavi/gonavi-build-linux-amd64-webkit41",
"/tmp/.gonavi-update-linux-0.6.5",
"/tmp/GoNavi/updates",
"/tmp/GoNavi/updates/0.6.5/.gonavi-update-linux-0.6.5",
"/tmp/GoNavi/updates/0.6.5/update.log",
12345,
)
mustContain := []string{
`TARGET_NAME="$(basename "$TARGET")"`,
`NEWBIN="$TMPDIR/$TARGET_NAME"`,
`NEWBIN=$(find "$TMPDIR" -type f -name "$TARGET_NAME" | head -n 1)`,
`NEWBIN=$(find "$TMPDIR" -type f -name "GoNavi" | head -n 1)`,
`NEWBIN="$UPDATE_TMP_DIR/$TARGET_NAME"`,
`NEWBIN=$(find "$UPDATE_TMP_DIR" -type f -name "$TARGET_NAME" | head -n 1)`,
`NEWBIN=$(find "$UPDATE_TMP_DIR" -type f -name "GoNavi" | head -n 1)`,
`if ! kill -0 "$NEW_PID" 2>/dev/null; then`,
`exec rm -rf "$UPDATES_DIR"`,
}
for _, want := range mustContain {
if !strings.Contains(script, want) {
t.Fatalf("linux update script missing required token: %s\nscript:\n%s", want, script)
}
}
launchIdx := strings.Index(script, `"$TARGET" >/dev/null 2>&1 &`)
cleanupIdx := strings.Index(script, `exec rm -rf "$UPDATES_DIR"`)
if launchIdx < 0 || cleanupIdx < launchIdx {
t.Fatalf("linux updates cleanup must follow successful relaunch (launch=%d cleanup=%d)\n%s", launchIdx, cleanupIdx, script)
}
}
func TestApplyGitHubAPIRequestHeadersUsesTokenAndVersion(t *testing.T) {

View File

@@ -115,6 +115,7 @@ func TestBuildWindowsLaunchCommandUsesHiddenPowerShellFile(t *testing.T) {
SourcePath: `C:\tmp\GoNavi-0.8.5-Windows-Amd64.exe`,
TargetPath: `C:\GoNavi\GoNavi.exe`,
CurrentTargetPath: `C:\GoNavi\GoNavi.exe`,
UpdatesDir: `C:\tmp\gonavi-update`,
StagedDir: `C:\tmp\gonavi-update`,
LogPath: `C:\tmp\gonavi-update\update.log`,
MaintenanceEventName: `Global\GoNavi-Update-Test`,

View File

@@ -50,6 +50,7 @@ func launchWindowsMSIUpdate(staged *stagedUpdate, targetExe string, pid int, wai
context := windowsMSIUpdateLaunchContext{
SourcePath: staged.FilePath,
TargetPath: strings.TrimSpace(targetExe),
UpdatesDir: resolveUpdateCleanupDir(staged.WorkspaceDir),
StagedDir: staged.StagedDir,
LogPath: staged.InstallLogPath,
MSILogPath: msiLogPath,
@@ -135,6 +136,7 @@ func launchWindowsUpdateWithCleanup(staged *stagedUpdate, targetExe string, pid
SourcePath: staged.FilePath,
TargetPath: finalTargetExe,
CurrentTargetPath: currentTargetExe,
UpdatesDir: resolveUpdateCleanupDir(staged.WorkspaceDir),
StagedDir: staged.StagedDir,
LogPath: staged.InstallLogPath,
MaintenanceEventName: staged.MaintenanceEventName,

View File

@@ -149,12 +149,12 @@ func TestResolveWindowsUpdateFinalTargetPathKeepsFixedExecutablePath(t *testing.
}
}
func TestBuildWindowsPowerShellScriptSchedulesStagedDirectoryCleanupAfterSuccess(t *testing.T) {
func TestBuildWindowsPowerShellScriptSchedulesUpdatesDirectoryCleanupAfterSuccess(t *testing.T) {
script := buildWindowsPowerShellScript()
mustContain := []string{
`Write-UpdateLog 'update finished'`,
`$CleanupCommand = 'Start-Sleep -Seconds 2; Remove-Item -LiteralPath $env:GONAVI_UPDATE_STAGED_DIR`,
`$CleanupCommand = 'Start-Sleep -Seconds 2; Remove-Item -LiteralPath $env:GONAVI_UPDATE_ROOT_DIR`,
`$CleanupWorkingDirectory = [IO.Path]::GetTempPath()`,
`-EncodedCommand`,
}
@@ -166,6 +166,12 @@ func TestBuildWindowsPowerShellScriptSchedulesStagedDirectoryCleanupAfterSuccess
if strings.Contains(script, `cmd.exe`) {
t.Fatalf("PowerShell updater must not route cleanup through cmd.exe\n%s", script)
}
relaunchIdx := strings.Index(script, `$NewProcess = Start-Process -FilePath $Target`)
cleanupIdx := strings.Index(script, `$CleanupCommand = 'Start-Sleep -Seconds 2; Remove-Item -LiteralPath $env:GONAVI_UPDATE_ROOT_DIR`)
failureIdx := strings.LastIndex(script, `} catch {`)
if relaunchIdx < 0 || cleanupIdx < relaunchIdx || failureIdx < cleanupIdx {
t.Fatalf("updates cleanup must be scheduled only on the success path (relaunch=%d cleanup=%d failure=%d)\n%s", relaunchIdx, cleanupIdx, failureIdx, script)
}
}
func TestBuildWindowsPowerShellScriptDoesNotEmbedUnicodeRuntimePaths(t *testing.T) {
@@ -210,8 +216,9 @@ func TestBuildWindowsLaunchCommandPreservesSpecialPathsInEnvironment(t *testing.
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`,
UpdatesDir: `C:\Users\tester\AppData\Local\Temp\GoNavi %TEMP%\updates`,
StagedDir: `C:\Users\tester\AppData\Local\Temp\GoNavi %TEMP%\updates\0.8.5\stage`,
LogPath: `C:\Users\tester\AppData\Local\Temp\GoNavi %TEMP%\updates\0.8.5\stage\update.log`,
MaintenanceEventName: `Global\GoNavi-Update-Test`,
HandoffEventName: `Local\GoNavi-Update-Handoff-Test`,
PID: 12345,
@@ -222,6 +229,7 @@ func TestBuildWindowsLaunchCommandPreservesSpecialPathsInEnvironment(t *testing.
"GONAVI_UPDATE_SOURCE": context.SourcePath,
"GONAVI_UPDATE_TARGET": context.TargetPath,
"GONAVI_UPDATE_CURRENT_TARGET": context.CurrentTargetPath,
"GONAVI_UPDATE_ROOT_DIR": context.UpdatesDir,
"GONAVI_UPDATE_STAGED_DIR": context.StagedDir,
"GONAVI_UPDATE_LOG_PATH": context.LogPath,
"GONAVI_UPDATE_MAINTENANCE_EVENT_NAME": context.MaintenanceEventName,

View File

@@ -60,18 +60,33 @@ func TestExpectedAssetNameForWindowsInstallMode(t *testing.T) {
}
}
func TestResolveUpdateWorkspaceDirForPlatformSeparatesMSIFromInstallDirectory(t *testing.T) {
installTarget := filepath.Join("C:\\Program Files", "GoNavi", "GoNavi.exe")
userCacheDir := filepath.Join("C:\\Users", "tester", "AppData", "Local")
msiDir := resolveUpdateWorkspaceDirForPlatform("windows", "1.2.3", updateInstallModeMSI, installTarget, userCacheDir)
wantMSIDir := filepath.Join(userCacheDir, "GoNavi", "updates")
if msiDir != wantMSIDir {
t.Fatalf("MSI workspace = %q, want %q", msiDir, wantMSIDir)
func TestResolveUpdateWorkspaceDirForPlatformUsesVersionedCacheForEveryPlatform(t *testing.T) {
installTarget := filepath.Join("Users", "tester", "Desktop", "GoNavi.exe")
userCacheDir := filepath.Join("Users", "tester", "cache")
want := filepath.Join(userCacheDir, "GoNavi", "updates", "1.2.3")
cases := []struct {
goos string
installMode updateInstallMode
}{
{goos: "darwin", installMode: updateInstallModePortable},
{goos: "windows", installMode: updateInstallModePortable},
{goos: "windows", installMode: updateInstallModeMSI},
{goos: "linux", installMode: updateInstallModePortable},
}
portableDir := resolveUpdateWorkspaceDirForPlatform("windows", "1.2.3", updateInstallModePortable, installTarget, userCacheDir)
if portableDir != filepath.Dir(installTarget) {
t.Fatalf("portable workspace = %q, want install directory %q", portableDir, filepath.Dir(installTarget))
for _, tc := range cases {
got := resolveUpdateWorkspaceDirForPlatform(tc.goos, "1.2.3", tc.installMode, installTarget, userCacheDir)
if got != want {
t.Fatalf("%s/%s workspace = %q, want %q", tc.goos, tc.installMode, got, want)
}
}
}
func TestResolveUpdateWorkspaceDirForPlatformFallsBackToVersionedTempDirectory(t *testing.T) {
got := resolveUpdateWorkspaceDirForPlatform("linux", "v1.2.3 beta", updateInstallModePortable, "/opt/GoNavi", "")
want := filepath.Join(os.TempDir(), "GoNavi", "updates", "v1.2.3-beta")
if got != want {
t.Fatalf("temporary workspace = %q, want %q", got, want)
}
}

View File

@@ -2,6 +2,7 @@ $ErrorActionPreference = 'Stop'
$Source = $env:GONAVI_UPDATE_SOURCE
$Target = $env:GONAVI_UPDATE_TARGET
$UpdatesDir = $env:GONAVI_UPDATE_ROOT_DIR
$StagedDir = $env:GONAVI_UPDATE_STAGED_DIR
$LogPath = $env:GONAVI_UPDATE_LOG_PATH
$MSILogPath = $env:GONAVI_UPDATE_MSI_LOG_PATH
@@ -73,7 +74,7 @@ function Remove-UpdateArtifact {
}
try {
foreach ($requiredPath in @($Source, $Target, $StagedDir, $LogPath, $MSILogPath, $MSIExecPath, $MaintenanceEventName, $HandoffEventName)) {
foreach ($requiredPath in @($Source, $Target, $UpdatesDir, $StagedDir, $LogPath, $MSILogPath, $MSIExecPath, $MaintenanceEventName, $HandoffEventName)) {
if ([string]::IsNullOrWhiteSpace($requiredPath)) {
throw 'missing required MSI updater path'
}
@@ -167,7 +168,7 @@ try {
Remove-UpdateArtifact $Source
Write-UpdateLog 'MSI update finished'
$CleanupCommand = 'Start-Sleep -Seconds 2; Remove-Item -LiteralPath $env:GONAVI_UPDATE_STAGED_DIR -Recurse -Force -ErrorAction SilentlyContinue'
$CleanupCommand = 'Start-Sleep -Seconds 2; Remove-Item -LiteralPath $env:GONAVI_UPDATE_ROOT_DIR -Recurse -Force -ErrorAction SilentlyContinue'
$EncodedCleanupCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($CleanupCommand))
$CleanupWorkingDirectory = [IO.Path]::GetTempPath()
try {

View File

@@ -16,6 +16,7 @@ var windowsShortcutRepairPowerShellScript string
type windowsMSIUpdateLaunchContext struct {
SourcePath string
TargetPath string
UpdatesDir string
StagedDir string
LogPath string
MSILogPath string
@@ -45,6 +46,7 @@ func buildWindowsMSILaunchCommand(scriptPath string, context windowsMSIUpdateLau
cmd.Env = append(cmd.Environ(),
"GONAVI_UPDATE_SOURCE="+context.SourcePath,
"GONAVI_UPDATE_TARGET="+context.TargetPath,
"GONAVI_UPDATE_ROOT_DIR="+context.UpdatesDir,
"GONAVI_UPDATE_STAGED_DIR="+context.StagedDir,
"GONAVI_UPDATE_LOG_PATH="+context.LogPath,
"GONAVI_UPDATE_MSI_LOG_PATH="+context.MSILogPath,

View File

@@ -34,6 +34,7 @@ func TestBuildWindowsMSIUpdatePowerShellScriptInstallsRelaunchesAndCleans(t *tes
`update maintenance lock could not be released before relaunch`,
`Start-Process -FilePath $Target -WorkingDirectory $TargetDir`,
`Remove-UpdateArtifact $Source`,
`Remove-Item -LiteralPath $env:GONAVI_UPDATE_ROOT_DIR`,
`MSI package retained for manual install`,
`previous application relaunched after MSI failure`,
}
@@ -59,6 +60,11 @@ func TestBuildWindowsMSIUpdatePowerShellScriptInstallsRelaunchesAndCleans(t *tes
if releaseIndex < repairIndex || releaseIndex > relaunchIndex {
t.Fatalf("maintenance lock must be released after install repair and before relaunch\n%s", script)
}
cleanupIndex := strings.Index(script, `$CleanupCommand = 'Start-Sleep -Seconds 2; Remove-Item -LiteralPath $env:GONAVI_UPDATE_ROOT_DIR`)
failureIndex := strings.LastIndex(script, `} catch {`)
if cleanupIndex < relaunchIndex || failureIndex < cleanupIndex {
t.Fatalf("MSI updates cleanup must be scheduled only after relaunch on the success path\n%s", script)
}
for _, r := range script {
if r > 0x7f {
t.Fatalf("MSI updater must remain ASCII-only, found %q", r)
@@ -70,9 +76,10 @@ func TestBuildWindowsMSILaunchCommandPreservesPathsInEnvironment(t *testing.T) {
context := windowsMSIUpdateLaunchContext{
SourcePath: `C:\Users\tester\AppData\Local\GoNavi 100%\GoNavi-Installer.msi`,
TargetPath: `D:\software ! 100% & portable\GoNavi.exe`,
StagedDir: `C:\Users\tester\AppData\Local\GoNavi 100%\stage`,
LogPath: `C:\Users\tester\AppData\Local\GoNavi 100%\stage\update.log`,
MSILogPath: `C:\Users\tester\AppData\Local\GoNavi 100%\stage\msi.log`,
UpdatesDir: `C:\Users\tester\AppData\Local\GoNavi 100%\updates`,
StagedDir: `C:\Users\tester\AppData\Local\GoNavi 100%\updates\1.2.3\stage`,
LogPath: `C:\Users\tester\AppData\Local\GoNavi 100%\updates\1.2.3\stage\update.log`,
MSILogPath: `C:\Users\tester\AppData\Local\GoNavi 100%\updates\1.2.3\stage\msi.log`,
MSIExecPath: `C:\Windows\System32\msiexec.exe`,
MaintenanceEventName: `Global\GoNavi-Update-Test`,
HandoffEventName: `Local\GoNavi-Update-Handoff-Test`,
@@ -99,6 +106,7 @@ func TestBuildWindowsMSILaunchCommandPreservesPathsInEnvironment(t *testing.T) {
want := map[string]string{
"GONAVI_UPDATE_SOURCE": context.SourcePath,
"GONAVI_UPDATE_TARGET": context.TargetPath,
"GONAVI_UPDATE_ROOT_DIR": context.UpdatesDir,
"GONAVI_UPDATE_STAGED_DIR": context.StagedDir,
"GONAVI_UPDATE_LOG_PATH": context.LogPath,
"GONAVI_UPDATE_MSI_LOG_PATH": context.MSILogPath,

View File

@@ -3,6 +3,7 @@ $ErrorActionPreference = 'Stop'
$Source = $env:GONAVI_UPDATE_SOURCE
$Target = $env:GONAVI_UPDATE_TARGET
$CurrentTarget = $env:GONAVI_UPDATE_CURRENT_TARGET
$UpdatesDir = $env:GONAVI_UPDATE_ROOT_DIR
$StagedDir = $env:GONAVI_UPDATE_STAGED_DIR
$LogPath = $env:GONAVI_UPDATE_LOG_PATH
$MaintenanceEventName = $env:GONAVI_UPDATE_MAINTENANCE_EVENT_NAME
@@ -152,7 +153,7 @@ function Select-PortableExecutable {
}
try {
foreach ($requiredPath in @($Source, $Target, $CurrentTarget, $StagedDir, $LogPath, $MaintenanceEventName, $HandoffEventName)) {
foreach ($requiredPath in @($Source, $Target, $CurrentTarget, $UpdatesDir, $StagedDir, $LogPath, $MaintenanceEventName, $HandoffEventName)) {
if ([string]::IsNullOrWhiteSpace($requiredPath)) {
throw 'missing required updater path'
}
@@ -272,7 +273,7 @@ try {
}
Write-UpdateLog 'update finished'
$CleanupCommand = 'Start-Sleep -Seconds 2; Remove-Item -LiteralPath $env:GONAVI_UPDATE_STAGED_DIR -Recurse -Force -ErrorAction SilentlyContinue'
$CleanupCommand = 'Start-Sleep -Seconds 2; Remove-Item -LiteralPath $env:GONAVI_UPDATE_ROOT_DIR -Recurse -Force -ErrorAction SilentlyContinue'
$EncodedCleanupCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($CleanupCommand))
$CleanupWorkingDirectory = [IO.Path]::GetTempPath()
try {

View File

@@ -51,6 +51,7 @@ func TestWindowsPowerShellUpdaterHandlesUnicodeAndShellMetacharacters(t *testing
SourcePath: sourcePath,
TargetPath: targetPath,
CurrentTargetPath: targetPath,
UpdatesDir: stagedDir,
StagedDir: stagedDir,
LogPath: logPath,
MaintenanceEventName: maintenanceName,
@@ -128,6 +129,7 @@ func TestWindowsPowerShellUpdaterRenamesVersionedPortableExecutable(t *testing.T
SourcePath: sourcePath,
TargetPath: targetPath,
CurrentTargetPath: currentTargetPath,
UpdatesDir: stagedDir,
StagedDir: stagedDir,
LogPath: logPath,
MaintenanceEventName: maintenanceName,
@@ -195,6 +197,7 @@ func TestWindowsPowerShellUpdaterSelectsExactTargetFilenameRecursivelyFromZip(t
SourcePath: sourcePath,
TargetPath: targetPath,
CurrentTargetPath: targetPath,
UpdatesDir: stagedDir,
StagedDir: stagedDir,
LogPath: logPath,
MaintenanceEventName: maintenanceName,
@@ -248,6 +251,7 @@ func TestWindowsPowerShellUpdaterRejectsAmbiguousZipAndRetainsPackage(t *testing
SourcePath: sourcePath,
TargetPath: targetPath,
CurrentTargetPath: targetPath,
UpdatesDir: stagedDir,
StagedDir: stagedDir,
LogPath: logPath,
MaintenanceEventName: maintenanceName,

View File

@@ -15,6 +15,7 @@ type windowsUpdateLaunchContext struct {
SourcePath string
TargetPath string
CurrentTargetPath string
UpdatesDir string
StagedDir string
LogPath string
MaintenanceEventName string