Build installer and management site from spec

This commit is contained in:
2026-05-07 23:22:34 +09:00
parent 0b061e63b7
commit af6e559682
33 changed files with 7125 additions and 1 deletions

138
installer/index.html Normal file
View 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
View 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
View 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;
}