Improve installer automation and config editor
This commit is contained in:
@@ -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(/ /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 }> {
|
||||
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, '<')}</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 () => {
|
||||
|
||||
Reference in New Issue
Block a user