Improve installer automation and config editor

This commit is contained in:
2026-05-08 19:29:07 +09:00
parent 5ff4e20b5e
commit 427b708277
12 changed files with 751 additions and 216 deletions

View File

@@ -29,9 +29,7 @@
<span>manifest.json URL</span> <span>manifest.json URL</span>
<input id="manifestUrl" /> <input id="manifestUrl" />
</label> </label>
<div class="buttonRow"> <p class="infoHint">설치기가 시작되면 서버팩 목록을 자동으로 불러옵니다.</p>
<button id="loadPacksButton" class="primary">목록 불러오기</button>
</div>
<div id="packList" class="packList"></div> <div id="packList" class="packList"></div>
<div class="buttonRow end"> <div class="buttonRow end">
<button id="toStep2" class="primary">다음</button> <button id="toStep2" class="primary">다음</button>
@@ -58,6 +56,7 @@
<section class="panel" data-panel="3"> <section class="panel" data-panel="3">
<p class="eyebrow">STEP 3</p> <p class="eyebrow">STEP 3</p>
<h2>JDK 확인 / 설치</h2> <h2>JDK 확인 / 설치</h2>
<div id="jdkRecommended" class="infoBox">선택한 서버팩의 권장 JDK 버전을 확인 중입니다.</div>
<label class="field"> <label class="field">
<span>JDK 경로</span> <span>JDK 경로</span>
<div class="inputRow"> <div class="inputRow">
@@ -65,9 +64,6 @@
<button id="browseJdkPath">폴더 선택</button> <button id="browseJdkPath">폴더 선택</button>
</div> </div>
</label> </label>
<div class="buttonRow">
<button id="detectJdkButton">자동 탐색</button>
</div>
<div id="jdkStatus" class="infoBox"></div> <div id="jdkStatus" class="infoBox"></div>
<div class="buttonRow between"> <div class="buttonRow between">
<button data-back="2">이전</button> <button data-back="2">이전</button>
@@ -83,7 +79,9 @@
</div> </div>
<div id="logView" class="logView"></div> <div id="logView" class="logView"></div>
<div id="eulaBlock" class="eulaBlock hidden"> <div id="eulaBlock" class="eulaBlock hidden">
<p>Minecraft EULA에 동의해야 설치를 계속할 수 있습니다.</p> <p>Minecraft EULA를 확인한 뒤 동의 버튼을 눌러야 다음 단계로 진행됩니다.</p>
<a id="eulaLink" class="eulaLink" href="#" target="_blank" rel="noreferrer">공식 EULA 원문 열기</a>
<pre id="eulaText" class="eulaText"></pre>
<button id="acceptEulaButton" class="primary">EULA 동의 후 계속</button> <button id="acceptEulaButton" class="primary">EULA 동의 후 계속</button>
</div> </div>
</section> </section>

View File

