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>
This commit is contained in:
19
CLAUDE.md
19
CLAUDE.md
@@ -10,7 +10,7 @@ C:\Users\98kim\sdk\go1.25.1\bin\go.exe build -ldflags="-H windowsgui -s -w" -o l
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Go** 단일 파일 (`main.go`)
|
- **Go** 4파일 구조 (`main.go`, `ui.go`, `download.go`, `protocol.go`)
|
||||||
- **Win32 API** — `user32.dll`, `gdi32.dll`, `comctl32.dll`, `uxtheme.dll`, `shell32.dll`
|
- **Win32 API** — `user32.dll`, `gdi32.dll`, `comctl32.dll`, `uxtheme.dll`, `shell32.dll`
|
||||||
- `golang.org/x/sys/windows` + `windows/registry`
|
- `golang.org/x/sys/windows` + `windows/registry`
|
||||||
|
|
||||||
@@ -21,13 +21,18 @@ C:\Users\98kim\sdk\go1.25.1\bin\go.exe build -ldflags="-H windowsgui -s -w" -o l
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
모든 로직이 `main.go` 단일 파일에 있음:
|
역할별 4파일 구조:
|
||||||
|
|
||||||
- **`enableDPIAwareness()`** — `SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2)` 호출. `main()` 첫 줄에서 실행.
|
| 파일 | 담당 |
|
||||||
|
|---|---|
|
||||||
|
| `main.go` | 진입점(`main`), 단일 인스턴스, `handleURI`, version |
|
||||||
|
| `ui.go` | Win32 DLL/proc 선언, WndProc, progress window, DPI, font, msgBox |
|
||||||
|
| `download.go` | HTTP 클라이언트, 다운로드/추출/해시, `ensureGame`, `ensureLauncher` |
|
||||||
|
| `protocol.go` | 상수, URI 프로토콜 등록/해제, `redeemTicket`, `fetchServerInfo` |
|
||||||
|
|
||||||
|
주요 함수:
|
||||||
- **`downloadWithProgress()`** — Win32 메시지 루프 직접 운영. 반드시 메인 고루틴에서 호출 (`runtime.LockOSThread`).
|
- **`downloadWithProgress()`** — Win32 메시지 루프 직접 운영. 반드시 메인 고루틴에서 호출 (`runtime.LockOSThread`).
|
||||||
- **`progressWndProc()`** — `WM_CTLCOLORSTATIC`으로 다크 테마 적용. `hBrushBg` 전역 변수 참조.
|
- **`fetchServerInfo()`** — 3회 재시도 (exponential backoff).
|
||||||
- **`setProgress(text, pct)`** — 다운로드 고루틴에서 호출해 레이블 텍스트와 진행 막대 동시 업데이트.
|
|
||||||
- **`fetchServerInfo()`** — `https://a301.api.tolelom.xyz/api/download/info` 조회.
|
|
||||||
- **`ensureGame()`** — `A301.exe` SHA256 해시 비교 후 불일치 시 재다운로드.
|
- **`ensureGame()`** — `A301.exe` SHA256 해시 비교 후 불일치 시 재다운로드.
|
||||||
|
|
||||||
## UI Details
|
## UI Details
|
||||||
@@ -50,7 +55,7 @@ protocolName = "a301" // 기술 식별자
|
|||||||
- `install()` 시 런처를 `%LOCALAPPDATA%\A301\launcher.exe`로 복사 후 해당 경로를 레지스트리에 등록.
|
- `install()` 시 런처를 `%LOCALAPPDATA%\A301\launcher.exe`로 복사 후 해당 경로를 레지스트리에 등록.
|
||||||
- 게임 파일(`A301.exe` 등)도 `%LOCALAPPDATA%\A301\`에 설치됨.
|
- 게임 파일(`A301.exe` 등)도 `%LOCALAPPDATA%\A301\`에 설치됨.
|
||||||
- 사용자가 원본 다운로드 파일을 삭제해도 프로토콜 핸들러가 정상 동작.
|
- 사용자가 원본 다운로드 파일을 삭제해도 프로토콜 핸들러가 정상 동작.
|
||||||
- 토큰은 명령줄이 아닌 `A301_TOKEN` 환경변수로 게임에 전달.
|
- 토큰은 커맨드라인(`-token`)과 `A301_TOKEN` 환경변수 양쪽으로 게임에 전달.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
181
protocol.go
Normal file
181
protocol.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
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 = "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}
|
||||||
|
body := fmt.Sprintf(`{"ticket":"%s"}`, ticket)
|
||||||
|
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