From 6e347224d36ae5567b589acd6834da3ebb0cdfe2 Mon Sep 17 00:00:00 2001 From: art Date: Tue, 27 Jan 2026 17:54:22 +0900 Subject: [PATCH] Implement initial project structure and setup --- docs/deliverables.md | 12 ++ docs/spec.md | 33 ++++++ docs/tech-decisions.md | 19 +++ docs/ui-ux.md | 58 ++++++++++ package.json | 46 ++++++++ src/main/main.ts | 36 ++++++ src/preload/preload.ts | 5 + src/renderer/App.tsx | 232 +++++++++++++++++++++++++++++++++++++ src/renderer/index.html | 15 +++ src/renderer/main.tsx | 14 +++ src/renderer/styles.css | 181 +++++++++++++++++++++++++++++ src/renderer/vite-env.d.ts | 7 ++ tsconfig.json | 14 +++ tsconfig.main.json | 13 +++ vite.config.ts | 17 +++ 15 files changed, 702 insertions(+) create mode 100644 docs/deliverables.md create mode 100644 docs/spec.md create mode 100644 docs/tech-decisions.md create mode 100644 docs/ui-ux.md create mode 100644 package.json create mode 100644 src/main/main.ts create mode 100644 src/preload/preload.ts create mode 100644 src/renderer/App.tsx create mode 100644 src/renderer/index.html create mode 100644 src/renderer/main.tsx create mode 100644 src/renderer/styles.css create mode 100644 src/renderer/vite-env.d.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.main.json create mode 100644 vite.config.ts diff --git a/docs/deliverables.md b/docs/deliverables.md new file mode 100644 index 0000000..82e89a2 --- /dev/null +++ b/docs/deliverables.md @@ -0,0 +1,12 @@ +## 빌드/배포 산출물(Deliverables) + +- Electron Windows `exe` 빌드 산출물 +- Gitea 릴리즈 아티팩트 업로드 +- 릴리즈 노트(버전, 변경 사항, 설치/실행 가이드) + +## 배포 흐름(Release Flow) + +1. 버전 태깅(Version tag) +2. Windows `exe` 빌드 +3. Gitea 릴리즈 생성 및 `exe` 업로드 +4. 릴리즈 노트 작성 diff --git a/docs/spec.md b/docs/spec.md new file mode 100644 index 0000000..0dd2a61 --- /dev/null +++ b/docs/spec.md @@ -0,0 +1,33 @@ +## 목표(Goals) + +- SPT 4.0.11 기준 커스텀 런처 제공 +- Windows 11 환경에서 Electron `exe`로 배포 +- 단일 Git repo + Git LFS 기반 모드 동기화 +- `SPT Launcher.exe`를 대체하여 타르코프 클라이언트 직접 실행 + +## 범위( Scope ) + +- 서버 상태 확인(연결/헬스 체크) UI 제공 +- 로그인(Login) UI 제공(인증 방식은 추후 확정) +- 모드 동기화 UI 제공(동기화 정책은 추후 확정) +- 클라이언트 실행 버튼 제공(경로 탐지는 추후 확정) + +## 고정 전제(Fixed Assumptions) + +- 서버 URL은 빌드 시점에 고정: `http://pandoli365.com:5069/` +- 모드 Git URL: `https://gitea.pandoli365.com/art/spt-mods` +- 배포는 Gitea 릴리즈에서 `exe` 제공 +- 패키지 매니저: Yarn + +## 정책(Policies, UI 기준) + +- 로그인 상태는 UI에서 명확히 표시 +- 서버 상태는 Health 체크 결과를 상태 배지로 표시 +- 모드 동기화는 상태/진행률 표시, 실패 시 재시도 안내 +- 실행 버튼은 사전 조건(로그인/서버/동기화) 충족 여부 표시 + +## 범위 제외(Out of Scope) + +- 서버/모드 동기화 상세 로직(추후 결정) +- 인증 스키마/보안 저장소 구현(추후 결정) +- 자동 업데이트 정책(추후 결정) diff --git a/docs/tech-decisions.md b/docs/tech-decisions.md new file mode 100644 index 0000000..226b961 --- /dev/null +++ b/docs/tech-decisions.md @@ -0,0 +1,19 @@ +## Electron 구조(Architecture) + +- main/renderer 분리 +- IPC 채널은 최소화하고 명시적 요청/응답 형태로 설계 + +## 저장소/보안(Storage/Security) + +- 로그인 토큰 저장 위치는 OS keychain 또는 암호화 파일 후보 +- 최종 결정은 인증 방식 확정 후 진행 + +## Git/Git LFS 처리 + +- 기본 전략: 시스템에 Git/Git LFS가 없으면 설치 안내 UI 표시 +- 번들/자동 설치 여부는 사용자 합의 후 결정 + +## 동기화 방식 + +- 단일 repo + LFS 기반 +- 동기화 트리거: 앱 시작 시 확인 + 수동 동기화 버튼 diff --git a/docs/ui-ux.md b/docs/ui-ux.md new file mode 100644 index 0000000..f4ffca4 --- /dev/null +++ b/docs/ui-ux.md @@ -0,0 +1,58 @@ +## 화면 목록(Screens) + +1. **홈(Home)** + - 서버 상태(Health) + - 로그인 상태 + - 모드 동기화 상태 + - 게임 실행 버튼 +2. **로그인(Login)** + - 계정 입력/로그인 버튼 + - 실패 메시지 영역 +3. **모드 동기화(Mod Sync)** + - 최신 버전 표시 + - 동기화 진행률 + - 로그/오류 출력 +4. **설정(Settings)** + - 서버 URL(읽기 전용, 빌드 고정) + - 모드 Git URL(읽기 전용, 빌드 고정) + - 로그 폴더 열기 + +## 핵심 플로우(Flows) + +### 앱 시작(App Launch) + +1. 서버 상태(Health) 체크 +2. 로그인 상태 확인(토큰 존재 여부 등) +3. 모드 상태 표시(로컬/원격 버전 비교는 추후 결정) +4. 사용자가 필요한 동작(로그인/동기화) 수행 후 실행 + +### 로그인(Login) + +1. 로그인 화면 진입 +2. 자격 증명 입력 +3. 성공 시 홈으로 복귀, 실패 시 메시지 표시 + +### 모드 동기화(Sync) + +1. 모드 동기화 화면 진입 +2. 진행률 표시 및 결과 표시 +3. 실패 시 재시도 제공 + +### 게임 실행(Launch) + +1. 사전 조건 확인(서버/로그인/동기화) +2. 클라이언트 실행 +3. 실패 시 오류 메시지 표시 + +## 상태/메시지 기준(States) + +- **서버 상태**: 정상/지연/불가 +- **로그인 상태**: 로그인됨/로그아웃됨/오류 +- **동기화 상태**: 최신/진행중/실패 +- **실행 상태**: 준비됨/차단됨/실행중 + +## UX 원칙(UX Principles) + +- 홈에서 현재 상태와 다음 행동을 즉시 알 수 있어야 함 +- 실패는 즉시 이유와 해결 방법을 제시 +- 동작 중에는 진행률과 남은 작업을 보여줌 diff --git a/package.json b/package.json new file mode 100644 index 0000000..c5cc0a9 --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "spt-launcher", + "version": "0.1.0", + "description": "SPT 4.0.11 custom launcher", + "main": "dist/main/main.js", + "private": true, + "scripts": { + "dev": "concurrently -k \"vite\" \"wait-on http://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron .\"", + "build:renderer": "vite build", + "build:main": "tsc -p tsconfig.main.json", + "build": "yarn build:renderer && yarn build:main", + "pack": "yarn build && electron-builder --dir", + "dist": "yarn build && electron-builder" + }, + "dependencies": { + "electron": "^30.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.1.2", + "cross-env": "^7.0.3", + "electron-builder": "^24.13.3", + "typescript": "^5.7.3", + "vite": "^5.4.12", + "wait-on": "^8.0.1" + }, + "build": { + "appId": "com.spt.launcher", + "productName": "SPT Launcher", + "files": [ + "dist/**", + "package.json" + ], + "directories": { + "buildResources": "build" + }, + "win": { + "target": "nsis" + } + } +} diff --git a/src/main/main.ts b/src/main/main.ts new file mode 100644 index 0000000..17e492c --- /dev/null +++ b/src/main/main.ts @@ -0,0 +1,36 @@ +import { app, BrowserWindow } from "electron"; +import path from "node:path"; + +const isDev = Boolean(process.env.VITE_DEV_SERVER_URL); + +const createWindow = () => { + const mainWindow = new BrowserWindow({ + width: 1100, + height: 720, + webPreferences: { + preload: path.join(__dirname, "../preload/preload.js") + } + }); + + if (isDev) { + mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL as string); + } else { + mainWindow.loadFile(path.join(__dirname, "../renderer/index.html")); + } +}; + +app.whenReady().then(() => { + createWindow(); + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } +}); diff --git a/src/preload/preload.ts b/src/preload/preload.ts new file mode 100644 index 0000000..82e58a9 --- /dev/null +++ b/src/preload/preload.ts @@ -0,0 +1,5 @@ +import { contextBridge } from "electron"; + +contextBridge.exposeInMainWorld("sptLauncher", { + appName: "SPT Launcher" +}); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx new file mode 100644 index 0000000..fac4a99 --- /dev/null +++ b/src/renderer/App.tsx @@ -0,0 +1,232 @@ +import { useEffect, useMemo, useState } from "react"; + +type Screen = "serverCheck" | "login" | "signup" | "resetPassword" | "main"; + +const App = () => { + const [screen, setScreen] = useState("serverCheck"); + const [serverHealthy, setServerHealthy] = useState(true); + const [hasSession, setHasSession] = useState(false); + const [syncInProgress, setSyncInProgress] = useState(false); + const [simulateSyncFail, setSimulateSyncFail] = useState(false); + + useEffect(() => { + if (screen !== "serverCheck") { + return; + } + + const timer = window.setTimeout(() => { + setServerHealthy(true); + setScreen(hasSession ? "main" : "login"); + }, 900); + + return () => window.clearTimeout(timer); + }, [screen, hasSession]); + + useEffect(() => { + if (screen !== "main") { + return; + } + + const interval = window.setInterval(() => { + setServerHealthy((current) => current); + }, 5000); + + return () => window.clearInterval(interval); + }, [screen]); + + const serverStatusLabel = useMemo(() => { + return serverHealthy ? "정상" : "불가"; + }, [serverHealthy]); + + const handleLogin = () => { + setHasSession(true); + setScreen("main"); + }; + + const handleSignup = () => { + window.alert("회원가입 요청이 접수되었습니다. 관리자 승인 후 이용 가능합니다."); + setScreen("login"); + }; + + const handlePasswordReset = () => { + window.alert("비밀번호 변경 요청이 접수되었습니다."); + setScreen("login"); + }; + + const handleLogout = () => { + setHasSession(false); + setScreen("login"); + }; + + const handleLaunch = () => { + if (syncInProgress) { + return; + } + + setSyncInProgress(true); + + window.setTimeout(() => { + setSyncInProgress(false); + + if (!simulateSyncFail) { + window.alert("모드 동기화 성공. 게임을 실행합니다."); + return; + } + + const proceed = window.confirm( + "모드 동기화가 실패했습니다. 그래도 실행할까요?" + ); + + if (proceed) { + window.alert("실패 상태로 게임을 실행합니다."); + } + }, 1000); + }; + + return ( +
+
+

