Compare commits
8 Commits
281a365952
...
4c32817f7e
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c32817f7e | |||
| 3c46b55f93 | |||
| e09513f8d9 | |||
| bf19d5d542 | |||
| e1d0e6fed0 | |||
| 208b2d3189 | |||
| 4cd118cef0 | |||
| 42c00b37d5 |
12
CLAUDE.md
12
CLAUDE.md
@@ -11,7 +11,7 @@ C:\Users\98kim\sdk\go1.25.1\bin\go.exe build -ldflags="-H windowsgui -s -w -X ma
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Go** 4파일 구조 (`main.go`, `ui.go`, `download.go`, `protocol.go`)
|
- **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`
|
- **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`
|
||||||
|
|
||||||
@@ -22,18 +22,20 @@ C:\Users\98kim\sdk\go1.25.1\bin\go.exe build -ldflags="-H windowsgui -s -w -X ma
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
역할별 4파일 구조:
|
역할별 6파일 구조:
|
||||||
|
|
||||||
| 파일 | 담당 |
|
| 파일 | 담당 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `main.go` | 진입점(`main`), 단일 인스턴스, `handleURI`, version |
|
| `main.go` | 진입점(`main`), 단일 인스턴스, `handleURI`, version |
|
||||||
| `ui.go` | Win32 DLL/proc 선언, WndProc, progress window, DPI, font, msgBox |
|
| `win32.go` | Win32 상수, DLL/proc 선언, 구조체 |
|
||||||
|
| `ui.go` | WndProc, progress window, DPI, font, msgBox |
|
||||||
| `download.go` | HTTP 클라이언트, 다운로드/추출/해시, `ensureGame`, `ensureLauncher` |
|
| `download.go` | HTTP 클라이언트, 다운로드/추출/해시, `ensureGame`, `ensureLauncher` |
|
||||||
| `protocol.go` | 상수, URI 프로토콜 등록/해제, `redeemTicket`, `fetchServerInfo` |
|
| `protocol.go` | 상수, URI 프로토콜 등록/해제, `redeemTicket`, `fetchServerInfo` |
|
||||||
|
| `game.go` | `handleURI`, 게임 실행 흐름 |
|
||||||
|
|
||||||
주요 함수:
|
주요 함수:
|
||||||
- **`downloadWithProgress()`** — Win32 메시지 루프 직접 운영. 반드시 메인 고루틴에서 호출 (`runtime.LockOSThread`).
|
- **`downloadWithProgress()`** — Win32 메시지 루프 직접 운영. 반드시 메인 고루틴에서 호출 (`runtime.LockOSThread`).
|
||||||
- **`fetchServerInfo()`** — 3회 재시도 (exponential backoff).
|
- **`fetchServerInfo()`** — `apiRetryCount`회 재시도 (exponential backoff).
|
||||||
- **`ensureGame()`** — `A301.exe` SHA256 해시 비교 후 불일치 시 재다운로드.
|
- **`ensureGame()`** — `A301.exe` SHA256 해시 비교 후 불일치 시 재다운로드.
|
||||||
|
|
||||||
## UI Details
|
## UI Details
|
||||||
@@ -62,5 +64,5 @@ protocolName = "a301" // 기술 식별자
|
|||||||
|
|
||||||
- `extractZip()` — zip 내 최상위 디렉토리 1단계 제거 후 추출. `launcher.exe` 자신은 덮어쓰기 방지. Symlink 엔트리는 스킵.
|
- `extractZip()` — zip 내 최상위 디렉토리 1단계 제거 후 추출. `launcher.exe` 자신은 덮어쓰기 방지. Symlink 엔트리는 스킵.
|
||||||
- 레지스트리는 `HKCU` (현재 사용자) 에만 쓰므로 관리자 권한 불필요.
|
- 레지스트리는 `HKCU` (현재 사용자) 에만 쓰므로 관리자 권한 불필요.
|
||||||
- `fetchServerInfo()` — 3회 재시도 (exponential backoff).
|
- `fetchServerInfo()` — `apiRetryCount`회 재시도 (exponential backoff).
|
||||||
- `doDownload()` — Range 헤더로 이어받기 지원. 취소/오류 시 임시 파일 유지.
|
- `doDownload()` — Range 헤더로 이어받기 지원. 취소/오류 시 임시 파일 유지.
|
||||||
|
|||||||
154
download.go
154
download.go
@@ -19,6 +19,7 @@ import (
|
|||||||
|
|
||||||
const maxDownloadSize = 2 << 30 // 2 GB
|
const maxDownloadSize = 2 << 30 // 2 GB
|
||||||
const maxExtractFileSize = 4 << 30 // 개별 파일 최대 4 GB
|
const maxExtractFileSize = 4 << 30 // 개별 파일 최대 4 GB
|
||||||
|
const tmpZipName = "a301_game.zip" // 임시 다운로드 파일명
|
||||||
|
|
||||||
var downloadCancelled atomic.Bool
|
var downloadCancelled atomic.Bool
|
||||||
|
|
||||||
@@ -115,32 +116,21 @@ func openTmpFile(resp *http.Response, tmpPath string, resumeOffset int64) (tmpFi
|
|||||||
|
|
||||||
// formatProgress 다운로드 진행률 텍스트를 생성한다.
|
// formatProgress 다운로드 진행률 텍스트를 생성한다.
|
||||||
func formatProgress(pct int, speedBytesPerSec float64, remaining float64) string {
|
func formatProgress(pct int, speedBytesPerSec float64, remaining float64) string {
|
||||||
speedMB := speedBytesPerSec / 1024 / 1024
|
|
||||||
if speedBytesPerSec <= 0 {
|
if speedBytesPerSec <= 0 {
|
||||||
return fmt.Sprintf("다운로드 중... %d%% (%.1f MB/s)", pct, speedMB)
|
return fmt.Sprintf("다운로드 중... %d%%", pct)
|
||||||
}
|
}
|
||||||
|
speedMB := speedBytesPerSec / 1024 / 1024
|
||||||
if remaining < 60 {
|
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))
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("다운로드 중... %d%% (%.1f MB/s, %d분 남음)", pct, speedMB, int(remaining/60))
|
return fmt.Sprintf("다운로드 중... %d%% (%.1f MB/s, %d분 남음)", pct, speedMB, int(remaining/60))
|
||||||
}
|
}
|
||||||
|
|
||||||
// doDownload 파일을 다운로드하고 zip을 추출하여 destDir에 배치한다.
|
// downloadBody 응답 본문을 tmpFile에 쓰고 진행률을 갱신한다.
|
||||||
func doDownload(downloadURL, destDir string) error {
|
// downloaded는 이어받기 시작 오프셋, total은 전체 크기(미확정이면 0).
|
||||||
tmpPath := filepath.Join(os.TempDir(), "a301_game.zip")
|
// tmpPath는 크기 초과 시 파일 삭제에만 사용된다.
|
||||||
|
// 완료 또는 오류 시 tmpFile을 닫는다.
|
||||||
resp, resumeOffset, err := doDownloadRequest(downloadURL, tmpPath)
|
func downloadBody(resp *http.Response, tmpFile *os.File, tmpPath string, downloaded, total int64) error {
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
tmpFile, downloaded, total, err := openTmpFile(resp, tmpPath, resumeOffset)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 다운로드 루프
|
|
||||||
buf := make([]byte, 32*1024)
|
buf := make([]byte, 32*1024)
|
||||||
var lastSpeedUpdate time.Time
|
var lastSpeedUpdate time.Time
|
||||||
var lastBytes int64
|
var lastBytes int64
|
||||||
@@ -193,10 +183,29 @@ func doDownload(downloadURL, destDir string) error {
|
|||||||
return fmt.Errorf("다운로드 중 오류: %w", readErr)
|
return fmt.Errorf("다운로드 중 오류: %w", readErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tmpFile.Close()
|
return tmpFile.Close()
|
||||||
defer os.Remove(tmpPath)
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
// zip 추출
|
|
||||||
setProgress("압축을 해제하는 중...", -1)
|
setProgress("압축을 해제하는 중...", -1)
|
||||||
|
|
||||||
tmpExtractDir, err := os.MkdirTemp("", "a301_extract_")
|
tmpExtractDir, err := os.MkdirTemp("", "a301_extract_")
|
||||||
@@ -217,7 +226,7 @@ func doDownload(downloadURL, destDir string) error {
|
|||||||
// ── zip 추출 ─────────────────────────────────────────────────
|
// ── zip 추출 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
// extractZip zip 파일을 destDir에 추출한다.
|
// extractZip zip 파일을 destDir에 추출한다.
|
||||||
// zip 내 최상위 디렉토리 1단계를 제거하고, launcher.exe 자신은 덮어쓰기 방지.
|
// zip에 단일 최상위 래퍼 디렉토리가 있으면 1단계 제거하고, launcher.exe 자신은 덮어쓰기 방지.
|
||||||
func extractZip(zipPath, destDir string) error {
|
func extractZip(zipPath, destDir string) error {
|
||||||
r, err := zip.OpenReader(zipPath)
|
r, err := zip.OpenReader(zipPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -225,10 +234,11 @@ func extractZip(zipPath, destDir string) error {
|
|||||||
}
|
}
|
||||||
defer r.Close()
|
defer r.Close()
|
||||||
|
|
||||||
|
strip := hasWrapperDir(r.File)
|
||||||
selfName := strings.ToLower(filepath.Base(os.Args[0]))
|
selfName := strings.ToLower(filepath.Base(os.Args[0]))
|
||||||
|
|
||||||
for _, f := range r.File {
|
for _, f := range r.File {
|
||||||
rel := stripTopDir(f.Name)
|
rel := resolveZipEntry(f.Name, strip)
|
||||||
if rel == "" {
|
if rel == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -267,18 +277,50 @@ func extractZip(zipPath, destDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripTopDir zip 엔트리에서 최상위 디렉토리를 제거한 상대 경로를 반환한다.
|
// hasWrapperDir zip의 모든 엔트리가 동일한 단일 최상위 디렉토리 아래에 있는지 확인한다.
|
||||||
// 최상위 디렉토리 자체거나 빈 경로면 ""을 반환.
|
// 예: "game/A301.exe", "game/Data/" → true ("game"이 래퍼)
|
||||||
func stripTopDir(name string) string {
|
// "A301.exe", "A301_Data/" → false (래퍼 없음)
|
||||||
clean := filepath.ToSlash(name)
|
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)
|
parts := strings.SplitN(clean, "/", 2)
|
||||||
if len(parts) == 2 && parts[1] != "" {
|
top := parts[0]
|
||||||
return parts[1]
|
if len(parts) == 1 && !f.FileInfo().IsDir() {
|
||||||
|
// 루트 레벨에 파일이 있으면 래퍼 디렉토리 아님
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
if len(parts) == 1 && !strings.Contains(clean, "/") && parts[0] != "" {
|
if wrapper == "" {
|
||||||
return parts[0]
|
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 ""
|
||||||
|
}
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
// 래퍼 제거
|
||||||
|
parts := strings.SplitN(clean, "/", 2)
|
||||||
|
if len(parts) < 2 || parts[1] == "" {
|
||||||
|
return "" // 래퍼 디렉토리 자체
|
||||||
|
}
|
||||||
|
return parts[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractFile 단일 zip 엔트리를 dest 경로에 추출한다.
|
// extractFile 단일 zip 엔트리를 dest 경로에 추출한다.
|
||||||
@@ -377,29 +419,33 @@ func hashFile(path string) (string, error) {
|
|||||||
|
|
||||||
// ── 게임/런처 업데이트 ──────────────────────────────────────
|
// ── 게임/런처 업데이트 ──────────────────────────────────────
|
||||||
|
|
||||||
|
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 게임 파일이 최신인지 확인하고 필요 시 다운로드한다.
|
// ensureGame 게임 파일이 최신인지 확인하고 필요 시 다운로드한다.
|
||||||
|
// 서버의 fileHash는 zip 파일 전체의 해시이므로, 로컬에 저장된 해시와 비교한다.
|
||||||
func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error {
|
func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error {
|
||||||
if serverInfo.FileHash == "" {
|
if serverInfo.FileHash == "" {
|
||||||
return fmt.Errorf("서버에서 유효한 파일 정보를 받지 못했습니다")
|
return fmt.Errorf("서버에서 유효한 파일 정보를 받지 못했습니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
needsDownload := false
|
// 게임 파일이 없거나 로컬 해시가 서버와 다르면 다운로드 필요
|
||||||
if _, err := os.Stat(gamePath); os.IsNotExist(err) {
|
localHash := readLocalHash(gameDir)
|
||||||
needsDownload = true
|
if _, err := os.Stat(gamePath); err == nil && strings.EqualFold(localHash, serverInfo.FileHash) {
|
||||||
} else if err != nil {
|
return 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 {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL 검증 후 다운로드
|
// URL 검증 후 다운로드
|
||||||
@@ -414,15 +460,19 @@ func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error {
|
|||||||
return fmt.Errorf("게임 설치 실패: %w", err)
|
return fmt.Errorf("게임 설치 실패: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다운로드 후 해시 재검증
|
// 다운로드된 zip의 해시를 검증 후 삭제
|
||||||
newHash, err := hashFile(gamePath)
|
tmpPath := filepath.Join(os.TempDir(), tmpZipName)
|
||||||
|
defer os.Remove(tmpPath)
|
||||||
|
|
||||||
|
zipHash, err := hashFile(tmpPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("다운로드 후 파일 검증 실패: %w", err)
|
return fmt.Errorf("다운로드 후 파일 검증 실패: %w", err)
|
||||||
}
|
}
|
||||||
if !strings.EqualFold(newHash, serverInfo.FileHash) {
|
if !strings.EqualFold(zipHash, serverInfo.FileHash) {
|
||||||
os.Remove(gamePath)
|
|
||||||
return fmt.Errorf("다운로드된 파일의 해시가 일치하지 않습니다 (파일이 손상되었을 수 있습니다)")
|
return fmt.Errorf("다운로드된 파일의 해시가 일치하지 않습니다 (파일이 손상되었을 수 있습니다)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writeLocalHash(gameDir, serverInfo.FileHash)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
protocol.go
22
protocol.go
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -21,6 +22,9 @@ import (
|
|||||||
const (
|
const (
|
||||||
protocolName = "a301"
|
protocolName = "a301"
|
||||||
gameExeName = "A301.exe"
|
gameExeName = "A301.exe"
|
||||||
|
|
||||||
|
apiRetryCount = 3 // fetchServerInfo, redeemTicket 재시도 횟수
|
||||||
|
maxJSONBodySize = 1 << 20 // JSON 응답 바디 최대 1MB
|
||||||
)
|
)
|
||||||
|
|
||||||
// serverInfoURL, redeemTicketURL은 빌드 시 -ldflags로 오버라이드 가능.
|
// serverInfoURL, redeemTicketURL은 빌드 시 -ldflags로 오버라이드 가능.
|
||||||
@@ -180,10 +184,10 @@ func retryWithBackoff(maxRetries int, fn func() error) error {
|
|||||||
return lastErr
|
return lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchServerInfo 서버에서 게임/런처 다운로드 정보를 조회한다 (3회 재시도).
|
// fetchServerInfo 서버에서 게임/런처 다운로드 정보를 조회한다 (apiRetryCount회 재시도).
|
||||||
func fetchServerInfo() (*downloadInfo, error) {
|
func fetchServerInfo() (*downloadInfo, error) {
|
||||||
var info *downloadInfo
|
var info *downloadInfo
|
||||||
err := retryWithBackoff(3, func() error {
|
err := retryWithBackoff(apiRetryCount, func() error {
|
||||||
resp, err := apiClient.Get(serverInfoURL)
|
resp, err := apiClient.Get(serverInfoURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("서버 연결 실패: %w", err)
|
return fmt.Errorf("서버 연결 실패: %w", err)
|
||||||
@@ -198,28 +202,28 @@ func fetchServerInfo() (*downloadInfo, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result downloadInfo
|
var result downloadInfo
|
||||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
|
if err := json.NewDecoder(io.LimitReader(resp.Body, maxJSONBodySize)).Decode(&result); err != nil {
|
||||||
return fmt.Errorf("서버 응답 파싱 실패: %w", err)
|
return fmt.Errorf("서버 응답 파싱 실패: %w", err)
|
||||||
}
|
}
|
||||||
info = &result
|
info = &result
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("서버 연결 실패 (3회 재시도): %w", err)
|
return nil, fmt.Errorf("서버 연결 실패 (%d회 재시도): %w", apiRetryCount, err)
|
||||||
}
|
}
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// redeemTicket 일회용 티켓을 서버에 보내 JWT 액세스 토큰으로 교환한다 (3회 재시도).
|
// redeemTicket 일회용 티켓을 서버에 보내 JWT 액세스 토큰으로 교환한다 (apiRetryCount회 재시도).
|
||||||
func redeemTicket(ticket string) (string, error) {
|
func redeemTicket(ticket string) (string, error) {
|
||||||
var token string
|
var token string
|
||||||
err := retryWithBackoff(3, func() error {
|
err := retryWithBackoff(apiRetryCount, func() error {
|
||||||
payload, err := json.Marshal(map[string]string{"ticket": ticket})
|
payload, err := json.Marshal(map[string]string{"ticket": ticket})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("요청 데이터 생성 실패: %w", err)
|
return fmt.Errorf("요청 데이터 생성 실패: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := apiClient.Post(redeemTicketURL, "application/json", strings.NewReader(string(payload)))
|
resp, err := apiClient.Post(redeemTicketURL, "application/json", bytes.NewReader(payload))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("서버에 연결할 수 없습니다: %w", err)
|
return fmt.Errorf("서버에 연결할 수 없습니다: %w", err)
|
||||||
}
|
}
|
||||||
@@ -235,7 +239,7 @@ func redeemTicket(ticket string) (string, error) {
|
|||||||
var result struct {
|
var result struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
|
if err := json.NewDecoder(io.LimitReader(resp.Body, maxJSONBodySize)).Decode(&result); err != nil {
|
||||||
return fmt.Errorf("서버 응답을 처리할 수 없습니다: %w", err)
|
return fmt.Errorf("서버 응답을 처리할 수 없습니다: %w", err)
|
||||||
}
|
}
|
||||||
if result.Token == "" {
|
if result.Token == "" {
|
||||||
@@ -245,7 +249,7 @@ func redeemTicket(ticket string) (string, error) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("인증 실패 (3회 재시도): %w", err)
|
return "", fmt.Errorf("인증 실패 (%d회 재시도): %w", apiRetryCount, err)
|
||||||
}
|
}
|
||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|||||||
168
ui.go
168
ui.go
@@ -10,174 +10,6 @@ import (
|
|||||||
"golang.org/x/sys/windows"
|
"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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 윈도우 핸들 (진행 창에서 사용) ──────────────────────────
|
// ── 윈도우 핸들 (진행 창에서 사용) ──────────────────────────
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
171
win32.go
Normal file
171
win32.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user