From a0face763abc0f9f062e0303e55616cfaf4bcae2 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Thu, 12 Mar 2026 13:08:39 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=C2=B7=ED=8C=8C=EC=9D=BC=20=EC=B2=98=EB=A6=AC=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=ED=95=B8=EB=93=A4=EB=A7=81=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- main.go | 113 ++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 82 insertions(+), 31 deletions(-) diff --git a/main.go b/main.go index 01360bf..090aea0 100644 --- a/main.go +++ b/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<