diff --git a/installer/index.html b/installer/index.html index 1cdfd7e..e14b2cd 100644 --- a/installer/index.html +++ b/installer/index.html @@ -29,9 +29,7 @@ manifest.json URL -
- -
+

설치기가 시작되면 서버팩 목록을 자동으로 불러옵니다.

@@ -58,6 +56,7 @@

STEP 3

JDK 확인 / 설치

+
선택한 서버팩의 권장 JDK 버전을 확인 중입니다.
-
- -
@@ -83,7 +79,9 @@
diff --git a/installer/renderer.js b/installer/renderer.js index 40aac2f..58f3fed 100644 --- a/installer/renderer.js +++ b/installer/renderer.js @@ -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 = '
서버팩 목록을 불러오는 중입니다.
' + try { + const manifest = await window.installerApi.loadPacks(state.manifestUrl) + renderPackList(manifest.packs) + } catch (error) { + packList.innerHTML = `
${error.message}
` + } +} + +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,106 +155,106 @@ 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 () => { - logView.textContent = '' - document.getElementById('eulaBlock').classList.add('hidden') - const result = await window.installerApi.startInstall({ - manifestUrl: state.manifestUrl, - packFile: state.selectedPack.file, - installPath: state.installPath, - jdkPath: state.jdkPath - }) - if (result.warning != null) { - appendLog({ message: result.warning, tone: 'warn' }) +startInstallButton.addEventListener('click', async () => { + if (state.selectedPack == null) { + alert('서버팩을 먼저 선택하세요.') + return + } + + logView.textContent = '' + eulaBlock.classList.add('hidden') + eulaText.textContent = '' + startInstallButton.disabled = true + + try { + const result = await window.installerApi.startInstall({ + manifestUrl: state.manifestUrl, + packFile: state.selectedPack.file, + installPath: state.installPath, + jdkPath: state.jdkPath + }) + if (result.warning != null) { + appendLog({ message: result.warning, tone: 'warn' }) + } + await goToStep(result.nextStep) + } finally { + startInstallButton.disabled = false } - setActiveStep(result.nextStep) }) document.getElementById('acceptEulaButton').addEventListener('click', async () => { - document.getElementById('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 () => { diff --git a/installer/styles.css b/installer/styles.css index 2989053..330158f 100644 --- a/installer/styles.css +++ b/installer/styles.css @@ -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; +} diff --git a/manifest/sample-pack.json b/manifest/sample-pack.json index 83f9431..c107090 100644 --- a/manifest/sample-pack.json +++ b/manifest/sample-pack.json @@ -1,5 +1,6 @@ { "mcVersion": "1.20.1", + "recommendedJdkVersion": 17, "serverMinRam": 2048, "serverMaxRam": 4096, "clientMinRam": 4096, diff --git a/src/installer/main.ts b/src/installer/main.ts index 9c08bb7..9057774 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -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 | 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 { + 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 { + const candidates = new Set() + 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 { + 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 { return result.filePaths[0] } -async function detectJdkCandidates(): Promise { - const candidates = new Set() - 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 { + 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(//gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim() +} + +async function loadMinecraftEulaText(): Promise { + 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 { + const eulaText = await loadMinecraftEulaText() await new Promise((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 { - 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 { } configEditorServer.close(() => { configEditorServer = null + configEditorBaseUrl = null resolve() }) }) @@ -348,10 +494,276 @@ function parseProperties(raw: string): Record { return result } -function stringifyProperties(values: Record): 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 ` + + + + + 서버 설정 편집기 + + + +
+ +
+
+
+
+
루트
+
/
+
+
파일을 선택하세요.
+
+
왼쪽에서 파일이나 폴더를 선택하세요.
+
+
+
+ + +` } async function openConfigEditor(): Promise { @@ -359,94 +771,71 @@ async function openConfigEditor(): Promise { 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(` - - - - 서버 설정 편집기 - - - -
-

서버 설정 편집기

-
-
-

server.properties

-
메모장 대신 주요 항목을 설명과 함께 수정합니다.
-
-
-
-
-
-
-
-
-
-
-

bukkit.yml

-
기타 Bukkit 설정은 전체 파일을 직접 수정합니다.
- -
- -
-
- -`) - }) - - 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((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((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 { await shell.openPath(currentInstall.installPath) } +async function findServerJar(root: string): Promise { + 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 { if (!enabled || currentInstall == null) { return @@ -568,8 +971,9 @@ function bindIpcHandlers() { return response.json() as Promise }) + 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 () => { diff --git a/src/installer/preload.ts b/src/installer/preload.ts index de152f3..6fb6d36 100644 --- a/src/installer/preload.ts +++ b/src/installer/preload.ts @@ -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'), diff --git a/src/installer/types.ts b/src/installer/types.ts index c05248a..57844ed 100644 --- a/src/installer/types.ts +++ b/src/installer/types.ts @@ -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 diff --git a/src/server/routes/op.ts b/src/server/routes/op.ts index 4a2f9a8..928a48f 100644 --- a/src/server/routes/op.ts +++ b/src/server/routes/op.ts @@ -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)), diff --git a/src/shared/store.ts b/src/shared/store.ts index 1e851ea..487d6af 100644 --- a/src/shared/store.ts +++ b/src/shared/store.ts @@ -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 { 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), diff --git a/src/shared/types.ts b/src/shared/types.ts index e982726..1c7b4de 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -13,6 +13,7 @@ export interface RootManifest { export interface PackDefinition { mcVersion: string + recommendedJdkVersion?: number serverMinRam: number serverMaxRam: number clientMinRam: number diff --git a/views/op/editor.ejs b/views/op/editor.ejs index a3a3b3d..8b0c502 100644 --- a/views/op/editor.ejs +++ b/views/op/editor.ejs @@ -45,6 +45,10 @@ <% }) %> +