'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'
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 =
'
' + tt('step1.heading') + ' ' +
'' + tt('step1.loading') + '
' +
'' + tt('common.next') + '
'
pageHost.appendChild(section)
var listEl = section.querySelector('#packList')
var nextBtn = section.querySelector('#next')
function renderList() {
listEl.innerHTML = ''
if (state.packs.length === 0) {
listEl.innerHTML = '' + tt('step1.empty') + '
'
return
}
state.packs.forEach(function (pack) {
var btn = document.createElement('button')
btn.type = 'button'
btn.innerHTML = '' + pack.name + ' ' +
tt('step1.subtitle', { mc: pack.pack.mcVersion, platform: pack.pack.platform.type }) + ' '
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
renderStep2()
})
;(async function () {
try {
var packs = await installerApi.loadPacks()
state.packs = packs
renderList()
} catch (err) {
listEl.innerHTML = '' + tt('step1.fetchFailed', { message: err.message }) + '
'
}
})()
}
function renderStep2() {
setActiveStep(2)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'' + tt('step2.heading') + ' ' +
'' +
'' + tt('step2.singleTitle') + ' ' + tt('step2.singleHint') + ' ' +
'' + tt('step2.multiTitle') + ' ' + tt('step2.multiHint') + ' ' +
'
' +
'' + tt('common.back') + ' ' + tt('common.next') + '
'
pageHost.appendChild(section)
var nextBtn = section.querySelector('#next')
var modeButtons = section.querySelectorAll('[data-mode]')
function applySelection(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
}
modeButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
applySelection(btn.getAttribute('data-mode'))
})
})
if (state.mode === 'single' || state.mode === 'multi') applySelection(state.mode)
nextBtn.addEventListener('click', function () {
if (!state.mode) return
state.stepDone[2] = true
if (state.mode === 'single') renderStep4()
else renderStep3()
})
section.querySelector('#back').addEventListener('click', renderStep1)
}
function renderStep3() {
setActiveStep(3)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'' + tt('step3.heading') + ' ' +
'
'
pageHost.appendChild(section)
var subHost = section.querySelector('#subHost')
function show31() { subHost.innerHTML = ''; renderSubStep31(subHost, renderStep2, 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 =
'' + tt('step3.sub31.heading') + ' ' +
'' + tt('step3.sub31.description') + '
' +
' ' +
'' + tt('step3.sub31.pickFolder') + '
' +
'
' +
'' + tt('common.back') + ' ' + tt('common.next') + '
'
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 =
'' + tt('step3.sub32.heading') + ' ' +
'' + tt('step3.sub32.description') + '
' +
' ' +
'' + tt('step3.sub32.pickFolder') + ' ' +
'' + tt('step3.sub32.auto') + ' ' +
'' + tt('step3.sub32.install') + '
' +
'
' +
'' + tt('common.back') + ' ' + tt('common.next') + '
'
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 =
'' + tt('step3.sub33.heading') + ' ' +
'' + tt('step3.sub33.description') + '
' +
'' + tt('step3.sub33.waiting') + '
' +
'' +
'
' + tt('step3.sub33.ramHeading') + ' ' +
'
' + tt('step3.sub33.ramChecking') + '
' +
'
' +
'' + tt('common.back') + ' ' + tt('common.next') + '
'
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) = 비동의/창 닫힘.
async function openEulaPopup(installPath) {
var read = await installerApi.readEula(installPath)
var bodyHtml = ''
if (read.exists) {
bodyHtml = '' + tt('step3.eulaModal.fromFile') + '
' +
'' + escapeHtml(read.content) + ' '
} else {
var fetched = await installerApi.fetchMinecraftEula()
if (fetched.html) {
bodyHtml = '' + tt('step3.eulaModal.fromMojang', { url: fetched.url }) + '
' +
''
} else {
bodyHtml = '' + tt('step3.eulaModal.loadFailed') + '
'
}
}
return new Promise(function (resolve) {
var overlay = document.createElement('div')
overlay.className = 'modalOverlay'
overlay.innerHTML =
'' +
'
' + tt('step3.eulaModal.title') + ' × ' +
'
' + bodyHtml + '
' +
'
' +
'' + tt('common.reject') + ' ' +
'' + tt('common.agree') + ' ' +
' ' +
'
'
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 escapeHtml(text) {
return String(text).replace(/[&<>"']/g, function (ch) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]
})
}
function escapeAttr(text) {
return String(text).replace(/&/g, '&').replace(/"/g, '"')
}
function renderSubStep34(host, back, done) {
host.innerHTML =
'' + tt('step3.sub34.heading') + ' ' +
'' + tt('step3.sub34.description') + '
' +
'' + tt('step3.sub34.open') + ' ' +
'
' +
'' + tt('common.back') + ' ' + tt('common.next') + '
'
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 =
'' + tt('step3.sub35.heading') + ' ' +
'' + tt('step3.sub35.description') + '
' +
'' + tt('step3.sub35.portLabel') + '
' +
'' + tt('step3.sub35.recheck') + ' ' +
'
' +
'' + tt('common.back') + ' ' + tt('common.next') + '
'
var resultMsg = host.querySelector('#resultMsg')
var nextBtn = host.querySelector('#next')
var runBtn = host.querySelector('#run')
host.querySelector('#back').addEventListener('click', back)
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
if (result.status === 'preForwarded') {
resultMsg.innerHTML = tt('step3.sub35.preForwarded', { ip: result.externalIp, port: result.port })
resultMsg.classList.add('success')
} else if (result.status === 'upnpOk') {
resultMsg.innerHTML = tt('step3.sub35.upnpOk', { ip: result.externalIp, port: result.port })
resultMsg.classList.add('success')
} else {
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 = 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 =
'' + tt('step4.heading') + ' ' +
'
'
pageHost.appendChild(section)
var subHost = section.querySelector('#subHost')
function backToPrevStep() { if (state.mode === 'multi') renderStep3(); else renderStep2() }
function show41() { subHost.innerHTML = ''; renderSubStep41(subHost, pack, backToPrevStep, show42) }
function show42() { subHost.innerHTML = ''; renderSubStep42(subHost, show41, show43) }
function show43() {
subHost.innerHTML = ''
renderSubStep43(subHost, show42, function () {
state.stepDone[4] = true
renderStep5()
})
}
show41()
}
function renderSubStep41(host, pack, back, done) {
var platformType = pack ? pack.pack.platform.type : 'vanilla'
if (platformType === 'vanilla') {
state.client.installPlatform = false
host.innerHTML =
'' + tt('step4.sub41.heading') + ' ' +
'' + tt('step4.sub41.vanillaInfo') + '
' +
'' + tt('step4.sub41.vanillaNoInstall') + '
' +
'' + tt('common.back') + ' ' + tt('common.next') + '
'
host.querySelector('#back').addEventListener('click', back)
host.querySelector('#next').addEventListener('click', done)
return
}
host.innerHTML =
'' + tt('step4.sub41.heading') + ' ' +
'' + tt('step4.sub41.info', { platform: platformType }) + '
' +
'' +
'' + tt('step4.sub41.installTitle') + ' ' + tt('step4.sub41.installHint', { platform: platformType }) + ' ' +
'' + tt('step4.sub41.skipTitle') + ' ' + tt('step4.sub41.skipHint') + ' ' +
'
' +
'' + tt('common.back') + ' ' + tt('common.next') + '
'
var nextBtn = host.querySelector('#next')
var choiceButtons = host.querySelectorAll('[data-choice]')
function applyChoice(choice) {
state.client.installPlatform = choice === 'install'
choiceButtons.forEach(function (btn) {
if (btn.getAttribute('data-choice') === choice) btn.classList.add('selected')
else btn.classList.remove('selected')
})
nextBtn.disabled = false
}
choiceButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
applyChoice(btn.getAttribute('data-choice'))
})
})
if (typeof state.client.installPlatform === 'boolean') {
applyChoice(state.client.installPlatform ? 'install' : 'skip')
}
host.querySelector('#back').addEventListener('click', back)
nextBtn.addEventListener('click', done)
}
function renderSubStep42(host, back, done) {
host.innerHTML =
'' + tt('step4.sub42.heading') + ' ' +
'' + tt('step4.sub42.description') + '
' +
'' + tt('step4.sub42.installing') + '
' +
'' + tt('common.back') + ' ' + tt('common.next') + '
'
var msg = host.querySelector('#msg')
var nextBtn = host.querySelector('#next')
host.querySelector('#back').addEventListener('click', back)
nextBtn.addEventListener('click', done)
// 이미 설치됐다면 다시 돌리지 않음
if (state.client.clientInstalled) {
msg.textContent = tt('step4.sub42.done')
msg.classList.add('success')
nextBtn.disabled = false
return
}
// 페이지 진입 즉시 자동 설치
;(async function () {
try {
await installerApi.installClient({
packKey: state.selectedPackKey,
installPlatform: !!state.client.installPlatform
})
msg.textContent = tt('step4.sub42.done')
msg.classList.add('success')
state.client.clientInstalled = true
nextBtn.disabled = false
} catch (err) {
msg.textContent = tt('step4.sub42.failed', { message: (err && err.message) ? err.message : String(err) })
msg.classList.add('error')
}
})()
}
function renderSubStep43(host, back, done) {
host.innerHTML =
'' + tt('step4.sub43.heading') + ' ' +
'' + tt('step4.sub43.description') + '
' +
'' + tt('common.back') + ' ' + tt('step4.sub43.goStep5') + '
'
host.querySelector('#back').addEventListener('click', back)
host.querySelector('#next').addEventListener('click', done)
}
function renderStep5() {
setActiveStep(5)
clearPage()
var section = document.createElement('section')
section.className = 'page'
var multi = state.mode === 'multi'
section.innerHTML =
'' + tt('step5.heading') + ' ' +
'' + tt('step5.summary') + '
' +
(multi ? '' +
'
' + tt('step5.serverHeading') + ' ' +
'' + tt('step5.openServerFolder') + ' ' +
' ' + tt('step5.shortcut') + ' ' +
' ' + tt('step5.startServer') + ' ' +
'' : '') +
'' +
'
' + tt('step5.launcherHeading') + ' ' +
' ' + tt('step5.startLauncher') + ' ' +
'' +
'' + tt('common.back') + ' ' + tt('step5.finish') + '
'
pageHost.appendChild(section)
section.querySelector('#back').addEventListener('click', renderStep4)
if (multi) {
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 (multi) {
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()
})()