'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 = '

' + tt('step1.heading') + '

' + '

' + tt('step1.loading') + '

' + '
' 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') + '

' + '
' + '' + '' + '
' + '' + '
' pageHost.appendChild(section) var nextBtn = section.querySelector('#next') var modeButtons = section.querySelectorAll('[data-mode]') var roleSection = section.querySelector('#roleSection') var roleButtons = section.querySelectorAll('[data-role]') 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') }) if (mode === 'multi') { roleSection.hidden = false // 역할이 이미 골라져 있으면 그대로, 아니면 사용자가 골라야 next 활성화. nextBtn.disabled = !state.role } else { roleSection.hidden = true state.role = null roleButtons.forEach(function (btn) { btn.classList.remove('selected') }) nextBtn.disabled = false } } 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 } modeButtons.forEach(function (btn) { btn.addEventListener('click', function () { applyMode(btn.getAttribute('data-mode')) }) }) roleButtons.forEach(function (btn) { btn.addEventListener('click', function () { applyRole(btn.getAttribute('data-role')) }) }) if (state.mode === 'single' || state.mode === 'multi') { applyMode(state.mode) if (state.mode === 'multi' && state.role) applyRole(state.role) } nextBtn.addEventListener('click', function () { if (!state.mode) return if (state.mode === 'multi' && !state.role) return state.stepDone[2] = true // 멀티+호스트 만 서버 설치(step3) 를 거친다. // 싱글, 멀티+참가자 는 곧장 클라이언트(step4) 로. if (state.mode === 'multi' && state.role === 'host') renderStep3() else renderStep4() }) 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') + '

' + '
' + '
' + '
' + '
' 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') + '

' + '
' + '' + '' + '
' + '
' + '
' 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') + '
' + '' + '
' 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 = '

' + 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 = '' 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 = '

' + tt('step3.sub34.heading') + '

' + '

' + tt('step3.sub34.description') + '

' + '' + '
' + '
' 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') + '

' + '
' + '' + '
' + '
' 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 = '

' + tt('step4.heading') + '

' + '
' pageHost.appendChild(section) var subHost = section.querySelector('#subHost') // 플랫폼 선택 UI 는 더 이상 보여주지 않는다. 음악퀴즈에 지정된 플랫폼이 // 바닐라가 아니면 자동으로 설치하고, 바닐라면 건너뛴다 — 사용자가 고를 일이 없다. var platformType = pack ? pack.pack.platform.type : 'vanilla' state.client.installPlatform = platformType !== 'vanilla' // 멀티+호스트 만 step3 (서버 설치) 를 거쳤으므로 거기로 돌아간다. // 싱글 / 멀티+참가자 는 step2 로 되돌아간다. function backToPrevStep() { if (state.mode === 'multi' && state.role === 'host') renderStep3() 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 = '

' + tt('step4.sub42.heading') + '

' + '

' + tt('step4.sub42.description') + '

' + '
' + tt('step4.sub42.installing') + '
' + '
' 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, // 참가자는 친구 서버에 접속만 하므로 클라이언트에 맵을 풀지 않는다. skipMap: state.mode === 'multi' && state.role === 'participant' }) 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 renderStep5() { setActiveStep(5) clearPage() var section = document.createElement('section') section.className = 'page' // 서버 마무리 액션 (바로가기/서버 실행) 은 step3 를 거친 호스트 만 노출한다. // 싱글, 멀티+참가자 는 서버를 직접 띄우지 않으므로 런처만 보여준다. var showServerActions = state.mode === 'multi' && state.role === 'host' section.innerHTML = '

' + tt('step5.heading') + '

' + '

' + tt('step5.summary') + '

' + (showServerActions ? '
' + '

' + tt('step5.serverHeading') + '

' + '' + '' + '' + '
' : '') + '
' + '

' + tt('step5.launcherHeading') + '

' + '' + '
' + '
' 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() })()