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>
971 lines
39 KiB
JavaScript
971 lines
39 KiB
JavaScript
'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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
}
|
||
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, '&').replace(/"/g, '"')
|
||
}
|
||
|
||
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()
|
||
})()
|