Compare commits

...

35 Commits

Author SHA1 Message Date
4c32817f7e docs: CLAUDE.md의 하드코딩된 재시도 횟수를 apiRetryCount 참조로 수정
Some checks failed
CI/CD / test (push) Has been cancelled
CI/CD / release (push) Has been cancelled
2026-04-13 03:10:18 +09:00
3c46b55f93 docs: CLAUDE.md 파일 구조 6파일로 업데이트 2026-04-13 03:06:13 +09:00
e09513f8d9 refactor: apiRetryCount 상수를 doc comment와 에러 메시지에도 반영
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 03:05:02 +09:00
bf19d5d542 refactor: apiRetryCount·maxJSONBodySize 상수화, bytes.NewReader 수정
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 03:00:34 +09:00
e1d0e6fed0 refactor: downloadBody 문서화 개선
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:56:10 +09:00
208b2d3189 refactor: doDownload에서 downloadBody 분리
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:47:47 +09:00
4cd118cef0 refactor: tmpZipName 상수화, formatProgress 속도 미확정 처리 수정
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:40:01 +09:00
42c00b37d5 refactor: Win32 선언부를 win32.go로 분리
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:36:01 +09:00
281a365952 refactor: 코드 가독성 개선 및 버그 수정
Some checks failed
CI/CD / test (push) Has been cancelled
CI/CD / release (push) Has been cancelled
- main.go를 main()만 남기고 함수 분리 (game.go, protocol.go, ui.go)
- 재시도 로직을 retryWithBackoff 공통 함수로 통합
- redeemTicketFrom 별도 HTTP 클라이언트 → apiClient 사용으로 통일
- doDownload에서 resumeOffset 이중 계산 제거
- extractZip에서 stripTopDir/extractFile 함수 분리
- downloadWithProgress에서 createProgressWindow 함수 분리
- DLL 선언을 DLL별로 그룹화, 상수를 역할별로 분리
- 전체 주석 한국어 통일 및 섹션 구분 추가

버그 수정:
- ensureLauncher가 설치 경로 대신 실행 중인 경로를 해시하던 문제 수정
- uninstall 시 실행 중인 exe 삭제 실패 → 백그라운드 cmd로 대체
- moveContents에서 os.Remove 에러를 무시하던 문제 수정
- install/uninstall 메시지 통일, exitWithError 헬퍼 추가
- .gitignore에 *.exe 통일, ANALYSIS.md 삭제
- 빌드 명령에 git 태그 기반 버전 주입 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 02:17:51 +09:00
742712aa49 fix: 게임에 토큰이 전달되지 않아 로그인 화면이 뜨는 버그 수정
Some checks failed
CI/CD / test (push) Has been cancelled
CI/CD / release (push) Has been cancelled
환경변수(A301_TOKEN)로 토큰을 전달했지만 게임은 커맨드라인 인자(-token)를
읽도록 되어있어 토큰이 전달되지 않던 문제.
exec.Command에 -token 인자를 추가하여 게임의 AuthState가 토큰을 받도록 수정.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:21:23 +09:00
0932ecd39e fix: 아키텍처 리뷰 이슈 3건 수정
Some checks failed
CI/CD / test (push) Has been cancelled
CI/CD / release (push) Has been cancelled
- 명령줄 토큰 노출 제거 — exec.Command에서 -token 인자 제거, 환경변수(A301_TOKEN)만 사용
- redeemTicket 재시도 추가 — 3회 exponential backoff, 4xx는 즉시 실패
- 임시 추출 디렉토리 defer os.RemoveAll 추가 — 중복 정리 코드 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 15:57:02 +09:00
b026520b35 fix: 런처 안정성 개선 (4건)
Some checks failed
CI/CD / test (push) Has been cancelled
CI/CD / release (push) Has been cancelled
- ticket JSON 직렬화 json.Marshal 사용 (특수문자 안전)
- 4xx 에러 메시지 "서버 오류"→"요청 실패" 수정
- 자동 업데이트 실패 시 stderr 로깅 추가
- 서버 URL을 ldflags로 오버라이드 가능하도록 var 전환

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:26:41 +09:00
d55be620bd Merge branch 'main' of https://git.tolelom.xyz/A301/a301_launcher
# Conflicts:
#	main.go
2026-03-18 21:39:57 +09:00
03345d18b9 ci: Gitea 환경으로 전환 (릴리즈 GitHub Actions → Gitea API)
Some checks failed
CI/CD / test (push) Has been cancelled
CI/CD / release (push) Has been cancelled
- softprops/action-gh-release 제거
- Gitea REST API (PowerShell)로 릴리즈 생성 + launcher.exe 업로드

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:25:31 +09:00
1e7aebf6b0 ci: go vet + go test 추가 + Go 1.25 업그레이드 + 태그 릴리즈 빌드
- test job: go vet + build + go test ./... (Windows runner)
- release job: 'v*' 태그 push 시 launcher.exe를 GitHub Release에 업로드
- Go 1.24 → 1.25 업그레이드

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:22:01 +09:00
84dd2373a4 refactor: 단일 파일을 main/ui/download/protocol 4개 파일로 분리
- main.go: 진입점(main), handleURI, version
- ui.go: Win32 UI (progress window, DPI, 폰트, 메시지박스)
- download.go: 다운로드/추출 로직 (HTTP client, extractZip, doDownload)
- protocol.go: 레지스트리 등록/해제, ensureGame, ensureLauncher, 서버 API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 17:11:41 +09:00
8759587e25 test: 보안 크리티컬 함수 유닛 테스트 20개 추가
- extractZip: ZipSlip/NTFS ADS/Symlink 차단 검증 포함
- hashFile: 정상/빈파일/미존재 케이스
- redeemTicket: httptest 기반 서버 응답 처리
- URI 파싱: 정상/파라미터 누락/잘못된 스킴

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:58:53 +09:00
19b4d4895f feat: ticket redeem 인증 + 토큰 이중 전달 + 보안/품질 개선
- launch ticket을 서버에서 redeem하여 새 JWT 획득 (토큰 수명 문제 해결)
- 게임에 커맨드라인 -token + 환경변수 A301_TOKEN 이중 전달
- fileHash 빈 문자열 이중 방어 (변조된 게임 실행 차단)
- Win32 API 반환값 검증 (RegisterClassEx, CreateWindowEx)
- 버전 ldflags 주입 지원 (var version = "dev")

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:42:43 +09:00
13b44b04a2 ci: GitHub Actions 워크플로우 추가
Windows Go 빌드 자동화 (push/PR on main)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:57:20 +09:00
a8d9ab9d36 feat: 오프라인 모드 + 다운로드 UX + 언인스톨 개선
오프라인 모드:
- 서버 미응답 시 설치된 게임 직접 실행 옵션

