Implement initial project structure and setup

This commit is contained in:
이정수 2026-01-27 17:54:22 +09:00
parent 440ea5b463
commit 6e347224d3
15 changed files with 702 additions and 0 deletions

12
docs/deliverables.md Normal file
View File

@ -0,0 +1,12 @@
## 빌드/배포 산출물(Deliverables)
- Electron Windows `exe` 빌드 산출물
- Gitea 릴리즈 아티팩트 업로드
- 릴리즈 노트(버전, 변경 사항, 설치/실행 가이드)
## 배포 흐름(Release Flow)
1. 버전 태깅(Version tag)
2. Windows `exe` 빌드
3. Gitea 릴리즈 생성 및 `exe` 업로드
4. 릴리즈 노트 작성

33
docs/spec.md Normal file
View File

@ -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)
- 서버/모드 동기화 상세 로직(추후 결정)
- 인증 스키마/보안 저장소 구현(추후 결정)
- 자동 업데이트 정책(추후 결정)

19
docs/tech-decisions.md Normal file
View File

@ -0,0 +1,19 @@
## Electron 구조(Architecture)
- main/renderer 분리
- IPC 채널은 최소화하고 명시적 요청/응답 형태로 설계
## 저장소/보안(Storage/Security)
- 로그인 토큰 저장 위치는 OS keychain 또는 암호화 파일 후보
- 최종 결정은 인증 방식 확정 후 진행
## Git/Git LFS 처리
- 기본 전략: 시스템에 Git/Git LFS가 없으면 설치 안내 UI 표시
- 번들/자동 설치 여부는 사용자 합의 후 결정
## 동기화 방식
- 단일 repo + LFS 기반
- 동기화 트리거: 앱 시작 시 확인 + 수동 동기화 버튼

58
docs/ui-ux.md Normal file
View File

@ -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)
- 홈에서 현재 상태와 다음 행동을 즉시 알 수 있어야 함
- 실패는 즉시 이유와 해결 방법을 제시
- 동작 중에는 진행률과 남은 작업을 보여줌

46
package.json Normal file
View File

@ -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"
}
}
}

36
src/main/main.ts Normal file
View File

@ -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();
}
});

5
src/preload/preload.ts Normal file
View File

@ -0,0 +1,5 @@
import { contextBridge } from "electron";
contextBridge.exposeInMainWorld("sptLauncher", {
appName: "SPT Launcher"
});

232
src/renderer/App.tsx Normal file
View File

@ -0,0 +1,232 @@
import { useEffect, useMemo, useState } from "react";
type Screen = "serverCheck" | "login" | "signup" | "resetPassword" | "main";
const App = () => {
const [screen, setScreen] = useState<Screen>("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 (
<div className="app">
<header className="app-header">
<h1>SPT Launcher</h1>
</header>
{screen === "serverCheck" && (
<section className="card hero">
<h2> </h2>
<p className="status idle"> ...</p>
<p className="helper"> .</p>
</section>
)}
{screen === "login" && (
<section className="card hero">
<h2></h2>
<p className="status idle"> </p>
<div className="login-form">
<input type="text" placeholder="아이디" />
<input type="password" placeholder="비밀번호" />
</div>
<button type="button" onClick={handleLogin}>
</button>
<div className="link-row">
<button
type="button"
className="ghost"
onClick={() => setScreen("signup")}
>
</button>
<button
type="button"
className="ghost"
onClick={() => setScreen("resetPassword")}
>
</button>
<button
type="button"
className="ghost"
onClick={() => setHasSession(true)}
>
()
</button>
</div>
</section>
)}
{screen === "signup" && (
<section className="card hero">
<h2></h2>
<p className="status idle"> </p>
<p className="notice">
.
</p>
<div className="login-form">
<input type="text" placeholder="아이디" />
<input type="password" placeholder="비밀번호" />
<input type="password" placeholder="비밀번호 확인" />
</div>
<button type="button" onClick={handleSignup}>
</button>
<button type="button" className="ghost" onClick={() => setScreen("login")}>
</button>
</section>
)}
{screen === "resetPassword" && (
<section className="card hero">
<h2> </h2>
<p className="status idle"> </p>
<div className="login-form">
<input type="text" placeholder="아이디" />
<input type="password" placeholder="기존 비밀번호" />
<input type="password" placeholder="새 비밀번호" />
<input type="password" placeholder="새 비밀번호 확인" />
</div>
<button type="button" onClick={handlePasswordReset}>
</button>
<button type="button" className="ghost" onClick={() => setScreen("login")}>
</button>
</section>
)}
{screen === "main" && (
<main className="main-layout">
<section className="card profile">
<div>
<h2> </h2>
<p className="muted">닉네임: Pandoli</p>
<p className="muted">레벨: 45</p>
</div>
<div className="profile-actions">
<button type="button"> </button>
<button type="button" className="ghost">
</button>
</div>
</section>
<section className="card action">
<h2> </h2>
<p className="status ok"></p>
<label className="checkbox">
<input
type="checkbox"
checked={simulateSyncFail}
onChange={(event) => setSimulateSyncFail(event.target.checked)}
/>
</label>
<button
type="button"
onClick={handleLaunch}
disabled={syncInProgress}
>
{syncInProgress ? "동기화 중..." : "게임 시작"}
</button>
</section>
<footer className="footer">
<div className="footer-left">
<span className="label"> </span>
<span className={`status ${serverHealthy ? "ok" : "blocked"}`}>
{serverStatusLabel}
</span>
</div>
<div className="footer-right">
<button type="button" className="ghost" onClick={handleLogout}>
</button>
</div>
</footer>
</main>
)}
</div>
);
};
export default App;

15
src/renderer/index.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>SPT Launcher</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

14
src/renderer/main.tsx Normal file
View File

@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
);
}

181
src/renderer/styles.css Normal file
View File

@ -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;
}

7
src/renderer/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
interface Window {
sptLauncher: {
appName: string;
};
}

14
tsconfig.json Normal file
View File

@ -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"]
}

13
tsconfig.main.json Normal file
View File

@ -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/**/*"]
}

17
vite.config.ts Normal file
View File

@ -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
}
});