refactor: 단일 파일을 main/ui/download/protocol 4개 파일로 분리

- main.go: 진입점(main), handleURI, version
- ui.go: Win32 UI (progress window, DPI, 폰트, 메시지박스)
- download.go: 다운로드/추출 로직 (HTTP client, extractZip, doDownload)
- protocol.go: 레지스트리 등록/해제, ensureGame, ensureLauncher, 서버 API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 17:11:41 +09:00
parent 8759587e25
commit 84dd2373a4
5 changed files with 1059 additions and 1099 deletions

387
ui.go Normal file
View File

@@ -0,0 +1,387 @@
package main
import (
"fmt"
"runtime"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
// Win32 constants
const (
wmDestroy uint32 = 0x0002
wmClose uint32 = 0x0010
wmSetFont uint32 = 0x0030
wmSetText uint32 = 0x000C
wmCtlColorStatic uint32 = 0x0138
wmAppDone uint32 = 0x8001
wsPopup uintptr = 0x80000000
wsCaption uintptr = 0x00C00000
wsSysMenu uintptr = 0x00080000
wsChild uintptr = 0x40000000
wsVisible uintptr = 0x10000000
ssCenter uintptr = 0x00000001
pbsSmooth uintptr = 0x01
pbmSetRange32 uint32 = 0x0406
pbmSetPos uint32 = 0x0402
pbmSetBarColor uint32 = 0x0409
pbmSetBkColor uint32 = 0x2001
setBkModeTransparent = 1
swShow = 5
smCxScreen = 0
smCyScreen = 1
mbOK uintptr = 0x00000000
mbInfo uintptr = 0x00000040
mbError uintptr = 0x00000010
mbYesNo uintptr = 0x00000004
mbQ uintptr = 0x00000020
idYes = 6
iccProgressClass uint32 = 0x00000020
)
// rgb builds a COLORREF from R, G, B components.
func rgb(r, g, b uint8) uintptr {
return uintptr(r) | (uintptr(g) << 8) | (uintptr(b) << 16)
}
// 웹사이트 색상과 동일한 팔레트
var (
colorBg = rgb(46, 44, 47) // #2E2C2F
colorText = rgb(200, 200, 200) // 밝은 회색
colorAccent = rgb(186, 205, 176) // #BACDB0
colorProgressBg = rgb(65, 63, 67) // bg보다 약간 밝은 색
)
var (
user32 = windows.NewLazySystemDLL("user32.dll")
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
gdi32 = windows.NewLazySystemDLL("gdi32.dll")
comctl32 = windows.NewLazySystemDLL("comctl32.dll")
uxtheme = windows.NewLazySystemDLL("uxtheme.dll")
messageBoxWProc = user32.NewProc("MessageBoxW")
registerClassExWProc = user32.NewProc("RegisterClassExW")
createWindowExWProc = user32.NewProc("CreateWindowExW")
showWindowProc = user32.NewProc("ShowWindow")
updateWindowProc = user32.NewProc("UpdateWindow")
getMessageWProc = user32.NewProc("GetMessageW")
translateMsgProc = user32.NewProc("TranslateMessage")
dispatchMsgWProc = user32.NewProc("DispatchMessageW")
sendMessageWProc = user32.NewProc("SendMessageW")
postMessageWProc = user32.NewProc("PostMessageW")
defWindowProcWProc = user32.NewProc("DefWindowProcW")
destroyWindowProc = user32.NewProc("DestroyWindow")
postQuitMsgProc = user32.NewProc("PostQuitMessage")
getSystemMetricsProc = user32.NewProc("GetSystemMetrics")
getDpiForSystemProc = user32.NewProc("GetDpiForSystem")
setProcessDpiAwarenessContextProc = user32.NewProc("SetProcessDpiAwarenessContext")
findWindowWProc = user32.NewProc("FindWindowW")
setForegroundWindowProc = user32.NewProc("SetForegroundWindow")
createMutexWProc = kernel32.NewProc("CreateMutexW")
getModuleHandleWProc = kernel32.NewProc("GetModuleHandleW")
createFontIndirectWProc = gdi32.NewProc("CreateFontIndirectW")
createSolidBrushProc = gdi32.NewProc("CreateSolidBrush")
setTextColorProc = gdi32.NewProc("SetTextColor")
setBkModeProc = gdi32.NewProc("SetBkMode")
deleteObjectProc = gdi32.NewProc("DeleteObject")
initCommonControlsExProc = comctl32.NewProc("InitCommonControlsEx")
setWindowThemeProc = uxtheme.NewProc("SetWindowTheme")
wndProcCb uintptr
titleLabelHwnd uintptr
progressLabelHwnd uintptr
progressBarHwnd uintptr
hBrushBg uintptr
)
type wndClassExW struct {
cbSize uint32
style uint32
lpfnWndProc uintptr
cbClsExtra int32
cbWndExtra int32
hInstance uintptr
hIcon uintptr
hCursor uintptr
hbrBackground uintptr
lpszMenuName *uint16
lpszClassName *uint16
hIconSm uintptr
}
type msgW struct {
hwnd uintptr
message uint32
wParam uintptr
lParam uintptr
time uint32
ptX int32
ptY int32
}
type initCommonControlsExS struct {
dwSize uint32
dwICC uint32
}
type logFontW struct {
lfHeight int32
lfWidth int32
lfEscapement int32
lfOrientation int32
lfWeight int32
lfItalic byte
lfUnderline byte
lfStrikeOut byte
lfCharSet byte
lfOutPrecision byte
lfClipPrecision byte
lfQuality byte
lfPitchAndFamily byte
lfFaceName [32]uint16
}
func init() {
wndProcCb = syscall.NewCallback(progressWndProc)
}
func enableDPIAwareness() {
// DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4 (Windows 10 1703+)
setProcessDpiAwarenessContextProc.Call(^uintptr(3))
}
func getSystemDPI() uint32 {
dpi, _, _ := getDpiForSystemProc.Call()
if dpi == 0 {
return 96
}
return uint32(dpi)
}
// dpiScale scales a base-96-DPI pixel value to the system DPI.
func dpiScale(px int, dpi uint32) uintptr {
return uintptr(int(float64(px)*float64(dpi)/96.0 + 0.5))
}
func createUIFont(pointSize int, dpi uint32, bold bool) uintptr {
weight := int32(400) // FW_NORMAL
if bold {
weight = 700 // FW_BOLD
}
lf := logFontW{
lfHeight: -int32(pointSize) * int32(dpi) / 72,
lfWeight: weight,
lfCharSet: 1, // DEFAULT_CHARSET
lfQuality: 5, // CLEARTYPE_QUALITY
}
face, _ := windows.UTF16FromString("Segoe UI")
copy(lf.lfFaceName[:], face)
font, _, _ := createFontIndirectWProc.Call(uintptr(unsafe.Pointer(&lf)))
// font가 0이면 시스템 기본 폰트가 사용됨 (WM_SETFONT에 0 전달 시 기본값)
return font
}
func initCommonControls() {
icc := initCommonControlsExS{
dwSize: uint32(unsafe.Sizeof(initCommonControlsExS{})),
dwICC: iccProgressClass,
}
initCommonControlsExProc.Call(uintptr(unsafe.Pointer(&icc)))
}
func msgBox(title, text string, flags uintptr) int {
t, err := windows.UTF16PtrFromString(title)
if err != nil {
return 0
}
m, err := windows.UTF16PtrFromString(text)
if err != nil {
return 0
}
ret, _, _ := messageBoxWProc.Call(0, uintptr(unsafe.Pointer(m)), uintptr(unsafe.Pointer(t)), flags)
return int(ret)
}
func progressWndProc(hwnd, uMsg, wParam, lParam uintptr) uintptr {
switch uint32(uMsg) {
case wmClose:
ret := msgBox("One of the plans 런처", "다운로드를 취소하시겠습니까?", mbYesNo|mbQ)
if ret == idYes {
downloadCancelled.Store(true)
destroyWindowProc.Call(hwnd)
}
return 0
case wmDestroy:
postQuitMsgProc.Call(0)
return 0
case wmAppDone:
destroyWindowProc.Call(hwnd)
return 0
case wmCtlColorStatic:
hdc := wParam
setBkModeProc.Call(hdc, setBkModeTransparent)
if lParam == titleLabelHwnd {
setTextColorProc.Call(hdc, colorAccent)
} else {
setTextColorProc.Call(hdc, colorText)
}
return hBrushBg
}
ret, _, _ := defWindowProcWProc.Call(hwnd, uMsg, wParam, lParam)
return ret
}
func setProgress(text string, pct int) {
if text != "" {
t, err := windows.UTF16PtrFromString(text)
if err == nil {
sendMessageWProc.Call(progressLabelHwnd, uintptr(wmSetText), 0, uintptr(unsafe.Pointer(t)))
}
}
if pct >= 0 {
sendMessageWProc.Call(progressBarHwnd, uintptr(pbmSetPos), uintptr(pct), 0)
}
}
// downloadWithProgress shows a DPI-aware dark-themed progress window and downloads+extracts the zip.
// Must be called from the main goroutine (Win32 message loop requirement).
func downloadWithProgress(downloadURL, destDir string) error {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
initCommonControls()
dpi := getSystemDPI()
s := func(px int) uintptr { return dpiScale(px, dpi) }
hBrushBg, _, _ = createSolidBrushProc.Call(colorBg)
defer deleteObjectProc.Call(hBrushBg)
hInstance, _, _ := getModuleHandleWProc.Call(0)
className, _ := windows.UTF16PtrFromString("A301Progress")
wc := wndClassExW{
cbSize: uint32(unsafe.Sizeof(wndClassExW{})),
lpfnWndProc: wndProcCb,
hInstance: hInstance,
lpszClassName: className,
hbrBackground: hBrushBg,
}
atom, _, _ := registerClassExWProc.Call(uintptr(unsafe.Pointer(&wc)))
if atom == 0 {
return fmt.Errorf("윈도우 클래스 등록 실패")
}
screenW, _, _ := getSystemMetricsProc.Call(smCxScreen)
screenH, _, _ := getSystemMetricsProc.Call(smCyScreen)
winW := s(440)
winH := s(152)
x := (screenW - winW) / 2
y := (screenH - winH) / 2
titleStr, _ := windows.UTF16PtrFromString("One of the plans 런처")
hwnd, _, _ := createWindowExWProc.Call(
0,
uintptr(unsafe.Pointer(className)),
uintptr(unsafe.Pointer(titleStr)),
wsPopup|wsCaption|wsSysMenu|wsVisible,
x, y, winW, winH,
0, 0, hInstance, 0,
)
if hwnd == 0 {
return fmt.Errorf("다운로드 창 생성 실패")
}
titleFont := createUIFont(13, dpi, true)
defer deleteObjectProc.Call(titleFont)
statusFont := createUIFont(9, dpi, false)
defer deleteObjectProc.Call(statusFont)
staticClass, _ := windows.UTF16PtrFromString("STATIC")
titleText, _ := windows.UTF16PtrFromString("One of the plans")
titleLabelHwnd, _, _ = createWindowExWProc.Call(
0,
uintptr(unsafe.Pointer(staticClass)),
uintptr(unsafe.Pointer(titleText)),
wsChild|wsVisible|ssCenter,
s(20), s(14), winW-s(40), s(28),
hwnd, 0, hInstance, 0,
)
sendMessageWProc.Call(titleLabelHwnd, uintptr(wmSetFont), titleFont, 1)
initText, _ := windows.UTF16PtrFromString("게임 파일을 다운로드하는 중...")
progressLabelHwnd, _, _ = createWindowExWProc.Call(
0,
uintptr(unsafe.Pointer(staticClass)),
uintptr(unsafe.Pointer(initText)),
wsChild|wsVisible|ssCenter,
s(20), s(52), winW-s(40), s(20),
hwnd, 0, hInstance, 0,
)
sendMessageWProc.Call(progressLabelHwnd, uintptr(wmSetFont), statusFont, 1)
progressClass, _ := windows.UTF16PtrFromString("msctls_progress32")
progressBarHwnd, _, _ = createWindowExWProc.Call(
0,
uintptr(unsafe.Pointer(progressClass)),
0,
wsChild|wsVisible|pbsSmooth,
s(20), s(82), winW-s(40), s(18),
hwnd, 0, hInstance, 0,
)
sendMessageWProc.Call(progressBarHwnd, uintptr(pbmSetRange32), 0, 100)
empty, _ := windows.UTF16PtrFromString("")
setWindowThemeProc.Call(progressBarHwnd, uintptr(unsafe.Pointer(empty)), uintptr(unsafe.Pointer(empty)))
sendMessageWProc.Call(progressBarHwnd, uintptr(pbmSetBarColor), 0, colorAccent)
sendMessageWProc.Call(progressBarHwnd, uintptr(pbmSetBkColor), 0, colorProgressBg)
showWindowProc.Call(hwnd, swShow)
updateWindowProc.Call(hwnd)
downloadCancelled.Store(false)
errCh := make(chan error, 1)
go func() {
errCh <- doDownload(downloadURL, destDir)
postMessageWProc.Call(hwnd, uintptr(wmAppDone), 0, 0)
}()
var m msgW
for {
ret, _, _ := getMessageWProc.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
if ret == 0 || ret == ^uintptr(0) {
break
}
translateMsgProc.Call(uintptr(unsafe.Pointer(&m)))
dispatchMsgWProc.Call(uintptr(unsafe.Pointer(&m)))
}
return <-errCh
}
func activateExistingWindow() {
className, _ := windows.UTF16PtrFromString("A301Progress")
hwnd, _, _ := findWindowWProc.Call(uintptr(unsafe.Pointer(className)), 0)
if hwnd != 0 {
setForegroundWindowProc.Call(hwnd)
}
}
func acquireSingleInstance() bool {
name, _ := windows.UTF16PtrFromString("Global\\A301LauncherMutex")
_, _, err := createMutexWProc.Call(0, 0, uintptr(unsafe.Pointer(name)))
// ERROR_ALREADY_EXISTS = 183
if errno, ok := err.(syscall.Errno); ok && errno == 183 {
return false
}
return true
}