- 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>
480 lines
12 KiB
Go
480 lines
12 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 // 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))
|
|
}
|
|
}
|
|
}
|