fix: 다운로드·파일 처리 버그 수정 및 에러 핸들링 강화

- 416 응답 시 무한 재귀를 doDownloadRequest 반복문으로 교체
- copyFile에서 out.Close() 에러를 반환하도록 수정
- ContentLength=-1일 때 잘못된 total 계산 방지
- fetchServerInfo 재시도 로직을 errNoRetry 타입 에러로 교체
- extractZip에서 최상위 디렉토리 엔트리 스킵 처리
- moveContents 크로스 드라이브 복사 후 원본 파일 삭제
- 진행률 100% 초과 클램핑
- install() SetStringValue 에러 체크 추가
- handleURI에서 gameDir 미존재 시 MkdirAll 방어 코드 추가
- ensureGame에서 os.Stat 비정상 에러 명시적 처리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 13:08:39 +09:00
parent 48df55a82e
commit a0face763a

113
main.go
View File

@@ -5,6 +5,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -425,26 +426,44 @@ func downloadWithProgress(downloadURL, destDir string) error {
return <-errCh return <-errCh
} }
// 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) {
for attempt := 0; attempt < 2; attempt++ {
var resumeOffset int64
if fi, err := os.Stat(tmpPath); err == nil {
resumeOffset = fi.Size()
}
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return nil, fmt.Errorf("다운로드 요청 생성 실패: %w", err)
}
if resumeOffset > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeOffset))
}
resp, err := downloadClient.Do(req)
if err != nil {
return nil, fmt.Errorf("다운로드 연결 실패: %w", err)
}
if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable {
resp.Body.Close()
os.Remove(tmpPath)
continue // 임시 파일 삭제 후 처음부터 재시도
}
return resp, nil
}
return nil, fmt.Errorf("다운로드 실패: 재시도 횟수 초과")
}
func doDownload(downloadURL, destDir string) error { func doDownload(downloadURL, destDir string) error {
tmpPath := filepath.Join(os.TempDir(), "a301_game.zip") tmpPath := filepath.Join(os.TempDir(), "a301_game.zip")
// 이어받기: 기존 임시 파일 크기 확인 resp, err := doDownloadRequest(downloadURL, tmpPath)
var resumeOffset int64
if fi, err := os.Stat(tmpPath); err == nil {
resumeOffset = fi.Size()
}
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil { if err != nil {
return fmt.Errorf("다운로드 요청 생성 실패: %w", err) return err
}
if resumeOffset > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeOffset))
}
resp, err := downloadClient.Do(req)
if err != nil {
return fmt.Errorf("다운로드 연결 실패: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -452,20 +471,26 @@ func doDownload(downloadURL, destDir string) error {
var total int64 var total int64
var tmpFile *os.File var tmpFile *os.File
// 이어받기: 기존 임시 파일 크기 확인
var resumeOffset int64
if fi, statErr := os.Stat(tmpPath); statErr == nil {
resumeOffset = fi.Size()
}
switch resp.StatusCode { switch resp.StatusCode {
case http.StatusPartialContent: case http.StatusPartialContent:
// 서버가 Range 요청 수락 → 이어받기 // 서버가 Range 요청 수락 → 이어받기
downloaded = resumeOffset downloaded = resumeOffset
total = resumeOffset + resp.ContentLength if resp.ContentLength > 0 {
total = resumeOffset + resp.ContentLength
}
tmpFile, err = os.OpenFile(tmpPath, os.O_WRONLY|os.O_APPEND, 0644) tmpFile, err = os.OpenFile(tmpPath, os.O_WRONLY|os.O_APPEND, 0644)
case http.StatusOK: case http.StatusOK:
// 서버가 Range 미지원이거나 파일이 변경됨 → 처음부터 // 서버가 Range 미지원이거나 파일이 변경됨 → 처음부터
total = resp.ContentLength if resp.ContentLength > 0 {
total = resp.ContentLength
}
tmpFile, err = os.Create(tmpPath) tmpFile, err = os.Create(tmpPath)
case http.StatusRequestedRangeNotSatisfiable:
// 임시 파일이 서버 파일보다 큼 (서버 파일 변경) → 처음부터
os.Remove(tmpPath)
return doDownload(downloadURL, destDir)
default: default:
return fmt.Errorf("다운로드 실패 (HTTP %d)", resp.StatusCode) return fmt.Errorf("다운로드 실패 (HTTP %d)", resp.StatusCode)
} }
@@ -501,6 +526,9 @@ func doDownload(downloadURL, destDir string) error {
} }
if total > 0 { if total > 0 {
pct := int(downloaded * 100 / total) pct := int(downloaded * 100 / total)
if pct > 100 {
pct = 100
}
setProgress(fmt.Sprintf("다운로드 중... %d%%", pct), pct) setProgress(fmt.Sprintf("다운로드 중... %d%%", pct), pct)
} }
} }
@@ -554,9 +582,11 @@ func extractZip(zipPath, destDir string) error {
var rel string var rel string
if len(parts) == 2 && parts[1] != "" { if len(parts) == 2 && parts[1] != "" {
rel = parts[1] rel = parts[1]
} else if len(parts) == 1 && parts[0] != "" { } else if len(parts) == 1 && !strings.Contains(clean, "/") && parts[0] != "" {
// 최상위에 직접 있는 파일 (디렉토리 없는 zip)
rel = parts[0] rel = parts[0]
} else { } else {
// 최상위 디렉토리 자체("A301/") 등 → 스킵
continue continue
} }
@@ -630,6 +660,7 @@ func moveContents(srcDir, dstDir string) error {
os.Remove(dst) // 실패한 부분 파일 제거 os.Remove(dst) // 실패한 부분 파일 제거
return err return err
} }
os.Remove(src) // 복사 성공 후 원본 삭제
} }
} }
} }
@@ -646,13 +677,23 @@ func copyFile(src, dst string) error {
if err != nil { if err != nil {
return err return err
} }
defer out.Close() if _, err = io.Copy(out, in); err != nil {
_, err = io.Copy(out, in) out.Close()
return err return err
}
return out.Close()
} }
// ── Server info ────────────────────────────────────────────────────────────── // ── Server info ──────────────────────────────────────────────────────────────
// errNoRetry wraps errors that should not be retried (e.g. 4xx responses).
type errNoRetry struct {
err error
}
func (e *errNoRetry) Error() string { return e.err.Error() }
func (e *errNoRetry) Unwrap() error { return e.err }
type downloadInfo struct { type downloadInfo struct {
FileHash string `json:"fileHash"` FileHash string `json:"fileHash"`
URL string `json:"url"` URL string `json:"url"`
@@ -666,10 +707,10 @@ func fetchServerInfoOnce() (*downloadInfo, error) {
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode == 404 { if resp.StatusCode == 404 {
return nil, fmt.Errorf("게임이 아직 준비되지 않았습니다") return nil, &errNoRetry{fmt.Errorf("게임이 아직 준비되지 않았습니다")}
} }
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return nil, fmt.Errorf("서버 오류 (HTTP %d)", resp.StatusCode) return nil, &errNoRetry{fmt.Errorf("서버 오류 (HTTP %d)", resp.StatusCode)}
} }
var info downloadInfo var info downloadInfo
@@ -689,7 +730,8 @@ func fetchServerInfo() (*downloadInfo, error) {
} }
lastErr = err lastErr = err
// 4xx 에러는 재시도해도 의미 없음 // 4xx 에러는 재시도해도 의미 없음
if strings.Contains(err.Error(), "서버 오류") || strings.Contains(err.Error(), "준비되지") { var noRetry *errNoRetry
if errors.As(err, &noRetry) {
return nil, err return nil, err
} }
time.Sleep(time.Duration(1<<i) * time.Second) // 1s, 2s, 4s time.Sleep(time.Duration(1<<i) * time.Second) // 1s, 2s, 4s
@@ -761,8 +803,12 @@ func install() error {
return fmt.Errorf("레지스트리 키 생성 실패: %w", err) return fmt.Errorf("레지스트리 키 생성 실패: %w", err)
} }
defer key.Close() defer key.Close()
key.SetStringValue("", "URL:One of the plans Protocol") if err := key.SetStringValue("", "URL:One of the plans Protocol"); err != nil {
key.SetStringValue("URL Protocol", "") return fmt.Errorf("프로토콜 값 설정 실패: %w", err)
}
if err := key.SetStringValue("URL Protocol", ""); err != nil {
return fmt.Errorf("URL Protocol 값 설정 실패: %w", err)
}
cmdKey, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Classes\`+protocolName+`\shell\open\command`, registry.SET_VALUE) cmdKey, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Classes\`+protocolName+`\shell\open\command`, registry.SET_VALUE)
if err != nil { if err != nil {
@@ -798,6 +844,8 @@ func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error {
if _, err := os.Stat(gamePath); os.IsNotExist(err) { if _, err := os.Stat(gamePath); os.IsNotExist(err) {
needsDownload = true needsDownload = true
} else if err != nil {
return fmt.Errorf("게임 파일 확인 실패: %w", err)
} else if serverInfo.FileHash != "" { } else if serverInfo.FileHash != "" {
localHash, err := hashFile(gamePath) localHash, err := hashFile(gamePath)
if err != nil { if err != nil {
@@ -851,6 +899,9 @@ func handleURI(rawURI string) error {
if err != nil { if err != nil {
return err return err
} }
if err := os.MkdirAll(gameDir, 0755); err != nil {
return fmt.Errorf("게임 디렉토리 생성 실패: %w", err)
}
gamePath := filepath.Join(gameDir, gameExeName) gamePath := filepath.Join(gameDir, gameExeName)
serverInfo, err := fetchServerInfo() serverInfo, err := fetchServerInfo()