diff --git a/.gitignore b/.gitignore index 8bbdd58..527ca83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Build output -launcher.exe +*.exe # IDE .idea/ diff --git a/ANALYSIS.md b/ANALYSIS.md deleted file mode 100644 index de5c739..0000000 --- a/ANALYSIS.md +++ /dev/null @@ -1,288 +0,0 @@ -# A301 시스템 통합 분석 및 개선 계획 - -> 분석일: 2026-03-06 -> 대상: 런처(Go) / 서버(Go+Fiber) / 웹 클라이언트(React+Vite) - ---- - -## 1. 시스템 전체 흐름 - -``` -[웹 클라이언트] [서버] [런처] [게임 클라이언트] - | | | | - |-- POST /auth/login ----->| | | - |<-- {token, refresh} -----| | | - | | | | - |-- GET /download/info --->| | | - |<-- {url,hash,version} ---| | | - | | | | - |== a301://launch?token= =|========================>| | - | | | | - | |<-- GET /download/info --| | - | |-- {url,hash,version} -->| | - | | |-- SHA256 비교 | - | |<-- GET /download/file --| (불일치시) | - | |-- game.zip ------------>| | - | | |-- 압축 해제 | - | | |-- A301.exe -token xxx ->| - | | | | - | |<--- /internal/chain ----|-- 게임 내 API 호출 ---->| -``` - ---- - -## 2. 발견된 버그 및 문제점 - -### ~~2.1 [RESOLVED] 런처: Zip Slip 경로 검증 로직~~ - -> 재검증 결과 로직이 정상임. `!A && B`는 De Morgan 법칙에 의해 "descendant이거나 self"일 때만 허용하는 올바른 조건. - ---- - -### ~~2.2 [RESOLVED] 서버: /auth/verify Redis 세션 검증~~ - -> 재검증 결과 `service.go:212-216`에서 이미 Redis 세션을 확인하고 있음. - ---- - -### 2.3 [HIGH] 런처: 서버 fileHash 비어있으면 검증 우회 - -**위치**: `launcher/main.go` ensureGame() -**문제**: `serverInfo.FileHash == ""`이면 해시 비교를 건너뛰고 기존 파일을 그대로 사용. 서버에서 zip에 A301.exe가 없으면 fileHash가 빈 문자열로 저장됨. - -**연쇄 시나리오**: -1. 관리자가 A301.exe 없는 zip 업로드 → 서버 fileHash = "" -2. 런처가 info 조회 → hash 빈 문자열 → 검증 스킵 -3. 로컬 A301.exe가 어떤 파일이든 실행됨 - -**수정안**: -- 서버: zip에 A301.exe 없으면 업로드 거부 (현재는 빈 해시 허용) -- 런처: fileHash가 비어있으면 에러 처리 - -**심각도**: HIGH (보안/무결성) - ---- - -### 2.4 [HIGH] 런처: 토큰이 프로세스 명령줄에 노출 - -**위치**: `launcher/main.go` handleURI() -**문제**: `A301.exe -token `로 실행 → tasklist, Process Explorer 등에서 토큰 노출 - -**수정안**: -- 환경변수로 전달: `os.Setenv("A301_TOKEN", token)` 후 실행 -- 또는 임시 파일에 토큰 쓰고 파일 경로 전달 - -**심각도**: HIGH (보안) - ---- - -### 2.5 [HIGH] 블록체인 트랜잭션 멱등성 미보장 - -**위치**: `server/internal/chain/service.go` 모든 트랜잭션 함수 -**문제**: 동일 요청 재전송 시 중복 처리 (토큰 이중 전송, NFT 이중 민팅) -- 네트워크 타임아웃 후 클라이언트 재시도 시 발생 가능 - -**수정안**: -- 클라이언트에서 idempotency key 전송 -- 서버에서 Redis로 중복 요청 감지 (TTL 기반) - -**심각도**: HIGH (데이터 무결성) - ---- - -### 2.6 [HIGH] 서버: Rate Limiting 없음 - -**위치**: 전체 API -**문제**: 토큰 리프레시, 로그인, 다운로드 등 모든 엔드포인트에 속도 제한 없음. -- 브루트포스 로그인 공격 가능 -- 리프레시 토큰 재사용 공격 가능 -- 다운로드 대역폭 남용 가능 - -**수정안**: Fiber 미들웨어로 rate limiter 추가 (IP 기반 + 토큰 기반) - -**심각도**: HIGH (보안) - ---- - -### 2.7 [MEDIUM] 웹 클라: 관리자 권한 클라이언트 사이드만 검증 - -**위치**: `client/src/components/AdminRoute.jsx` -**문제**: localStorage의 `role`을 조작하면 관리자 UI 접근 가능. API 호출은 서버에서 차단되지만, UI 자체가 노출됨. - -**영향**: 관리자 기능 목록/구조가 일반 사용자에게 보임 - -**수정안**: 서버 API가 이미 권한 체크하므로 실질적 위험은 낮음. 다만 관리자 라우트 로딩 시 서버에서 role 재확인하면 더 안전. - -**심각도**: MEDIUM (UX/보안) - ---- - -### 2.8 [MEDIUM] 런처: Symlink 공격 미차단 - -**위치**: `launcher/main.go` extractZip() -**문제**: zip 내 심볼릭 링크를 통한 디렉토리 탈출 미검증 -- `filepath.Clean()`은 심볼릭 링크를 해석하지 않음 - -**수정안**: zip 엔트리의 파일 모드에서 symlink 비트 확인 → 거부 - -**심각도**: MEDIUM (보안) - ---- - -### 2.9 [MEDIUM] 런처: 네트워크 재시도 로직 없음 - -**위치**: `launcher/main.go` fetchServerInfo(), doDownload() -**문제**: 일시적 네트워크 오류 시 즉시 실패. 다운로드 99%에서 끊기면 처음부터 재시작. - -**수정안**: -- fetchServerInfo: 3회 재시도 (exponential backoff) -- doDownload: Range 헤더 지원으로 이어받기 구현 - -**심각도**: MEDIUM (안정성/UX) - ---- - -### 2.10 [MEDIUM] 런처: 단일 인스턴스 체크 시 사용자 피드백 없음 - -**위치**: `launcher/main.go` main() mutex 체크 -**문제**: 이미 실행 중이면 조용히 종료 → 사용자는 클릭했는데 아무 반응 없음 - -**수정안**: 기존 인스턴스 창을 foreground로 가져오기 (`FindWindow` + `SetForegroundWindow`) - -**심각도**: MEDIUM (UX) - ---- - -### 2.11 [MEDIUM] 웹 클라: 다운로드 정보 실패 시 무음 처리 - -**위치**: `client/src/components/DownloadSection.jsx` -**문제**: `/api/download/info` 실패 시 console.error도 없이 "런처 준비 중" 메시지 표시. 디버깅 불가. - -**수정안**: 에러 로깅 추가 + 재시도 버튼 제공 - -**심각도**: MEDIUM (UX/디버깅) - ---- - -### 2.12 [MEDIUM] 서버: 파일 업로드 크기 제한 없음 - -**위치**: `server/internal/download/handler.go` -**문제**: StreamRequestBody 활성화되어 있지만 별도 크기 제한 없음. 수십 GB 파일 업로드 시 디스크 고갈 가능. - -**수정안**: Fiber의 BodyLimit 미들웨어 적용 (예: 4GB) - -**심각도**: MEDIUM (안정성) - ---- - -### 2.13 [LOW] 서버: Username 대소문자 구분 - -**위치**: `server/internal/auth/model.go` -**문제**: "User"와 "user"가 별도 계정으로 등록 가능. 블록체인 username 조회 시 혼동 가능. - -**수정안**: 등록/로그인 시 username을 항상 소문자로 정규화 - -**심각도**: LOW (데이터 일관성) - ---- - -### 2.14 [LOW] 서버: Wallet 암호화 키 시작 시 미검증 - -**위치**: `server/internal/chain/service.go` -**문제**: WALLET_ENCRYPTION_KEY가 잘못되면 런타임에 지갑 복호화 실패. 서버 시작 시 검증하지 않음. - -**수정안**: 서비스 초기화 시 키 길이/형식 검증 (64 hex = 32 bytes) → fail fast - -**심각도**: LOW (운영 안정성) - ---- - -### 2.15 [LOW] 웹 클라: 비밀번호 검증이 약함 - -**위치**: `client/src/pages/RegisterPage.jsx` -**문제**: 6자 이상만 체크. 복잡도 요구 없음. - -**수정안**: 서버 측 검증에 의존하더라도, 클라이언트에서도 실시간 피드백 제공 - -**심각도**: LOW (보안/UX) - ---- - -## 3. 통합 흐름 검증 결과 - -### 3.1 정상 시나리오 (Happy Path) - OK - -| 단계 | 동작 | 상태 | -|------|------|------| -| 웹 로그인 | POST /auth/login → JWT 발급 | OK | -| 다운로드 정보 | GET /download/info → hash/url/version | OK | -| 런처 호출 | a301://launch?token=xxx | OK | -| 런처 서버 조회 | GET /download/info | OK | -| 해시 비교 | 로컬 A301.exe SHA256 vs 서버 fileHash | OK | -| 다운로드 | GET /download/file → zip | OK | -| 압축 해제 | extractZip → moveContents | OK | -| 게임 실행 | A301.exe -token xxx | OK | -| 게임→서버 | /internal/chain/* (X-API-Key) | OK | - -### 3.2 엣지 케이스 검증 - -| 시나리오 | 기대 동작 | 실제 동작 | 판정 | -|----------|-----------|-----------|------| -| 서버 다운 시 런처 호출 | 에러 표시 | MessageBox 표시 후 종료 | OK | -| 토큰 만료 후 게임 시작 | 리프레시 후 전달 | 웹 클라: localStorage에서 읽음 (최신 토큰) | PARTIAL - 리프레시 타이밍에 따라 만료 토큰 전달 가능 | -| 다운로드 중 네트워크 끊김 | 재시도 또는 복구 | 즉시 실패, 재시작 필요 | FAIL | -| 동시에 런처 2개 실행 | 하나만 실행 | Mutex로 차단, 피드백 없음 | PARTIAL | -| A301.exe 없는 zip 업로드 | 업로드 거부 | ~~fileHash="" 로 저장~~ → 업로드 거부 (수정됨) | **FIXED** | -| 로그아웃 후 토큰으로 verify | 무효 판정 | Redis 세션 확인으로 무효 판정 (기존 정상) | OK | -| 관리자가 대용량 파일 업로드 | 크기 제한 | 제한 없음 | FAIL | -| 블록체인 트랜잭션 중복 요청 | 한 번만 처리 | 중복 처리됨 | FAIL | - ---- - -## 4. 개선 우선순위 로드맵 - -### Phase 1: 보안 긴급 패치 - -| # | 항목 | 대상 | 상태 | -|---|------|------|------| -| 1 | ~~Zip Slip 검증 로직 수정~~ | 런처 | 재검증: 정상 | -| 2 | ~~/verify Redis 세션 확인 추가~~ | 서버 | 재검증: 이미 구현됨 | -| 3 | fileHash 빈 문자열 시 업로드 거부 | 서버 | **완료** | -| 4 | 토큰 전달 방식 변경 (명령줄 → 환경변수) | 런처 | **완료** | -| 5 | Rate limiting 추가 | 서버 | **완료** | -| 6 | Symlink 차단 | 런처 | **완료** | - -### Phase 2: 안정성 개선 - -| # | 항목 | 대상 | 상태 | -|---|------|------|------| -| 7 | 네트워크 재시도 로직 (fetchServerInfo) | 런처 | **완료** | -| 8 | 블록체인 트랜잭션 멱등성 키 | 서버 | **완료** | -| 9 | 파일 업로드 크기 제한 | 서버 | **완료** | -| 10 | 런처 단일 인스턴스 UX 개선 | 런처 | **완료** | - -### Phase 3: UX 개선 - -| # | 항목 | 대상 | 상태 | -|---|------|------|------| -| 11 | 다운로드 정보 실패 시 재시도 버튼 | 웹 클라 | **완료** | -| 12 | 비밀번호 복잡도 실시간 피드백 | 웹 클라 | **완료** | -| 13 | Username 대소문자 정규화 | 서버 | **완료** | -| 14 | Wallet 암호화 키 시작 시 검증 | 서버 | 이미 구현됨 | -| 15 | 다운로드 이어받기 (Range 헤더) | 런처 | **완료** | - ---- - -## 5. 컴포넌트별 요약 - -### 런처 (Go/Win32) -- **강점**: DPI 인식, 원자적 파일 이동, 다운로드 진행 UI, 2GB 크기 제한 -- **약점**: Zip Slip 검증 오류, 토큰 노출, 재시도 없음, symlink 미차단 - -### 서버 (Go/Fiber) -- **강점**: 모듈화된 구조, JWT+Redis 세션, AES-256 지갑 암호화, 원자적 파일 업로드 -- **약점**: verify 세션 미검증, rate limiting 없음, 트랜잭션 멱등성 없음, 빈 해시 허용 - -### 웹 클라이언트 (React/Vite) -- **강점**: 토큰 자동 리프레시, 관리자 UI 분리, 다크 테마, SPA 라우팅 -- **약점**: 에러 피드백 부족, 관리자 클라이언트 사이드만 검증, 약한 비밀번호 정책 diff --git a/CLAUDE.md b/CLAUDE.md index c2a8dc6..e32a8c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,10 +3,11 @@ ## Build Command ```bash -C:\Users\98kim\sdk\go1.25.1\bin\go.exe build -ldflags="-H windowsgui -s -w" -o launcher.exe . +C:\Users\98kim\sdk\go1.25.1\bin\go.exe build -ldflags="-H windowsgui -s -w -X main.version=$(git describe --tags --always)" -o launcher.exe . ``` -`-H windowsgui` 필수 — 없으면 실행 시 콘솔 창이 함께 열림. +- `-H windowsgui` 필수 — 없으면 실행 시 콘솔 창이 함께 열림. +- `-X main.version=...` — git 태그 기반 버전 자동 주입. 태그가 있으면 `v1.0.0`, 태그 이후 커밋이 있으면 `v1.0.0-3-gabcdef` 형태. ## Tech Stack diff --git a/download.go b/download.go index 493016b..6065e51 100644 --- a/download.go +++ b/download.go @@ -15,11 +15,16 @@ import ( "time" ) -const maxDownloadSize = 2 << 30 // 2GB -const maxExtractFileSize = 4 << 30 // 개별 파일 최대 4GB +// ── 상수 및 변수 ───────────────────────────────────────────── + +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) @@ -30,13 +35,13 @@ var checkRedirect = func(req *http.Request, via []*http.Request) error { return nil } -// apiClient: 서버 정보 조회 등 짧은 요청용 (전체 120초 타임아웃) +// apiClient 서버 정보 조회 등 짧은 요청용 (전체 120초 타임아웃). var apiClient = &http.Client{ Timeout: 120 * time.Second, CheckRedirect: checkRedirect, } -// downloadClient: 대용량 파일 다운로드용 (연결 30초 + 유휴 60초, 전체 타임아웃 없음) +// downloadClient 대용량 파일 다운로드용 (전체 타임아웃 없음). var downloadClient = &http.Client{ Transport: &http.Transport{ TLSHandshakeTimeout: 30 * time.Second, @@ -46,18 +51,20 @@ var downloadClient = &http.Client{ 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) { +// ── 파일 다운로드 ──────────────────────────────────────────── + +// doDownloadRequest Range 헤더로 이어받기를 시도한다. +// 서버가 416(범위 불일치)을 반환하면 임시 파일을 삭제하고 처음부터 다시 요청한다. +func doDownloadRequest(downloadURL, tmpPath string) (resp *http.Response, resumeOffset int64, err error) { for attempt := 0; attempt < 2; attempt++ { - var resumeOffset int64 - if fi, err := os.Stat(tmpPath); err == nil { + 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, fmt.Errorf("다운로드 요청 생성 실패: %w", err) + return nil, 0, fmt.Errorf("다운로드 요청 생성 실패: %w", err) } if resumeOffset > 0 { req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeOffset)) @@ -65,7 +72,7 @@ func doDownloadRequest(downloadURL, tmpPath string) (*http.Response, error) { resp, err := downloadClient.Do(req) if err != nil { - return nil, fmt.Errorf("다운로드 연결 실패: %w", err) + return nil, 0, fmt.Errorf("다운로드 연결 실패: %w", err) } if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable { @@ -73,29 +80,13 @@ func doDownloadRequest(downloadURL, tmpPath string) (*http.Response, error) { os.Remove(tmpPath) continue } - return resp, nil + return resp, resumeOffset, nil } - return nil, fmt.Errorf("다운로드 실패: 재시도 횟수 초과") + return nil, 0, 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() - } - +// 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 @@ -109,20 +100,48 @@ func doDownload(downloadURL, destDir string) error { } tmpFile, err = os.Create(tmpPath) default: - return fmt.Errorf("다운로드 실패 (HTTP %d)", resp.StatusCode) + return nil, 0, 0, fmt.Errorf("다운로드 실패 (HTTP %d)", resp.StatusCode) } if err != nil { - return fmt.Errorf("임시 파일 열기 실패: %w", err) + return nil, 0, 0, fmt.Errorf("임시 파일 열기 실패: %w", err) } - if total > maxDownloadSize { tmpFile.Close() os.Remove(tmpPath) - return fmt.Errorf("파일이 너무 큽니다 (%d bytes)", total) + 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 @@ -132,7 +151,8 @@ func doDownload(downloadURL, destDir string) error { tmpFile.Close() return fmt.Errorf("다운로드가 취소되었습니다") } - n, err := resp.Body.Read(buf) + + n, readErr := resp.Body.Read(buf) if n > 0 { if _, werr := tmpFile.Write(buf[:n]); werr != nil { tmpFile.Close() @@ -145,6 +165,7 @@ func doDownload(downloadURL, destDir string) error { return fmt.Errorf("다운로드 크기가 제한을 초과했습니다") } + // 500ms마다 속도 계산 및 진행률 갱신 now := time.Now() if now.Sub(lastSpeedUpdate) >= 500*time.Millisecond { elapsed := now.Sub(lastSpeedUpdate).Seconds() @@ -160,31 +181,22 @@ func doDownload(downloadURL, destDir string) error { 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) + remaining := float64(total-downloaded) / speedBytesPerSec + setProgress(formatProgress(pct, speedBytesPerSec, remaining), pct) } } - if err == io.EOF { + if readErr == io.EOF { break } - if err != nil { + if readErr != nil { tmpFile.Close() - return fmt.Errorf("다운로드 중 오류: %w", err) + return fmt.Errorf("다운로드 중 오류: %w", readErr) } } tmpFile.Close() defer os.Remove(tmpPath) + // zip 추출 setProgress("압축을 해제하는 중...", -1) tmpExtractDir, err := os.MkdirTemp("", "a301_extract_") @@ -196,13 +208,16 @@ func doDownload(downloadURL, destDir string) error { 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 { @@ -213,36 +228,29 @@ func extractZip(zipPath, destDir string) error { 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 { + 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) } - - 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) { + 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 } @@ -252,32 +260,57 @@ func extractZip(zipPath, destDir string) error { continue } - if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + if err := extractFile(f, dest); 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 } +// 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 { @@ -294,7 +327,10 @@ func moveContents(srcDir, dstDir string) error { return err } } else { - os.Remove(dst) + // 기존 파일 삭제 후 이동. 삭제 실패 시(파일 잠금 등) 에러 반환. + 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) @@ -307,6 +343,7 @@ func moveContents(srcDir, dstDir string) error { return nil } +// copyFile src를 dst로 복사한다. func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { @@ -324,6 +361,7 @@ func copyFile(src, dst string) error { return out.Close() } +// hashFile 파일의 SHA-256 해시를 계산한다. func hashFile(path string) (string, error) { f, err := os.Open(path) if err != nil { @@ -337,13 +375,15 @@ func hashFile(path string) (string, error) { 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 { @@ -358,33 +398,35 @@ func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error { } } - 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("다운로드된 파일의 해시가 일치하지 않습니다 (파일이 손상되었을 수 있습니다)") - } - } + 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 downloads a file from url to destPath using apiClient. +// downloadFile url에서 destPath로 파일을 다운로드한다. func downloadFile(dlURL, destPath string) error { resp, err := apiClient.Get(dlURL) if err != nil { @@ -405,22 +447,23 @@ func downloadFile(dlURL, destPath string) error { return err } -// ensureLauncher checks if the installed launcher is up-to-date and replaces it if not. +// ensureLauncher 설치된 런처가 최신인지 확인하고 필요 시 교체한다. +// 항상 설치 경로(%LOCALAPPDATA%\A301\launcher.exe)를 대상으로 한다. func ensureLauncher(serverInfo *downloadInfo) (updated bool, err error) { if serverInfo.LauncherHash == "" { return false, nil } - installedPath, err := launcherPath() + 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 } @@ -430,6 +473,7 @@ func ensureLauncher(serverInfo *downloadInfo) (updated bool, err error) { return false, nil } + // 새 런처를 .new로 다운로드 → 해시 검증 → 기존 파일과 교체 newPath := installedPath + ".new" if err := downloadFile(dlURL, newPath); err != nil { os.Remove(newPath) @@ -446,6 +490,7 @@ func ensureLauncher(serverInfo *downloadInfo) (updated bool, err error) { return false, fmt.Errorf("런처 해시 불일치") } + // 원자적 교체: 기존→.old, .new→기존 oldPath := installedPath + ".old" os.Remove(oldPath) if err := os.Rename(installedPath, oldPath); err != nil { @@ -453,6 +498,7 @@ func ensureLauncher(serverInfo *downloadInfo) (updated bool, err error) { 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) } @@ -462,7 +508,7 @@ func ensureLauncher(serverInfo *downloadInfo) (updated bool, err error) { return true, nil } -// cleanupOldFiles removes .old and .new leftover files from previous launcher updates. +// cleanupOldFiles 이전 런처 업데이트에서 남은 .old/.new 파일을 제거한다. func cleanupOldFiles(dir string) { entries, err := os.ReadDir(dir) if err != nil { diff --git a/game.go b/game.go new file mode 100644 index 0000000..18c92c6 --- /dev/null +++ b/game.go @@ -0,0 +1,141 @@ +package main + +import ( + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// handleURI 웹에서 a301://... 링크를 통해 호출되는 핵심 게임 실행 흐름. +func handleURI(rawURI string) error { + // 1. URI에서 티켓 추출 → JWT 교환 + token, err := authenticateFromURI(rawURI) + if err != nil { + return err + } + + // 2. 게임 디렉토리 준비 + 프로토콜 갱신 + 잔여 파일 정리 + gameDir, gamePath, err := prepareGameDir() + if err != nil { + return err + } + + // 3. 서버 정보 조회 (실패 시 오프라인 실행 시도) + serverInfo, err := fetchServerInfo() + if err != nil { + return tryOfflineLaunch(gamePath, gameDir, token, err) + } + + // 4. 런처 자동 업데이트 (실패해도 게임 실행은 계속) + if restartNeeded := tryLauncherUpdate(serverInfo); restartNeeded { + return nil + } + + // 5. 게임 다운로드/검증 + if err := ensureGame(gameDir, gamePath, serverInfo); err != nil { + return err + } + + // 6. 게임 실행 + return launchGame(gamePath, gameDir, token) +} + +// authenticateFromURI URI를 파싱하고 일회용 티켓을 JWT로 교환한다. +func authenticateFromURI(rawURI string) (string, error) { + parsed, err := url.Parse(rawURI) + if err != nil { + return "", fmt.Errorf("URI 파싱 실패: %w", err) + } + if parsed.Scheme != protocolName { + return "", fmt.Errorf("지원하지 않는 URI 스킴: %s", parsed.Scheme) + } + + 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 token, nil +} + +// prepareGameDir 게임 디렉토리를 생성하고 프로토콜 등록을 갱신한다. +func prepareGameDir() (gameDir, gamePath string, err error) { + gameDir, err = installDir() + if err != nil { + return "", "", err + } + + if err := os.MkdirAll(gameDir, 0755); err != nil { + return "", "", fmt.Errorf("게임 디렉토리 생성 실패: %w", err) + } + gamePath = filepath.Join(gameDir, gameExeName) + + // 프로토콜 등록이 현재 런처를 가리키도록 갱신 (실패해도 진행) + _ = install() + + // 이전 업데이트에서 남은 .old/.new 파일 정리 + cleanupOldFiles(gameDir) + + return gameDir, gamePath, nil +} + +// tryOfflineLaunch 서버 연결 실패 시 설치된 게임을 직접 실행한다. +func tryOfflineLaunch(gamePath, gameDir, token string, serverErr error) error { + if _, err := os.Stat(gamePath); err != nil { + return fmt.Errorf("버전 확인 실패: %w", serverErr) + } + + ret := msgBox("One of the plans", + "서버에 연결할 수 없습니다.\n설치된 게임을 실행하시겠습니까?\n(업데이트 확인 불가)", + mbYesNo|mbQ) + if ret != idYes { + return fmt.Errorf("사용자가 취소했습니다") + } + + return launchGame(gamePath, gameDir, token) +} + +// tryLauncherUpdate 런처 업데이트를 확인하고 필요 시 새 런처로 재시작한다. +// 재시작이 필요하면 true를 반환한다. +func tryLauncherUpdate(serverInfo *downloadInfo) bool { + updated, err := ensureLauncher(serverInfo) + if err != nil { + return false + } + if !updated { + return false + } + + launcherDir, err := installDir() + if err != nil { + return false + } + cmd := exec.Command(filepath.Join(launcherDir, "launcher.exe"), os.Args[1:]...) + if err := cmd.Start(); err != nil { + return false + } + os.Exit(0) + return true // unreachable, os.Exit 위에서 종료 +} + +// launchGame 게임 프로세스를 시작한다. +func launchGame(gamePath, gameDir, token string) error { + cmd := exec.Command(gamePath, "-token", token) + cmd.Dir = gameDir + if err := cmd.Start(); err != nil { + return fmt.Errorf("게임 실행 실패: %w", err) + } + return nil +} diff --git a/main.go b/main.go index b79b15c..262a8f7 100644 --- a/main.go +++ b/main.go @@ -2,152 +2,55 @@ package main import ( "fmt" - "net/url" "os" - "os/exec" - "path/filepath" "strings" ) // version is set at build time via -ldflags "-X main.version=x.y.z" var version = "dev" -func handleURI(rawURI string) error { - parsed, err := url.Parse(rawURI) - if err != nil { - return fmt.Errorf("URI 파싱 실패: %w", err) - } - - if parsed.Scheme != protocolName { - return fmt.Errorf("지원하지 않는 URI 스킴: %s", parsed.Scheme) - } - - // 웹 클라이언트가 발급한 일회용 티켓을 서버에서 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("서버에서 유효하지 않은 토큰을 받았습니다") - } - - gameDir, err := installDir() - if err != nil { - return err - } - if err := os.MkdirAll(gameDir, 0755); err != nil { - return fmt.Errorf("게임 디렉토리 생성 실패: %w", err) - } - gamePath := filepath.Join(gameDir, gameExeName) - - // 프로토콜 등록이 현재 런처를 가리키도록 갱신 (사일런트) - _ = install() - - // 이전 업데이트에서 남은 .old/.new 파일 정리 - cleanupOldFiles(gameDir) - - 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, "-token", token) - cmd.Dir = gameDir - if err := cmd.Start(); err != nil { - return fmt.Errorf("게임 실행 실패: %w", err) - } - return nil - } - return fmt.Errorf("사용자가 취소했습니다") - } - return fmt.Errorf("버전 확인 실패: %w", err) - } - - // 런처 자동 업데이트 체크 - if updated, updateErr := ensureLauncher(serverInfo); updateErr != nil { - fmt.Fprintf(os.Stderr, "런처 업데이트 실패: %v\n", 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 - } - - cmd := exec.Command(gamePath, "-token", token) - cmd.Dir = gameDir - if err := cmd.Start(); err != nil { - return fmt.Errorf("게임 실행 실패: %w", err) - } - return nil -} - func main() { - // DLL Hijacking 방어: 시스템 디렉토리에서만 DLL 로드 + // DLL Hijacking 방어: DLL 탐색 경로를 System32로만 제한한다. + // 게임 폴더처럼 사용자가 파일을 쓸 수 있는 디렉토리에 + // 악성 DLL이 심겨 있어도 로드되지 않는다. + // 반드시 다른 DLL이 로드되기 전 가장 먼저 호출해야 한다. const loadLibrarySearchSystem32 = 0x00000800 kernel32.NewProc("SetDefaultDllDirectories").Call(loadLibrarySearchSystem32) + // Windows에게 "DPI는 내가 직접 처리한다"고 선언한다. + // 이 선언 없이는 OS가 창을 통째로 확대해 흐릿하게 표시한다. + // Per-Monitor V2: 모니터마다 DPI가 달라도 각각 대응 가능. enableDPIAwareness() + // 단일 인스턴스 보장: 이미 실행 중이면 기존 창을 앞으로 가져오고 종료한다. + // 내부적으로 Named Mutex("Global\A301LauncherMutex")로 중복 실행을 감지한다. if !acquireSingleInstance() { activateExistingWindow() return } - if len(os.Args) < 2 { - if err := install(); err != nil { - msgBox("One of the plans 런처 - 오류", fmt.Sprintf("설치 실패:\n%v", err), mbOK|mbError) - os.Exit(1) + // 인수 결정: 없으면 "install" (더블클릭 = 최초 설치) + arg := "install" + if len(os.Args) >= 2 { + arg = os.Args[1] + } + + // a301://... URI는 별도 처리 (HasPrefix라 switch로 분기 불가) + if strings.HasPrefix(arg, protocolName+"://") { + if err := handleURI(arg); err != nil { + exitWithError(fmt.Sprintf("실행 실패:\n%v", err)) } - msgBox("One of the plans", "설치가 완료되었습니다.\n웹에서 게임 시작 버튼을 클릭하세요.", mbOK|mbInfo) return } - arg := os.Args[1] - switch { - case arg == "install": - if err := install(); err != nil { - msgBox("One of the plans 런처 - 오류", fmt.Sprintf("등록 실패:\n%v", err), mbOK|mbError) - os.Exit(1) - } - 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": + switch arg { + case "install": + handleInstall() + case "uninstall": + handleUninstall() + case "--version", "version": msgBox("One of the plans 런처", fmt.Sprintf("버전: %s", version), 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) - os.Exit(1) - } - default: - msgBox("One of the plans 런처 - 오류", fmt.Sprintf("알 수 없는 명령: %s", arg), mbOK|mbError) - os.Exit(1) + exitWithError(fmt.Sprintf("알 수 없는 명령: %s", arg)) } } diff --git a/main_test.go b/main_test.go index 6311e8b..95b06fb 100644 --- a/main_test.go +++ b/main_test.go @@ -271,7 +271,10 @@ func TestRedeemTicket_Success(t *testing.T) { })) defer srv.Close() - token, err := redeemTicketFrom(srv.URL, "test-ticket") + origURL := redeemTicketURL + redeemTicketURL = srv.URL + defer func() { redeemTicketURL = origURL }() + token, err := redeemTicket("test-ticket") if err != nil { t.Fatal(err) } @@ -287,7 +290,10 @@ func TestRedeemTicket_ServerError(t *testing.T) { })) defer srv.Close() - _, err := redeemTicketFrom(srv.URL, "bad-ticket") + origURL := redeemTicketURL + redeemTicketURL = srv.URL + defer func() { redeemTicketURL = origURL }() + _, err := redeemTicket("bad-ticket") if err == nil { t.Fatal("서버 에러 시 에러가 반환되지 않음") } @@ -300,7 +306,10 @@ func TestRedeemTicket_InvalidJSON(t *testing.T) { })) defer srv.Close() - _, err := redeemTicketFrom(srv.URL, "ticket") + origURL := redeemTicketURL + redeemTicketURL = srv.URL + defer func() { redeemTicketURL = origURL }() + _, err := redeemTicket("ticket") if err == nil { t.Fatal("잘못된 JSON에 에러가 반환되지 않음") } @@ -313,15 +322,20 @@ func TestRedeemTicket_EmptyToken(t *testing.T) { })) defer srv.Close() - _, err := redeemTicketFrom(srv.URL, "ticket") + origURL := redeemTicketURL + redeemTicketURL = srv.URL + defer func() { redeemTicketURL = origURL }() + _, err := redeemTicket("ticket") if err == nil { t.Fatal("빈 토큰에 에러가 반환되지 않음") } } func TestRedeemTicket_Unreachable(t *testing.T) { - // 존재하지 않는 서버 주소 - _, err := redeemTicketFrom("http://127.0.0.1:1", "ticket") + origURL := redeemTicketURL + redeemTicketURL = "http://127.0.0.1:1" + defer func() { redeemTicketURL = origURL }() + _, err := redeemTicket("ticket") if err == nil { t.Fatal("연결 불가 시 에러가 반환되지 않음") } diff --git a/protocol.go b/protocol.go index 83f4e9e..a6d6c5c 100644 --- a/protocol.go +++ b/protocol.go @@ -7,33 +7,29 @@ import ( "io" "net/http" "os" + "os/exec" "path/filepath" "strings" + "syscall" "time" "golang.org/x/sys/windows/registry" ) +// ── 상수 및 타입 ────────────────────────────────────────────── + const ( protocolName = "a301" gameExeName = "A301.exe" ) -// serverInfoURL and redeemTicketURL can be overridden at build time via -// -ldflags "-X main.serverInfoURL=... -X main.redeemTicketURL=..." +// serverInfoURL, redeemTicketURL은 빌드 시 -ldflags로 오버라이드 가능. var ( serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info" redeemTicketURL = "https://a301.api.tolelom.xyz/api/auth/redeem-ticket" ) -// 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 } - +// downloadInfo 서버에서 받아오는 게임/런처 다운로드 정보. type downloadInfo struct { FileHash string `json:"fileHash"` URL string `json:"url"` @@ -41,7 +37,17 @@ type downloadInfo struct { LauncherHash string `json:"launcherHash"` } -// installDir returns the fixed install directory: %LOCALAPPDATA%\A301 +// errNoRetry 재시도하면 안 되는 에러를 감싼다 (예: HTTP 4xx). +type errNoRetry struct { + err error +} + +func (e *errNoRetry) Error() string { return e.err.Error() } +func (e *errNoRetry) Unwrap() error { return e.err } + +// ── 경로 ────────────────────────────────────────────────────── + +// installDir 고정 설치 경로: %LOCALAPPDATA%\A301 func installDir() (string, error) { localAppData := os.Getenv("LOCALAPPDATA") if localAppData == "" { @@ -50,7 +56,7 @@ func installDir() (string, error) { return filepath.Join(localAppData, "A301"), nil } -// launcherPath returns the current executable's absolute path. +// launcherPath 현재 실행 중인 런처의 절대 경로를 반환한다. func launcherPath() (string, error) { exe, err := os.Executable() if err != nil { @@ -59,6 +65,9 @@ func launcherPath() (string, error) { return filepath.Abs(exe) } +// ── 설치/제거 ───────────────────────────────────────────────── + +// install 런처를 설치 경로로 복사하고 a301:// 프로토콜을 레지스트리에 등록한다. func install() error { srcPath, err := launcherPath() if err != nil { @@ -73,14 +82,15 @@ func install() error { return fmt.Errorf("설치 디렉토리 생성 실패: %w", err) } + // 이미 설치 경로에서 실행 중이면 복사 생략 dstPath := filepath.Join(dir, "launcher.exe") - if !strings.EqualFold(srcPath, dstPath) { if err := copyFile(srcPath, dstPath); err != nil { return fmt.Errorf("런처 설치 실패: %w", err) } } + // 레지스트리에 a301:// 프로토콜 등록 key, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Classes\`+protocolName, registry.SET_VALUE) if err != nil { return fmt.Errorf("레지스트리 키 생성 실패: %w", err) @@ -93,6 +103,7 @@ func install() error { return fmt.Errorf("URL Protocol 값 설정 실패: %w", err) } + // 프로토콜 핸들러 명령 등록: "%LOCALAPPDATA%\A301\launcher.exe" "%1" cmdKey, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Classes\`+protocolName+`\shell\open\command`, registry.SET_VALUE) if err != nil { return fmt.Errorf("command 키 생성 실패: %w", err) @@ -101,7 +112,9 @@ func install() error { return cmdKey.SetStringValue("", fmt.Sprintf(`"%s" "%%1"`, dstPath)) } +// uninstall 레지스트리에서 a301:// 프로토콜을 제거한다. func uninstall() error { + // 하위 키부터 역순으로 삭제해야 함 paths := []string{ `Software\Classes\` + protocolName + `\shell\open\command`, `Software\Classes\` + protocolName + `\shell\open`, @@ -116,94 +129,123 @@ func uninstall() error { return nil } -func fetchServerInfoOnce() (*downloadInfo, error) { - resp, err := apiClient.Get(serverInfoURL) - if err != nil { - return nil, fmt.Errorf("서버 연결 실패: %w", err) +// handleInstall 런처를 설치하고 프로토콜을 등록한다. +func handleInstall() { + if err := install(); err != nil { + exitWithError(fmt.Sprintf("설치 실패:\n%v", 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 + msgBox("One of the plans", "설치가 완료되었습니다.\n웹에서 게임 시작 버튼을 클릭하세요.", mbOK|mbInfo) } +// handleUninstall 프로토콜 제거 + 선택적 데이터 삭제. +func handleUninstall() { + ret := msgBox("One of the plans 런처", "게임 데이터도 함께 삭제하시겠습니까?", mbYesNo|mbQ) + deleteData := ret == idYes + + if err := uninstall(); err != nil { + exitWithError(fmt.Sprintf("제거 실패:\n%v", err)) + } + + if deleteData { + if dir, err := installDir(); err == nil { + // 실행 중인 launcher.exe는 즉시 삭제할 수 없으므로, + // 백그라운드 cmd 프로세스가 런처 종료 후 폴더를 삭제한다. + cmd := exec.Command("cmd", "/c", "ping", "-n", "3", "127.0.0.1", ">nul", "&&", "rmdir", "/s", "/q", dir) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + cmd.Start() + } + } + + msgBox("One of the plans 런처", "제거가 완료되었습니다.", mbOK|mbInfo) +} + +// ── 서버 API ────────────────────────────────────────────────── + +// retryWithBackoff 최대 maxRetries회 재시도한다 (exponential backoff). +// errNoRetry를 반환하면 즉시 중단한다. +func retryWithBackoff(maxRetries int, fn func() error) error { + var lastErr error + for i := range maxRetries { + if err := fn(); err == nil { + return nil + } else { + lastErr = err + var noRetry *errNoRetry + if errors.As(err, &noRetry) { + return err + } + } + time.Sleep(time.Duration(1<= 400 { + return &errNoRetry{fmt.Errorf("요청 실패 (HTTP %d)", resp.StatusCode)} + } + + var result downloadInfo + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil { + return fmt.Errorf("서버 응답 파싱 실패: %w", err) + } + info = &result + return nil + }) + if err != nil { + return nil, fmt.Errorf("서버 연결 실패 (3회 재시도): %w", err) } - return nil, fmt.Errorf("서버 연결 실패 (%d회 재시도): %w", maxRetries, lastErr) + return info, nil } -// redeemTicket exchanges a one-time launch ticket for a fresh JWT access token. -// Retries up to 3 times with exponential backoff on transient errors. +// redeemTicket 일회용 티켓을 서버에 보내 JWT 액세스 토큰으로 교환한다 (3회 재시도). func redeemTicket(ticket string) (string, error) { - const maxRetries = 3 - var lastErr error - for i := range maxRetries { - token, err := redeemTicketFrom(redeemTicketURL, ticket) - if err == nil { - return token, nil + var token string + err := retryWithBackoff(3, func() error { + payload, err := json.Marshal(map[string]string{"ticket": ticket}) + if err != nil { + return fmt.Errorf("요청 데이터 생성 실패: %w", err) } - lastErr = err - // HTTP 4xx errors should not be retried - var noRetry *errNoRetry - if errors.As(err, &noRetry) { - return "", err + + resp, err := apiClient.Post(redeemTicketURL, "application/json", strings.NewReader(string(payload))) + if err != nil { + return fmt.Errorf("서버에 연결할 수 없습니다: %w", err) } - time.Sleep(time.Duration(1<= 400 && resp.StatusCode < 500 { + return &errNoRetry{fmt.Errorf("런처 인증에 실패했습니다 (HTTP %d)", resp.StatusCode)} + } + 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("서버가 토큰을 반환하지 않았습니다") + } + token = result.Token + return nil + }) if err != nil { - return "", fmt.Errorf("요청 데이터 생성 실패: %w", err) + return "", fmt.Errorf("인증 실패 (3회 재시도): %w", err) } - body := string(payload) - resp, err := client.Post(url, "application/json", strings.NewReader(body)) - if err != nil { - return "", fmt.Errorf("서버에 연결할 수 없습니다: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 && resp.StatusCode < 500 { - return "", &errNoRetry{fmt.Errorf("런처 인증에 실패했습니다 (HTTP %d)", resp.StatusCode)} - } - 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 + return token, nil } diff --git a/ui.go b/ui.go index e9c4d6b..0c5f537 100644 --- a/ui.go +++ b/ui.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "os" "runtime" "syscall" "unsafe" @@ -9,28 +10,41 @@ import ( "golang.org/x/sys/windows" ) -// Win32 constants +// ── Win32 메시지 상수 ──────────────────────────────────────── + const ( wmDestroy uint32 = 0x0002 wmClose uint32 = 0x0010 wmSetFont uint32 = 0x0030 wmSetText uint32 = 0x000C wmCtlColorStatic uint32 = 0x0138 - wmAppDone uint32 = 0x8001 + wmAppDone uint32 = 0x8001 // 다운로드 완료 시 사용하는 커스텀 메시지 +) +// ── 윈도우 스타일 상수 ─────────────────────────────────────── + +const ( wsPopup uintptr = 0x80000000 wsCaption uintptr = 0x00C00000 wsSysMenu uintptr = 0x00080000 wsChild uintptr = 0x40000000 wsVisible uintptr = 0x10000000 ssCenter uintptr = 0x00000001 +) +// ── 프로그레스바 상수 ──────────────────────────────────────── + +const ( pbsSmooth uintptr = 0x01 pbmSetRange32 uint32 = 0x0406 pbmSetPos uint32 = 0x0402 pbmSetBarColor uint32 = 0x0409 pbmSetBkColor uint32 = 0x2001 +) +// ── 기타 Win32 상수 ────────────────────────────────────────── + +const ( setBkModeTransparent = 1 swShow = 5 @@ -47,7 +61,9 @@ const ( iccProgressClass uint32 = 0x00000020 ) -// rgb builds a COLORREF from R, G, B components. +// ── 색상 ───────────────────────────────────────────────────── + +// rgb COLORREF(0x00BBGGRR) 값을 생성한다. func rgb(r, g, b uint8) uintptr { return uintptr(r) | (uintptr(g) << 8) | (uintptr(b) << 16) } @@ -60,13 +76,18 @@ var ( colorProgressBg = rgb(65, 63, 67) // bg보다 약간 밝은 색 ) +// ── DLL 및 프로시저 ────────────────────────────────────────── + 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") +) +// user32.dll +var ( messageBoxWProc = user32.NewProc("MessageBoxW") registerClassExWProc = user32.NewProc("RegisterClassExW") createWindowExWProc = user32.NewProc("CreateWindowExW") @@ -85,23 +106,31 @@ var ( 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 ) +// kernel32.dll +var ( + createMutexWProc = kernel32.NewProc("CreateMutexW") + getModuleHandleWProc = kernel32.NewProc("GetModuleHandleW") +) + +// gdi32.dll +var ( + createFontIndirectWProc = gdi32.NewProc("CreateFontIndirectW") + createSolidBrushProc = gdi32.NewProc("CreateSolidBrush") + setTextColorProc = gdi32.NewProc("SetTextColor") + setBkModeProc = gdi32.NewProc("SetBkMode") + deleteObjectProc = gdi32.NewProc("DeleteObject") +) + +// comctl32.dll / uxtheme.dll +var ( + initCommonControlsExProc = comctl32.NewProc("InitCommonControlsEx") + setWindowThemeProc = uxtheme.NewProc("SetWindowTheme") +) + +// ── Win32 구조체 ───────────────────────────────────────────── + type wndClassExW struct { cbSize uint32 style uint32 @@ -149,10 +178,23 @@ type logFontW struct { lfFaceName [32]uint16 } +// ── 윈도우 핸들 (진행 창에서 사용) ────────────────────────── + +var ( + wndProcCb uintptr + titleLabelHwnd uintptr + progressLabelHwnd uintptr + progressBarHwnd uintptr + hBrushBg uintptr +) + func init() { wndProcCb = syscall.NewCallback(progressWndProc) } +// ── DPI ────────────────────────────────────────────────────── + +// enableDPIAwareness Per-Monitor V2 DPI 인식을 선언한다. func enableDPIAwareness() { // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4 (Windows 10 1703+) setProcessDpiAwarenessContextProc.Call(^uintptr(3)) @@ -166,11 +208,13 @@ func getSystemDPI() uint32 { return uint32(dpi) } -// dpiScale scales a base-96-DPI pixel value to the system DPI. +// dpiScale 96 DPI 기준 픽셀 값을 현재 DPI에 맞게 변환한다. func dpiScale(px int, dpi uint32) uintptr { return uintptr(int(float64(px)*float64(dpi)/96.0 + 0.5)) } +// ── 폰트 ───────────────────────────────────────────────────── + func createUIFont(pointSize int, dpi uint32, bold bool) uintptr { weight := int32(400) // FW_NORMAL if bold { @@ -185,16 +229,15 @@ 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 } -func initCommonControls() { - icc := initCommonControlsExS{ - dwSize: uint32(unsafe.Sizeof(initCommonControlsExS{})), - dwICC: iccProgressClass, - } - initCommonControlsExProc.Call(uintptr(unsafe.Pointer(&icc))) +// ── 메시지 박스 ────────────────────────────────────────────── + +// exitWithError 오류 메시지를 표시하고 프로그램을 종료한다. +func exitWithError(msg string) { + msgBox("One of the plans 런처 - 오류", msg, mbOK|mbError) + os.Exit(1) } func msgBox(title, text string, flags uintptr) int { @@ -210,6 +253,37 @@ func msgBox(title, text string, flags uintptr) int { return int(ret) } +// ── 단일 인스턴스 ──────────────────────────────────────────── + +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 +} + +func activateExistingWindow() { + className, _ := windows.UTF16PtrFromString("A301Progress") + hwnd, _, _ := findWindowWProc.Call(uintptr(unsafe.Pointer(className)), 0) + if hwnd != 0 { + setForegroundWindowProc.Call(hwnd) + } +} + +// ── 진행 창 (프로그레스 윈도우) ────────────────────────────── + +func initCommonControls() { + icc := initCommonControlsExS{ + dwSize: uint32(unsafe.Sizeof(initCommonControlsExS{})), + dwICC: iccProgressClass, + } + initCommonControlsExProc.Call(uintptr(unsafe.Pointer(&icc))) +} + +// progressWndProc 진행 창의 메시지 처리 프로시저. func progressWndProc(hwnd, uMsg, wParam, lParam uintptr) uintptr { switch uint32(uMsg) { case wmClose: @@ -239,6 +313,7 @@ func progressWndProc(hwnd, uMsg, wParam, lParam uintptr) uintptr { return ret } +// setProgress 진행 창의 텍스트와 퍼센트를 갱신한다. func setProgress(text string, pct int) { if text != "" { t, err := windows.UTF16PtrFromString(text) @@ -251,19 +326,11 @@ func setProgress(text string, pct int) { } } -// 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() +// createProgressWindow 다크 테마 진행 창을 생성하고 핸들을 반환한다. +func createProgressWindow(dpi uint32) (hwnd uintptr, err error) { 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") @@ -276,9 +343,10 @@ func downloadWithProgress(downloadURL, destDir string) error { } atom, _, _ := registerClassExWProc.Call(uintptr(unsafe.Pointer(&wc))) if atom == 0 { - return fmt.Errorf("윈도우 클래스 등록 실패") + return 0, fmt.Errorf("윈도우 클래스 등록 실패") } + // 화면 중앙에 배치 screenW, _, _ := getSystemMetricsProc.Call(smCxScreen) screenH, _, _ := getSystemMetricsProc.Call(smCyScreen) winW := s(440) @@ -287,7 +355,7 @@ func downloadWithProgress(downloadURL, destDir string) error { y := (screenH - winH) / 2 titleStr, _ := windows.UTF16PtrFromString("One of the plans 런처") - hwnd, _, _ := createWindowExWProc.Call( + hwnd, _, _ = createWindowExWProc.Call( 0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(titleStr)), @@ -296,16 +364,15 @@ func downloadWithProgress(downloadURL, destDir string) error { 0, 0, hInstance, 0, ) if hwnd == 0 { - return fmt.Errorf("다운로드 창 생성 실패") + return 0, 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, @@ -317,6 +384,7 @@ func downloadWithProgress(downloadURL, destDir string) error { ) sendMessageWProc.Call(titleLabelHwnd, uintptr(wmSetFont), titleFont, 1) + // 상태 라벨 initText, _ := windows.UTF16PtrFromString("게임 파일을 다운로드하는 중...") progressLabelHwnd, _, _ = createWindowExWProc.Call( 0, @@ -328,6 +396,7 @@ func downloadWithProgress(downloadURL, destDir string) error { ) sendMessageWProc.Call(progressLabelHwnd, uintptr(wmSetFont), statusFont, 1) + // 프로그레스바 progressClass, _ := windows.UTF16PtrFromString("msctls_progress32") progressBarHwnd, _, _ = createWindowExWProc.Call( 0, @@ -339,6 +408,7 @@ func downloadWithProgress(downloadURL, destDir string) error { ) 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) @@ -347,14 +417,34 @@ func downloadWithProgress(downloadURL, destDir string) error { showWindowProc.Call(hwnd, swShow) updateWindowProc.Call(hwnd) + return hwnd, nil +} + +// downloadWithProgress 진행 창을 표시하면서 파일을 다운로드하고 추출한다. +// Win32 메시지 루프를 사용하므로 반드시 메인 고루틴에서 호출해야 한다. +func downloadWithProgress(downloadURL, destDir string) error { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + initCommonControls() + dpi := getSystemDPI() + + hwnd, err := createProgressWindow(dpi) + if err != nil { + return err + } + defer deleteObjectProc.Call(hBrushBg) + downloadCancelled.Store(false) + // 별도 고루틴에서 다운로드, 완료 시 wmAppDone 메시지로 창 닫기 errCh := make(chan error, 1) go func() { errCh <- doDownload(downloadURL, destDir) postMessageWProc.Call(hwnd, uintptr(wmAppDone), 0, 0) }() + // Win32 메시지 루프 var m msgW for { ret, _, _ := getMessageWProc.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0) @@ -367,21 +457,3 @@ func downloadWithProgress(downloadURL, destDir string) error { 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 -}