Files
a301_launcher/protocol.go

256 lines
8.3 KiB
Go

package main
import (
"bytes"
"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"
apiRetryCount = 3 // fetchServerInfo, redeemTicket 재시도 횟수
maxJSONBodySize = 1 << 20 // JSON 응답 바디 최대 1MB
)
// 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<<i) * time.Second)
}
return lastErr
}
// fetchServerInfo 서버에서 게임/런처 다운로드 정보를 조회한다 (apiRetryCount회 재시도).
func fetchServerInfo() (*downloadInfo, error) {
var info *downloadInfo
err := retryWithBackoff(apiRetryCount, func() error {
resp, err := apiClient.Get(serverInfoURL)
if err != nil {
return fmt.Errorf("서버 연결 실패: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return &errNoRetry{fmt.Errorf("게임이 아직 준비되지 않았습니다")}
}
if resp.StatusCode >= 400 {
return &errNoRetry{fmt.Errorf("요청 실패 (HTTP %d)", resp.StatusCode)}
}
var result downloadInfo
if err := json.NewDecoder(io.LimitReader(resp.Body, maxJSONBodySize)).Decode(&result); err != nil {
return fmt.Errorf("서버 응답 파싱 실패: %w", err)
}
info = &result
return nil
})
if err != nil {
return nil, fmt.Errorf("서버 연결 실패 (%d회 재시도): %w", apiRetryCount, err)
}
return info, nil
}
// redeemTicket 일회용 티켓을 서버에 보내 JWT 액세스 토큰으로 교환한다 (apiRetryCount회 재시도).
func redeemTicket(ticket string) (string, error) {
var token string
err := retryWithBackoff(apiRetryCount, 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", bytes.NewReader(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, maxJSONBodySize)).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("인증 실패 (%d회 재시도): %w", apiRetryCount, err)
}
return token, nil
}