'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 renderIntro() { setActiveStep(1) clearPage() var section = document.createElement('section') section.className = 'page' section.innerHTML = '
' + tt('intro.message') + '
' + '' pageHost.appendChild(section) section.querySelector('#introNext').addEventListener('click', renderStep1) } function renderStep1() { setActiveStep(1) clearPage() var section = document.createElement('section') section.className = 'page' section.innerHTML = '' + tt('step1.loading') + '
' + tt('step1.empty') + '
' return } state.packs.forEach(function (pack) { var btn = document.createElement('button') btn.type = 'button' btn.innerHTML = '' + pack.name + '' + tt('step1.fetchFailed', { message: err.message }) + '
' } })() } // 약관 동의 페이지: 음악퀴즈 선택 직후, 싱글/멀티 선택(step2) 진입 전에 노출. // v0.3.4~ : 어떤 약관을 표시할지는 사이트(/manifest/terms/' + tt('agreement.loading') + '
' 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 = '' + tt('agreement.listLoadFailed', { message: message }) + '
' + '' + tt('agreement.intro') + '
' + '' + tt('agreement.loadFailed', { message: res.message || '' }) + '
' return } var html = renderTermsMarkdown(res.content || '') cache[kind] = html body.innerHTML = html }).catch(function (err) { body.innerHTML = '' + tt('agreement.loadFailed', { message: err.message }) + '
' }) } 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, '>') } function inline(s) { s = escHtml(s) s = s.replace(/`([^`]+)`/g, '$1')
s = s.replace(/\*\*([^*]+)\*\*/g, '$1')
s = s.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, '$1$2')
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
s = s.replace(/(^|[\s(])(https?:\/\/[^\s)]+)/g, function (m, p, u) {
return p + '' + u + ''
})
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('' + escHtml(code.join('\n')) + '')
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('' + renderTermsMarkdown(q.join('\n')) + '') continue } var ol = /^\s*\d+\.\s+(.*)$/.exec(line) if (ol) { if (stack !== 'ol') { closeList(); out.push('
' + inline(para.join('\n').replace(/\n/g, '
')) + '
' + tt('step3.sub31.description') + '
' + '' + tt('step3.sub32.description') + '
' + '' + tt('step3.sub33.description') + '
' + '' + 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.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.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.sub42.description') + '
' + '' + tt('step5.summary') + '
' + (showServerActions ? '