Files
minecraft_launcher/installer/renderer.js
claude-bot ffb2048627 terms: agreement pages + site Notion-style editor + rp cancel fix
- 5종 약관(map/resourcepack/mod/installer/installer-rp) markdown 시드 + manifest/terms/ 노출
- 사이트 /op/agreement 목록 + Notion 스타일 markdown 에디터 (슬래시 명령어, 미리보기)
- 메인 installer: 음악퀴즈 선택 직후 약관 동의 페이지(맵·모드·설치기) 추가
- rp installer: 음악퀴즈 선택 직후 약관 동의 페이지(리소스팩·설치기) 추가
- rp installer 취소 버그 수정: buildResourcepackZip 단계간 + archive.abort() 폴링
- rp installer 취소 UX: 즉시 "취소 중…" 표시, 취소 시 installFailed 알림 생략
- 0.2.6 → 0.3.0 (큰 기능)

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

928 lines
37 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) 진입 전에 노출.
// 메인 설치기는 맵·모드·설치기 세 약관을 모두 확인·동의해야 다음 단계로 갈 수 있다.
function renderAgreement() {
setActiveStep(1)
clearPage()
var KINDS = [
{ id: 'map', tab: tt('agreement.tabMap') },
{ id: 'mod', tab: tt('agreement.tabMod') },
{ id: 'installer', tab: tt('agreement.tabInstaller') }
]
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()
})()