refactor: tmpZipName 상수화, formatProgress 속도 미확정 처리 수정
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
114
download.go
114
download.go
@@ -19,6 +19,7 @@ import (
|
|||||||
|
|
||||||
const maxDownloadSize = 2 << 30 // 2 GB
|
const maxDownloadSize = 2 << 30 // 2 GB
|
||||||
const maxExtractFileSize = 4 << 30 // 개별 파일 최대 4 GB
|
const maxExtractFileSize = 4 << 30 // 개별 파일 최대 4 GB
|
||||||
|
const tmpZipName = "a301_game.zip" // 임시 다운로드 파일명
|
||||||
|
|
||||||
var downloadCancelled atomic.Bool
|
var downloadCancelled atomic.Bool
|
||||||
|
|
||||||
@@ -115,10 +116,10 @@ func openTmpFile(resp *http.Response, tmpPath string, resumeOffset int64) (tmpFi
|
|||||||
|
|
||||||
// formatProgress 다운로드 진행률 텍스트를 생성한다.
|
// formatProgress 다운로드 진행률 텍스트를 생성한다.
|
||||||
func formatProgress(pct int, speedBytesPerSec float64, remaining float64) string {
|
func formatProgress(pct int, speedBytesPerSec float64, remaining float64) string {
|
||||||
speedMB := speedBytesPerSec / 1024 / 1024
|
|
||||||
if speedBytesPerSec <= 0 {
|
if speedBytesPerSec <= 0 {
|
||||||
return fmt.Sprintf("다운로드 중... %d%% (%.1f MB/s)", pct, speedMB)
|
return fmt.Sprintf("다운로드 중... %d%%", pct)
|
||||||
}
|
}
|
||||||
|
speedMB := speedBytesPerSec / 1024 / 1024
|
||||||
if remaining < 60 {
|
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))
|
||||||
}
|
}
|
||||||
@@ -127,7 +128,7 @@ func formatProgress(pct int, speedBytesPerSec float64, remaining float64) string
|
|||||||
|
|
||||||
// doDownload 파일을 다운로드하고 zip을 추출하여 destDir에 배치한다.
|
// doDownload 파일을 다운로드하고 zip을 추출하여 destDir에 배치한다.
|
||||||
func doDownload(downloadURL, destDir string) error {
|
func doDownload(downloadURL, destDir string) error {
|
||||||
tmpPath := filepath.Join(os.TempDir(), "a301_game.zip")
|
tmpPath := filepath.Join(os.TempDir(), tmpZipName)
|
||||||
|
|
||||||
resp, resumeOffset, err := doDownloadRequest(downloadURL, tmpPath)
|
resp, resumeOffset, err := doDownloadRequest(downloadURL, tmpPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -194,7 +195,7 @@ func doDownload(downloadURL, destDir string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
tmpFile.Close()
|
tmpFile.Close()
|
||||||
defer os.Remove(tmpPath)
|
// zip 파일은 ensureGame에서 해시 검증 후 삭제하므로 여기서는 삭제하지 않는다.
|
||||||
|
|
||||||
// zip 추출
|
// zip 추출
|
||||||
setProgress("압축을 해제하는 중...", -1)
|
setProgress("압축을 해제하는 중...", -1)
|
||||||
@@ -217,7 +218,7 @@ func doDownload(downloadURL, destDir string) error {
|
|||||||
// ── zip 추출 ─────────────────────────────────────────────────
|
// ── zip 추출 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
// extractZip zip 파일을 destDir에 추출한다.
|
// extractZip zip 파일을 destDir에 추출한다.
|
||||||
// zip 내 최상위 디렉토리 1단계를 제거하고, launcher.exe 자신은 덮어쓰기 방지.
|
// zip에 단일 최상위 래퍼 디렉토리가 있으면 1단계 제거하고, launcher.exe 자신은 덮어쓰기 방지.
|
||||||
func extractZip(zipPath, destDir string) error {
|
func extractZip(zipPath, destDir string) error {
|
||||||
r, err := zip.OpenReader(zipPath)
|
r, err := zip.OpenReader(zipPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -225,10 +226,11 @@ func extractZip(zipPath, destDir string) error {
|
|||||||
}
|
}
|
||||||
defer r.Close()
|
defer r.Close()
|
||||||
|
|
||||||
|
strip := hasWrapperDir(r.File)
|
||||||
selfName := strings.ToLower(filepath.Base(os.Args[0]))
|
selfName := strings.ToLower(filepath.Base(os.Args[0]))
|
||||||
|
|
||||||
for _, f := range r.File {
|
for _, f := range r.File {
|
||||||
rel := stripTopDir(f.Name)
|
rel := resolveZipEntry(f.Name, strip)
|
||||||
if rel == "" {
|
if rel == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -267,18 +269,50 @@ func extractZip(zipPath, destDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripTopDir zip 엔트리에서 최상위 디렉토리를 제거한 상대 경로를 반환한다.
|
// hasWrapperDir zip의 모든 엔트리가 동일한 단일 최상위 디렉토리 아래에 있는지 확인한다.
|
||||||
// 최상위 디렉토리 자체거나 빈 경로면 ""을 반환.
|
// 예: "game/A301.exe", "game/Data/" → true ("game"이 래퍼)
|
||||||
func stripTopDir(name string) string {
|
// "A301.exe", "A301_Data/" → false (래퍼 없음)
|
||||||
|
func hasWrapperDir(files []*zip.File) bool {
|
||||||
|
if len(files) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var wrapper string
|
||||||
|
for _, f := range files {
|
||||||
|
clean := filepath.ToSlash(f.Name)
|
||||||
|
parts := strings.SplitN(clean, "/", 2)
|
||||||
|
top := parts[0]
|
||||||
|
if len(parts) == 1 && !f.FileInfo().IsDir() {
|
||||||
|
// 루트 레벨에 파일이 있으면 래퍼 디렉토리 아님
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if wrapper == "" {
|
||||||
|
wrapper = top
|
||||||
|
} else if top != wrapper {
|
||||||
|
// 최상위에 여러 폴더/파일 → 래퍼 아님
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wrapper != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveZipEntry zip 엔트리의 경로를 반환한다.
|
||||||
|
// strip=true이면 최상위 디렉토리를 제거한다.
|
||||||
|
func resolveZipEntry(name string, strip bool) string {
|
||||||
clean := filepath.ToSlash(name)
|
clean := filepath.ToSlash(name)
|
||||||
|
if !strip {
|
||||||
|
// 래퍼 없음: 디렉토리 엔트리("/")만 빈 문자열로 반환
|
||||||
|
clean = strings.TrimSuffix(clean, "/")
|
||||||
|
if clean == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
// 래퍼 제거
|
||||||
parts := strings.SplitN(clean, "/", 2)
|
parts := strings.SplitN(clean, "/", 2)
|
||||||
if len(parts) == 2 && parts[1] != "" {
|
if len(parts) < 2 || parts[1] == "" {
|
||||||
return parts[1]
|
return "" // 래퍼 디렉토리 자체
|
||||||
}
|
}
|
||||||
if len(parts) == 1 && !strings.Contains(clean, "/") && parts[0] != "" {
|
return parts[1]
|
||||||
return parts[0]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractFile 단일 zip 엔트리를 dest 경로에 추출한다.
|
// extractFile 단일 zip 엔트리를 dest 경로에 추출한다.
|
||||||
@@ -377,29 +411,33 @@ func hashFile(path string) (string, error) {
|
|||||||
|
|
||||||
// ── 게임/런처 업데이트 ──────────────────────────────────────
|
// ── 게임/런처 업데이트 ──────────────────────────────────────
|
||||||
|
|
||||||
|
const hashFileName = ".filehash" // 마지막 설치된 zip 해시를 저장하는 파일
|
||||||
|
|
||||||
|
// readLocalHash 로컬에 저장된 마지막 zip 해시를 읽는다.
|
||||||
|
func readLocalHash(gameDir string) string {
|
||||||
|
data, err := os.ReadFile(filepath.Join(gameDir, hashFileName))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeLocalHash zip 해시를 로컬에 저장한다.
|
||||||
|
func writeLocalHash(gameDir, hash string) {
|
||||||
|
os.WriteFile(filepath.Join(gameDir, hashFileName), []byte(hash), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
// ensureGame 게임 파일이 최신인지 확인하고 필요 시 다운로드한다.
|
// ensureGame 게임 파일이 최신인지 확인하고 필요 시 다운로드한다.
|
||||||
|
// 서버의 fileHash는 zip 파일 전체의 해시이므로, 로컬에 저장된 해시와 비교한다.
|
||||||
func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error {
|
func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error {
|
||||||
if serverInfo.FileHash == "" {
|
if serverInfo.FileHash == "" {
|
||||||
return fmt.Errorf("서버에서 유효한 파일 정보를 받지 못했습니다")
|
return fmt.Errorf("서버에서 유효한 파일 정보를 받지 못했습니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
needsDownload := false
|
// 게임 파일이 없거나 로컬 해시가 서버와 다르면 다운로드 필요
|
||||||
if _, err := os.Stat(gamePath); os.IsNotExist(err) {
|
localHash := readLocalHash(gameDir)
|
||||||
needsDownload = true
|
if _, err := os.Stat(gamePath); err == nil && strings.EqualFold(localHash, serverInfo.FileHash) {
|
||||||
} else if err != nil {
|
return 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 검증 후 다운로드
|
// URL 검증 후 다운로드
|
||||||
@@ -414,15 +452,19 @@ func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error {
|
|||||||
return fmt.Errorf("게임 설치 실패: %w", err)
|
return fmt.Errorf("게임 설치 실패: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다운로드 후 해시 재검증
|
// 다운로드된 zip의 해시를 검증 후 삭제
|
||||||
newHash, err := hashFile(gamePath)
|
tmpPath := filepath.Join(os.TempDir(), tmpZipName)
|
||||||
|
defer os.Remove(tmpPath)
|
||||||
|
|
||||||
|
zipHash, err := hashFile(tmpPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("다운로드 후 파일 검증 실패: %w", err)
|
return fmt.Errorf("다운로드 후 파일 검증 실패: %w", err)
|
||||||
}
|
}
|
||||||
if !strings.EqualFold(newHash, serverInfo.FileHash) {
|
if !strings.EqualFold(zipHash, serverInfo.FileHash) {
|
||||||
os.Remove(gamePath)
|
|
||||||
return fmt.Errorf("다운로드된 파일의 해시가 일치하지 않습니다 (파일이 손상되었을 수 있습니다)")
|
return fmt.Errorf("다운로드된 파일의 해시가 일치하지 않습니다 (파일이 손상되었을 수 있습니다)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writeLocalHash(gameDir, serverInfo.FileHash)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user