다운로드 UX:
- 속도(MB/s) + 남은 시간 표시 (초/분)

언인스톨:
- 게임 데이터 삭제 여부 사용자 선택
- --version 플래그 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:51:18 +09:00
66dc8a14de feat: 런처 자동 업데이트 + 보안 수정
자동 업데이트:
- ensureLauncher(): SHA256 비교 → 다운로드 → 해시 검증 → rename-dance 교체
- 첫 실행 시 확인 대화 제거 → 자동 설치
- handleURI() 진입 시 프로토콜 등록 자동 갱신
- .old/.new 잔여 파일 자동 정리

보안:
- DLL Hijacking 방어 (SetDefaultDllDirectories)
- ZIP 경로 탈출 강화 (절대경로/NTFS ADS 거부)
- UTF16 에러 처리 (msgBox, setProgress)
- out.Close() 에러 체크

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:37:57 +09:00
a28510df57 feat: 일회용 ticket을 서버에서 JWT로 교환하는 로직 추가
브라우저에서 a301://launch?ticket=<hex> 형태로 호출 시
POST /api/auth/redeem-ticket으로 JWT를 받아 게임에 전달.
기존 token 파라미터 하위 호환 유지.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 21:28:36 +09:00
574a6ee277 fix: DPI 스케일링 정밀도 개선 및 토큰 형식 검증
- DPI 계산을 float64 기반으로 변경하여 반올림 정확도 향상
- JWT 토큰 3-part 형식 사전 검증 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:40:12 +09:00
b71c0d7baf fix: URI 스킴 검증 추가
handleURI에서 parsed.Scheme이 "a301"인지 검증하여
잘못된 스킴의 URI 입력 방지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:49:54 +09:00
a0face763a fix: 다운로드·파일 처리 버그 수정 및 에러 핸들링 강화
- 416 응답 시 무한 재귀를 doDownloadRequest 반복문으로 교체
- copyFile에서 out.Close() 에러를 반환하도록 수정
- ContentLength=-1일 때 잘못된 total 계산 방지
- fetchServerInfo 재시도 로직을 errNoRetry 타입 에러로 교체
- extractZip에서 최상위 디렉토리 엔트리 스킵 처리
- moveContents 크로스 드라이브 복사 후 원본 파일 삭제
- 진행률 100% 초과 클램핑
- install() SetStringValue 에러 체크 추가
- handleURI에서 gameDir 미존재 시 MkdirAll 방어 코드 추가
- ensureGame에서 os.Stat 비정상 에러 명시적 처리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:08:39 +09:00
48df55a82e fix: 보안 강화, 안정성 및 UX 개선
- 토큰 전달 방식 변경: 명령줄 인자(-token) → 환경변수(A301_TOKEN)로 프로세스 목록 노출 방지
- 고정 설치 경로: %LOCALAPPDATA%\A301\로 런처 복사 후 레지스트리 등록 (Downloads 정리 시 깨짐 방지)
- zip 추출 시 symlink 엔트리 스킵 (경로 탈출 방지)
- fetchServerInfo 3회 재시도 (exponential backoff)
- 다운로드 이어받기: Range 헤더 지원, 취소/오류 시 임시 파일 유지
- 416 응답 시 서버 파일 변경 감지하여 처음부터 재다운로드
- 단일 인스턴스 UX: 기존 창 FindWindow+SetForegroundWindow로 활성화
- uninstall 시 설치 디렉토리 정리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:10:11 +09:00
9bb422f9b2 fix: 보안 강화 및 안정성 개선
- extractZip: io.LimitReader 적용으로 zip bomb 방어 (개별 파일 4GB 제한)
- moveContents: cross-drive 복사 실패 시 부분 파일 제거

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:51:43 +09:00
9fb98b0028 fix: 보안 및 안정성 보강
- Zip Slip 경로 검증 추가
- HTTP 상태 코드 검증 (doDownload)
- HTTP 타임아웃 설정 (API/다운로드 클라이언트 분리)
- 다운로드 URL 스킴 검증 (https/http만 허용)
- 리다이렉트 스킴 제한 (CheckRedirect)
- 다운로드 크기 제한 (2GB)
- fetchServerInfo 응답 크기 제한 (1MB)
- 다운로드 후 해시 검증
- 다중 인스턴스 실행 방지 (CreateMutexW)
- 다운로드 취소 기능 (wmClose 핸들러)
- 압축 해제 실패 시 잔여 파일 정리 (임시 디렉토리 추출)
- 도달 불가능한 dead code 및 미사용 코드 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:04:45 +09:00
10651d294a docs: README, CLAUDE.md 작성
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 00:20:43 +09:00
f77a7e0e23 feat: 게임 가제 'One of the plans' UI 텍스트 적용
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 00:18:13 +09:00
28c1b377df feat: 다운로드 창 다크 테마 적용
- 배경 #2E2C2F, 타이틀 #BACDB0(강조색), 상태 텍스트 밝은 회색
- WM_CTLCOLORSTATIC으로 STATIC 컨트롤 색상 제어
- SetWindowTheme + PBM_SETBARCOLOR로 진행 막대 색상 변경
- "A301" 타이틀 레이블(13pt bold) + 상태 레이블(9pt) 분리
- 웹사이트 색상 팔레트와 통일

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 00:14:39 +09:00
22b5efdaab feat: DPI 인식 및 진행 막대 UI 추가
- SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2)로 고DPI 디스플레이 지원
- GetDpiForSystem()으로 시스템 DPI 조회 후 모든 크기 동적 스케일링
- 다운로드 진행창에 msctls_progress32 진행 막대 추가 (0~100%)
- Segoe UI 9pt 폰트를 DPI에 맞게 CreateFontIndirectW로 생성
- 4K(200% / 192 DPI)와 FHD(100% / 96 DPI) 모두 동일한 시각적 크기 보장

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 00:08:06 +09:00
6fafe1f3af fix: serverInfoURL을 API 서버 주소로 수정
프론트엔드 nginx에 /api/ 프록시 없음 → index.html 반환 → JSON 파싱 실패
직접 API 서버(a301.api.tolelom.xyz)로 요청하도록 변경

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 23:40:48 +09:00
90fcc0f94e fix: 서버 상태 코드별 명확한 에러 메시지 처리
- 404: 게임이 아직 준비되지 않았습니다
- 4xx/5xx: 서버 오류 (HTTP 상태코드)
- JSON 파싱 실패는 진짜 비정상 응답일 때만 표시

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 23:38:24 +09:00
ad9d372d7c feat: 자동 다운로드/설치 기능 추가
- 게임 파일 없거나 해시 불일치 시 서버에서 zip 자동 다운로드
- Win32 진행률 창으로 다운로드 진행률 표시 (X% 업데이트)
- zip 압축 해제 후 게임 실행 (런처 자신은 덮어쓰기 방지)
- 더블클릭 시 프로토콜만 등록 (토큰 없으므로 다운로드 불필요)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 23:00:19 +09:00
11 changed files with 2098 additions and 238 deletions

