Improve installer automation and config editor

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

View File

@@ -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,25 +330,93 @@ 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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
async function loadMinecraftEulaText(): Promise<string> {
try {
const response = await fetch(MINECRAFT_EULA_URL)
if (response.ok) {
const html = await response.text()
const plainText = stripHtmlToText(html)
const startIndex = plainText.indexOf('MINECRAFT END USER LICENSE AGREEMENT')
if (startIndex >= 0) {
return plainText.slice(startIndex, startIndex + 7000)
}
return plainText.slice(0, 7000)
}
} catch {
// Fall back to the bundled summary below.
}
return [
'Minecraft EULA 요약',
'',
'이 설치기는 공식 Minecraft EULA 동의를 받아야만 서버팩 설치를 계속할 수 있습니다.',
'상업적 이용, 계정 공유, 저작권 침해 등은 허용되지 않으며, 원문은 아래 주소에서 확인할 수 있습니다.',
'',
MINECRAFT_EULA_URL
].join('\n')
}
async function waitForEulaAcceptance(): Promise<void> {
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 }> {
if (hasHangul(payload.installPath)) {
throw new Error('설치 경로에 한글이 포함되어 있습니다.')
@@ -296,6 +440,7 @@ async function startInstall(payload: InstallPayload): Promise<{ nextStep: number
}
const extractedRoot = await downloadAndExtractPack(packMeta.baseUrl, packMeta.packDefinition, payload.installPath)
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,94 +771,71 @@ async function openConfigEditor(): Promise<string> {
throw new Error('설치된 서버팩 정보가 없습니다.')
}
await stopConfigEditor()
const editorApp = express()
editorApp.use(express.urlencoded({ extended: true }))
if (configEditorServer == null || configEditorBaseUrl == null) {
const editorApp = express()
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, '&lt;')}</textarea>
</div>
<button type="submit">적용</button>
</form>
</div>
</body>
</html>`)
})
editorApp.post('/save', async (req, res) => {
const serverPropertiesPath = path.join(currentInstall!.extractedRoot, 'server.properties')
const bukkitPath = path.join(currentInstall!.extractedRoot, 'bukkit.yml')
const values = {
motd: String(req.body.motd ?? ''),
'server-port': String(req.body['server-port'] ?? '25565'),
'max-players': String(req.body['max-players'] ?? '20'),
'white-list': String(req.body['white-list'] ?? 'false'),
pvp: String(req.body.pvp ?? 'true'),
'online-mode': String(req.body['online-mode'] ?? 'true')
}
await fsp.writeFile(serverPropertiesPath, `${stringifyProperties(values)}\n`, 'utf8')
await fsp.writeFile(bukkitPath, String(req.body.bukkitRaw ?? ''), 'utf8')
res.redirect('/')
})
const url = await new Promise<string>((resolve) => {
configEditorServer = editorApp.listen(0, '127.0.0.1', () => {
const address = configEditorServer?.address()
const port = typeof address === 'object' && address != null ? address.port : 0
resolve(`http://127.0.0.1:${port}`)
editorApp.get('/', (_req, res) => {
res.send(renderConfigEditorPage())
})
})
currentInstall.configEditorUrl = url
await shell.openExternal(url)
sendLog(`설정 편집기 실행: ${url}`, 'success')
return url
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)
}
})
const url = await new Promise<string>((resolve) => {
configEditorServer = editorApp.listen(0, '127.0.0.1', () => {
const address = configEditorServer?.address()
const port = typeof address === 'object' && address != null ? address.port : 0
resolve(`http://127.0.0.1:${port}`)
})
})
configEditorBaseUrl = url
}
currentInstall.configEditorUrl = configEditorBaseUrl
await shell.openExternal(`${configEditorBaseUrl}/?open=${Date.now()}`)
sendLog(`설정 편집기 실행: ${configEditorBaseUrl}`, 'success')
return configEditorBaseUrl
}
async function configurePort(): Promise<{ status: string; message: string; externalAddress?: string }> {
@@ -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 () => {