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