85
.github/workflows/ci.yml vendored Normal file
View 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

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# Build output # Build output
launcher.exe *.exe
# IDE # IDE
.idea/ .idea/

68
CLAUDE.md Normal file
View File

@@ -0,0 +1,68 @@
# CLAUDE.md
## Build Command
```bash
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` 필수 — 없으면 실행 시 콘솔 창이 함께 열림.
- `-X main.version=...` — git 태그 기반 버전 자동 주입. 태그가 있으면 `v1.0.0`, 태그 이후 커밋이 있으면 `v1.0.0-3-gabcdef` 형태.
## Tech Stack
- **Go** 6파일 구조 (`main.go`, `win32.go`, `ui.go`, `download.go`, `protocol.go`, `game.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
역할별 6파일 구조:
| 파일 | 담당 |
|---|---|
| `main.go` | 진입점(`main`), 단일 인스턴스, `handleURI`, version |
| `win32.go` | Win32 상수, DLL/proc 선언, 구조체 |
| `ui.go` | WndProc, progress window, DPI, font, msgBox |
| `download.go` | HTTP 클라이언트, 다운로드/추출/해시, `ensureGame`, `ensureLauncher` |
| `protocol.go` | 상수, URI 프로토콜 등록/해제, `redeemTicket`, `fetchServerInfo` |
| `game.go` | `handleURI`, 게임 실행 흐름 |
주요 함수:
- **`downloadWithProgress()`** — Win32 메시지 루프 직접 운영. 반드시 메인 고루틴에서 호출 (`runtime.LockOSThread`).
- **`fetchServerInfo()`** — `apiRetryCount`회 재시도 (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()``apiRetryCount`회 재시도 (exponential backoff).
- `doDownload()` — Range 헤더로 이어받기 지원. 취소/오류 시 임시 파일 유지.

64
README.md Normal file
View 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` — 레지스트리 접근

573
download.go Normal file
View File

@@ -0,0 +1,573 @@
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 // 2 GB
const maxExtractFileSize = 4 << 30 // 개별 파일 최대 4 GB
const tmpZipName = "a301_game.zip" // 임시 다운로드 파일명
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)
}
if len(via) >= 10 {
return fmt.Errorf("리다이렉트 횟수 초과")
}
return nil
}
// apiClient 서버 정보 조회 등 짧은 요청용 (전체 120초 타임아웃).
var apiClient = &http.Client{
Timeout: 120 * time.Second,
CheckRedirect: checkRedirect,
}
// downloadClient 대용량 파일 다운로드용 (전체 타임아웃 없음).
var downloadClient = &http.Client{
Transport: &http.Transport{
TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
IdleConnTimeout: 60 * time.Second,
},
CheckRedirect: checkRedirect,
}
// ── 파일 다운로드 ────────────────────────────────────────────
// doDownloadRequest Range 헤더로 이어받기를 시도한다.
// 서버가 416(범위 불일치)을 반환하면 임시 파일을 삭제하고 처음부터 다시 요청한다.
func doDownloadRequest(downloadURL, tmpPath string) (resp *http.Response, resumeOffset int64, err error) {
for attempt := 0; attempt < 2; attempt++ {
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, 0, 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, 0, fmt.Errorf("다운로드 연결 실패: %w", err)
}
if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable {
resp.Body.Close()
os.Remove(tmpPath)
continue
}
return resp, resumeOffset, nil
}
return nil, 0, fmt.Errorf("다운로드 실패: 재시도 횟수 초과")
}
// 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
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 nil, 0, 0, fmt.Errorf("다운로드 실패 (HTTP %d)", resp.StatusCode)
}
if err != nil {
return nil, 0, 0, fmt.Errorf("임시 파일 열기 실패: %w", err)
}
if total > maxDownloadSize {
tmpFile.Close()
os.Remove(tmpPath)
return nil, 0, 0, fmt.Errorf("파일이 너무 큽니다 (%d bytes)", total)
}
return tmpFile, downloaded, total, nil
}
// formatProgress 다운로드 진행률 텍스트를 생성한다.
func formatProgress(pct int, speedBytesPerSec float64, remaining float64) string {
if speedBytesPerSec <= 0 {
return fmt.Sprintf("다운로드 중... %d%%", pct)
}
speedMB := speedBytesPerSec / 1024 / 1024
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))
}
// downloadBody 응답 본문을 tmpFile에 쓰고 진행률을 갱신한다.
// downloaded는 이어받기 시작 오프셋, total은 전체 크기(미확정이면 0).
// tmpPath는 크기 초과 시 파일 삭제에만 사용된다.
// 완료 또는 오류 시 tmpFile을 닫는다.
func downloadBody(resp *http.Response, tmpFile *os.File, tmpPath string, downloaded, total int64) error {
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, readErr := 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("다운로드 크기가 제한을 초과했습니다")
}
// 500ms마다 속도 계산 및 진행률 갱신
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
}
remaining := float64(total-downloaded) / speedBytesPerSec
setProgress(formatProgress(pct, speedBytesPerSec, remaining), pct)
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
tmpFile.Close()
return fmt.Errorf("다운로드 중 오류: %w", readErr)
}
}
return tmpFile.Close()
}
// doDownload 파일을 다운로드하고 zip을 추출하여 destDir에 배치한다.
// 다운로드 완료 후 zip 파일은 tmpZipName 경로에 남겨둔다 (ensureGame에서 해시 검증 후 삭제).
func doDownload(downloadURL, destDir string) error {
tmpPath := filepath.Join(os.TempDir(), tmpZipName)
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
}
if err := downloadBody(resp, tmpFile, tmpPath, downloaded, total); err != nil {
return err
}
setProgress("압축을 해제하는 중...", -1)
tmpExtractDir, err := os.MkdirTemp("", "a301_extract_")
if err != nil {
return fmt.Errorf("임시 추출 디렉토리 생성 실패: %w", err)
}
defer os.RemoveAll(tmpExtractDir)
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 {
return fmt.Errorf("zip 열기 실패: %w", err)
}
defer r.Close()
strip := hasWrapperDir(r.File)
selfName := strings.ToLower(filepath.Base(os.Args[0]))
for _, f := range r.File {
rel := resolveZipEntry(f.Name, strip)
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)
}
dest := filepath.Join(destDir, filepath.FromSlash(rel))
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
}
if f.FileInfo().IsDir() {
os.MkdirAll(dest, 0755)
continue
}
if err := extractFile(f, dest); err != nil {
return err
}
}
return nil
}
// hasWrapperDir zip의 모든 엔트리가 동일한 단일 최상위 디렉토리 아래에 있는지 확인한다.
// 예: "game/A301.exe", "game/Data/" → true ("game"이 래퍼)
// "A301.exe", "A301_Data/" → false (래퍼 없음)
func hasWrapperDir(files []*zip.File) bool {
if len(files) == 0 {
return false
}
var wrapper string
for _, f := range files {
clean := filepath.ToSlash(f.Name)
parts := strings.SplitN(clean, "/", 2)
top := parts[0]
if len(parts) == 1 && !f.FileInfo().IsDir() {
// 루트 레벨에 파일이 있으면 래퍼 디렉토리 아님
return false
}
if wrapper == "" {
wrapper = top
} else if top != wrapper {
// 최상위에 여러 폴더/파일 → 래퍼 아님
return false
}
}
return wrapper != ""
}
// resolveZipEntry zip 엔트리의 경로를 반환한다.
// strip=true이면 최상위 디렉토리를 제거한다.
func resolveZipEntry(name string, strip bool) string {
clean := filepath.ToSlash(name)
if !strip {
// 래퍼 없음: 디렉토리 엔트리("/")만 빈 문자열로 반환
clean = strings.TrimSuffix(clean, "/")
if clean == "" {
return ""
}
return clean
}
// 래퍼 제거
parts := strings.SplitN(clean, "/", 2)
if len(parts) < 2 || parts[1] == "" {
return "" // 래퍼 디렉토리 자체
}
return parts[1]
}
// 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 {
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 {
// 기존 파일 삭제 후 이동. 삭제 실패 시(파일 잠금 등) 에러 반환.
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)
return err
}
os.Remove(src)
}
}
}
return nil
}
// copyFile src를 dst로 복사한다.
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()
}
// hashFile 파일의 SHA-256 해시를 계산한다.
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
}
// ── 게임/런처 업데이트 ──────────────────────────────────────
const hashFileName = ".filehash" // 마지막 설치된 zip 해시를 저장하는 파일
// readLocalHash 로컬에 저장된 마지막 zip 해시를 읽는다.
func readLocalHash(gameDir string) string {
data, err := os.ReadFile(filepath.Join(gameDir, hashFileName))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
// writeLocalHash zip 해시를 로컬에 저장한다.
func writeLocalHash(gameDir, hash string) {
os.WriteFile(filepath.Join(gameDir, hashFileName), []byte(hash), 0644)
}
// ensureGame 게임 파일이 최신인지 확인하고 필요 시 다운로드한다.
// 서버의 fileHash는 zip 파일 전체의 해시이므로, 로컬에 저장된 해시와 비교한다.
func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error {
if serverInfo.FileHash == "" {
return fmt.Errorf("서버에서 유효한 파일 정보를 받지 못했습니다")
}
// 게임 파일이 없거나 로컬 해시가 서버와 다르면 다운로드 필요
localHash := readLocalHash(gameDir)
if _, err := os.Stat(gamePath); err == nil && strings.EqualFold(localHash, serverInfo.FileHash) {
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)
}
// 다운로드된 zip의 해시를 검증 후 삭제
tmpPath := filepath.Join(os.TempDir(), tmpZipName)
defer os.Remove(tmpPath)
zipHash, err := hashFile(tmpPath)
if err != nil {
return fmt.Errorf("다운로드 후 파일 검증 실패: %w", err)
}
if !strings.EqualFold(zipHash, serverInfo.FileHash) {
return fmt.Errorf("다운로드된 파일의 해시가 일치하지 않습니다 (파일이 손상되었을 수 있습니다)")
}
writeLocalHash(gameDir, serverInfo.FileHash)
return nil
}
// downloadFile url에서 destPath로 파일을 다운로드한다.
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 설치된 런처가 최신인지 확인하고 필요 시 교체한다.
// 항상 설치 경로(%LOCALAPPDATA%\A301\launcher.exe)를 대상으로 한다.
func ensureLauncher(serverInfo *downloadInfo) (updated bool, err error) {
if serverInfo.LauncherHash == "" {
return false, nil
}
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
}
dlURL := serverInfo.LauncherURL
if dlURL == "" {
return false, nil
}
// 새 런처를 .new로 다운로드 → 해시 검증 → 기존 파일과 교체
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("런처 해시 불일치")
}
// 원자적 교체: 기존→.old, .new→기존
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 이전 런처 업데이트에서 남은 .old/.new 파일을 제거한다.
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))
}
}
}

141
game.go Normal file
View File

@@ -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
}

273
main.go
View File

@@ -1,257 +1,56 @@
package main package main
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt" "fmt"
"io"
"net/http"
"net/url"
"os" "os"
"os/exec"
"path/filepath"
"strings" "strings"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
) )
const ( // version is set at build time via -ldflags "-X main.version=x.y.z"
protocolName = "a301" var version = "dev"
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() { func main() {
if len(os.Args) < 2 { // DLL Hijacking 방어: DLL 탐색 경로를 System32로만 제한한다.
ret := msgBox( // 게임 폴더처럼 사용자가 파일을 쓸 수 있는 디렉토리에
"A301 런처", // 악성 DLL이 심겨 있어도 로드되지 않는다.
"게임 실행을 위해 프로토콜을 등록합니다.\n계속하시겠습니까?", // 반드시 다른 DLL이 로드되기 전 가장 먼저 호출해야 한다.
mbYesNo|mbIconQuestion, const loadLibrarySearchSystem32 = 0x00000800
) kernel32.NewProc("SetDefaultDllDirectories").Call(loadLibrarySearchSystem32)
if ret != idYes {
return // Windows에게 "DPI는 내가 직접 처리한다"고 선언한다.
} // 이 선언 없이는 OS가 창을 통째로 확대해 흐릿하게 표시한다.
if err := install(); err != nil { // Per-Monitor V2: 모니터마다 DPI가 달라도 각각 대응 가능.
msgBox("A301 런처 - 오류", fmt.Sprintf("등록 실패:\n%v", err), mbOK|mbIconError) enableDPIAwareness()
os.Exit(1)
} // 단일 인스턴스 보장: 이미 실행 중이면 기존 창을 앞으로 가져오고 종료한다.
msgBox("A301 런처", "a301:// 프로토콜이 등록되었습니다.\n이제 웹에서 게임 시작 버튼을 사용할 수 있습니다.", mbOK|mbIconInfo) // 내부적으로 Named Mutex("Global\A301LauncherMutex")로 중복 실행을 감지한다.
if !acquireSingleInstance() {
activateExistingWindow()
return return
} }
arg := os.Args[1] // 인수 결정: 없으면 "install" (더블클릭 = 최초 설치)
arg := "install"
if len(os.Args) >= 2 {
arg = os.Args[1]
}
switch { // a301://... URI는 별도 처리 (HasPrefix라 switch로 분기 불가)
case arg == "install": if strings.HasPrefix(arg, protocolName+"://") {
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 { if err := handleURI(arg); err != nil {
msgBox("A301 런처 - 오류", fmt.Sprintf("실행 실패:\n%v", err), mbOK|mbIconError) exitWithError(fmt.Sprintf("실행 실패:\n%v", err))
os.Exit(1)
} }
return
}
switch arg {
case "install":
handleInstall()
case "uninstall":
handleUninstall()
case "--version", "version":
msgBox("One of the plans 런처", fmt.Sprintf("버전: %s", version), mbOK|mbInfo)
default: default:
msgBox("A301 런처 - 오류", fmt.Sprintf("알 수 없는 명령: %s", arg), mbOK|mbIconError) exitWithError(fmt.Sprintf("알 수 없는 명령: %s", arg))
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
}
func handleURI(rawURI string) error {
parsed, err := url.Parse(rawURI)
if err != nil {
return fmt.Errorf("URI 파싱 실패: %w", err)
}
token := parsed.Query().Get("token")
if token == "" {
return fmt.Errorf("토큰이 없습니다")
}
exePath, err := launcherPath()
if err != nil {
return err
}
gameDir := filepath.Dir(exePath)
gamePath := filepath.Join(gameDir, gameExeName)
if _, err := os.Stat(gamePath); os.IsNotExist(err) {
return fmt.Errorf("게임 파일을 찾을 수 없습니다:\n%s", gamePath)
}
// 서버에서 최신 해시 조회
serverInfo, err := fetchServerInfo()
if err != nil {
return fmt.Errorf("버전 확인 실패:\n%w", err)
}
// 서버에 해시가 등록된 경우에만 검증
if serverInfo.FileHash != "" {
localHash, err := hashFile(gamePath)
if err != nil {
return fmt.Errorf("게임 파일 검증 실패:\n%w", err)
}
if !strings.EqualFold(localHash, serverInfo.FileHash) {
ret := msgBox(
"A301 - 업데이트 필요",
"새로운 버전의 게임이 있습니다.\n최신 버전을 다운로드해주세요.\n\n확인을 누르면 다운로드 페이지로 이동합니다.",
mbOK|mbIconInfo,
)
if ret > 0 {
openBrowser(webDownloadURL)
}
return fmt.Errorf("버전이 최신이 아닙니다")
}
}
cmd := exec.Command(gamePath, "-token", token)
cmd.Dir = gameDir
if err := cmd.Start(); err != nil {
return fmt.Errorf("게임 실행 실패: %w", err)
}
return nil
}

413
main_test.go Normal file
View File

@@ -0,0 +1,413 @@
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()
origURL := redeemTicketURL
redeemTicketURL = srv.URL
defer func() { redeemTicketURL = origURL }()
token, err := redeemTicket("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()
origURL := redeemTicketURL
redeemTicketURL = srv.URL
defer func() { redeemTicketURL = origURL }()
_, err := redeemTicket("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()
origURL := redeemTicketURL
redeemTicketURL = srv.URL
defer func() { redeemTicketURL = origURL }()
_, err := redeemTicket("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()
origURL := redeemTicketURL
redeemTicketURL = srv.URL
defer func() { redeemTicketURL = origURL }()
_, err := redeemTicket("ticket")
if err == nil {
t.Fatal("빈 토큰에 에러가 반환되지 않음")
}
}
func TestRedeemTicket_Unreachable(t *testing.T) {
origURL := redeemTicketURL
redeemTicketURL = "http://127.0.0.1:1"
defer func() { redeemTicketURL = origURL }()
_, err := redeemTicket("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)
}
}

255
protocol.go Normal file
View File

@@ -0,0 +1,255 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"golang.org/x/sys/windows/registry"
)
// ── 상수 및 타입 ──────────────────────────────────────────────
const (
protocolName = "a301"
gameExeName = "A301.exe"
apiRetryCount = 3 // fetchServerInfo, redeemTicket 재시도 횟수
maxJSONBodySize = 1 << 20 // JSON 응답 바디 최대 1MB
)
// serverInfoURL, redeemTicketURL은 빌드 시 -ldflags로 오버라이드 가능.
var (
serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info"
redeemTicketURL = "https://a301.api.tolelom.xyz/api/auth/redeem-ticket"
)
// downloadInfo 서버에서 받아오는 게임/런처 다운로드 정보.
type downloadInfo struct {
FileHash string `json:"fileHash"`
URL string `json:"url"`
LauncherURL string `json:"launcherUrl"`
LauncherHash string `json:"launcherHash"`
}
// 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 == "" {
return "", fmt.Errorf("LOCALAPPDATA 환경변수를 찾을 수 없습니다")
}
return filepath.Join(localAppData, "A301"), nil
}
// launcherPath 현재 실행 중인 런처의 절대 경로를 반환한다.
func launcherPath() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
}
return filepath.Abs(exe)
}
// ── 설치/제거 ─────────────────────────────────────────────────
// install 런처를 설치 경로로 복사하고 a301:// 프로토콜을 레지스트리에 등록한다.
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)
}
}
// 레지스트리에 a301:// 프로토콜 등록
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)
}
// 프로토콜 핸들러 명령 등록: "%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)
}
defer cmdKey.Close()
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`,
`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
}
// handleInstall 런처를 설치하고 프로토콜을 등록한다.
func handleInstall() {
if err := install(); err != nil {
exitWithError(fmt.Sprintf("설치 실패:\n%v", err))
}
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<<i) * time.Second)
}
return lastErr
}
// fetchServerInfo 서버에서 게임/런처 다운로드 정보를 조회한다 (apiRetryCount회 재시도).
func fetchServerInfo() (*downloadInfo, error) {
var info *downloadInfo
err := retryWithBackoff(apiRetryCount, func() error {
resp, err := apiClient.Get(serverInfoURL)
if err != nil {
return fmt.Errorf("서버 연결 실패: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return &errNoRetry{fmt.Errorf("게임이 아직 준비되지 않았습니다")}
}
if resp.StatusCode >= 400 {
return &errNoRetry{fmt.Errorf("요청 실패 (HTTP %d)", resp.StatusCode)}
}
var result downloadInfo
if err := json.NewDecoder(io.LimitReader(resp.Body, maxJSONBodySize)).Decode(&result); err != nil {
return fmt.Errorf("서버 응답 파싱 실패: %w", err)
}
info = &result
return nil
})
if err != nil {
return nil, fmt.Errorf("서버 연결 실패 (%d회 재시도): %w", apiRetryCount, err)
}
return info, nil
}
// redeemTicket 일회용 티켓을 서버에 보내 JWT 액세스 토큰으로 교환한다 (apiRetryCount회 재시도).
func redeemTicket(ticket string) (string, error) {
var token string
err := retryWithBackoff(apiRetryCount, func() error {
payload, err := json.Marshal(map[string]string{"ticket": ticket})
if err != nil {
return fmt.Errorf("요청 데이터 생성 실패: %w", err)
}
resp, err := apiClient.Post(redeemTicketURL, "application/json", bytes.NewReader(payload))
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, maxJSONBodySize)).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("인증 실패 (%d회 재시도): %w", apiRetryCount, err)
}
return token, nil
}

291
ui.go Normal file
View File

@@ -0,0 +1,291 @@
package main
import (
"fmt"
"os"
"runtime"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
// ── 윈도우 핸들 (진행 창에서 사용) ──────────────────────────
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))
}
func getSystemDPI() uint32 {
dpi, _, _ := getDpiForSystemProc.Call()
if dpi == 0 {
return 96
}
return uint32(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 {
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)))
return font
}
// ── 메시지 박스 ──────────────────────────────────────────────
// exitWithError 오류 메시지를 표시하고 프로그램을 종료한다.
func exitWithError(msg string) {
msgBox("One of the plans 런처 - 오류", msg, mbOK|mbError)
os.Exit(1)
}
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 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:
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
}
// setProgress 진행 창의 텍스트와 퍼센트를 갱신한다.
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)
}
}
// createProgressWindow 다크 테마 진행 창을 생성하고 핸들을 반환한다.
func createProgressWindow(dpi uint32) (hwnd uintptr, err error) {
s := func(px int) uintptr { return dpiScale(px, dpi) }
hBrushBg, _, _ = createSolidBrushProc.Call(colorBg)
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 0, 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 0, fmt.Errorf("다운로드 창 생성 실패")
}
// 폰트
titleFont := createUIFont(13, dpi, true)
statusFont := createUIFont(9, dpi, false)
// 타이틀 라벨
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)
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)
if ret == 0 || ret == ^uintptr(0) {
break
}
translateMsgProc.Call(uintptr(unsafe.Pointer(&m)))
dispatchMsgWProc.Call(uintptr(unsafe.Pointer(&m)))
}
return <-errCh
}

171
win32.go Normal file
View File

@@ -0,0 +1,171 @@
package main
import "golang.org/x/sys/windows"
// ── Win32 메시지 상수 ────────────────────────────────────────
const (
wmDestroy uint32 = 0x0002
wmClose uint32 = 0x0010
wmSetFont uint32 = 0x0030
wmSetText uint32 = 0x000C
wmCtlColorStatic uint32 = 0x0138
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
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 COLORREF(0x00BBGGRR) 값을 생성한다.
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보다 약간 밝은 색
)
// ── 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")
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")
)
// 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
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
}