package main import ( "encoding/json" "errors" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "strings" "syscall" "time" "golang.org/x/sys/windows/registry" ) // ── 상수 및 타입 ────────────────────────────────────────────── const ( protocolName = "a301" gameExeName = "A301.exe" ) // serverInfoURL, redeemTicketURL은 빌드 시 -ldflags로 오버라이드 가능. var ( serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info" redeemTicketURL = "https://a301.api.tolelom.xyz/api/auth/redeem-ticket" ) // downloadInfo 서버에서 받아오는 게임/런처 다운로드 정보. type downloadInfo struct { FileHash string `json:"fileHash"` URL string `json:"url"` LauncherURL string `json:"launcherUrl"` LauncherHash string `json:"launcherHash"` } // errNoRetry 재시도하면 안 되는 에러를 감싼다 (예: HTTP 4xx). type errNoRetry struct { err error } func (e *errNoRetry) Error() string { return e.err.Error() } func (e *errNoRetry) Unwrap() error { return e.err } // ── 경로 ────────────────────────────────────────────────────── // installDir 고정 설치 경로: %LOCALAPPDATA%\A301 func installDir() (string, error) { localAppData := os.Getenv("LOCALAPPDATA") if localAppData == "" { return "", fmt.Errorf("LOCALAPPDATA 환경변수를 찾을 수 없습니다") } return filepath.Join(localAppData, "A301"), nil } // launcherPath 현재 실행 중인 런처의 절대 경로를 반환한다. func launcherPath() (string, error) { exe, err := os.Executable() if err != nil { return "", err } return filepath.Abs(exe) } // ── 설치/제거 ───────────────────────────────────────────────── // install 런처를 설치 경로로 복사하고 a301:// 프로토콜을 레지스트리에 등록한다. 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) } } // 레지스트리에 a301:// 프로토콜 등록 key, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Classes\`+protocolName, registry.SET_VALUE) if err != nil { return fmt.Errorf("레지스트리 키 생성 실패: %w", err) } defer key.Close() if err := key.SetStringValue("", "URL:One of the plans Protocol"); err != nil { return fmt.Errorf("프로토콜 값 설정 실패: %w", err) } if err := key.SetStringValue("URL Protocol", ""); err != nil { return fmt.Errorf("URL Protocol 값 설정 실패: %w", err) } // 프로토콜 핸들러 명령 등록: "%LOCALAPPDATA%\A301\launcher.exe" "%1" 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)) } // uninstall 레지스트리에서 a301:// 프로토콜을 제거한다. 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 } // handleInstall 런처를 설치하고 프로토콜을 등록한다. func handleInstall() { if err := install(); err != nil { exitWithError(fmt.Sprintf("설치 실패:\n%v", err)) } msgBox("One of the plans", "설치가 완료되었습니다.\n웹에서 게임 시작 버튼을 클릭하세요.", mbOK|mbInfo) } // handleUninstall 프로토콜 제거 + 선택적 데이터 삭제. func handleUninstall() { ret := msgBox("One of the plans 런처", "게임 데이터도 함께 삭제하시겠습니까?", mbYesNo|mbQ) deleteData := ret == idYes if err := uninstall(); err != nil { exitWithError(fmt.Sprintf("제거 실패:\n%v", err)) } if deleteData { if dir, err := installDir(); err == nil { // 실행 중인 launcher.exe는 즉시 삭제할 수 없으므로, // 백그라운드 cmd 프로세스가 런처 종료 후 폴더를 삭제한다. cmd := exec.Command("cmd", "/c", "ping", "-n", "3", "127.0.0.1", ">nul", "&&", "rmdir", "/s", "/q", dir) cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} cmd.Start() } } msgBox("One of the plans 런처", "제거가 완료되었습니다.", mbOK|mbInfo) } // ── 서버 API ────────────────────────────────────────────────── // retryWithBackoff 최대 maxRetries회 재시도한다 (exponential backoff). // errNoRetry를 반환하면 즉시 중단한다. func retryWithBackoff(maxRetries int, fn func() error) error { var lastErr error for i := range maxRetries { if err := fn(); err == nil { return nil } else { lastErr = err var noRetry *errNoRetry if errors.As(err, &noRetry) { return err } } time.Sleep(time.Duration(1<= 400 { return &errNoRetry{fmt.Errorf("요청 실패 (HTTP %d)", resp.StatusCode)} } var result downloadInfo if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil { return fmt.Errorf("서버 응답 파싱 실패: %w", err) } info = &result return nil }) if err != nil { return nil, fmt.Errorf("서버 연결 실패 (3회 재시도): %w", err) } return info, nil } // redeemTicket 일회용 티켓을 서버에 보내 JWT 액세스 토큰으로 교환한다 (3회 재시도). func redeemTicket(ticket string) (string, error) { var token string err := retryWithBackoff(3, func() error { payload, err := json.Marshal(map[string]string{"ticket": ticket}) if err != nil { return fmt.Errorf("요청 데이터 생성 실패: %w", err) } resp, err := apiClient.Post(redeemTicketURL, "application/json", strings.NewReader(string(payload))) if err != nil { return fmt.Errorf("서버에 연결할 수 없습니다: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 && resp.StatusCode < 500 { return &errNoRetry{fmt.Errorf("런처 인증에 실패했습니다 (HTTP %d)", resp.StatusCode)} } if resp.StatusCode != http.StatusOK { return fmt.Errorf("런처 인증에 실패했습니다 (HTTP %d)", resp.StatusCode) } var result struct { Token string `json:"token"` } if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil { return fmt.Errorf("서버 응답을 처리할 수 없습니다: %w", err) } if result.Token == "" { return fmt.Errorf("서버가 토큰을 반환하지 않았습니다") } token = result.Token return nil }) if err != nil { return "", fmt.Errorf("인증 실패 (3회 재시도): %w", err) } return token, nil }