diff --git a/CLAUDE.md b/CLAUDE.md index e0bb6d3..c2a8dc6 100644 --- a/CLAUDE.md +++ b/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 -- **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` - `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 -모든 로직이 `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`). -- **`progressWndProc()`** — `WM_CTLCOLORSTATIC`으로 다크 테마 적용. `hBrushBg` 전역 변수 참조. -- **`setProgress(text, pct)`** — 다운로드 고루틴에서 호출해 레이블 텍스트와 진행 막대 동시 업데이트. -- **`fetchServerInfo()`** — `https://a301.api.tolelom.xyz/api/download/info` 조회. +- **`fetchServerInfo()`** — 3회 재시도 (exponential backoff). - **`ensureGame()`** — `A301.exe` SHA256 해시 비교 후 불일치 시 재다운로드. ## UI Details @@ -50,7 +55,7 @@ protocolName = "a301" // 기술 식별자 - `install()` 시 런처를 `%LOCALAPPDATA%\A301\launcher.exe`로 복사 후 해당 경로를 레지스트리에 등록. - 게임 파일(`A301.exe` 등)도 `%LOCALAPPDATA%\A301\`에 설치됨. - 사용자가 원본 다운로드 파일을 삭제해도 프로토콜 핸들러가 정상 동작. -- 토큰은 명령줄이 아닌 `A301_TOKEN` 환경변수로 게임에 전달. +- 토큰은 커맨드라인(`-token`)과 `A301_TOKEN` 환경변수 양쪽으로 게임에 전달. ## Notes diff --git a/download.go b/download.go new file mode 100644 index 0000000..ccbb0d7 --- /dev/null +++ b/download.go @@ -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)) + } + } +} diff --git a/main.go b/main.go index ced4cfe..fdf5751 100644 --- a/main.go +++ b/main.go @@ -1,1087 +1,17 @@ package main import ( - "archive/zip" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" "fmt" - "io" - "net/http" "net/url" "os" "os/exec" "path/filepath" - "runtime" "strings" - "sync/atomic" - "syscall" - "time" - "unsafe" - - "golang.org/x/sys/windows" - "golang.org/x/sys/windows/registry" ) // version is set at build time via -ldflags "-X main.version=x.y.z" var version = "dev" -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" -) - -const maxDownloadSize = 2 << 30 // 2GB -const maxExtractFileSize = 4 << 30 // 개별 파일 최대 4GB - -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, -} - -// 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 - downloadCancelled atomic.Bool -) - -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) -} - -// ── DPI helpers ────────────────────────────────────────────────────────────── - -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. -// Uses floating-point to avoid precision loss on non-standard DPI values. -func dpiScale(px int, dpi uint32) uintptr { - return uintptr(int(float64(px)*float64(dpi)/96.0 + 0.5)) -} - -// ── Font helpers ───────────────────────────────────────────────────────────── - -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 -} - -// ── Common controls ────────────────────────────────────────────────────────── - -func initCommonControls() { - icc := initCommonControlsExS{ - dwSize: uint32(unsafe.Sizeof(initCommonControlsExS{})), - dwICC: iccProgressClass, - } - initCommonControlsExProc.Call(uintptr(unsafe.Pointer(&icc))) -} - -// ── Win32 helpers ──────────────────────────────────────────────────────────── - -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) -} - -// ── Progress window ────────────────────────────────────────────────────────── - -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) } - - // 배경 브러시 생성 (window proc에서도 사용) - 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") - - // ── "A301" 타이틀 레이블 ── - // 클라이언트 영역 레이아웃 (base 96 DPI): - // y=14 h=28 → "A301" 타이틀 (13pt bold, 강조색) - // y=52 h=20 → 상태 텍스트 (9pt, 밝은 회색) - // y=82 h=18 → 진행 막대 - 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) - - // 비주얼 스타일 비활성화 → PBM_SETBARCOLOR/PBM_SETBKCOLOR 적용 가능 - 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) - // 윈도우가 이미 파괴된 경우(취소) PostMessage는 무시됨 - 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 -} - -// 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: - // 서버가 Range 요청 수락 → 이어받기 - 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: - // 서버가 Range 미지원이거나 파일이 변경됨 → 처음부터 - 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 { - // zip 내 최상위 디렉토리 제거 (A301/A301.exe → A301.exe) - 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] != "" { - // 최상위에 직접 있는 파일 (디렉토리 없는 zip) - rel = parts[0] - } else { - // 최상위 디렉토리 자체("A301/") 등 → 스킵 - continue - } - - // 절대 경로 거부 - if filepath.IsAbs(rel) { - return fmt.Errorf("잘못된 zip 경로 (절대 경로): %s", rel) - } - // NTFS ADS 방어: 경로에 ':' 포함 시 거부 - 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)) - - // Zip Slip 방지: 추출 경로가 대상 디렉토리 안인지 검증 - 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) - } - - // Symlink 차단: zip 내 심볼릭 링크를 통한 경로 탈출 방지 - 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) // 기존 파일 제거 (Rename 실패 방지) - 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() -} - -// ── Server info ────────────────────────────────────────────────────────────── - -// 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"` -} - -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 - // 4xx 에러는 재시도해도 의미 없음 - var noRetry *errNoRetry - if errors.As(err, &noRetry) { - return nil, err - } - time.Sleep(time.Duration(1<= 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<= 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 +}