SPT Launcher

+
+ + {screen === "serverCheck" && ( +
+

서버 상태 확인 중

+

체크 중...

+

서버 상태 확인 후 로그인 화면으로 이동합니다.

+
+ )} + + {screen === "login" && ( +
+

로그인

+

로그인 필요

+
+ + +
+ +
+ + + +
+
+ )} + + {screen === "signup" && ( +
+

회원가입

+

관리자 승인 필요

+

+ 회원가입 후 서버 관리자 승인이 완료되어야 로그인 가능합니다. +

+
+ + + +
+ + +
+ )} + + {screen === "resetPassword" && ( +
+

비밀번호 변경

+

본인 확인 필요

+
+ + + + +
+ + +
+ )} + + {screen === "main" && ( +
+
+
+

프로필 정보

+

닉네임: Pandoli

+

레벨: 45

+
+
+ + +
+
+ +
+

게임 시작

+

준비됨

+ + +
+ +
+
+ 서버 상태 + + {serverStatusLabel} + +
+
+ +
+
+
+ )} +
+ ); +}; + +export default App; diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..74dbdde --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,15 @@ + + + + + + SPT Launcher + + +
+ + + diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx new file mode 100644 index 0000000..d04726a --- /dev/null +++ b/src/renderer/main.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./styles.css"; + +const rootElement = document.getElementById("root"); + +if (rootElement) { + createRoot(rootElement).render( + + + + ); +} diff --git a/src/renderer/styles.css b/src/renderer/styles.css new file mode 100644 index 0000000..dee2781 --- /dev/null +++ b/src/renderer/styles.css @@ -0,0 +1,181 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", sans-serif; + background: #0f1218; + color: #f5f7fb; +} + +.app { + padding: 20px; +} + +.app-header { + margin-bottom: 16px; +} + +.app-header h1 { + margin: 0; + font-size: 20px; +} + +.card { + background: #171b22; + border-radius: 12px; + padding: 14px; + border: 1px solid #242a35; +} + +.card h2 { + margin-top: 0; +} + +.status { + display: inline-block; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + margin-bottom: 12px; +} + +.status.ok { + background: #1f3d2b; + color: #8ee3a5; +} + +.status.idle { + background: #2b2f3a; + color: #c0c7d4; +} + +.status.blocked { + background: #3a2525; + color: #f2a7a7; +} + +input { + border-radius: 8px; + border: 1px solid #2c3340; + background: #0f1218; + color: #f5f7fb; + padding: 8px 12px; +} + +input::placeholder { + color: #7c8597; +} + +button { + background: #2c78ff; + color: #fff; + border: none; + padding: 8px 14px; + border-radius: 8px; + cursor: pointer; +} + +button:hover { + background: #2566db; +} + +button:disabled { + cursor: not-allowed; + background: #2b3a55; +} + +.ghost { + background: transparent; + border: 1px solid #2c78ff; + color: #b8d1ff; +} + +.ghost:hover { + background: #17233a; +} + +.hero { + max-width: 460px; +} + +.helper { + color: #a8b0c1; +} + +.login-form { + display: grid; + gap: 10px; + margin: 12px 0 16px; +} + +.link-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.notice { + color: #d5d9e6; + background: #202635; + border: 1px solid #2f3646; + padding: 10px 12px; + border-radius: 8px; +} + +.main-layout { + display: grid; + gap: 12px; + grid-template-columns: 1.2fr 1fr; +} + +.profile { + display: grid; + gap: 12px; +} + +.profile-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.action { + display: grid; + gap: 12px; +} + +.checkbox { + display: flex; + gap: 8px; + align-items: center; + font-size: 14px; +} + +.footer { + grid-column: 1 / -1; + display: flex; + justify-content: space-between; + align-items: center; + background: #141923; + border-radius: 12px; + padding: 10px 12px; + border: 1px solid #242a35; +} + +.footer-left { + display: flex; + gap: 12px; + align-items: center; +} + +.label { + color: #a8b0c1; + font-size: 13px; +} + +.muted { + color: #a8b0c1; +} diff --git a/src/renderer/vite-env.d.ts b/src/renderer/vite-env.d.ts new file mode 100644 index 0000000..53345db --- /dev/null +++ b/src/renderer/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +interface Window { + sptLauncher: { + appName: string; + }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..497cce9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["DOM", "ES2020"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src/renderer/**/*", "src/renderer/vite-env.d.ts"] +} diff --git a/tsconfig.main.json b/tsconfig.main.json new file mode 100644 index 0000000..a2903c6 --- /dev/null +++ b/tsconfig.main.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "lib": ["ES2020"], + "types": ["node", "electron"], + "outDir": "dist", + "rootDir": "src", + "noEmit": false + }, + "include": ["src/main/**/*", "src/preload/**/*"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..36a5980 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "node:path"; + +export default defineConfig({ + root: path.resolve(__dirname, "src/renderer"), + plugins: [react()], + base: "./", + build: { + outDir: path.resolve(__dirname, "dist/renderer"), + emptyOutDir: true + }, + server: { + port: 5173, + strictPort: true + } +});