Files
a301_launcher/main.go
tolelom 48df55a82e fix: 보안 강화, 안정성 및 UX 개선
- 토큰 전달 방식 변경: 명령줄 인자(-token) → 환경변수(A301_TOKEN)로 프로세스 목록 노출 방지
- 고정 설치 경로: %LOCALAPPDATA%\A301\로 런처 복사 후 레지스트리 등록 (Downloads 정리 시 깨짐 방지)
- zip 추출 시 symlink 엔트리 스킵 (경로 탈출 방지)
- fetchServerInfo 3회 재시도 (exponential backoff)
- 다운로드 이어받기: Range 헤더 지원, 취소/오류 시 임시 파일 유지
- 416 응답 시 서버 파일 변경 감지하여 처음부터 재다운로드
- 단일 인스턴스 UX: 기존 창 FindWindow+SetForegroundWindow로 활성화
- uninstall 시 설치 디렉토리 정리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:10:11 +09:00

942 lines
28 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"
"sync/atomic"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
const (
protocolName = "a301"
gameExeName = "A301.exe"
serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info"
)
const maxDownloadSize = 2 << 30 // 2GB
const maxExtractFileSize = 4 << 30 // 개별 파일 최대 4GB
var checkRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" && req.URL.Scheme != "http" {
return fmt.Errorf("허용되지 않는 리다이렉트 스킴: %s", req.URL.Scheme)
}
if len(via) >= 10 {
return fmt.Errorf("리다이렉트 횟수 초과")
}
return nil
}
// apiClient: 서버 정보 조회 등 짧은 요청용 (전체 120초 타임아웃)
var apiClient = &http.Client{
Timeout: 120 * time.Second,
CheckRedirect: checkRedirect,
}
// downloadClient: 대용량 파일 다운로드용 (연결 30초 + 유휴 60초, 전체 타임아웃 없음)
var downloadClient = &http.Client{
Transport: &http.Transport{
TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
IdleConnTimeout: 60 * time.Second,
},
CheckRedirect: checkRedirect,
}
// 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
downloadCancelled atomic.Bool
)
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, _, _ := messageBoxWProc.Call(0, uintptr(unsafe.Pointer(m)), uintptr(unsafe.Pointer(t)), flags)
return int(ret)
}
// ── Progress window ──────────────────────────────────────────────────────────
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, _ := 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,
)
titleFont := createUIFont(13, dpi, true)
defer deleteObjectProc.Call(titleFont)
statusFont := createUIFont(9, dpi, false)
defer deleteObjectProc.Call(statusFont)
staticClass, _ := windows.UTF16PtrFromString("STATIC")
// ── "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,
)
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)
// 비주얼 스타일 비활성화 → 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)
downloadCancelled.Store(false)
errCh := make(chan error, 1)
go func() {
errCh <- doDownload(downloadURL, destDir)
// 윈도우가 이미 파괴된 경우(취소) PostMessage는 무시됨
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 {
tmpPath := filepath.Join(os.TempDir(), "a301_game.zip")
// 이어받기: 기존 임시 파일 크기 확인
var resumeOffset int64
if fi, err := os.Stat(tmpPath); err == nil {
resumeOffset = fi.Size()
}
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("다운로드 요청 생성 실패: %w", err)
}
if resumeOffset > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeOffset))
}
resp, err := downloadClient.Do(req)
if err != nil {
return fmt.Errorf("다운로드 연결 실패: %w", err)
}
defer resp.Body.Close()
var downloaded int64
var total int64
var tmpFile *os.File
switch resp.StatusCode {
case http.StatusPartialContent:
// 서버가 Range 요청 수락 → 이어받기
downloaded = resumeOffset
total = resumeOffset + resp.ContentLength
tmpFile, err = os.OpenFile(tmpPath, os.O_WRONLY|os.O_APPEND, 0644)
case http.StatusOK:
// 서버가 Range 미지원이거나 파일이 변경됨 → 처음부터
total = resp.ContentLength
tmpFile, err = os.Create(tmpPath)
case http.StatusRequestedRangeNotSatisfiable:
// 임시 파일이 서버 파일보다 큼 (서버 파일 변경) → 처음부터
os.Remove(tmpPath)
return doDownload(downloadURL, destDir)
default:
return fmt.Errorf("다운로드 실패 (HTTP %d)", resp.StatusCode)
}
if err != nil {
return fmt.Errorf("임시 파일 열기 실패: %w", err)
}
if total > maxDownloadSize {
tmpFile.Close()
os.Remove(tmpPath)
return fmt.Errorf("파일이 너무 큽니다 (%d bytes)", total)
}
buf := make([]byte, 32*1024)
for {
if downloadCancelled.Load() {
tmpFile.Close()
// 취소 시 임시 파일 유지 (이어받기 가능)
return fmt.Errorf("다운로드가 취소되었습니다")
}
n, err := resp.Body.Read(buf)
if n > 0 {
if _, werr := tmpFile.Write(buf[:n]); werr != nil {
tmpFile.Close()
return fmt.Errorf("파일 쓰기 실패: %w", werr)
}
downloaded += int64(n)
if downloaded > maxDownloadSize {
tmpFile.Close()
os.Remove(tmpPath)
return fmt.Errorf("다운로드 크기가 제한을 초과했습니다")
}
if total > 0 {
pct := int(downloaded * 100 / total)
setProgress(fmt.Sprintf("다운로드 중... %d%%", pct), pct)
}
}
if err == io.EOF {
break
}
if err != nil {
tmpFile.Close()
// 네트워크 오류 시 임시 파일 유지 (이어받기 가능)
return fmt.Errorf("다운로드 중 오류: %w", err)
}
}
tmpFile.Close()
defer os.Remove(tmpPath)
setProgress("압축을 해제하는 중...", -1)
// 임시 디렉토리에 먼저 추출 후 성공 시 이동 (실패 시 잔여 파일 방지)
tmpExtractDir, err := os.MkdirTemp("", "a301_extract_")
if err != nil {
return fmt.Errorf("임시 추출 디렉토리 생성 실패: %w", err)
}
if err := extractZip(tmpPath, tmpExtractDir); err != nil {
os.RemoveAll(tmpExtractDir)
return err
}
if err := moveContents(tmpExtractDir, destDir); err != nil {
os.RemoveAll(tmpExtractDir)
return fmt.Errorf("파일 이동 실패: %w", err)
}
os.RemoveAll(tmpExtractDir)
return nil
}
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))
// Zip Slip 방지: 추출 경로가 대상 디렉토리 안인지 검증
cleanDest := filepath.Clean(dest)
cleanBase := filepath.Clean(destDir) + string(os.PathSeparator)
if !strings.HasPrefix(cleanDest, cleanBase) && cleanDest != filepath.Clean(destDir) {
return fmt.Errorf("잘못된 zip 경로: %s", rel)
}
// Symlink 차단: zip 내 심볼릭 링크를 통한 경로 탈출 방지
if f.FileInfo().Mode()&os.ModeSymlink != 0 {
continue
}
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, io.LimitReader(rc, maxExtractFileSize))
out.Close()
rc.Close()
if err != nil {
return err
}
}
return nil
}
func moveContents(srcDir, dstDir string) error {
entries, err := os.ReadDir(srcDir)
if err != nil {
return err
}
for _, e := range entries {
src := filepath.Join(srcDir, e.Name())
dst := filepath.Join(dstDir, e.Name())
if e.IsDir() {
if err := os.MkdirAll(dst, 0755); err != nil {
return err
}
if err := moveContents(src, dst); err != nil {
return err
}
} else {
os.Remove(dst) // 기존 파일 제거 (Rename 실패 방지)
if err := os.Rename(src, dst); err != nil {
// 드라이브가 다를 경우 복사 후 삭제
if err := copyFile(src, dst); err != nil {
os.Remove(dst) // 실패한 부분 파일 제거
return err
}
}
}
}
return nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
// ── Server info ──────────────────────────────────────────────────────────────
type downloadInfo struct {
FileHash string `json:"fileHash"`
URL string `json:"url"`
}
func fetchServerInfoOnce() (*downloadInfo, error) {
resp, err := apiClient.Get(serverInfoURL)
if err != nil {
return nil, fmt.Errorf("서버 연결 실패: %w", err)
}
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(io.LimitReader(resp.Body, 1<<20)).Decode(&info); err != nil {
return nil, fmt.Errorf("서버 응답 파싱 실패: %w", err)
}
return &info, nil
}
func fetchServerInfo() (*downloadInfo, error) {
const maxRetries = 3
var lastErr error
for i := range maxRetries {
info, err := fetchServerInfoOnce()
if err == nil {
return info, nil
}
lastErr = err
// 4xx 에러는 재시도해도 의미 없음
if strings.Contains(err.Error(), "서버 오류") || strings.Contains(err.Error(), "준비되지") {
return nil, err
}
time.Sleep(time.Duration(1<<i) * time.Second) // 1s, 2s, 4s
}
return nil, fmt.Errorf("서버 연결 실패 (%d회 재시도): %w", maxRetries, lastErr)
}
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 ────────────────────────────────────────────────────────────
// installDir returns the fixed install directory: %LOCALAPPDATA%\A301
func installDir() (string, error) {
localAppData := os.Getenv("LOCALAPPDATA")
if localAppData == "" {
return "", fmt.Errorf("LOCALAPPDATA 환경변수를 찾을 수 없습니다")
}
return filepath.Join(localAppData, "A301"), nil
}
// launcherPath returns the current executable's absolute path.
func launcherPath() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
}
return filepath.Abs(exe)
}
// ── Protocol install / uninstall ─────────────────────────────────────────────
func install() error {
srcPath, err := launcherPath()
if err != nil {
return fmt.Errorf("실행 파일 경로를 찾을 수 없습니다: %w", err)
}
dir, err := installDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("설치 디렉토리 생성 실패: %w", err)
}
dstPath := filepath.Join(dir, "launcher.exe")
// 이미 설치 위치에서 실행 중이 아니면 복사
if !strings.EqualFold(srcPath, dstPath) {
if err := copyFile(srcPath, dstPath); 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"`, dstPath))
}
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
}
}
// 설치 디렉토리 삭제 (자기 자신은 실행 중이라 삭제 실패할 수 있음 — 무시)
if dir, err := installDir(); err == nil {
os.RemoveAll(dir)
}
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이 없습니다")
}
u, err := url.Parse(serverInfo.URL)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
return fmt.Errorf("유효하지 않은 다운로드 URL")
}
if err := downloadWithProgress(serverInfo.URL, gameDir); err != nil {
return fmt.Errorf("게임 설치 실패: %w", err)
}
if serverInfo.FileHash != "" {
newHash, err := hashFile(gamePath)
if err != nil {
return fmt.Errorf("다운로드 후 파일 검증 실패: %w", err)
}
if !strings.EqualFold(newHash, serverInfo.FileHash) {
os.Remove(gamePath)
return fmt.Errorf("다운로드된 파일의 해시가 일치하지 않습니다 (파일이 손상되었을 수 있습니다)")
}
}
}
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("토큰이 없습니다")
}
gameDir, err := installDir()
if err != nil {
return err
}
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)
cmd.Dir = gameDir
cmd.Env = append(os.Environ(), "A301_TOKEN="+token)
if err := cmd.Start(); err != nil {
return fmt.Errorf("게임 실행 실패: %w", err)
}
return nil
}
// ── Entry point ──────────────────────────────────────────────────────────────
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
}
func main() {
enableDPIAwareness()
if !acquireSingleInstance() {
activateExistingWindow()
return
}
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 {
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)
}
}