refactor: 코드 가독성 개선 및 버그 수정
Some checks failed
CI/CD / test (push) Has been cancelled
CI/CD / release (push) Has been cancelled

- 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:
2026-03-29 02:17:51 +09:00
parent 742712aa49
commit 281a365952
9 changed files with 617 additions and 686 deletions

149
main.go
View File

@@ -2,152 +2,55 @@ package main
import (
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
)
// version is set at build time via -ldflags "-X main.version=x.y.z"
var version = "dev"
func handleURI(rawURI string) error {
parsed, err := url.Parse(rawURI)
if err != nil {
return fmt.Errorf("URI 파싱 실패: %w", err)
}
if parsed.Scheme != protocolName {
return fmt.Errorf("지원하지 않는 URI 스킴: %s", parsed.Scheme)
}
// 웹 클라이언트가 발급한 일회용 티켓을 서버에서 JWT로 교환
ticket := parsed.Query().Get("token")
if ticket == "" {
return fmt.Errorf("토큰이 없습니다")
}
token, err := redeemTicket(ticket)
if err != nil {
return fmt.Errorf("런처 인증에 실패했습니다: %w", err)
}
// JWT는 점(.)으로 구분된 3파트 형식이어야 함
if parts := strings.Split(token, "."); len(parts) != 3 {
return fmt.Errorf("서버에서 유효하지 않은 토큰을 받았습니다")
}
gameDir, err := installDir()
if err != nil {
return err
}
if err := os.MkdirAll(gameDir, 0755); err != nil {
return fmt.Errorf("게임 디렉토리 생성 실패: %w", err)
}
gamePath := filepath.Join(gameDir, gameExeName)
// 프로토콜 등록이 현재 런처를 가리키도록 갱신 (사일런트)
_ = install()
// 이전 업데이트에서 남은 .old/.new 파일 정리
cleanupOldFiles(gameDir)
serverInfo, err := fetchServerInfo()
if err != nil {
// 오프라인 모드: 게임이 이미 설치되어 있으면 직접 실행
if _, statErr := os.Stat(gamePath); statErr == nil {
ret := msgBox("One of the plans", "서버에 연결할 수 없습니다.\n설치된 게임을 실행하시겠습니까?\n(업데이트 확인 불가)", mbYesNo|mbQ)
if ret == idYes {
cmd := exec.Command(gamePath, "-token", token)
cmd.Dir = gameDir
if err := cmd.Start(); err != nil {
return fmt.Errorf("게임 실행 실패: %w", err)
}
return nil
}
return fmt.Errorf("사용자가 취소했습니다")
}
return fmt.Errorf("버전 확인 실패: %w", err)
}
// 런처 자동 업데이트 체크
if updated, updateErr := ensureLauncher(serverInfo); updateErr != nil {
fmt.Fprintf(os.Stderr, "런처 업데이트 실패: %v\n", updateErr)
} else if updated {
cmd := exec.Command(os.Args[0], os.Args[1:]...)
if err := cmd.Start(); err != nil {
return fmt.Errorf("새 런처 시작 실패: %w", err)
}
os.Exit(0)
}
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
}
func main() {
// DLL Hijacking 방어: 시스템 디렉토리에서만 DLL 로드
// DLL Hijacking 방어: DLL 탐색 경로를 System32로만 제한한다.
// 게임 폴더처럼 사용자가 파일을 쓸 수 있는 디렉토리에
// 악성 DLL이 심겨 있어도 로드되지 않는다.
// 반드시 다른 DLL이 로드되기 전 가장 먼저 호출해야 한다.
const loadLibrarySearchSystem32 = 0x00000800
kernel32.NewProc("SetDefaultDllDirectories").Call(loadLibrarySearchSystem32)
// Windows에게 "DPI는 내가 직접 처리한다"고 선언한다.
// 이 선언 없이는 OS가 창을 통째로 확대해 흐릿하게 표시한다.
// Per-Monitor V2: 모니터마다 DPI가 달라도 각각 대응 가능.
enableDPIAwareness()
// 단일 인스턴스 보장: 이미 실행 중이면 기존 창을 앞으로 가져오고 종료한다.
// 내부적으로 Named Mutex("Global\A301LauncherMutex")로 중복 실행을 감지한다.
if !acquireSingleInstance() {
activateExistingWindow()
return
}
if len(os.Args) < 2 {
if err := install(); err != nil {
msgBox("One of the plans 런처 - 오류", fmt.Sprintf("설치 실패:\n%v", err), mbOK|mbError)
os.Exit(1)
// 인수 결정: 없으면 "install" (더블클릭 = 최초 설치)
arg := "install"
if len(os.Args) >= 2 {
arg = os.Args[1]
}
// a301://... URI는 별도 처리 (HasPrefix라 switch로 분기 불가)
if strings.HasPrefix(arg, protocolName+"://") {
if err := handleURI(arg); err != nil {
exitWithError(fmt.Sprintf("실행 실패:\n%v", err))
}
msgBox("One of the plans", "설치가 완료되었습니다.\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":
ret := msgBox("One of the plans 런처", "게임 데이터도 함께 삭제하시겠습니까?", mbYesNo|mbQ)
deleteData := ret == idYes
if err := uninstall(); err != nil {
msgBox("One of the plans 런처 - 오류", fmt.Sprintf("제거 실패:\n%v", err), mbOK|mbError)
os.Exit(1)
}
if deleteData {
if dir, err := installDir(); err == nil {
os.RemoveAll(dir)
}
}
msgBox("One of the plans 런처", "a301:// 프로토콜이 제거되었습니다.", mbOK|mbInfo)
case arg == "--version" || arg == "version":
switch arg {
case "install":
handleInstall()
case "uninstall":
handleUninstall()
case "--version", "version":
msgBox("One of the plans 런처", fmt.Sprintf("버전: %s", version), 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)
exitWithError(fmt.Sprintf("알 수 없는 명령: %s", arg))
}
}