Files
minecraft_launcher/installer/renderer.js
claude-bot 6447b1cb78 terms: block install on terms list fetch failure (retry UI)
Reviewer caught that v0.3.4 was bypassing the agreement step entirely on
network/server errors, letting users install without ever seeing terms.
Now only the explicit empty-list response (terms:[]) skips the step.
Network errors, 404s, and IPC failures render an error page with Back/Retry
buttons; no next button is exposed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 10:20:49 +09:00

971 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict'
const installerApi = window.installer
// I18N 사전: locales/installer/ko-kr.json. 처음 한 번 메인 프로세스에서 받아오고
// 그 뒤로는 동기적으로 lookup. tt() 가 호출될 때 사전이 비어 있어도 키를 그대로 반환해
// 화면이 깨지지는 않는다.
var I18N = {}
function tt(key, params) {
var parts = key.split('.')
var cur = I18N
for (var i = 0; i < parts.length; i++) {
if (cur && typeof cur === 'object' && parts[i] in cur) cur = cur[parts[i]]
else { cur = null; break }
}
var tpl = (typeof cur === 'string') ? cur : key
if (!params) return tpl
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, name) {
return (name in params) ? String(params[name]) : ('{{' + name + '}}')
})
}
const state = {
packs: [],
selectedPackKey: null,
mode: null, // 'single' | 'multi'
// mode==='multi' 일 때만 의미가 있다.
// 'host' → 서버를 직접 연다. 기존 멀티 흐름 (step3 + step4) 그대로.
// 'participant' → 친구 서버에 접속만 한다. step3 (서버 설치) 를 건너뛰고
// client 측에서도 맵은 받지 않는다 (참가자라 서버에 이미 있음).
role: null, // 'host' | 'participant' | null
serverInstall: {
path: '',
jdk: '',
eulaAccepted: false,
ram: null,
portStatus: null
},
client: {
installPlatform: true
},
finishToggles: {
desktopShortcut: true,
startServer: true,
startLauncher: true
},
stepDone: { 1: false, 2: false, 3: false, 4: false }
}
const pageHost = document.getElementById('pageHost')
const stepIndicator = document.getElementById('stepIndicator')
const logViewer = document.getElementById('logViewer')
const logBody = document.getElementById('logBody')
const logToggle = document.getElementById('logToggle')
function applyStaticI18n() {
document.title = tt('app.browserTitle')
var headerH1 = document.querySelector('.appHeader h1')
if (headerH1) headerH1.textContent = tt('app.headerTitle')
stepIndicator.querySelectorAll('li').forEach(function (item) {
var step = Number(item.getAttribute('data-step'))
item.textContent = tt('stepIndicator.step' + step)
})
var logHeader = logViewer.querySelector('h2')
if (logHeader) logHeader.textContent = tt('logViewer.title')
logToggle.textContent = tt('common.collapse')
}
logToggle.addEventListener('click', function () {
logViewer.classList.toggle('collapsed')
if (logViewer.classList.contains('collapsed')) {
logViewer.style.height = '36px'
logToggle.textContent = tt('common.expand')
} else {
logViewer.style.height = ''
logToggle.textContent = tt('common.collapse')
}
})
installerApi.onLog(function (line) {
logViewer.hidden = false
logBody.textContent += line + '\n'
logBody.scrollTop = logBody.scrollHeight
})
function setActiveStep(step) {
stepIndicator.querySelectorAll('li').forEach(function (item) {
var index = Number(item.getAttribute('data-step'))
item.classList.remove('active', 'done')
if (index < step) item.classList.add('done')
if (index === step) item.classList.add('active')
})
}
function clearPage() {
pageHost.innerHTML = ''
}
function renderStep1() {
setActiveStep(1)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + tt('step1.heading') + '</h2>' +
'<div id="packList" class="cardChoice"><p class="formMessage">' + tt('step1.loading') + '</p></div>' +
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
pageHost.appendChild(section)
var listEl = section.querySelector('#packList')
var nextBtn = section.querySelector('#next')
function renderList() {
listEl.innerHTML = ''
if (state.packs.length === 0) {
listEl.innerHTML = '<p class="formMessage error">' + tt('step1.empty') + '</p>'
return
}
state.packs.forEach(function (pack) {
var btn = document.createElement('button')
btn.type = 'button'
btn.innerHTML = '<strong>' + pack.name + '</strong><br><small>' +
tt('step1.subtitle', { mc: pack.pack.mcVersion, platform: pack.pack.platform.type }) + '</small>'
if (state.selectedPackKey === pack.key) btn.classList.add('selected')
btn.addEventListener('click', function () {
state.selectedPackKey = pack.key
nextBtn.disabled = false
renderList()
})
listEl.appendChild(btn)
})
}
nextBtn.addEventListener('click', async function () {
if (!state.selectedPackKey) return
await installerApi.setSelectedPack(state.selectedPackKey)
state.stepDone[1] = true
renderAgreement()
})
;(async function () {
try {
var packs = await installerApi.loadPacks()
state.packs = packs
renderList()
} catch (err) {
listEl.innerHTML = '<p class="formMessage error">' + tt('step1.fetchFailed', { message: err.message }) + '</p>'
}
})()
}
// 약관 동의 페이지: 음악퀴즈 선택 직후, 싱글/멀티 선택(step2) 진입 전에 노출.
// v0.3.4~ : 어떤 약관을 표시할지는 사이트(/manifest/terms/<pack>/index.json) 가
// 결정. 메인 인스톨러용으로 표시 토글된 항목만 받아 탭을 만든다. 목록이 비어 있는 (terms:[])
// 정상 응답일 때만 단계 자체를 건너뛴다. 네트워크 오류/404/서버 오류는 사용자가 약관 동의
// 없이 설치로 넘어가는 것을 막기 위해 오류 화면 + 다시 시도 버튼으로 차단한다.
function renderAgreement() {
setActiveStep(1)
clearPage()
var loadingSection = document.createElement('section')
loadingSection.className = 'page'
loadingSection.innerHTML = '<h2>' + tt('agreement.heading') + '</h2>' +
'<p class="formMessage">' + tt('agreement.loading') + '</p>'
pageHost.appendChild(loadingSection)
installerApi.getTermsList().then(function (res) {
if (!res || !res.ok) {
showAgreementError((res && res.message) || 'unknown')
return
}
var terms = (res.terms || []).map(function (t) {
return { id: t.kind, tab: t.label }
})
if (terms.length === 0) {
// 명시적으로 표시 대상이 0개라고 서버가 알려준 정상 응답 → 약관 단계 스킵.
renderStep2()
return
}
clearPage()
renderAgreementWithKinds(terms)
}).catch(function (err) {
showAgreementError(err && err.message ? err.message : 'unknown')
})
}
// 약관 목록을 못 받아왔을 때: 사용자에게 오류 + 다시 시도/뒤로 가기 옵션을 보여준다.
// 동의 없이 설치 단계로 넘어가지 않도록 next 버튼을 두지 않는다.
function showAgreementError(message) {
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + tt('agreement.heading') + '</h2>' +
'<p class="formMessage error">' + tt('agreement.listLoadFailed', { message: message }) + '</p>' +
'<div class="actionRow">' +
'<button class="secondaryBtn" id="back">' + tt('common.back') + '</button>' +
'<button class="primaryBtn" id="retry">' + tt('agreement.retry') + '</button>' +
'</div>'
pageHost.appendChild(section)
section.querySelector('#back').addEventListener('click', renderStep1)
section.querySelector('#retry').addEventListener('click', renderAgreement)
}
function renderAgreementWithKinds(KINDS) {
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + tt('agreement.heading') + '</h2>' +
'<p class="formMessage">' + tt('agreement.intro') + '</p>' +
'<div class="tabBar" id="agTabs">' +
KINDS.map(function (k, i) {
return '<button type="button" class="tabBtn' + (i === 0 ? ' active' : '') + '" data-ag="' + k.id + '">' + k.tab + '</button>'
}).join('') +
'</div>' +
'<div class="agreementBody" id="agBody">' + tt('agreement.loading') + '</div>' +
'<label class="toggleRow" style="margin-top:12px;"><input type="checkbox" id="agAccept" /> ' +
tt('agreement.agreeAll') + '</label>' +
'<div class="formMessage" id="agMsg"></div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
pageHost.appendChild(section)
var body = section.querySelector('#agBody')
var tabs = section.querySelectorAll('[data-ag]')
var nextBtn = section.querySelector('#next')
var accept = section.querySelector('#agAccept')
var msg = section.querySelector('#agMsg')
// 약관 본문은 한 번 받으면 캐시. 탭 전환 시 재요청하지 않는다.
var cache = {}
function showKind(kind) {
if (cache[kind]) {
body.innerHTML = cache[kind]
return
}
body.textContent = tt('agreement.loading')
installerApi.getTerm(kind).then(function (res) {
if (!res.ok) {
body.innerHTML = '<p class="formMessage error">' + tt('agreement.loadFailed', { message: res.message || '' }) + '</p>'
return
}
var html = renderTermsMarkdown(res.content || '')
cache[kind] = html
body.innerHTML = html
}).catch(function (err) {
body.innerHTML = '<p class="formMessage error">' + tt('agreement.loadFailed', { message: err.message }) + '</p>'
})
}
tabs.forEach(function (b) {
b.addEventListener('click', function () {
tabs.forEach(function (x) { x.classList.remove('active') })
b.classList.add('active')
showKind(b.getAttribute('data-ag'))
})
})
accept.addEventListener('change', function () {
nextBtn.disabled = !accept.checked
if (accept.checked) msg.textContent = ''
})
nextBtn.addEventListener('click', function () {
if (!accept.checked) {
msg.textContent = tt('agreement.agreeRequired')
msg.classList.add('error')
return
}
renderStep2()
})
section.querySelector('#back').addEventListener('click', renderStep1)
showKind(KINDS[0].id)
}
// 인스톨러용 미니 markdown 렌더러. 사이트 termsEditor 와 동일한 규칙을 처리한다.
function renderTermsMarkdown(src) {
function escHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function inline(s) {
s = escHtml(s)
s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
s = s.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, '$1<em>$2</em>')
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
s = s.replace(/(^|[\s(])(https?:\/\/[^\s)]+)/g, function (m, p, u) {
return p + '<a href="' + u + '" target="_blank" rel="noopener">' + u + '</a>'
})
return s
}
var lines = src.replace(/\r\n/g, '\n').split('\n')
var out = []
var i = 0
var stack = null
function closeList() { if (stack) { out.push('</' + stack + '>'); stack = null } }
while (i < lines.length) {
var line = lines[i]
var fence = /^```(\w*)\s*$/.exec(line)
if (fence) {
closeList()
var code = []; i += 1
while (i < lines.length && !/^```\s*$/.test(lines[i])) { code.push(lines[i]); i += 1 }
if (i < lines.length) i += 1
out.push('<pre><code>' + escHtml(code.join('\n')) + '</code></pre>')
continue
}
var togStart = /^:::toggle\s+(.+)$/.exec(line)
if (togStart) {
closeList()
var summary = togStart[1]; var body2 = []; i += 1
while (i < lines.length && !/^:::\s*$/.test(lines[i])) { body2.push(lines[i]); i += 1 }
if (i < lines.length) i += 1
out.push('<details><summary>' + inline(summary) + '</summary>' + renderTermsMarkdown(body2.join('\n')) + '</details>')
continue
}
var h = /^(#{1,6})\s+(.*)$/.exec(line)
if (h) {
closeList()
out.push('<h' + h[1].length + '>' + inline(h[2]) + '</h' + h[1].length + '>')
i += 1; continue
}
if (/^---+\s*$/.test(line)) { closeList(); out.push('<hr/>'); i += 1; continue }
if (/^>\s?/.test(line)) {
closeList()
var q = []
while (i < lines.length && /^>\s?/.test(lines[i])) { q.push(lines[i].replace(/^>\s?/, '')); i += 1 }
out.push('<blockquote>' + renderTermsMarkdown(q.join('\n')) + '</blockquote>')
continue
}
var ol = /^\s*\d+\.\s+(.*)$/.exec(line)
if (ol) {
if (stack !== 'ol') { closeList(); out.push('<ol>'); stack = 'ol' }
out.push('<li>' + inline(ol[1]) + '</li>'); i += 1; continue
}
var ul = /^\s*[-*]\s+(.*)$/.exec(line)
if (ul) {
if (stack !== 'ul') { closeList(); out.push('<ul>'); stack = 'ul' }
out.push('<li>' + inline(ul[1]) + '</li>'); i += 1; continue
}
if (/^\s*$/.test(line)) { closeList(); i += 1; continue }
closeList()
var para = [line]; i += 1
while (i < lines.length && !/^\s*$/.test(lines[i])
&& !/^(#{1,6})\s+/.test(lines[i])
&& !/^\s*[-*]\s+/.test(lines[i])
&& !/^\s*\d+\.\s+/.test(lines[i])
&& !/^>/.test(lines[i])
&& !/^---+\s*$/.test(lines[i])
&& !/^```/.test(lines[i])
&& !/^:::/.test(lines[i])) {
para.push(lines[i]); i += 1
}
out.push('<p>' + inline(para.join('\n').replace(/\n/g, '<br/>')) + '</p>')
}
closeList()
return out.join('\n')
}
function renderStep2() {
setActiveStep(2)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + tt('step2.heading') + '</h2>' +
'<div class="cardChoice">' +
'<button id="single" type="button" data-mode="single"><strong>' + tt('step2.singleTitle') + '</strong><br><small>' + tt('step2.singleHint') + '</small></button>' +
'<button id="multi" type="button" data-mode="multi"><strong>' + tt('step2.multiTitle') + '</strong><br><small>' + tt('step2.multiHint') + '</small></button>' +
'</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
pageHost.appendChild(section)
var nextBtn = section.querySelector('#next')
var modeButtons = section.querySelectorAll('[data-mode]')
function applyMode(mode) {
state.mode = mode
modeButtons.forEach(function (btn) {
if (btn.getAttribute('data-mode') === mode) btn.classList.add('selected')
else btn.classList.remove('selected')
})
nextBtn.disabled = false
// 모드가 바뀌면 이전에 골랐던 역할은 의미가 없어진다. 멀티→싱글 전환 시 잔존하던
// role 이 다음 단계 분기에 영향 주지 않도록 명시적으로 초기화.
if (mode !== 'multi') state.role = null
}
modeButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
applyMode(btn.getAttribute('data-mode'))
})
})
if (state.mode === 'single' || state.mode === 'multi') {
applyMode(state.mode)
}
nextBtn.addEventListener('click', function () {
if (!state.mode) return
state.stepDone[2] = true
// 멀티는 호스트/참가자 선택 탭을 거친다. 싱글은 곧장 클라이언트(step4) 로.
if (state.mode === 'multi') renderStep2Role()
else renderStep4()
})
section.querySelector('#back').addEventListener('click', renderAgreement)
}
function renderStep2Role() {
// 스텝 인디케이터는 여전히 2 단계 안쪽이다 — 호스트/참가자 선택은 모드 선택의
// 하위 결정이기 때문. 별도 탭으로 분리해서 한 화면에 한 결정만 보이도록 한다.
setActiveStep(2)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + tt('step2.roleHeading') + '</h2>' +
'<div class="cardChoice">' +
'<button type="button" data-role="host"><strong>' + tt('step2.hostTitle') + '</strong><br><small>' + tt('step2.hostHint') + '</small></button>' +
'<button type="button" data-role="participant"><strong>' + tt('step2.participantTitle') + '</strong><br><small>' + tt('step2.participantHint') + '</small></button>' +
'</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
pageHost.appendChild(section)
var nextBtn = section.querySelector('#next')
var roleButtons = section.querySelectorAll('[data-role]')
function applyRole(role) {
state.role = role
roleButtons.forEach(function (btn) {
if (btn.getAttribute('data-role') === role) btn.classList.add('selected')
else btn.classList.remove('selected')
})
nextBtn.disabled = false
}
roleButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
applyRole(btn.getAttribute('data-role'))
})
})
if (state.role === 'host' || state.role === 'participant') applyRole(state.role)
nextBtn.addEventListener('click', function () {
if (!state.role) return
// 호스트는 서버 설치(step3) 부터, 참가자는 클라이언트(step4) 로 바로.
if (state.role === 'host') renderStep3()
else renderStep4()
})
section.querySelector('#back').addEventListener('click', renderStep2)
}
function renderStep3() {
setActiveStep(3)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + tt('step3.heading') + '</h2>' +
'<div class="subStep" id="subHost"></div>'
pageHost.appendChild(section)
var subHost = section.querySelector('#subHost')
// step3 는 멀티+호스트 만 진입하므로 sub31 의 back 은 역할 선택 탭으로.
function show31() { subHost.innerHTML = ''; renderSubStep31(subHost, renderStep2Role, 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, show34, function () {
state.stepDone[3] = true
renderStep4()
})
}
show31()
}
function renderSubStep31(host, back, done) {
host.innerHTML =
'<h3>' + tt('step3.sub31.heading') + '</h3>' +
'<p class="formMessage">' + tt('step3.sub31.description') + '</p>' +
'<div class="fieldset"><label><input id="installPath" type="text" placeholder="C:\\MusicQuizServer" value="' + (state.serverInstall.path || '') + '" /></label>' +
'<button class="secondaryBtn" id="pickFolder">' + tt('step3.sub31.pickFolder') + '</button></div>' +
'<div class="formMessage" id="msg"></div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
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('#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 || tt('step3.sub31.invalidPath')
msg.classList.add('error')
return
}
msg.textContent = tt('step3.sub31.confirmed', { message: result.message })
msg.classList.remove('error')
msg.classList.add('success')
state.serverInstall.path = input.value.trim()
done()
})
}
function renderSubStep32(host, back, done) {
host.innerHTML =
'<h3>' + tt('step3.sub32.heading') + '</h3>' +
'<p class="formMessage">' + tt('step3.sub32.description') + '</p>' +
'<div class="fieldset"><label><input id="jdkPath" type="text" placeholder="C:\\Program Files\\Java\\jdk-17" value="' + (state.serverInstall.jdk || '') + '" /></label>' +
'<button class="secondaryBtn" id="pickJdk">' + tt('step3.sub32.pickFolder') + '</button>' +
'<button class="secondaryBtn" id="auto">' + tt('step3.sub32.auto') + '</button>' +
'<button class="secondaryBtn" id="install">' + tt('step3.sub32.install') + '</button></div>' +
'<div class="formMessage" id="msg"></div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
var input = host.querySelector('#jdkPath')
var msg = host.querySelector('#msg')
var installBtn = host.querySelector('#install')
var autoBtn = host.querySelector('#auto')
var pickBtn = host.querySelector('#pickJdk')
var nextBtn = host.querySelector('#next')
var installing = false
function setInstallingUi(on) {
installing = on
if (on) {
installBtn.textContent = tt('step3.sub32.installCancel')
installBtn.classList.remove('secondaryBtn')
installBtn.classList.add('dangerBtn')
autoBtn.disabled = true
pickBtn.disabled = true
nextBtn.disabled = true
input.disabled = true
} else {
installBtn.textContent = tt('step3.sub32.install')
installBtn.classList.remove('dangerBtn')
installBtn.classList.add('secondaryBtn')
autoBtn.disabled = false
pickBtn.disabled = false
nextBtn.disabled = false
input.disabled = false
}
}
autoBtn.addEventListener('click', async function () {
if (installing) return
var detect = await installerApi.detectJdk()
if (detect.found) {
input.value = detect.path
msg.textContent = tt('step3.sub32.found', { path: detect.path })
msg.classList.remove('error')
msg.classList.add('success')
} else {
msg.textContent = tt('step3.sub32.notFound')
msg.classList.remove('success')
msg.classList.add('error')
}
})
pickBtn.addEventListener('click', async function () {
if (installing) return
var picked = await installerApi.pickFolder()
if (picked) input.value = picked
})
installBtn.addEventListener('click', async function () {
if (installing) {
// 진행 중이면 취소.
msg.textContent = tt('step3.sub32.cancelRequested')
msg.classList.remove('success', 'error')
await installerApi.cancelJdkInstall()
return
}
setInstallingUi(true)
msg.classList.remove('success', 'error')
msg.textContent = tt('step3.sub32.downloading')
try {
var result = await installerApi.installJdk()
if (result.ok && result.path) {
input.value = result.path
state.serverInstall.jdk = result.path
msg.textContent = tt('step3.sub32.installComplete', { path: result.path })
msg.classList.add('success')
} else {
var raw = result.message || tt('common.unknownError')
msg.textContent = raw === '취소됨'
? tt('step3.sub32.installCanceled')
: tt('step3.sub32.installFailed', { message: raw })
msg.classList.add('error')
}
} catch (err) {
msg.textContent = tt('step3.sub32.installError', { message: (err && err.message) ? err.message : String(err) })
msg.classList.add('error')
} finally {
setInstallingUi(false)
}
})
host.querySelector('#back').addEventListener('click', function () {
if (installing) return
back()
})
nextBtn.addEventListener('click', function () {
if (installing) return
if (!input.value.trim()) {
msg.textContent = tt('step3.sub32.pathRequired')
msg.classList.add('error')
return
}
state.serverInstall.jdk = input.value.trim()
done()
})
;(async function () {
var detect = await installerApi.detectJdk()
if (detect.found && !input.value) {
input.value = detect.path
msg.textContent = tt('step3.sub32.autoDetected', { path: detect.path })
msg.classList.add('success')
} else if (!detect.found) {
msg.textContent = tt('step3.sub32.notFoundHint')
}
})()
}
function renderSubStep33(host, back, done) {
host.innerHTML =
'<h3>' + tt('step3.sub33.heading') + '</h3>' +
'<p class="formMessage">' + tt('step3.sub33.description') + '</p>' +
'<div class="formMessage" id="downloadStatus">' + tt('step3.sub33.waiting') + '</div>' +
'<div id="ramSection" hidden style="margin-top:14px;">' +
'<h4>' + tt('step3.sub33.ramHeading') + '</h4>' +
'<div class="formMessage" id="ramMsg">' + tt('step3.sub33.ramChecking') + '</div>' +
'</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
var statusEl = host.querySelector('#downloadStatus')
var ramSection = host.querySelector('#ramSection')
var ramMsg = host.querySelector('#ramMsg')
var nextBtn = host.querySelector('#next')
host.querySelector('#back').addEventListener('click', back)
nextBtn.addEventListener('click', function () {
if (!state.serverInstall.eulaAccepted) return
done()
})
// 이미 통과했던 상태 복원: 사용자가 다음→이전으로 돌아왔을 때 재다운로드 강요하지 않는다.
if (state.serverInstall.eulaAccepted && state.serverInstall.ram) {
statusEl.textContent = tt('step3.sub33.doneSummary')
statusEl.classList.add('success')
showRamResult(state.serverInstall.ram)
nextBtn.disabled = false
return
}
// 페이지 진입 즉시 자동 다운로드
;(async function () {
state.serverInstall.eulaAccepted = false
nextBtn.disabled = true
statusEl.classList.remove('success', 'error')
statusEl.textContent = tt('step3.sub33.downloading')
try {
await installerApi.startServerInstall({
packKey: state.selectedPackKey,
installPath: state.serverInstall.path,
jdkPath: state.serverInstall.jdk
})
statusEl.textContent = tt('step3.sub33.eulaPrompt')
var accepted = await openEulaPopup(state.serverInstall.path)
if (!accepted) {
statusEl.textContent = tt('step3.sub33.eulaRejected')
statusEl.classList.add('error')
return
}
try {
await installerApi.acceptEula(state.serverInstall.path)
} catch (err) {
statusEl.textContent = tt('step3.sub33.eulaSaveFailed', { message: err.message })
statusEl.classList.add('error')
return
}
state.serverInstall.eulaAccepted = true
statusEl.textContent = tt('step3.sub33.doneSummary')
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 = tt('step3.sub33.downloadFailed', { message: (err && err.message) ? err.message : String(err) })
statusEl.classList.add('error')
}
})()
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 = tt('step3.sub33.ramTooLow', { system: result.systemRamMb, min: minRam })
ramMsg.classList.add('error')
} else if (result.decision === 'minOk') {
ramMsg.innerHTML = tt('step3.sub33.ramMinOk', { system: result.systemRamMb, applied: result.appliedRamMb })
ramMsg.classList.add('warn')
} else {
ramMsg.textContent = tt('step3.sub33.ramMaxOk', { system: result.systemRamMb, applied: result.appliedRamMb })
ramMsg.classList.add('success')
}
}
}
// EULA 동의 팝업. resolve(true) = 동의, resolve(false) = 비동의/창 닫힘.
// eula.txt 의 내용과 무관하게 항상 minecraft.net 의 공식 EULA 페이지를 받아서
// 표시한다 — 사용자가 실제 서버 약관을 보고 동의하도록.
async function openEulaPopup(_installPath) {
var bodyHtml = ''
var fetched = await installerApi.fetchMinecraftEula()
if (fetched.html) {
bodyHtml = '<p class="formMessage">' + tt('step3.eulaModal.fromMojang', { url: fetched.url }) + '</p>' +
'<iframe class="eulaFrame" sandbox srcdoc="' + escapeAttr(fetched.html) + '"></iframe>'
} else {
bodyHtml = '<p class="formMessage error">' + tt('step3.eulaModal.loadFailed') + '</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>' + tt('step3.eulaModal.title') + '</h3><button type="button" class="modalClose" aria-label="' + tt('common.close') + '">×</button></header>' +
'<div class="modalBody">' + bodyHtml + '</div>' +
'<footer class="actionRow">' +
'<button type="button" class="secondaryBtn" data-action="reject">' + tt('common.reject') + '</button>' +
'<button type="button" class="primaryBtn" data-action="accept">' + tt('common.agree') + '</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 escapeAttr(text) {
return String(text).replace(/&/g, '&amp;').replace(/"/g, '&quot;')
}
function renderSubStep34(host, back, done) {
host.innerHTML =
'<h3>' + tt('step3.sub34.heading') + '</h3>' +
'<p class="formMessage">' + tt('step3.sub34.description') + '</p>' +
'<button class="secondaryBtn" id="open">' + tt('step3.sub34.open') + '</button>' +
'<div class="formMessage" id="editorMsg"></div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
host.querySelector('#open').addEventListener('click', async function () {
var msg = host.querySelector('#editorMsg')
try {
var result = await installerApi.startServerConfigEditor(state.serverInstall.path)
msg.innerHTML = tt('step3.sub34.openedAt', { url: result.url })
} catch (err) {
msg.textContent = tt('step3.sub34.openFailed', { message: err.message })
msg.classList.add('error')
}
})
host.querySelector('#back').addEventListener('click', back)
host.querySelector('#next').addEventListener('click', done)
}
function renderSubStep35(host, back, done) {
host.innerHTML =
'<h3>' + tt('step3.sub35.heading') + '</h3>' +
'<p class="formMessage">' + tt('step3.sub35.description') + '</p>' +
'<div class="fieldset"><label>' + tt('step3.sub35.portLabel') + ' <input id="port" type="text" value="25565" /></label></div>' +
'<button class="secondaryBtn" id="run">' + tt('step3.sub35.recheck') + '</button>' +
'<div class="formMessage" id="resultMsg"></div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
var resultMsg = host.querySelector('#resultMsg')
var nextBtn = host.querySelector('#next')
var runBtn = host.querySelector('#run')
host.querySelector('#back').addEventListener('click', back)
// 25565 는 마인크래프트 자바판 기본 포트라 클라이언트에서 생략 가능 →
// 사용자에게도 ip 만 보여주는 게 깔끔하다.
function formatServerAddress(ip, port) {
var safeIp = ip || tt('step3.sub35.ipUnknown')
if (Number(port) === 25565) return safeIp
return safeIp + ':' + port
}
async function runCheck() {
runBtn.disabled = true
resultMsg.classList.remove('success', 'warn', 'error')
resultMsg.textContent = tt('step3.sub35.checking')
var port = Number(host.querySelector('#port').value) || 25565
try {
var result = await installerApi.checkPortForward(port)
state.serverInstall.portStatus = result
var address = formatServerAddress(result.externalIp, result.port)
if (result.status === 'preForwarded') {
resultMsg.innerHTML = tt('step3.sub35.preForwarded', { address: address })
resultMsg.classList.add('success')
} else if (result.status === 'upnpOk') {
resultMsg.innerHTML = tt('step3.sub35.upnpOk', { address: address })
resultMsg.classList.add('success')
} else {
resultMsg.innerHTML = (result.message || tt('step3.sub35.manualHint')) +
tt('step3.sub35.manualDetail', { address: address })
resultMsg.classList.add('warn')
}
nextBtn.disabled = false
} catch (err) {
resultMsg.textContent = tt('step3.sub35.checkFailed', { message: (err && err.message) ? err.message : String(err) })
resultMsg.classList.add('error')
} finally {
runBtn.disabled = false
}
}
runBtn.addEventListener('click', runCheck)
nextBtn.addEventListener('click', done)
// 페이지 진입 즉시 자동 점검
runCheck()
}
function renderStep4() {
setActiveStep(4)
clearPage()
var pack = state.packs.find(function (p) { return p.key === state.selectedPackKey })
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + tt('step4.heading') + '</h2>' +
'<div class="subStep" id="subHost"></div>'
pageHost.appendChild(section)
var subHost = section.querySelector('#subHost')
// 플랫폼 선택 UI 는 더 이상 보여주지 않는다. 음악퀴즈에 지정된 플랫폼이
// 바닐라가 아니면 자동으로 설치하고, 바닐라면 건너뛴다 — 사용자가 고를 일이 없다.
var platformType = pack ? pack.pack.platform.type : 'vanilla'
state.client.installPlatform = platformType !== 'vanilla'
// 멀티+호스트 만 step3 (서버 설치) 를 거쳤으므로 거기로 돌아간다.
// 멀티+참가자 는 직전 화면이 역할 선택 탭이므로 거기로, 싱글은 모드 탭으로.
function backToPrevStep() {
if (state.mode === 'multi' && state.role === 'host') renderStep3()
else if (state.mode === 'multi') renderStep2Role()
else renderStep2()
}
function show42() { subHost.innerHTML = ''; renderSubStep42(subHost, backToPrevStep, goStep5) }
function goStep5() {
state.stepDone[4] = true
renderStep5()
}
show42()
}
function renderSubStep42(host, back, done) {
host.innerHTML =
'<h3>' + tt('step4.sub42.heading') + '</h3>' +
'<p class="formMessage">' + tt('step4.sub42.description') + '</p>' +
'<div class="formMessage" id="msg">' + tt('step4.sub42.installing') + '</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
var msg = host.querySelector('#msg')
var nextBtn = host.querySelector('#next')
host.querySelector('#back').addEventListener('click', back)
nextBtn.addEventListener('click', done)
// 이번에 실제로 보내야 할 payload. 이전 진입에서 같은 payload 로 이미 끝났으면
// 다시 돌리지 않지만, packKey / installPlatform / skipMap 중 하나라도 다르면
// (예: 참가자 → 싱글 로 뒤로가서 변경) 재설치한다.
var payload = {
packKey: state.selectedPackKey,
installPlatform: !!state.client.installPlatform,
// 참가자는 친구 서버에 접속만 하므로 클라이언트에 맵을 풀지 않는다.
skipMap: state.mode === 'multi' && state.role === 'participant'
}
var last = state.client.lastInstall
if (last
&& last.packKey === payload.packKey
&& last.installPlatform === payload.installPlatform
&& last.skipMap === payload.skipMap) {
msg.textContent = tt('step4.sub42.done')
msg.classList.add('success')
nextBtn.disabled = false
return
}
// 페이지 진입 즉시 자동 설치
;(async function () {
try {
await installerApi.installClient(payload)
msg.textContent = tt('step4.sub42.done')
msg.classList.add('success')
state.client.lastInstall = payload
nextBtn.disabled = false
} catch (err) {
// 실패한 호출은 "마지막 성공" 기록에 남기지 않는다. 다음 진입 시 재시도.
state.client.lastInstall = null
msg.textContent = tt('step4.sub42.failed', { message: (err && err.message) ? err.message : String(err) })
msg.classList.add('error')
}
})()
}
function renderStep5() {
setActiveStep(5)
clearPage()
var section = document.createElement('section')
section.className = 'page'
// 서버 마무리 액션 (바로가기/서버 실행) 은 step3 를 거친 호스트 만 노출한다.
// 싱글, 멀티+참가자 는 서버를 직접 띄우지 않으므로 런처만 보여준다.
var showServerActions = state.mode === 'multi' && state.role === 'host'
section.innerHTML =
'<h2>' + tt('step5.heading') + '</h2>' +
'<p>' + tt('step5.summary') + '</p>' +
(showServerActions ? '<div class="subStep">' +
'<h3>' + tt('step5.serverHeading') + '</h3>' +
'<button class="secondaryBtn" id="openFolder">' + tt('step5.openServerFolder') + '</button>' +
'<label class="toggleRow"><input type="checkbox" id="shortcut" checked /> ' + tt('step5.shortcut') + '</label>' +
'<label class="toggleRow"><input type="checkbox" id="startServer" checked /> ' + tt('step5.startServer') + '</label>' +
'</div>' : '') +
'<div class="subStep">' +
'<h3>' + tt('step5.launcherHeading') + '</h3>' +
'<label class="toggleRow"><input type="checkbox" id="startLauncher" checked /> ' + tt('step5.startLauncher') + '</label>' +
'</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="finish">' + tt('step5.finish') + '</button></div>'
pageHost.appendChild(section)
section.querySelector('#back').addEventListener('click', renderStep4)
if (showServerActions) {
section.querySelector('#openFolder').addEventListener('click', function () {
installerApi.openServerFolder()
})
}
section.querySelector('#finish').addEventListener('click', async function () {
var finishBtn = section.querySelector('#finish')
finishBtn.disabled = true
finishBtn.textContent = tt('step5.finishing')
try {
if (showServerActions) {
if (section.querySelector('#shortcut').checked) await installerApi.createDesktopShortcut()
if (section.querySelector('#startServer').checked) await installerApi.startServer()
}
if (section.querySelector('#startLauncher').checked) await installerApi.startMinecraftLauncher()
} catch (err) {
// 마무리 액션 실패는 무시하고 종료 진행
}
finishBtn.textContent = tt('step5.finished')
if (installerApi.quitApp) installerApi.quitApp()
})
}
// 시작 진입점: 사전을 먼저 받아서 정적 텍스트 갱신 후 첫 페이지 렌더.
;(async function () {
try {
I18N = (await installerApi.loadLocale()) || {}
} catch (_) { I18N = {} }
applyStaticI18n()
renderStep1()
})()