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") 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 { resp, err := downloadClient.Get(downloadURL) if err != nil { return fmt.Errorf("다운로드 연결 실패: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("다운로드 실패 (HTTP %d)", resp.StatusCode) } total := resp.ContentLength if total > maxDownloadSize { return fmt.Errorf("파일이 너무 큽니다 (%d bytes)", total) } tmpPath := filepath.Join(os.TempDir(), "a301_game.zip") tmpFile, err := os.Create(tmpPath) if err != nil { return fmt.Errorf("임시 파일 생성 실패: %w", err) } var downloaded int64 buf := make([]byte, 32*1024) for { if downloadCancelled.Load() { tmpFile.Close() os.Remove(tmpPath) return fmt.Errorf("다운로드가 취소되었습니다") } 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 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() os.Remove(tmpPath) 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) } 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 fetchServerInfo() (*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 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: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이 없습니다") } 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("토큰이 없습니다") } 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 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() { 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) } }