@@ -1,6 +1,7 @@
const state = { const state = {
manifestUrl: '', manifestUrl: '',
selectedPack: null, selectedPack: null,
selectedPackMeta: null,
installPath: '', installPath: '',
jdkPath: '' 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 stepMap = new Map([...document.querySelectorAll('.steps li')].map((step) => [step.dataset.step, step]))
const logView = document.getElementById('logView') const logView = document.getElementById('logView')
const packList = document.getElementById('packList') 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) { function setActiveStep(step) {
for (const [key, panel] of panelMap.entries()) { for (const [key, panel] of panelMap.entries()) {
@@ -22,7 +32,9 @@ function setActiveStep(step) {
function appendLog(entry) { function appendLog(entry) {
if (entry?.action === 'eula-required') { if (entry?.action === 'eula-required') {
document.getElementById('eulaBlock').classList.remove('hidden') eulaText.textContent = entry.eulaText ?? ''
eulaLink.href = entry.eulaUrl ?? '#'
eulaBlock.classList.remove('hidden')
return return
} }
@@ -54,10 +66,87 @@ function renderPackList(packs) {
}) })
} }
async function loadPackManifest() {
state.manifestUrl = manifestUrlInput.value.trim()
state.selectedPack = null
state.selectedPackMeta = null
packList.innerHTML = '<div class="infoBox">서버팩 목록을 불러오는 중입니다.</div>'
try {
const manifest = await window.installerApi.loadPacks(state.manifestUrl)
renderPackList(manifest.packs)
} catch (error) {
packList.innerHTML = `<div class="infoBox">${error.message}</div>`
}
}
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() { async function bootstrap() {
const defaults = await window.installerApi.getDefaults() const defaults = await window.installerApi.getDefaults()
state.manifestUrl = defaults.manifestUrl state.manifestUrl = defaults.manifestUrl
document.getElementById('manifestUrl').value = defaults.manifestUrl manifestUrlInput.value = defaults.manifestUrl
await loadPackManifest()
} }
window.installerApi.onLog(appendLog) window.installerApi.onLog(appendLog)
@@ -66,106 +155,106 @@ packList.addEventListener('change', () => {
const checked = packList.querySelector('input[name="packChoice"]:checked') const checked = packList.querySelector('input[name="packChoice"]:checked')
if (checked == null) { if (checked == null) {
state.selectedPack = null state.selectedPack = null
state.selectedPackMeta = null
return return
} }
state.selectedPack = { state.selectedPack = {
file: checked.value, file: checked.value,
name: checked.dataset.packName ?? checked.value name: checked.dataset.packName ?? checked.value
} }
state.selectedPackMeta = null
}) })
document.querySelectorAll('[data-back]').forEach((button) => { document.querySelectorAll('[data-back]').forEach((button) => {
button.addEventListener('click', () => { button.addEventListener('click', async () => {
setActiveStep(button.dataset.back) await goToStep(button.dataset.back)
}) })
}) })
document.getElementById('loadPacksButton').addEventListener('click', async () => { manifestUrlInput.addEventListener('change', async () => {
state.manifestUrl = document.getElementById('manifestUrl').value.trim() await loadPackManifest()
const manifest = await window.installerApi.loadPacks(state.manifestUrl)
renderPackList(manifest.packs)
}) })
document.getElementById('toStep2').addEventListener('click', () => { document.getElementById('toStep2').addEventListener('click', async () => {
if (state.selectedPack == null) { if (state.selectedPack == null) {
alert('서버팩을 먼저 선택하세요.') alert('서버팩을 먼저 선택하세요.')
return return
} }
setActiveStep(2) await goToStep(2)
}) })
document.getElementById('browseInstallPath').addEventListener('click', async () => { document.getElementById('browseInstallPath').addEventListener('click', async () => {
const selected = await window.installerApi.chooseDirectory() const selected = await window.installerApi.chooseDirectory()
if (selected != null) { if (selected != null) {
state.installPath = selected state.installPath = selected
document.getElementById('installPath').value = selected installPathInput.value = selected
validateInstallPath(selected) validateInstallPath(selected)
} }
}) })
document.getElementById('installPath').addEventListener('input', (event) => { installPathInput.addEventListener('input', (event) => {
state.installPath = event.target.value state.installPath = event.target.value
validateInstallPath(state.installPath) validateInstallPath(state.installPath)
}) })
document.getElementById('toStep3').addEventListener('click', () => { document.getElementById('toStep3').addEventListener('click', async () => {
if (!validateInstallPath(state.installPath)) { if (!validateInstallPath(state.installPath)) {
alert('올바른 설치 경로를 입력하세요.') alert('올바른 설치 경로를 입력하세요.')
return return
} }
setActiveStep(3) await goToStep(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 () => { document.getElementById('browseJdkPath').addEventListener('click', async () => {
const selected = await window.installerApi.chooseJdk() const selected = await window.installerApi.chooseJdk()
if (selected != null) { if (selected != null) {
state.jdkPath = selected 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 state.jdkPath = event.target.value
}) })
document.getElementById('toStep4').addEventListener('click', () => { document.getElementById('toStep4').addEventListener('click', async () => {
if (state.jdkPath.trim().length === 0) { if (state.jdkPath.trim().length === 0) {
alert('JDK 경로를 지정하세요.') alert('JDK 경로를 지정하세요.')
return return
} }
setActiveStep(4) await goToStep(4)
}) })
document.getElementById('startInstallButton').addEventListener('click', async () => { startInstallButton.addEventListener('click', async () => {
logView.textContent = '' if (state.selectedPack == null) {
document.getElementById('eulaBlock').classList.add('hidden') alert('서버팩을 먼저 선택하세요.')
const result = await window.installerApi.startInstall({ return
manifestUrl: state.manifestUrl, }
packFile: state.selectedPack.file,
installPath: state.installPath, logView.textContent = ''
jdkPath: state.jdkPath eulaBlock.classList.add('hidden')
}) eulaText.textContent = ''
if (result.warning != null) { startInstallButton.disabled = true
appendLog({ message: result.warning, tone: 'warn' })
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('acceptEulaButton').addEventListener('click', async () => {
document.getElementById('eulaBlock').classList.add('hidden')
await window.installerApi.acceptEula() await window.installerApi.acceptEula()
eulaBlock.classList.add('hidden')
}) })
document.getElementById('openConfigEditorButton').addEventListener('click', async () => { document.getElementById('openConfigEditorButton').addEventListener('click', async () => {
@@ -173,8 +262,8 @@ document.getElementById('openConfigEditorButton').addEventListener('click', asyn
document.getElementById('configEditorStatus').textContent = `브라우저에서 열림: ${url}` document.getElementById('configEditorStatus').textContent = `브라우저에서 열림: ${url}`
}) })
document.getElementById('toStep6').addEventListener('click', () => { document.getElementById('toStep6').addEventListener('click', async () => {
setActiveStep(6) await goToStep(6)
}) })
document.getElementById('configurePortButton').addEventListener('click', async () => { document.getElementById('configurePortButton').addEventListener('click', async () => {
@@ -182,8 +271,8 @@ document.getElementById('configurePortButton').addEventListener('click', async (
document.getElementById('portStatusBox').textContent = result.message document.getElementById('portStatusBox').textContent = result.message
}) })
document.getElementById('toStep7').addEventListener('click', () => { document.getElementById('toStep7').addEventListener('click', async () => {
setActiveStep(7) await goToStep(7)
}) })
document.getElementById('openFolderButton').addEventListener('click', async () => { document.getElementById('openFolderButton').addEventListener('click', async () => {

View File

@@ -86,6 +86,11 @@ h2 { margin: 0 0 20px; font-size: 34px; }
.field { display: grid; gap: 8px; margin-bottom: 16px; } .field { display: grid; gap: 8px; margin-bottom: 16px; }
.infoHint {
margin: 0 0 16px;
color: var(--muted);
}
.field input { .field input {
width: 100%; width: 100%;
min-height: 48px; min-height: 48px;
@@ -175,3 +180,17 @@ button.primary {
gap: 12px; gap: 12px;
margin-top: 16px; 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;
}

View File

@@ -1,5 +1,6 @@
{ {
"mcVersion": "1.20.1", "mcVersion": "1.20.1",
"recommendedJdkVersion": 17,
"serverMinRam": 2048, "serverMinRam": 2048,
"serverMaxRam": 4096, "serverMaxRam": 4096,
"clientMinRam": 4096, "clientMinRam": 4096,

View File

@@ -4,22 +4,26 @@ import fsp from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import os from 'node:os' import os from 'node:os'
import express from 'express' import express from 'express'
import session from 'express-session'
import AdmZip from 'adm-zip' import AdmZip from 'adm-zip'
import upnp from 'nat-upnp' import upnp from 'nat-upnp'
import { execFile } from 'node:child_process' import { execFile } from 'node:child_process'
import { promisify } from 'node:util' import { promisify } from 'node:util'
import { createHash } from 'node:crypto'
import { createApp } from '../server/app' import { createApp } from '../server/app'
import { InstallPayload, InstallSessionState, SelectedPackPayload } from './types' import { normalizePackDefinition } from '../shared/store'
import { PackDefinition, RootManifest } from '../shared/types' import { PackDefinition, RootManifest } from '../shared/types'
import { DetectJdkResult, InstallPayload, InstallSessionState, SelectedPackPayload } from './types'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
const DEFAULT_MANIFEST_URL = process.env.INSTALLER_MANIFEST_URL ?? 'http://127.0.0.1:3000/manifest.json' 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 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 mainWindow: BrowserWindow | null = null
let currentInstall: InstallSessionState | null = null let currentInstall: InstallSessionState | null = null
let configEditorServer: ReturnType<express.Express['listen']> | null = null let configEditorServer: ReturnType<express.Express['listen']> | null = null
let configEditorBaseUrl: string | null = null
let pendingEulaResolver: (() => void) | null = null let pendingEulaResolver: (() => void) | null = null
function sendLog(message: string, tone: 'info' | 'warn' | 'error' | 'success' = 'info') { function sendLog(message: string, tone: 'info' | 'warn' | 'error' | 'success' = 'info') {
@@ -34,6 +38,111 @@ function hasHangul(input: string): boolean {
return /[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/.test(input) 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<number | null> {
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<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]
}
async function detectJdk(recommendedVersion?: number | null): Promise<DetectJdkResult> {
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() { function ensureWindow() {
if (mainWindow != null) { if (mainWindow != null) {
return mainWindow return mainWindow
@@ -85,54 +194,6 @@ async function chooseDirectory(): Promise<string | null> {
return result.filePaths[0] 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 }> { async function fetchPackManifest(payload: SelectedPackPayload): Promise<{ baseUrl: string; packDefinition: PackDefinition; packName: string }> {
const manifestResponse = await fetch(payload.manifestUrl) const manifestResponse = await fetch(payload.manifestUrl)
if (!manifestResponse.ok) { if (!manifestResponse.ok) {
@@ -154,11 +215,26 @@ async function fetchPackManifest(payload: SelectedPackPayload): Promise<{ baseUr
return { return {
baseUrl: manifestUrl.origin, baseUrl: manifestUrl.origin,
packDefinition: await packResponse.json() as PackDefinition, packDefinition: normalizePackDefinition(await packResponse.json() as PackDefinition),
packName: packEntry.name 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 } { function resolveClientRamMb(pack: PackDefinition): { selected: number; warning: string | null } {
const systemRamMb = Math.floor(os.totalmem() / 1024 / 1024) const systemRamMb = Math.floor(os.totalmem() / 1024 / 1024)
@@ -254,25 +330,93 @@ async function downloadAndExtractPack(baseUrl: string, pack: PackDefinition, ins
return customRoot return customRoot
} }
async function ensureEditableConfigFiles(root: string, pack: PackDefinition): Promise<void> {
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(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
async function loadMinecraftEulaText(): Promise<string> {
try {
const response = await fetch(MINECRAFT_EULA_URL)
if (response.ok) {
const html = await response.text()
const plainText = stripHtmlToText(html)
const startIndex = plainText.indexOf('MINECRAFT END USER LICENSE AGREEMENT')
if (startIndex >= 0) {
return plainText.slice(startIndex, startIndex + 7000)
}
return plainText.slice(0, 7000)
}
} catch {
// Fall back to the bundled summary below.
}
return [
'Minecraft EULA 요약',
'',
'이 설치기는 공식 Minecraft EULA 동의를 받아야만 서버팩 설치를 계속할 수 있습니다.',
'상업적 이용, 계정 공유, 저작권 침해 등은 허용되지 않으며, 원문은 아래 주소에서 확인할 수 있습니다.',
'',
MINECRAFT_EULA_URL
].join('\n')
}
async function waitForEulaAcceptance(): Promise<void> { async function waitForEulaAcceptance(): Promise<void> {
const eulaText = await loadMinecraftEulaText()
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
pendingEulaResolver = resolve pendingEulaResolver = resolve
sendLog('Minecraft EULA 동의가 필요합니다.', 'warn') sendLog('Minecraft EULA 동의가 필요합니다.', 'warn')
mainWindow?.webContents.send('installer:log', { action: 'eula-required' }) mainWindow?.webContents.send('installer:log', {
action: 'eula-required',
eulaText,
eulaUrl: MINECRAFT_EULA_URL
})
}) })
} }
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 }> { async function startInstall(payload: InstallPayload): Promise<{ nextStep: number; warning: string | null }> {
if (hasHangul(payload.installPath)) { if (hasHangul(payload.installPath)) {
throw new Error('설치 경로에 한글이 포함되어 있습니다.') throw new Error('설치 경로에 한글이 포함되어 있습니다.')
@@ -296,6 +440,7 @@ async function startInstall(payload: InstallPayload): Promise<{ nextStep: number
} }
const extractedRoot = await downloadAndExtractPack(packMeta.baseUrl, packMeta.packDefinition, payload.installPath) const extractedRoot = await downloadAndExtractPack(packMeta.baseUrl, packMeta.packDefinition, payload.installPath)
await ensureEditableConfigFiles(extractedRoot, packMeta.packDefinition)
const eulaPath = path.join(extractedRoot, 'eula.txt') const eulaPath = path.join(extractedRoot, 'eula.txt')
if (fs.existsSync(eulaPath)) { if (fs.existsSync(eulaPath)) {
await fsp.unlink(eulaPath) await fsp.unlink(eulaPath)
@@ -331,6 +476,7 @@ async function stopConfigEditor(): Promise<void> {
} }
configEditorServer.close(() => { configEditorServer.close(() => {
configEditorServer = null configEditorServer = null
configEditorBaseUrl = null
resolve() resolve()
}) })
}) })
@@ -348,10 +494,276 @@ function parseProperties(raw: string): Record<string, string> {
return result return result
} }
function stringifyProperties(values: Record<string, string>): string { function normalizeEditorRelativePath(input: string | null | undefined): string {
return Object.entries(values) const sanitized = String(input ?? '')
.map(([key, value]) => `${key}=${value}`) .replace(/\\/g, '/')
.join('\n') .replace(/^\/+/, '')
const normalized = path.posix.normalize(sanitized)
if (normalized === '.' || normalized === '/') {
return ''
}
if (normalized.startsWith('..')) {
throw new Error('잘못된 파일 경로입니다.')
}
return normalized
}
function resolveEditorAbsolutePath(relativePath: string): string {
if (currentInstall == null) {
throw new Error('설치된 서버팩 정보가 없습니다.')
}
const normalized = normalizeEditorRelativePath(relativePath)
const rootPath = path.resolve(currentInstall.extractedRoot)
const resolved = path.resolve(rootPath, normalized)
if (resolved !== rootPath && !resolved.startsWith(`${rootPath}${path.sep}`)) {
throw new Error('허용되지 않은 경로 접근입니다.')
}
return resolved
}
function isEditableTextFile(relativePath: string): boolean {
const extension = path.extname(relativePath).toLowerCase()
const editableExtensions = new Set([
'.txt',
'.properties',
'.yml',
'.yaml',
'.json',
'.toml',
'.cfg',
'.conf',
'.ini',
'.xml',
'.md',
'.bat',
'.cmd',
'.sh',
'.log'
])
return editableExtensions.has(extension) || path.basename(relativePath) === 'eula.txt'
}
async function listEditorEntries(relativePath = '') {
const targetPath = resolveEditorAbsolutePath(relativePath)
const entries = await fsp.readdir(targetPath, { withFileTypes: true })
return entries
.filter((entry) => entry.name !== '.DS_Store')
.sort((left, right) => {
if (left.isDirectory() !== right.isDirectory()) {
return left.isDirectory() ? -1 : 1
}
return left.name.localeCompare(right.name)
})
.map((entry) => ({
name: entry.name,
relativePath: normalizeEditorRelativePath(path.posix.join(relativePath, entry.name)),
isDirectory: entry.isDirectory()
}))
}
async function readEditorFile(relativePath: string): Promise<{ editable: boolean; content: string; sha1: string; size: number }> {
const targetPath = resolveEditorAbsolutePath(relativePath)
const stats = await fsp.stat(targetPath)
if (!stats.isFile()) {
throw new Error('파일이 아닙니다.')
}
if (!isEditableTextFile(relativePath) || stats.size > 1024 * 1024) {
return {
editable: false,
content: '이 파일 형식은 웹 편집기에서 바로 수정하지 않습니다.',
sha1: '',
size: stats.size
}
}
const raw = await fsp.readFile(targetPath, 'utf8')
return {
editable: true,
content: raw,
sha1: createHash('sha1').update(raw).digest('hex'),
size: stats.size
}
}
async function writeEditorFile(relativePath: string, content: string): Promise<{ sha1: string }> {
if (!isEditableTextFile(relativePath)) {
throw new Error('이 파일은 웹 편집기로 수정하지 않습니다.')
}
const targetPath = resolveEditorAbsolutePath(relativePath)
await fsp.writeFile(targetPath, content, 'utf8')
return {
sha1: createHash('sha1').update(content).digest('hex')
}
}
function renderConfigEditorPage(): string {
return `<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>서버 설정 편집기</title>
<style>
:root{color-scheme:dark;--bg:#0f1411;--panel:#171e1a;--soft:#202924;--line:#2d3932;--text:#f3f5f4;--muted:#abb5af;--accent:#f0bf57;--ok:#8cd98c;}
*{box-sizing:border-box;} body{margin:0;font-family:"Segoe UI",sans-serif;background:linear-gradient(180deg,#0b100d 0%,#111813 100%);color:var(--text);}
.shell{display:grid;grid-template-columns:340px 1fr;min-height:100vh;}
.sidebar{padding:24px;border-right:1px solid var(--line);background:rgba(12,17,14,0.86);}
.content{padding:24px;}
.card{background:rgba(23,30,26,0.94);border:1px solid var(--line);border-radius:24px;padding:20px;}
.entryList{display:grid;gap:10px;margin-top:18px;max-height:calc(100vh - 180px);overflow:auto;}
.entryButton{width:100%;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border-radius:16px;border:1px solid var(--line);background:var(--soft);color:var(--text);cursor:pointer;text-align:left;}
.entryButton small{color:var(--muted);}
.entryButton.dir{font-weight:700;}
.toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px;}
.crumbs{color:var(--muted);word-break:break-all;}
.status{color:var(--muted);min-height:24px;}
.status.ok{color:var(--ok);}
.status.error{color:#ff9898;}
textarea{width:100%;min-height:calc(100vh - 230px);padding:16px;border-radius:18px;border:1px solid var(--line);background:var(--soft);color:var(--text);font:14px/1.55 Consolas,monospace;resize:vertical;}
.placeholder{display:grid;place-items:center;min-height:calc(100vh - 230px);border:1px dashed var(--line);border-radius:18px;color:var(--muted);padding:28px;text-align:center;}
.ghost{min-height:42px;padding:0 16px;border-radius:999px;border:1px solid var(--line);background:transparent;color:var(--text);cursor:pointer;}
</style>
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="card">
<h1 style="margin:0 0 8px;font-size:28px;">서버 설정 편집기</h1>
<div style="color:var(--muted);">파일과 폴더를 탐색하고 텍스트 파일을 수정하면 자동 저장됩니다.</div>
<div class="toolbar" style="margin-top:18px;">
<button id="upButton" class="ghost">상위 폴더</button>
<button id="refreshButton" class="ghost">새로고침</button>
</div>
<div id="entryList" class="entryList"></div>
</div>
</aside>
<main class="content">
<div class="card">
<div class="toolbar">
<div>
<div id="currentPath" style="font-size:24px;font-weight:700;">루트</div>
<div id="crumbs" class="crumbs">/</div>
</div>
<div id="saveStatus" class="status">파일을 선택하세요.</div>
</div>
<div id="editorHost" class="placeholder">왼쪽에서 파일이나 폴더를 선택하세요.</div>
</div>
</main>
</div>
<script>
const state = {
directory: '',
selectedFile: '',
saveTimer: null
}
const entryList = document.getElementById('entryList')
const currentPath = document.getElementById('currentPath')
const crumbs = document.getElementById('crumbs')
const editorHost = document.getElementById('editorHost')
const saveStatus = document.getElementById('saveStatus')
function setStatus(message, tone = '') {
saveStatus.textContent = message
saveStatus.className = tone ? 'status ' + tone : 'status'
}
async function loadDirectory(relativePath = '') {
const response = await fetch('/api/list?path=' + encodeURIComponent(relativePath))
if (!response.ok) {
throw new Error('목록을 불러오지 못했습니다.')
}
const payload = await response.json()
state.directory = payload.relativePath
currentPath.textContent = payload.relativePath || '루트'
crumbs.textContent = '/' + (payload.relativePath || '')
entryList.innerHTML = ''
for (const entry of payload.entries) {
const button = document.createElement('button')
button.type = 'button'
button.className = 'entryButton ' + (entry.isDirectory ? 'dir' : 'file')
button.innerHTML = '<span>' + entry.name + '</span><small>' + (entry.isDirectory ? '폴더' : '파일') + '</small>'
button.addEventListener('click', () => {
if (entry.isDirectory) {
loadDirectory(entry.relativePath).catch((error) => setStatus(error.message, 'error'))
return
}
loadFile(entry.relativePath).catch((error) => setStatus(error.message, 'error'))
})
entryList.appendChild(button)
}
if (payload.entries.length === 0) {
entryList.innerHTML = '<div class="placeholder" style="min-height:160px;">이 폴더에는 항목이 없습니다.</div>'
}
}
async function loadFile(relativePath) {
const response = await fetch('/api/file?path=' + encodeURIComponent(relativePath))
if (!response.ok) {
throw new Error('파일을 열지 못했습니다.')
}
const payload = await response.json()
state.selectedFile = payload.relativePath
currentPath.textContent = payload.relativePath
crumbs.textContent = '/' + payload.relativePath
if (!payload.editable) {
editorHost.innerHTML = '<div class="placeholder">' + payload.content + '</div>'
setStatus('이 파일은 읽기 전용입니다.')
return
}
editorHost.innerHTML = ''
const textarea = document.createElement('textarea')
textarea.value = payload.content
textarea.addEventListener('input', () => {
setStatus('변경 감지됨. 자동 저장 중...')
if (state.saveTimer != null) {
clearTimeout(state.saveTimer)
}
state.saveTimer = setTimeout(async () => {
const saveResponse = await fetch('/api/file', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: state.selectedFile,
content: textarea.value
})
})
if (!saveResponse.ok) {
setStatus('저장 실패', 'error')
return
}
setStatus('저장 완료', 'ok')
}, 300)
})
editorHost.appendChild(textarea)
setStatus('파일을 수정하면 바로 저장됩니다.')
}
document.getElementById('refreshButton').addEventListener('click', () => {
loadDirectory(state.directory).catch((error) => setStatus(error.message, 'error'))
})
document.getElementById('upButton').addEventListener('click', () => {
if (!state.directory) {
loadDirectory('').catch((error) => setStatus(error.message, 'error'))
return
}
const next = state.directory.split('/').slice(0, -1).join('/')
loadDirectory(next).catch((error) => setStatus(error.message, 'error'))
})
loadDirectory('').catch((error) => setStatus(error.message, 'error'))
</script>
</body>
</html>`
} }
async function openConfigEditor(): Promise<string> { async function openConfigEditor(): Promise<string> {
@@ -359,94 +771,71 @@ async function openConfigEditor(): Promise<string> {
throw new Error('설치된 서버팩 정보가 없습니다.') throw new Error('설치된 서버팩 정보가 없습니다.')
} }
await stopConfigEditor() if (configEditorServer == null || configEditorBaseUrl == null) {
const editorApp = express() const editorApp = express()
editorApp.use(express.urlencoded({ extended: true })) editorApp.use(express.json({ limit: '5mb' }))
editorApp.get('/', async (_req, res) => { editorApp.get('/', (_req, res) => {
const serverPropertiesPath = path.join(currentInstall!.extractedRoot, 'server.properties') res.send(renderConfigEditorPage())
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, '&lt;')}</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 editorApp.get('/api/list', async (req, res, next) => {
await shell.openExternal(url) try {
sendLog(`설정 편집기 실행: ${url}`, 'success') const relativePath = normalizeEditorRelativePath(String(req.query.path ?? ''))
return url const parentPath = relativePath.includes('/') ? relativePath.split('/').slice(0, -1).join('/') : ''
res.json({
relativePath,
parentPath,
entries: await listEditorEntries(relativePath)
})
} catch (error) {
next(error)
}
})
editorApp.get('/api/file', async (req, res, next) => {
try {
const relativePath = normalizeEditorRelativePath(String(req.query.path ?? ''))
const fileState = await readEditorFile(relativePath)
res.json({
relativePath,
...fileState
})
} catch (error) {
next(error)
}
})
editorApp.put('/api/file', async (req, res, next) => {
try {
const relativePath = normalizeEditorRelativePath(String(req.body.path ?? ''))
const content = String(req.body.content ?? '')
const result = await writeEditorFile(relativePath, content)
res.json({
relativePath,
savedAt: new Date().toISOString(),
...result
})
} catch (error) {
next(error)
}
})
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}`)
})
})
configEditorBaseUrl = url
}
currentInstall.configEditorUrl = configEditorBaseUrl
await shell.openExternal(`${configEditorBaseUrl}/?open=${Date.now()}`)
sendLog(`설정 편집기 실행: ${configEditorBaseUrl}`, 'success')
return configEditorBaseUrl
} }
async function configurePort(): Promise<{ status: string; message: string; externalAddress?: string }> { async function configurePort(): Promise<{ status: string; message: string; externalAddress?: string }> {
@@ -454,7 +843,11 @@ async function configurePort(): Promise<{ status: string; message: string; exter
throw new Error('설치된 서버 정보가 없습니다.') throw new Error('설치된 서버 정보가 없습니다.')
} }
const port = 25565 const serverPropertiesPath = path.join(currentInstall.extractedRoot, 'server.properties')
const configuredPort = fs.existsSync(serverPropertiesPath)
? Number.parseInt(parseProperties(await fsp.readFile(serverPropertiesPath, 'utf8'))['server-port'] ?? '25565', 10)
: 25565
const port = Number.isFinite(configuredPort) ? configuredPort : 25565
const client = upnp.createClient() const client = upnp.createClient()
const externalIpResponse = await fetch('https://api.ipify.org?format=json') const externalIpResponse = await fetch('https://api.ipify.org?format=json')
@@ -509,6 +902,16 @@ async function openInstalledFolder(): Promise<void> {
await shell.openPath(currentInstall.installPath) await shell.openPath(currentInstall.installPath)
} }
async function findServerJar(root: string): Promise<string | null> {
const entries = await fsp.readdir(root, { withFileTypes: true })
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.jar')) {
return path.join(root, entry.name)
}
}
return null
}
async function createDesktopShortcut(enabled: boolean): Promise<void> { async function createDesktopShortcut(enabled: boolean): Promise<void> {
if (!enabled || currentInstall == null) { if (!enabled || currentInstall == null) {
return return
@@ -568,8 +971,9 @@ function bindIpcHandlers() {
return response.json() as Promise<RootManifest> return response.json() as Promise<RootManifest>
}) })
ipcMain.handle('installer:inspect-pack', async (_event, manifestUrl: string, packFile: string) => inspectPack(manifestUrl, packFile))
ipcMain.handle('installer:choose-directory', async () => chooseDirectory()) ipcMain.handle('installer:choose-directory', async () => chooseDirectory())
ipcMain.handle('installer:detect-jdk', async () => detectJdk()) ipcMain.handle('installer:detect-jdk', async (_event, recommendedVersion?: number | null) => detectJdk(recommendedVersion))
ipcMain.handle('installer:choose-jdk', async () => chooseDirectory()) ipcMain.handle('installer:choose-jdk', async () => chooseDirectory())
ipcMain.handle('installer:start-install', async (_event, payload: InstallPayload) => startInstall(payload)) ipcMain.handle('installer:start-install', async (_event, payload: InstallPayload) => startInstall(payload))
ipcMain.handle('installer:accept-eula', async () => { ipcMain.handle('installer:accept-eula', async () => {

View File

@@ -3,8 +3,9 @@ import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('installerApi', { contextBridge.exposeInMainWorld('installerApi', {
getDefaults: () => ipcRenderer.invoke('installer:get-defaults'), getDefaults: () => ipcRenderer.invoke('installer:get-defaults'),
loadPacks: (manifestUrl: string) => ipcRenderer.invoke('installer:load-packs', manifestUrl), loadPacks: (manifestUrl: string) => ipcRenderer.invoke('installer:load-packs', manifestUrl),
inspectPack: (manifestUrl: string, packFile: string) => ipcRenderer.invoke('installer:inspect-pack', manifestUrl, packFile),
chooseDirectory: () => ipcRenderer.invoke('installer:choose-directory'), chooseDirectory: () => ipcRenderer.invoke('installer:choose-directory'),
detectJdk: () => ipcRenderer.invoke('installer:detect-jdk'), detectJdk: (recommendedVersion?: number | null) => ipcRenderer.invoke('installer:detect-jdk', recommendedVersion),
chooseJdk: () => ipcRenderer.invoke('installer:choose-jdk'), chooseJdk: () => ipcRenderer.invoke('installer:choose-jdk'),
startInstall: (payload: unknown) => ipcRenderer.invoke('installer:start-install', payload), startInstall: (payload: unknown) => ipcRenderer.invoke('installer:start-install', payload),
acceptEula: () => ipcRenderer.invoke('installer:accept-eula'), acceptEula: () => ipcRenderer.invoke('installer:accept-eula'),

View File

@@ -4,6 +4,23 @@ export interface InstallerDefaults {
manifestUrl: string manifestUrl: string
} }
export interface PackMetadata {
packName: string
packDefinition: PackDefinition
}
export interface JdkCandidate {
path: string
majorVersion: number | null
}
export interface DetectJdkResult {
detected: string | null
candidates: JdkCandidate[]
recommendedVersion: number | null
exactMatch: boolean
}
export interface SelectedPackPayload { export interface SelectedPackPayload {
manifestUrl: string manifestUrl: string
pack: PackListEntry pack: PackListEntry

View File

@@ -34,13 +34,13 @@ opRouter.get('/op', (req, res) => {
opRouter.post('/op/login', async (req, res, next) => { opRouter.post('/op/login', async (req, res, next) => {
try { try {
const { id, password } = req.body as { id?: string; password?: string } const { password } = req.body as { password?: string }
const accounts = await loadAccounts() const accounts = await loadAccounts()
const matched = accounts.find((entry) => entry.id === id && entry.password === password) const matched = accounts.find((entry) => entry.password === password)
if (matched == null) { if (matched == null) {
res.status(401).render('op/login', { res.status(401).render('op/login', {
errorMessage: '아이디 또는 비밀번호가 올바르지 않습니다.' errorMessage: '비밀번호가 올바르지 않습니다.'
}) })
return return
} }
@@ -128,6 +128,7 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
const normalized = normalizePackDefinition({ const normalized = normalizePackDefinition({
mcVersion: pickFirstValue(req.body.mcVersion), mcVersion: pickFirstValue(req.body.mcVersion),
recommendedJdkVersion: Number(pickFirstValue(req.body.recommendedJdkVersion)),
serverMinRam: Number(pickFirstValue(req.body.serverMinRam)), serverMinRam: Number(pickFirstValue(req.body.serverMinRam)),
serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)), serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)),
clientMinRam: Number(pickFirstValue(req.body.clientMinRam)), clientMinRam: Number(pickFirstValue(req.body.clientMinRam)),

View File

@@ -22,6 +22,7 @@ const defaultAccount: AccountEntry[] = [
const defaultPackDefinition: PackDefinition = { const defaultPackDefinition: PackDefinition = {
mcVersion: '1.20.1', mcVersion: '1.20.1',
recommendedJdkVersion: 17,
serverMinRam: 2048, serverMinRam: 2048,
serverMaxRam: 4096, serverMaxRam: 4096,
clientMinRam: 4096, clientMinRam: 4096,
@@ -211,6 +212,9 @@ export async function updatePack(
export function normalizePackDefinition(input: Partial<PackDefinition>): PackDefinition { export function normalizePackDefinition(input: Partial<PackDefinition>): PackDefinition {
return { return {
mcVersion: String(input.mcVersion ?? '1.20.1').trim() || '1.20.1', mcVersion: String(input.mcVersion ?? '1.20.1').trim() || '1.20.1',
recommendedJdkVersion: Number.isFinite(Number(input.recommendedJdkVersion))
? Number(input.recommendedJdkVersion)
: 17,
serverMinRam: Number(input.serverMinRam ?? 2048), serverMinRam: Number(input.serverMinRam ?? 2048),
serverMaxRam: Number(input.serverMaxRam ?? 4096), serverMaxRam: Number(input.serverMaxRam ?? 4096),
clientMinRam: Number(input.clientMinRam ?? 4096), clientMinRam: Number(input.clientMinRam ?? 4096),

View File

@@ -13,6 +13,7 @@ export interface RootManifest {
export interface PackDefinition { export interface PackDefinition {
mcVersion: string mcVersion: string
recommendedJdkVersion?: number
serverMinRam: number serverMinRam: number
serverMaxRam: number serverMaxRam: number
clientMinRam: number clientMinRam: number

View File

@@ -45,6 +45,10 @@
<% }) %> <% }) %>
</select> </select>
</label> </label>
<label>
<span>권장 JDK 버전</span>
<input type="number" name="recommendedJdkVersion" value="<%= pack.recommendedJdkVersion ?? 17 %>" min="8" required />
</label>
<label> <label>
<span>packPath</span> <span>packPath</span>
<input name="packPath" value="<%= pack.packPath %>" required /> <input name="packPath" value="<%= pack.packPath %>" required />

View File

@@ -11,10 +11,6 @@
<p class="eyebrow">OP LOGIN</p> <p class="eyebrow">OP LOGIN</p>
<h1>관리자 로그인</h1> <h1>관리자 로그인</h1>
<form method="post" action="/op/login" class="stackForm"> <form method="post" action="/op/login" class="stackForm">
<label>
<span>아이디</span>
<input name="id" required />
</label>
<label> <label>
<span>비밀번호</span> <span>비밀번호</span>
<input type="password" name="password" required /> <input type="password" name="password" required />