Improve installer automation and config editor
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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,91 +155,88 @@ 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 () => {
|
||||||
|
if (state.selectedPack == null) {
|
||||||
|
alert('서버팩을 먼저 선택하세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
logView.textContent = ''
|
logView.textContent = ''
|
||||||
document.getElementById('eulaBlock').classList.add('hidden')
|
eulaBlock.classList.add('hidden')
|
||||||
|
eulaText.textContent = ''
|
||||||
|
startInstallButton.disabled = true
|
||||||
|
|
||||||
|
try {
|
||||||
const result = await window.installerApi.startInstall({
|
const result = await window.installerApi.startInstall({
|
||||||
manifestUrl: state.manifestUrl,
|
manifestUrl: state.manifestUrl,
|
||||||
packFile: state.selectedPack.file,
|
packFile: state.selectedPack.file,
|
||||||
@@ -160,12 +246,15 @@ document.getElementById('startInstallButton').addEventListener('click', async ()
|
|||||||
if (result.warning != null) {
|
if (result.warning != null) {
|
||||||
appendLog({ message: result.warning, tone: 'warn' })
|
appendLog({ message: result.warning, tone: 'warn' })
|
||||||
}
|
}
|
||||||
setActiveStep(result.nextStep)
|
await goToStep(result.nextStep)
|
||||||
|
} finally {
|
||||||
|
startInstallButton.disabled = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,23 +330,91 @@ 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(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/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 }> {
|
||||||
@@ -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,80 +771,54 @@ 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, '<')}</textarea>
|
|
||||||
</div>
|
|
||||||
<button type="submit">적용</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
editorApp.post('/save', async (req, res) => {
|
editorApp.get('/api/list', async (req, res, next) => {
|
||||||
const serverPropertiesPath = path.join(currentInstall!.extractedRoot, 'server.properties')
|
try {
|
||||||
const bukkitPath = path.join(currentInstall!.extractedRoot, 'bukkit.yml')
|
const relativePath = normalizeEditorRelativePath(String(req.query.path ?? ''))
|
||||||
const values = {
|
const parentPath = relativePath.includes('/') ? relativePath.split('/').slice(0, -1).join('/') : ''
|
||||||
motd: String(req.body.motd ?? ''),
|
res.json({
|
||||||
'server-port': String(req.body['server-port'] ?? '25565'),
|
relativePath,
|
||||||
'max-players': String(req.body['max-players'] ?? '20'),
|
parentPath,
|
||||||
'white-list': String(req.body['white-list'] ?? 'false'),
|
entries: await listEditorEntries(relativePath)
|
||||||
pvp: String(req.body.pvp ?? 'true'),
|
})
|
||||||
'online-mode': String(req.body['online-mode'] ?? 'true')
|
} 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)
|
||||||
}
|
}
|
||||||
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) => {
|
const url = await new Promise<string>((resolve) => {
|
||||||
@@ -443,10 +829,13 @@ async function openConfigEditor(): Promise<string> {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
currentInstall.configEditorUrl = url
|
configEditorBaseUrl = url
|
||||||
await shell.openExternal(url)
|
}
|
||||||
sendLog(`설정 편집기 실행: ${url}`, 'success')
|
|
||||||
return 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 () => {
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
Reference in New Issue
Block a user