From 66dc8a14de74a8c6e41a4df10b066e1445caaade Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 18 Mar 2026 10:37:57 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EB=9F=B0=EC=B2=98=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20+=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자동 업데이트: - ensureLauncher(): SHA256 비교 → 다운로드 → 해시 검증 → rename-dance 교체 - 첫 실행 시 확인 대화 제거 → 자동 설치 - handleURI() 진입 시 프로토콜 등록 자동 갱신 - .old/.new 잔여 파일 자동 정리 보안: - DLL Hijacking 방어 (SetDefaultDllDirectories) - ZIP 경로 탈출 강화 (절대경로/NTFS ADS 거부) - UTF16 에러 처리 (msgBox, setProgress) - out.Close() 에러 체크 Co-Authored-By: Claude Opus 4.6 (1M context) --- main.go | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 160 insertions(+), 13 deletions(-) diff --git a/main.go b/main.go index 0fccfef..55028e2 100644 --- a/main.go +++ b/main.go @@ -257,8 +257,14 @@ func initCommonControls() { // ── Win32 helpers ──────────────────────────────────────────────────────────── func msgBox(title, text string, flags uintptr) int { - t, _ := windows.UTF16PtrFromString(title) - m, _ := windows.UTF16PtrFromString(text) + t, err := windows.UTF16PtrFromString(title) + if err != nil { + return 0 + } + m, err := windows.UTF16PtrFromString(text) + if err != nil { + return 0 + } ret, _, _ := messageBoxWProc.Call(0, uintptr(unsafe.Pointer(m)), uintptr(unsafe.Pointer(t)), flags) return int(ret) } @@ -297,8 +303,10 @@ func progressWndProc(hwnd, uMsg, wParam, lParam uintptr) uintptr { func setProgress(text string, pct int) { if text != "" { - t, _ := windows.UTF16PtrFromString(text) - sendMessageWProc.Call(progressLabelHwnd, uintptr(wmSetText), 0, uintptr(unsafe.Pointer(t))) + t, err := windows.UTF16PtrFromString(text) + if err == nil { + sendMessageWProc.Call(progressLabelHwnd, uintptr(wmSetText), 0, uintptr(unsafe.Pointer(t))) + } } if pct >= 0 { sendMessageWProc.Call(progressBarHwnd, uintptr(pbmSetPos), uintptr(pct), 0) @@ -591,6 +599,15 @@ func extractZip(zipPath, destDir string) error { continue } + // 절대 경로 거부 + if filepath.IsAbs(rel) { + return fmt.Errorf("잘못된 zip 경로 (절대 경로): %s", rel) + } + // NTFS ADS 방어: 경로에 ':' 포함 시 거부 + if strings.Contains(rel, ":") { + return fmt.Errorf("잘못된 zip 경로 (ADS): %s", rel) + } + // 런처 파일 건너뜀 if strings.ToLower(filepath.Base(rel)) == selfName { continue @@ -629,11 +646,14 @@ func extractZip(zipPath, destDir string) error { return err } _, err = io.Copy(out, io.LimitReader(rc, maxExtractFileSize)) - out.Close() + closeErr := out.Close() rc.Close() if err != nil { return err } + if closeErr != nil { + return fmt.Errorf("파일 닫기 실패: %w", closeErr) + } } return nil } @@ -696,8 +716,10 @@ func (e *errNoRetry) Error() string { return e.err.Error() } func (e *errNoRetry) Unwrap() error { return e.err } type downloadInfo struct { - FileHash string `json:"fileHash"` - URL string `json:"url"` + FileHash string `json:"fileHash"` + URL string `json:"url"` + LauncherURL string `json:"launcherUrl"` + LauncherHash string `json:"launcherHash"` } func fetchServerInfoOnce() (*downloadInfo, error) { @@ -883,6 +905,112 @@ func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error { return nil } +// ── Launcher self-update ───────────────────────────────────────────────────── + +// 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. +// Since a running exe cannot overwrite itself on Windows, we: +// 1. Download the new launcher to a .new temp file +// 2. Rename the current exe to .old +// 3. Rename .new to the current exe path +// 4. Return updated=true so the caller can re-exec +func ensureLauncher(serverInfo *downloadInfo) (updated bool, err error) { + if serverInfo.LauncherHash == "" { + return false, nil // server doesn't provide launcher hash, skip + } + + installedPath, err := launcherPath() + if err != nil { + return false, nil // can't determine own path, skip + } + + localHash, err := hashFile(installedPath) + if err != nil { + return false, nil + } + + if strings.EqualFold(localHash, serverInfo.LauncherHash) { + return false, nil // already up-to-date + } + + // Determine download URL + dlURL := serverInfo.LauncherURL + if dlURL == "" { + return false, nil // no launcher URL available + } + + // Download new launcher to temp file + newPath := installedPath + ".new" + if err := downloadFile(dlURL, newPath); err != nil { + os.Remove(newPath) + return false, fmt.Errorf("런처 업데이트 다운로드 실패: %w", err) + } + + // Verify downloaded file hash + 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("런처 해시 불일치") + } + + // Replace: current → .old, new → current + oldPath := installedPath + ".old" + os.Remove(oldPath) // remove previous .old if exists + 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 { + // Try to restore + 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 +} + +// ── Cleanup helpers ────────────────────────────────────────────────────────── + +// 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)) + } + } +} + // ── URI handler ────────────────────────────────────────────────────────────── func handleURI(rawURI string) error { @@ -913,11 +1041,29 @@ func handleURI(rawURI string) error { } gamePath := filepath.Join(gameDir, gameExeName) + // 프로토콜 등록이 현재 런처를 가리키도록 갱신 (사일런트) + _ = install() + + // 이전 업데이트에서 남은 .old/.new 파일 정리 + cleanupOldFiles(gameDir) + serverInfo, err := fetchServerInfo() if err != nil { return fmt.Errorf("버전 확인 실패: %w", err) } + // 런처 자동 업데이트 체크 + if updated, updateErr := ensureLauncher(serverInfo); updateErr != nil { + _ = updateErr // 업데이트 실패는 치명적이지 않음 + } else if updated { + // 새 런처로 재실행 + cmd := exec.Command(os.Args[0], os.Args[1:]...) + if err := cmd.Start(); err != nil { + return fmt.Errorf("새 런처 시작 실패: %w", err) + } + os.Exit(0) + } + if err := ensureGame(gameDir, gamePath, serverInfo); err != nil { return err } @@ -952,6 +1098,11 @@ func acquireSingleInstance() bool { } func main() { + // DLL Hijacking 방어: 시스템 디렉토리에서만 DLL 로드 + // SetDefaultDllDirectories는 Windows 8+ (KB2533623)에서 사용 가능 + const loadLibrarySearchSystem32 = 0x00000800 + kernel32.NewProc("SetDefaultDllDirectories").Call(loadLibrarySearchSystem32) + enableDPIAwareness() if !acquireSingleInstance() { @@ -960,15 +1111,11 @@ func main() { } if len(os.Args) < 2 { - ret := msgBox("One of the plans 런처", "게임 실행을 위해 프로토콜을 등록합니다.\n계속하시겠습니까?", mbYesNo|mbQ) - if ret != idYes { - return - } if err := install(); err != nil { - msgBox("One of the plans 런처 - 오류", fmt.Sprintf("등록 실패:\n%v", err), mbOK|mbError) + msgBox("One of the plans 런처 - 오류", fmt.Sprintf("설치 실패:\n%v", err), mbOK|mbError) os.Exit(1) } - msgBox("One of the plans 런처", "a301:// 프로토콜이 등록되었습니다.\n이제 웹에서 게임 시작 버튼을 사용할 수 있습니다.", mbOK|mbInfo) + msgBox("One of the plans", "설치가 완료되었습니다.\n웹에서 게임 시작 버튼을 클릭하세요.", mbOK|mbInfo) return } From a8d9ab9d369586d2707e053fdc9a81327f10d32e Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 18 Mar 2026 10:51:18 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EC=98=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EB=93=9C=20+=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20UX=20+=20=EC=96=B8=EC=9D=B8=EC=8A=A4?= =?UTF-8?q?=ED=86=A8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 오프라인 모드: - 서버 미응답 시 설치된 게임 직접 실행 옵션 다운로드 UX: - 속도(MB/s) + 남은 시간 표시 (초/분) 언인스톨: - 게임 데이터 삭제 여부 사용자 선택 - --version 플래그 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- main.go | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/main.go b/main.go index 55028e2..24bdba5 100644 --- a/main.go +++ b/main.go @@ -515,6 +515,10 @@ func doDownload(downloadURL, destDir string) error { buf := make([]byte, 32*1024) + var lastSpeedUpdate time.Time + var lastBytes int64 + var speedBytesPerSec float64 + for { if downloadCancelled.Load() { tmpFile.Close() @@ -533,12 +537,34 @@ func doDownload(downloadURL, destDir string) error { 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 } - setProgress(fmt.Sprintf("다운로드 중... %d%%", pct), pct) + + 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 { @@ -853,10 +879,6 @@ func uninstall() error { return err } } - // 설치 디렉토리 삭제 (자기 자신은 실행 중이라 삭제 실패할 수 있음 — 무시) - if dir, err := installDir(); err == nil { - os.RemoveAll(dir) - } return nil } @@ -1049,6 +1071,20 @@ func handleURI(rawURI string) error { serverInfo, err := fetchServerInfo() if err != nil { + // 오프라인 모드: 게임이 이미 설치되어 있으면 직접 실행 + if _, statErr := os.Stat(gamePath); statErr == nil { + ret := msgBox("One of the plans", "서버에 연결할 수 없습니다.\n설치된 게임을 실행하시겠습니까?\n(업데이트 확인 불가)", mbYesNo|mbQ) + if ret == idYes { + cmd := exec.Command(gamePath) + cmd.Dir = gameDir + cmd.Env = append(os.Environ(), "A301_TOKEN="+token) + if err := cmd.Start(); err != nil { + return fmt.Errorf("게임 실행 실패: %w", err) + } + return nil + } + return fmt.Errorf("사용자가 취소했습니다") + } return fmt.Errorf("버전 확인 실패: %w", err) } @@ -1129,12 +1165,22 @@ func main() { msgBox("One of the plans 런처", "a301:// 프로토콜이 등록되었습니다.", mbOK|mbInfo) case arg == "uninstall": + ret := msgBox("One of the plans 런처", "게임 데이터도 함께 삭제하시겠습니까?", mbYesNo|mbQ) + deleteData := ret == idYes if err := uninstall(); err != nil { msgBox("One of the plans 런처 - 오류", fmt.Sprintf("제거 실패:\n%v", err), mbOK|mbError) os.Exit(1) } + if deleteData { + if dir, err := installDir(); err == nil { + os.RemoveAll(dir) + } + } msgBox("One of the plans 런처", "a301:// 프로토콜이 제거되었습니다.", mbOK|mbInfo) + case arg == "--version" || arg == "version": + msgBox("One of the plans 런처", "버전: 1.0.0", mbOK|mbInfo) + case strings.HasPrefix(arg, protocolName+"://"): if err := handleURI(arg); err != nil { msgBox("One of the plans 런처 - 오류", fmt.Sprintf("실행 실패:\n%v", err), mbOK|mbError) From 13b44b04a2119176a5305e65dc042e2a4dae0df9 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 18 Mar 2026 10:57:20 +0900 Subject: [PATCH 3/8] =?UTF-8?q?ci:=20GitHub=20Actions=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows Go 빌드 자동화 (push/PR on main) Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..baaec88 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + # Windows runner required for Win32 API dependencies + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Build + run: go build -ldflags="-H windowsgui -s -w" -o launcher.exe . From 19b4d4895fa8a227d10259ae49be8c1f7fa30c4e Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 18 Mar 2026 16:42:43 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20ticket=20redeem=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20+=20=ED=86=A0=ED=81=B0=20=EC=9D=B4=EC=A4=91=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=20+=20=EB=B3=B4=EC=95=88/=ED=92=88=EC=A7=88?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - launch ticket을 서버에서 redeem하여 새 JWT 획득 (토큰 수명 문제 해결) - 게임에 커맨드라인 -token + 환경변수 A301_TOKEN 이중 전달 - fileHash 빈 문자열 이중 방어 (변조된 게임 실행 차단) - Win32 API 반환값 검증 (RegisterClassEx, CreateWindowEx) - 버전 ldflags 주입 지원 (var version = "dev") Co-Authored-By: Claude Opus 4.6 (1M context) --- main.go | 71 ++++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/main.go b/main.go index 24bdba5..4278de0 100644 --- a/main.go +++ b/main.go @@ -24,10 +24,14 @@ import ( "golang.org/x/sys/windows/registry" ) +// version is set at build time via -ldflags "-X main.version=x.y.z" +var version = "dev" + const ( - protocolName = "a301" - gameExeName = "A301.exe" - serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info" + protocolName = "a301" + gameExeName = "A301.exe" + serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info" + redeemTicketURL = "https://a301.api.tolelom.xyz/api/auth/redeem-ticket" ) const maxDownloadSize = 2 << 30 // 2GB @@ -241,6 +245,7 @@ func createUIFont(pointSize int, dpi uint32, bold bool) uintptr { face, _ := windows.UTF16FromString("Segoe UI") copy(lf.lfFaceName[:], face) font, _, _ := createFontIndirectWProc.Call(uintptr(unsafe.Pointer(&lf))) + // font가 0이면 시스템 기본 폰트가 사용됨 (WM_SETFONT에 0 전달 시 기본값) return font } @@ -337,7 +342,10 @@ func downloadWithProgress(downloadURL, destDir string) error { lpszClassName: className, hbrBackground: hBrushBg, } - registerClassExWProc.Call(uintptr(unsafe.Pointer(&wc))) + atom, _, _ := registerClassExWProc.Call(uintptr(unsafe.Pointer(&wc))) + if atom == 0 { + return fmt.Errorf("윈도우 클래스 등록 실패") + } screenW, _, _ := getSystemMetricsProc.Call(smCxScreen) screenH, _, _ := getSystemMetricsProc.Call(smCyScreen) @@ -356,6 +364,9 @@ func downloadWithProgress(downloadURL, destDir string) error { x, y, winW, winH, 0, 0, hInstance, 0, ) + if hwnd == 0 { + return fmt.Errorf("다운로드 창 생성 실패") + } titleFont := createUIFont(13, dpi, true) defer deleteObjectProc.Call(titleFont) @@ -788,6 +799,34 @@ func fetchServerInfo() (*downloadInfo, error) { return nil, fmt.Errorf("서버 연결 실패 (%d회 재시도): %w", maxRetries, lastErr) } +// redeemTicket exchanges a one-time launch ticket for a fresh JWT access token. +// The ticket has a 30-second TTL on the server and can only be used once. +// 재시도 불필요 — ticket은 일회용이므로 한 번 사용(또는 만료)되면 소멸. +func redeemTicket(ticket string) (string, error) { + client := &http.Client{Timeout: 10 * time.Second} + body := fmt.Sprintf(`{"ticket":"%s"}`, ticket) + resp, err := client.Post(redeemTicketURL, "application/json", strings.NewReader(body)) + if err != nil { + return "", fmt.Errorf("서버에 연결할 수 없습니다: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("런처 인증에 실패했습니다 (HTTP %d)", resp.StatusCode) + } + + var result struct { + Token string `json:"token"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil { + return "", fmt.Errorf("서버 응답을 처리할 수 없습니다: %w", err) + } + if result.Token == "" { + return "", fmt.Errorf("서버가 토큰을 반환하지 않았습니다") + } + return result.Token, nil +} + func hashFile(path string) (string, error) { f, err := os.Open(path) if err != nil { @@ -885,13 +924,17 @@ func uninstall() error { // ── Game update check + download ───────────────────────────────────────────── 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 if serverInfo.FileHash != "" { + } else { localHash, err := hashFile(gamePath) if err != nil { return fmt.Errorf("파일 검증 실패: %w", err) @@ -1045,13 +1088,19 @@ func handleURI(rawURI string) error { return fmt.Errorf("지원하지 않는 URI 스킴: %s", parsed.Scheme) } - token := parsed.Query().Get("token") - if token == "" { + // 웹 클라이언트가 발급한 일회용 티켓을 서버에서 JWT로 교환 + ticket := parsed.Query().Get("token") + if ticket == "" { return fmt.Errorf("토큰이 없습니다") } + + token, err := redeemTicket(ticket) + if err != nil { + return fmt.Errorf("런처 인증에 실패했습니다: %w", err) + } // JWT는 점(.)으로 구분된 3파트 형식이어야 함 if parts := strings.Split(token, "."); len(parts) != 3 { - return fmt.Errorf("유효하지 않은 토큰 형식입니다") + return fmt.Errorf("서버에서 유효하지 않은 토큰을 받았습니다") } gameDir, err := installDir() @@ -1075,7 +1124,7 @@ func handleURI(rawURI string) error { if _, statErr := os.Stat(gamePath); statErr == nil { ret := msgBox("One of the plans", "서버에 연결할 수 없습니다.\n설치된 게임을 실행하시겠습니까?\n(업데이트 확인 불가)", mbYesNo|mbQ) if ret == idYes { - cmd := exec.Command(gamePath) + cmd := exec.Command(gamePath, "-token", token) cmd.Dir = gameDir cmd.Env = append(os.Environ(), "A301_TOKEN="+token) if err := cmd.Start(); err != nil { @@ -1104,7 +1153,7 @@ func handleURI(rawURI string) error { return err } - cmd := exec.Command(gamePath) + cmd := exec.Command(gamePath, "-token", token) cmd.Dir = gameDir cmd.Env = append(os.Environ(), "A301_TOKEN="+token) if err := cmd.Start(); err != nil { @@ -1179,7 +1228,7 @@ func main() { msgBox("One of the plans 런처", "a301:// 프로토콜이 제거되었습니다.", mbOK|mbInfo) case arg == "--version" || arg == "version": - msgBox("One of the plans 런처", "버전: 1.0.0", mbOK|mbInfo) + msgBox("One of the plans 런처", fmt.Sprintf("버전: %s", version), mbOK|mbInfo) case strings.HasPrefix(arg, protocolName+"://"): if err := handleURI(arg); err != nil { From 8759587e25b5e62d1a2ab07bd23867fedc3d3eff Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 18 Mar 2026 16:58:53 +0900 Subject: [PATCH 5/8] =?UTF-8?q?test:=20=EB=B3=B4=EC=95=88=20=ED=81=AC?= =?UTF-8?q?=EB=A6=AC=ED=8B=B0=EC=BB=AC=20=ED=95=A8=EC=88=98=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=9B=20=ED=85=8C=EC=8A=A4=ED=8A=B8=2020=EA=B0=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extractZip: ZipSlip/NTFS ADS/Symlink 차단 검증 포함 - hashFile: 정상/빈파일/미존재 케이스 - redeemTicket: httptest 기반 서버 응답 처리 - URI 파싱: 정상/파라미터 누락/잘못된 스킴 Co-Authored-By: Claude Opus 4.6 (1M context) --- main.go | 6 +- main_test.go | 399 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 main_test.go diff --git a/main.go b/main.go index 4278de0..ced4cfe 100644 --- a/main.go +++ b/main.go @@ -803,9 +803,13 @@ func fetchServerInfo() (*downloadInfo, error) { // The ticket has a 30-second TTL on the server and can only be used once. // 재시도 불필요 — ticket은 일회용이므로 한 번 사용(또는 만료)되면 소멸. func redeemTicket(ticket string) (string, error) { + return redeemTicketFrom(redeemTicketURL, ticket) +} + +func redeemTicketFrom(url, ticket string) (string, error) { client := &http.Client{Timeout: 10 * time.Second} body := fmt.Sprintf(`{"ticket":"%s"}`, ticket) - resp, err := client.Post(redeemTicketURL, "application/json", strings.NewReader(body)) + resp, err := client.Post(url, "application/json", strings.NewReader(body)) if err != nil { return "", fmt.Errorf("서버에 연결할 수 없습니다: %w", err) } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..6311e8b --- /dev/null +++ b/main_test.go @@ -0,0 +1,399 @@ +package main + +import ( + "archive/zip" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" +) + +// ── extractZip tests ───────────────────────────────────────────────────────── + +// createTestZip creates a zip file at zipPath with the given entries. +// Each entry is a path → content pair. Directories have empty content and end with "/". +func createTestZip(t *testing.T, zipPath string, entries map[string]string) { + t.Helper() + f, err := os.Create(zipPath) + if err != nil { + t.Fatal(err) + } + w := zip.NewWriter(f) + for name, content := range entries { + fw, err := w.Create(name) + if err != nil { + t.Fatal(err) + } + if content != "" { + if _, err := fw.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } +} + +func TestExtractZip_Normal(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "test.zip") + destDir := filepath.Join(tmpDir, "out") + os.MkdirAll(destDir, 0755) + + // zip 내 최상위 디렉토리 제거 동작 검증 (A301/hello.txt → hello.txt) + createTestZip(t, zipPath, map[string]string{ + "A301/hello.txt": "world", + "A301/sub/nested.txt": "deep", + }) + + if err := extractZip(zipPath, destDir); err != nil { + t.Fatal(err) + } + + // hello.txt가 destDir에 직접 존재해야 함 + content, err := os.ReadFile(filepath.Join(destDir, "hello.txt")) + if err != nil { + t.Fatalf("hello.txt 읽기 실패: %v", err) + } + if string(content) != "world" { + t.Errorf("hello.txt 내용 불일치: got %q, want %q", string(content), "world") + } + + content, err = os.ReadFile(filepath.Join(destDir, "sub", "nested.txt")) + if err != nil { + t.Fatalf("sub/nested.txt 읽기 실패: %v", err) + } + if string(content) != "deep" { + t.Errorf("sub/nested.txt 내용 불일치: got %q, want %q", string(content), "deep") + } +} + +func TestExtractZip_FlatZip(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "flat.zip") + destDir := filepath.Join(tmpDir, "out") + os.MkdirAll(destDir, 0755) + + // 디렉토리 없이 최상위에 직접 파일이 있는 zip + createTestZip(t, zipPath, map[string]string{ + "readme.txt": "flat file", + }) + + if err := extractZip(zipPath, destDir); err != nil { + t.Fatal(err) + } + + content, err := os.ReadFile(filepath.Join(destDir, "readme.txt")) + if err != nil { + t.Fatalf("readme.txt 읽기 실패: %v", err) + } + if string(content) != "flat file" { + t.Errorf("내용 불일치: got %q", string(content)) + } +} + +func TestExtractZip_ZipSlip(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "evil.zip") + destDir := filepath.Join(tmpDir, "out") + os.MkdirAll(destDir, 0755) + + // Zip Slip: 경로 탈출 시도 + f, err := os.Create(zipPath) + if err != nil { + t.Fatal(err) + } + w := zip.NewWriter(f) + // A301/../../../etc/passwd → 최상위 제거 후 ../../etc/passwd + fw, _ := w.Create("A301/../../../etc/passwd") + fw.Write([]byte("evil")) + w.Close() + f.Close() + + err = extractZip(zipPath, destDir) + if err == nil { + t.Fatal("Zip Slip 공격이 차단되지 않음") + } +} + +func TestExtractZip_NTFS_ADS(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "ads.zip") + destDir := filepath.Join(tmpDir, "out") + os.MkdirAll(destDir, 0755) + + // NTFS ADS: 콜론 포함 경로 + createTestZip(t, zipPath, map[string]string{ + "A301/file.txt:hidden": "ads data", + }) + + err := extractZip(zipPath, destDir) + if err == nil { + t.Fatal("NTFS ADS 공격이 차단되지 않음") + } +} + +func TestExtractZip_Empty(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "empty.zip") + destDir := filepath.Join(tmpDir, "out") + os.MkdirAll(destDir, 0755) + + // 빈 zip + createTestZip(t, zipPath, map[string]string{}) + + if err := extractZip(zipPath, destDir); err != nil { + t.Fatalf("빈 zip 처리 실패: %v", err) + } + + // destDir에 아무것도 없어야 함 + entries, _ := os.ReadDir(destDir) + if len(entries) != 0 { + t.Errorf("빈 zip인데 파일이 추출됨: %d개", len(entries)) + } +} + +func TestExtractZip_NestedDirs(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "nested.zip") + destDir := filepath.Join(tmpDir, "out") + os.MkdirAll(destDir, 0755) + + createTestZip(t, zipPath, map[string]string{ + "root/a/b/c/deep.txt": "deep content", + "root/a/b/mid.txt": "mid content", + }) + + if err := extractZip(zipPath, destDir); err != nil { + t.Fatal(err) + } + + content, err := os.ReadFile(filepath.Join(destDir, "a", "b", "c", "deep.txt")) + if err != nil { + t.Fatal(err) + } + if string(content) != "deep content" { + t.Errorf("deep.txt 내용 불일치: got %q", string(content)) + } +} + +func TestExtractZip_AbsolutePath(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "abs.zip") + destDir := filepath.Join(tmpDir, "out") + os.MkdirAll(destDir, 0755) + + f, err := os.Create(zipPath) + if err != nil { + t.Fatal(err) + } + w := zip.NewWriter(f) + // 절대 경로 시도 + fw, _ := w.Create("A301/C:\\Windows\\evil.txt") + fw.Write([]byte("evil")) + w.Close() + f.Close() + + err = extractZip(zipPath, destDir) + // Windows에서 C: 포함은 ADS로도 잡히지만, 절대 경로로도 잡혀야 함 + if err == nil { + t.Fatal("절대 경로 공격이 차단되지 않음") + } +} + +// ── hashFile tests ─────────────────────────────────────────────────────────── + +func TestHashFile_Normal(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "test.bin") + content := []byte("hello world") + os.WriteFile(path, content, 0644) + + got, err := hashFile(path) + if err != nil { + t.Fatal(err) + } + + h := sha256.Sum256(content) + want := hex.EncodeToString(h[:]) + + if got != want { + t.Errorf("해시 불일치: got %s, want %s", got, want) + } +} + +func TestHashFile_Empty(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "empty.bin") + os.WriteFile(path, []byte{}, 0644) + + got, err := hashFile(path) + if err != nil { + t.Fatal(err) + } + + h := sha256.Sum256([]byte{}) + want := hex.EncodeToString(h[:]) + + if got != want { + t.Errorf("빈 파일 해시 불일치: got %s, want %s", got, want) + } +} + +func TestHashFile_NotExist(t *testing.T) { + _, err := hashFile("/nonexistent/path/to/file") + if err == nil { + t.Fatal("존재하지 않는 파일에 에러가 발생하지 않음") + } +} + +// ── redeemTicket tests (httptest) ──────────────────────────────────────────── + +func TestRedeemTicket_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("예상 메서드 POST, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Content-Type이 application/json이 아님") + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"token":"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.abc123"}`) + })) + defer srv.Close() + + token, err := redeemTicketFrom(srv.URL, "test-ticket") + if err != nil { + t.Fatal(err) + } + if token != "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.abc123" { + t.Errorf("토큰 불일치: got %s", token) + } +} + +func TestRedeemTicket_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"error":"invalid ticket"}`) + })) + defer srv.Close() + + _, err := redeemTicketFrom(srv.URL, "bad-ticket") + if err == nil { + t.Fatal("서버 에러 시 에러가 반환되지 않음") + } +} + +func TestRedeemTicket_InvalidJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `not json`) + })) + defer srv.Close() + + _, err := redeemTicketFrom(srv.URL, "ticket") + if err == nil { + t.Fatal("잘못된 JSON에 에러가 반환되지 않음") + } +} + +func TestRedeemTicket_EmptyToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"token":""}`) + })) + defer srv.Close() + + _, err := redeemTicketFrom(srv.URL, "ticket") + if err == nil { + t.Fatal("빈 토큰에 에러가 반환되지 않음") + } +} + +func TestRedeemTicket_Unreachable(t *testing.T) { + // 존재하지 않는 서버 주소 + _, err := redeemTicketFrom("http://127.0.0.1:1", "ticket") + if err == nil { + t.Fatal("연결 불가 시 에러가 반환되지 않음") + } +} + +// ── URL parsing tests ──────────────────────────────────────────────────────── + +func TestParseURI_ValidToken(t *testing.T) { + raw := "a301://launch?token=test-ticket-123" + parsed, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + + if parsed.Scheme != protocolName { + t.Errorf("스킴 불일치: got %s, want %s", parsed.Scheme, protocolName) + } + + token := parsed.Query().Get("token") + if token != "test-ticket-123" { + t.Errorf("토큰 불일치: got %s", token) + } +} + +func TestParseURI_MissingToken(t *testing.T) { + raw := "a301://launch" + parsed, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + + token := parsed.Query().Get("token") + if token != "" { + t.Errorf("토큰이 비어있어야 함: got %s", token) + } +} + +func TestParseURI_WrongScheme(t *testing.T) { + raw := "http://launch?token=xxx" + parsed, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + + if parsed.Scheme == protocolName { + t.Error("잘못된 스킴이 허용됨") + } +} + +func TestParseURI_EncodedToken(t *testing.T) { + // URL 인코딩된 토큰 + raw := "a301://launch?token=abc%2Bdef%3Dghi" + parsed, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + + token := parsed.Query().Get("token") + if token != "abc+def=ghi" { + t.Errorf("URL 디코딩 불일치: got %s, want abc+def=ghi", token) + } +} + +func TestParseURI_MultipleParams(t *testing.T) { + raw := "a301://launch?token=myticket&extra=ignored" + parsed, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + + token := parsed.Query().Get("token") + if token != "myticket" { + t.Errorf("토큰 불일치: got %s", token) + } +} From 84dd2373a4e927b5e30029b878acb8f551f2a4a7 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 18 Mar 2026 17:11:41 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20=EB=8B=A8=EC=9D=BC=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=84=20main/ui/download/protocol=204=EA=B0=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 19 +- download.go | 479 ++++++++++++++++++++++ main.go | 1092 --------------------------------------------------- protocol.go | 181 +++++++++ ui.go | 387 ++++++++++++++++++ 5 files changed, 1059 insertions(+), 1099 deletions(-) create mode 100644 download.go create mode 100644 protocol.go create mode 100644 ui.go diff --git a/CLAUDE.md b/CLAUDE.md index e0bb6d3..c2a8dc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ C:\Users\98kim\sdk\go1.25.1\bin\go.exe build -ldflags="-H windowsgui -s -w" -o l ## Tech Stack -- **Go** 단일 파일 (`main.go`) +- **Go** 4파일 구조 (`main.go`, `ui.go`, `download.go`, `protocol.go`) - **Win32 API** — `user32.dll`, `gdi32.dll`, `comctl32.dll`, `uxtheme.dll`, `shell32.dll` - `golang.org/x/sys/windows` + `windows/registry` @@ -21,13 +21,18 @@ C:\Users\98kim\sdk\go1.25.1\bin\go.exe build -ldflags="-H windowsgui -s -w" -o l ## Architecture -모든 로직이 `main.go` 단일 파일에 있음: +역할별 4파일 구조: -- **`enableDPIAwareness()`** — `SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2)` 호출. `main()` 첫 줄에서 실행. +| 파일 | 담당 | +|---|---| +| `main.go` | 진입점(`main`), 단일 인스턴스, `handleURI`, version | +| `ui.go` | Win32 DLL/proc 선언, WndProc, progress window, DPI, font, msgBox | +| `download.go` | HTTP 클라이언트, 다운로드/추출/해시, `ensureGame`, `ensureLauncher` | +| `protocol.go` | 상수, URI 프로토콜 등록/해제, `redeemTicket`, `fetchServerInfo` | + +주요 함수: - **`downloadWithProgress()`** — Win32 메시지 루프 직접 운영. 반드시 메인 고루틴에서 호출 (`runtime.LockOSThread`). -- **`progressWndProc()`** — `WM_CTLCOLORSTATIC`으로 다크 테마 적용. `hBrushBg` 전역 변수 참조. -- **`setProgress(text, pct)`** — 다운로드 고루틴에서 호출해 레이블 텍스트와 진행 막대 동시 업데이트. -- **`fetchServerInfo()`** — `https://a301.api.tolelom.xyz/api/download/info` 조회. +- **`fetchServerInfo()`** — 3회 재시도 (exponential backoff). - **`ensureGame()`** — `A301.exe` SHA256 해시 비교 후 불일치 시 재다운로드. ## UI Details @@ -50,7 +55,7 @@ protocolName = "a301" // 기술 식별자 - `install()` 시 런처를 `%LOCALAPPDATA%\A301\launcher.exe`로 복사 후 해당 경로를 레지스트리에 등록. - 게임 파일(`A301.exe` 등)도 `%LOCALAPPDATA%\A301\`에 설치됨. - 사용자가 원본 다운로드 파일을 삭제해도 프로토콜 핸들러가 정상 동작. -- 토큰은 명령줄이 아닌 `A301_TOKEN` 환경변수로 게임에 전달. +- 토큰은 커맨드라인(`-token`)과 `A301_TOKEN` 환경변수 양쪽으로 게임에 전달. ## Notes diff --git a/download.go b/download.go new file mode 100644 index 0000000..ccbb0d7 --- /dev/null +++ b/download.go @@ -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)) + } + } +} diff --git a/main.go b/main.go index ced4cfe..fdf5751 100644 --- a/main.go +++ b/main.go @@ -1,1087 +1,17 @@ package main import ( - "archive/zip" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" "fmt" - "io" - "net/http" "net/url" "os" "os/exec" "path/filepath" - "runtime" "strings" - "sync/atomic" - "syscall" - "time" - "unsafe" - - "golang.org/x/sys/windows" - "golang.org/x/sys/windows/registry" ) // version is set at build time via -ldflags "-X main.version=x.y.z" var version = "dev" -const ( - protocolName = "a301" - gameExeName = "A301.exe" - serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info" - redeemTicketURL = "https://a301.api.tolelom.xyz/api/auth/redeem-ticket" -) - -const maxDownloadSize = 2 << 30 // 2GB -const maxExtractFileSize = 4 << 30 // 개별 파일 최대 4GB - -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, -} - -// Win32 constants -const ( - wmDestroy uint32 = 0x0002 - wmClose uint32 = 0x0010 - wmSetFont uint32 = 0x0030 - wmSetText uint32 = 0x000C - wmCtlColorStatic uint32 = 0x0138 - wmAppDone uint32 = 0x8001 - - wsPopup uintptr = 0x80000000 - wsCaption uintptr = 0x00C00000 - wsSysMenu uintptr = 0x00080000 - wsChild uintptr = 0x40000000 - wsVisible uintptr = 0x10000000 - ssCenter uintptr = 0x00000001 - - pbsSmooth uintptr = 0x01 - pbmSetRange32 uint32 = 0x0406 - pbmSetPos uint32 = 0x0402 - pbmSetBarColor uint32 = 0x0409 - pbmSetBkColor uint32 = 0x2001 - - setBkModeTransparent = 1 - - swShow = 5 - smCxScreen = 0 - smCyScreen = 1 - - mbOK uintptr = 0x00000000 - mbInfo uintptr = 0x00000040 - mbError uintptr = 0x00000010 - mbYesNo uintptr = 0x00000004 - mbQ uintptr = 0x00000020 - idYes = 6 - - iccProgressClass uint32 = 0x00000020 -) - -// rgb builds a COLORREF from R, G, B components. -func rgb(r, g, b uint8) uintptr { - return uintptr(r) | (uintptr(g) << 8) | (uintptr(b) << 16) -} - -// 웹사이트 색상과 동일한 팔레트 -var ( - colorBg = rgb(46, 44, 47) // #2E2C2F - colorText = rgb(200, 200, 200) // 밝은 회색 - colorAccent = rgb(186, 205, 176) // #BACDB0 - colorProgressBg = rgb(65, 63, 67) // bg보다 약간 밝은 색 -) - -var ( - user32 = windows.NewLazySystemDLL("user32.dll") - kernel32 = windows.NewLazySystemDLL("kernel32.dll") - gdi32 = windows.NewLazySystemDLL("gdi32.dll") - comctl32 = windows.NewLazySystemDLL("comctl32.dll") - uxtheme = windows.NewLazySystemDLL("uxtheme.dll") - - messageBoxWProc = user32.NewProc("MessageBoxW") - registerClassExWProc = user32.NewProc("RegisterClassExW") - createWindowExWProc = user32.NewProc("CreateWindowExW") - showWindowProc = user32.NewProc("ShowWindow") - updateWindowProc = user32.NewProc("UpdateWindow") - getMessageWProc = user32.NewProc("GetMessageW") - translateMsgProc = user32.NewProc("TranslateMessage") - dispatchMsgWProc = user32.NewProc("DispatchMessageW") - sendMessageWProc = user32.NewProc("SendMessageW") - postMessageWProc = user32.NewProc("PostMessageW") - defWindowProcWProc = user32.NewProc("DefWindowProcW") - destroyWindowProc = user32.NewProc("DestroyWindow") - postQuitMsgProc = user32.NewProc("PostQuitMessage") - getSystemMetricsProc = user32.NewProc("GetSystemMetrics") - getDpiForSystemProc = user32.NewProc("GetDpiForSystem") - setProcessDpiAwarenessContextProc = user32.NewProc("SetProcessDpiAwarenessContext") - findWindowWProc = user32.NewProc("FindWindowW") - setForegroundWindowProc = user32.NewProc("SetForegroundWindow") - createMutexWProc = kernel32.NewProc("CreateMutexW") - getModuleHandleWProc = kernel32.NewProc("GetModuleHandleW") - createFontIndirectWProc = gdi32.NewProc("CreateFontIndirectW") - createSolidBrushProc = gdi32.NewProc("CreateSolidBrush") - setTextColorProc = gdi32.NewProc("SetTextColor") - setBkModeProc = gdi32.NewProc("SetBkMode") - deleteObjectProc = gdi32.NewProc("DeleteObject") - initCommonControlsExProc = comctl32.NewProc("InitCommonControlsEx") - setWindowThemeProc = uxtheme.NewProc("SetWindowTheme") - - wndProcCb uintptr - titleLabelHwnd uintptr - progressLabelHwnd uintptr - progressBarHwnd uintptr - hBrushBg uintptr - downloadCancelled atomic.Bool -) - -type wndClassExW struct { - cbSize uint32 - style uint32 - lpfnWndProc uintptr - cbClsExtra int32 - cbWndExtra int32 - hInstance uintptr - hIcon uintptr - hCursor uintptr - hbrBackground uintptr - lpszMenuName *uint16 - lpszClassName *uint16 - hIconSm uintptr -} - -type msgW struct { - hwnd uintptr - message uint32 - wParam uintptr - lParam uintptr - time uint32 - ptX int32 - ptY int32 -} - -type initCommonControlsExS struct { - dwSize uint32 - dwICC uint32 -} - -type logFontW struct { - lfHeight int32 - lfWidth int32 - lfEscapement int32 - lfOrientation int32 - lfWeight int32 - lfItalic byte - lfUnderline byte - lfStrikeOut byte - lfCharSet byte - lfOutPrecision byte - lfClipPrecision byte - lfQuality byte - lfPitchAndFamily byte - lfFaceName [32]uint16 -} - -func init() { - wndProcCb = syscall.NewCallback(progressWndProc) -} - -// ── DPI helpers ────────────────────────────────────────────────────────────── - -func enableDPIAwareness() { - // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4 (Windows 10 1703+) - setProcessDpiAwarenessContextProc.Call(^uintptr(3)) -} - -func getSystemDPI() uint32 { - dpi, _, _ := getDpiForSystemProc.Call() - if dpi == 0 { - return 96 - } - return uint32(dpi) -} - -// dpiScale scales a base-96-DPI pixel value to the system DPI. -// Uses floating-point to avoid precision loss on non-standard DPI values. -func dpiScale(px int, dpi uint32) uintptr { - return uintptr(int(float64(px)*float64(dpi)/96.0 + 0.5)) -} - -// ── Font helpers ───────────────────────────────────────────────────────────── - -func createUIFont(pointSize int, dpi uint32, bold bool) uintptr { - weight := int32(400) // FW_NORMAL - if bold { - weight = 700 // FW_BOLD - } - lf := logFontW{ - lfHeight: -int32(pointSize) * int32(dpi) / 72, - lfWeight: weight, - lfCharSet: 1, // DEFAULT_CHARSET - lfQuality: 5, // CLEARTYPE_QUALITY - } - face, _ := windows.UTF16FromString("Segoe UI") - copy(lf.lfFaceName[:], face) - font, _, _ := createFontIndirectWProc.Call(uintptr(unsafe.Pointer(&lf))) - // font가 0이면 시스템 기본 폰트가 사용됨 (WM_SETFONT에 0 전달 시 기본값) - return font -} - -// ── Common controls ────────────────────────────────────────────────────────── - -func initCommonControls() { - icc := initCommonControlsExS{ - dwSize: uint32(unsafe.Sizeof(initCommonControlsExS{})), - dwICC: iccProgressClass, - } - initCommonControlsExProc.Call(uintptr(unsafe.Pointer(&icc))) -} - -// ── Win32 helpers ──────────────────────────────────────────────────────────── - -func msgBox(title, text string, flags uintptr) int { - t, err := windows.UTF16PtrFromString(title) - if err != nil { - return 0 - } - m, err := windows.UTF16PtrFromString(text) - if err != nil { - return 0 - } - ret, _, _ := messageBoxWProc.Call(0, uintptr(unsafe.Pointer(m)), uintptr(unsafe.Pointer(t)), flags) - return int(ret) -} - -// ── Progress window ────────────────────────────────────────────────────────── - -func progressWndProc(hwnd, uMsg, wParam, lParam uintptr) uintptr { - switch uint32(uMsg) { - case wmClose: - ret := msgBox("One of the plans 런처", "다운로드를 취소하시겠습니까?", mbYesNo|mbQ) - if ret == idYes { - downloadCancelled.Store(true) - destroyWindowProc.Call(hwnd) - } - return 0 - case wmDestroy: - postQuitMsgProc.Call(0) - return 0 - case wmAppDone: - destroyWindowProc.Call(hwnd) - return 0 - case wmCtlColorStatic: - // 다크 테마: 배경 브러시 + 텍스트 색 지정 - hdc := wParam - setBkModeProc.Call(hdc, setBkModeTransparent) - if lParam == titleLabelHwnd { - setTextColorProc.Call(hdc, colorAccent) - } else { - setTextColorProc.Call(hdc, colorText) - } - return hBrushBg - } - ret, _, _ := defWindowProcWProc.Call(hwnd, uMsg, wParam, lParam) - return ret -} - -func setProgress(text string, pct int) { - if text != "" { - t, err := windows.UTF16PtrFromString(text) - if err == nil { - sendMessageWProc.Call(progressLabelHwnd, uintptr(wmSetText), 0, uintptr(unsafe.Pointer(t))) - } - } - if pct >= 0 { - sendMessageWProc.Call(progressBarHwnd, uintptr(pbmSetPos), uintptr(pct), 0) - } -} - -// downloadWithProgress shows a DPI-aware dark-themed progress window and downloads+extracts the zip. -// Must be called from the main goroutine (Win32 message loop requirement). -func downloadWithProgress(downloadURL, destDir string) error { - runtime.LockOSThread() - defer runtime.UnlockOSThread() - - initCommonControls() - dpi := getSystemDPI() - s := func(px int) uintptr { return dpiScale(px, dpi) } - - // 배경 브러시 생성 (window proc에서도 사용) - hBrushBg, _, _ = createSolidBrushProc.Call(colorBg) - defer deleteObjectProc.Call(hBrushBg) - - hInstance, _, _ := getModuleHandleWProc.Call(0) - - className, _ := windows.UTF16PtrFromString("A301Progress") - wc := wndClassExW{ - cbSize: uint32(unsafe.Sizeof(wndClassExW{})), - lpfnWndProc: wndProcCb, - hInstance: hInstance, - lpszClassName: className, - hbrBackground: hBrushBg, - } - atom, _, _ := registerClassExWProc.Call(uintptr(unsafe.Pointer(&wc))) - if atom == 0 { - return fmt.Errorf("윈도우 클래스 등록 실패") - } - - screenW, _, _ := getSystemMetricsProc.Call(smCxScreen) - screenH, _, _ := getSystemMetricsProc.Call(smCyScreen) - winW := s(440) - winH := s(152) - x := (screenW - winW) / 2 - y := (screenH - winH) / 2 - - // 창 타이틀은 비워서 타이틀바를 최소화 - titleStr, _ := windows.UTF16PtrFromString("One of the plans 런처") - hwnd, _, _ := createWindowExWProc.Call( - 0, - uintptr(unsafe.Pointer(className)), - uintptr(unsafe.Pointer(titleStr)), - wsPopup|wsCaption|wsSysMenu|wsVisible, - x, y, winW, winH, - 0, 0, hInstance, 0, - ) - if hwnd == 0 { - return fmt.Errorf("다운로드 창 생성 실패") - } - - titleFont := createUIFont(13, dpi, true) - defer deleteObjectProc.Call(titleFont) - statusFont := createUIFont(9, dpi, false) - defer deleteObjectProc.Call(statusFont) - - staticClass, _ := windows.UTF16PtrFromString("STATIC") - - // ── "A301" 타이틀 레이블 ── - // 클라이언트 영역 레이아웃 (base 96 DPI): - // y=14 h=28 → "A301" 타이틀 (13pt bold, 강조색) - // y=52 h=20 → 상태 텍스트 (9pt, 밝은 회색) - // y=82 h=18 → 진행 막대 - titleText, _ := windows.UTF16PtrFromString("One of the plans") - titleLabelHwnd, _, _ = createWindowExWProc.Call( - 0, - uintptr(unsafe.Pointer(staticClass)), - uintptr(unsafe.Pointer(titleText)), - wsChild|wsVisible|ssCenter, - s(20), s(14), winW-s(40), s(28), - hwnd, 0, hInstance, 0, - ) - sendMessageWProc.Call(titleLabelHwnd, uintptr(wmSetFont), titleFont, 1) - - // ── 상태 레이블 ── - initText, _ := windows.UTF16PtrFromString("게임 파일을 다운로드하는 중...") - progressLabelHwnd, _, _ = createWindowExWProc.Call( - 0, - uintptr(unsafe.Pointer(staticClass)), - uintptr(unsafe.Pointer(initText)), - wsChild|wsVisible|ssCenter, - s(20), s(52), winW-s(40), s(20), - hwnd, 0, hInstance, 0, - ) - sendMessageWProc.Call(progressLabelHwnd, uintptr(wmSetFont), statusFont, 1) - - // ── 진행 막대 ── - progressClass, _ := windows.UTF16PtrFromString("msctls_progress32") - progressBarHwnd, _, _ = createWindowExWProc.Call( - 0, - uintptr(unsafe.Pointer(progressClass)), - 0, - wsChild|wsVisible|pbsSmooth, - s(20), s(82), winW-s(40), s(18), - hwnd, 0, hInstance, 0, - ) - sendMessageWProc.Call(progressBarHwnd, uintptr(pbmSetRange32), 0, 100) - - // 비주얼 스타일 비활성화 → PBM_SETBARCOLOR/PBM_SETBKCOLOR 적용 가능 - empty, _ := windows.UTF16PtrFromString("") - setWindowThemeProc.Call(progressBarHwnd, uintptr(unsafe.Pointer(empty)), uintptr(unsafe.Pointer(empty))) - sendMessageWProc.Call(progressBarHwnd, uintptr(pbmSetBarColor), 0, colorAccent) - sendMessageWProc.Call(progressBarHwnd, uintptr(pbmSetBkColor), 0, colorProgressBg) - - showWindowProc.Call(hwnd, swShow) - updateWindowProc.Call(hwnd) - - downloadCancelled.Store(false) - - errCh := make(chan error, 1) - go func() { - errCh <- doDownload(downloadURL, destDir) - // 윈도우가 이미 파괴된 경우(취소) PostMessage는 무시됨 - postMessageWProc.Call(hwnd, uintptr(wmAppDone), 0, 0) - }() - - var m msgW - for { - ret, _, _ := getMessageWProc.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0) - if ret == 0 || ret == ^uintptr(0) { - break - } - translateMsgProc.Call(uintptr(unsafe.Pointer(&m))) - dispatchMsgWProc.Call(uintptr(unsafe.Pointer(&m))) - } - - return <-errCh -} - -// 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: - // 서버가 Range 요청 수락 → 이어받기 - 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: - // 서버가 Range 미지원이거나 파일이 변경됨 → 처음부터 - 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 { - // zip 내 최상위 디렉토리 제거 (A301/A301.exe → A301.exe) - 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] != "" { - // 최상위에 직접 있는 파일 (디렉토리 없는 zip) - rel = parts[0] - } else { - // 최상위 디렉토리 자체("A301/") 등 → 스킵 - continue - } - - // 절대 경로 거부 - if filepath.IsAbs(rel) { - return fmt.Errorf("잘못된 zip 경로 (절대 경로): %s", rel) - } - // NTFS ADS 방어: 경로에 ':' 포함 시 거부 - 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)) - - // Zip Slip 방지: 추출 경로가 대상 디렉토리 안인지 검증 - 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) - } - - // Symlink 차단: zip 내 심볼릭 링크를 통한 경로 탈출 방지 - 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) // 기존 파일 제거 (Rename 실패 방지) - 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() -} - -// ── Server info ────────────────────────────────────────────────────────────── - -// errNoRetry wraps errors that should not be retried (e.g. 4xx responses). -type errNoRetry struct { - err error -} - -func (e *errNoRetry) Error() string { return e.err.Error() } -func (e *errNoRetry) Unwrap() error { return e.err } - -type downloadInfo struct { - FileHash string `json:"fileHash"` - URL string `json:"url"` - LauncherURL string `json:"launcherUrl"` - LauncherHash string `json:"launcherHash"` -} - -func fetchServerInfoOnce() (*downloadInfo, error) { - resp, err := apiClient.Get(serverInfoURL) - if err != nil { - return nil, fmt.Errorf("서버 연결 실패: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == 404 { - return nil, &errNoRetry{fmt.Errorf("게임이 아직 준비되지 않았습니다")} - } - if resp.StatusCode >= 400 { - return nil, &errNoRetry{fmt.Errorf("서버 오류 (HTTP %d)", resp.StatusCode)} - } - - var info downloadInfo - if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&info); err != nil { - return nil, fmt.Errorf("서버 응답 파싱 실패: %w", err) - } - return &info, nil -} - -func fetchServerInfo() (*downloadInfo, error) { - const maxRetries = 3 - var lastErr error - for i := range maxRetries { - info, err := fetchServerInfoOnce() - if err == nil { - return info, nil - } - lastErr = err - // 4xx 에러는 재시도해도 의미 없음 - var noRetry *errNoRetry - if errors.As(err, &noRetry) { - return nil, err - } - time.Sleep(time.Duration(1<= 400 { + return nil, &errNoRetry{fmt.Errorf("서버 오류 (HTTP %d)", resp.StatusCode)} + } + + var info downloadInfo + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&info); err != nil { + return nil, fmt.Errorf("서버 응답 파싱 실패: %w", err) + } + return &info, nil +} + +func fetchServerInfo() (*downloadInfo, error) { + const maxRetries = 3 + var lastErr error + for i := range maxRetries { + info, err := fetchServerInfoOnce() + if err == nil { + return info, nil + } + lastErr = err + var noRetry *errNoRetry + if errors.As(err, &noRetry) { + return nil, err + } + time.Sleep(time.Duration(1<= 0 { + sendMessageWProc.Call(progressBarHwnd, uintptr(pbmSetPos), uintptr(pct), 0) + } +} + +// downloadWithProgress shows a DPI-aware dark-themed progress window and downloads+extracts the zip. +// Must be called from the main goroutine (Win32 message loop requirement). +func downloadWithProgress(downloadURL, destDir string) error { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + initCommonControls() + dpi := getSystemDPI() + s := func(px int) uintptr { return dpiScale(px, dpi) } + + hBrushBg, _, _ = createSolidBrushProc.Call(colorBg) + defer deleteObjectProc.Call(hBrushBg) + + hInstance, _, _ := getModuleHandleWProc.Call(0) + + className, _ := windows.UTF16PtrFromString("A301Progress") + wc := wndClassExW{ + cbSize: uint32(unsafe.Sizeof(wndClassExW{})), + lpfnWndProc: wndProcCb, + hInstance: hInstance, + lpszClassName: className, + hbrBackground: hBrushBg, + } + atom, _, _ := registerClassExWProc.Call(uintptr(unsafe.Pointer(&wc))) + if atom == 0 { + return fmt.Errorf("윈도우 클래스 등록 실패") + } + + screenW, _, _ := getSystemMetricsProc.Call(smCxScreen) + screenH, _, _ := getSystemMetricsProc.Call(smCyScreen) + winW := s(440) + winH := s(152) + x := (screenW - winW) / 2 + y := (screenH - winH) / 2 + + titleStr, _ := windows.UTF16PtrFromString("One of the plans 런처") + hwnd, _, _ := createWindowExWProc.Call( + 0, + uintptr(unsafe.Pointer(className)), + uintptr(unsafe.Pointer(titleStr)), + wsPopup|wsCaption|wsSysMenu|wsVisible, + x, y, winW, winH, + 0, 0, hInstance, 0, + ) + if hwnd == 0 { + return fmt.Errorf("다운로드 창 생성 실패") + } + + titleFont := createUIFont(13, dpi, true) + defer deleteObjectProc.Call(titleFont) + statusFont := createUIFont(9, dpi, false) + defer deleteObjectProc.Call(statusFont) + + staticClass, _ := windows.UTF16PtrFromString("STATIC") + + titleText, _ := windows.UTF16PtrFromString("One of the plans") + titleLabelHwnd, _, _ = createWindowExWProc.Call( + 0, + uintptr(unsafe.Pointer(staticClass)), + uintptr(unsafe.Pointer(titleText)), + wsChild|wsVisible|ssCenter, + s(20), s(14), winW-s(40), s(28), + hwnd, 0, hInstance, 0, + ) + sendMessageWProc.Call(titleLabelHwnd, uintptr(wmSetFont), titleFont, 1) + + initText, _ := windows.UTF16PtrFromString("게임 파일을 다운로드하는 중...") + progressLabelHwnd, _, _ = createWindowExWProc.Call( + 0, + uintptr(unsafe.Pointer(staticClass)), + uintptr(unsafe.Pointer(initText)), + wsChild|wsVisible|ssCenter, + s(20), s(52), winW-s(40), s(20), + hwnd, 0, hInstance, 0, + ) + sendMessageWProc.Call(progressLabelHwnd, uintptr(wmSetFont), statusFont, 1) + + progressClass, _ := windows.UTF16PtrFromString("msctls_progress32") + progressBarHwnd, _, _ = createWindowExWProc.Call( + 0, + uintptr(unsafe.Pointer(progressClass)), + 0, + wsChild|wsVisible|pbsSmooth, + s(20), s(82), winW-s(40), s(18), + hwnd, 0, hInstance, 0, + ) + sendMessageWProc.Call(progressBarHwnd, uintptr(pbmSetRange32), 0, 100) + + empty, _ := windows.UTF16PtrFromString("") + setWindowThemeProc.Call(progressBarHwnd, uintptr(unsafe.Pointer(empty)), uintptr(unsafe.Pointer(empty))) + sendMessageWProc.Call(progressBarHwnd, uintptr(pbmSetBarColor), 0, colorAccent) + sendMessageWProc.Call(progressBarHwnd, uintptr(pbmSetBkColor), 0, colorProgressBg) + + showWindowProc.Call(hwnd, swShow) + updateWindowProc.Call(hwnd) + + downloadCancelled.Store(false) + + errCh := make(chan error, 1) + go func() { + errCh <- doDownload(downloadURL, destDir) + postMessageWProc.Call(hwnd, uintptr(wmAppDone), 0, 0) + }() + + var m msgW + for { + ret, _, _ := getMessageWProc.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0) + if ret == 0 || ret == ^uintptr(0) { + break + } + translateMsgProc.Call(uintptr(unsafe.Pointer(&m))) + dispatchMsgWProc.Call(uintptr(unsafe.Pointer(&m))) + } + + return <-errCh +} + +func activateExistingWindow() { + className, _ := windows.UTF16PtrFromString("A301Progress") + hwnd, _, _ := findWindowWProc.Call(uintptr(unsafe.Pointer(className)), 0) + if hwnd != 0 { + setForegroundWindowProc.Call(hwnd) + } +} + +func acquireSingleInstance() bool { + name, _ := windows.UTF16PtrFromString("Global\\A301LauncherMutex") + _, _, err := createMutexWProc.Call(0, 0, uintptr(unsafe.Pointer(name))) + // ERROR_ALREADY_EXISTS = 183 + if errno, ok := err.(syscall.Errno); ok && errno == 183 { + return false + } + return true +} From 1e7aebf6b0a924345b2a24b46fa87ce99454eb6d Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 18 Mar 2026 17:22:01 +0900 Subject: [PATCH 7/8] =?UTF-8?q?ci:=20go=20vet=20+=20go=20test=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20+=20Go=201.25=20=EC=97=85=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20+=20=ED=83=9C=EA=B7=B8=20=EB=A6=B4?= =?UTF-8?q?=EB=A6=AC=EC=A6=88=20=EB=B9=8C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test job: go vet + build + go test ./... (Windows runner) - release job: 'v*' 태그 push 시 launcher.exe를 GitHub Release에 업로드 - Go 1.24 → 1.25 업그레이드 Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 43 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index baaec88..3edf5cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,21 +1,56 @@ -name: CI +name: CI/CD on: push: branches: [main] + tags: ['v*'] pull_request: branches: [main] jobs: - build: - # Windows runner required for Win32 API dependencies + # ── 1. 정적 분석 + 빌드 + 테스트 ────────────────────────────────────────── + test: + # Win32 API 의존성으로 Windows runner 필수 runs-on: windows-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: '1.25' + cache: true + + - name: Vet + run: go vet ./... - name: Build run: go build -ldflags="-H windowsgui -s -w" -o launcher.exe . + + - name: Test + run: go test ./... -v + + # ── 2. 릴리즈 빌드 & GitHub Release 업로드 (태그 push 시만) ─────────────── + release: + needs: test + if: startsWith(github.ref, 'refs/tags/v') + runs-on: windows-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Build release binary + run: | + $VERSION = "${{ github.ref_name }}" + go build -ldflags="-H windowsgui -s -w -X main.version=$VERSION" -o launcher.exe . + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: launcher.exe + generate_release_notes: true From 03345d18b9d18b882c8a573fe88bf5f585e3ca38 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 18 Mar 2026 17:25:31 +0900 Subject: [PATCH 8/8] =?UTF-8?q?ci:=20Gitea=20=ED=99=98=EA=B2=BD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98=20(=EB=A6=B4=EB=A6=AC=EC=A6=88?= =?UTF-8?q?=20GitHub=20Actions=20=E2=86=92=20Gitea=20API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - softprops/action-gh-release 제거 - Gitea REST API (PowerShell)로 릴리즈 생성 + launcher.exe 업로드 Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 51 +++++++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3edf5cf..e96216e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,6 @@ on: jobs: # ── 1. 정적 분석 + 빌드 + 테스트 ────────────────────────────────────────── test: - # Win32 API 의존성으로 Windows runner 필수 runs-on: windows-latest steps: - uses: actions/checkout@v4 @@ -29,13 +28,11 @@ jobs: - name: Test run: go test ./... -v - # ── 2. 릴리즈 빌드 & GitHub Release 업로드 (태그 push 시만) ─────────────── + # ── 2. Gitea Release 생성 + launcher.exe 업로드 (태그 push 시만) ────────── release: needs: test if: startsWith(github.ref, 'refs/tags/v') runs-on: windows-latest - permissions: - contents: write steps: - uses: actions/checkout@v4 @@ -46,11 +43,43 @@ jobs: - name: Build release binary run: | - $VERSION = "${{ github.ref_name }}" - go build -ldflags="-H windowsgui -s -w -X main.version=$VERSION" -o launcher.exe . + $version = "${{ github.ref_name }}" + go build -ldflags="-H windowsgui -s -w -X main.version=$version" -o launcher.exe . - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: launcher.exe - generate_release_notes: true + - name: Create Gitea release & upload launcher.exe + shell: pwsh + run: | + $version = "${{ github.ref_name }}" + $repo = "${{ github.repository }}" + $token = "${{ secrets.GITEA_TOKEN }}" + $baseUrl = "https://git.tolelom.xyz/api/v1" + + $headers = @{ + "Authorization" = "token $token" + "Content-Type" = "application/json" + } + + # 릴리즈 생성 + $body = @{ + tag_name = $version + name = $version + body = "Release $version" + } | ConvertTo-Json -Compress + + $release = Invoke-RestMethod ` + -Uri "$baseUrl/repos/$repo/releases" ` + -Method Post ` + -Headers $headers ` + -Body $body + + # launcher.exe 업로드 + $uploadHeaders = @{ + "Authorization" = "token $token" + "Content-Type" = "application/octet-stream" + } + $fileBytes = [System.IO.File]::ReadAllBytes("${{ github.workspace }}\launcher.exe") + Invoke-RestMethod ` + -Uri "$baseUrl/repos/$repo/releases/$($release.id)/assets?name=launcher.exe" ` + -Method Post ` + -Headers $uploadHeaders ` + -Body $fileBytes