Files
a301_launcher/download.go
tolelom 281a365952
Some checks failed
CI/CD / test (push) Has been cancelled
CI/CD / release (push) Has been cancelled
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>
2026-03-29 02:17:51 +09:00

524 lines
15 KiB
Go

package main
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
)
// ── 상수 및 변수 ─────────────────────────────────────────────
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)
}
if len(via) >= 10 {
return fmt.Errorf("리다이렉트 횟수 초과")
}
return nil
}
// apiClient 서버 정보 조회 등 짧은 요청용 (전체 120초 타임아웃).
var apiClient = &http.Client{
Timeout: 120 * time.Second,
CheckRedirect: checkRedirect,
}
// downloadClient 대용량 파일 다운로드용 (전체 타임아웃 없음).
var downloadClient = &http.Client{
Transport: &http.Transport{
TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
IdleConnTimeout: 60 * time.Second,
},
CheckRedirect: checkRedirect,
}
// ── 파일 다운로드 ────────────────────────────────────────────
// doDownloadRequest Range 헤더로 이어받기를 시도한다.
// 서버가 416(범위 불일치)을 반환하면 임시 파일을 삭제하고 처음부터 다시 요청한다.
func doDownloadRequest(downloadURL, tmpPath string) (resp *http.Response, resumeOffset int64, err error) {
for attempt := 0; attempt < 2; attempt++ {
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, 0, 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, 0, fmt.Errorf("다운로드 연결 실패: %w", err)
}
if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable {
resp.Body.Close()
os.Remove(tmpPath)
continue
}
return resp, resumeOffset, nil
}
return nil, 0, fmt.Errorf("다운로드 실패: 재시도 횟수 초과")
}
// 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
if resp.ContentLength > 0 {
total = resumeOffset + resp.ContentLength
}
tmpFile, err = os.OpenFile(tmpPath, os.O_WRONLY|os.O_APPEND, 0644)
case http.StatusOK:
if resp.ContentLength > 0 {
total = resp.ContentLength
}
tmpFile, err = os.Create(tmpPath)
default:
return nil, 0, 0, fmt.Errorf("다운로드 실패 (HTTP %d)", resp.StatusCode)
}
if err != nil {
return nil, 0, 0, fmt.Errorf("임시 파일 열기 실패: %w", err)
}
if total > maxDownloadSize {
tmpFile.Close()
os.Remove(tmpPath)
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
for {
if downloadCancelled.Load() {
tmpFile.Close()
return fmt.Errorf("다운로드가 취소되었습니다")
}
n, readErr := resp.Body.Read(buf)
if n > 0 {
if _, werr := tmpFile.Write(buf[:n]); werr != nil {
tmpFile.Close()
return fmt.Errorf("파일 쓰기 실패: %w", werr)
}
downloaded += int64(n)
if downloaded > maxDownloadSize {
tmpFile.Close()
os.Remove(tmpPath)
return fmt.Errorf("다운로드 크기가 제한을 초과했습니다")
}
// 500ms마다 속도 계산 및 진행률 갱신
now := time.Now()
if now.Sub(lastSpeedUpdate) >= 500*time.Millisecond {
elapsed := now.Sub(lastSpeedUpdate).Seconds()
if elapsed > 0 {
speedBytesPerSec = float64(downloaded-lastBytes) / elapsed
}
lastBytes = downloaded
lastSpeedUpdate = now
}
if total > 0 {
pct := int(downloaded * 100 / total)
if pct > 100 {
pct = 100
}
remaining := float64(total-downloaded) / speedBytesPerSec
setProgress(formatProgress(pct, speedBytesPerSec, remaining), pct)
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
tmpFile.Close()
return fmt.Errorf("다운로드 중 오류: %w", readErr)
}
}
tmpFile.Close()
defer os.Remove(tmpPath)
// zip 추출
setProgress("압축을 해제하는 중...", -1)
tmpExtractDir, err := os.MkdirTemp("", "a301_extract_")
if err != nil {
return fmt.Errorf("임시 추출 디렉토리 생성 실패: %w", err)
}
defer os.RemoveAll(tmpExtractDir)
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 {
return fmt.Errorf("zip 열기 실패: %w", err)
}
defer r.Close()
selfName := strings.ToLower(filepath.Base(os.Args[0]))
for _, f := range r.File {
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)
}
dest := filepath.Join(destDir, filepath.FromSlash(rel))
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
}
if f.FileInfo().IsDir() {
os.MkdirAll(dest, 0755)
continue
}
if err := extractFile(f, dest); err != nil {
return err
}
}
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 {
return err
}
for _, e := range entries {
src := filepath.Join(srcDir, e.Name())
dst := filepath.Join(dstDir, e.Name())
if e.IsDir() {
if err := os.MkdirAll(dst, 0755); err != nil {
return err
}
if err := moveContents(src, dst); err != nil {
return err
}
} else {
// 기존 파일 삭제 후 이동. 삭제 실패 시(파일 잠금 등) 에러 반환.
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)
return err
}
os.Remove(src)
}
}
}
return nil
}
// copyFile src를 dst로 복사한다.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err = io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
// hashFile 파일의 SHA-256 해시를 계산한다.
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
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 {
return fmt.Errorf("게임 파일 확인 실패: %w", err)
} else {
localHash, err := hashFile(gamePath)
if err != nil {
return fmt.Errorf("파일 검증 실패: %w", err)
}
if !strings.EqualFold(localHash, serverInfo.FileHash) {
needsDownload = true
}
}
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 url에서 destPath로 파일을 다운로드한다.
func downloadFile(dlURL, destPath string) error {
resp, err := apiClient.Get(dlURL)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
f, err := os.Create(destPath)
if err != nil {
return err
}
_, err = io.Copy(f, io.LimitReader(resp.Body, maxDownloadSize))
if closeErr := f.Close(); closeErr != nil && err == nil {
err = closeErr
}
return err
}
// ensureLauncher 설치된 런처가 최신인지 확인하고 필요 시 교체한다.
// 항상 설치 경로(%LOCALAPPDATA%\A301\launcher.exe)를 대상으로 한다.
func ensureLauncher(serverInfo *downloadInfo) (updated bool, err error) {
if serverInfo.LauncherHash == "" {
return false, nil
}
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
}
dlURL := serverInfo.LauncherURL
if dlURL == "" {
return false, nil
}
// 새 런처를 .new로 다운로드 → 해시 검증 → 기존 파일과 교체
newPath := installedPath + ".new"
if err := downloadFile(dlURL, newPath); err != nil {
os.Remove(newPath)
return false, fmt.Errorf("런처 업데이트 다운로드 실패: %w", err)
}
newHash, err := hashFile(newPath)
if err != nil {
os.Remove(newPath)
return false, fmt.Errorf("런처 검증 실패: %w", err)
}
if !strings.EqualFold(newHash, serverInfo.LauncherHash) {
os.Remove(newPath)
return false, fmt.Errorf("런처 해시 불일치")
}
// 원자적 교체: 기존→.old, .new→기존
oldPath := installedPath + ".old"
os.Remove(oldPath)
if err := os.Rename(installedPath, oldPath); err != nil {
os.Remove(newPath)
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)
}
return false, fmt.Errorf("런처 교체 실패: %w", err)
}
return true, nil
}
// cleanupOldFiles 이전 런처 업데이트에서 남은 .old/.new 파일을 제거한다.
func cleanupOldFiles(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
name := e.Name()
if strings.HasSuffix(name, ".old") || strings.HasSuffix(name, ".new") {
os.Remove(filepath.Join(dir, name))
}
}
}