Files
a301_launcher/main.go
tolelom ad9d372d7c feat: 자동 다운로드/설치 기능 추가
- 게임 파일 없거나 해시 불일치 시 서버에서 zip 자동 다운로드
- Win32 진행률 창으로 다운로드 진행률 표시 (X% 업데이트)
- zip 압축 해제 후 게임 실행 (런처 자신은 덮어쓰기 방지)
- 더블클릭 시 프로토콜만 등록 (토큰 없으므로 다운로드 불필요)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 23:00:19 +09:00

519 lines
14 KiB
Go

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.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()
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)
}
}