refactor: 코드 가독성 개선 및 버그 수정
- main.go를 main()만 남기고 함수 분리 (game.go, protocol.go, ui.go) - 재시도 로직을 retryWithBackoff 공통 함수로 통합 - redeemTicketFrom 별도 HTTP 클라이언트 → apiClient 사용으로 통일 - doDownload에서 resumeOffset 이중 계산 제거 - extractZip에서 stripTopDir/extractFile 함수 분리 - downloadWithProgress에서 createProgressWindow 함수 분리 - DLL 선언을 DLL별로 그룹화, 상수를 역할별로 분리 - 전체 주석 한국어 통일 및 섹션 구분 추가 버그 수정: - ensureLauncher가 설치 경로 대신 실행 중인 경로를 해시하던 문제 수정 - uninstall 시 실행 중인 exe 삭제 실패 → 백그라운드 cmd로 대체 - moveContents에서 os.Remove 에러를 무시하던 문제 수정 - install/uninstall 메시지 통일, exitWithError 헬퍼 추가 - .gitignore에 *.exe 통일, ANALYSIS.md 삭제 - 빌드 명령에 git 태그 기반 버전 주입 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
218
protocol.go
218
protocol.go
@@ -7,33 +7,29 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
// ── 상수 및 타입 ──────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
protocolName = "a301"
|
||||
gameExeName = "A301.exe"
|
||||
)
|
||||
|
||||
// serverInfoURL and redeemTicketURL can be overridden at build time via
|
||||
// -ldflags "-X main.serverInfoURL=... -X main.redeemTicketURL=..."
|
||||
// serverInfoURL, redeemTicketURL은 빌드 시 -ldflags로 오버라이드 가능.
|
||||
var (
|
||||
serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info"
|
||||
redeemTicketURL = "https://a301.api.tolelom.xyz/api/auth/redeem-ticket"
|
||||
)
|
||||
|
||||
// errNoRetry wraps errors that should not be retried (e.g. 4xx responses).
|
||||
type errNoRetry struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *errNoRetry) Error() string { return e.err.Error() }
|
||||
func (e *errNoRetry) Unwrap() error { return e.err }
|
||||
|
||||
// downloadInfo 서버에서 받아오는 게임/런처 다운로드 정보.
|
||||
type downloadInfo struct {
|
||||
FileHash string `json:"fileHash"`
|
||||
URL string `json:"url"`
|
||||
@@ -41,7 +37,17 @@ type downloadInfo struct {
|
||||
LauncherHash string `json:"launcherHash"`
|
||||
}
|
||||
|
||||
// installDir returns the fixed install directory: %LOCALAPPDATA%\A301
|
||||
// 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 == "" {
|
||||
@@ -50,7 +56,7 @@ func installDir() (string, error) {
|
||||
return filepath.Join(localAppData, "A301"), nil
|
||||
}
|
||||
|
||||
// launcherPath returns the current executable's absolute path.
|
||||
// launcherPath 현재 실행 중인 런처의 절대 경로를 반환한다.
|
||||
func launcherPath() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
@@ -59,6 +65,9 @@ func launcherPath() (string, error) {
|
||||
return filepath.Abs(exe)
|
||||
}
|
||||
|
||||
// ── 설치/제거 ─────────────────────────────────────────────────
|
||||
|
||||
// install 런처를 설치 경로로 복사하고 a301:// 프로토콜을 레지스트리에 등록한다.
|
||||
func install() error {
|
||||
srcPath, err := launcherPath()
|
||||
if err != nil {
|
||||
@@ -73,14 +82,15 @@ func install() error {
|
||||
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)
|
||||
@@ -93,6 +103,7 @@ func install() error {
|
||||
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)
|
||||
@@ -101,7 +112,9 @@ func install() error {
|
||||
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`,
|
||||
@@ -116,94 +129,123 @@ func uninstall() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchServerInfoOnce() (*downloadInfo, error) {
|
||||
resp, err := apiClient.Get(serverInfoURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("서버 연결 실패: %w", err)
|
||||
// handleInstall 런처를 설치하고 프로토콜을 등록한다.
|
||||
func handleInstall() {
|
||||
if err := install(); err != nil {
|
||||
exitWithError(fmt.Sprintf("설치 실패:\n%v", err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, &errNoRetry{fmt.Errorf("게임이 아직 준비되지 않았습니다")}
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, &errNoRetry{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
|
||||
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 서버에서 게임/런처 다운로드 정보를 조회한다 (3회 재시도).
|
||||
func fetchServerInfo() (*downloadInfo, error) {
|
||||
const maxRetries = 3
|
||||
var lastErr error
|
||||
for i := range maxRetries {
|
||||
info, err := fetchServerInfoOnce()
|
||||
if err == nil {
|
||||
return info, nil
|
||||
var info *downloadInfo
|
||||
err := retryWithBackoff(3, func() error {
|
||||
resp, err := apiClient.Get(serverInfoURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("서버 연결 실패: %w", err)
|
||||
}
|
||||
lastErr = err
|
||||
var noRetry *errNoRetry
|
||||
if errors.As(err, &noRetry) {
|
||||
return nil, err
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return &errNoRetry{fmt.Errorf("게임이 아직 준비되지 않았습니다")}
|
||||
}
|
||||
time.Sleep(time.Duration(1<<i) * time.Second)
|
||||
if resp.StatusCode >= 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 nil, fmt.Errorf("서버 연결 실패 (%d회 재시도): %w", maxRetries, lastErr)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// redeemTicket exchanges a one-time launch ticket for a fresh JWT access token.
|
||||
// Retries up to 3 times with exponential backoff on transient errors.
|
||||
// redeemTicket 일회용 티켓을 서버에 보내 JWT 액세스 토큰으로 교환한다 (3회 재시도).
|
||||
func redeemTicket(ticket string) (string, error) {
|
||||
const maxRetries = 3
|
||||
var lastErr error
|
||||
for i := range maxRetries {
|
||||
token, err := redeemTicketFrom(redeemTicketURL, ticket)
|
||||
if err == nil {
|
||||
return token, nil
|
||||
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)
|
||||
}
|
||||
lastErr = err
|
||||
// HTTP 4xx errors should not be retried
|
||||
var noRetry *errNoRetry
|
||||
if errors.As(err, &noRetry) {
|
||||
return "", err
|
||||
|
||||
resp, err := apiClient.Post(redeemTicketURL, "application/json", strings.NewReader(string(payload)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("서버에 연결할 수 없습니다: %w", err)
|
||||
}
|
||||
time.Sleep(time.Duration(1<<i) * time.Second)
|
||||
}
|
||||
return "", fmt.Errorf("인증 실패 (%d회 재시도): %w", maxRetries, lastErr)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
func redeemTicketFrom(url, ticket string) (string, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
payload, err := json.Marshal(map[string]string{"ticket": ticket})
|
||||
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("요청 데이터 생성 실패: %w", err)
|
||||
return "", fmt.Errorf("인증 실패 (3회 재시도): %w", err)
|
||||
}
|
||||
body := string(payload)
|
||||
resp, err := client.Post(url, "application/json", strings.NewReader(body))
|
||||
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("서버가 토큰을 반환하지 않았습니다")
|
||||
}
|
||||
return result.Token, nil
|
||||
return token, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user