package main import ( "archive/zip" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "os/exec" "path/filepath" "runtime" "strings" "syscall" "unsafe" "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" ) const ( protocolName = "a301" gameExeName = "A301.exe" serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info" webURL = "https://a301.tolelom.xyz" ) // Win32 constants const ( wmDestroy uint32 = 0x0002 wmClose uint32 = 0x0010 wmSetText uint32 = 0x000C wmAppDone uint32 = 0x8001 wsPopup uintptr = 0x80000000 wsCaption uintptr = 0x00C00000 wsChild uintptr = 0x40000000 wsVisible uintptr = 0x10000000 ssCenter uintptr = 0x00000001 swShow = 5 smCxScreen = 0 smCyScreen = 1 mbOK uintptr = 0x00000000 mbInfo uintptr = 0x00000040 mbError uintptr = 0x00000010 mbYesNo uintptr = 0x00000004 mbQ uintptr = 0x00000020 idYes = 6 ) var ( user32 = windows.NewLazySystemDLL("user32.dll") kernel32 = windows.NewLazySystemDLL("kernel32.dll") shell32 = windows.NewLazySystemDLL("shell32.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") shellExecuteWProc = shell32.NewProc("ShellExecuteW") getModuleHandleWProc = kernel32.NewProc("GetModuleHandleW") wndProcCb uintptr progressLabelHwnd 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 } func init() { wndProcCb = syscall.NewCallback(progressWndProc) } // ── Win32 helpers ────────────────────────────────────────────────────────── func msgBox(title, text string, flags uintptr) int { t, _ := windows.UTF16PtrFromString(title) m, _ := windows.UTF16PtrFromString(text) ret, _, _ := messageBoxWProc.Call(0, uintptr(unsafe.Pointer(m)), uintptr(unsafe.Pointer(t)), flags) return int(ret) } func openBrowser(rawURL string) { u, _ := windows.UTF16PtrFromString(rawURL) op, _ := windows.UTF16PtrFromString("open") shellExecuteWProc.Call(0, uintptr(unsafe.Pointer(op)), uintptr(unsafe.Pointer(u)), 0, 0, 1) } // ── Progress window ──────────────────────────────────────────────────────── func progressWndProc(hwnd, uMsg, wParam, lParam uintptr) uintptr { switch uint32(uMsg) { case wmClose: return 0 // 닫기 방지 case wmDestroy: postQuitMsgProc.Call(0) return 0 case wmAppDone: destroyWindowProc.Call(hwnd) return 0 } ret, _, _ := defWindowProcWProc.Call(hwnd, uMsg, wParam, lParam) return ret } func setProgressText(text string) { t, _ := windows.UTF16PtrFromString(text) sendMessageWProc.Call(progressLabelHwnd, uintptr(wmSetText), 0, uintptr(unsafe.Pointer(t))) } // downloadWithProgress shows a 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() hInstance, _, _ := getModuleHandleWProc.Call(0) className, _ := windows.UTF16PtrFromString("A301Progress") wc := wndClassExW{ cbSize: uint32(unsafe.Sizeof(wndClassExW{})), lpfnWndProc: wndProcCb, hInstance: hInstance, lpszClassName: className, hbrBackground: 16, // COLOR_BTNFACE + 1 } registerClassExWProc.Call(uintptr(unsafe.Pointer(&wc))) screenW, _, _ := getSystemMetricsProc.Call(smCxScreen) screenH, _, _ := getSystemMetricsProc.Call(smCyScreen) const winW, winH = 420, 110 x := (screenW - winW) / 2 y := (screenH - winH) / 2 title, _ := windows.UTF16PtrFromString("A301 - 게임 설치") hwnd, _, _ := createWindowExWProc.Call( 0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(title)), wsPopup|wsCaption|wsVisible, x, y, winW, winH, 0, 0, hInstance, 0, ) staticClass, _ := windows.UTF16PtrFromString("STATIC") initText, _ := windows.UTF16PtrFromString("게임 파일을 다운로드하는 중...") progressLabelHwnd, _, _ = createWindowExWProc.Call( 0, uintptr(unsafe.Pointer(staticClass)), uintptr(unsafe.Pointer(initText)), wsChild|wsVisible|ssCenter, 10, 35, 400, 30, hwnd, 0, hInstance, 0, ) showWindowProc.Call(hwnd, swShow) updateWindowProc.Call(hwnd) 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 doDownload(downloadURL, destDir string) error { resp, err := http.Get(downloadURL) if err != nil { return fmt.Errorf("다운로드 연결 실패: %w", err) } defer resp.Body.Close() tmpPath := filepath.Join(os.TempDir(), "a301_game.zip") tmpFile, err := os.Create(tmpPath) if err != nil { return fmt.Errorf("임시 파일 생성 실패: %w", err) } total := resp.ContentLength var downloaded int64 buf := make([]byte, 32*1024) for { n, err := resp.Body.Read(buf) if n > 0 { if _, werr := tmpFile.Write(buf[:n]); werr != nil { tmpFile.Close() os.Remove(tmpPath) return fmt.Errorf("파일 쓰기 실패: %w", werr) } downloaded += int64(n) if total > 0 { pct := downloaded * 100 / total setProgressText(fmt.Sprintf("다운로드 중... %d%%", pct)) } } if err == io.EOF { break } if err != nil { tmpFile.Close() os.Remove(tmpPath) return fmt.Errorf("다운로드 중 오류: %w", err) } } tmpFile.Close() defer os.Remove(tmpPath) setProgressText("압축을 해제하는 중...") return extractZip(tmpPath, destDir) } 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 && parts[0] != "" { rel = parts[0] } else { continue } // 런처 파일 건너뜀 if strings.ToLower(filepath.Base(rel)) == selfName { continue } dest := filepath.Join(destDir, filepath.FromSlash(rel)) 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, rc) out.Close() rc.Close() if err != nil { return err } } return nil } // ── Server info ──────────────────────────────────────────────────────────── type downloadInfo struct { FileHash string `json:"fileHash"` URL string `json:"url"` } func fetchServerInfo() (*downloadInfo, error) { resp, err := http.Get(serverInfoURL) if err != nil { return nil, fmt.Errorf("서버 연결 실패: %w", err) } defer resp.Body.Close() if resp.StatusCode == 404 { return nil, fmt.Errorf("게임이 아직 준비되지 않았습니다") } if resp.StatusCode >= 400 { return nil, fmt.Errorf("서버 오류 (HTTP %d)", resp.StatusCode) } var info downloadInfo if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { return nil, fmt.Errorf("서버 응답 파싱 실패: %w", err) } return &info, nil } func hashFile(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil } // ── Launcher path ────────────────────────────────────────────────────────── func launcherPath() (string, error) { exe, err := os.Executable() if err != nil { return "", err } return filepath.Abs(exe) } // ── Protocol install / uninstall ─────────────────────────────────────────── func install() error { exePath, err := launcherPath() if err != nil { return fmt.Errorf("실행 파일 경로를 찾을 수 없습니다: %w", err) } key, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Classes\`+protocolName, registry.SET_VALUE) if err != nil { return fmt.Errorf("레지스트리 키 생성 실패: %w", err) } defer key.Close() key.SetStringValue("", "URL:A301 Protocol") key.SetStringValue("URL Protocol", "") 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"`, exePath)) } 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 } // ── Game update check + download ─────────────────────────────────────────── func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error { needsDownload := false if _, err := os.Stat(gamePath); os.IsNotExist(err) { needsDownload = true } else if serverInfo.FileHash != "" { 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이 없습니다") } if err := downloadWithProgress(serverInfo.URL, gameDir); err != nil { return fmt.Errorf("게임 설치 실패: %w", err) } } return nil } // ── URI handler ──────────────────────────────────────────────────────────── func handleURI(rawURI string) error { parsed, err := url.Parse(rawURI) if err != nil { return fmt.Errorf("URI 파싱 실패: %w", err) } token := parsed.Query().Get("token") if token == "" { return fmt.Errorf("토큰이 없습니다") } exePath, err := launcherPath() if err != nil { return err } gameDir := filepath.Dir(exePath) gamePath := filepath.Join(gameDir, gameExeName) serverInfo, err := fetchServerInfo() if err != nil { return fmt.Errorf("버전 확인 실패: %w", err) } if err := ensureGame(gameDir, gamePath, serverInfo); err != nil { return err } cmd := exec.Command(gamePath, "-token", token) cmd.Dir = gameDir if err := cmd.Start(); err != nil { return fmt.Errorf("게임 실행 실패: %w", err) } return nil } // ── Entry point ──────────────────────────────────────────────────────────── func main() { if len(os.Args) < 2 { ret := msgBox("A301 런처", "게임 실행을 위해 프로토콜을 등록합니다.\n계속하시겠습니까?", mbYesNo|mbQ) if ret != idYes { return } if err := install(); err != nil { msgBox("A301 런처 - 오류", fmt.Sprintf("등록 실패:\n%v", err), mbOK|mbError) os.Exit(1) } msgBox("A301 런처", "a301:// 프로토콜이 등록되었습니다.\n이제 웹에서 게임 시작 버튼을 사용할 수 있습니다.", mbOK|mbInfo) return } arg := os.Args[1] switch { case arg == "install": if err := install(); err != nil { msgBox("A301 런처 - 오류", fmt.Sprintf("등록 실패:\n%v", err), mbOK|mbError) os.Exit(1) } msgBox("A301 런처", "a301:// 프로토콜이 등록되었습니다.", mbOK|mbInfo) case arg == "uninstall": if err := uninstall(); err != nil { msgBox("A301 런처 - 오류", fmt.Sprintf("제거 실패:\n%v", err), mbOK|mbError) os.Exit(1) } msgBox("A301 런처", "a301:// 프로토콜이 제거되었습니다.", mbOK|mbInfo) case strings.HasPrefix(arg, protocolName+"://"): if err := handleURI(arg); err != nil { if strings.Contains(err.Error(), "버전이 최신이 아닙니다") { ret := msgBox("A301 - 업데이트 필요", "새로운 버전이 있습니다. 다운로드 페이지로 이동할까요?", mbYesNo|mbInfo) if ret == idYes { openBrowser(webURL) } } else { msgBox("A301 런처 - 오류", fmt.Sprintf("실행 실패:\n%v", err), mbOK|mbError) } os.Exit(1) } default: msgBox("A301 런처 - 오류", fmt.Sprintf("알 수 없는 명령: %s", arg), mbOK|mbError) os.Exit(1) } }