refactor: 단일 파일을 main/ui/download/protocol 4개 파일로 분리
- main.go: 진입점(main), handleURI, version - ui.go: Win32 UI (progress window, DPI, 폰트, 메시지박스) - download.go: 다운로드/추출 로직 (HTTP client, extractZip, doDownload) - protocol.go: 레지스트리 등록/해제, ensureGame, ensureLauncher, 서버 API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
479
download.go
Normal file
479
download.go
Normal file
@@ -0,0 +1,479 @@
|
||||
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 // 2GB
|
||||
const maxExtractFileSize = 4 << 30 // 개별 파일 최대 4GB
|
||||
|
||||
var downloadCancelled atomic.Bool
|
||||
|
||||
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: 대용량 파일 다운로드용 (연결 30초 + 유휴 60초, 전체 타임아웃 없음)
|
||||
var downloadClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSHandshakeTimeout: 30 * time.Second,
|
||||
ResponseHeaderTimeout: 30 * time.Second,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
},
|
||||
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) {
|
||||
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")
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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 fmt.Errorf("다운로드 실패 (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("임시 파일 열기 실패: %w", err)
|
||||
}
|
||||
|
||||
if total > maxDownloadSize {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("파일이 너무 큽니다 (%d bytes)", total)
|
||||
}
|
||||
|
||||
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, err := 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("다운로드 크기가 제한을 초과했습니다")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("다운로드 중 오류: %w", err)
|
||||
}
|
||||
}
|
||||
tmpFile.Close()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
setProgress("압축을 해제하는 중...", -1)
|
||||
|
||||
tmpExtractDir, err := os.MkdirTemp("", "a301_extract_")
|
||||
if err != nil {
|
||||
return fmt.Errorf("임시 추출 디렉토리 생성 실패: %w", err)
|
||||
}
|
||||
|
||||
if err := extractZip(tmpPath, tmpExtractDir); err != nil {
|
||||
os.RemoveAll(tmpExtractDir)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := moveContents(tmpExtractDir, destDir); err != nil {
|
||||
os.RemoveAll(tmpExtractDir)
|
||||
return fmt.Errorf("파일 이동 실패: %w", err)
|
||||
}
|
||||
os.RemoveAll(tmpExtractDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
|
||||
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) {
|
||||
return fmt.Errorf("잘못된 zip 경로: %s", rel)
|
||||
}
|
||||
|
||||
if f.FileInfo().Mode()&os.ModeSymlink != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(dest, 0755)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); 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
|
||||
}
|
||||
|
||||
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 {
|
||||
os.Remove(dst)
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
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("다운로드된 파일의 해시가 일치하지 않습니다 (파일이 손상되었을 수 있습니다)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadFile downloads a file from url to destPath using apiClient.
|
||||
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 checks if the installed launcher is up-to-date and replaces it if not.
|
||||
func ensureLauncher(serverInfo *downloadInfo) (updated bool, err error) {
|
||||
if serverInfo.LauncherHash == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
installedPath, err := launcherPath()
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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("런처 해시 불일치")
|
||||
}
|
||||
|
||||
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 removes .old and .new leftover files from previous launcher updates.
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user