- 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>
524 lines
15 KiB
Go
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))
|
|
}
|
|
}
|
|
}
|