Sub-step 이전/다음 navigation + EULA popup flow

- Each 3-x and 4-x sub-step now has its own [이전][다음] action row at the bottom; 이전 returns to the previous sub-step (or previous main step on the first one) so users can move freely both directions
- 3-3 EULA: replace the inline checkbox with a modal popup. After server zip downloads, the renderer reads eula.txt via server:readEula; if absent, falls back to the live minecraft.net/en-us/eula HTML via server:fetchMinecraftEula and shows it in a sandboxed iframe
- Popup buttons: 동의 → server:acceptEula and proceed to RAM check; 비동의 / X / overlay click → "EULA 동의 실패. 다운로드를 취소합니다." and re-enable 다운로드 시작 for retry
- main.ts: stop auto-deleting eula.txt after server zip extraction so the popup can read whatever the zip provided
- 4-2 install completion now keeps a state.client.clientInstalled flag so backing into 4-2 doesn't force a re-install

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 03:25:10 +09:00
parent 6c042503d6
commit 01a34e08aa
4 changed files with 252 additions and 93 deletions

View File

@@ -160,19 +160,17 @@ function renderStep3() {
section.className = 'page' section.className = 'page'
section.innerHTML = section.innerHTML =
'<h2>3단계. 서버 관련 설정</h2>' + '<h2>3단계. 서버 관련 설정</h2>' +
'<div class="subStep" id="subHost"></div>' + '<div class="subStep" id="subHost"></div>'
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><span></span></div>'
pageHost.appendChild(section) pageHost.appendChild(section)
var subHost = section.querySelector('#subHost') var subHost = section.querySelector('#subHost')
section.querySelector('#back').addEventListener('click', renderStep2)
function show31() { subHost.innerHTML = ''; renderSubStep31(subHost, show32) } function show31() { subHost.innerHTML = ''; renderSubStep31(subHost, renderStep2, show32) }
function show32() { subHost.innerHTML = ''; renderSubStep32(subHost, show33) } function show32() { subHost.innerHTML = ''; renderSubStep32(subHost, show31, show33) }
function show33() { subHost.innerHTML = ''; renderSubStep33(subHost, show34) } function show33() { subHost.innerHTML = ''; renderSubStep33(subHost, show32, show34) }
function show34() { subHost.innerHTML = ''; renderSubStep34(subHost, show35) } function show34() { subHost.innerHTML = ''; renderSubStep34(subHost, show33, show35) }
function show35() { function show35() {
subHost.innerHTML = '' subHost.innerHTML = ''
renderSubStep35(subHost, function () { renderSubStep35(subHost, show34, function () {
state.stepDone[3] = true state.stepDone[3] = true
renderStep4() renderStep4()
}) })
@@ -180,21 +178,22 @@ function renderStep3() {
show31() show31()
} }
function renderSubStep31(host, done) { function renderSubStep31(host, back, done) {
host.innerHTML = host.innerHTML =
'<h3>3-1. 서버 설치 경로</h3>' + '<h3>3-1. 서버 설치 경로</h3>' +
'<p class="formMessage">서버를 생성할 폴더를 선택하세요. 경로에 한글이 포함되면 안 됩니다.</p>' + '<p class="formMessage">서버를 생성할 폴더를 선택하세요. 경로에 한글이 포함되면 안 됩니다.</p>' +
'<div class="fieldset"><label><input id="installPath" type="text" placeholder="C:\\MusicQuizServer" value="' + (state.serverInstall.path || '') + '" /></label>' + '<div class="fieldset"><label><input id="installPath" type="text" placeholder="C:\\MusicQuizServer" value="' + (state.serverInstall.path || '') + '" /></label>' +
'<button class="secondaryBtn" id="pickFolder">폴더 선택</button></div>' + '<button class="secondaryBtn" id="pickFolder">폴더 선택</button></div>' +
'<div class="formMessage" id="msg"></div>' + '<div class="formMessage" id="msg"></div>' +
'<button class="primaryBtn" id="confirm">확인</button>' '<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
var input = host.querySelector('#installPath') var input = host.querySelector('#installPath')
var msg = host.querySelector('#msg') var msg = host.querySelector('#msg')
host.querySelector('#pickFolder').addEventListener('click', async function () { host.querySelector('#pickFolder').addEventListener('click', async function () {
var picked = await installerApi.pickFolder() var picked = await installerApi.pickFolder()
if (picked) input.value = picked 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()) var result = await installerApi.validateInstallPath(input.value.trim())
if (!result.ok) { if (!result.ok) {
msg.textContent = result.message || '경로가 유효하지 않습니다.' msg.textContent = result.message || '경로가 유효하지 않습니다.'
@@ -209,7 +208,7 @@ function renderSubStep31(host, done) {
}) })
} }
function renderSubStep32(host, done) { function renderSubStep32(host, back, done) {
host.innerHTML = host.innerHTML =
'<h3>3-2. JDK 확인</h3>' + '<h3>3-2. JDK 확인</h3>' +
'<p class="formMessage">JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 직접 폴더를 선택해도 됩니다.</p>' + '<p class="formMessage">JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 직접 폴더를 선택해도 됩니다.</p>' +
@@ -217,7 +216,7 @@ function renderSubStep32(host, done) {
'<button class="secondaryBtn" id="pickJdk">폴더 선택</button>' + '<button class="secondaryBtn" id="pickJdk">폴더 선택</button>' +
'<button class="secondaryBtn" id="auto">자동 탐색</button></div>' + '<button class="secondaryBtn" id="auto">자동 탐색</button></div>' +
'<div class="formMessage" id="msg"></div>' + '<div class="formMessage" id="msg"></div>' +
'<button class="primaryBtn" id="confirm">확인</button>' '<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
var input = host.querySelector('#jdkPath') var input = host.querySelector('#jdkPath')
var msg = host.querySelector('#msg') var msg = host.querySelector('#msg')
host.querySelector('#auto').addEventListener('click', async function () { host.querySelector('#auto').addEventListener('click', async function () {
@@ -236,7 +235,8 @@ function renderSubStep32(host, done) {
var picked = await installerApi.pickFolder() var picked = await installerApi.pickFolder()
if (picked) input.value = picked 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()) { if (!input.value.trim()) {
msg.textContent = 'JDK 경로를 입력해 주세요.' msg.textContent = 'JDK 경로를 입력해 주세요.'
msg.classList.add('error') msg.classList.add('error')
@@ -255,35 +255,39 @@ function renderSubStep32(host, done) {
})() })()
} }
function renderSubStep33(host, done) { function renderSubStep33(host, back, done) {
host.innerHTML = host.innerHTML =
'<h3>3-3. 서버 다운로드 및 설치</h3>' + '<h3>3-3. 서버 다운로드 및 설치</h3>' +
'<p class="formMessage">선택한 음악퀴즈의 서버 파일을 다운로드합니다. 진행 상황은 하단 로그 뷰어에 표시됩니다.</p>' + '<p class="formMessage">선택한 음악퀴즈의 서버 파일을 다운로드합니다. 진행 상황은 하단 로그 뷰어에 표시됩니다.</p>' +
'<div class="formMessage" id="downloadStatus">대기 중</div>' + '<div class="formMessage" id="downloadStatus">대기 중</div>' +
'<button class="primaryBtn" id="startDownload">다운로드 시작</button>' + '<button class="primaryBtn" id="startDownload">다운로드 시작</button>' +
'<div id="eulaSection" hidden style="margin-top:14px;">' +
'<h3>3-3-3. EULA 동의</h3>' +
'<div class="eulaBox">Minecraft EULA: 본 설치는 Minecraft End User License Agreement (https://www.minecraft.net/ko-kr/eula) 동의가 필요합니다. 동의 시 eula.txt가 새로 작성됩니다.</div>' +
'<label class="toggleRow"><input id="eulaCheck" type="checkbox" /> Minecraft EULA에 동의합니다.</label>' +
'<div class="formMessage" id="eulaMsg"></div>' +
'</div>' +
'<div id="ramSection" hidden style="margin-top:14px;">' + '<div id="ramSection" hidden style="margin-top:14px;">' +
'<h3>3-3-4. 램 검사</h3>' + '<h4>램 검사</h4>' +
'<div class="formMessage" id="ramMsg">검사 중...</div>' + '<div class="formMessage" id="ramMsg">검사 중...</div>' +
'</div>' + '</div>' +
'<div class="actionRow"><span></span><button class="primaryBtn" id="confirm" hidden>다음</button></div>' '<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
var startBtn = host.querySelector('#startDownload') var startBtn = host.querySelector('#startDownload')
var statusEl = host.querySelector('#downloadStatus') var statusEl = host.querySelector('#downloadStatus')
var eulaSection = host.querySelector('#eulaSection')
var ramSection = host.querySelector('#ramSection') var ramSection = host.querySelector('#ramSection')
var ramMsg = host.querySelector('#ramMsg') var ramMsg = host.querySelector('#ramMsg')
var confirmBtn = host.querySelector('#confirm') var nextBtn = host.querySelector('#next')
var eulaCheck = host.querySelector('#eulaCheck')
var eulaMsg = host.querySelector('#eulaMsg') 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.addEventListener('click', async function () {
startBtn.disabled = true startBtn.disabled = true
state.serverInstall.eulaAccepted = false
nextBtn.disabled = true
statusEl.classList.remove('success', 'error')
statusEl.textContent = '다운로드 중...' statusEl.textContent = '다운로드 중...'
try { try {
await installerApi.startServerInstall({ await installerApi.startServerInstall({
@@ -291,55 +295,123 @@ function renderSubStep33(host, done) {
installPath: state.serverInstall.path, installPath: state.serverInstall.path,
jdkPath: state.serverInstall.jdk jdkPath: state.serverInstall.jdk
}) })
statusEl.textContent = '다운로드 완료. EULA 동의가 필요합니다.' statusEl.textContent = 'EULA 동의가 필요합니다. 팝업을 확인해 주세요.'
eulaSection.hidden = false 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) { } catch (err) {
statusEl.textContent = '다운로드 실패: ' + err.message statusEl.textContent = '다운로드 실패: ' + err.message
statusEl.classList.add('error')
startBtn.disabled = false startBtn.disabled = false
} }
}) })
eulaCheck.addEventListener('change', async function () { nextBtn.addEventListener('click', function () {
if (!eulaCheck.checked) return if (!state.serverInstall.eulaAccepted) return
try { done()
await installerApi.acceptEula(state.serverInstall.path) })
eulaMsg.textContent = 'EULA 동의 저장됨.'
eulaMsg.classList.add('success') function showRamResult(result) {
ramSection.hidden = false ramSection.hidden = false
var result = await installerApi.checkRam(state.selectedPackKey) ramMsg.classList.remove('error', 'warn', 'success')
state.serverInstall.ram = result
if (result.decision === 'tooLow') { if (result.decision === 'tooLow') {
ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 음악퀴즈 최소 요구치(' + (state.packs.find(function (p) { return p.key === state.selectedPackKey }).pack.serverMinRam) + 'MB)에 미치지 못합니다. 설치를 중단합니다.' 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') ramMsg.classList.add('error')
return } else if (result.decision === 'minOk') {
}
if (result.decision === 'minOk') {
ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 권장치보다 부족합니다. 최소치(' + result.appliedRamMb + 'MB)로 진행합니다.' ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 권장치보다 부족합니다. 최소치(' + result.appliedRamMb + 'MB)로 진행합니다.'
ramMsg.classList.add('warn') ramMsg.classList.add('warn')
} else { } else {
ramMsg.textContent = '시스템 램(' + result.systemRamMb + 'MB) 충분. ' + result.appliedRamMb + 'MB로 설정.' ramMsg.textContent = '시스템 램(' + result.systemRamMb + 'MB) 충분. ' + result.appliedRamMb + 'MB로 설정.'
ramMsg.classList.add('success') ramMsg.classList.add('success')
} }
confirmBtn.hidden = false
} catch (err) {
eulaMsg.textContent = 'EULA 저장 실패: ' + err.message
eulaMsg.classList.add('error')
} }
}) }
confirmBtn.addEventListener('click', function () { // EULA 동의 팝업. resolve(true) = 동의, resolve(false) = 비동의/창 닫힘.
state.serverInstall.eulaAccepted = true async function openEulaPopup(installPath) {
done() var read = await installerApi.readEula(installPath)
var bodyHtml = ''
if (read.exists) {
bodyHtml = '<p class="formMessage">서버 파일에 포함된 eula.txt 내용입니다.</p>' +
'<pre class="eulaPre">' + escapeHtml(read.content) + '</pre>'
} else {
var fetched = await installerApi.fetchMinecraftEula()
if (fetched.html) {
bodyHtml = '<p class="formMessage">서버 파일에 eula.txt가 없어 minecraft.net의 EULA를 표시합니다 (<a href="' + fetched.url + '" target="_blank">' + fetched.url + '</a>).</p>' +
'<iframe class="eulaFrame" sandbox srcdoc="' + escapeAttr(fetched.html) + '"></iframe>'
} else {
bodyHtml = '<p class="formMessage error">EULA 페이지를 불러올 수 없습니다. 직접 확인해 주세요: <a href="https://www.minecraft.net/en-us/eula" target="_blank">https://www.minecraft.net/en-us/eula</a></p>'
}
}
return new Promise(function (resolve) {
var overlay = document.createElement('div')
overlay.className = 'modalOverlay'
overlay.innerHTML =
'<div class="modalCard" role="dialog" aria-modal="true">' +
'<header><h3>Minecraft EULA 동의</h3><button type="button" class="modalClose" aria-label="닫기">×</button></header>' +
'<div class="modalBody">' + bodyHtml + '</div>' +
'<footer class="actionRow">' +
'<button type="button" class="secondaryBtn" data-action="reject">비동의</button>' +
'<button type="button" class="primaryBtn" data-action="accept">동의</button>' +
'</footer>' +
'</div>'
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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]
})
}
function escapeAttr(text) {
return String(text).replace(/&/g, '&amp;').replace(/"/g, '&quot;')
}
function renderSubStep34(host, back, done) {
host.innerHTML = host.innerHTML =
'<h3>3-4. 서버 설정 편집</h3>' + '<h3>3-4. 서버 설정 편집</h3>' +
'<p class="formMessage">로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.</p>' + '<p class="formMessage">로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.</p>' +
'<button class="secondaryBtn" id="open">편집기 열기</button>' + '<button class="secondaryBtn" id="open">편집기 열기</button>' +
'<div class="formMessage" id="editorMsg"></div>' + '<div class="formMessage" id="editorMsg"></div>' +
'<button class="primaryBtn" id="confirm">확인</button>' '<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
host.querySelector('#open').addEventListener('click', async function () { host.querySelector('#open').addEventListener('click', async function () {
var msg = host.querySelector('#editorMsg') var msg = host.querySelector('#editorMsg')
try { try {
@@ -350,19 +422,22 @@ function renderSubStep34(host, done) {
msg.classList.add('error') 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 = host.innerHTML =
'<h3>3-5. 포트포워딩 점검</h3>' + '<h3>3-5. 포트포워딩 점검</h3>' +
'<p class="formMessage">서버의 외부 접근 가능 여부를 확인합니다. UPnP를 시도해도 안 되면 직접 포트포워딩을 안내합니다.</p>' + '<p class="formMessage">서버의 외부 접근 가능 여부를 확인합니다. UPnP를 시도해도 안 되면 직접 포트포워딩을 안내합니다.</p>' +
'<div class="fieldset"><label>포트 <input id="port" type="text" value="25565" /></label></div>' + '<div class="fieldset"><label>포트 <input id="port" type="text" value="25565" /></label></div>' +
'<button class="secondaryBtn" id="run">검사 시작</button>' + '<button class="secondaryBtn" id="run">검사 시작</button>' +
'<div class="formMessage" id="resultMsg"></div>' + '<div class="formMessage" id="resultMsg"></div>' +
'<button class="primaryBtn" id="confirm" hidden>확인</button>' '<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
var resultMsg = host.querySelector('#resultMsg') 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 () { host.querySelector('#run').addEventListener('click', async function () {
var port = Number(host.querySelector('#port').value) || 25565 var port = Number(host.querySelector('#port').value) || 25565
resultMsg.textContent = '확인 중...' resultMsg.textContent = '확인 중...'
@@ -379,9 +454,9 @@ function renderSubStep35(host, done) {
'<br><small>외부 IP: ' + (result.externalIp || '확인 불가') + ', 포트: ' + result.port + '</small>' '<br><small>외부 IP: ' + (result.externalIp || '확인 불가') + ', 포트: ' + result.port + '</small>'
resultMsg.classList.add('warn') resultMsg.classList.add('warn')
} }
confirmBtn.hidden = false nextBtn.disabled = false
}) })
confirmBtn.addEventListener('click', done) nextBtn.addEventListener('click', done)
} }
function renderStep4() { function renderStep4() {
@@ -392,25 +467,16 @@ function renderStep4() {
section.className = 'page' section.className = 'page'
section.innerHTML = section.innerHTML =
'<h2>4단계. 유저 클라이언트 설정</h2>' + '<h2>4단계. 유저 클라이언트 설정</h2>' +
'<div class="subStep" id="subHost"></div>' + '<div class="subStep" id="subHost"></div>'
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><span></span></div>'
pageHost.appendChild(section) pageHost.appendChild(section)
var subHost = section.querySelector('#subHost') var subHost = section.querySelector('#subHost')
section.querySelector('#back').addEventListener('click', function () { function backToPrevStep() { if (state.mode === 'multi') renderStep3(); else renderStep2() }
if (state.mode === 'multi') renderStep3(); else renderStep2()
})
function show41() { function show41() { subHost.innerHTML = ''; renderSubStep41(subHost, pack, backToPrevStep, show42) }
subHost.innerHTML = '' function show42() { subHost.innerHTML = ''; renderSubStep42(subHost, show41, show43) }
renderSubStep41(subHost, pack, show42)
}
function show42() {
subHost.innerHTML = ''
renderSubStep42(subHost, show43)
}
function show43() { function show43() {
subHost.innerHTML = '' subHost.innerHTML = ''
renderSubStep43(subHost, function () { renderSubStep43(subHost, show42, function () {
state.stepDone[4] = true state.stepDone[4] = true
renderStep5() renderStep5()
}) })
@@ -418,7 +484,7 @@ function renderStep4() {
show41() show41()
} }
function renderSubStep41(host, pack, done) { function renderSubStep41(host, pack, back, done) {
var platformType = pack ? pack.pack.platform.type : 'vanilla' var platformType = pack ? pack.pack.platform.type : 'vanilla'
if (platformType === 'vanilla') { if (platformType === 'vanilla') {
state.client.installPlatform = false state.client.installPlatform = false
@@ -426,7 +492,8 @@ function renderSubStep41(host, pack, done) {
'<h3>4-1. 모드 플랫폼</h3>' + '<h3>4-1. 모드 플랫폼</h3>' +
'<p class="formMessage">선택한 음악퀴즈의 플랫폼: <strong>vanilla</strong></p>' + '<p class="formMessage">선택한 음악퀴즈의 플랫폼: <strong>vanilla</strong></p>' +
'<p class="formMessage">바닐라이므로 별도 설치는 필요 없습니다.</p>' + '<p class="formMessage">바닐라이므로 별도 설치는 필요 없습니다.</p>' +
'<div class="actionRow"><span></span><button class="primaryBtn" id="next">다음</button></div>' '<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
host.querySelector('#back').addEventListener('click', back)
host.querySelector('#next').addEventListener('click', done) host.querySelector('#next').addEventListener('click', done)
return return
} }
@@ -438,7 +505,7 @@ function renderSubStep41(host, pack, done) {
'<button type="button" data-choice="install"><strong>권장 플랫폼 설치</strong><br><small>' + platformType + ' 설치파일을 함께 다운로드해 4-2 설치 시작 시 함께 설치됩니다.</small></button>' + '<button type="button" data-choice="install"><strong>권장 플랫폼 설치</strong><br><small>' + platformType + ' 설치파일을 함께 다운로드해 4-2 설치 시작 시 함께 설치됩니다.</small></button>' +
'<button type="button" data-choice="skip"><strong>기본 마인크래프트로 설치</strong><br><small>플랫폼은 설치하지 않고 바닐라로 진행합니다.</small></button>' + '<button type="button" data-choice="skip"><strong>기본 마인크래프트로 설치</strong><br><small>플랫폼은 설치하지 않고 바닐라로 진행합니다.</small></button>' +
'</div>' + '</div>' +
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>다음</button></div>' '<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
var nextBtn = host.querySelector('#next') var nextBtn = host.querySelector('#next')
var choiceButtons = host.querySelectorAll('[data-choice]') var choiceButtons = host.querySelectorAll('[data-choice]')
@@ -462,19 +529,22 @@ function renderSubStep41(host, pack, done) {
applyChoice(state.client.installPlatform ? 'install' : 'skip') applyChoice(state.client.installPlatform ? 'install' : 'skip')
} }
host.querySelector('#back').addEventListener('click', back)
nextBtn.addEventListener('click', done) nextBtn.addEventListener('click', done)
} }
function renderSubStep42(host, done) { function renderSubStep42(host, back, done) {
host.innerHTML = host.innerHTML =
'<h3>4-2. 모드/리소스팩 다운로드 및 launcher_profiles 갱신</h3>' + '<h3>4-2. 모드/리소스팩 다운로드 및 launcher_profiles 갱신</h3>' +
'<p class="formMessage">%appdata%\\.mc_custom 에 모드와 리소스팩을 설치하고, launcher_profiles.json에 프로필을 등록합니다.</p>' + '<p class="formMessage">%appdata%\\.mc_custom 에 모드와 리소스팩을 설치하고, launcher_profiles.json에 프로필을 등록합니다.</p>' +
'<button class="primaryBtn" id="run">설치 시작</button>' + '<button class="primaryBtn" id="run">설치 시작</button>' +
'<div class="formMessage" id="msg"></div>' + '<div class="formMessage" id="msg"></div>' +
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" hidden>다음</button></div>' '<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
var runBtn = host.querySelector('#run') var runBtn = host.querySelector('#run')
var msg = host.querySelector('#msg') var msg = host.querySelector('#msg')
var nextBtn = host.querySelector('#next') var nextBtn = host.querySelector('#next')
if (state.client.clientInstalled) nextBtn.disabled = false
host.querySelector('#back').addEventListener('click', back)
runBtn.addEventListener('click', async function () { runBtn.addEventListener('click', async function () {
runBtn.disabled = true runBtn.disabled = true
msg.textContent = '설치 중...' msg.textContent = '설치 중...'
@@ -486,7 +556,8 @@ function renderSubStep42(host, done) {
}) })
msg.textContent = '클라이언트 설치 완료.' msg.textContent = '클라이언트 설치 완료.'
msg.classList.add('success') msg.classList.add('success')
nextBtn.hidden = false state.client.clientInstalled = true
nextBtn.disabled = false
} catch (err) { } catch (err) {
msg.textContent = '설치 실패: ' + err.message msg.textContent = '설치 실패: ' + err.message
msg.classList.add('error') msg.classList.add('error')
@@ -496,9 +567,13 @@ function renderSubStep42(host, done) {
nextBtn.addEventListener('click', done) nextBtn.addEventListener('click', done)
} }
function renderSubStep43(host, done) { function renderSubStep43(host, back, done) {
host.innerHTML = '<h3>4-3. 완료 확인</h3><p class="formMessage">모드와 리소스팩이 .mc_custom에 설치되어 있고, launcher_profiles.json도 갱신되었습니다.</p><button class="primaryBtn" id="confirm">5단계로</button>' host.innerHTML =
host.querySelector('#confirm').addEventListener('click', done) '<h3>4-3. 완료 확인</h3>' +
'<p class="formMessage">모드와 리소스팩이 .mc_custom에 설치되어 있고, launcher_profiles.json도 갱신되었습니다.</p>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">5단계로</button></div>'
host.querySelector('#back').addEventListener('click', back)
host.querySelector('#next').addEventListener('click', done)
} }
function renderStep5() { function renderStep5() {

View File

@@ -155,6 +155,67 @@ main {
.toggleRow { display: flex; align-items: center; gap: 10px; margin: 8px 0; } .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 { display: inline-flex; padding: 3px 10px; border-radius: 999px; font-size: 12px; }
.statusBadge.pending { background: #2c3849; color: var(--text-muted); } .statusBadge.pending { background: #2c3849; color: var(--text-muted); }
.statusBadge.ok { background: rgba(63, 185, 80, 0.2); color: var(--success); } .statusBadge.ok { background: rgba(63, 185, 80, 0.2); color: var(--success); }

View File

@@ -295,11 +295,30 @@ ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) =
sendLog(`서버 설치 경로: ${installPath}`) sendLog(`서버 설치 경로: ${installPath}`)
await downloadServerZip(pack.pack, installPath) await downloadServerZip(pack.pack, installPath)
// 다운로드한 zip에 들어있을 수 있는 eula.txt를 그대로 보존한다.
// 동의 흐름은 renderer가 별도 IPC로 읽고 동의 시 덮어쓴다.
})
const eulaPath = path.join(installPath, 'eula.txt') ipcMain.handle('server:readEula', async (_event, installPath: string): Promise<{ exists: boolean; content: string }> => {
if (fs.existsSync(eulaPath)) { if (!installPath) return { exists: false, content: '' }
await fsp.unlink(eulaPath) const target = path.join(path.resolve(installPath), 'eula.txt')
sendLog('기존 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: '' }
} }
}) })

View File

@@ -19,6 +19,10 @@ const api = {
// 3-3 // 3-3
startServerInstall: (payload: ServerInstallPayload): Promise<void> => startServerInstall: (payload: ServerInstallPayload): Promise<void> =>
ipcRenderer.invoke('server:install', payload), 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<void> => acceptEula: (installPath: string): Promise<void> =>
ipcRenderer.invoke('server:acceptEula', installPath), ipcRenderer.invoke('server:acceptEula', installPath),
checkRam: (packKey: string): Promise<RamCheckResult> => checkRam: (packKey: string): Promise<RamCheckResult> =>