diff --git a/main.go b/main.go index f3b6f05..56cb06b 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "archive/zip" "crypto/sha256" "encoding/hex" "encoding/json" @@ -11,7 +12,9 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" + "syscall" "unsafe" "golang.org/x/sys/windows" @@ -19,153 +22,303 @@ import ( ) const ( - protocolName = "a301" - gameExeName = "A301.exe" - serverInfoURL = "https://a301.tolelom.xyz/api/download/info" - webDownloadURL = "https://a301.tolelom.xyz" + protocolName = "a301" + gameExeName = "A301.exe" + serverInfoURL = "https://a301.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") - messageBoxW = user32.NewProc("MessageBoxW") - shellExecuteW = windows.NewLazySystemDLL("shell32.dll").NewProc("ShellExecuteW") + 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 ) -const ( - mbOK = 0x00000000 - mbIconInfo = 0x00000040 - mbIconError = 0x00000010 - mbYesNo = 0x00000004 - mbIconQuestion = 0x00000020 - idYes = 6 -) +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, _, _ := messageBoxW.Call( - 0, - uintptr(unsafe.Pointer(m)), - uintptr(unsafe.Pointer(t)), - flags, - ) + 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") - shellExecuteW.Call(0, uintptr(unsafe.Pointer(op)), uintptr(unsafe.Pointer(u)), 0, 0, 1) + shellExecuteWProc.Call(0, uintptr(unsafe.Pointer(op)), uintptr(unsafe.Pointer(u)), 0, 0, 1) } -func main() { - if len(os.Args) < 2 { - ret := msgBox( - "A301 런처", - "게임 실행을 위해 프로토콜을 등록합니다.\n계속하시겠습니까?", - mbYesNo|mbIconQuestion, - ) - if ret != idYes { - return - } - if err := install(); err != nil { - msgBox("A301 런처 - 오류", fmt.Sprintf("등록 실패:\n%v", err), mbOK|mbIconError) - os.Exit(1) - } - msgBox("A301 런처", "a301:// 프로토콜이 등록되었습니다.\n이제 웹에서 게임 시작 버튼을 사용할 수 있습니다.", mbOK|mbIconInfo) - return - } - - arg := os.Args[1] - - switch { - case arg == "install": - if err := install(); err != nil { - msgBox("A301 런처 - 오류", fmt.Sprintf("등록 실패:\n%v", err), mbOK|mbIconError) - os.Exit(1) - } - msgBox("A301 런처", "a301:// 프로토콜이 등록되었습니다.", mbOK|mbIconInfo) - - case arg == "uninstall": - if err := uninstall(); err != nil { - msgBox("A301 런처 - 오류", fmt.Sprintf("제거 실패:\n%v", err), mbOK|mbIconError) - os.Exit(1) - } - msgBox("A301 런처", "a301:// 프로토콜이 제거되었습니다.", mbOK|mbIconInfo) - - case strings.HasPrefix(arg, protocolName+"://"): - if err := handleURI(arg); err != nil { - msgBox("A301 런처 - 오류", fmt.Sprintf("실행 실패:\n%v", err), mbOK|mbIconError) - os.Exit(1) - } - - default: - msgBox("A301 런처 - 오류", fmt.Sprintf("알 수 없는 명령: %s", arg), mbOK|mbIconError) - os.Exit(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 launcherPath() (string, error) { - exe, err := os.Executable() - if err != nil { - return "", err - } - return filepath.Abs(exe) +func setProgressText(text string) { + t, _ := windows.UTF16PtrFromString(text) + sendMessageWProc.Call(progressLabelHwnd, uintptr(wmSetText), 0, uintptr(unsafe.Pointer(t))) } -func install() error { - exePath, err := launcherPath() - if err != nil { - return fmt.Errorf("실행 파일 경로를 찾을 수 없습니다: %w", err) - } +// 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() - key, _, err := registry.CreateKey( - registry.CURRENT_USER, - `Software\Classes\`+protocolName, - registry.SET_VALUE, + 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, ) - if err != nil { - return fmt.Errorf("레지스트리 키 생성 실패: %w", err) - } - defer key.Close() - if err := key.SetStringValue("", "URL:A301 Protocol"); err != nil { - return err - } - if err := key.SetStringValue("URL Protocol", ""); err != nil { - return err - } - - cmdKey, _, err := registry.CreateKey( - registry.CURRENT_USER, - `Software\Classes\`+protocolName+`\shell\open\command`, - registry.SET_VALUE, + 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, ) - if err != nil { - return fmt.Errorf("command 키 생성 실패: %w", err) - } - defer cmdKey.Close() - cmdValue := fmt.Sprintf(`"%s" "%%1"`, exePath) - return cmdKey.SetStringValue("", cmdValue) + 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 uninstall() error { - paths := []string{ - `Software\Classes\` + protocolName + `\shell\open\command`, - `Software\Classes\` + protocolName + `\shell\open`, - `Software\Classes\` + protocolName + `\shell`, - `Software\Classes\` + protocolName, +func doDownload(downloadURL, destDir string) error { + resp, err := http.Get(downloadURL) + if err != nil { + return fmt.Errorf("다운로드 연결 실패: %w", err) } - for _, p := range paths { - err := registry.DeleteKey(registry.CURRENT_USER, p) - if err != nil && err != registry.ErrNotExist { + 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"` @@ -191,7 +344,6 @@ func hashFile(path string) (string, error) { return "", err } defer f.Close() - h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "", err @@ -199,6 +351,86 @@ func hashFile(path string) (string, error) { 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 { @@ -217,34 +449,13 @@ func handleURI(rawURI string) error { gameDir := filepath.Dir(exePath) gamePath := filepath.Join(gameDir, gameExeName) - if _, err := os.Stat(gamePath); os.IsNotExist(err) { - return fmt.Errorf("게임 파일을 찾을 수 없습니다:\n%s", gamePath) - } - - // 서버에서 최신 해시 조회 serverInfo, err := fetchServerInfo() if err != nil { - return fmt.Errorf("버전 확인 실패:\n%w", err) + return fmt.Errorf("버전 확인 실패: %w", err) } - // 서버에 해시가 등록된 경우에만 검증 - if serverInfo.FileHash != "" { - localHash, err := hashFile(gamePath) - if err != nil { - return fmt.Errorf("게임 파일 검증 실패:\n%w", err) - } - - if !strings.EqualFold(localHash, serverInfo.FileHash) { - ret := msgBox( - "A301 - 업데이트 필요", - "새로운 버전의 게임이 있습니다.\n최신 버전을 다운로드해주세요.\n\n확인을 누르면 다운로드 페이지로 이동합니다.", - mbOK|mbIconInfo, - ) - if ret > 0 { - openBrowser(webDownloadURL) - } - return fmt.Errorf("버전이 최신이 아닙니다") - } + if err := ensureGame(gameDir, gamePath, serverInfo); err != nil { + return err } cmd := exec.Command(gamePath, "-token", token) @@ -252,6 +463,56 @@ func handleURI(rawURI string) error { 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) + } +}