i18n: 음악퀴즈 설치기 UI 문구를 locales/installer/ko-kr.json 으로 분리

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 03:53:55 +09:00
parent c2fcc2fbbf
commit 135bc98840
4 changed files with 610 additions and 246 deletions

View File

@@ -2,6 +2,24 @@
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,
@@ -30,14 +48,27 @@ 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 = '펼치기'
logToggle.textContent = tt('common.expand')
} else {
logViewer.style.height = ''
logToggle.textContent = '접기'
logToggle.textContent = tt('common.collapse')
}
})
@@ -66,9 +97,9 @@ function renderStep1() {
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>1단계. 설치할 음악퀴즈 선택</h2>' +
'<div id="packList" class="cardChoice"><p class="formMessage">목록을 불러오는 중...</p></div>' +
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>다음</button></div>'
'<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')
@@ -76,13 +107,14 @@ function renderStep1() {
function renderList() {
listEl.innerHTML = ''
if (state.packs.length === 0) {
listEl.innerHTML = '<p class="formMessage error">등록된 음악퀴즈가 없습니다.</p>'
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>마인크래프트 ' + pack.pack.mcVersion + ' / ' + pack.pack.platform.type + '</small>'
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
@@ -106,7 +138,7 @@ function renderStep1() {
state.packs = packs
renderList()
} catch (err) {
listEl.innerHTML = '<p class="formMessage error">목록을 가져오지 못했습니다: ' + err.message + '</p>'
listEl.innerHTML = '<p class="formMessage error">' + tt('step1.fetchFailed', { message: err.message }) + '</p>'
}
})()
}
@@ -117,12 +149,12 @@ function renderStep2() {
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>2단계. 싱글 / 멀티 선택</h2>' +
'<h2>' + tt('step2.heading') + '</h2>' +
'<div class="cardChoice">' +
'<button id="single" type="button" data-mode="single"><strong>싱글</strong><br><small>혼자 즐기는 모드. 4단계만 진행합니다.</small></button>' +
'<button id="multi" type="button" data-mode="multi"><strong>멀티</strong><br><small>친구들과 함께. 3단계 서버 설치 후 4단계를 진행합니다.</small></button>' +
'<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">이전</button><button class="primaryBtn" id="next" disabled>다음</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]')
@@ -159,7 +191,7 @@ function renderStep3() {
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>3단계. 서버 관련 설정</h2>' +
'<h2>' + tt('step3.heading') + '</h2>' +
'<div class="subStep" id="subHost"></div>'
pageHost.appendChild(section)
var subHost = section.querySelector('#subHost')
@@ -180,12 +212,12 @@ function renderStep3() {
function renderSubStep31(host, back, done) {
host.innerHTML =
'<h3>3-1. 서버 설치 경로</h3>' +
'<p class="formMessage">서버를 생성할 폴더를 선택하세요. 경로에 한글이 포함되면 안 됩니다.</p>' +
'<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">폴더 선택</button></div>' +
'<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">이전</button><button class="primaryBtn" id="next">다음</button></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 () {
@@ -196,11 +228,11 @@ function renderSubStep31(host, back, done) {
host.querySelector('#next').addEventListener('click', async function () {
var result = await installerApi.validateInstallPath(input.value.trim())
if (!result.ok) {
msg.textContent = result.message || '경로가 유효하지 않습니다.'
msg.textContent = result.message || tt('step3.sub31.invalidPath')
msg.classList.add('error')
return
}
msg.textContent = '경로 확정: ' + result.message
msg.textContent = tt('step3.sub31.confirmed', { message: result.message })
msg.classList.remove('error')
msg.classList.add('success')
state.serverInstall.path = input.value.trim()
@@ -210,14 +242,14 @@ function renderSubStep31(host, back, done) {
function renderSubStep32(host, back, done) {
host.innerHTML =
'<h3>3-2. JDK 확인</h3>' +
'<p class="formMessage">JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 없으면 "자동 설치" 로 Temurin 21 을 받아 설치할 수 있습니다.</p>' +
'<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">폴더 선택</button>' +
'<button class="secondaryBtn" id="auto">자동 탐색</button>' +
'<button class="secondaryBtn" id="install">자동 설치</button></div>' +
'<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">이전</button><button class="primaryBtn" id="next">다음</button></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')
@@ -229,7 +261,7 @@ function renderSubStep32(host, back, done) {
function setInstallingUi(on) {
installing = on
if (on) {
installBtn.textContent = '설치 취소'
installBtn.textContent = tt('step3.sub32.installCancel')
installBtn.classList.remove('secondaryBtn')
installBtn.classList.add('dangerBtn')
autoBtn.disabled = true
@@ -237,7 +269,7 @@ function renderSubStep32(host, back, done) {
nextBtn.disabled = true
input.disabled = true
} else {
installBtn.textContent = '자동 설치'
installBtn.textContent = tt('step3.sub32.install')
installBtn.classList.remove('dangerBtn')
installBtn.classList.add('secondaryBtn')
autoBtn.disabled = false
@@ -252,11 +284,11 @@ function renderSubStep32(host, back, done) {
var detect = await installerApi.detectJdk()
if (detect.found) {
input.value = detect.path
msg.textContent = 'JDK 발견: ' + detect.path
msg.textContent = tt('step3.sub32.found', { path: detect.path })
msg.classList.remove('error')
msg.classList.add('success')
} else {
msg.textContent = 'JDK를 자동으로 찾지 못했습니다. "자동 설치" 를 눌러 Temurin 21 을 설치하거나 직접 선택해 주세요.'
msg.textContent = tt('step3.sub32.notFound')
msg.classList.remove('success')
msg.classList.add('error')
}
@@ -269,27 +301,30 @@ function renderSubStep32(host, back, done) {
installBtn.addEventListener('click', async function () {
if (installing) {
// 진행 중이면 취소.
msg.textContent = 'JDK 설치 취소 요청 중...'
msg.textContent = tt('step3.sub32.cancelRequested')
msg.classList.remove('success', 'error')
await installerApi.cancelJdkInstall()
return
}
setInstallingUi(true)
msg.classList.remove('success', 'error')
msg.textContent = 'Temurin 21 다운로드 중... (네트워크 상태에 따라 1~5분)'
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 = 'JDK 자동 설치 완료: ' + result.path
msg.textContent = tt('step3.sub32.installComplete', { path: result.path })
msg.classList.add('success')
} else {
msg.textContent = 'JDK 설치 ' + (result.message === '취소됨' ? '취소됨' : '실패: ' + (result.message || '알 수 없는 오류'))
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 = 'JDK 설치 오류: ' + (err && err.message ? err.message : err)
msg.textContent = tt('step3.sub32.installError', { message: (err && err.message) ? err.message : String(err) })
msg.classList.add('error')
} finally {
setInstallingUi(false)
@@ -302,7 +337,7 @@ function renderSubStep32(host, back, done) {
nextBtn.addEventListener('click', function () {
if (installing) return
if (!input.value.trim()) {
msg.textContent = 'JDK 경로를 입력해 주세요.'
msg.textContent = tt('step3.sub32.pathRequired')
msg.classList.add('error')
return
}
@@ -313,24 +348,24 @@ function renderSubStep32(host, back, done) {
var detect = await installerApi.detectJdk()
if (detect.found && !input.value) {
input.value = detect.path
msg.textContent = 'JDK 자동 탐색됨: ' + detect.path
msg.textContent = tt('step3.sub32.autoDetected', { path: detect.path })
msg.classList.add('success')
} else if (!detect.found) {
msg.textContent = 'JDK를 자동으로 찾지 못했습니다. "자동 설치" 를 누르면 Temurin 21 LTS 를 받아 설치합니다.'
msg.textContent = tt('step3.sub32.notFoundHint')
}
})()
}
function renderSubStep33(host, back, done) {
host.innerHTML =
'<h3>3-3. 서버 다운로드 및 설치</h3>' +
'<p class="formMessage">선택한 음악퀴즈의 서버 파일을 다운로드합니다. 진행 상황은 하단 로그 뷰어에 표시됩니다.</p>' +
'<div class="formMessage" id="downloadStatus">대기 중</div>' +
'<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>램 검사</h4>' +
'<div class="formMessage" id="ramMsg">검사 중...</div>' +
'<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">이전</button><button class="primaryBtn" id="next" disabled>다음</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>'
var statusEl = host.querySelector('#downloadStatus')
var ramSection = host.querySelector('#ramSection')
@@ -345,7 +380,7 @@ function renderSubStep33(host, back, done) {
// 이미 통과했던 상태 복원: 사용자가 다음→이전으로 돌아왔을 때 재다운로드 강요하지 않는다.
if (state.serverInstall.eulaAccepted && state.serverInstall.ram) {
statusEl.textContent = '다운로드 및 EULA 동의 완료.'
statusEl.textContent = tt('step3.sub33.doneSummary')
statusEl.classList.add('success')
showRamResult(state.serverInstall.ram)
nextBtn.disabled = false
@@ -357,29 +392,29 @@ function renderSubStep33(host, back, done) {
state.serverInstall.eulaAccepted = false
nextBtn.disabled = true
statusEl.classList.remove('success', 'error')
statusEl.textContent = '다운로드 중...'
statusEl.textContent = tt('step3.sub33.downloading')
try {
await installerApi.startServerInstall({
packKey: state.selectedPackKey,
installPath: state.serverInstall.path,
jdkPath: state.serverInstall.jdk
})
statusEl.textContent = 'EULA 동의가 필요합니다. 팝업을 확인해 주세요.'
statusEl.textContent = tt('step3.sub33.eulaPrompt')
var accepted = await openEulaPopup(state.serverInstall.path)
if (!accepted) {
statusEl.textContent = 'EULA 동의 실패. 다운로드를 취소했습니다. 이전→다음으로 다시 시도하세요.'
statusEl.textContent = tt('step3.sub33.eulaRejected')
statusEl.classList.add('error')
return
}
try {
await installerApi.acceptEula(state.serverInstall.path)
} catch (err) {
statusEl.textContent = 'EULA 저장 실패: ' + err.message
statusEl.textContent = tt('step3.sub33.eulaSaveFailed', { message: err.message })
statusEl.classList.add('error')
return
}
state.serverInstall.eulaAccepted = true
statusEl.textContent = '다운로드 및 EULA 동의 완료.'
statusEl.textContent = tt('step3.sub33.doneSummary')
statusEl.classList.add('success')
var ram = await installerApi.checkRam(state.selectedPackKey)
state.serverInstall.ram = ram
@@ -387,7 +422,7 @@ function renderSubStep33(host, back, done) {
if (ram.decision === 'tooLow') return
nextBtn.disabled = false
} catch (err) {
statusEl.textContent = '다운로드 실패: ' + (err && err.message ? err.message : err)
statusEl.textContent = tt('step3.sub33.downloadFailed', { message: (err && err.message) ? err.message : String(err) })
statusEl.classList.add('error')
}
})()
@@ -398,13 +433,13 @@ function renderSubStep33(host, back, done) {
if (result.decision === 'tooLow') {
var pack = state.packs.find(function (p) { return p.key === state.selectedPackKey })
var minRam = pack ? pack.pack.serverMinRam : 0
ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 음악퀴즈 최소 요구치(' + minRam + 'MB)에 미치지 못합니다. 설치를 중단합니다.'
ramMsg.innerHTML = tt('step3.sub33.ramTooLow', { system: result.systemRamMb, min: minRam })
ramMsg.classList.add('error')
} else if (result.decision === 'minOk') {
ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 권장치보다 부족합니다. 최소치(' + result.appliedRamMb + 'MB)로 진행합니다.'
ramMsg.innerHTML = tt('step3.sub33.ramMinOk', { system: result.systemRamMb, applied: result.appliedRamMb })
ramMsg.classList.add('warn')
} else {
ramMsg.textContent = '시스템 램(' + result.systemRamMb + 'MB) 충분. ' + result.appliedRamMb + 'MB로 설정.'
ramMsg.textContent = tt('step3.sub33.ramMaxOk', { system: result.systemRamMb, applied: result.appliedRamMb })
ramMsg.classList.add('success')
}
}
@@ -415,15 +450,15 @@ async function openEulaPopup(installPath) {
var read = await installerApi.readEula(installPath)
var bodyHtml = ''
if (read.exists) {
bodyHtml = '<p class="formMessage">서버 파일에 포함된 eula.txt 내용입니다.</p>' +
bodyHtml = '<p class="formMessage">' + tt('step3.eulaModal.fromFile') + '</p>' +
'<pre class="eulaPre">' + escapeHtml(read.content) + '</pre>'
} else {
var fetched = await installerApi.fetchMinecraftEula()
if (fetched.html) {
bodyHtml = '<p class="formMessage">서버 파일에 eula.txt가 없어 minecraft.net의 EULA를 표시합니다 (<a href="' + fetched.url + '" target="_blank">' + fetched.url + '</a>).</p>' +
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">EULA 페이지를 불러올 수 없습니다. 직접 확인해 주세요: <a href="https://www.minecraft.net/en-us/eula" target="_blank">https://www.minecraft.net/en-us/eula</a></p>'
bodyHtml = '<p class="formMessage error">' + tt('step3.eulaModal.loadFailed') + '</p>'
}
}
return new Promise(function (resolve) {
@@ -431,11 +466,11 @@ async function openEulaPopup(installPath) {
overlay.className = 'modalOverlay'
overlay.innerHTML =
'<div class="modalCard" role="dialog" aria-modal="true">' +
'<header><h3>Minecraft EULA 동의</h3><button type="button" class="modalClose" aria-label="닫기">×</button></header>' +
'<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">비동의</button>' +
'<button type="button" class="primaryBtn" data-action="accept">동의</button>' +
'<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)
@@ -468,18 +503,18 @@ function escapeAttr(text) {
function renderSubStep34(host, back, done) {
host.innerHTML =
'<h3>3-4. 서버 설정 편집</h3>' +
'<p class="formMessage">로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.</p>' +
'<button class="secondaryBtn" id="open">편집기 열기</button>' +
'<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">이전</button><button class="primaryBtn" id="next">다음</button></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 = '편집기 주소: <a href="' + result.url + '" target="_blank">' + result.url + '</a>'
msg.innerHTML = tt('step3.sub34.openedAt', { url: result.url })
} catch (err) {
msg.textContent = '편집기 실행 실패: ' + err.message
msg.textContent = tt('step3.sub34.openFailed', { message: err.message })
msg.classList.add('error')
}
})
@@ -489,12 +524,12 @@ function renderSubStep34(host, back, done) {
function renderSubStep35(host, back, done) {
host.innerHTML =
'<h3>3-5. 포트포워딩 점검</h3>' +
'<p class="formMessage">서버의 외부 접근 가능 여부를 확인합니다. UPnP를 시도해도 안 되면 직접 포트포워딩을 안내합니다.</p>' +
'<div class="fieldset"><label>포트 <input id="port" type="text" value="25565" /></label></div>' +
'<button class="secondaryBtn" id="run">재점검</button>' +
'<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">이전</button><button class="primaryBtn" id="next" disabled>다음</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>'
var resultMsg = host.querySelector('#resultMsg')
var nextBtn = host.querySelector('#next')
var runBtn = host.querySelector('#run')
@@ -503,25 +538,26 @@ function renderSubStep35(host, back, done) {
async function runCheck() {
runBtn.disabled = true
resultMsg.classList.remove('success', 'warn', 'error')
resultMsg.textContent = '확인 중...'
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
if (result.status === 'preForwarded') {
resultMsg.innerHTML = '이미 외부 접속 가능: ' + result.externalIp + ':' + result.port
resultMsg.innerHTML = tt('step3.sub35.preForwarded', { ip: result.externalIp, port: result.port })
resultMsg.classList.add('success')
} else if (result.status === 'upnpOk') {
resultMsg.innerHTML = 'UPnP로 자동 개방 완료: ' + result.externalIp + ':' + result.port
resultMsg.innerHTML = tt('step3.sub35.upnpOk', { ip: result.externalIp, port: result.port })
resultMsg.classList.add('success')
} else {
resultMsg.innerHTML = (result.message || '직접 포트포워딩을 해주세요.') +
'<br><small>외부 IP: ' + (result.externalIp || '확인 불가') + ', 포트: ' + result.port + '</small>'
var ip = result.externalIp || tt('step3.sub35.ipUnknown')
resultMsg.innerHTML = (result.message || tt('step3.sub35.manualHint')) +
tt('step3.sub35.manualDetail', { ip: ip, port: result.port })
resultMsg.classList.add('warn')
}
nextBtn.disabled = false
} catch (err) {
resultMsg.textContent = '점검 실패: ' + (err && err.message ? err.message : err)
resultMsg.textContent = tt('step3.sub35.checkFailed', { message: (err && err.message) ? err.message : String(err) })
resultMsg.classList.add('error')
} finally {
runBtn.disabled = false
@@ -541,7 +577,7 @@ function renderStep4() {
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>4단계. 유저 클라이언트 설정</h2>' +
'<h2>' + tt('step4.heading') + '</h2>' +
'<div class="subStep" id="subHost"></div>'
pageHost.appendChild(section)
var subHost = section.querySelector('#subHost')
@@ -564,23 +600,23 @@ function renderSubStep41(host, pack, back, done) {
if (platformType === 'vanilla') {
state.client.installPlatform = false
host.innerHTML =
'<h3>4-1. 모드 플랫폼</h3>' +
'<p class="formMessage">선택한 음악퀴즈의 플랫폼: <strong>vanilla</strong></p>' +
'<p class="formMessage">바닐라이므로 별도 설치는 필요 없습니다.</p>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
'<h3>' + tt('step4.sub41.heading') + '</h3>' +
'<p class="formMessage">' + tt('step4.sub41.vanillaInfo') + '</p>' +
'<p class="formMessage">' + tt('step4.sub41.vanillaNoInstall') + '</p>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
host.querySelector('#back').addEventListener('click', back)
host.querySelector('#next').addEventListener('click', done)
return
}
host.innerHTML =
'<h3>4-1. 모드 플랫폼</h3>' +
'<p class="formMessage">선택한 음악퀴즈의 플랫폼: <strong>' + platformType + '</strong></p>' +
'<h3>' + tt('step4.sub41.heading') + '</h3>' +
'<p class="formMessage">' + tt('step4.sub41.info', { platform: platformType }) + '</p>' +
'<div class="cardChoice">' +
'<button type="button" data-choice="install"><strong>권장 플랫폼 설치</strong><br><small>' + platformType + ' 설치파일을 함께 다운로드해 4-2 설치 시작 시 함께 설치됩니다.</small></button>' +
'<button type="button" data-choice="skip"><strong>기본 마인크래프트로 설치</strong><br><small>플랫폼은 설치하지 않고 바닐라로 진행합니다.</small></button>' +
'<button type="button" data-choice="install"><strong>' + tt('step4.sub41.installTitle') + '</strong><br><small>' + tt('step4.sub41.installHint', { platform: platformType }) + '</small></button>' +
'<button type="button" data-choice="skip"><strong>' + tt('step4.sub41.skipTitle') + '</strong><br><small>' + tt('step4.sub41.skipHint') + '</small></button>' +
'</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</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>'
var nextBtn = host.querySelector('#next')
var choiceButtons = host.querySelectorAll('[data-choice]')
@@ -610,10 +646,10 @@ function renderSubStep41(host, pack, back, done) {
function renderSubStep42(host, back, done) {
host.innerHTML =
'<h3>4-2. 모드/리소스팩 다운로드 및 launcher_profiles 갱신</h3>' +
'<p class="formMessage">%appdata%\\.mc_custom 에 모드와 리소스팩을 설치하고, launcher_profiles.json에 프로필을 등록합니다.</p>' +
'<div class="formMessage" id="msg">설치 중...</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
'<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)
@@ -621,7 +657,7 @@ function renderSubStep42(host, back, done) {
// 이미 설치됐다면 다시 돌리지 않음
if (state.client.clientInstalled) {
msg.textContent = '클라이언트 설치 완료.'
msg.textContent = tt('step4.sub42.done')
msg.classList.add('success')
nextBtn.disabled = false
return
@@ -634,12 +670,12 @@ function renderSubStep42(host, back, done) {
packKey: state.selectedPackKey,
installPlatform: !!state.client.installPlatform
})
msg.textContent = '클라이언트 설치 완료.'
msg.textContent = tt('step4.sub42.done')
msg.classList.add('success')
state.client.clientInstalled = true
nextBtn.disabled = false
} catch (err) {
msg.textContent = '설치 실패: ' + (err && err.message ? err.message : err)
msg.textContent = tt('step4.sub42.failed', { message: (err && err.message) ? err.message : String(err) })
msg.classList.add('error')
}
})()
@@ -647,9 +683,9 @@ function renderSubStep42(host, back, done) {
function renderSubStep43(host, back, done) {
host.innerHTML =
'<h3>4-3. 완료 확인</h3>' +
'<p class="formMessage">모드와 리소스팩이 .mc_custom에 설치되어 있고, launcher_profiles.json도 갱신되었습니다.</p>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">5단계로</button></div>'
'<h3>' + tt('step4.sub43.heading') + '</h3>' +
'<p class="formMessage">' + tt('step4.sub43.description') + '</p>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('step4.sub43.goStep5') + '</button></div>'
host.querySelector('#back').addEventListener('click', back)
host.querySelector('#next').addEventListener('click', done)
}
@@ -661,19 +697,19 @@ function renderStep5() {
section.className = 'page'
var multi = state.mode === 'multi'
section.innerHTML =
'<h2>5단계. 설치 완료</h2>' +
'<p>모든 단계가 끝났습니다. 아래 옵션을 선택해 주세요.</p>' +
'<h2>' + tt('step5.heading') + '</h2>' +
'<p>' + tt('step5.summary') + '</p>' +
(multi ? '<div class="subStep">' +
'<h3>서버</h3>' +
'<button class="secondaryBtn" id="openFolder">서버 폴더 열기</button>' +
'<label class="toggleRow"><input type="checkbox" id="shortcut" checked /> 바탕화면에 서버 실행 바로가기 만들기</label>' +
'<label class="toggleRow"><input type="checkbox" id="startServer" checked /> 서버 바로 실행</label>' +
'<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>마인크래프트 런처</h3>' +
'<label class="toggleRow"><input type="checkbox" id="startLauncher" checked /> 마인크래프트 런처 실행</label>' +
'<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">이전</button><button class="primaryBtn" id="finish">완료</button></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 (multi) {
@@ -684,7 +720,7 @@ function renderStep5() {
section.querySelector('#finish').addEventListener('click', async function () {
var finishBtn = section.querySelector('#finish')
finishBtn.disabled = true
finishBtn.textContent = '마무리 중…'
finishBtn.textContent = tt('step5.finishing')
try {
if (multi) {
if (section.querySelector('#shortcut').checked) await installerApi.createDesktopShortcut()
@@ -694,9 +730,16 @@ function renderStep5() {
} catch (err) {
// 마무리 액션 실패는 무시하고 종료 진행
}
finishBtn.textContent = '완료됨'
finishBtn.textContent = tt('step5.finished')
if (installerApi.quitApp) installerApi.quitApp()
})
}
renderStep1()
// 시작 진입점: 사전을 먼저 받아서 정적 텍스트 갱신 후 첫 페이지 렌더.
;(async function () {
try {
I18N = (await installerApi.loadLocale()) || {}
} catch (_) { I18N = {} }
applyStaticI18n()
renderStep1()
})()