Build installer and management site from spec
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
release
|
||||||
|
logs/*.log
|
||||||
|
*.tsbuildinfo
|
||||||
37
README.md
37
README.md
@@ -1,4 +1,39 @@
|
|||||||
# Minecraft Launcher
|
# Minecraft Server Pack Easy Installer
|
||||||
|
|
||||||
|
Node.js + TypeScript + Express + EJS 기반 관리 사이트와 Electron 기반 Windows 설치기 프로젝트입니다.
|
||||||
|
|
||||||
|
## 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
관리 사이트 기본 주소:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
설치기 개발 실행:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run installer
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 설치 파일 빌드:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dist:win
|
||||||
|
```
|
||||||
|
|
||||||
|
기본 관리자 계정:
|
||||||
|
|
||||||
|
```text
|
||||||
|
id: admin
|
||||||
|
password: change-me
|
||||||
|
```
|
||||||
- 실패하면 `직접 포트포워딩 해주세요` 안내를 표시합니다.
|
- 실패하면 `직접 포트포워딩 해주세요` 안내를 표시합니다.
|
||||||
- 접속 주소를 직접 입력한 경우에는 자동 포트 개방을 건너뜁니다.
|
- 접속 주소를 직접 입력한 경우에는 자동 포트 개방을 건너뜁니다.
|
||||||
|
|
||||||
|
|||||||
6
account.json
Normal file
6
account.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "admin",
|
||||||
|
"password": "change-me"
|
||||||
|
}
|
||||||
|
]
|
||||||
16
electron-builder.yml
Normal file
16
electron-builder.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
appId: kr.tkrmagid.mc-custom-suite
|
||||||
|
productName: MC Custom Installer
|
||||||
|
directories:
|
||||||
|
output: release
|
||||||
|
files:
|
||||||
|
- dist/**/*
|
||||||
|
- installer/**/*
|
||||||
|
- views/**/*
|
||||||
|
- public/**/*
|
||||||
|
- manifest.json
|
||||||
|
- account.json
|
||||||
|
- manifest/**/*
|
||||||
|
- file/**/*
|
||||||
|
win:
|
||||||
|
target:
|
||||||
|
- nsis
|
||||||
1
file/.gitkeep
Normal file
1
file/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
BIN
file/sample-pack.zip
Normal file
BIN
file/sample-pack.zip
Normal file
Binary file not shown.
138
installer/index.html
Normal file
138
installer/index.html
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>MC Custom Installer</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<aside class="steps">
|
||||||
|
<h1>MC Custom Installer</h1>
|
||||||
|
<ol>
|
||||||
|
<li data-step="1" class="active">서버팩 선택</li>
|
||||||
|
<li data-step="2">설치 경로 설정</li>
|
||||||
|
<li data-step="3">JDK 확인 / 설치</li>
|
||||||
|
<li data-step="4">다운로드 및 설치</li>
|
||||||
|
<li data-step="5">서버 설정</li>
|
||||||
|
<li data-step="6">포트포워딩 설정</li>
|
||||||
|
<li data-step="7">완료</li>
|
||||||
|
</ol>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<section class="panel active" data-panel="1">
|
||||||
|
<p class="eyebrow">STEP 1</p>
|
||||||
|
<h2>서버팩 선택</h2>
|
||||||
|
<label class="field">
|
||||||
|
<span>manifest.json URL</span>
|
||||||
|
<input id="manifestUrl" />
|
||||||
|
</label>
|
||||||
|
<div class="buttonRow">
|
||||||
|
<button id="loadPacksButton" class="primary">목록 불러오기</button>
|
||||||
|
</div>
|
||||||
|
<div id="packList" class="packList"></div>
|
||||||
|
<div class="buttonRow end">
|
||||||
|
<button id="toStep2" class="primary">다음</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" data-panel="2">
|
||||||
|
<p class="eyebrow">STEP 2</p>
|
||||||
|
<h2>설치 경로 설정</h2>
|
||||||
|
<label class="field">
|
||||||
|
<span>설치 경로</span>
|
||||||
|
<div class="inputRow">
|
||||||
|
<input id="installPath" />
|
||||||
|
<button id="browseInstallPath">폴더 선택</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<p id="installPathWarning" class="warningText"></p>
|
||||||
|
<div class="buttonRow between">
|
||||||
|
<button data-back="1">이전</button>
|
||||||
|
<button id="toStep3" class="primary">다음</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" data-panel="3">
|
||||||
|
<p class="eyebrow">STEP 3</p>
|
||||||
|
<h2>JDK 확인 / 설치</h2>
|
||||||
|
<label class="field">
|
||||||
|
<span>JDK 경로</span>
|
||||||
|
<div class="inputRow">
|
||||||
|
<input id="jdkPath" />
|
||||||
|
<button id="browseJdkPath">폴더 선택</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div class="buttonRow">
|
||||||
|
<button id="detectJdkButton">자동 탐색</button>
|
||||||
|
</div>
|
||||||
|
<div id="jdkStatus" class="infoBox"></div>
|
||||||
|
<div class="buttonRow between">
|
||||||
|
<button data-back="2">이전</button>
|
||||||
|
<button id="toStep4" class="primary">다음</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" data-panel="4">
|
||||||
|
<p class="eyebrow">STEP 4</p>
|
||||||
|
<h2>다운로드 및 설치</h2>
|
||||||
|
<div class="buttonRow">
|
||||||
|
<button id="startInstallButton" class="primary">설치 시작</button>
|
||||||
|
</div>
|
||||||
|
<div id="logView" class="logView"></div>
|
||||||
|
<div id="eulaBlock" class="eulaBlock hidden">
|
||||||
|
<p>Minecraft EULA에 동의해야 설치를 계속할 수 있습니다.</p>
|
||||||
|
<button id="acceptEulaButton" class="primary">EULA 동의 후 계속</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" data-panel="5">
|
||||||
|
<p class="eyebrow">STEP 5</p>
|
||||||
|
<h2>서버 설정</h2>
|
||||||
|
<p>로컬 웹서버를 띄워 브라우저에서 설정 파일을 수정합니다.</p>
|
||||||
|
<div class="buttonRow">
|
||||||
|
<button id="openConfigEditorButton" class="primary">설정 편집기 열기</button>
|
||||||
|
</div>
|
||||||
|
<div id="configEditorStatus" class="infoBox"></div>
|
||||||
|
<div class="buttonRow between">
|
||||||
|
<button data-back="4">이전</button>
|
||||||
|
<button id="toStep6" class="primary">다음</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" data-panel="6">
|
||||||
|
<p class="eyebrow">STEP 6</p>
|
||||||
|
<h2>포트포워딩 설정</h2>
|
||||||
|
<div class="buttonRow">
|
||||||
|
<button id="configurePortButton" class="primary">포트 개방 확인 / 시도</button>
|
||||||
|
</div>
|
||||||
|
<div id="portStatusBox" class="infoBox"></div>
|
||||||
|
<div class="buttonRow between">
|
||||||
|
<button data-back="5">이전</button>
|
||||||
|
<button id="toStep7" class="primary">다음</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" data-panel="7">
|
||||||
|
<p class="eyebrow">STEP 7</p>
|
||||||
|
<h2>완료</h2>
|
||||||
|
<label class="toggleRow">
|
||||||
|
<input type="checkbox" id="createShortcutToggle" checked />
|
||||||
|
<span>바탕화면에 서버 실행 바로가기 만들기</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggleRow">
|
||||||
|
<input type="checkbox" id="runServerToggle" checked />
|
||||||
|
<span>서버 바로 실행</span>
|
||||||
|
</label>
|
||||||
|
<div class="buttonRow">
|
||||||
|
<button id="openFolderButton">서버 폴더 열기</button>
|
||||||
|
<button id="finishButton" class="primary">적용 및 완료</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="./renderer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
198
installer/renderer.js
Normal file
198
installer/renderer.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
const state = {
|
||||||
|
manifestUrl: '',
|
||||||
|
selectedPack: null,
|
||||||
|
installPath: '',
|
||||||
|
jdkPath: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelMap = new Map([...document.querySelectorAll('.panel')].map((panel) => [panel.dataset.panel, panel]))
|
||||||
|
const stepMap = new Map([...document.querySelectorAll('.steps li')].map((step) => [step.dataset.step, step]))
|
||||||
|
const logView = document.getElementById('logView')
|
||||||
|
const packList = document.getElementById('packList')
|
||||||
|
|
||||||
|
function setActiveStep(step) {
|
||||||
|
for (const [key, panel] of panelMap.entries()) {
|
||||||
|
panel.classList.toggle('active', key === String(step))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, item] of stepMap.entries()) {
|
||||||
|
item.classList.toggle('active', key === String(step))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLog(entry) {
|
||||||
|
if (entry?.action === 'eula-required') {
|
||||||
|
document.getElementById('eulaBlock').classList.remove('hidden')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = `[${entry?.tone ?? 'info'}] ${entry?.message ?? ''}`
|
||||||
|
logView.textContent += `${line}\n`
|
||||||
|
logView.scrollTop = logView.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateInstallPath(pathValue) {
|
||||||
|
const warning = document.getElementById('installPathWarning')
|
||||||
|
const hasHangul = /[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/.test(pathValue)
|
||||||
|
warning.textContent = hasHangul ? '경로에 한글이 포함되면 안 됩니다.' : ''
|
||||||
|
return !hasHangul && pathValue.trim().length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPackList(packs) {
|
||||||
|
packList.innerHTML = ''
|
||||||
|
packs.forEach((pack) => {
|
||||||
|
const label = document.createElement('label')
|
||||||
|
label.className = 'packOption'
|
||||||
|
label.innerHTML = `
|
||||||
|
<input type="radio" name="packChoice" value="${pack.file}" />
|
||||||
|
<div>
|
||||||
|
<strong>${pack.name}</strong>
|
||||||
|
<span>${pack.file}</span>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
packList.appendChild(label)
|
||||||
|
})
|
||||||
|
|
||||||
|
packList.addEventListener('change', () => {
|
||||||
|
const checked = packList.querySelector('input[name="packChoice"]:checked')
|
||||||
|
if (checked == null) {
|
||||||
|
state.selectedPack = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.selectedPack = packs.find((pack) => pack.file === checked.value) ?? null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const defaults = await window.installerApi.getDefaults()
|
||||||
|
state.manifestUrl = defaults.manifestUrl
|
||||||
|
document.getElementById('manifestUrl').value = defaults.manifestUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
window.installerApi.onLog(appendLog)
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-back]').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
setActiveStep(button.dataset.back)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('loadPacksButton').addEventListener('click', async () => {
|
||||||
|
state.manifestUrl = document.getElementById('manifestUrl').value.trim()
|
||||||
|
const manifest = await window.installerApi.loadPacks(state.manifestUrl)
|
||||||
|
renderPackList(manifest.packs)
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('toStep2').addEventListener('click', () => {
|
||||||
|
if (state.selectedPack == null) {
|
||||||
|
alert('서버팩을 먼저 선택하세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setActiveStep(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('browseInstallPath').addEventListener('click', async () => {
|
||||||
|
const selected = await window.installerApi.chooseDirectory()
|
||||||
|
if (selected != null) {
|
||||||
|
state.installPath = selected
|
||||||
|
document.getElementById('installPath').value = selected
|
||||||
|
validateInstallPath(selected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('installPath').addEventListener('input', (event) => {
|
||||||
|
state.installPath = event.target.value
|
||||||
|
validateInstallPath(state.installPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('toStep3').addEventListener('click', () => {
|
||||||
|
if (!validateInstallPath(state.installPath)) {
|
||||||
|
alert('올바른 설치 경로를 입력하세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setActiveStep(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('detectJdkButton').addEventListener('click', async () => {
|
||||||
|
const result = await window.installerApi.detectJdk()
|
||||||
|
document.getElementById('jdkStatus').textContent = result.detected != null
|
||||||
|
? `자동 탐색 성공: ${result.detected}`
|
||||||
|
: `JDK를 찾지 못했습니다. 탐색 경로: ${result.candidates.join(', ') || '없음'}`
|
||||||
|
|
||||||
|
if (result.detected != null) {
|
||||||
|
state.jdkPath = result.detected
|
||||||
|
document.getElementById('jdkPath').value = result.detected
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('browseJdkPath').addEventListener('click', async () => {
|
||||||
|
const selected = await window.installerApi.chooseJdk()
|
||||||
|
if (selected != null) {
|
||||||
|
state.jdkPath = selected
|
||||||
|
document.getElementById('jdkPath').value = selected
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('jdkPath').addEventListener('input', (event) => {
|
||||||
|
state.jdkPath = event.target.value
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('toStep4').addEventListener('click', () => {
|
||||||
|
if (state.jdkPath.trim().length === 0) {
|
||||||
|
alert('JDK 경로를 지정하세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setActiveStep(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('startInstallButton').addEventListener('click', async () => {
|
||||||
|
logView.textContent = ''
|
||||||
|
document.getElementById('eulaBlock').classList.add('hidden')
|
||||||
|
const result = await window.installerApi.startInstall({
|
||||||
|
manifestUrl: state.manifestUrl,
|
||||||
|
packFile: state.selectedPack.file,
|
||||||
|
installPath: state.installPath,
|
||||||
|
jdkPath: state.jdkPath
|
||||||
|
})
|
||||||
|
if (result.warning != null) {
|
||||||
|
appendLog({ message: result.warning, tone: 'warn' })
|
||||||
|
}
|
||||||
|
setActiveStep(result.nextStep)
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('acceptEulaButton').addEventListener('click', async () => {
|
||||||
|
document.getElementById('eulaBlock').classList.add('hidden')
|
||||||
|
await window.installerApi.acceptEula()
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('openConfigEditorButton').addEventListener('click', async () => {
|
||||||
|
const url = await window.installerApi.openConfigEditor()
|
||||||
|
document.getElementById('configEditorStatus').textContent = `브라우저에서 열림: ${url}`
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('toStep6').addEventListener('click', () => {
|
||||||
|
setActiveStep(6)
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('configurePortButton').addEventListener('click', async () => {
|
||||||
|
const result = await window.installerApi.configurePort()
|
||||||
|
document.getElementById('portStatusBox').textContent = result.message
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('toStep7').addEventListener('click', () => {
|
||||||
|
setActiveStep(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('openFolderButton').addEventListener('click', async () => {
|
||||||
|
await window.installerApi.openFolder()
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('finishButton').addEventListener('click', async () => {
|
||||||
|
const createShortcut = document.getElementById('createShortcutToggle').checked
|
||||||
|
const runServer = document.getElementById('runServerToggle').checked
|
||||||
|
await window.installerApi.createShortcut(createShortcut)
|
||||||
|
await window.installerApi.runServer(runServer)
|
||||||
|
alert('설치가 완료되었습니다.')
|
||||||
|
})
|
||||||
|
|
||||||
|
bootstrap()
|
||||||
177
installer/styles.css
Normal file
177
installer/styles.css
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #0b100d;
|
||||||
|
--panel: #141b17;
|
||||||
|
--soft: #1d2721;
|
||||||
|
--line: #2a362f;
|
||||||
|
--text: #f2f4f3;
|
||||||
|
--muted: #a9b2ad;
|
||||||
|
--accent: #f0bf57;
|
||||||
|
--warn: #ffb36b;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Segoe UI", sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(240, 191, 87, 0.16), transparent 28%),
|
||||||
|
linear-gradient(180deg, #0b100d 0%, #121914 100%);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
padding: 30px 24px;
|
||||||
|
background: rgba(10, 14, 12, 0.78);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps h1 {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps ol {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps li {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps li.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #1b1408;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: none;
|
||||||
|
max-width: 920px;
|
||||||
|
background: rgba(20, 27, 23, 0.92);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.active { display: block; }
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 0.28em;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { margin: 0 0 20px; font-size: 34px; }
|
||||||
|
|
||||||
|
.field { display: grid; gap: 8px; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--soft);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputRow,
|
||||||
|
.buttonRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonRow { margin-top: 18px; }
|
||||||
|
.buttonRow.end { justify-content: flex-end; }
|
||||||
|
.buttonRow.between { justify-content: space-between; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
color: #1c1509;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.packList {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.packOption {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.packOption strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warningText {
|
||||||
|
color: var(--warn);
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoBox,
|
||||||
|
.logView,
|
||||||
|
.eulaBlock {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logView {
|
||||||
|
height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden { display: none; }
|
||||||
|
|
||||||
|
.toggleRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
0
logs/.gitkeep
Normal file
0
logs/.gitkeep
Normal file
8
manifest.json
Normal file
8
manifest.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"packs": [
|
||||||
|
{
|
||||||
|
"name": "Sample Pack",
|
||||||
|
"file": "sample-pack"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
13
manifest/sample-pack.json
Normal file
13
manifest/sample-pack.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"mcVersion": "1.20.1",
|
||||||
|
"serverMinRam": 2048,
|
||||||
|
"serverMaxRam": 4096,
|
||||||
|
"clientMinRam": 4096,
|
||||||
|
"clientRecommendedRam": 8192,
|
||||||
|
"packPath": "sample-pack.zip",
|
||||||
|
"description": "기본 샘플 서버팩",
|
||||||
|
"configEditableFiles": [
|
||||||
|
"server.properties",
|
||||||
|
"bukkit.yml"
|
||||||
|
]
|
||||||
|
}
|
||||||
4832
package-lock.json
generated
Normal file
4832
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "mc-custom-suite",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Minecraft server pack easy installer and management site",
|
||||||
|
"main": "dist/installer/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "npm run build && node dist/server/app.js",
|
||||||
|
"installer": "npm run build && electron .",
|
||||||
|
"dist:win": "npm run build && electron-builder --win nsis",
|
||||||
|
"smoke": "npm run build && node -e \"require('./dist/shared/store').ensureProjectFiles(); console.log('smoke ok')\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"adm-zip": "^0.5.16",
|
||||||
|
"ejs": "^3.1.10",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"express-session": "^1.18.2",
|
||||||
|
"nat-upnp": "^1.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/adm-zip": "^0.5.7",
|
||||||
|
"@types/ejs": "^3.1.5",
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/express-session": "^1.18.2",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"electron": "^39.2.7",
|
||||||
|
"electron-builder": "^26.4.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
286
public/styles.css
Normal file
286
public/styles.css
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #0e1411;
|
||||||
|
--panel: #151d19;
|
||||||
|
--panel-strong: #1d2822;
|
||||||
|
--text: #f3f5f4;
|
||||||
|
--muted: #a7b2ac;
|
||||||
|
--line: #2b3831;
|
||||||
|
--accent: #f0bf57;
|
||||||
|
--danger: #ff7a6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Segoe UI", sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(240, 191, 87, 0.16), transparent 28%),
|
||||||
|
linear-gradient(180deg, #0b100d 0%, #101712 100%);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.siteBody {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero,
|
||||||
|
.adminHeader,
|
||||||
|
.pageWrap,
|
||||||
|
.loginPanel {
|
||||||
|
width: min(1180px, calc(100% - 48px));
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 56px 0 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 0.28em;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1,
|
||||||
|
.loginPanel h1,
|
||||||
|
.editorHeader h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroText {
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageWrap {
|
||||||
|
padding-bottom: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.packCard {
|
||||||
|
min-width: 260px;
|
||||||
|
background: rgba(21, 29, 25, 0.92);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 22px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.packCard h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghostLink,
|
||||||
|
.primaryLink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryLink,
|
||||||
|
.primaryButton {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #16130e;
|
||||||
|
border: none;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dangerButton {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #180d0b;
|
||||||
|
border: none;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryButton,
|
||||||
|
.dangerButton {
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 26px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandLink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandMark {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #17120d;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userMenu summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userMenu[open] form {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userMenu button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteForm {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectableCard {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectableCard input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
right: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectionTitle {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminLoginBody {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginPanel,
|
||||||
|
.editorWrap {
|
||||||
|
background: rgba(21, 29, 25, 0.94);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginPanel {
|
||||||
|
max-width: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackForm,
|
||||||
|
.editorForm {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackForm label,
|
||||||
|
.editorForm label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridTwo {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel-strong);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 150px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorText {
|
||||||
|
color: #ffb7ae;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 18px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 840px) {
|
||||||
|
.hero,
|
||||||
|
.adminHeader,
|
||||||
|
.editorHeader {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridTwo {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
597
src/installer/main.ts
Normal file
597
src/installer/main.ts
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
import { BrowserWindow, app, dialog, ipcMain, shell } from 'electron'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import fsp from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import os from 'node:os'
|
||||||
|
import express from 'express'
|
||||||
|
import session from 'express-session'
|
||||||
|
import AdmZip from 'adm-zip'
|
||||||
|
import upnp from 'nat-upnp'
|
||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
|
import { createApp } from '../server/app'
|
||||||
|
import { InstallPayload, InstallSessionState, SelectedPackPayload } from './types'
|
||||||
|
import { PackDefinition, RootManifest } from '../shared/types'
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
const DEFAULT_MANIFEST_URL = process.env.INSTALLER_MANIFEST_URL ?? 'http://127.0.0.1:3000/manifest.json'
|
||||||
|
const DEFAULT_SITE_URL = process.env.MANAGEMENT_SITE_URL ?? 'http://127.0.0.1:3000'
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
let currentInstall: InstallSessionState | null = null
|
||||||
|
let configEditorServer: ReturnType<express.Express['listen']> | null = null
|
||||||
|
let pendingEulaResolver: (() => void) | null = null
|
||||||
|
|
||||||
|
function sendLog(message: string, tone: 'info' | 'warn' | 'error' | 'success' = 'info') {
|
||||||
|
mainWindow?.webContents.send('installer:log', {
|
||||||
|
message,
|
||||||
|
tone,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasHangul(input: string): boolean {
|
||||||
|
return /[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/.test(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWindow() {
|
||||||
|
if (mainWindow != null) {
|
||||||
|
return mainWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
const appRoot = app.getAppPath()
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1320,
|
||||||
|
height: 860,
|
||||||
|
minWidth: 1180,
|
||||||
|
minHeight: 760,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(appRoot, 'dist', 'installer', 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.loadFile(path.join(appRoot, 'installer', 'index.html'))
|
||||||
|
mainWindow.on('closed', () => {
|
||||||
|
mainWindow = null
|
||||||
|
})
|
||||||
|
return mainWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startManagementSite(): Promise<void> {
|
||||||
|
const appInstance = await createApp()
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const server = appInstance.listen(3000, '127.0.0.1', () => resolve())
|
||||||
|
server.once('error', (error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code === 'EADDRINUSE') {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseDirectory(): Promise<string | null> {
|
||||||
|
const targetWindow = ensureWindow()
|
||||||
|
const result = await dialog.showOpenDialog(targetWindow, {
|
||||||
|
properties: ['openDirectory', 'createDirectory']
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.canceled || result.filePaths.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return result.filePaths[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectJdkCandidates(): Promise<string[]> {
|
||||||
|
const candidates = new Set<string>()
|
||||||
|
const envCandidates = [process.env.JAVA_HOME, process.env.JDK_HOME]
|
||||||
|
|
||||||
|
for (const candidate of envCandidates) {
|
||||||
|
if (candidate != null && candidate.trim().length > 0) {
|
||||||
|
candidates.add(candidate.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const javaRoot = 'C:\\Program Files\\Java'
|
||||||
|
if (fs.existsSync(javaRoot)) {
|
||||||
|
const entries = await fsp.readdir(javaRoot, { withFileTypes: true })
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
candidates.add(path.join(javaRoot, entry.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...candidates]
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveJavaExecutable(jdkPath: string): string {
|
||||||
|
return process.platform === 'win32'
|
||||||
|
? path.join(jdkPath, 'bin', 'java.exe')
|
||||||
|
: path.join(jdkPath, 'bin', 'java')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectJdk(): Promise<{ detected: string | null; candidates: string[] }> {
|
||||||
|
const candidates = await detectJdkCandidates()
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(resolveJavaExecutable(candidate))) {
|
||||||
|
return {
|
||||||
|
detected: candidate,
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
detected: null,
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPackManifest(payload: SelectedPackPayload): Promise<{ baseUrl: string; packDefinition: PackDefinition; packName: string }> {
|
||||||
|
const manifestResponse = await fetch(payload.manifestUrl)
|
||||||
|
if (!manifestResponse.ok) {
|
||||||
|
throw new Error(`manifest.json 요청 실패: ${manifestResponse.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootManifest = await manifestResponse.json() as RootManifest
|
||||||
|
const packEntry = rootManifest.packs.find((entry) => entry.file === payload.pack.file)
|
||||||
|
if (packEntry == null) {
|
||||||
|
throw new Error('선택한 서버팩을 manifest.json에서 찾지 못했습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestUrl = new URL(payload.manifestUrl)
|
||||||
|
const packUrl = new URL(`/manifest/${payload.pack.file}.json`, manifestUrl.origin)
|
||||||
|
const packResponse = await fetch(packUrl)
|
||||||
|
if (!packResponse.ok) {
|
||||||
|
throw new Error(`서버팩 JSON 요청 실패: ${packResponse.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl: manifestUrl.origin,
|
||||||
|
packDefinition: await packResponse.json() as PackDefinition,
|
||||||
|
packName: packEntry.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveClientRamMb(pack: PackDefinition): { selected: number; warning: string | null } {
|
||||||
|
const systemRamMb = Math.floor(os.totalmem() / 1024 / 1024)
|
||||||
|
|
||||||
|
if (systemRamMb >= pack.clientRecommendedRam) {
|
||||||
|
return {
|
||||||
|
selected: pack.clientRecommendedRam,
|
||||||
|
warning: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemRamMb >= pack.clientMinRam) {
|
||||||
|
return {
|
||||||
|
selected: pack.clientMinRam,
|
||||||
|
warning: '권장 램보다 시스템 램이 적어 최소 램으로 설치합니다.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('플레이 불가: 시스템 램이 최소 램보다 적습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeLauncherProfile(packName: string, installRoot: string, pack: PackDefinition): Promise<void> {
|
||||||
|
const appData = process.env.APPDATA
|
||||||
|
if (appData == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const launcherProfilesPath = path.join(appData, '.minecraft', 'launcher_profiles.json')
|
||||||
|
const gameDir = path.join(installRoot, '.mc_custom')
|
||||||
|
const selectedRam = resolveClientRamMb(pack).selected
|
||||||
|
|
||||||
|
let payload: Record<string, unknown> = {}
|
||||||
|
if (fs.existsSync(launcherProfilesPath)) {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(await fsp.readFile(launcherProfilesPath, 'utf8')) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
payload = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = typeof payload.profiles === 'object' && payload.profiles != null
|
||||||
|
? payload.profiles as Record<string, unknown>
|
||||||
|
: {}
|
||||||
|
|
||||||
|
profiles[packName] = {
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
gameDir,
|
||||||
|
icon: 'Grass',
|
||||||
|
javaArgs: `-Xms${Math.min(selectedRam, 2048)}M -Xmx${selectedRam}M`,
|
||||||
|
lastVersionId: pack.mcVersion,
|
||||||
|
name: packName,
|
||||||
|
type: 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.profiles = profiles
|
||||||
|
await fsp.mkdir(path.dirname(launcherProfilesPath), { recursive: true })
|
||||||
|
await fsp.writeFile(launcherProfilesPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAndExtractPack(baseUrl: string, pack: PackDefinition, installRoot: string): Promise<string> {
|
||||||
|
const customRoot = path.join(installRoot, '.mc_custom')
|
||||||
|
await fsp.mkdir(customRoot, { recursive: true })
|
||||||
|
|
||||||
|
if (pack.files != null && pack.files.length > 0) {
|
||||||
|
for (const filePath of pack.files) {
|
||||||
|
const targetUrl = new URL(`/file/${filePath}`, baseUrl).toString()
|
||||||
|
const targetPath = path.join(customRoot, filePath)
|
||||||
|
await fsp.mkdir(path.dirname(targetPath), { recursive: true })
|
||||||
|
sendLog(`다운로드: ${filePath}`)
|
||||||
|
const response = await fetch(targetUrl)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`파일 다운로드 실패: ${filePath}`)
|
||||||
|
}
|
||||||
|
const arrayBuffer = await response.arrayBuffer()
|
||||||
|
await fsp.writeFile(targetPath, Buffer.from(arrayBuffer))
|
||||||
|
}
|
||||||
|
return customRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveUrl = new URL(`/file/${pack.packPath}`, baseUrl).toString()
|
||||||
|
sendLog(`다운로드: ${archiveUrl}`)
|
||||||
|
const response = await fetch(archiveUrl)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`서버팩 다운로드 실패: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveBuffer = Buffer.from(await response.arrayBuffer())
|
||||||
|
const archivePath = path.join(customRoot, 'server-pack.zip')
|
||||||
|
await fsp.writeFile(archivePath, archiveBuffer)
|
||||||
|
sendLog('압축 해제 시작')
|
||||||
|
const zip = new AdmZip(archivePath)
|
||||||
|
zip.extractAllTo(customRoot, true)
|
||||||
|
await fsp.unlink(archivePath)
|
||||||
|
return customRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForEulaAcceptance(): Promise<void> {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
pendingEulaResolver = resolve
|
||||||
|
sendLog('Minecraft EULA 동의가 필요합니다.', 'warn')
|
||||||
|
mainWindow?.webContents.send('installer:log', { action: 'eula-required' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findServerJar(root: string): Promise<string | null> {
|
||||||
|
const entries = await fsp.readdir(root, { withFileTypes: true })
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = path.join(root, entry.name)
|
||||||
|
if (entry.isFile() && entry.name.endsWith('.jar')) {
|
||||||
|
return entryPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startInstall(payload: InstallPayload): Promise<{ nextStep: number; warning: string | null }> {
|
||||||
|
if (hasHangul(payload.installPath)) {
|
||||||
|
throw new Error('설치 경로에 한글이 포함되어 있습니다.')
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(resolveJavaExecutable(payload.jdkPath))) {
|
||||||
|
throw new Error('유효한 JDK 경로를 지정해야 합니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const packMeta = await fetchPackManifest({
|
||||||
|
manifestUrl: payload.manifestUrl,
|
||||||
|
pack: {
|
||||||
|
file: payload.packFile,
|
||||||
|
name: payload.packFile
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const ramDecision = resolveClientRamMb(packMeta.packDefinition)
|
||||||
|
sendLog('서버팩 정보 확인 완료')
|
||||||
|
if (ramDecision.warning != null) {
|
||||||
|
sendLog(ramDecision.warning, 'warn')
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractedRoot = await downloadAndExtractPack(packMeta.baseUrl, packMeta.packDefinition, payload.installPath)
|
||||||
|
const eulaPath = path.join(extractedRoot, 'eula.txt')
|
||||||
|
if (fs.existsSync(eulaPath)) {
|
||||||
|
await fsp.unlink(eulaPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentInstall = {
|
||||||
|
manifestUrl: payload.manifestUrl,
|
||||||
|
packFile: payload.packFile,
|
||||||
|
installPath: payload.installPath,
|
||||||
|
jdkPath: payload.jdkPath,
|
||||||
|
packDefinition: packMeta.packDefinition,
|
||||||
|
packName: packMeta.packName,
|
||||||
|
extractedRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitForEulaAcceptance()
|
||||||
|
await fsp.writeFile(eulaPath, 'eula=true\n', 'utf8')
|
||||||
|
sendLog('EULA 동의 반영 완료', 'success')
|
||||||
|
await writeLauncherProfile(packMeta.packName, payload.installPath, packMeta.packDefinition)
|
||||||
|
sendLog('Minecraft 런처 프로필 추가 완료', 'success')
|
||||||
|
|
||||||
|
return {
|
||||||
|
nextStep: 5,
|
||||||
|
warning: ramDecision.warning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopConfigEditor(): Promise<void> {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (configEditorServer == null) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configEditorServer.close(() => {
|
||||||
|
configEditorServer = null
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProperties(raw: string): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
for (const line of raw.split(/\r?\n/)) {
|
||||||
|
if (line.trim().length === 0 || line.trim().startsWith('#') || !line.includes('=')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const [key, ...valueParts] = line.split('=')
|
||||||
|
result[key.trim()] = valueParts.join('=').trim()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyProperties(values: Record<string, string>): string {
|
||||||
|
return Object.entries(values)
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConfigEditor(): Promise<string> {
|
||||||
|
if (currentInstall == null) {
|
||||||
|
throw new Error('설치된 서버팩 정보가 없습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
await stopConfigEditor()
|
||||||
|
const editorApp = express()
|
||||||
|
editorApp.use(express.urlencoded({ extended: true }))
|
||||||
|
|
||||||
|
editorApp.get('/', async (_req, res) => {
|
||||||
|
const serverPropertiesPath = path.join(currentInstall!.extractedRoot, 'server.properties')
|
||||||
|
const bukkitPath = path.join(currentInstall!.extractedRoot, 'bukkit.yml')
|
||||||
|
const serverPropertiesRaw = fs.existsSync(serverPropertiesPath)
|
||||||
|
? await fsp.readFile(serverPropertiesPath, 'utf8')
|
||||||
|
: ''
|
||||||
|
const parsed = parseProperties(serverPropertiesRaw)
|
||||||
|
const bukkitRaw = fs.existsSync(bukkitPath)
|
||||||
|
? await fsp.readFile(bukkitPath, 'utf8')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
res.send(`<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>서버 설정 편집기</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:Arial,sans-serif;background:#101412;color:#f5f5f5;margin:0;padding:24px;}
|
||||||
|
.wrap{max-width:960px;margin:0 auto;}
|
||||||
|
.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
|
||||||
|
label{display:block;font-weight:700;margin-bottom:6px;}
|
||||||
|
input,textarea{width:100%;padding:10px;border-radius:10px;border:1px solid #2c3a34;background:#171d1a;color:#fff;}
|
||||||
|
textarea{min-height:220px;}
|
||||||
|
button{padding:12px 18px;border:none;border-radius:999px;background:#f0bf57;color:#111;font-weight:700;cursor:pointer;}
|
||||||
|
.card{background:#171d1a;padding:18px;border-radius:18px;margin-bottom:18px;}
|
||||||
|
.desc{color:#b9c0bc;font-size:14px;margin-bottom:12px;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>서버 설정 편집기</h1>
|
||||||
|
<form method="post" action="/save">
|
||||||
|
<div class="card">
|
||||||
|
<h2>server.properties</h2>
|
||||||
|
<div class="desc">메모장 대신 주요 항목을 설명과 함께 수정합니다.</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div><label>MOTD</label><input name="motd" value="${parsed.motd ?? ''}" /></div>
|
||||||
|
<div><label>서버 포트</label><input name="server-port" value="${parsed['server-port'] ?? '25565'}" /></div>
|
||||||
|
<div><label>최대 인원수</label><input name="max-players" value="${parsed['max-players'] ?? '20'}" /></div>
|
||||||
|
<div><label>화이트리스트</label><input name="white-list" value="${parsed['white-list'] ?? 'false'}" /></div>
|
||||||
|
<div><label>PvP</label><input name="pvp" value="${parsed.pvp ?? 'true'}" /></div>
|
||||||
|
<div><label>온라인 모드</label><input name="online-mode" value="${parsed['online-mode'] ?? 'true'}" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>bukkit.yml</h2>
|
||||||
|
<div class="desc">기타 Bukkit 설정은 전체 파일을 직접 수정합니다.</div>
|
||||||
|
<textarea name="bukkitRaw">${bukkitRaw.replace(/</g, '<')}</textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit">적용</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`)
|
||||||
|
})
|
||||||
|
|
||||||
|
editorApp.post('/save', async (req, res) => {
|
||||||
|
const serverPropertiesPath = path.join(currentInstall!.extractedRoot, 'server.properties')
|
||||||
|
const bukkitPath = path.join(currentInstall!.extractedRoot, 'bukkit.yml')
|
||||||
|
const values = {
|
||||||
|
motd: String(req.body.motd ?? ''),
|
||||||
|
'server-port': String(req.body['server-port'] ?? '25565'),
|
||||||
|
'max-players': String(req.body['max-players'] ?? '20'),
|
||||||
|
'white-list': String(req.body['white-list'] ?? 'false'),
|
||||||
|
pvp: String(req.body.pvp ?? 'true'),
|
||||||
|
'online-mode': String(req.body['online-mode'] ?? 'true')
|
||||||
|
}
|
||||||
|
await fsp.writeFile(serverPropertiesPath, `${stringifyProperties(values)}\n`, 'utf8')
|
||||||
|
await fsp.writeFile(bukkitPath, String(req.body.bukkitRaw ?? ''), 'utf8')
|
||||||
|
res.redirect('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = await new Promise<string>((resolve) => {
|
||||||
|
configEditorServer = editorApp.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = configEditorServer?.address()
|
||||||
|
const port = typeof address === 'object' && address != null ? address.port : 0
|
||||||
|
resolve(`http://127.0.0.1:${port}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
currentInstall.configEditorUrl = url
|
||||||
|
await shell.openExternal(url)
|
||||||
|
sendLog(`설정 편집기 실행: ${url}`, 'success')
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configurePort(): Promise<{ status: string; message: string; externalAddress?: string }> {
|
||||||
|
if (currentInstall == null) {
|
||||||
|
throw new Error('설치된 서버 정보가 없습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = 25565
|
||||||
|
const client = upnp.createClient()
|
||||||
|
|
||||||
|
const externalIpResponse = await fetch('https://api.ipify.org?format=json')
|
||||||
|
const externalIp = externalIpResponse.ok
|
||||||
|
? (await externalIpResponse.json() as { ip?: string }).ip
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const mapPort = () => new Promise<void>((resolve, reject) => {
|
||||||
|
client.portMapping(
|
||||||
|
{
|
||||||
|
public: port,
|
||||||
|
private: port,
|
||||||
|
ttl: 3600
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (error != null) {
|
||||||
|
reject(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mapPort()
|
||||||
|
currentInstall.externalPort = port
|
||||||
|
currentInstall.externalAddress = externalIp
|
||||||
|
const message = externalIp != null
|
||||||
|
? `UPnP 자동 개방 성공: ${externalIp}:${port}`
|
||||||
|
: `UPnP 자동 개방 성공: 포트 ${port}`
|
||||||
|
sendLog(message, 'success')
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
message,
|
||||||
|
externalAddress: externalIp
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const message = '자동 포트 개방 실패. 직접 포트포워딩을 해주세요.'
|
||||||
|
sendLog(message, 'error')
|
||||||
|
return {
|
||||||
|
status: 'manual',
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openInstalledFolder(): Promise<void> {
|
||||||
|
if (currentInstall == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await shell.openPath(currentInstall.installPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDesktopShortcut(enabled: boolean): Promise<void> {
|
||||||
|
if (!enabled || currentInstall == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const desktopDir = path.join(os.homedir(), 'Desktop')
|
||||||
|
const shortcutPath = path.join(desktopDir, `${currentInstall.packName} 서버 실행.cmd`)
|
||||||
|
const serverJar = await findServerJar(currentInstall.extractedRoot)
|
||||||
|
if (serverJar == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents = [
|
||||||
|
'@echo off',
|
||||||
|
`cd /d "${currentInstall.extractedRoot}"`,
|
||||||
|
`"${resolveJavaExecutable(currentInstall.jdkPath)}" -Xms${currentInstall.packDefinition.serverMinRam}M -Xmx${currentInstall.packDefinition.serverMaxRam}M -jar "${serverJar}" nogui`
|
||||||
|
].join('\r\n')
|
||||||
|
|
||||||
|
await fsp.writeFile(shortcutPath, contents, 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runServer(enabled: boolean): Promise<void> {
|
||||||
|
if (!enabled || currentInstall == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverJar = await findServerJar(currentInstall.extractedRoot)
|
||||||
|
if (serverJar == null) {
|
||||||
|
sendLog('서버 JAR을 찾지 못해 자동 실행을 생략합니다.', 'warn')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const javaExec = resolveJavaExecutable(currentInstall.jdkPath)
|
||||||
|
execFile(javaExec, [
|
||||||
|
`-Xms${currentInstall.packDefinition.serverMinRam}M`,
|
||||||
|
`-Xmx${currentInstall.packDefinition.serverMaxRam}M`,
|
||||||
|
'-jar',
|
||||||
|
serverJar,
|
||||||
|
'nogui'
|
||||||
|
], {
|
||||||
|
cwd: currentInstall.extractedRoot
|
||||||
|
})
|
||||||
|
sendLog('서버 실행 시작', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindIpcHandlers() {
|
||||||
|
ipcMain.handle('installer:get-defaults', async () => ({
|
||||||
|
manifestUrl: DEFAULT_MANIFEST_URL,
|
||||||
|
managementSiteUrl: DEFAULT_SITE_URL
|
||||||
|
}))
|
||||||
|
|
||||||
|
ipcMain.handle('installer:load-packs', async (_event, manifestUrl: string) => {
|
||||||
|
const response = await fetch(manifestUrl)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`manifest.json 요청 실패: ${response.status}`)
|
||||||
|
}
|
||||||
|
return response.json() as Promise<RootManifest>
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('installer:choose-directory', async () => chooseDirectory())
|
||||||
|
ipcMain.handle('installer:detect-jdk', async () => detectJdk())
|
||||||
|
ipcMain.handle('installer:choose-jdk', async () => chooseDirectory())
|
||||||
|
ipcMain.handle('installer:start-install', async (_event, payload: InstallPayload) => startInstall(payload))
|
||||||
|
ipcMain.handle('installer:accept-eula', async () => {
|
||||||
|
pendingEulaResolver?.()
|
||||||
|
pendingEulaResolver = null
|
||||||
|
})
|
||||||
|
ipcMain.handle('installer:open-config-editor', async () => openConfigEditor())
|
||||||
|
ipcMain.handle('installer:configure-port', async () => configurePort())
|
||||||
|
ipcMain.handle('installer:open-folder', async () => openInstalledFolder())
|
||||||
|
ipcMain.handle('installer:create-shortcut', async (_event, enabled: boolean) => createDesktopShortcut(enabled))
|
||||||
|
ipcMain.handle('installer:run-server', async (_event, enabled: boolean) => runServer(enabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
await startManagementSite()
|
||||||
|
bindIpcHandlers()
|
||||||
|
ensureWindow()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', async () => {
|
||||||
|
await stopConfigEditor()
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
})
|
||||||
17
src/installer/preload.ts
Normal file
17
src/installer/preload.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('installerApi', {
|
||||||
|
getDefaults: () => ipcRenderer.invoke('installer:get-defaults'),
|
||||||
|
loadPacks: (manifestUrl: string) => ipcRenderer.invoke('installer:load-packs', manifestUrl),
|
||||||
|
chooseDirectory: () => ipcRenderer.invoke('installer:choose-directory'),
|
||||||
|
detectJdk: () => ipcRenderer.invoke('installer:detect-jdk'),
|
||||||
|
chooseJdk: () => ipcRenderer.invoke('installer:choose-jdk'),
|
||||||
|
startInstall: (payload: unknown) => ipcRenderer.invoke('installer:start-install', payload),
|
||||||
|
acceptEula: () => ipcRenderer.invoke('installer:accept-eula'),
|
||||||
|
openConfigEditor: () => ipcRenderer.invoke('installer:open-config-editor'),
|
||||||
|
configurePort: () => ipcRenderer.invoke('installer:configure-port'),
|
||||||
|
openFolder: () => ipcRenderer.invoke('installer:open-folder'),
|
||||||
|
createShortcut: (enabled: boolean) => ipcRenderer.invoke('installer:create-shortcut', enabled),
|
||||||
|
runServer: (enabled: boolean) => ipcRenderer.invoke('installer:run-server', enabled),
|
||||||
|
onLog: (handler: (entry: unknown) => void) => ipcRenderer.on('installer:log', (_event, entry) => handler(entry))
|
||||||
|
})
|
||||||
30
src/installer/types.ts
Normal file
30
src/installer/types.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { PackDefinition, PackListEntry } from '../shared/types'
|
||||||
|
|
||||||
|
export interface InstallerDefaults {
|
||||||
|
manifestUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectedPackPayload {
|
||||||
|
manifestUrl: string
|
||||||
|
pack: PackListEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallPayload {
|
||||||
|
manifestUrl: string
|
||||||
|
packFile: string
|
||||||
|
installPath: string
|
||||||
|
jdkPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallSessionState {
|
||||||
|
manifestUrl: string
|
||||||
|
packFile: string
|
||||||
|
installPath: string
|
||||||
|
jdkPath: string
|
||||||
|
packDefinition: PackDefinition
|
||||||
|
packName: string
|
||||||
|
extractedRoot: string
|
||||||
|
externalAddress?: string
|
||||||
|
externalPort?: number
|
||||||
|
configEditorUrl?: string
|
||||||
|
}
|
||||||
58
src/server/app.ts
Normal file
58
src/server/app.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import session from 'express-session'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileDir, manifestDir, publicDir, viewsDir } from '../shared/paths'
|
||||||
|
import { ensureProjectFiles } from '../shared/store'
|
||||||
|
import { indexRouter } from './routes/index'
|
||||||
|
import { opRouter } from './routes/op'
|
||||||
|
|
||||||
|
export async function createApp() {
|
||||||
|
await ensureProjectFiles()
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
app.set('view engine', 'ejs')
|
||||||
|
app.set('views', viewsDir)
|
||||||
|
|
||||||
|
app.use(express.urlencoded({ extended: true }))
|
||||||
|
app.use(express.json())
|
||||||
|
app.use(
|
||||||
|
session({
|
||||||
|
secret: 'mc-custom-suite-session-secret',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
app.use('/static', express.static(publicDir))
|
||||||
|
app.use('/manifest', express.static(manifestDir))
|
||||||
|
app.use('/file', express.static(fileDir))
|
||||||
|
|
||||||
|
app.use(indexRouter)
|
||||||
|
app.use(opRouter)
|
||||||
|
|
||||||
|
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
|
console.error(error)
|
||||||
|
res.status(500).send('서버 내부 오류가 발생했습니다.')
|
||||||
|
})
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await createApp()
|
||||||
|
const port = Number(process.env.PORT ?? 3000)
|
||||||
|
app.listen(port, '127.0.0.1', () => {
|
||||||
|
console.log(`Management site listening on http://127.0.0.1:${port}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
bootstrap().catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
15
src/server/middleware/auth.ts
Normal file
15
src/server/middleware/auth.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextFunction, Request, Response } from 'express'
|
||||||
|
|
||||||
|
declare module 'express-session' {
|
||||||
|
interface SessionData {
|
||||||
|
userId?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
if (req.session.userId == null) {
|
||||||
|
res.redirect('/op')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
31
src/server/routes/index.ts
Normal file
31
src/server/routes/index.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { manifestRootPath } from '../../shared/paths'
|
||||||
|
import { fetchReleaseVersions } from '../../shared/mojang'
|
||||||
|
import { loadRootManifest } from '../../shared/store'
|
||||||
|
|
||||||
|
export const indexRouter = Router()
|
||||||
|
|
||||||
|
indexRouter.get('/', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const manifest = await loadRootManifest()
|
||||||
|
res.render('index', {
|
||||||
|
packs: manifest.packs
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
indexRouter.get('/manifest.json', (_req, res) => {
|
||||||
|
res.sendFile(path.resolve(manifestRootPath))
|
||||||
|
})
|
||||||
|
|
||||||
|
indexRouter.get('/api/releases', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const releases = await fetchReleaseVersions()
|
||||||
|
res.json(releases)
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
143
src/server/routes/op.ts
Normal file
143
src/server/routes/op.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { fetchReleaseVersions } from '../../shared/mojang'
|
||||||
|
import {
|
||||||
|
createNewPack,
|
||||||
|
deletePacks,
|
||||||
|
loadAccounts,
|
||||||
|
loadPackDefinition,
|
||||||
|
loadRootManifest,
|
||||||
|
normalizePackDefinition,
|
||||||
|
updatePack
|
||||||
|
} from '../../shared/store'
|
||||||
|
import { requireAuth } from '../middleware/auth'
|
||||||
|
|
||||||
|
export const opRouter = Router()
|
||||||
|
|
||||||
|
function pickFirstValue(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return typeof value[0] === 'string' ? value[0] : ''
|
||||||
|
}
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
opRouter.get('/op', (req, res) => {
|
||||||
|
if (req.session.userId != null) {
|
||||||
|
res.redirect('/op/dashboard')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render('op/login', {
|
||||||
|
errorMessage: null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/login', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id, password } = req.body as { id?: string; password?: string }
|
||||||
|
const accounts = await loadAccounts()
|
||||||
|
const matched = accounts.find((entry) => entry.id === id && entry.password === password)
|
||||||
|
|
||||||
|
if (matched == null) {
|
||||||
|
res.status(401).render('op/login', {
|
||||||
|
errorMessage: '아이디 또는 비밀번호가 올바르지 않습니다.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.userId = matched.id
|
||||||
|
res.redirect('/op/dashboard')
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/logout', requireAuth, (req, res) => {
|
||||||
|
req.session.destroy(() => {
|
||||||
|
res.redirect('/op')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.get('/op/dashboard', requireAuth, async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const manifest = await loadRootManifest()
|
||||||
|
res.render('op/dashboard', {
|
||||||
|
userId: _req.session.userId,
|
||||||
|
packs: manifest.packs
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/dashboard/packs', requireAuth, async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const packKey = await createNewPack()
|
||||||
|
res.redirect(`/op/dashboard/${packKey}`)
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/dashboard/packs/delete', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rawSelection = req.body.packKeys
|
||||||
|
const packKeys = Array.isArray(rawSelection)
|
||||||
|
? rawSelection.map(String)
|
||||||
|
: typeof rawSelection === 'string'
|
||||||
|
? [rawSelection]
|
||||||
|
: []
|
||||||
|
|
||||||
|
await deletePacks(packKeys)
|
||||||
|
res.redirect('/op/dashboard')
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.get('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const packName = pickFirstValue(req.params.packName)
|
||||||
|
const definition = await loadPackDefinition(packName)
|
||||||
|
if (definition == null) {
|
||||||
|
res.status(404).send('서버팩 JSON을 찾을 수 없습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootManifest = await loadRootManifest()
|
||||||
|
const packEntry = rootManifest.packs.find((entry) => entry.file === packName)
|
||||||
|
const releases = await fetchReleaseVersions()
|
||||||
|
|
||||||
|
res.render('op/editor', {
|
||||||
|
userId: req.session.userId,
|
||||||
|
packKey: packName,
|
||||||
|
packEntry,
|
||||||
|
pack: definition,
|
||||||
|
releases
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const packKey = pickFirstValue(req.params.packName)
|
||||||
|
const nextPackName = pickFirstValue(req.body.displayName).trim() || packKey
|
||||||
|
const nextJsonKey = pickFirstValue(req.body.fileName).trim() || packKey
|
||||||
|
|
||||||
|
const normalized = normalizePackDefinition({
|
||||||
|
mcVersion: pickFirstValue(req.body.mcVersion),
|
||||||
|
serverMinRam: Number(pickFirstValue(req.body.serverMinRam)),
|
||||||
|
serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)),
|
||||||
|
clientMinRam: Number(pickFirstValue(req.body.clientMinRam)),
|
||||||
|
clientRecommendedRam: Number(pickFirstValue(req.body.clientRecommendedRam)),
|
||||||
|
packPath: pickFirstValue(req.body.packPath),
|
||||||
|
description: pickFirstValue(req.body.description)
|
||||||
|
})
|
||||||
|
|
||||||
|
const changedKey = await updatePack(packKey, nextPackName, nextJsonKey, normalized)
|
||||||
|
res.redirect(`/op/dashboard/${changedKey}`)
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
30
src/shared/mojang.ts
Normal file
30
src/shared/mojang.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { MinecraftRelease } from './types'
|
||||||
|
|
||||||
|
const VERSION_MANIFEST_URL = 'https://launchermeta.mojang.com/mc/game/version_manifest.json'
|
||||||
|
|
||||||
|
let cachedReleases: MinecraftRelease[] | null = null
|
||||||
|
|
||||||
|
export async function fetchReleaseVersions(): Promise<MinecraftRelease[]> {
|
||||||
|
if (cachedReleases != null) {
|
||||||
|
return cachedReleases
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(VERSION_MANIFEST_URL)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch versions: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json() as { versions?: MinecraftRelease[] }
|
||||||
|
cachedReleases = (payload.versions ?? []).filter((entry) => entry.type === 'release')
|
||||||
|
return cachedReleases
|
||||||
|
} catch {
|
||||||
|
cachedReleases = [
|
||||||
|
{ id: '1.21.4', type: 'release' },
|
||||||
|
{ id: '1.21.1', type: 'release' },
|
||||||
|
{ id: '1.20.6', type: 'release' },
|
||||||
|
{ id: '1.20.1', type: 'release' }
|
||||||
|
]
|
||||||
|
return cachedReleases
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/shared/nat-upnp.d.ts
vendored
Normal file
9
src/shared/nat-upnp.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
declare module 'nat-upnp' {
|
||||||
|
const upnp: {
|
||||||
|
createClient(): {
|
||||||
|
portMapping(options: Record<string, unknown>, callback: (error?: Error | null) => void): void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default upnp
|
||||||
|
}
|
||||||
9
src/shared/paths.ts
Normal file
9
src/shared/paths.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export const projectRoot = process.cwd()
|
||||||
|
export const manifestRootPath = path.join(projectRoot, 'manifest.json')
|
||||||
|
export const accountPath = path.join(projectRoot, 'account.json')
|
||||||
|
export const manifestDir = path.join(projectRoot, 'manifest')
|
||||||
|
export const fileDir = path.join(projectRoot, 'file')
|
||||||
|
export const viewsDir = path.join(projectRoot, 'views')
|
||||||
|
export const publicDir = path.join(projectRoot, 'public')
|
||||||
211
src/shared/store.ts
Normal file
211
src/shared/store.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import fsp from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { accountPath, fileDir, manifestDir, manifestRootPath } from './paths'
|
||||||
|
import { AccountEntry, PackDefinition, PackListEntry, RootManifest } from './types'
|
||||||
|
|
||||||
|
const defaultRootManifest: RootManifest = {
|
||||||
|
packs: [
|
||||||
|
{
|
||||||
|
name: 'Sample Pack',
|
||||||
|
file: 'sample-pack'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultAccount: AccountEntry[] = [
|
||||||
|
{
|
||||||
|
id: 'admin',
|
||||||
|
password: 'change-me'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultPackDefinition: PackDefinition = {
|
||||||
|
mcVersion: '1.20.1',
|
||||||
|
serverMinRam: 2048,
|
||||||
|
serverMaxRam: 4096,
|
||||||
|
clientMinRam: 4096,
|
||||||
|
clientRecommendedRam: 8192,
|
||||||
|
packPath: 'sample-pack.zip',
|
||||||
|
description: '새 서버팩',
|
||||||
|
configEditableFiles: ['server.properties', 'bukkit.yml']
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDir(targetPath: string): Promise<void> {
|
||||||
|
await fsp.mkdir(targetPath, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureJsonFile<T>(targetPath: string, defaultValue: T): Promise<void> {
|
||||||
|
if (!fs.existsSync(targetPath)) {
|
||||||
|
await fsp.writeFile(targetPath, `${JSON.stringify(defaultValue, null, 2)}\n`, 'utf8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureProjectFiles(): Promise<void> {
|
||||||
|
await ensureDir(manifestDir)
|
||||||
|
await ensureDir(fileDir)
|
||||||
|
await ensureJsonFile(manifestRootPath, defaultRootManifest)
|
||||||
|
await ensureJsonFile(accountPath, defaultAccount)
|
||||||
|
|
||||||
|
const samplePackPath = path.join(manifestDir, 'sample-pack.json')
|
||||||
|
await ensureJsonFile(samplePackPath, defaultPackDefinition)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonFile<T>(targetPath: string, fallback: T): Promise<T> {
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(targetPath, 'utf8')
|
||||||
|
return JSON.parse(raw) as T
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonFile(targetPath: string, payload: unknown): Promise<void> {
|
||||||
|
await fsp.writeFile(targetPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePackKey(name: string): string {
|
||||||
|
const trimmed = name.trim().replace(/\.json$/i, '')
|
||||||
|
const normalized = trimmed.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-')
|
||||||
|
return normalized.length > 0 ? normalized : 'new'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRootManifest(): Promise<RootManifest> {
|
||||||
|
await ensureProjectFiles()
|
||||||
|
return readJsonFile<RootManifest>(manifestRootPath, defaultRootManifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveRootManifest(manifest: RootManifest): Promise<void> {
|
||||||
|
await writeJsonFile(manifestRootPath, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAccounts(): Promise<AccountEntry[]> {
|
||||||
|
await ensureProjectFiles()
|
||||||
|
return readJsonFile<AccountEntry[]>(accountPath, defaultAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listManifestFiles(): Promise<string[]> {
|
||||||
|
await ensureProjectFiles()
|
||||||
|
const entries = await fsp.readdir(manifestDir, { withFileTypes: true })
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
||||||
|
.map((entry) => entry.name.replace(/\.json$/i, ''))
|
||||||
|
.sort((left, right) => left.localeCompare(right))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPackDefinition(packKey: string): Promise<PackDefinition | null> {
|
||||||
|
await ensureProjectFiles()
|
||||||
|
const safeKey = sanitizePackKey(packKey)
|
||||||
|
const filePath = path.join(manifestDir, `${safeKey}.json`)
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return readJsonFile<PackDefinition>(filePath, defaultPackDefinition)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePackDefinition(packKey: string, payload: PackDefinition): Promise<void> {
|
||||||
|
const safeKey = sanitizePackKey(packKey)
|
||||||
|
await writeJsonFile(path.join(manifestDir, `${safeKey}.json`), payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextAvailableNewKey(existing: string[]): string {
|
||||||
|
if (!existing.includes('new')) {
|
||||||
|
return 'new'
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 2
|
||||||
|
while (existing.includes(`new${index}`)) {
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
return `new${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNewPack(): Promise<string> {
|
||||||
|
const existing = await listManifestFiles()
|
||||||
|
const packKey = nextAvailableNewKey(existing)
|
||||||
|
await savePackDefinition(packKey, {
|
||||||
|
...defaultPackDefinition,
|
||||||
|
description: `새 서버팩 (${packKey})`
|
||||||
|
})
|
||||||
|
|
||||||
|
const manifest = await loadRootManifest()
|
||||||
|
manifest.packs.push({
|
||||||
|
name: `새 서버팩 (${packKey})`,
|
||||||
|
file: packKey
|
||||||
|
})
|
||||||
|
await saveRootManifest(manifest)
|
||||||
|
return packKey
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePacks(packKeys: string[]): Promise<void> {
|
||||||
|
const targetKeys = new Set(packKeys.map((entry) => sanitizePackKey(entry)))
|
||||||
|
const manifest = await loadRootManifest()
|
||||||
|
manifest.packs = manifest.packs.filter((entry) => !targetKeys.has(entry.file))
|
||||||
|
await saveRootManifest(manifest)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
[...targetKeys].map(async (packKey) => {
|
||||||
|
const filePath = path.join(manifestDir, `${packKey}.json`)
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
await fsp.unlink(filePath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePack(
|
||||||
|
currentKey: string,
|
||||||
|
nextName: string,
|
||||||
|
nextKey: string,
|
||||||
|
definition: PackDefinition
|
||||||
|
): Promise<string> {
|
||||||
|
const safeCurrentKey = sanitizePackKey(currentKey)
|
||||||
|
const safeNextKey = sanitizePackKey(nextKey)
|
||||||
|
const currentPath = path.join(manifestDir, `${safeCurrentKey}.json`)
|
||||||
|
const nextPath = path.join(manifestDir, `${safeNextKey}.json`)
|
||||||
|
|
||||||
|
if (safeCurrentKey !== safeNextKey && fs.existsSync(nextPath)) {
|
||||||
|
throw new Error('같은 이름의 JSON 파일이 이미 존재합니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
await savePackDefinition(safeNextKey, definition)
|
||||||
|
|
||||||
|
if (safeCurrentKey !== safeNextKey && fs.existsSync(currentPath)) {
|
||||||
|
await fsp.unlink(currentPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = await loadRootManifest()
|
||||||
|
const targetIndex = manifest.packs.findIndex((entry) => entry.file === safeCurrentKey)
|
||||||
|
const nextEntry: PackListEntry = {
|
||||||
|
name: nextName.trim(),
|
||||||
|
file: safeNextKey
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetIndex >= 0) {
|
||||||
|
manifest.packs[targetIndex] = nextEntry
|
||||||
|
} else {
|
||||||
|
manifest.packs.push(nextEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveRootManifest(manifest)
|
||||||
|
return safeNextKey
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePackDefinition(input: Partial<PackDefinition>): PackDefinition {
|
||||||
|
return {
|
||||||
|
mcVersion: String(input.mcVersion ?? '1.20.1').trim() || '1.20.1',
|
||||||
|
serverMinRam: Number(input.serverMinRam ?? 2048),
|
||||||
|
serverMaxRam: Number(input.serverMaxRam ?? 4096),
|
||||||
|
clientMinRam: Number(input.clientMinRam ?? 4096),
|
||||||
|
clientRecommendedRam: Number(input.clientRecommendedRam ?? 8192),
|
||||||
|
packPath: String(input.packPath ?? '').trim(),
|
||||||
|
description: String(input.description ?? '').trim(),
|
||||||
|
files: Array.isArray(input.files)
|
||||||
|
? input.files.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0)
|
||||||
|
: undefined,
|
||||||
|
configEditableFiles: Array.isArray(input.configEditableFiles)
|
||||||
|
? input.configEditableFiles.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0)
|
||||||
|
: ['server.properties', 'bukkit.yml']
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/shared/types.ts
Normal file
30
src/shared/types.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export interface PackListEntry {
|
||||||
|
name: string
|
||||||
|
file: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RootManifest {
|
||||||
|
packs: PackListEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackDefinition {
|
||||||
|
mcVersion: string
|
||||||
|
serverMinRam: number
|
||||||
|
serverMaxRam: number
|
||||||
|
clientMinRam: number
|
||||||
|
clientRecommendedRam: number
|
||||||
|
packPath: string
|
||||||
|
description?: string
|
||||||
|
files?: string[]
|
||||||
|
configEditableFiles?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountEntry {
|
||||||
|
id: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MinecraftRelease {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
31
views/index.ejs
Normal file
31
views/index.ejs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>서버팩 목록</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="siteBody">
|
||||||
|
<header class="hero">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">MC CUSTOM</p>
|
||||||
|
<h1>마인크래프트 서버팩 목록</h1>
|
||||||
|
<p class="heroText">manifest.json에 등록된 서버팩을 한 줄 카드 목록으로 제공합니다.</p>
|
||||||
|
</div>
|
||||||
|
<a class="primaryLink" href="/op">관리자 로그인</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="pageWrap">
|
||||||
|
<section class="cardRow">
|
||||||
|
<% packs.forEach((pack) => { %>
|
||||||
|
<article class="packCard">
|
||||||
|
<h2><%= pack.name %></h2>
|
||||||
|
<p>manifest 키: <code><%= pack.file %></code></p>
|
||||||
|
<a class="ghostLink" href="/manifest/<%= pack.file %>.json">JSON 보기</a>
|
||||||
|
</article>
|
||||||
|
<% }) %>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
views/op/dashboard.ejs
Normal file
33
views/op/dashboard.ejs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>관리자 대시보드</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="siteBody">
|
||||||
|
<%- include('../partials/navbar', { userId }) %>
|
||||||
|
|
||||||
|
<main class="pageWrap">
|
||||||
|
<section class="toolbar">
|
||||||
|
<form method="post" action="/op/dashboard/packs">
|
||||||
|
<button class="primaryButton" type="submit">서버팩 추가</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/op/dashboard/packs/delete" class="deleteForm">
|
||||||
|
<button class="dangerButton" type="submit">선택 삭제</button>
|
||||||
|
<div class="cardRow">
|
||||||
|
<% packs.forEach((pack) => { %>
|
||||||
|
<label class="packCard selectableCard">
|
||||||
|
<input type="checkbox" name="packKeys" value="<%= pack.file %>" />
|
||||||
|
<span class="selectionTitle"><%= pack.name %></span>
|
||||||
|
<span><code><%= pack.file %></code></span>
|
||||||
|
<a class="ghostLink" href="/op/dashboard/<%= pack.file %>">편집</a>
|
||||||
|
</label>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
75
views/op/editor.ejs
Normal file
75
views/op/editor.ejs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title><%= packEntry?.name ?? packKey %> 편집</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="siteBody">
|
||||||
|
<%- include('../partials/navbar', { userId }) %>
|
||||||
|
|
||||||
|
<main class="pageWrap">
|
||||||
|
<section class="editorWrap">
|
||||||
|
<div class="editorHeader">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">PACK EDITOR</p>
|
||||||
|
<h1><%= packEntry?.name ?? packKey %></h1>
|
||||||
|
</div>
|
||||||
|
<a class="ghostLink" href="/op/dashboard">목록으로</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="editorForm">
|
||||||
|
<div class="gridTwo">
|
||||||
|
<label>
|
||||||
|
<span>서버팩 이름</span>
|
||||||
|
<input name="displayName" value="<%= packEntry?.name ?? '' %>" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>JSON 파일 이름</span>
|
||||||
|
<input name="fileName" value="<%= packKey %>" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>설명</span>
|
||||||
|
<textarea name="description"><%= pack.description ?? '' %></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="gridTwo">
|
||||||
|
<label>
|
||||||
|
<span>마인크래프트 버전</span>
|
||||||
|
<select name="mcVersion" required>
|
||||||
|
<% releases.forEach((release) => { %>
|
||||||
|
<option value="<%= release.id %>" <%= release.id === pack.mcVersion ? 'selected' : '' %>><%= release.id %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>packPath</span>
|
||||||
|
<input name="packPath" value="<%= pack.packPath %>" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>서버 최소 램</span>
|
||||||
|
<input type="number" name="serverMinRam" value="<%= pack.serverMinRam %>" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>서버 최대 램</span>
|
||||||
|
<input type="number" name="serverMaxRam" value="<%= pack.serverMaxRam %>" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>클라이언트 최소 램</span>
|
||||||
|
<input type="number" name="clientMinRam" value="<%= pack.clientMinRam %>" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>클라이언트 권장 램</span>
|
||||||
|
<input type="number" name="clientRecommendedRam" value="<%= pack.clientRecommendedRam %>" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="primaryButton" type="submit">적용</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
views/op/login.ejs
Normal file
29
views/op/login.ejs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>관리자 로그인</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="siteBody adminLoginBody">
|
||||||
|
<main class="loginPanel">
|
||||||
|
<p class="eyebrow">OP LOGIN</p>
|
||||||
|
<h1>관리자 로그인</h1>
|
||||||
|
<form method="post" action="/op/login" class="stackForm">
|
||||||
|
<label>
|
||||||
|
<span>아이디</span>
|
||||||
|
<input name="id" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>비밀번호</span>
|
||||||
|
<input type="password" name="password" required />
|
||||||
|
</label>
|
||||||
|
<% if (errorMessage) { %>
|
||||||
|
<p class="errorText"><%= errorMessage %></p>
|
||||||
|
<% } %>
|
||||||
|
<button class="primaryButton" type="submit">로그인</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
views/partials/navbar.ejs
Normal file
12
views/partials/navbar.ejs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<header class="adminHeader">
|
||||||
|
<a class="brandLink" href="/op/dashboard">
|
||||||
|
<span class="brandMark">MC</span>
|
||||||
|
<span>관리자 페이지</span>
|
||||||
|
</a>
|
||||||
|
<details class="userMenu">
|
||||||
|
<summary><%= userId %></summary>
|
||||||
|
<form method="post" action="/op/logout">
|
||||||
|
<button type="submit">로그아웃</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</header>
|
||||||
Reference in New Issue
Block a user