diff --git a/installer/renderer.js b/installer/renderer.js
index 333d362..72bd6d2 100644
--- a/installer/renderer.js
+++ b/installer/renderer.js
@@ -160,19 +160,17 @@ function renderStep3() {
section.className = 'page'
section.innerHTML =
'
3단계. 서버 관련 설정
' +
- '' +
- '
'
+ ''
pageHost.appendChild(section)
var subHost = section.querySelector('#subHost')
- section.querySelector('#back').addEventListener('click', renderStep2)
- function show31() { subHost.innerHTML = ''; renderSubStep31(subHost, show32) }
- function show32() { subHost.innerHTML = ''; renderSubStep32(subHost, show33) }
- function show33() { subHost.innerHTML = ''; renderSubStep33(subHost, show34) }
- function show34() { subHost.innerHTML = ''; renderSubStep34(subHost, show35) }
+ function show31() { subHost.innerHTML = ''; renderSubStep31(subHost, renderStep2, show32) }
+ function show32() { subHost.innerHTML = ''; renderSubStep32(subHost, show31, show33) }
+ function show33() { subHost.innerHTML = ''; renderSubStep33(subHost, show32, show34) }
+ function show34() { subHost.innerHTML = ''; renderSubStep34(subHost, show33, show35) }
function show35() {
subHost.innerHTML = ''
- renderSubStep35(subHost, function () {
+ renderSubStep35(subHost, show34, function () {
state.stepDone[3] = true
renderStep4()
})
@@ -180,21 +178,22 @@ function renderStep3() {
show31()
}
-function renderSubStep31(host, done) {
+function renderSubStep31(host, back, done) {
host.innerHTML =
'3-1. 서버 설치 경로
' +
'서버를 생성할 폴더를 선택하세요. 경로에 한글이 포함되면 안 됩니다.
' +
'' +
'
' +
'' +
- ''
+ ''
var input = host.querySelector('#installPath')
var msg = host.querySelector('#msg')
host.querySelector('#pickFolder').addEventListener('click', async function () {
var picked = await installerApi.pickFolder()
if (picked) input.value = picked
})
- host.querySelector('#confirm').addEventListener('click', async function () {
+ host.querySelector('#back').addEventListener('click', back)
+ host.querySelector('#next').addEventListener('click', async function () {
var result = await installerApi.validateInstallPath(input.value.trim())
if (!result.ok) {
msg.textContent = result.message || '경로가 유효하지 않습니다.'
@@ -209,7 +208,7 @@ function renderSubStep31(host, done) {
})
}
-function renderSubStep32(host, done) {
+function renderSubStep32(host, back, done) {
host.innerHTML =
'3-2. JDK 확인
' +
'JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 직접 폴더를 선택해도 됩니다.
' +
@@ -217,7 +216,7 @@ function renderSubStep32(host, done) {
'' +
'' +
'' +
- ''
+ ''
var input = host.querySelector('#jdkPath')
var msg = host.querySelector('#msg')
host.querySelector('#auto').addEventListener('click', async function () {
@@ -236,7 +235,8 @@ function renderSubStep32(host, done) {
var picked = await installerApi.pickFolder()
if (picked) input.value = picked
})
- host.querySelector('#confirm').addEventListener('click', function () {
+ host.querySelector('#back').addEventListener('click', back)
+ host.querySelector('#next').addEventListener('click', function () {
if (!input.value.trim()) {
msg.textContent = 'JDK 경로를 입력해 주세요.'
msg.classList.add('error')
@@ -255,35 +255,39 @@ function renderSubStep32(host, done) {
})()
}
-function renderSubStep33(host, done) {
+function renderSubStep33(host, back, done) {
host.innerHTML =
'3-3. 서버 다운로드 및 설치
' +
'선택한 음악퀴즈의 서버 파일을 다운로드합니다. 진행 상황은 하단 로그 뷰어에 표시됩니다.
' +
'대기 중
' +
'' +
- '' +
- '
3-3-3. EULA 동의
' +
- '
Minecraft EULA: 본 설치는 Minecraft End User License Agreement (https://www.minecraft.net/ko-kr/eula) 동의가 필요합니다. 동의 시 eula.txt가 새로 작성됩니다.
' +
- '
' +
- '
' +
- '
' +
'' +
- '
3-3-4. 램 검사
' +
+ '
램 검사
' +
'
검사 중...
' +
'
' +
- '
'
+ ''
var startBtn = host.querySelector('#startDownload')
var statusEl = host.querySelector('#downloadStatus')
- var eulaSection = host.querySelector('#eulaSection')
var ramSection = host.querySelector('#ramSection')
var ramMsg = host.querySelector('#ramMsg')
- var confirmBtn = host.querySelector('#confirm')
- var eulaCheck = host.querySelector('#eulaCheck')
- var eulaMsg = host.querySelector('#eulaMsg')
+ var nextBtn = host.querySelector('#next')
+
+ host.querySelector('#back').addEventListener('click', back)
+
+ // 이미 통과했던 상태 복원: 사용자가 다음→이전으로 돌아왔을 때 재다운로드 강요하지 않는다.
+ if (state.serverInstall.eulaAccepted && state.serverInstall.ram) {
+ statusEl.textContent = '다운로드 및 EULA 동의 완료.'
+ statusEl.classList.add('success')
+ showRamResult(state.serverInstall.ram)
+ nextBtn.disabled = false
+ }
startBtn.addEventListener('click', async function () {
startBtn.disabled = true
+ state.serverInstall.eulaAccepted = false
+ nextBtn.disabled = true
+ statusEl.classList.remove('success', 'error')
statusEl.textContent = '다운로드 중...'
try {
await installerApi.startServerInstall({
@@ -291,55 +295,123 @@ function renderSubStep33(host, done) {
installPath: state.serverInstall.path,
jdkPath: state.serverInstall.jdk
})
- statusEl.textContent = '다운로드 완료. EULA 동의가 필요합니다.'
- eulaSection.hidden = false
+ statusEl.textContent = 'EULA 동의가 필요합니다. 팝업을 확인해 주세요.'
+ var accepted = await openEulaPopup(state.serverInstall.path)
+ if (!accepted) {
+ statusEl.textContent = 'EULA 동의 실패. 다운로드를 취소합니다. "다운로드 시작"으로 다시 시도하세요.'
+ statusEl.classList.add('error')
+ startBtn.disabled = false
+ return
+ }
+ try {
+ await installerApi.acceptEula(state.serverInstall.path)
+ } catch (err) {
+ statusEl.textContent = 'EULA 저장 실패: ' + err.message
+ statusEl.classList.add('error')
+ startBtn.disabled = false
+ return
+ }
+ state.serverInstall.eulaAccepted = true
+ statusEl.textContent = '다운로드 및 EULA 동의 완료.'
+ statusEl.classList.add('success')
+ var ram = await installerApi.checkRam(state.selectedPackKey)
+ state.serverInstall.ram = ram
+ showRamResult(ram)
+ if (ram.decision === 'tooLow') return
+ nextBtn.disabled = false
} catch (err) {
statusEl.textContent = '다운로드 실패: ' + err.message
+ statusEl.classList.add('error')
startBtn.disabled = false
}
})
- eulaCheck.addEventListener('change', async function () {
- if (!eulaCheck.checked) return
- try {
- await installerApi.acceptEula(state.serverInstall.path)
- eulaMsg.textContent = 'EULA 동의 저장됨.'
- eulaMsg.classList.add('success')
- ramSection.hidden = false
- var result = await installerApi.checkRam(state.selectedPackKey)
- state.serverInstall.ram = result
- if (result.decision === 'tooLow') {
- ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 음악퀴즈 최소 요구치(' + (state.packs.find(function (p) { return p.key === state.selectedPackKey }).pack.serverMinRam) + 'MB)에 미치지 못합니다. 설치를 중단합니다.'
- ramMsg.classList.add('error')
- return
- }
- if (result.decision === 'minOk') {
- ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 권장치보다 부족합니다. 최소치(' + result.appliedRamMb + 'MB)로 진행합니다.'
- ramMsg.classList.add('warn')
- } else {
- ramMsg.textContent = '시스템 램(' + result.systemRamMb + 'MB) 충분. ' + result.appliedRamMb + 'MB로 설정.'
- ramMsg.classList.add('success')
- }
- confirmBtn.hidden = false
- } catch (err) {
- eulaMsg.textContent = 'EULA 저장 실패: ' + err.message
- eulaMsg.classList.add('error')
- }
+ nextBtn.addEventListener('click', function () {
+ if (!state.serverInstall.eulaAccepted) return
+ done()
})
- confirmBtn.addEventListener('click', function () {
- state.serverInstall.eulaAccepted = true
- done()
+ function showRamResult(result) {
+ ramSection.hidden = false
+ ramMsg.classList.remove('error', 'warn', 'success')
+ if (result.decision === 'tooLow') {
+ var pack = state.packs.find(function (p) { return p.key === state.selectedPackKey })
+ var minRam = pack ? pack.pack.serverMinRam : 0
+ ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 음악퀴즈 최소 요구치(' + minRam + 'MB)에 미치지 못합니다. 설치를 중단합니다.'
+ ramMsg.classList.add('error')
+ } else if (result.decision === 'minOk') {
+ ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 권장치보다 부족합니다. 최소치(' + result.appliedRamMb + 'MB)로 진행합니다.'
+ ramMsg.classList.add('warn')
+ } else {
+ ramMsg.textContent = '시스템 램(' + result.systemRamMb + 'MB) 충분. ' + result.appliedRamMb + 'MB로 설정.'
+ ramMsg.classList.add('success')
+ }
+ }
+}
+
+// EULA 동의 팝업. resolve(true) = 동의, resolve(false) = 비동의/창 닫힘.
+async function openEulaPopup(installPath) {
+ var read = await installerApi.readEula(installPath)
+ var bodyHtml = ''
+ if (read.exists) {
+ bodyHtml = '서버 파일에 포함된 eula.txt 내용입니다.
' +
+ '' + escapeHtml(read.content) + '
'
+ } else {
+ var fetched = await installerApi.fetchMinecraftEula()
+ if (fetched.html) {
+ bodyHtml = '서버 파일에 eula.txt가 없어 minecraft.net의 EULA를 표시합니다 (' + fetched.url + ').
' +
+ ''
+ } else {
+ bodyHtml = 'EULA 페이지를 불러올 수 없습니다. 직접 확인해 주세요: https://www.minecraft.net/en-us/eula
'
+ }
+ }
+ return new Promise(function (resolve) {
+ var overlay = document.createElement('div')
+ overlay.className = 'modalOverlay'
+ overlay.innerHTML =
+ '' +
+ '
' +
+ '
' + bodyHtml + '
' +
+ '
' +
+ '
'
+ document.body.appendChild(overlay)
+
+ var settled = false
+ function close(result) {
+ if (settled) return
+ settled = true
+ overlay.remove()
+ resolve(result)
+ }
+ overlay.querySelector('[data-action="accept"]').addEventListener('click', function () { close(true) })
+ overlay.querySelector('[data-action="reject"]').addEventListener('click', function () { close(false) })
+ overlay.querySelector('.modalClose').addEventListener('click', function () { close(false) })
+ overlay.addEventListener('click', function (event) {
+ if (event.target === overlay) close(false)
+ })
})
}
-function renderSubStep34(host, done) {
+function escapeHtml(text) {
+ return String(text).replace(/[&<>"']/g, function (ch) {
+ return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]
+ })
+}
+
+function escapeAttr(text) {
+ return String(text).replace(/&/g, '&').replace(/"/g, '"')
+}
+
+function renderSubStep34(host, back, done) {
host.innerHTML =
'3-4. 서버 설정 편집
' +
'로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.
' +
'' +
'' +
- ''
+ ''
host.querySelector('#open').addEventListener('click', async function () {
var msg = host.querySelector('#editorMsg')
try {
@@ -350,19 +422,22 @@ function renderSubStep34(host, done) {
msg.classList.add('error')
}
})
- host.querySelector('#confirm').addEventListener('click', done)
+ host.querySelector('#back').addEventListener('click', back)
+ host.querySelector('#next').addEventListener('click', done)
}
-function renderSubStep35(host, done) {
+function renderSubStep35(host, back, done) {
host.innerHTML =
'3-5. 포트포워딩 점검
' +
'서버의 외부 접근 가능 여부를 확인합니다. UPnP를 시도해도 안 되면 직접 포트포워딩을 안내합니다.
' +
'' +
'' +
'' +
- ''
+ ''
var resultMsg = host.querySelector('#resultMsg')
- var confirmBtn = host.querySelector('#confirm')
+ var nextBtn = host.querySelector('#next')
+ if (state.serverInstall.portStatus) nextBtn.disabled = false
+ host.querySelector('#back').addEventListener('click', back)
host.querySelector('#run').addEventListener('click', async function () {
var port = Number(host.querySelector('#port').value) || 25565
resultMsg.textContent = '확인 중...'
@@ -379,9 +454,9 @@ function renderSubStep35(host, done) {
'
외부 IP: ' + (result.externalIp || '확인 불가') + ', 포트: ' + result.port + ''
resultMsg.classList.add('warn')
}
- confirmBtn.hidden = false
+ nextBtn.disabled = false
})
- confirmBtn.addEventListener('click', done)
+ nextBtn.addEventListener('click', done)
}
function renderStep4() {
@@ -392,25 +467,16 @@ function renderStep4() {
section.className = 'page'
section.innerHTML =
'4단계. 유저 클라이언트 설정
' +
- '' +
- '
'
+ ''
pageHost.appendChild(section)
var subHost = section.querySelector('#subHost')
- section.querySelector('#back').addEventListener('click', function () {
- if (state.mode === 'multi') renderStep3(); else renderStep2()
- })
+ function backToPrevStep() { if (state.mode === 'multi') renderStep3(); else renderStep2() }
- function show41() {
- subHost.innerHTML = ''
- renderSubStep41(subHost, pack, show42)
- }
- function show42() {
- subHost.innerHTML = ''
- renderSubStep42(subHost, show43)
- }
+ function show41() { subHost.innerHTML = ''; renderSubStep41(subHost, pack, backToPrevStep, show42) }
+ function show42() { subHost.innerHTML = ''; renderSubStep42(subHost, show41, show43) }
function show43() {
subHost.innerHTML = ''
- renderSubStep43(subHost, function () {
+ renderSubStep43(subHost, show42, function () {
state.stepDone[4] = true
renderStep5()
})
@@ -418,7 +484,7 @@ function renderStep4() {
show41()
}
-function renderSubStep41(host, pack, done) {
+function renderSubStep41(host, pack, back, done) {
var platformType = pack ? pack.pack.platform.type : 'vanilla'
if (platformType === 'vanilla') {
state.client.installPlatform = false
@@ -426,7 +492,8 @@ function renderSubStep41(host, pack, done) {
'4-1. 모드 플랫폼
' +
'선택한 음악퀴즈의 플랫폼: vanilla
' +
'바닐라이므로 별도 설치는 필요 없습니다.
' +
- '
'
+ ''
+ host.querySelector('#back').addEventListener('click', back)
host.querySelector('#next').addEventListener('click', done)
return
}
@@ -438,7 +505,7 @@ function renderSubStep41(host, pack, done) {
'' +
'' +
'' +
- '
'
+ ''
var nextBtn = host.querySelector('#next')
var choiceButtons = host.querySelectorAll('[data-choice]')
@@ -462,19 +529,22 @@ function renderSubStep41(host, pack, done) {
applyChoice(state.client.installPlatform ? 'install' : 'skip')
}
+ host.querySelector('#back').addEventListener('click', back)
nextBtn.addEventListener('click', done)
}
-function renderSubStep42(host, done) {
+function renderSubStep42(host, back, done) {
host.innerHTML =
'4-2. 모드/리소스팩 다운로드 및 launcher_profiles 갱신
' +
'%appdata%\\.mc_custom 에 모드와 리소스팩을 설치하고, launcher_profiles.json에 프로필을 등록합니다.
' +
'' +
'' +
- '
'
+ ''
var runBtn = host.querySelector('#run')
var msg = host.querySelector('#msg')
var nextBtn = host.querySelector('#next')
+ if (state.client.clientInstalled) nextBtn.disabled = false
+ host.querySelector('#back').addEventListener('click', back)
runBtn.addEventListener('click', async function () {
runBtn.disabled = true
msg.textContent = '설치 중...'
@@ -486,7 +556,8 @@ function renderSubStep42(host, done) {
})
msg.textContent = '클라이언트 설치 완료.'
msg.classList.add('success')
- nextBtn.hidden = false
+ state.client.clientInstalled = true
+ nextBtn.disabled = false
} catch (err) {
msg.textContent = '설치 실패: ' + err.message
msg.classList.add('error')
@@ -496,9 +567,13 @@ function renderSubStep42(host, done) {
nextBtn.addEventListener('click', done)
}
-function renderSubStep43(host, done) {
- host.innerHTML = '4-3. 완료 확인
모드와 리소스팩이 .mc_custom에 설치되어 있고, launcher_profiles.json도 갱신되었습니다.
'
- host.querySelector('#confirm').addEventListener('click', done)
+function renderSubStep43(host, back, done) {
+ host.innerHTML =
+ '4-3. 완료 확인
' +
+ '모드와 리소스팩이 .mc_custom에 설치되어 있고, launcher_profiles.json도 갱신되었습니다.
' +
+ ''
+ host.querySelector('#back').addEventListener('click', back)
+ host.querySelector('#next').addEventListener('click', done)
}
function renderStep5() {
diff --git a/installer/styles.css b/installer/styles.css
index f686907..848eba9 100644
--- a/installer/styles.css
+++ b/installer/styles.css
@@ -155,6 +155,67 @@ main {
.toggleRow { display: flex; align-items: center; gap: 10px; margin: 8px 0; }
+.modalOverlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.modalCard {
+ background: var(--bg-alt);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ width: min(720px, 92vw);
+ max-height: 86vh;
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+ overflow: hidden;
+}
+
+.modalCard > header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border);
+}
+
+.modalCard > header h3 { margin: 0; font-size: 16px; }
+.modalClose { background: transparent; border: none; color: var(--text-muted); font-size: 22px; cursor: pointer; }
+.modalClose:hover { color: var(--text); }
+
+.modalBody {
+ padding: 16px;
+ overflow-y: auto;
+}
+
+.modalCard > footer { padding: 12px 16px; border-top: 1px solid var(--border); margin: 0; }
+
+.eulaPre {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 12px;
+ font-family: 'Consolas', monospace;
+ font-size: 12px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ max-height: 50vh;
+ overflow-y: auto;
+}
+
+.eulaFrame {
+ width: 100%;
+ height: 50vh;
+ background: white;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+
.statusBadge { display: inline-flex; padding: 3px 10px; border-radius: 999px; font-size: 12px; }
.statusBadge.pending { background: #2c3849; color: var(--text-muted); }
.statusBadge.ok { background: rgba(63, 185, 80, 0.2); color: var(--success); }
diff --git a/src/installer/main.ts b/src/installer/main.ts
index b7ef68a..9845ed9 100644
--- a/src/installer/main.ts
+++ b/src/installer/main.ts
@@ -295,11 +295,30 @@ ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) =
sendLog(`서버 설치 경로: ${installPath}`)
await downloadServerZip(pack.pack, installPath)
+ // 다운로드한 zip에 들어있을 수 있는 eula.txt를 그대로 보존한다.
+ // 동의 흐름은 renderer가 별도 IPC로 읽고 동의 시 덮어쓴다.
+})
- const eulaPath = path.join(installPath, 'eula.txt')
- if (fs.existsSync(eulaPath)) {
- await fsp.unlink(eulaPath)
- sendLog('기존 eula.txt 삭제, 사용자 동의를 다시 받습니다.')
+ipcMain.handle('server:readEula', async (_event, installPath: string): Promise<{ exists: boolean; content: string }> => {
+ if (!installPath) return { exists: false, content: '' }
+ const target = path.join(path.resolve(installPath), 'eula.txt')
+ try {
+ const content = await fsp.readFile(target, 'utf8')
+ return { exists: true, content }
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { exists: false, content: '' }
+ throw error
+ }
+})
+
+ipcMain.handle('server:fetchMinecraftEula', async (): Promise<{ url: string; html: string }> => {
+ const url = 'https://www.minecraft.net/en-us/eula'
+ try {
+ const buffer = await fetchBuffer(url)
+ return { url, html: buffer.toString('utf8') }
+ } catch (error) {
+ sendLog(`Minecraft EULA 페이지 조회 실패: ${(error as Error).message}`)
+ return { url, html: '' }
}
})
diff --git a/src/installer/preload.ts b/src/installer/preload.ts
index f4a9110..58112a2 100644
--- a/src/installer/preload.ts
+++ b/src/installer/preload.ts
@@ -19,6 +19,10 @@ const api = {
// 3-3
startServerInstall: (payload: ServerInstallPayload): Promise =>
ipcRenderer.invoke('server:install', payload),
+ readEula: (installPath: string): Promise<{ exists: boolean; content: string }> =>
+ ipcRenderer.invoke('server:readEula', installPath),
+ fetchMinecraftEula: (): Promise<{ url: string; html: string }> =>
+ ipcRenderer.invoke('server:fetchMinecraftEula'),
acceptEula: (installPath: string): Promise =>
ipcRenderer.invoke('server:acceptEula', installPath),
checkRam: (packKey: string): Promise =>