Improve installer automation and config editor
This commit is contained in:
@@ -29,9 +29,7 @@
|
||||
<span>manifest.json URL</span>
|
||||
<input id="manifestUrl" />
|
||||
</label>
|
||||
<div class="buttonRow">
|
||||
<button id="loadPacksButton" class="primary">목록 불러오기</button>
|
||||
</div>
|
||||
<p class="infoHint">설치기가 시작되면 서버팩 목록을 자동으로 불러옵니다.</p>
|
||||
<div id="packList" class="packList"></div>
|
||||
<div class="buttonRow end">
|
||||
<button id="toStep2" class="primary">다음</button>
|
||||
@@ -58,6 +56,7 @@
|
||||
<section class="panel" data-panel="3">
|
||||
<p class="eyebrow">STEP 3</p>
|
||||
<h2>JDK 확인 / 설치</h2>
|
||||
<div id="jdkRecommended" class="infoBox">선택한 서버팩의 권장 JDK 버전을 확인 중입니다.</div>
|
||||
<label class="field">
|
||||
<span>JDK 경로</span>
|
||||
<div class="inputRow">
|
||||
@@ -65,9 +64,6 @@
|
||||
<button id="browseJdkPath">폴더 선택</button>
|
||||
</div>
|
||||
</label>
|
||||
<div class="buttonRow">
|
||||
<button id="detectJdkButton">자동 탐색</button>
|
||||
</div>
|
||||
<div id="jdkStatus" class="infoBox"></div>
|
||||
<div class="buttonRow between">
|
||||
<button data-back="2">이전</button>
|
||||
@@ -83,7 +79,9 @@
|
||||
</div>
|
||||
<div id="logView" class="logView"></div>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const state = {
|
||||
manifestUrl: '',
|
||||
selectedPack: null,
|
||||
selectedPackMeta: null,
|
||||
installPath: '',
|
||||
jdkPath: ''
|
||||
}
|
||||
@@ -9,6 +10,15 @@ const panelMap = new Map([...document.querySelectorAll('.panel')].map((panel) =>
|
||||
const stepMap = new Map([...document.querySelectorAll('.steps li')].map((step) => [step.dataset.step, step]))
|
||||
const logView = document.getElementById('logView')
|
||||
const packList = document.getElementById('packList')
|
||||
const manifestUrlInput = document.getElementById('manifestUrl')
|
||||
const installPathInput = document.getElementById('installPath')
|
||||
const jdkPathInput = document.getElementById('jdkPath')
|
||||
const jdkStatus = document.getElementById('jdkStatus')
|
||||
const jdkRecommended = document.getElementById('jdkRecommended')
|
||||
const eulaBlock = document.getElementById('eulaBlock')
|
||||
const eulaText = document.getElementById('eulaText')
|
||||
const eulaLink = document.getElementById('eulaLink')
|
||||
const startInstallButton = document.getElementById('startInstallButton')
|
||||
|
||||
function setActiveStep(step) {
|
||||
for (const [key, panel] of panelMap.entries()) {
|
||||
@@ -22,7 +32,9 @@ function setActiveStep(step) {
|
||||
|
||||
function appendLog(entry) {
|
||||
if (entry?.action === 'eula-required') {
|
||||
document.getElementById('eulaBlock').classList.remove('hidden')
|
||||
eulaText.textContent = entry.eulaText ?? ''
|
||||
eulaLink.href = entry.eulaUrl ?? '#'
|
||||
eulaBlock.classList.remove('hidden')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -54,10 +66,87 @@ function renderPackList(packs) {
|
||||
})
|
||||
}
|
||||
|
||||
async function loadPackManifest() {
|
||||
state.manifestUrl = manifestUrlInput.value.trim()
|
||||
state.selectedPack = null
|
||||
state.selectedPackMeta = null
|
||||
packList.innerHTML = '<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() {
|
||||
const defaults = await window.installerApi.getDefaults()
|
||||
state.manifestUrl = defaults.manifestUrl
|
||||
document.getElementById('manifestUrl').value = defaults.manifestUrl
|
||||
manifestUrlInput.value = defaults.manifestUrl
|
||||
await loadPackManifest()
|
||||
}
|
||||
|
||||
window.installerApi.onLog(appendLog)
|
||||
@@ -66,91 +155,88 @@ packList.addEventListener('change', () => {
|
||||
const checked = packList.querySelector('input[name="packChoice"]:checked')
|
||||
if (checked == null) {
|
||||
state.selectedPack = null
|
||||
state.selectedPackMeta = null
|
||||
return
|
||||
}
|
||||
state.selectedPack = {
|
||||
file: checked.value,
|
||||
name: checked.dataset.packName ?? checked.value
|
||||
}
|
||||
state.selectedPackMeta = null
|
||||
})
|
||||
|
||||
document.querySelectorAll('[data-back]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
setActiveStep(button.dataset.back)
|
||||
button.addEventListener('click', async () => {
|
||||
await goToStep(button.dataset.back)
|
||||
})
|
||||
})
|
||||
|
||||
document.getElementById('loadPacksButton').addEventListener('click', async () => {
|
||||
state.manifestUrl = document.getElementById('manifestUrl').value.trim()
|
||||
const manifest = await window.installerApi.loadPacks(state.manifestUrl)
|
||||
renderPackList(manifest.packs)
|
||||
manifestUrlInput.addEventListener('change', async () => {
|
||||
await loadPackManifest()
|
||||
})
|
||||
|
||||
document.getElementById('toStep2').addEventListener('click', () => {
|
||||
document.getElementById('toStep2').addEventListener('click', async () => {
|
||||
if (state.selectedPack == null) {
|
||||
alert('서버팩을 먼저 선택하세요.')
|
||||
return
|
||||
}
|
||||
setActiveStep(2)
|
||||
await goToStep(2)
|
||||
})
|
||||
|
||||
document.getElementById('browseInstallPath').addEventListener('click', async () => {
|
||||
const selected = await window.installerApi.chooseDirectory()
|
||||
if (selected != null) {
|
||||
state.installPath = selected
|
||||
document.getElementById('installPath').value = selected
|
||||
installPathInput.value = selected
|
||||
validateInstallPath(selected)
|
||||
}
|
||||
})
|
||||
|
||||
document.getElementById('installPath').addEventListener('input', (event) => {
|
||||
installPathInput.addEventListener('input', (event) => {
|
||||
state.installPath = event.target.value
|
||||
validateInstallPath(state.installPath)
|
||||
})
|
||||
|
||||
document.getElementById('toStep3').addEventListener('click', () => {
|
||||
document.getElementById('toStep3').addEventListener('click', async () => {
|
||||
if (!validateInstallPath(state.installPath)) {
|
||||
alert('올바른 설치 경로를 입력하세요.')
|
||||
return
|
||||
}
|
||||
setActiveStep(3)
|
||||
})
|
||||
|
||||
document.getElementById('detectJdkButton').addEventListener('click', async () => {
|
||||
const result = await window.installerApi.detectJdk()
|
||||
document.getElementById('jdkStatus').textContent = result.detected != null
|
||||
? `자동 탐색 성공: ${result.detected}`
|
||||
: `JDK를 찾지 못했습니다. 탐색 경로: ${result.candidates.join(', ') || '없음'}`
|
||||
|
||||
if (result.detected != null) {
|
||||
state.jdkPath = result.detected
|
||||
document.getElementById('jdkPath').value = result.detected
|
||||
}
|
||||
await goToStep(3)
|
||||
})
|
||||
|
||||
document.getElementById('browseJdkPath').addEventListener('click', async () => {
|
||||
const selected = await window.installerApi.chooseJdk()
|
||||
if (selected != null) {
|
||||
state.jdkPath = selected
|
||||
document.getElementById('jdkPath').value = selected
|
||||
jdkPathInput.value = selected
|
||||
}
|
||||
})
|
||||
|
||||
document.getElementById('jdkPath').addEventListener('input', (event) => {
|
||||
jdkPathInput.addEventListener('input', (event) => {
|
||||
state.jdkPath = event.target.value
|
||||
})
|
||||
|
||||
document.getElementById('toStep4').addEventListener('click', () => {
|
||||
document.getElementById('toStep4').addEventListener('click', async () => {
|
||||
if (state.jdkPath.trim().length === 0) {
|
||||
alert('JDK 경로를 지정하세요.')
|
||||
return
|
||||
}
|
||||
setActiveStep(4)
|
||||
await goToStep(4)
|
||||
})
|
||||
|
||||
document.getElementById('startInstallButton').addEventListener('click', async () => {
|
||||
startInstallButton.addEventListener('click', async () => {
|
||||
if (state.selectedPack == null) {
|
||||
alert('서버팩을 먼저 선택하세요.')
|
||||
return
|
||||
}
|
||||
|
||||
logView.textContent = ''
|
||||
document.getElementById('eulaBlock').classList.add('hidden')
|
||||
eulaBlock.classList.add('hidden')
|
||||
eulaText.textContent = ''
|
||||
startInstallButton.disabled = true
|
||||
|
||||
try {
|
||||
const result = await window.installerApi.startInstall({
|
||||
manifestUrl: state.manifestUrl,
|
||||
packFile: state.selectedPack.file,
|
||||
@@ -160,12 +246,15 @@ document.getElementById('startInstallButton').addEventListener('click', async ()
|
||||
if (result.warning != null) {
|
||||
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('eulaBlock').classList.add('hidden')
|
||||
await window.installerApi.acceptEula()
|
||||
eulaBlock.classList.add('hidden')
|
||||
})
|
||||
|
||||
document.getElementById('openConfigEditorButton').addEventListener('click', async () => {
|
||||
@@ -173,8 +262,8 @@ document.getElementById('openConfigEditorButton').addEventListener('click', asyn
|
||||
document.getElementById('configEditorStatus').textContent = `브라우저에서 열림: ${url}`
|
||||
})
|
||||
|
||||
document.getElementById('toStep6').addEventListener('click', () => {
|
||||
setActiveStep(6)
|
||||
document.getElementById('toStep6').addEventListener('click', async () => {
|
||||
await goToStep(6)
|
||||
})
|
||||
|
||||
document.getElementById('configurePortButton').addEventListener('click', async () => {
|
||||
@@ -182,8 +271,8 @@ document.getElementById('configurePortButton').addEventListener('click', async (
|
||||
document.getElementById('portStatusBox').textContent = result.message
|
||||
})
|
||||
|
||||
document.getElementById('toStep7').addEventListener('click', () => {
|
||||
setActiveStep(7)
|
||||
document.getElementById('toStep7').addEventListener('click', async () => {
|
||||
await goToStep(7)
|
||||
})
|
||||
|
||||
document.getElementById('openFolderButton').addEventListener('click', async () => {
|
||||
|
||||
@@ -86,6 +86,11 @@ h2 { margin: 0 0 20px; font-size: 34px; }
|
||||
|
||||
.field { display: grid; gap: 8px; margin-bottom: 16px; }
|
||||
|
||||
.infoHint {
|
||||
margin: 0 0 16px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
@@ -175,3 +180,17 @@ button.primary {
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.eulaLink {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.eulaText {
|
||||
margin-top: 14px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"mcVersion": "1.20.1",
|
||||
"recommendedJdkVersion": 17,
|
||||
"serverMinRam": 2048,
|
||||
"serverMaxRam": 4096,
|
||||
"clientMinRam": 4096,
|
||||
|
||||
@@ -4,22 +4,26 @@ import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import express from 'express'
|
||||
import session from 'express-session'
|
||||
import AdmZip from 'adm-zip'
|
||||
import upnp from 'nat-upnp'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createApp } from '../server/app'
|
||||
import { InstallPayload, InstallSessionState, SelectedPackPayload } from './types'
|
||||
import { normalizePackDefinition } from '../shared/store'
|
||||
import { PackDefinition, RootManifest } from '../shared/types'
|
||||
import { DetectJdkResult, InstallPayload, InstallSessionState, SelectedPackPayload } from './types'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const DEFAULT_MANIFEST_URL = process.env.INSTALLER_MANIFEST_URL ?? 'http://127.0.0.1:3000/manifest.json'
|
||||
const DEFAULT_SITE_URL = process.env.MANAGEMENT_SITE_URL ?? 'http://127.0.0.1:3000'
|
||||
const MINECRAFT_EULA_URL = 'https://www.minecraft.net/eula'
|
||||
const DEFAULT_CONFIG_FILES = ['server.properties', 'bukkit.yml']
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let currentInstall: InstallSessionState | null = null
|
||||
let configEditorServer: ReturnType<express.Express['listen']> | null = null
|
||||
let configEditorBaseUrl: string | null = null
|
||||
let pendingEulaResolver: (() => void) | null = null
|
||||
|
||||
function sendLog(message: string, tone: 'info' | 'warn' | 'error' | 'success' = 'info') {
|
||||
@@ -34,6 +38,111 @@ function hasHangul(input: string): boolean {
|
||||
return /[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/.test(input)
|
||||
}
|
||||
|
||||
function resolveJavaExecutable(jdkPath: string): string {
|
||||
return process.platform === 'win32'
|
||||
? path.join(jdkPath, 'bin', 'java.exe')
|
||||
: path.join(jdkPath, 'bin', 'java')
|
||||
}
|
||||
|
||||
function parseJavaMajorVersion(rawVersion: string): number | null {
|
||||
const cleaned = rawVersion.trim().replace(/^"+|"+$/g, '')
|
||||
if (cleaned.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (cleaned.startsWith('1.')) {
|
||||
const legacy = Number.parseInt(cleaned.split('.')[1] ?? '', 10)
|
||||
return Number.isFinite(legacy) ? legacy : null
|
||||
}
|
||||
|
||||
const major = Number.parseInt(cleaned.split(/[._-]/)[0] ?? '', 10)
|
||||
return Number.isFinite(major) ? major : null
|
||||
}
|
||||
|
||||
async function detectJavaMajorVersion(jdkPath: string): Promise<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() {
|
||||
if (mainWindow != null) {
|
||||
return mainWindow
|
||||
@@ -85,54 +194,6 @@ async function chooseDirectory(): Promise<string | null> {
|
||||
return result.filePaths[0]
|
||||
}
|
||||
|
||||
async function detectJdkCandidates(): Promise<string[]> {
|
||||
const candidates = new Set<string>()
|
||||
const envCandidates = [process.env.JAVA_HOME, process.env.JDK_HOME]
|
||||
|
||||
for (const candidate of envCandidates) {
|
||||
if (candidate != null && candidate.trim().length > 0) {
|
||||
candidates.add(candidate.trim())
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const javaRoot = 'C:\\Program Files\\Java'
|
||||
if (fs.existsSync(javaRoot)) {
|
||||
const entries = await fsp.readdir(javaRoot, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
candidates.add(path.join(javaRoot, entry.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...candidates]
|
||||
}
|
||||
|
||||
function resolveJavaExecutable(jdkPath: string): string {
|
||||
return process.platform === 'win32'
|
||||
? path.join(jdkPath, 'bin', 'java.exe')
|
||||
: path.join(jdkPath, 'bin', 'java')
|
||||
}
|
||||
|
||||
async function detectJdk(): Promise<{ detected: string | null; candidates: string[] }> {
|
||||
const candidates = await detectJdkCandidates()
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(resolveJavaExecutable(candidate))) {
|
||||
return {
|
||||
detected: candidate,
|
||||
candidates
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
detected: null,
|
||||
candidates
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPackManifest(payload: SelectedPackPayload): Promise<{ baseUrl: string; packDefinition: PackDefinition; packName: string }> {
|
||||
const manifestResponse = await fetch(payload.manifestUrl)
|
||||
if (!manifestResponse.ok) {
|
||||
@@ -154,11 +215,26 @@ async function fetchPackManifest(payload: SelectedPackPayload): Promise<{ baseUr
|
||||
|
||||
return {
|
||||
baseUrl: manifestUrl.origin,
|
||||
packDefinition: await packResponse.json() as PackDefinition,
|
||||
packDefinition: normalizePackDefinition(await packResponse.json() as PackDefinition),
|
||||
packName: packEntry.name
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectPack(manifestUrl: string, packFile: string): Promise<{ packName: string; packDefinition: PackDefinition }> {
|
||||
const packMeta = await fetchPackManifest({
|
||||
manifestUrl,
|
||||
pack: {
|
||||
file: packFile,
|
||||
name: packFile
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
packName: packMeta.packName,
|
||||
packDefinition: packMeta.packDefinition
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClientRamMb(pack: PackDefinition): { selected: number; warning: string | null } {
|
||||
const systemRamMb = Math.floor(os.totalmem() / 1024 / 1024)
|
||||
|
||||
@@ -254,23 +330,91 @@ async function downloadAndExtractPack(baseUrl: string, pack: PackDefinition, ins
|
||||
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> {
|
||||
const eulaText = await loadMinecraftEulaText()
|
||||
await new Promise<void>((resolve) => {
|
||||
pendingEulaResolver = resolve
|
||||
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 }> {
|
||||
@@ -296,6 +440,7 @@ async function startInstall(payload: InstallPayload): Promise<{ nextStep: number
|
||||
}
|
||||
|
||||
const extractedRoot = await downloadAndExtractPack(packMeta.baseUrl, packMeta.packDefinition, payload.installPath)
|
||||
await ensureEditableConfigFiles(extractedRoot, packMeta.packDefinition)
|
||||
const eulaPath = path.join(extractedRoot, 'eula.txt')
|
||||
if (fs.existsSync(eulaPath)) {
|
||||
await fsp.unlink(eulaPath)
|
||||
@@ -331,6 +476,7 @@ async function stopConfigEditor(): Promise<void> {
|
||||
}
|
||||
configEditorServer.close(() => {
|
||||
configEditorServer = null
|
||||
configEditorBaseUrl = null
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
@@ -348,10 +494,276 @@ function parseProperties(raw: string): Record<string, string> {
|
||||
return result
|
||||
}
|
||||
|
||||
function stringifyProperties(values: Record<string, string>): string {
|
||||
return Object.entries(values)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
function normalizeEditorRelativePath(input: string | null | undefined): string {
|
||||
const sanitized = String(input ?? '')
|
||||
.replace(/\\/g, '/')
|
||||
.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> {
|
||||
@@ -359,80 +771,54 @@ async function openConfigEditor(): Promise<string> {
|
||||
throw new Error('설치된 서버팩 정보가 없습니다.')
|
||||
}
|
||||
|
||||
await stopConfigEditor()
|
||||
if (configEditorServer == null || configEditorBaseUrl == null) {
|
||||
const editorApp = express()
|
||||
editorApp.use(express.urlencoded({ extended: true }))
|
||||
editorApp.use(express.json({ limit: '5mb' }))
|
||||
|
||||
editorApp.get('/', async (_req, res) => {
|
||||
const serverPropertiesPath = path.join(currentInstall!.extractedRoot, 'server.properties')
|
||||
const bukkitPath = path.join(currentInstall!.extractedRoot, 'bukkit.yml')
|
||||
const serverPropertiesRaw = fs.existsSync(serverPropertiesPath)
|
||||
? await fsp.readFile(serverPropertiesPath, 'utf8')
|
||||
: ''
|
||||
const parsed = parseProperties(serverPropertiesRaw)
|
||||
const bukkitRaw = fs.existsSync(bukkitPath)
|
||||
? await fsp.readFile(bukkitPath, 'utf8')
|
||||
: ''
|
||||
|
||||
res.send(`<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>서버 설정 편집기</title>
|
||||
<style>
|
||||
body{font-family:Arial,sans-serif;background:#101412;color:#f5f5f5;margin:0;padding:24px;}
|
||||
.wrap{max-width:960px;margin:0 auto;}
|
||||
.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
|
||||
label{display:block;font-weight:700;margin-bottom:6px;}
|
||||
input,textarea{width:100%;padding:10px;border-radius:10px;border:1px solid #2c3a34;background:#171d1a;color:#fff;}
|
||||
textarea{min-height:220px;}
|
||||
button{padding:12px 18px;border:none;border-radius:999px;background:#f0bf57;color:#111;font-weight:700;cursor:pointer;}
|
||||
.card{background:#171d1a;padding:18px;border-radius:18px;margin-bottom:18px;}
|
||||
.desc{color:#b9c0bc;font-size:14px;margin-bottom:12px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>서버 설정 편집기</h1>
|
||||
<form method="post" action="/save">
|
||||
<div class="card">
|
||||
<h2>server.properties</h2>
|
||||
<div class="desc">메모장 대신 주요 항목을 설명과 함께 수정합니다.</div>
|
||||
<div class="grid">
|
||||
<div><label>MOTD</label><input name="motd" value="${parsed.motd ?? ''}" /></div>
|
||||
<div><label>서버 포트</label><input name="server-port" value="${parsed['server-port'] ?? '25565'}" /></div>
|
||||
<div><label>최대 인원수</label><input name="max-players" value="${parsed['max-players'] ?? '20'}" /></div>
|
||||
<div><label>화이트리스트</label><input name="white-list" value="${parsed['white-list'] ?? 'false'}" /></div>
|
||||
<div><label>PvP</label><input name="pvp" value="${parsed.pvp ?? 'true'}" /></div>
|
||||
<div><label>온라인 모드</label><input name="online-mode" value="${parsed['online-mode'] ?? 'true'}" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>bukkit.yml</h2>
|
||||
<div class="desc">기타 Bukkit 설정은 전체 파일을 직접 수정합니다.</div>
|
||||
<textarea name="bukkitRaw">${bukkitRaw.replace(/</g, '<')}</textarea>
|
||||
</div>
|
||||
<button type="submit">적용</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>`)
|
||||
editorApp.get('/', (_req, res) => {
|
||||
res.send(renderConfigEditorPage())
|
||||
})
|
||||
|
||||
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')
|
||||
editorApp.get('/api/list', async (req, res, next) => {
|
||||
try {
|
||||
const relativePath = normalizeEditorRelativePath(String(req.query.path ?? ''))
|
||||
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)
|
||||
}
|
||||
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) => {
|
||||
@@ -443,10 +829,13 @@ async function openConfigEditor(): Promise<string> {
|
||||
})
|
||||
})
|
||||
|
||||
currentInstall.configEditorUrl = url
|
||||
await shell.openExternal(url)
|
||||
sendLog(`설정 편집기 실행: ${url}`, 'success')
|
||||
return url
|
||||
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 }> {
|
||||
@@ -454,7 +843,11 @@ async function configurePort(): Promise<{ status: string; message: string; exter
|
||||
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 externalIpResponse = await fetch('https://api.ipify.org?format=json')
|
||||
@@ -509,6 +902,16 @@ async function openInstalledFolder(): Promise<void> {
|
||||
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> {
|
||||
if (!enabled || currentInstall == null) {
|
||||
return
|
||||
@@ -568,8 +971,9 @@ function bindIpcHandlers() {
|
||||
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: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:start-install', async (_event, payload: InstallPayload) => startInstall(payload))
|
||||
ipcMain.handle('installer:accept-eula', async () => {
|
||||
|
||||
@@ -3,8 +3,9 @@ import { contextBridge, ipcRenderer } from 'electron'
|
||||
contextBridge.exposeInMainWorld('installerApi', {
|
||||
getDefaults: () => ipcRenderer.invoke('installer:get-defaults'),
|
||||
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'),
|
||||
detectJdk: () => ipcRenderer.invoke('installer:detect-jdk'),
|
||||
detectJdk: (recommendedVersion?: number | null) => ipcRenderer.invoke('installer:detect-jdk', recommendedVersion),
|
||||
chooseJdk: () => ipcRenderer.invoke('installer:choose-jdk'),
|
||||
startInstall: (payload: unknown) => ipcRenderer.invoke('installer:start-install', payload),
|
||||
acceptEula: () => ipcRenderer.invoke('installer:accept-eula'),
|
||||
|
||||
@@ -4,6 +4,23 @@ export interface InstallerDefaults {
|
||||
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 {
|
||||
manifestUrl: string
|
||||
pack: PackListEntry
|
||||
|
||||
@@ -34,13 +34,13 @@ opRouter.get('/op', (req, res) => {
|
||||
|
||||
opRouter.post('/op/login', async (req, res, next) => {
|
||||
try {
|
||||
const { id, password } = req.body as { id?: string; password?: string }
|
||||
const { password } = req.body as { password?: string }
|
||||
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) {
|
||||
res.status(401).render('op/login', {
|
||||
errorMessage: '아이디 또는 비밀번호가 올바르지 않습니다.'
|
||||
errorMessage: '비밀번호가 올바르지 않습니다.'
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -128,6 +128,7 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
|
||||
|
||||
const normalized = normalizePackDefinition({
|
||||
mcVersion: pickFirstValue(req.body.mcVersion),
|
||||
recommendedJdkVersion: Number(pickFirstValue(req.body.recommendedJdkVersion)),
|
||||
serverMinRam: Number(pickFirstValue(req.body.serverMinRam)),
|
||||
serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)),
|
||||
clientMinRam: Number(pickFirstValue(req.body.clientMinRam)),
|
||||
|
||||
@@ -22,6 +22,7 @@ const defaultAccount: AccountEntry[] = [
|
||||
|
||||
const defaultPackDefinition: PackDefinition = {
|
||||
mcVersion: '1.20.1',
|
||||
recommendedJdkVersion: 17,
|
||||
serverMinRam: 2048,
|
||||
serverMaxRam: 4096,
|
||||
clientMinRam: 4096,
|
||||
@@ -211,6 +212,9 @@ export async function updatePack(
|
||||
export function normalizePackDefinition(input: Partial<PackDefinition>): PackDefinition {
|
||||
return {
|
||||
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),
|
||||
serverMaxRam: Number(input.serverMaxRam ?? 4096),
|
||||
clientMinRam: Number(input.clientMinRam ?? 4096),
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface RootManifest {
|
||||
|
||||
export interface PackDefinition {
|
||||
mcVersion: string
|
||||
recommendedJdkVersion?: number
|
||||
serverMinRam: number
|
||||
serverMaxRam: number
|
||||
clientMinRam: number
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>권장 JDK 버전</span>
|
||||
<input type="number" name="recommendedJdkVersion" value="<%= pack.recommendedJdkVersion ?? 17 %>" min="8" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>packPath</span>
|
||||
<input name="packPath" value="<%= pack.packPath %>" required />
|
||||
|
||||
@@ -11,10 +11,6 @@
|
||||
<p class="eyebrow">OP LOGIN</p>
|
||||
<h1>관리자 로그인</h1>
|
||||
<form method="post" action="/op/login" class="stackForm">
|
||||
<label>
|
||||
<span>아이디</span>
|
||||
<input name="id" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>비밀번호</span>
|
||||
<input type="password" name="password" required />
|
||||
|
||||
Reference in New Issue
Block a user