Compare commits
24 Commits
v0.0.1
...
b026520b35
| Author | SHA1 | Date | |
|---|---|---|---|
| b026520b35 | |||
| d55be620bd | |||
| 03345d18b9 | |||
| 1e7aebf6b0 | |||
| 84dd2373a4 | |||
| 8759587e25 | |||
| 19b4d4895f | |||
| 13b44b04a2 | |||
| a8d9ab9d36 | |||
| 66dc8a14de | |||
| a28510df57 | |||
| 574a6ee277 | |||
| b71c0d7baf | |||
| a0face763a | |||
| 48df55a82e | |||
| 9bb422f9b2 | |||
| 9fb98b0028 | |||
| 10651d294a | |||
| f77a7e0e23 | |||
| 28c1b377df | |||
| 22b5efdaab | |||
| 6fafe1f3af | |||
| 90fcc0f94e | |||
| ad9d372d7c |
85
.github/workflows/ci.yml
vendored
Normal file
85
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
# ── 1. 정적 분석 + 빌드 + 테스트 ──────────────────────────────────────────
|
||||
test:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
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. Gitea Release 생성 + launcher.exe 업로드 (태그 push 시만) ──────────
|
||||
release:
|
||||
needs: test
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: windows-latest
|
||||
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 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
|
||||
288
ANALYSIS.md
Normal file
288
ANALYSIS.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# 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 <JWT>`로 실행 → 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 라우팅
|
||||
- **약점**: 에러 피드백 부족, 관리자 클라이언트 사이드만 검증, 약한 비밀번호 정책
|
||||
65
CLAUDE.md
Normal file
65
CLAUDE.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Build Command
|
||||
|
||||
```bash
|
||||
C:\Users\98kim\sdk\go1.25.1\bin\go.exe build -ldflags="-H windowsgui -s -w" -o launcher.exe .
|
||||
```
|
||||
|
||||
`-H windowsgui` 필수 — 없으면 실행 시 콘솔 창이 함께 열림.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **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`
|
||||
|
||||
## Project Purpose
|
||||
|
||||
"One of the plans" Windows 런처.
|
||||
`a301://` 커스텀 URI 프로토콜 등록 → 게임 자동 다운로드/업데이트/실행.
|
||||
|
||||
## Architecture
|
||||
|
||||
역할별 4파일 구조:
|
||||
|
||||
| 파일 | 담당 |
|
||||
|---|---|
|
||||
| `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`).
|
||||
- **`fetchServerInfo()`** — 3회 재시도 (exponential backoff).
|
||||
- **`ensureGame()`** — `A301.exe` SHA256 해시 비교 후 불일치 시 재다운로드.
|
||||
|
||||
## UI Details
|
||||
|
||||
- DPI: `GetDpiForSystem()` → `dpiScale(px, dpi)` 함수로 모든 크기 동적 계산
|
||||
- 진행 막대 색: `SetWindowTheme("", "")` 비주얼 스타일 비활성화 후 `PBM_SETBARCOLOR` 적용
|
||||
- 다크 배경: `WM_CTLCOLORSTATIC` 핸들러에서 `hBrushBg` 반환 + `SetBkMode(TRANSPARENT)`
|
||||
- 타이틀 레이블(`titleLabelHwnd`)만 강조색, 나머지 STATIC은 밝은 회색
|
||||
|
||||
## Key Constants
|
||||
|
||||
```go
|
||||
serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info"
|
||||
gameExeName = "A301.exe" // 기술 식별자 — 게임 표기명과 별개
|
||||
protocolName = "a301" // 기술 식별자
|
||||
```
|
||||
|
||||
## Install Location
|
||||
|
||||
- `install()` 시 런처를 `%LOCALAPPDATA%\A301\launcher.exe`로 복사 후 해당 경로를 레지스트리에 등록.
|
||||
- 게임 파일(`A301.exe` 등)도 `%LOCALAPPDATA%\A301\`에 설치됨.
|
||||
- 사용자가 원본 다운로드 파일을 삭제해도 프로토콜 핸들러가 정상 동작.
|
||||
- 토큰은 커맨드라인(`-token`)과 `A301_TOKEN` 환경변수 양쪽으로 게임에 전달.
|
||||
|
||||
## Notes
|
||||
|
||||
- `extractZip()` — zip 내 최상위 디렉토리 1단계 제거 후 추출. `launcher.exe` 자신은 덮어쓰기 방지. Symlink 엔트리는 스킵.
|
||||
- 레지스트리는 `HKCU` (현재 사용자) 에만 쓰므로 관리자 권한 불필요.
|
||||
- `fetchServerInfo()` — 3회 재시도 (exponential backoff).
|
||||
- `doDownload()` — Range 헤더로 이어받기 지원. 취소/오류 시 임시 파일 유지.
|
||||
64
README.md
Normal file
64
README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# One of the plans — Launcher
|
||||
|
||||
Windows 전용 게임 런처. `a301://` 커스텀 URI 프로토콜을 등록하고, 웹에서 게임 시작 버튼 클릭 시 게임을 자동 다운로드/업데이트/실행합니다.
|
||||
|
||||
## 빌드
|
||||
|
||||
```bash
|
||||
# Go 1.24+ 필요
|
||||
go build -ldflags="-H windowsgui -s -w" -o launcher.exe .
|
||||
```
|
||||
|
||||
`-H windowsgui` — 콘솔 창 없이 실행 (Win32 GUI 앱)
|
||||
`-s -w` — 디버그 심볼 제거 (바이너리 크기 축소)
|
||||
|
||||
## 사용법
|
||||
|
||||
| 실행 방법 | 동작 |
|
||||
|-----------|------|
|
||||
| `launcher.exe` (인자 없음) | `a301://` 프로토콜 등록 확인 다이얼로그 |
|
||||
| `launcher.exe install` | 프로토콜 강제 등록 |
|
||||
| `launcher.exe uninstall` | 프로토콜 제거 |
|
||||
| `launcher.exe "a301://launch?token=<JWT>"` | 게임 실행 (웹에서 자동 호출) |
|
||||
|
||||
## 게임 실행 흐름
|
||||
|
||||
```
|
||||
웹 "게임 시작" 클릭
|
||||
└─ a301://launch?token=JWT
|
||||
└─ launcher.exe 실행
|
||||
├─ 서버에서 최신 버전 정보 조회 (fileHash, url)
|
||||
├─ A301.exe 없음 또는 해시 불일치 → 다운로드 창 표시
|
||||
│ ├─ game.zip 다운로드 (진행률 표시)
|
||||
│ └─ 압축 해제 (launcher.exe 자신은 덮어쓰기 방지)
|
||||
└─ A301.exe -token <JWT> 실행
|
||||
```
|
||||
|
||||
## UI
|
||||
|
||||
다크 테마 Win32 창 (배경 `#2E2C2F`, 강조색 `#BACDB0`).
|
||||
4K / FHD 해상도 모두 자연스럽게 표시 (DPI 인식 + 동적 스케일링).
|
||||
|
||||
## 배포 구조
|
||||
|
||||
유저가 받는 구성:
|
||||
|
||||
```
|
||||
(게임 폴더)/
|
||||
├── launcher.exe ← 이 파일
|
||||
└── A301.exe ← 런처가 자동 다운로드
|
||||
```
|
||||
|
||||
`launcher.exe`를 처음 실행하면 레지스트리에 `a301://` 프로토콜을 등록하고, 이후 웹에서 게임 시작 버튼을 누를 때마다 자동으로 이 런처가 실행됩니다.
|
||||
|
||||
## 레지스트리 등록 경로
|
||||
|
||||
```
|
||||
HKCU\Software\Classes\a301\
|
||||
HKCU\Software\Classes\a301\shell\open\command → "launcher.exe" "%1"
|
||||
```
|
||||
|
||||
## 의존성
|
||||
|
||||
- `golang.org/x/sys/windows` — Win32 API
|
||||
- `golang.org/x/sys/windows/registry` — 레지스트리 접근
|
||||
479
download.go
Normal file
479
download.go
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
328
main.go
328
main.go
@@ -1,203 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
protocolName = "a301"
|
||||
gameExeName = "A301.exe"
|
||||
serverInfoURL = "https://a301.tolelom.xyz/api/download/info"
|
||||
webDownloadURL = "https://a301.tolelom.xyz"
|
||||
)
|
||||
|
||||
var (
|
||||
user32 = windows.NewLazySystemDLL("user32.dll")
|
||||
messageBoxW = user32.NewProc("MessageBoxW")
|
||||
shellExecuteW = windows.NewLazySystemDLL("shell32.dll").NewProc("ShellExecuteW")
|
||||
)
|
||||
|
||||
const (
|
||||
mbOK = 0x00000000
|
||||
mbIconInfo = 0x00000040
|
||||
mbIconError = 0x00000010
|
||||
mbYesNo = 0x00000004
|
||||
mbIconQuestion = 0x00000020
|
||||
idYes = 6
|
||||
)
|
||||
|
||||
func msgBox(title, text string, flags uintptr) int {
|
||||
t, _ := windows.UTF16PtrFromString(title)
|
||||
m, _ := windows.UTF16PtrFromString(text)
|
||||
ret, _, _ := messageBoxW.Call(
|
||||
0,
|
||||
uintptr(unsafe.Pointer(m)),
|
||||
uintptr(unsafe.Pointer(t)),
|
||||
flags,
|
||||
)
|
||||
return int(ret)
|
||||
}
|
||||
|
||||
func openBrowser(rawURL string) {
|
||||
u, _ := windows.UTF16PtrFromString(rawURL)
|
||||
op, _ := windows.UTF16PtrFromString("open")
|
||||
shellExecuteW.Call(0, uintptr(unsafe.Pointer(op)), uintptr(unsafe.Pointer(u)), 0, 0, 1)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
ret := msgBox(
|
||||
"A301 런처",
|
||||
"게임 실행을 위해 프로토콜을 등록합니다.\n계속하시겠습니까?",
|
||||
mbYesNo|mbIconQuestion,
|
||||
)
|
||||
if ret != idYes {
|
||||
return
|
||||
}
|
||||
if err := install(); err != nil {
|
||||
msgBox("A301 런처 - 오류", fmt.Sprintf("등록 실패:\n%v", err), mbOK|mbIconError)
|
||||
os.Exit(1)
|
||||
}
|
||||
msgBox("A301 런처", "a301:// 프로토콜이 등록되었습니다.\n이제 웹에서 게임 시작 버튼을 사용할 수 있습니다.", mbOK|mbIconInfo)
|
||||
return
|
||||
}
|
||||
|
||||
arg := os.Args[1]
|
||||
|
||||
switch {
|
||||
case arg == "install":
|
||||
if err := install(); err != nil {
|
||||
msgBox("A301 런처 - 오류", fmt.Sprintf("등록 실패:\n%v", err), mbOK|mbIconError)
|
||||
os.Exit(1)
|
||||
}
|
||||
msgBox("A301 런처", "a301:// 프로토콜이 등록되었습니다.", mbOK|mbIconInfo)
|
||||
|
||||
case arg == "uninstall":
|
||||
if err := uninstall(); err != nil {
|
||||
msgBox("A301 런처 - 오류", fmt.Sprintf("제거 실패:\n%v", err), mbOK|mbIconError)
|
||||
os.Exit(1)
|
||||
}
|
||||
msgBox("A301 런처", "a301:// 프로토콜이 제거되었습니다.", mbOK|mbIconInfo)
|
||||
|
||||
case strings.HasPrefix(arg, protocolName+"://"):
|
||||
if err := handleURI(arg); err != nil {
|
||||
msgBox("A301 런처 - 오류", fmt.Sprintf("실행 실패:\n%v", err), mbOK|mbIconError)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
default:
|
||||
msgBox("A301 런처 - 오류", fmt.Sprintf("알 수 없는 명령: %s", arg), mbOK|mbIconError)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func launcherPath() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Abs(exe)
|
||||
}
|
||||
|
||||
func install() error {
|
||||
exePath, err := launcherPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("실행 파일 경로를 찾을 수 없습니다: %w", err)
|
||||
}
|
||||
|
||||
key, _, err := registry.CreateKey(
|
||||
registry.CURRENT_USER,
|
||||
`Software\Classes\`+protocolName,
|
||||
registry.SET_VALUE,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("레지스트리 키 생성 실패: %w", err)
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
if err := key.SetStringValue("", "URL:A301 Protocol"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := key.SetStringValue("URL Protocol", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
defer cmdKey.Close()
|
||||
|
||||
cmdValue := fmt.Sprintf(`"%s" "%%1"`, exePath)
|
||||
return cmdKey.SetStringValue("", cmdValue)
|
||||
}
|
||||
|
||||
func uninstall() error {
|
||||
paths := []string{
|
||||
`Software\Classes\` + protocolName + `\shell\open\command`,
|
||||
`Software\Classes\` + protocolName + `\shell\open`,
|
||||
`Software\Classes\` + protocolName + `\shell`,
|
||||
`Software\Classes\` + protocolName,
|
||||
}
|
||||
for _, p := range paths {
|
||||
err := registry.DeleteKey(registry.CURRENT_USER, p)
|
||||
if err != nil && err != registry.ErrNotExist {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type downloadInfo struct {
|
||||
FileHash string `json:"fileHash"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func fetchServerInfo() (*downloadInfo, error) {
|
||||
resp, err := http.Get(serverInfoURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("서버 연결 실패: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var info downloadInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return nil, fmt.Errorf("서버 응답 파싱 실패: %w", err)
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// 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)
|
||||
@@ -205,53 +18,138 @@ func handleURI(rawURI string) error {
|
||||
return fmt.Errorf("URI 파싱 실패: %w", err)
|
||||
}
|
||||
|
||||
token := parsed.Query().Get("token")
|
||||
if token == "" {
|
||||
if parsed.Scheme != protocolName {
|
||||
return fmt.Errorf("지원하지 않는 URI 스킴: %s", parsed.Scheme)
|
||||
}
|
||||
|
||||
// 웹 클라이언트가 발급한 일회용 티켓을 서버에서 JWT로 교환
|
||||
ticket := parsed.Query().Get("token")
|
||||
if ticket == "" {
|
||||
return fmt.Errorf("토큰이 없습니다")
|
||||
}
|
||||
|
||||
exePath, err := launcherPath()
|
||||
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
|
||||
}
|
||||
gameDir := filepath.Dir(exePath)
|
||||
if err := os.MkdirAll(gameDir, 0755); err != nil {
|
||||
return fmt.Errorf("게임 디렉토리 생성 실패: %w", err)
|
||||
}
|
||||
gamePath := filepath.Join(gameDir, gameExeName)
|
||||
|
||||
if _, err := os.Stat(gamePath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("게임 파일을 찾을 수 없습니다:\n%s", gamePath)
|
||||
}
|
||||
// 프로토콜 등록이 현재 런처를 가리키도록 갱신 (사일런트)
|
||||
_ = install()
|
||||
|
||||
// 이전 업데이트에서 남은 .old/.new 파일 정리
|
||||
cleanupOldFiles(gameDir)
|
||||
|
||||
// 서버에서 최신 해시 조회
|
||||
serverInfo, err := fetchServerInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("버전 확인 실패:\n%w", err)
|
||||
// 오프라인 모드: 게임이 이미 설치되어 있으면 직접 실행
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
// 서버에 해시가 등록된 경우에만 검증
|
||||
if serverInfo.FileHash != "" {
|
||||
localHash, err := hashFile(gamePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("게임 파일 검증 실패:\n%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 !strings.EqualFold(localHash, serverInfo.FileHash) {
|
||||
ret := msgBox(
|
||||
"A301 - 업데이트 필요",
|
||||
"새로운 버전의 게임이 있습니다.\n최신 버전을 다운로드해주세요.\n\n확인을 누르면 다운로드 페이지로 이동합니다.",
|
||||
mbOK|mbIconInfo,
|
||||
)
|
||||
if ret > 0 {
|
||||
openBrowser(webDownloadURL)
|
||||
}
|
||||
return fmt.Errorf("버전이 최신이 아닙니다")
|
||||
}
|
||||
if err := ensureGame(gameDir, gamePath, serverInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command(gamePath, "-token", token)
|
||||
cmd.Dir = gameDir
|
||||
cmd.Env = append(os.Environ(), "A301_TOKEN="+token)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("게임 실행 실패: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
// DLL Hijacking 방어: 시스템 디렉토리에서만 DLL 로드
|
||||
const loadLibrarySearchSystem32 = 0x00000800
|
||||
kernel32.NewProc("SetDefaultDllDirectories").Call(loadLibrarySearchSystem32)
|
||||
|
||||
enableDPIAwareness()
|
||||
|
||||
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)
|
||||
}
|
||||
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":
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
399
main_test.go
Normal file
399
main_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
190
protocol.go
Normal file
190
protocol.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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=..."
|
||||
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 }
|
||||
|
||||
type downloadInfo struct {
|
||||
FileHash string `json:"fileHash"`
|
||||
URL string `json:"url"`
|
||||
LauncherURL string `json:"launcherUrl"`
|
||||
LauncherHash string `json:"launcherHash"`
|
||||
}
|
||||
|
||||
// installDir returns the fixed install directory: %LOCALAPPDATA%\A301
|
||||
func installDir() (string, error) {
|
||||
localAppData := os.Getenv("LOCALAPPDATA")
|
||||
if localAppData == "" {
|
||||
return "", fmt.Errorf("LOCALAPPDATA 환경변수를 찾을 수 없습니다")
|
||||
}
|
||||
return filepath.Join(localAppData, "A301"), nil
|
||||
}
|
||||
|
||||
// launcherPath returns the current executable's absolute path.
|
||||
func launcherPath() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Abs(exe)
|
||||
}
|
||||
|
||||
func install() error {
|
||||
srcPath, err := launcherPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("실행 파일 경로를 찾을 수 없습니다: %w", err)
|
||||
}
|
||||
|
||||
dir, err := installDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
key, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Classes\`+protocolName, registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return fmt.Errorf("레지스트리 키 생성 실패: %w", err)
|
||||
}
|
||||
defer key.Close()
|
||||
if err := key.SetStringValue("", "URL:One of the plans Protocol"); err != nil {
|
||||
return fmt.Errorf("프로토콜 값 설정 실패: %w", err)
|
||||
}
|
||||
if err := key.SetStringValue("URL Protocol", ""); err != nil {
|
||||
return fmt.Errorf("URL Protocol 값 설정 실패: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
defer cmdKey.Close()
|
||||
return cmdKey.SetStringValue("", fmt.Sprintf(`"%s" "%%1"`, dstPath))
|
||||
}
|
||||
|
||||
func uninstall() error {
|
||||
paths := []string{
|
||||
`Software\Classes\` + protocolName + `\shell\open\command`,
|
||||
`Software\Classes\` + protocolName + `\shell\open`,
|
||||
`Software\Classes\` + protocolName + `\shell`,
|
||||
`Software\Classes\` + protocolName,
|
||||
}
|
||||
for _, p := range paths {
|
||||
if err := registry.DeleteKey(registry.CURRENT_USER, p); err != nil && err != registry.ErrNotExist {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
var noRetry *errNoRetry
|
||||
if errors.As(err, &noRetry) {
|
||||
return nil, err
|
||||
}
|
||||
time.Sleep(time.Duration(1<<i) * time.Second)
|
||||
}
|
||||
return nil, fmt.Errorf("서버 연결 실패 (%d회 재시도): %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// redeemTicket exchanges a one-time launch ticket for a fresh JWT access token.
|
||||
func redeemTicket(ticket string) (string, error) {
|
||||
return redeemTicketFrom(redeemTicketURL, ticket)
|
||||
}
|
||||
|
||||
func redeemTicketFrom(url, ticket string) (string, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
payload, err := json.Marshal(map[string]string{"ticket": ticket})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("요청 데이터 생성 실패: %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 != 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
|
||||
}
|
||||
387
ui.go
Normal file
387
ui.go
Normal file
@@ -0,0 +1,387 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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.
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
func initCommonControls() {
|
||||
icc := initCommonControlsExS{
|
||||
dwSize: uint32(unsafe.Sizeof(initCommonControlsExS{})),
|
||||
dwICC: iccProgressClass,
|
||||
}
|
||||
initCommonControlsExProc.Call(uintptr(unsafe.Pointer(&icc)))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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) }
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user