Compare commits
7 Commits
v0.0.1
...
10651d294a
| Author | SHA1 | Date | |
|---|---|---|---|
| 10651d294a | |||
| f77a7e0e23 | |||
| 28c1b377df | |||
| 22b5efdaab | |||
| 6fafe1f3af | |||
| 90fcc0f94e | |||
| ad9d372d7c |
51
CLAUDE.md
Normal file
51
CLAUDE.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Build Command
|
||||
|
||||
```bash
|
||||
C:\Users\98kim\sdk\go1.25.1\bin\go.exe build -ldflags="-H windowsgui -s -w" -o launcher.exe .
|
||||
```
|
||||
|
||||
`-H windowsgui` 필수 — 없으면 실행 시 콘솔 창이 함께 열림.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Go** 단일 파일 (`main.go`)
|
||||
- **Win32 API** — `user32.dll`, `gdi32.dll`, `comctl32.dll`, `uxtheme.dll`, `shell32.dll`
|
||||
- `golang.org/x/sys/windows` + `windows/registry`
|
||||
|
||||
## Project Purpose
|
||||
|
||||
"One of the plans" Windows 런처.
|
||||
`a301://` 커스텀 URI 프로토콜 등록 → 게임 자동 다운로드/업데이트/실행.
|
||||
|
||||
## Architecture
|
||||
|
||||
모든 로직이 `main.go` 단일 파일에 있음:
|
||||
|
||||
- **`enableDPIAwareness()`** — `SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2)` 호출. `main()` 첫 줄에서 실행.
|
||||
- **`downloadWithProgress()`** — Win32 메시지 루프 직접 운영. 반드시 메인 고루틴에서 호출 (`runtime.LockOSThread`).
|
||||
- **`progressWndProc()`** — `WM_CTLCOLORSTATIC`으로 다크 테마 적용. `hBrushBg` 전역 변수 참조.
|
||||
- **`setProgress(text, pct)`** — 다운로드 고루틴에서 호출해 레이블 텍스트와 진행 막대 동시 업데이트.
|
||||
- **`fetchServerInfo()`** — `https://a301.api.tolelom.xyz/api/download/info` 조회.
|
||||
- **`ensureGame()`** — `A301.exe` SHA256 해시 비교 후 불일치 시 재다운로드.
|
||||
|
||||
## UI Details
|
||||
|
||||
- DPI: `GetDpiForSystem()` → `dpiScale(px, dpi)` 함수로 모든 크기 동적 계산
|
||||
- 진행 막대 색: `SetWindowTheme("", "")` 비주얼 스타일 비활성화 후 `PBM_SETBARCOLOR` 적용
|
||||
- 다크 배경: `WM_CTLCOLORSTATIC` 핸들러에서 `hBrushBg` 반환 + `SetBkMode(TRANSPARENT)`
|
||||
- 타이틀 레이블(`titleLabelHwnd`)만 강조색, 나머지 STATIC은 밝은 회색
|
||||
|
||||
## Key Constants
|
||||
|
||||
```go
|
||||
serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info"
|
||||
gameExeName = "A301.exe" // 기술 식별자 — 게임 표기명과 별개
|
||||
protocolName = "a301" // 기술 식별자
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `extractZip()` — zip 내 최상위 디렉토리 1단계 제거 후 추출. `launcher.exe` 자신은 덮어쓰기 방지.
|
||||
- 레지스트리는 `HKCU` (현재 사용자) 에만 쓰므로 관리자 권한 불필요.
|
||||
64
README.md
Normal file
64
README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# One of the plans — Launcher
|
||||
|
||||
Windows 전용 게임 런처. `a301://` 커스텀 URI 프로토콜을 등록하고, 웹에서 게임 시작 버튼 클릭 시 게임을 자동 다운로드/업데이트/실행합니다.
|
||||
|
||||
## 빌드
|
||||
|
||||
```bash
|
||||
# Go 1.24+ 필요
|
||||
go build -ldflags="-H windowsgui -s -w" -o launcher.exe .
|
||||
```
|
||||
|
||||
`-H windowsgui` — 콘솔 창 없이 실행 (Win32 GUI 앱)
|
||||
`-s -w` — 디버그 심볼 제거 (바이너리 크기 축소)
|
||||
|
||||
## 사용법
|
||||
|
||||
| 실행 방법 | 동작 |
|
||||
|-----------|------|
|
||||
| `launcher.exe` (인자 없음) | `a301://` 프로토콜 등록 확인 다이얼로그 |
|
||||
| `launcher.exe install` | 프로토콜 강제 등록 |
|
||||
| `launcher.exe uninstall` | 프로토콜 제거 |
|
||||
| `launcher.exe "a301://launch?token=<JWT>"` | 게임 실행 (웹에서 자동 호출) |
|
||||
|
||||
## 게임 실행 흐름
|
||||
|
||||
```
|
||||
웹 "게임 시작" 클릭
|
||||
└─ a301://launch?token=JWT
|
||||
└─ launcher.exe 실행
|
||||
├─ 서버에서 최신 버전 정보 조회 (fileHash, url)
|
||||
├─ A301.exe 없음 또는 해시 불일치 → 다운로드 창 표시
|
||||
│ ├─ game.zip 다운로드 (진행률 표시)
|
||||
│ └─ 압축 해제 (launcher.exe 자신은 덮어쓰기 방지)
|
||||
└─ A301.exe -token <JWT> 실행
|
||||
```
|
||||
|
||||
## UI
|
||||
|
||||
다크 테마 Win32 창 (배경 `#2E2C2F`, 강조색 `#BACDB0`).
|
||||
4K / FHD 해상도 모두 자연스럽게 표시 (DPI 인식 + 동적 스케일링).
|
||||
|
||||
## 배포 구조
|
||||
|
||||
유저가 받는 구성:
|
||||
|
||||
```
|
||||
(게임 폴더)/
|
||||
├── launcher.exe ← 이 파일
|
||||
└── A301.exe ← 런처가 자동 다운로드
|
||||
```
|
||||
|
||||
`launcher.exe`를 처음 실행하면 레지스트리에 `a301://` 프로토콜을 등록하고, 이후 웹에서 게임 시작 버튼을 누를 때마다 자동으로 이 런처가 실행됩니다.
|
||||
|
||||
## 레지스트리 등록 경로
|
||||
|
||||
```
|
||||
HKCU\Software\Classes\a301\
|
||||
HKCU\Software\Classes\a301\shell\open\command → "launcher.exe" "%1"
|
||||
```
|
||||
|
||||
## 의존성
|
||||
|
||||
- `golang.org/x/sys/windows` — Win32 API
|
||||
- `golang.org/x/sys/windows/registry` — 레지스트리 접근
|
||||
717
main.go
717
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"
|
||||
@@ -21,151 +24,480 @@ import (
|
||||
const (
|
||||
protocolName = "a301"
|
||||
gameExeName = "A301.exe"
|
||||
serverInfoURL = "https://a301.tolelom.xyz/api/download/info"
|
||||
webDownloadURL = "https://a301.tolelom.xyz"
|
||||
serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info"
|
||||
webURL = "https://a301.tolelom.xyz"
|
||||
)
|
||||
|
||||
// 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")
|
||||
messageBoxW = user32.NewProc("MessageBoxW")
|
||||
shellExecuteW = windows.NewLazySystemDLL("shell32.dll").NewProc("ShellExecuteW")
|
||||
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
gdi32 = windows.NewLazySystemDLL("gdi32.dll")
|
||||
shell32 = windows.NewLazySystemDLL("shell32.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")
|
||||
shellExecuteWProc = shell32.NewProc("ShellExecuteW")
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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.
|
||||
func dpiScale(px int, dpi uint32) uintptr {
|
||||
return uintptr(px * int(dpi) / 96)
|
||||
}
|
||||
|
||||
// ── 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)))
|
||||
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, _ := 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,
|
||||
// ── 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
|
||||
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, _ := windows.UTF16PtrFromString(text)
|
||||
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,
|
||||
}
|
||||
registerClassExWProc.Call(uintptr(unsafe.Pointer(&wc)))
|
||||
|
||||
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 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]
|
||||
titleFont := createUIFont(13, dpi, true)
|
||||
defer deleteObjectProc.Call(titleFont)
|
||||
statusFont := createUIFont(9, dpi, false)
|
||||
defer deleteObjectProc.Call(statusFont)
|
||||
|
||||
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)
|
||||
staticClass, _ := windows.UTF16PtrFromString("STATIC")
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func launcherPath() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Abs(exe)
|
||||
}
|
||||
|
||||
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,
|
||||
// ── "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,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("레지스트리 키 생성 실패: %w", err)
|
||||
}
|
||||
defer key.Close()
|
||||
sendMessageWProc.Call(titleLabelHwnd, uintptr(wmSetFont), titleFont, 1)
|
||||
|
||||
if err := key.SetStringValue("", "URL:A301 Protocol"); err != nil {
|
||||
return err
|
||||
// ── 상태 레이블 ──
|
||||
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)
|
||||
|
||||
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
|
||||
}
|
||||
if err := key.SetStringValue("URL Protocol", ""); err != nil {
|
||||
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 := int(downloaded * 100 / total)
|
||||
setProgress(fmt.Sprintf("다운로드 중... %d%%", pct), 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)
|
||||
|
||||
setProgress("압축을 해제하는 중...", -1)
|
||||
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
|
||||
}
|
||||
|
||||
cmdKey, _, err := registry.CreateKey(
|
||||
registry.CURRENT_USER,
|
||||
`Software\Classes\`+protocolName+`\shell\open\command`,
|
||||
registry.SET_VALUE,
|
||||
)
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("command 키 생성 실패: %w", err)
|
||||
return err
|
||||
}
|
||||
defer cmdKey.Close()
|
||||
|
||||
cmdValue := fmt.Sprintf(`"%s" "%%1"`, exePath)
|
||||
return cmdKey.SetStringValue("", cmdValue)
|
||||
}
|
||||
|
||||
func uninstall() error {
|
||||
paths := []string{
|
||||
`Software\Classes\` + protocolName + `\shell\open\command`,
|
||||
`Software\Classes\` + protocolName + `\shell\open`,
|
||||
`Software\Classes\` + protocolName + `\shell`,
|
||||
`Software\Classes\` + protocolName,
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return err
|
||||
}
|
||||
for _, p := range paths {
|
||||
err := registry.DeleteKey(registry.CURRENT_USER, p)
|
||||
if err != nil && err != registry.ErrNotExist {
|
||||
_, 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"`
|
||||
@@ -178,6 +510,13 @@ func fetchServerInfo() (*downloadInfo, error) {
|
||||
}
|
||||
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)
|
||||
@@ -191,7 +530,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 +537,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:One of the plans 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 +635,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 +649,58 @@ func handleURI(rawURI string) error {
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("게임 실행 실패: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Entry point ──────────────────────────────────────────────────────────────
|
||||
|
||||
func main() {
|
||||
enableDPIAwareness()
|
||||
|
||||
if len(os.Args) < 2 {
|
||||
ret := msgBox("One of the plans 런처", "게임 실행을 위해 프로토콜을 등록합니다.\n계속하시겠습니까?", mbYesNo|mbQ)
|
||||
if ret != idYes {
|
||||
return
|
||||
}
|
||||
if err := install(); err != nil {
|
||||
msgBox("One of the plans 런처 - 오류", fmt.Sprintf("등록 실패:\n%v", err), mbOK|mbError)
|
||||
os.Exit(1)
|
||||
}
|
||||
msgBox("One of the plans 런처", "a301:// 프로토콜이 등록되었습니다.\n이제 웹에서 게임 시작 버튼을 사용할 수 있습니다.", mbOK|mbInfo)
|
||||
return
|
||||
}
|
||||
|
||||
arg := os.Args[1]
|
||||
switch {
|
||||
case arg == "install":
|
||||
if err := install(); err != nil {
|
||||
msgBox("One of the plans 런처 - 오류", fmt.Sprintf("등록 실패:\n%v", err), mbOK|mbError)
|
||||
os.Exit(1)
|
||||
}
|
||||
msgBox("One of the plans 런처", "a301:// 프로토콜이 등록되었습니다.", mbOK|mbInfo)
|
||||
|
||||
case arg == "uninstall":
|
||||
if err := uninstall(); err != nil {
|
||||
msgBox("One of the plans 런처 - 오류", fmt.Sprintf("제거 실패:\n%v", err), mbOK|mbError)
|
||||
os.Exit(1)
|
||||
}
|
||||
msgBox("One of the plans 런처", "a301:// 프로토콜이 제거되었습니다.", mbOK|mbInfo)
|
||||
|
||||
case strings.HasPrefix(arg, protocolName+"://"):
|
||||
if err := handleURI(arg); err != nil {
|
||||
if strings.Contains(err.Error(), "버전이 최신이 아닙니다") {
|
||||
ret := msgBox("One of the plans - 업데이트 필요", "새로운 버전이 있습니다. 다운로드 페이지로 이동할까요?", mbYesNo|mbInfo)
|
||||
if ret == idYes {
|
||||
openBrowser(webURL)
|
||||
}
|
||||
} else {
|
||||
msgBox("One of the plans 런처 - 오류", fmt.Sprintf("실행 실패:\n%v", err), mbOK|mbError)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
default:
|
||||
msgBox("One of the plans 런처 - 오류", fmt.Sprintf("알 수 없는 명령: %s", arg), mbOK|mbError)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user