diff --git a/download.go b/download.go index 6065e51..2885a3e 100644 --- a/download.go +++ b/download.go @@ -19,6 +19,7 @@ import ( const maxDownloadSize = 2 << 30 // 2 GB const maxExtractFileSize = 4 << 30 // 개별 파일 최대 4 GB +const tmpZipName = "a301_game.zip" // 임시 다운로드 파일명 var downloadCancelled atomic.Bool @@ -115,10 +116,10 @@ func openTmpFile(resp *http.Response, tmpPath string, resumeOffset int64) (tmpFi // 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) + return fmt.Sprintf("다운로드 중... %d%%", pct) } + speedMB := speedBytesPerSec / 1024 / 1024 if remaining < 60 { 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에 배치한다. 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) if err != nil { @@ -194,7 +195,7 @@ func doDownload(downloadURL, destDir string) error { } } tmpFile.Close() - defer os.Remove(tmpPath) + // zip 파일은 ensureGame에서 해시 검증 후 삭제하므로 여기서는 삭제하지 않는다. // zip 추출 setProgress("압축을 해제하는 중...", -1) @@ -217,7 +218,7 @@ func doDownload(downloadURL, destDir string) error { // ── zip 추출 ───────────────────────────────────────────────── // extractZip zip 파일을 destDir에 추출한다. -// zip 내 최상위 디렉토리 1단계를 제거하고, launcher.exe 자신은 덮어쓰기 방지. +// zip에 단일 최상위 래퍼 디렉토리가 있으면 1단계 제거하고, launcher.exe 자신은 덮어쓰기 방지. func extractZip(zipPath, destDir string) error { r, err := zip.OpenReader(zipPath) if err != nil { @@ -225,10 +226,11 @@ func extractZip(zipPath, destDir string) error { } defer r.Close() + strip := hasWrapperDir(r.File) selfName := strings.ToLower(filepath.Base(os.Args[0])) for _, f := range r.File { - rel := stripTopDir(f.Name) + rel := resolveZipEntry(f.Name, strip) if rel == "" { continue } @@ -267,18 +269,50 @@ func extractZip(zipPath, destDir string) error { return nil } -// stripTopDir zip 엔트리에서 최상위 디렉토리를 제거한 상대 경로를 반환한다. -// 최상위 디렉토리 자체거나 빈 경로면 ""을 반환. -func stripTopDir(name string) string { +// hasWrapperDir zip의 모든 엔트리가 동일한 단일 최상위 디렉토리 아래에 있는지 확인한다. +// 예: "game/A301.exe", "game/Data/" → true ("game"이 래퍼) +// "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) + if !strip { + // 래퍼 없음: 디렉토리 엔트리("/")만 빈 문자열로 반환 + clean = strings.TrimSuffix(clean, "/") + if clean == "" { + return "" + } + return clean + } + // 래퍼 제거 parts := strings.SplitN(clean, "/", 2) - if len(parts) == 2 && parts[1] != "" { - return parts[1] + if len(parts) < 2 || parts[1] == "" { + return "" // 래퍼 디렉토리 자체 } - if len(parts) == 1 && !strings.Contains(clean, "/") && parts[0] != "" { - return parts[0] - } - return "" + return parts[1] } // 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 게임 파일이 최신인지 확인하고 필요 시 다운로드한다. +// 서버의 fileHash는 zip 파일 전체의 해시이므로, 로컬에 저장된 해시와 비교한다. 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 + // 게임 파일이 없거나 로컬 해시가 서버와 다르면 다운로드 필요 + localHash := readLocalHash(gameDir) + if _, err := os.Stat(gamePath); err == nil && strings.EqualFold(localHash, serverInfo.FileHash) { + return nil // 게임 파일 존재 + 해시 일치 → 최신 상태 } // URL 검증 후 다운로드 @@ -414,15 +452,19 @@ func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error { return fmt.Errorf("게임 설치 실패: %w", err) } - // 다운로드 후 해시 재검증 - newHash, err := hashFile(gamePath) + // 다운로드된 zip의 해시를 검증 후 삭제 + tmpPath := filepath.Join(os.TempDir(), tmpZipName) + defer os.Remove(tmpPath) + + zipHash, err := hashFile(tmpPath) if err != nil { return fmt.Errorf("다운로드 후 파일 검증 실패: %w", err) } - if !strings.EqualFold(newHash, serverInfo.FileHash) { - os.Remove(gamePath) + if !strings.EqualFold(zipHash, serverInfo.FileHash) { return fmt.Errorf("다운로드된 파일의 해시가 일치하지 않습니다 (파일이 손상되었을 수 있습니다)") } + + writeLocalHash(gameDir, serverInfo.FileHash) return nil }