refactor: 코드 가독성 개선 및 버그 수정
- main.go를 main()만 남기고 함수 분리 (game.go, protocol.go, ui.go) - 재시도 로직을 retryWithBackoff 공통 함수로 통합 - redeemTicketFrom 별도 HTTP 클라이언트 → apiClient 사용으로 통일 - doDownload에서 resumeOffset 이중 계산 제거 - extractZip에서 stripTopDir/extractFile 함수 분리 - downloadWithProgress에서 createProgressWindow 함수 분리 - DLL 선언을 DLL별로 그룹화, 상수를 역할별로 분리 - 전체 주석 한국어 통일 및 섹션 구분 추가 버그 수정: - ensureLauncher가 설치 경로 대신 실행 중인 경로를 해시하던 문제 수정 - uninstall 시 실행 중인 exe 삭제 실패 → 백그라운드 cmd로 대체 - moveContents에서 os.Remove 에러를 무시하던 문제 수정 - install/uninstall 메시지 통일, exitWithError 헬퍼 추가 - .gitignore에 *.exe 통일, ANALYSIS.md 삭제 - 빌드 명령에 git 태그 기반 버전 주입 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
282
download.go
282
download.go
@@ -15,11 +15,16 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxDownloadSize = 2 << 30 // 2GB
|
||||
const maxExtractFileSize = 4 << 30 // 개별 파일 최대 4GB
|
||||
// ── 상수 및 변수 ─────────────────────────────────────────────
|
||||
|
||||
const maxDownloadSize = 2 << 30 // 2 GB
|
||||
const maxExtractFileSize = 4 << 30 // 개별 파일 최대 4 GB
|
||||
|
||||
var downloadCancelled atomic.Bool
|
||||
|
||||
// ── HTTP 클라이언트 ──────────────────────────────────────────
|
||||
|
||||
// checkRedirect 허용되지 않는 스킴이나 과도한 리다이렉트를 차단한다.
|
||||
var checkRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.Scheme != "https" && req.URL.Scheme != "http" {
|
||||
return fmt.Errorf("허용되지 않는 리다이렉트 스킴: %s", req.URL.Scheme)
|
||||
@@ -30,13 +35,13 @@ var checkRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// apiClient: 서버 정보 조회 등 짧은 요청용 (전체 120초 타임아웃)
|
||||
// apiClient 서버 정보 조회 등 짧은 요청용 (전체 120초 타임아웃).
|
||||
var apiClient = &http.Client{
|
||||
Timeout: 120 * time.Second,
|
||||
CheckRedirect: checkRedirect,
|
||||
}
|
||||
|
||||
// downloadClient: 대용량 파일 다운로드용 (연결 30초 + 유휴 60초, 전체 타임아웃 없음)
|
||||
// downloadClient 대용량 파일 다운로드용 (전체 타임아웃 없음).
|
||||
var downloadClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSHandshakeTimeout: 30 * time.Second,
|
||||
@@ -46,18 +51,20 @@ var downloadClient = &http.Client{
|
||||
CheckRedirect: checkRedirect,
|
||||
}
|
||||
|
||||
// doDownloadRequest sends a GET (with Range if a partial file exists).
|
||||
// If the server replies 416, it deletes the stale temp file and retries once.
|
||||
func doDownloadRequest(downloadURL, tmpPath string) (*http.Response, error) {
|
||||
// ── 파일 다운로드 ────────────────────────────────────────────
|
||||
|
||||
// doDownloadRequest Range 헤더로 이어받기를 시도한다.
|
||||
// 서버가 416(범위 불일치)을 반환하면 임시 파일을 삭제하고 처음부터 다시 요청한다.
|
||||
func doDownloadRequest(downloadURL, tmpPath string) (resp *http.Response, resumeOffset int64, err error) {
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
var resumeOffset int64
|
||||
if fi, err := os.Stat(tmpPath); err == nil {
|
||||
resumeOffset = 0
|
||||
if fi, statErr := os.Stat(tmpPath); statErr == nil {
|
||||
resumeOffset = fi.Size()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("다운로드 요청 생성 실패: %w", err)
|
||||
return nil, 0, fmt.Errorf("다운로드 요청 생성 실패: %w", err)
|
||||
}
|
||||
if resumeOffset > 0 {
|
||||
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeOffset))
|
||||
@@ -65,7 +72,7 @@ func doDownloadRequest(downloadURL, tmpPath string) (*http.Response, error) {
|
||||
|
||||
resp, err := downloadClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("다운로드 연결 실패: %w", err)
|
||||
return nil, 0, fmt.Errorf("다운로드 연결 실패: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable {
|
||||
@@ -73,29 +80,13 @@ func doDownloadRequest(downloadURL, tmpPath string) (*http.Response, error) {
|
||||
os.Remove(tmpPath)
|
||||
continue
|
||||
}
|
||||
return resp, nil
|
||||
return resp, resumeOffset, nil
|
||||
}
|
||||
return nil, fmt.Errorf("다운로드 실패: 재시도 횟수 초과")
|
||||
return nil, 0, fmt.Errorf("다운로드 실패: 재시도 횟수 초과")
|
||||
}
|
||||
|
||||
func doDownload(downloadURL, destDir string) error {
|
||||
tmpPath := filepath.Join(os.TempDir(), "a301_game.zip")
|
||||
|
||||
resp, err := doDownloadRequest(downloadURL, tmpPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var downloaded int64
|
||||
var total int64
|
||||
var tmpFile *os.File
|
||||
|
||||
var resumeOffset int64
|
||||
if fi, statErr := os.Stat(tmpPath); statErr == nil {
|
||||
resumeOffset = fi.Size()
|
||||
}
|
||||
|
||||
// openTmpFile 응답 상태에 따라 임시 파일을 이어쓰기 또는 새로 생성한다.
|
||||
func openTmpFile(resp *http.Response, tmpPath string, resumeOffset int64) (tmpFile *os.File, downloaded, total int64, err error) {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusPartialContent:
|
||||
downloaded = resumeOffset
|
||||
@@ -109,20 +100,48 @@ func doDownload(downloadURL, destDir string) error {
|
||||
}
|
||||
tmpFile, err = os.Create(tmpPath)
|
||||
default:
|
||||
return fmt.Errorf("다운로드 실패 (HTTP %d)", resp.StatusCode)
|
||||
return nil, 0, 0, fmt.Errorf("다운로드 실패 (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("임시 파일 열기 실패: %w", err)
|
||||
return nil, 0, 0, fmt.Errorf("임시 파일 열기 실패: %w", err)
|
||||
}
|
||||
|
||||
if total > maxDownloadSize {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("파일이 너무 큽니다 (%d bytes)", total)
|
||||
return nil, 0, 0, fmt.Errorf("파일이 너무 큽니다 (%d bytes)", total)
|
||||
}
|
||||
return tmpFile, downloaded, total, nil
|
||||
}
|
||||
|
||||
// formatProgress 다운로드 진행률 텍스트를 생성한다.
|
||||
func formatProgress(pct int, speedBytesPerSec float64, remaining float64) string {
|
||||
speedMB := speedBytesPerSec / 1024 / 1024
|
||||
if speedBytesPerSec <= 0 {
|
||||
return fmt.Sprintf("다운로드 중... %d%% (%.1f MB/s)", pct, speedMB)
|
||||
}
|
||||
if remaining < 60 {
|
||||
return fmt.Sprintf("다운로드 중... %d%% (%.1f MB/s, %d초 남음)", pct, speedMB, int(remaining))
|
||||
}
|
||||
return fmt.Sprintf("다운로드 중... %d%% (%.1f MB/s, %d분 남음)", pct, speedMB, int(remaining/60))
|
||||
}
|
||||
|
||||
// doDownload 파일을 다운로드하고 zip을 추출하여 destDir에 배치한다.
|
||||
func doDownload(downloadURL, destDir string) error {
|
||||
tmpPath := filepath.Join(os.TempDir(), "a301_game.zip")
|
||||
|
||||
resp, resumeOffset, err := doDownloadRequest(downloadURL, tmpPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
tmpFile, downloaded, total, err := openTmpFile(resp, tmpPath, resumeOffset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 다운로드 루프
|
||||
buf := make([]byte, 32*1024)
|
||||
|
||||
var lastSpeedUpdate time.Time
|
||||
var lastBytes int64
|
||||
var speedBytesPerSec float64
|
||||
@@ -132,7 +151,8 @@ func doDownload(downloadURL, destDir string) error {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("다운로드가 취소되었습니다")
|
||||
}
|
||||
n, err := resp.Body.Read(buf)
|
||||
|
||||
n, readErr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := tmpFile.Write(buf[:n]); werr != nil {
|
||||
tmpFile.Close()
|
||||
@@ -145,6 +165,7 @@ func doDownload(downloadURL, destDir string) error {
|
||||
return fmt.Errorf("다운로드 크기가 제한을 초과했습니다")
|
||||
}
|
||||
|
||||
// 500ms마다 속도 계산 및 진행률 갱신
|
||||
now := time.Now()
|
||||
if now.Sub(lastSpeedUpdate) >= 500*time.Millisecond {
|
||||
elapsed := now.Sub(lastSpeedUpdate).Seconds()
|
||||
@@ -160,31 +181,22 @@ func doDownload(downloadURL, destDir string) error {
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
|
||||
speedMB := speedBytesPerSec / 1024 / 1024
|
||||
text := fmt.Sprintf("다운로드 중... %d%% (%.1f MB/s)", pct, speedMB)
|
||||
if speedBytesPerSec > 0 {
|
||||
remaining := float64(total-downloaded) / speedBytesPerSec
|
||||
if remaining < 60 {
|
||||
text = fmt.Sprintf("다운로드 중... %d%% (%.1f MB/s, %d초 남음)", pct, speedMB, int(remaining))
|
||||
} else {
|
||||
text = fmt.Sprintf("다운로드 중... %d%% (%.1f MB/s, %d분 남음)", pct, speedMB, int(remaining/60))
|
||||
}
|
||||
}
|
||||
setProgress(text, pct)
|
||||
remaining := float64(total-downloaded) / speedBytesPerSec
|
||||
setProgress(formatProgress(pct, speedBytesPerSec, remaining), pct)
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
if readErr != nil {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("다운로드 중 오류: %w", err)
|
||||
return fmt.Errorf("다운로드 중 오류: %w", readErr)
|
||||
}
|
||||
}
|
||||
tmpFile.Close()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
// zip 추출
|
||||
setProgress("압축을 해제하는 중...", -1)
|
||||
|
||||
tmpExtractDir, err := os.MkdirTemp("", "a301_extract_")
|
||||
@@ -196,13 +208,16 @@ func doDownload(downloadURL, destDir string) error {
|
||||
if err := extractZip(tmpPath, tmpExtractDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := moveContents(tmpExtractDir, destDir); err != nil {
|
||||
return fmt.Errorf("파일 이동 실패: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── zip 추출 ─────────────────────────────────────────────────
|
||||
|
||||
// extractZip zip 파일을 destDir에 추출한다.
|
||||
// zip 내 최상위 디렉토리 1단계를 제거하고, launcher.exe 자신은 덮어쓰기 방지.
|
||||
func extractZip(zipPath, destDir string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
@@ -213,36 +228,29 @@ func extractZip(zipPath, destDir string) error {
|
||||
selfName := strings.ToLower(filepath.Base(os.Args[0]))
|
||||
|
||||
for _, f := range r.File {
|
||||
clean := filepath.ToSlash(f.Name)
|
||||
parts := strings.SplitN(clean, "/", 2)
|
||||
var rel string
|
||||
if len(parts) == 2 && parts[1] != "" {
|
||||
rel = parts[1]
|
||||
} else if len(parts) == 1 && !strings.Contains(clean, "/") && parts[0] != "" {
|
||||
rel = parts[0]
|
||||
} else {
|
||||
rel := stripTopDir(f.Name)
|
||||
if rel == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 보안 검증: 절대 경로, ADS, 경로 탈출(zip slip) 차단
|
||||
if filepath.IsAbs(rel) {
|
||||
return fmt.Errorf("잘못된 zip 경로 (절대 경로): %s", rel)
|
||||
}
|
||||
if strings.Contains(rel, ":") {
|
||||
return fmt.Errorf("잘못된 zip 경로 (ADS): %s", rel)
|
||||
}
|
||||
|
||||
if strings.ToLower(filepath.Base(rel)) == selfName {
|
||||
continue
|
||||
}
|
||||
|
||||
dest := filepath.Join(destDir, filepath.FromSlash(rel))
|
||||
|
||||
cleanDest := filepath.Clean(dest)
|
||||
cleanBase := filepath.Clean(destDir) + string(os.PathSeparator)
|
||||
if !strings.HasPrefix(cleanDest, cleanBase) && cleanDest != filepath.Clean(destDir) {
|
||||
if !strings.HasPrefix(filepath.Clean(dest), filepath.Clean(destDir)+string(os.PathSeparator)) &&
|
||||
filepath.Clean(dest) != filepath.Clean(destDir) {
|
||||
return fmt.Errorf("잘못된 zip 경로: %s", rel)
|
||||
}
|
||||
|
||||
// 자기 자신(launcher.exe)은 덮어쓰지 않음
|
||||
if strings.ToLower(filepath.Base(rel)) == selfName {
|
||||
continue
|
||||
}
|
||||
// 심볼릭 링크는 건너뜀
|
||||
if f.FileInfo().Mode()&os.ModeSymlink != 0 {
|
||||
continue
|
||||
}
|
||||
@@ -252,32 +260,57 @@ func extractZip(zipPath, destDir string) error {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||
if err := extractFile(f, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(out, io.LimitReader(rc, maxExtractFileSize))
|
||||
closeErr := out.Close()
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("파일 닫기 실패: %w", closeErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stripTopDir zip 엔트리에서 최상위 디렉토리를 제거한 상대 경로를 반환한다.
|
||||
// 최상위 디렉토리 자체거나 빈 경로면 ""을 반환.
|
||||
func stripTopDir(name string) string {
|
||||
clean := filepath.ToSlash(name)
|
||||
parts := strings.SplitN(clean, "/", 2)
|
||||
if len(parts) == 2 && parts[1] != "" {
|
||||
return parts[1]
|
||||
}
|
||||
if len(parts) == 1 && !strings.Contains(clean, "/") && parts[0] != "" {
|
||||
return parts[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractFile 단일 zip 엔트리를 dest 경로에 추출한다.
|
||||
func extractFile(f *zip.File, dest string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(out, io.LimitReader(rc, maxExtractFileSize))
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("파일 닫기 실패: %w", closeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── 파일 유틸리티 ────────────────────────────────────────────
|
||||
|
||||
// moveContents srcDir의 모든 파일/폴더를 dstDir로 이동한다.
|
||||
// Rename 실패 시 복사 후 원본 삭제로 대체한다.
|
||||
func moveContents(srcDir, dstDir string) error {
|
||||
entries, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
@@ -294,7 +327,10 @@ func moveContents(srcDir, dstDir string) error {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
os.Remove(dst)
|
||||
// 기존 파일 삭제 후 이동. 삭제 실패 시(파일 잠금 등) 에러 반환.
|
||||
if err := os.Remove(dst); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("기존 파일 삭제 실패 (%s): %w", e.Name(), err)
|
||||
}
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
os.Remove(dst)
|
||||
@@ -307,6 +343,7 @@ func moveContents(srcDir, dstDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyFile src를 dst로 복사한다.
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
@@ -324,6 +361,7 @@ func copyFile(src, dst string) error {
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
// hashFile 파일의 SHA-256 해시를 계산한다.
|
||||
func hashFile(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
@@ -337,13 +375,15 @@ func hashFile(path string) (string, error) {
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// ── 게임/런처 업데이트 ──────────────────────────────────────
|
||||
|
||||
// ensureGame 게임 파일이 최신인지 확인하고 필요 시 다운로드한다.
|
||||
func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error {
|
||||
if serverInfo.FileHash == "" {
|
||||
return fmt.Errorf("서버에서 유효한 파일 정보를 받지 못했습니다")
|
||||
}
|
||||
|
||||
needsDownload := false
|
||||
|
||||
if _, err := os.Stat(gamePath); os.IsNotExist(err) {
|
||||
needsDownload = true
|
||||
} else if err != nil {
|
||||
@@ -358,33 +398,35 @@ func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error {
|
||||
}
|
||||
}
|
||||
|
||||
if needsDownload {
|
||||
if serverInfo.URL == "" {
|
||||
return fmt.Errorf("다운로드 URL이 없습니다")
|
||||
}
|
||||
u, err := url.Parse(serverInfo.URL)
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
|
||||
return fmt.Errorf("유효하지 않은 다운로드 URL")
|
||||
}
|
||||
if err := downloadWithProgress(serverInfo.URL, gameDir); err != nil {
|
||||
return fmt.Errorf("게임 설치 실패: %w", err)
|
||||
}
|
||||
if serverInfo.FileHash != "" {
|
||||
newHash, err := hashFile(gamePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("다운로드 후 파일 검증 실패: %w", err)
|
||||
}
|
||||
if !strings.EqualFold(newHash, serverInfo.FileHash) {
|
||||
os.Remove(gamePath)
|
||||
return fmt.Errorf("다운로드된 파일의 해시가 일치하지 않습니다 (파일이 손상되었을 수 있습니다)")
|
||||
}
|
||||
}
|
||||
if !needsDownload {
|
||||
return nil
|
||||
}
|
||||
|
||||
// URL 검증 후 다운로드
|
||||
if serverInfo.URL == "" {
|
||||
return fmt.Errorf("다운로드 URL이 없습니다")
|
||||
}
|
||||
u, err := url.Parse(serverInfo.URL)
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
|
||||
return fmt.Errorf("유효하지 않은 다운로드 URL")
|
||||
}
|
||||
if err := downloadWithProgress(serverInfo.URL, gameDir); err != nil {
|
||||
return fmt.Errorf("게임 설치 실패: %w", err)
|
||||
}
|
||||
|
||||
// 다운로드 후 해시 재검증
|
||||
newHash, err := hashFile(gamePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("다운로드 후 파일 검증 실패: %w", err)
|
||||
}
|
||||
if !strings.EqualFold(newHash, serverInfo.FileHash) {
|
||||
os.Remove(gamePath)
|
||||
return fmt.Errorf("다운로드된 파일의 해시가 일치하지 않습니다 (파일이 손상되었을 수 있습니다)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadFile downloads a file from url to destPath using apiClient.
|
||||
// downloadFile url에서 destPath로 파일을 다운로드한다.
|
||||
func downloadFile(dlURL, destPath string) error {
|
||||
resp, err := apiClient.Get(dlURL)
|
||||
if err != nil {
|
||||
@@ -405,22 +447,23 @@ func downloadFile(dlURL, destPath string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ensureLauncher checks if the installed launcher is up-to-date and replaces it if not.
|
||||
// ensureLauncher 설치된 런처가 최신인지 확인하고 필요 시 교체한다.
|
||||
// 항상 설치 경로(%LOCALAPPDATA%\A301\launcher.exe)를 대상으로 한다.
|
||||
func ensureLauncher(serverInfo *downloadInfo) (updated bool, err error) {
|
||||
if serverInfo.LauncherHash == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
installedPath, err := launcherPath()
|
||||
dir, err := installDir()
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
installedPath := filepath.Join(dir, "launcher.exe")
|
||||
|
||||
localHash, err := hashFile(installedPath)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if strings.EqualFold(localHash, serverInfo.LauncherHash) {
|
||||
return false, nil
|
||||
}
|
||||
@@ -430,6 +473,7 @@ func ensureLauncher(serverInfo *downloadInfo) (updated bool, err error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 새 런처를 .new로 다운로드 → 해시 검증 → 기존 파일과 교체
|
||||
newPath := installedPath + ".new"
|
||||
if err := downloadFile(dlURL, newPath); err != nil {
|
||||
os.Remove(newPath)
|
||||
@@ -446,6 +490,7 @@ func ensureLauncher(serverInfo *downloadInfo) (updated bool, err error) {
|
||||
return false, fmt.Errorf("런처 해시 불일치")
|
||||
}
|
||||
|
||||
// 원자적 교체: 기존→.old, .new→기존
|
||||
oldPath := installedPath + ".old"
|
||||
os.Remove(oldPath)
|
||||
if err := os.Rename(installedPath, oldPath); err != nil {
|
||||
@@ -453,6 +498,7 @@ func ensureLauncher(serverInfo *downloadInfo) (updated bool, err error) {
|
||||
return false, fmt.Errorf("런처 교체 실패: %w", err)
|
||||
}
|
||||
if err := os.Rename(newPath, installedPath); err != nil {
|
||||
// 교체 실패 시 복원 시도
|
||||
if restoreErr := os.Rename(oldPath, installedPath); restoreErr != nil {
|
||||
return false, fmt.Errorf("런처 교체 실패 및 복원 불가: %w (원인: %v)", restoreErr, err)
|
||||
}
|
||||
@@ -462,7 +508,7 @@ func ensureLauncher(serverInfo *downloadInfo) (updated bool, err error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// cleanupOldFiles removes .old and .new leftover files from previous launcher updates.
|
||||
// cleanupOldFiles 이전 런처 업데이트에서 남은 .old/.new 파일을 제거한다.
|
||||
func cleanupOldFiles(dir string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user