diff --git a/installer/index.html b/installer/index.html
index 1cdfd7e..e14b2cd 100644
--- a/installer/index.html
+++ b/installer/index.html
@@ -29,9 +29,7 @@
manifest.json URL
-
설치기가 시작되면 서버팩 목록을 자동으로 불러옵니다.
@@ -58,6 +56,7 @@
STEP 3
JDK 확인 / 설치
+ 선택한 서버팩의 권장 JDK 버전을 확인 중입니다.
-
-
-
@@ -83,7 +79,9 @@
-
Minecraft EULA에 동의해야 설치를 계속할 수 있습니다.
+
Minecraft EULA를 확인한 뒤 동의 버튼을 눌러야 다음 단계로 진행됩니다.
+
공식 EULA 원문 열기
+
diff --git a/installer/renderer.js b/installer/renderer.js
index 40aac2f..58f3fed 100644
--- a/installer/renderer.js
+++ b/installer/renderer.js
@@ -1,6 +1,7 @@
const state = {
manifestUrl: '',
selectedPack: null,
+ selectedPackMeta: null,
installPath: '',
jdkPath: ''
}
@@ -9,6 +10,15 @@ const panelMap = new Map([...document.querySelectorAll('.panel')].map((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')
+const manifestUrlInput = document.getElementById('manifestUrl')
+const installPathInput = document.getElementById('installPath')
+const jdkPathInput = document.getElementById('jdkPath')
+const jdkStatus = document.getElementById('jdkStatus')
+const jdkRecommended = document.getElementById('jdkRecommended')
+const eulaBlock = document.getElementById('eulaBlock')
+const eulaText = document.getElementById('eulaText')
+const eulaLink = document.getElementById('eulaLink')
+const startInstallButton = document.getElementById('startInstallButton')
function setActiveStep(step) {
for (const [key, panel] of panelMap.entries()) {
@@ -22,7 +32,9 @@ function setActiveStep(step) {
function appendLog(entry) {
if (entry?.action === 'eula-required') {
- document.getElementById('eulaBlock').classList.remove('hidden')
+ eulaText.textContent = entry.eulaText ?? ''
+ eulaLink.href = entry.eulaUrl ?? '#'
+ eulaBlock.classList.remove('hidden')
return
}
@@ -54,10 +66,87 @@ function renderPackList(packs) {
})
}
+async function loadPackManifest() {
+ state.manifestUrl = manifestUrlInput.value.trim()
+ state.selectedPack = null
+ state.selectedPackMeta = null
+ packList.innerHTML = '
서버팩 목록을 불러오는 중입니다.
'
+ try {
+ const manifest = await window.installerApi.loadPacks(state.manifestUrl)
+ renderPackList(manifest.packs)
+ } catch (error) {
+ packList.innerHTML = `
${error.message}
`
+ }
+}
+
+async function loadSelectedPackMeta() {
+ if (state.selectedPack == null) {
+ state.selectedPackMeta = null
+ return null
+ }
+
+ const packMeta = await window.installerApi.inspectPack(state.manifestUrl, state.selectedPack.file)
+ state.selectedPackMeta = packMeta
+ return packMeta
+}
+
+async function autoDetectJdkForSelectedPack() {
+ const packMeta = await loadSelectedPackMeta()
+ if (packMeta == null) {
+ jdkRecommended.textContent = '먼저 서버팩을 선택하세요.'
+ jdkStatus.textContent = ''
+ return
+ }
+
+ const recommendedVersion = packMeta.packDefinition.recommendedJdkVersion ?? null
+ jdkRecommended.textContent = recommendedVersion != null
+ ? `선택한 서버팩의 권장 JDK 버전: ${recommendedVersion}`
+ : '선택한 서버팩에 권장 JDK 버전 정보가 없습니다.'
+
+ jdkStatus.textContent = 'JDK 자동 탐색 중입니다.'
+ const result = await window.installerApi.detectJdk(recommendedVersion)
+ if (result.detected != null) {
+ state.jdkPath = result.detected
+ jdkPathInput.value = result.detected
+ }
+
+ if (result.detected == null) {
+ jdkStatus.textContent = '설치 가능한 JDK를 찾지 못했습니다.'
+ return
+ }
+
+ const pickedCandidate = result.candidates.find((candidate) => candidate.path === result.detected)
+ const versionLabel = pickedCandidate?.majorVersion != null ? `JDK ${pickedCandidate.majorVersion}` : '버전 미확인 JDK'
+ if (result.recommendedVersion != null && result.exactMatch) {
+ jdkStatus.textContent = `권장 버전과 일치하는 ${versionLabel}를 자동 선택했습니다: ${result.detected}`
+ return
+ }
+
+ if (result.recommendedVersion != null) {
+ jdkStatus.textContent = `권장 JDK ${result.recommendedVersion}은 찾지 못해 ${versionLabel}를 대신 선택했습니다: ${result.detected}`
+ return
+ }
+
+ jdkStatus.textContent = `자동 탐색 성공: ${versionLabel} / ${result.detected}`
+}
+
+async function goToStep(step) {
+ setActiveStep(step)
+
+ if (step === 3) {
+ try {
+ await autoDetectJdkForSelectedPack()
+ } catch (error) {
+ jdkStatus.textContent = error.message
+ }
+ }
+}
+
async function bootstrap() {
const defaults = await window.installerApi.getDefaults()
state.manifestUrl = defaults.manifestUrl
- document.getElementById('manifestUrl').value = defaults.manifestUrl
+ manifestUrlInput.value = defaults.manifestUrl
+ await loadPackManifest()
}
window.installerApi.onLog(appendLog)
@@ -66,106 +155,106 @@ packList.addEventListener('change', () => {
const checked = packList.querySelector('input[name="packChoice"]:checked')
if (checked == null) {
state.selectedPack = null
+ state.selectedPackMeta = null
return
}
state.selectedPack = {
file: checked.value,
name: checked.dataset.packName ?? checked.value
}
+ state.selectedPackMeta = null
})
document.querySelectorAll('[data-back]').forEach((button) => {
- button.addEventListener('click', () => {
- setActiveStep(button.dataset.back)
+ button.addEventListener('click', async () => {
+ await goToStep(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)
+manifestUrlInput.addEventListener('change', async () => {
+ await loadPackManifest()
})
-document.getElementById('toStep2').addEventListener('click', () => {
+document.getElementById('toStep2').addEventListener('click', async () => {
if (state.selectedPack == null) {
alert('서버팩을 먼저 선택하세요.')
return
}
- setActiveStep(2)
+ await goToStep(2)
})
document.getElementById('browseInstallPath').addEventListener('click', async () => {
const selected = await window.installerApi.chooseDirectory()
if (selected != null) {
state.installPath = selected
- document.getElementById('installPath').value = selected
+ installPathInput.value = selected
validateInstallPath(selected)
}
})
-document.getElementById('installPath').addEventListener('input', (event) => {
+installPathInput.addEventListener('input', (event) => {
state.installPath = event.target.value
validateInstallPath(state.installPath)
})
-document.getElementById('toStep3').addEventListener('click', () => {
+document.getElementById('toStep3').addEventListener('click', async () => {
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
- }
+ await goToStep(3)
})
document.getElementById('browseJdkPath').addEventListener('click', async () => {
const selected = await window.installerApi.chooseJdk()
if (selected != null) {
state.jdkPath = selected
- document.getElementById('jdkPath').value = selected
+ jdkPathInput.value = selected
}
})
-document.getElementById('jdkPath').addEventListener('input', (event) => {
+jdkPathInput.addEventListener('input', (event) => {
state.jdkPath = event.target.value
})
-document.getElementById('toStep4').addEventListener('click', () => {
+document.getElementById('toStep4').addEventListener('click', async () => {
if (state.jdkPath.trim().length === 0) {
alert('JDK 경로를 지정하세요.')
return
}
- setActiveStep(4)
+ await goToStep(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' })
+startInstallButton.addEventListener('click', async () => {
+ if (state.selectedPack == null) {
+ alert('서버팩을 먼저 선택하세요.')
+ return
+ }
+
+ logView.textContent = ''
+ eulaBlock.classList.add('hidden')
+ eulaText.textContent = ''
+ startInstallButton.disabled = true
+
+ try {
+ 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' })
+ }
+ await goToStep(result.nextStep)
+ } finally {
+ startInstallButton.disabled = false
}
- setActiveStep(result.nextStep)
})
document.getElementById('acceptEulaButton').addEventListener('click', async () => {
- document.getElementById('eulaBlock').classList.add('hidden')
await window.installerApi.acceptEula()
+ eulaBlock.classList.add('hidden')
})
document.getElementById('openConfigEditorButton').addEventListener('click', async () => {
@@ -173,8 +262,8 @@ document.getElementById('openConfigEditorButton').addEventListener('click', asyn
document.getElementById('configEditorStatus').textContent = `브라우저에서 열림: ${url}`
})
-document.getElementById('toStep6').addEventListener('click', () => {
- setActiveStep(6)
+document.getElementById('toStep6').addEventListener('click', async () => {
+ await goToStep(6)
})
document.getElementById('configurePortButton').addEventListener('click', async () => {
@@ -182,8 +271,8 @@ document.getElementById('configurePortButton').addEventListener('click', async (
document.getElementById('portStatusBox').textContent = result.message
})
-document.getElementById('toStep7').addEventListener('click', () => {
- setActiveStep(7)
+document.getElementById('toStep7').addEventListener('click', async () => {
+ await goToStep(7)
})
document.getElementById('openFolderButton').addEventListener('click', async () => {
diff --git a/installer/styles.css b/installer/styles.css
index 2989053..330158f 100644
--- a/installer/styles.css
+++ b/installer/styles.css
@@ -86,6 +86,11 @@ h2 { margin: 0 0 20px; font-size: 34px; }
.field { display: grid; gap: 8px; margin-bottom: 16px; }
+.infoHint {
+ margin: 0 0 16px;
+ color: var(--muted);
+}
+
.field input {
width: 100%;
min-height: 48px;
@@ -175,3 +180,17 @@ button.primary {
gap: 12px;
margin-top: 16px;
}
+
+.eulaLink {
+ color: var(--accent);
+}
+
+.eulaText {
+ margin-top: 14px;
+ max-height: 260px;
+ overflow: auto;
+ white-space: pre-wrap;
+ font-family: Consolas, monospace;
+ font-size: 13px;
+ line-height: 1.55;
+}
diff --git a/manifest/sample-pack.json b/manifest/sample-pack.json
index 83f9431..c107090 100644
--- a/manifest/sample-pack.json
+++ b/manifest/sample-pack.json
@@ -1,5 +1,6 @@
{
"mcVersion": "1.20.1",
+ "recommendedJdkVersion": 17,
"serverMinRam": 2048,
"serverMaxRam": 4096,
"clientMinRam": 4096,
diff --git a/src/installer/main.ts b/src/installer/main.ts
index 9c08bb7..9057774 100644
--- a/src/installer/main.ts
+++ b/src/installer/main.ts
@@ -4,22 +4,26 @@ 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 { createHash } from 'node:crypto'
import { createApp } from '../server/app'
-import { InstallPayload, InstallSessionState, SelectedPackPayload } from './types'
+import { normalizePackDefinition } from '../shared/store'
import { PackDefinition, RootManifest } from '../shared/types'
+import { DetectJdkResult, InstallPayload, InstallSessionState, SelectedPackPayload } from './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'
+const MINECRAFT_EULA_URL = 'https://www.minecraft.net/eula'
+const DEFAULT_CONFIG_FILES = ['server.properties', 'bukkit.yml']
let mainWindow: BrowserWindow | null = null
let currentInstall: InstallSessionState | null = null
let configEditorServer: ReturnType
| null = null
+let configEditorBaseUrl: string | null = null
let pendingEulaResolver: (() => void) | null = null
function sendLog(message: string, tone: 'info' | 'warn' | 'error' | 'success' = 'info') {
@@ -34,6 +38,111 @@ function hasHangul(input: string): boolean {
return /[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/.test(input)
}
+function resolveJavaExecutable(jdkPath: string): string {
+ return process.platform === 'win32'
+ ? path.join(jdkPath, 'bin', 'java.exe')
+ : path.join(jdkPath, 'bin', 'java')
+}
+
+function parseJavaMajorVersion(rawVersion: string): number | null {
+ const cleaned = rawVersion.trim().replace(/^"+|"+$/g, '')
+ if (cleaned.length === 0) {
+ return null
+ }
+
+ if (cleaned.startsWith('1.')) {
+ const legacy = Number.parseInt(cleaned.split('.')[1] ?? '', 10)
+ return Number.isFinite(legacy) ? legacy : null
+ }
+
+ const major = Number.parseInt(cleaned.split(/[._-]/)[0] ?? '', 10)
+ return Number.isFinite(major) ? major : null
+}
+
+async function detectJavaMajorVersion(jdkPath: string): Promise {
+ const releasePath = path.join(jdkPath, 'release')
+ if (fs.existsSync(releasePath)) {
+ try {
+ const releaseContents = await fsp.readFile(releasePath, 'utf8')
+ const versionMatch = releaseContents.match(/JAVA_VERSION="([^"]+)"/)
+ if (versionMatch != null) {
+ return parseJavaMajorVersion(versionMatch[1])
+ }
+ } catch {
+ // Fall back to java -version below.
+ }
+ }
+
+ try {
+ const versionResult = await execFileAsync(resolveJavaExecutable(jdkPath), ['-version'])
+ const combined = `${versionResult.stdout}\n${versionResult.stderr}`
+ const versionMatch = combined.match(/version "([^"]+)"/)
+ if (versionMatch != null) {
+ return parseJavaMajorVersion(versionMatch[1])
+ }
+ } catch {
+ return null
+ }
+
+ return null
+}
+
+async function detectJdkCandidates(): Promise {
+ const candidates = new Set()
+ 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]
+}
+
+async function detectJdk(recommendedVersion?: number | null): Promise {
+ const rawCandidates = await detectJdkCandidates()
+ const candidates = await Promise.all(
+ rawCandidates
+ .filter((candidate) => fs.existsSync(resolveJavaExecutable(candidate)))
+ .map(async (candidate) => ({
+ path: candidate,
+ majorVersion: await detectJavaMajorVersion(candidate)
+ }))
+ )
+
+ const sortedCandidates = [...candidates].sort((left, right) => {
+ const leftMatch = recommendedVersion != null && left.majorVersion === recommendedVersion ? 1 : 0
+ const rightMatch = recommendedVersion != null && right.majorVersion === recommendedVersion ? 1 : 0
+ if (leftMatch !== rightMatch) {
+ return rightMatch - leftMatch
+ }
+
+ const leftVersion = left.majorVersion ?? -1
+ const rightVersion = right.majorVersion ?? -1
+ return rightVersion - leftVersion
+ })
+
+ return {
+ detected: sortedCandidates[0]?.path ?? null,
+ candidates: sortedCandidates,
+ recommendedVersion: recommendedVersion ?? null,
+ exactMatch: recommendedVersion != null && sortedCandidates[0]?.majorVersion === recommendedVersion
+ }
+}
+
function ensureWindow() {
if (mainWindow != null) {
return mainWindow
@@ -85,54 +194,6 @@ async function chooseDirectory(): Promise {
return result.filePaths[0]
}
-async function detectJdkCandidates(): Promise {
- const candidates = new Set()
- 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) {
@@ -154,11 +215,26 @@ async function fetchPackManifest(payload: SelectedPackPayload): Promise<{ baseUr
return {
baseUrl: manifestUrl.origin,
- packDefinition: await packResponse.json() as PackDefinition,
+ packDefinition: normalizePackDefinition(await packResponse.json() as PackDefinition),
packName: packEntry.name
}
}
+async function inspectPack(manifestUrl: string, packFile: string): Promise<{ packName: string; packDefinition: PackDefinition }> {
+ const packMeta = await fetchPackManifest({
+ manifestUrl,
+ pack: {
+ file: packFile,
+ name: packFile
+ }
+ })
+
+ return {
+ packName: packMeta.packName,
+ packDefinition: packMeta.packDefinition
+ }
+}
+
function resolveClientRamMb(pack: PackDefinition): { selected: number; warning: string | null } {
const systemRamMb = Math.floor(os.totalmem() / 1024 / 1024)
@@ -254,25 +330,93 @@ async function downloadAndExtractPack(baseUrl: string, pack: PackDefinition, ins
return customRoot
}
+async function ensureEditableConfigFiles(root: string, pack: PackDefinition): Promise {
+ const targetFiles = pack.configEditableFiles != null && pack.configEditableFiles.length > 0
+ ? pack.configEditableFiles
+ : DEFAULT_CONFIG_FILES
+
+ for (const relativeFile of targetFiles) {
+ const targetPath = path.join(root, relativeFile)
+ if (fs.existsSync(targetPath)) {
+ continue
+ }
+
+ await fsp.mkdir(path.dirname(targetPath), { recursive: true })
+ const baseName = path.basename(relativeFile)
+
+ if (baseName === 'server.properties') {
+ await fsp.writeFile(targetPath, [
+ 'motd=A Minecraft Server',
+ 'server-port=25565',
+ 'max-players=20',
+ 'white-list=false',
+ 'pvp=true',
+ 'online-mode=true'
+ ].join('\n') + '\n', 'utf8')
+ continue
+ }
+
+ if (baseName === 'bukkit.yml') {
+ await fsp.writeFile(targetPath, 'settings:\n allow-end: true\n', 'utf8')
+ continue
+ }
+
+ await fsp.writeFile(targetPath, '', 'utf8')
+ }
+}
+
+function stripHtmlToText(html: string): string {
+ return html
+ .replace(/
+