feat: DPI 인식 및 진행 막대 UI 추가

- SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2)로 고DPI 디스플레이 지원
- GetDpiForSystem()으로 시스템 DPI 조회 후 모든 크기 동적 스케일링
- 다운로드 진행창에 msctls_progress32 진행 막대 추가 (0~100%)
- Segoe UI 9pt 폰트를 DPI에 맞게 CreateFontIndirectW로 생성
- 4K(200% / 192 DPI)와 FHD(100% / 96 DPI) 모두 동일한 시각적 크기 보장

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 00:08:06 +09:00
parent 6fafe1f3af
commit 22b5efdaab

145
main.go
View File

@@ -32,15 +32,21 @@ const (
const ( const (
wmDestroy uint32 = 0x0002 wmDestroy uint32 = 0x0002
wmClose uint32 = 0x0010 wmClose uint32 = 0x0010
wmSetFont uint32 = 0x0030
wmSetText uint32 = 0x000C wmSetText uint32 = 0x000C
wmAppDone uint32 = 0x8001 wmAppDone uint32 = 0x8001
wsPopup uintptr = 0x80000000 wsPopup uintptr = 0x80000000
wsCaption uintptr = 0x00C00000 wsCaption uintptr = 0x00C00000
wsSysMenu uintptr = 0x00080000
wsChild uintptr = 0x40000000 wsChild uintptr = 0x40000000
wsVisible uintptr = 0x10000000 wsVisible uintptr = 0x10000000
ssCenter uintptr = 0x00000001 ssCenter uintptr = 0x00000001
pbsSmooth uintptr = 0x01
pbmSetRange32 uint32 = 0x0406
pbmSetPos uint32 = 0x0402
swShow = 5 swShow = 5
smCxScreen = 0 smCxScreen = 0
smCyScreen = 1 smCyScreen = 1
@@ -51,12 +57,17 @@ const (
mbYesNo uintptr = 0x00000004 mbYesNo uintptr = 0x00000004
mbQ uintptr = 0x00000020 mbQ uintptr = 0x00000020
idYes = 6 idYes = 6
iccProgressClass uint32 = 0x00000020
logpixelsX = 88
) )
var ( var (
user32 = windows.NewLazySystemDLL("user32.dll") user32 = windows.NewLazySystemDLL("user32.dll")
kernel32 = windows.NewLazySystemDLL("kernel32.dll") kernel32 = windows.NewLazySystemDLL("kernel32.dll")
gdi32 = windows.NewLazySystemDLL("gdi32.dll")
shell32 = windows.NewLazySystemDLL("shell32.dll") shell32 = windows.NewLazySystemDLL("shell32.dll")
comctl32 = windows.NewLazySystemDLL("comctl32.dll")
messageBoxWProc = user32.NewProc("MessageBoxW") messageBoxWProc = user32.NewProc("MessageBoxW")
registerClassExWProc = user32.NewProc("RegisterClassExW") registerClassExWProc = user32.NewProc("RegisterClassExW")
@@ -72,11 +83,17 @@ var (
destroyWindowProc = user32.NewProc("DestroyWindow") destroyWindowProc = user32.NewProc("DestroyWindow")
postQuitMsgProc = user32.NewProc("PostQuitMessage") postQuitMsgProc = user32.NewProc("PostQuitMessage")
getSystemMetricsProc = user32.NewProc("GetSystemMetrics") getSystemMetricsProc = user32.NewProc("GetSystemMetrics")
getDpiForSystemProc = user32.NewProc("GetDpiForSystem")
setProcessDpiAwarenessContextProc = user32.NewProc("SetProcessDpiAwarenessContext")
shellExecuteWProc = shell32.NewProc("ShellExecuteW") shellExecuteWProc = shell32.NewProc("ShellExecuteW")
getModuleHandleWProc = kernel32.NewProc("GetModuleHandleW") getModuleHandleWProc = kernel32.NewProc("GetModuleHandleW")
createFontIndirectWProc = gdi32.NewProc("CreateFontIndirectW")
deleteObjectProc = gdi32.NewProc("DeleteObject")
initCommonControlsExProc = comctl32.NewProc("InitCommonControlsEx")
wndProcCb uintptr wndProcCb uintptr
progressLabelHwnd uintptr progressLabelHwnd uintptr
progressBarHwnd uintptr
) )
type wndClassExW struct { type wndClassExW struct {
@@ -104,11 +121,78 @@ type msgW struct {
ptY 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() { func init() {
wndProcCb = syscall.NewCallback(progressWndProc) wndProcCb = syscall.NewCallback(progressWndProc)
} }
// ── Win32 helpers ────────────────────────────────────────────────────────── // ── 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.
func dpiScale(px int, dpi uint32) uintptr {
return uintptr(px * int(dpi) / 96)
}
// ── Font helpers ─────────────────────────────────────────────────────────────
func createUIFont(pointSize int, dpi uint32) uintptr {
lf := logFontW{
lfHeight: -int32(pointSize) * int32(dpi) / 72,
lfWeight: 400, // FW_NORMAL
lfCharSet: 1, // DEFAULT_CHARSET
lfQuality: 5, // CLEARTYPE_QUALITY
}
face, _ := windows.UTF16FromString("Segoe UI")
copy(lf.lfFaceName[:], face)
font, _, _ := createFontIndirectWProc.Call(uintptr(unsafe.Pointer(&lf)))
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 { func msgBox(title, text string, flags uintptr) int {
t, _ := windows.UTF16PtrFromString(title) t, _ := windows.UTF16PtrFromString(title)
@@ -123,7 +207,7 @@ func openBrowser(rawURL string) {
shellExecuteWProc.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)
} }
// ── Progress window ──────────────────────────────────────────────────────── // ── Progress window ──────────────────────────────────────────────────────────
func progressWndProc(hwnd, uMsg, wParam, lParam uintptr) uintptr { func progressWndProc(hwnd, uMsg, wParam, lParam uintptr) uintptr {
switch uint32(uMsg) { switch uint32(uMsg) {
@@ -140,17 +224,26 @@ func progressWndProc(hwnd, uMsg, wParam, lParam uintptr) uintptr {
return ret return ret
} }
func setProgressText(text string) { func setProgress(text string, pct int) {
if text != "" {
t, _ := windows.UTF16PtrFromString(text) t, _ := windows.UTF16PtrFromString(text)
sendMessageWProc.Call(progressLabelHwnd, uintptr(wmSetText), 0, uintptr(unsafe.Pointer(t))) sendMessageWProc.Call(progressLabelHwnd, uintptr(wmSetText), 0, uintptr(unsafe.Pointer(t)))
} }
if pct >= 0 {
sendMessageWProc.Call(progressBarHwnd, uintptr(pbmSetPos), uintptr(pct), 0)
}
}
// downloadWithProgress shows a progress window and downloads+extracts the zip. // downloadWithProgress shows a DPI-aware progress window and downloads+extracts the zip.
// Must be called from the main goroutine (Win32 message loop requirement). // Must be called from the main goroutine (Win32 message loop requirement).
func downloadWithProgress(downloadURL, destDir string) error { func downloadWithProgress(downloadURL, destDir string) error {
runtime.LockOSThread() runtime.LockOSThread()
defer runtime.UnlockOSThread() defer runtime.UnlockOSThread()
initCommonControls()
dpi := getSystemDPI()
s := func(px int) uintptr { return dpiScale(px, dpi) }
hInstance, _, _ := getModuleHandleWProc.Call(0) hInstance, _, _ := getModuleHandleWProc.Call(0)
className, _ := windows.UTF16PtrFromString("A301Progress") className, _ := windows.UTF16PtrFromString("A301Progress")
@@ -165,7 +258,8 @@ func downloadWithProgress(downloadURL, destDir string) error {
screenW, _, _ := getSystemMetricsProc.Call(smCxScreen) screenW, _, _ := getSystemMetricsProc.Call(smCxScreen)
screenH, _, _ := getSystemMetricsProc.Call(smCyScreen) screenH, _, _ := getSystemMetricsProc.Call(smCyScreen)
const winW, winH = 420, 110 winW := s(440)
winH := s(130)
x := (screenW - winW) / 2 x := (screenW - winW) / 2
y := (screenH - winH) / 2 y := (screenH - winH) / 2
@@ -174,11 +268,15 @@ func downloadWithProgress(downloadURL, destDir string) error {
0, 0,
uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(className)),
uintptr(unsafe.Pointer(title)), uintptr(unsafe.Pointer(title)),
wsPopup|wsCaption|wsVisible, wsPopup|wsCaption|wsSysMenu|wsVisible,
x, y, winW, winH, x, y, winW, winH,
0, 0, hInstance, 0, 0, 0, hInstance, 0,
) )
font := createUIFont(9, dpi)
defer deleteObjectProc.Call(font)
// 상태 레이블 (텍스트)
staticClass, _ := windows.UTF16PtrFromString("STATIC") staticClass, _ := windows.UTF16PtrFromString("STATIC")
initText, _ := windows.UTF16PtrFromString("게임 파일을 다운로드하는 중...") initText, _ := windows.UTF16PtrFromString("게임 파일을 다운로드하는 중...")
progressLabelHwnd, _, _ = createWindowExWProc.Call( progressLabelHwnd, _, _ = createWindowExWProc.Call(
@@ -186,9 +284,22 @@ func downloadWithProgress(downloadURL, destDir string) error {
uintptr(unsafe.Pointer(staticClass)), uintptr(unsafe.Pointer(staticClass)),
uintptr(unsafe.Pointer(initText)), uintptr(unsafe.Pointer(initText)),
wsChild|wsVisible|ssCenter, wsChild|wsVisible|ssCenter,
10, 35, 400, 30, s(16), s(20), winW-s(32), s(22),
hwnd, 0, hInstance, 0, hwnd, 0, hInstance, 0,
) )
sendMessageWProc.Call(progressLabelHwnd, uintptr(wmSetFont), font, 1)
// 진행 막대
progressClass, _ := windows.UTF16PtrFromString("msctls_progress32")
progressBarHwnd, _, _ = createWindowExWProc.Call(
0,
uintptr(unsafe.Pointer(progressClass)),
0,
wsChild|wsVisible|pbsSmooth,
s(16), s(52), winW-s(32), s(22),
hwnd, 0, hInstance, 0,
)
sendMessageWProc.Call(progressBarHwnd, uintptr(pbmSetRange32), 0, 100)
showWindowProc.Call(hwnd, swShow) showWindowProc.Call(hwnd, swShow)
updateWindowProc.Call(hwnd) updateWindowProc.Call(hwnd)
@@ -239,8 +350,8 @@ func doDownload(downloadURL, destDir string) error {
} }
downloaded += int64(n) downloaded += int64(n)
if total > 0 { if total > 0 {
pct := downloaded * 100 / total pct := int(downloaded * 100 / total)
setProgressText(fmt.Sprintf("다운로드 중... %d%%", pct)) setProgress(fmt.Sprintf("다운로드 중... %d%%", pct), pct)
} }
} }
if err == io.EOF { if err == io.EOF {
@@ -255,7 +366,7 @@ func doDownload(downloadURL, destDir string) error {
tmpFile.Close() tmpFile.Close()
defer os.Remove(tmpPath) defer os.Remove(tmpPath)
setProgressText("압축을 해제하는 중...") setProgress("압축을 해제하는 중...", -1)
return extractZip(tmpPath, destDir) return extractZip(tmpPath, destDir)
} }
@@ -317,7 +428,7 @@ func extractZip(zipPath, destDir string) error {
return nil return nil
} }
// ── Server info ──────────────────────────────────────────────────────────── // ── Server info ──────────────────────────────────────────────────────────────
type downloadInfo struct { type downloadInfo struct {
FileHash string `json:"fileHash"` FileHash string `json:"fileHash"`
@@ -358,7 +469,7 @@ func hashFile(path string) (string, error) {
return hex.EncodeToString(h.Sum(nil)), nil return hex.EncodeToString(h.Sum(nil)), nil
} }
// ── Launcher path ────────────────────────────────────────────────────────── // ── Launcher path ────────────────────────────────────────────────────────────
func launcherPath() (string, error) { func launcherPath() (string, error) {
exe, err := os.Executable() exe, err := os.Executable()
@@ -368,7 +479,7 @@ func launcherPath() (string, error) {
return filepath.Abs(exe) return filepath.Abs(exe)
} }
// ── Protocol install / uninstall ─────────────────────────────────────────── // ── Protocol install / uninstall ─────────────────────────────────────────────
func install() error { func install() error {
exePath, err := launcherPath() exePath, err := launcherPath()
@@ -407,7 +518,7 @@ func uninstall() error {
return nil return nil
} }
// ── Game update check + download ─────────────────────────────────────────── // ── Game update check + download ─────────────────────────────────────────────
func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error { func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error {
needsDownload := false needsDownload := false
@@ -436,7 +547,7 @@ func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error {
return nil return nil
} }
// ── URI handler ──────────────────────────────────────────────────────────── // ── URI handler ──────────────────────────────────────────────────────────────
func handleURI(rawURI string) error { func handleURI(rawURI string) error {
parsed, err := url.Parse(rawURI) parsed, err := url.Parse(rawURI)
@@ -473,9 +584,11 @@ func handleURI(rawURI string) error {
return nil return nil
} }
// ── Entry point ──────────────────────────────────────────────────────────── // ── Entry point ──────────────────────────────────────────────────────────────
func main() { func main() {
enableDPIAwareness()
if len(os.Args) < 2 { if len(os.Args) < 2 {
ret := msgBox("A301 런처", "게임 실행을 위해 프로토콜을 등록합니다.\n계속하시겠습니까?", mbYesNo|mbQ) ret := msgBox("A301 런처", "게임 실행을 위해 프로토콜을 등록합니다.\n계속하시겠습니까?", mbYesNo|mbQ)
if ret != idYes { if ret != idYes {