diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index ef82cae8..8337d6ba 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -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 { diff --git a/internal/app/methods_update_mac_script_test.go b/internal/app/methods_update_mac_script_test.go index bb457beb..a2649b83 100644 --- a/internal/app/methods_update_mac_script_test.go +++ b/internal/app/methods_update_mac_script_test.go @@ -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") { diff --git a/internal/app/methods_update_test.go b/internal/app/methods_update_test.go index 7892d29f..842e6e7f 100644 --- a/internal/app/methods_update_test.go +++ b/internal/app/methods_update_test.go @@ -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) { diff --git a/internal/app/methods_update_windows_script_test.go b/internal/app/methods_update_windows_script_test.go index 456aac9d..6b27a198 100644 --- a/internal/app/methods_update_windows_script_test.go +++ b/internal/app/methods_update_windows_script_test.go @@ -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`, diff --git a/internal/app/update_cleanup.go b/internal/app/update_cleanup.go index bc411af0..85b65ea8 100644 --- a/internal/app/update_cleanup.go +++ b/internal/app/update_cleanup.go @@ -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, diff --git a/internal/app/update_cleanup_test.go b/internal/app/update_cleanup_test.go index 8a645ce3..7838e3e8 100644 --- a/internal/app/update_cleanup_test.go +++ b/internal/app/update_cleanup_test.go @@ -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, diff --git a/internal/app/update_install_mode_test.go b/internal/app/update_install_mode_test.go index e0c72883..450984db 100644 --- a/internal/app/update_install_mode_test.go +++ b/internal/app/update_install_mode_test.go @@ -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) } } diff --git a/internal/app/windows_msi_update.ps1 b/internal/app/windows_msi_update.ps1 index d1a9eb14..6557b64d 100644 --- a/internal/app/windows_msi_update.ps1 +++ b/internal/app/windows_msi_update.ps1 @@ -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 { diff --git a/internal/app/windows_msi_update_script.go b/internal/app/windows_msi_update_script.go index 757a9417..6f920615 100644 --- a/internal/app/windows_msi_update_script.go +++ b/internal/app/windows_msi_update_script.go @@ -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, diff --git a/internal/app/windows_msi_update_script_test.go b/internal/app/windows_msi_update_script_test.go index 09833d9a..11dbbc05 100644 --- a/internal/app/windows_msi_update_script_test.go +++ b/internal/app/windows_msi_update_script_test.go @@ -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, diff --git a/internal/app/windows_update.ps1 b/internal/app/windows_update.ps1 index 5aa2312c..64f4221b 100644 --- a/internal/app/windows_update.ps1 +++ b/internal/app/windows_update.ps1 @@ -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 { diff --git a/internal/app/windows_update_integration_windows_test.go b/internal/app/windows_update_integration_windows_test.go index 837e95c7..9e5c873b 100644 --- a/internal/app/windows_update_integration_windows_test.go +++ b/internal/app/windows_update_integration_windows_test.go @@ -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, diff --git a/internal/app/windows_update_script.go b/internal/app/windows_update_script.go index c00cd425..caa3ebfe 100644 --- a/internal/app/windows_update_script.go +++ b/internal/app/windows_update_script.go @@ -15,6 +15,7 @@ type windowsUpdateLaunchContext struct { SourcePath string TargetPath string CurrentTargetPath string + UpdatesDir string StagedDir string LogPath string MaintenanceEventName string