diff --git a/installer/renderer.js b/installer/renderer.js index 9daee53..7e65d59 100644 --- a/installer/renderer.js +++ b/installer/renderer.js @@ -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 = - '

1단계. 설치할 음악퀴즈 선택

' + - '

목록을 불러오는 중...

' + - '
' + '

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

' + + '

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

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

등록된 음악퀴즈가 없습니다.

' + listEl.innerHTML = '

' + tt('step1.empty') + '

' return } state.packs.forEach(function (pack) { var btn = document.createElement('button') btn.type = 'button' - btn.innerHTML = '' + pack.name + '
마인크래프트 ' + pack.pack.mcVersion + ' / ' + pack.pack.platform.type + '' + 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 @@ -106,7 +138,7 @@ function renderStep1() { state.packs = packs renderList() } catch (err) { - listEl.innerHTML = '

목록을 가져오지 못했습니다: ' + err.message + '

' + listEl.innerHTML = '

' + tt('step1.fetchFailed', { message: err.message }) + '

' } })() } @@ -117,12 +149,12 @@ function renderStep2() { var section = document.createElement('section') section.className = 'page' section.innerHTML = - '

2단계. 싱글 / 멀티 선택

' + + '

' + tt('step2.heading') + '

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

3단계. 서버 관련 설정

' + + '

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

' + '
' pageHost.appendChild(section) var subHost = section.querySelector('#subHost') @@ -180,12 +212,12 @@ function renderStep3() { function renderSubStep31(host, back, done) { host.innerHTML = - '

3-1. 서버 설치 경로

' + - '

서버를 생성할 폴더를 선택하세요. 경로에 한글이 포함되면 안 됩니다.

' + + '

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

3-2. JDK 확인

' + - '

JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 없으면 "자동 설치" 로 Temurin 21 을 받아 설치할 수 있습니다.

' + + '

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

' + + '

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

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

3-3. 서버 다운로드 및 설치

' + - '

선택한 음악퀴즈의 서버 파일을 다운로드합니다. 진행 상황은 하단 로그 뷰어에 표시됩니다.

' + - '
대기 중
' + + '

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

' + + '

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

' + + '
' + tt('step3.sub33.waiting') + '
' + '' + - '
' + '
' 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 = '

서버 파일에 포함된 eula.txt 내용입니다.

' + + bodyHtml = '

' + tt('step3.eulaModal.fromFile') + '

' + '
' + escapeHtml(read.content) + '
' } else { var fetched = await installerApi.fetchMinecraftEula() if (fetched.html) { - bodyHtml = '

서버 파일에 eula.txt가 없어 minecraft.net의 EULA를 표시합니다 (' + fetched.url + ').

' + + bodyHtml = '

' + tt('step3.eulaModal.fromMojang', { url: fetched.url }) + '

' + '' } else { - bodyHtml = '

EULA 페이지를 불러올 수 없습니다. 직접 확인해 주세요: https://www.minecraft.net/en-us/eula

' + bodyHtml = '

' + tt('step3.eulaModal.loadFailed') + '

' } } return new Promise(function (resolve) { @@ -431,11 +466,11 @@ async function openEulaPopup(installPath) { overlay.className = 'modalOverlay' overlay.innerHTML = '' document.body.appendChild(overlay) @@ -468,18 +503,18 @@ function escapeAttr(text) { function renderSubStep34(host, back, done) { host.innerHTML = - '

3-4. 서버 설정 편집

' + - '

로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.

' + - '' + + '

' + 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 = '편집기 주소: ' + result.url + '' + 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 = - '

3-5. 포트포워딩 점검

' + - '

서버의 외부 접근 가능 여부를 확인합니다. UPnP를 시도해도 안 되면 직접 포트포워딩을 안내합니다.

' + - '
' + - '' + + '

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

' + + '

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

' + + '
' + + '' + '
' + - '
' + '
' 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 || '직접 포트포워딩을 해주세요.') + - '
외부 IP: ' + (result.externalIp || '확인 불가') + ', 포트: ' + result.port + '' + 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 = - '

4단계. 유저 클라이언트 설정

' + + '

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

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

4-1. 모드 플랫폼

' + - '

선택한 음악퀴즈의 플랫폼: vanilla

' + - '

바닐라이므로 별도 설치는 필요 없습니다.

' + - '
' + '

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

' + + '

' + tt('step4.sub41.vanillaInfo') + '

' + + '

' + tt('step4.sub41.vanillaNoInstall') + '

' + + '
' host.querySelector('#back').addEventListener('click', back) host.querySelector('#next').addEventListener('click', done) return } host.innerHTML = - '

4-1. 모드 플랫폼

' + - '

선택한 음악퀴즈의 플랫폼: ' + platformType + '

' + + '

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

' + + '

' + tt('step4.sub41.info', { platform: platformType }) + '

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

4-2. 모드/리소스팩 다운로드 및 launcher_profiles 갱신

' + - '

%appdata%\\.mc_custom 에 모드와 리소스팩을 설치하고, launcher_profiles.json에 프로필을 등록합니다.

' + - '
설치 중...
' + - '
' + '

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

4-3. 완료 확인

' + - '

모드와 리소스팩이 .mc_custom에 설치되어 있고, launcher_profiles.json도 갱신되었습니다.

' + - '
' + '

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

' + + '

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

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

5단계. 설치 완료

' + - '

모든 단계가 끝났습니다. 아래 옵션을 선택해 주세요.

' + + '

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

' + + '

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

' + (multi ? '
' + - '

서버

' + - '' + - '' + - '' + + '

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

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

마인크래프트 런처

' + - '' + + '

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

' + + '' + '
' + - '
' + '
' 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() +})() diff --git a/locales/installer/ko-kr.json b/locales/installer/ko-kr.json new file mode 100644 index 0000000..91a6cb3 --- /dev/null +++ b/locales/installer/ko-kr.json @@ -0,0 +1,302 @@ +{ + "common": { + "back": "이전", + "next": "다음", + "ok": "확인", + "cancel": "취소", + "close": "닫기", + "agree": "동의", + "reject": "비동의", + "apply": "적용", + "save": "저장", + "load": "불러오기", + "expand": "펼치기", + "collapse": "접기", + "saved": "저장 완료", + "saveFailed": "저장 실패", + "unknownError": "알 수 없는 오류" + }, + "app": { + "browserTitle": "마인크래프트 음악퀴즈 간편설치기", + "headerTitle": "마인크래프트 음악퀴즈 간편설치기" + }, + "stepIndicator": { + "step1": "1. 음악퀴즈", + "step2": "2. 모드", + "step3": "3. 서버", + "step4": "4. 클라이언트", + "step5": "5. 완료" + }, + "logViewer": { + "title": "설치 로그" + }, + "step1": { + "heading": "1단계. 설치할 음악퀴즈 선택", + "loading": "목록을 불러오는 중...", + "empty": "등록된 음악퀴즈가 없습니다.", + "fetchFailed": "목록을 가져오지 못했습니다: {{message}}", + "subtitle": "마인크래프트 {{mc}} / {{platform}}" + }, + "step2": { + "heading": "2단계. 싱글 / 멀티 선택", + "singleTitle": "싱글", + "singleHint": "혼자 즐기는 모드. 4단계만 진행합니다.", + "multiTitle": "멀티", + "multiHint": "친구들과 함께. 3단계 서버 설치 후 4단계를 진행합니다." + }, + "step3": { + "heading": "3단계. 서버 관련 설정", + "sub31": { + "heading": "3-1. 서버 설치 경로", + "description": "서버를 생성할 폴더를 선택하세요. 경로에 한글이 포함되면 안 됩니다.", + "pickFolder": "폴더 선택", + "invalidPath": "경로가 유효하지 않습니다.", + "confirmed": "경로 확정: {{message}}" + }, + "sub32": { + "heading": "3-2. JDK 확인", + "description": "JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 없으면 \"자동 설치\" 로 Temurin 21 을 받아 설치할 수 있습니다.", + "pickFolder": "폴더 선택", + "auto": "자동 탐색", + "install": "자동 설치", + "installCancel": "설치 취소", + "found": "JDK 발견: {{path}}", + "autoDetected": "JDK 자동 탐색됨: {{path}}", + "notFound": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 눌러 Temurin 21 을 설치하거나 직접 선택해 주세요.", + "notFoundHint": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 누르면 Temurin 21 LTS 를 받아 설치합니다.", + "cancelRequested": "JDK 설치 취소 요청 중...", + "downloading": "Temurin 21 다운로드 중... (네트워크 상태에 따라 1~5분)", + "installComplete": "JDK 자동 설치 완료: {{path}}", + "installCanceled": "JDK 설치 취소됨", + "installFailed": "JDK 설치 실패: {{message}}", + "installError": "JDK 설치 오류: {{message}}", + "pathRequired": "JDK 경로를 입력해 주세요." + }, + "sub33": { + "heading": "3-3. 서버 다운로드 및 설치", + "description": "선택한 음악퀴즈의 서버 파일을 다운로드합니다. 진행 상황은 하단 로그 뷰어에 표시됩니다.", + "waiting": "대기 중", + "downloading": "다운로드 중...", + "ramHeading": "램 검사", + "ramChecking": "검사 중...", + "eulaPrompt": "EULA 동의가 필요합니다. 팝업을 확인해 주세요.", + "eulaRejected": "EULA 동의 실패. 다운로드를 취소했습니다. 이전→다음으로 다시 시도하세요.", + "eulaSaveFailed": "EULA 저장 실패: {{message}}", + "doneSummary": "다운로드 및 EULA 동의 완료.", + "downloadFailed": "다운로드 실패: {{message}}", + "ramTooLow": "시스템 램({{system}}MB)이 음악퀴즈 최소 요구치({{min}}MB)에 미치지 못합니다. 설치를 중단합니다.", + "ramMinOk": "시스템 램({{system}}MB)이 권장치보다 부족합니다. 최소치({{applied}}MB)로 진행합니다.", + "ramMaxOk": "시스템 램({{system}}MB) 충분. {{applied}}MB로 설정." + }, + "eulaModal": { + "title": "Minecraft EULA 동의", + "fromFile": "서버 파일에 포함된 eula.txt 내용입니다.", + "fromMojang": "서버 파일에 eula.txt가 없어 minecraft.net의 EULA를 표시합니다 ({{url}}).", + "loadFailed": "EULA 페이지를 불러올 수 없습니다. 직접 확인해 주세요: https://www.minecraft.net/en-us/eula" + }, + "sub34": { + "heading": "3-4. 서버 설정 편집", + "description": "로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.", + "open": "편집기 열기", + "openedAt": "편집기 주소: {{url}}", + "openFailed": "편집기 실행 실패: {{message}}" + }, + "sub35": { + "heading": "3-5. 포트포워딩 점검", + "description": "서버의 외부 접근 가능 여부를 확인합니다. UPnP를 시도해도 안 되면 직접 포트포워딩을 안내합니다.", + "portLabel": "포트", + "recheck": "재점검", + "checking": "확인 중...", + "preForwarded": "이미 외부 접속 가능: {{ip}}:{{port}}", + "upnpOk": "UPnP로 자동 개방 완료: {{ip}}:{{port}}", + "manualHint": "직접 포트포워딩을 해주세요.", + "manualDetail": "
외부 IP: {{ip}}, 포트: {{port}}", + "checkFailed": "점검 실패: {{message}}", + "ipUnknown": "확인 불가" + } + }, + "step4": { + "heading": "4단계. 유저 클라이언트 설정", + "sub41": { + "heading": "4-1. 모드 플랫폼", + "vanillaInfo": "선택한 음악퀴즈의 플랫폼: vanilla", + "vanillaNoInstall": "바닐라이므로 별도 설치는 필요 없습니다.", + "info": "선택한 음악퀴즈의 플랫폼: {{platform}}", + "installTitle": "권장 플랫폼 설치", + "installHint": "{{platform}} 설치파일을 함께 다운로드해 4-2 설치 시작 시 함께 설치됩니다.", + "skipTitle": "기본 마인크래프트로 설치", + "skipHint": "플랫폼은 설치하지 않고 바닐라로 진행합니다." + }, + "sub42": { + "heading": "4-2. 모드/리소스팩 다운로드 및 launcher_profiles 갱신", + "description": "%appdata%\\.mc_custom 에 모드와 리소스팩을 설치하고, launcher_profiles.json에 프로필을 등록합니다.", + "installing": "설치 중...", + "done": "클라이언트 설치 완료.", + "failed": "설치 실패: {{message}}" + }, + "sub43": { + "heading": "4-3. 완료 확인", + "description": "모드와 리소스팩이 .mc_custom에 설치되어 있고, launcher_profiles.json도 갱신되었습니다.", + "goStep5": "5단계로" + } + }, + "step5": { + "heading": "5단계. 설치 완료", + "summary": "모든 단계가 끝났습니다. 아래 옵션을 선택해 주세요.", + "serverHeading": "서버", + "openServerFolder": "서버 폴더 열기", + "shortcut": "바탕화면에 서버 실행 바로가기 만들기", + "startServer": "서버 바로 실행", + "launcherHeading": "마인크래프트 런처", + "startLauncher": "마인크래프트 런처 실행", + "finish": "완료", + "finishing": "마무리 중…", + "finished": "완료됨" + }, + "configEditor": { + "pageTitle": "서버 설정 편집기", + "heading": "서버 설정 편집기", + "intro": "아래 파일을 직접 편집한 후 \"적용\" 버튼으로 저장합니다. 설치기 화면에서 다음 단계로 진행하기 전 마음껏 편집할 수 있습니다.", + "targetLabel": "대상 파일", + "applyButton": "적용", + "saved": "저장 완료", + "saveFailed": "저장 실패", + "unknownFile": "알 수 없는 파일", + "serverError": "서버 오류: {{message}}" + }, + "errors": { + "requestTimeout": "요청 시간 초과", + "requestTimeout15s": "요청 시간 초과(15s)", + "canceled": "취소되었습니다.", + "canceledShort": "취소됨", + "packNotFound": "선택한 음악퀴즈를 찾을 수 없습니다.", + "packNotFound2": "음악퀴즈를 찾을 수 없습니다.", + "installPathRequired": "서버 설치 경로를 입력해 주세요.", + "installPathHangul": "경로에 한글이 포함되면 마인크래프트 서버가 정상 동작하지 않습니다.", + "installPathHangulShort": "경로에 한글이 포함되면 안 됩니다.", + "jdkBusy": "이미 JDK 설치가 진행 중입니다.", + "javaExeMissing": "설치 후 java 실행 파일을 찾지 못했습니다: {{path}}", + "javaSpawnFailed": "Java 실행 실패: {{message}}", + "fabricInstallerExit": "fabric-installer 종료 코드 {{code}}{{detail}}", + "fabricLoaderRequired": "Fabric 로더 버전이 음악퀴즈에 지정되지 않았습니다. 관리 사이트에서 platform.loaderVersion 을 설정해 주세요.", + "fabricInstallerListEmpty": "Fabric installer 목록을 받지 못했습니다.", + "portAllocFail": "포트를 할당할 수 없습니다.", + "upnpTimeout": "UPnP 응답 없음(타임아웃 15s). 라우터의 UPnP가 꺼져 있거나 SSDP 패킷이 차단됐을 수 있습니다.", + "parseResponseFailed": "응답 파싱 실패: {{snippet}}" + }, + "log": { + "manifestDownload": "manifest 다운로드: {{url}}", + "packLoadFail": "pack 로드 실패 ({{file}}): {{message}}", + "packsLoaded": "로드된 음악퀴즈: {{count}}개", + "selectedPack": "선택: {{key}}", + "jdkInstallStart": "JDK(Temurin 21) 자동 설치 시작 — 다운로드 중...", + "jdkDownloadProgress": "JDK 다운로드: {{percent}}% ({{loaded}}MB / {{total}}MB)", + "jdkExtracting": "JDK 압축 해제 중...", + "jdkDoneRoot": "JDK 자동 설치 완료: {{path}}", + "jdkCanceled": "JDK 설치가 취소되었습니다.", + "jdkInstallFailedLog": "JDK 설치 실패: {{message}}", + "jdkCancelRequested": "JDK 설치 취소 요청을 보냈습니다.", + "labelDownload": "{{label}} 다운로드: {{url}}", + "labelExtract": "{{label}} 압축 해제: {{dir}}", + "labelServerFile": "서버 파일", + "labelMap": "맵", + "skipServerZip": "서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.", + "skipMapZip": "맵 파일(mapPath)이 비어 있어 맵 다운로드를 건너뜁니다.", + "skipModsFolder": "modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.", + "modsIndexFetch": "모드 목록 조회: {{url}}", + "modsFolderEmpty": "/file/mods/{{folder}}/ 안에 .jar 파일이 없습니다.", + "modDownload": "모드 다운로드: {{file}}", + "skipResourcepack": "resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.", + "resourcepackDownload": "리소스팩 다운로드: {{url}}", + "serverInstallPath": "서버 설치 경로: {{path}}", + "runBatMissing": "run.bat 이 없어 UPnP 자동 등록 스크립트 주입을 건너뜁니다.", + "runBatAlreadyInjected": "run.bat 에 이미 UPnP 자동 등록 스크립트가 들어 있어 건너뜁니다.", + "runBatNoJava": "run.bat 에서 java 호출 라인을 찾지 못해 UPnP 자동 등록 주입을 건너뜁니다.", + "runBatInjected": "run.bat 에 서버 기동/종료 시 UPnP 자동 등록·해제 스크립트를 추가했습니다.", + "mojangEulaFetchFail": "Minecraft EULA 페이지 조회 실패: {{message}}", + "eulaAccepted": "EULA 동의 저장 완료.", + "configEditorOpen": "서버 설정 편집기 실행: {{url}}", + "portCheckStart": "포트포워딩 점검 시작: 포트 {{port}}", + "upnpCleanup": "이전 실행의 UPnP 매핑이 남아 있으면 제거합니다(중복 방지)...", + "externalIpHttp": "외부 IP 확인(HTTP): {{ip}}", + "externalIpHttpFail": "외부 IP 확인 실패(HTTP). UPnP 게이트웨이를 통한 조회 시도...", + "externalIpUpnp": "외부 IP 확인(UPnP): {{ip}}", + "externalIpUpnpFail": "UPnP 게이트웨이에서도 외부 IP를 얻지 못했습니다.", + "probeStart": "외부 포트체크 서비스(ifconfig.co)로 1차 점검합니다...", + "probeResult": "1차 점검 결과: {{verdict}} ({{detail}})", + "probeVerdictSuccess": "성공", + "probeVerdictFail": "실패", + "probeVerdictUnknown": "확인 불가", + "probePreForwarded": "외부에서 {{addr}}:{{port}} 접근 확인됨. 사용자 규칙으로 포워딩 됨.", + "ipUnknown": "(IP 미상)", + "upnpTryOpen": "UPnP로 포트 {{port}} 자동 개방 시도(TCP)...", + "upnpReqOk": "UPnP portMapping 요청 성공. 외부 접근을 재확인합니다.", + "upnpTryFail": "UPnP 시도 실패: {{message}}", + "upnpFailDetail": "UPnP 실패: {{message}}. 라우터에서 UPnP가 꺼져 있을 수 있습니다. 직접 포트포워딩을 해주세요.", + "upnpRecheck": "UPnP 적용 후 재점검 {{attempt}}/3...", + "upnpDone": "UPnP로 포트 {{port}} 자동 개방 완료. 테스트 매핑을 제거합니다(실제 개방은 run.bat 이 서버 기동 시 자동으로 처리).", + "upnpCleanupTest": "테스트용 UPnP 매핑을 정리합니다.", + "upnpFailReason1": "UPnP 매핑은 등록됐지만 외부 포트체크 서비스에서 연결이 닿지 않았습니다. ISP 차단, 이중 NAT, 또는 방화벽 설정을 확인하세요.", + "upnpFailReason2": "외부 포트체크 결과를 받지 못했습니다({{detail}}). UPnP 매핑은 등록됐을 수 있습니다.", + "upnpClientFail": "UPnP 클라이언트 생성 실패: {{message}}", + "upnpExternalTimeout": "UPnP externalIp 조회 타임아웃(8s).", + "upnpExternalErr": "UPnP externalIp 오류: {{message}}", + "portInUse": "포트 {{port}}이(가) 이미 사용 중. 임시 리스너 없이 외부 서비스 응답만으로 판정합니다.", + "listenerBindFail": "임시 리스너 바인딩 실패: {{message}}", + "detailListenerHit": "임시 리스너 도달={{value}}", + "detailListenerSkip": "임시 리스너=skip(포트 사용중)", + "detailIfconfig": "ifconfig.co reachable={{reachable}} ip={{ip}}", + "detailIfconfigFail": "ifconfig.co 실패={{error}}", + "detailNone": "결과 없음", + "upnpClientFailRemove": "UPnP 클라이언트 생성 실패(매핑 제거 단계): {{message}}", + "upnpRemoveTimeout": "UPnP 매핑 제거 응답 없음(타임아웃 8s). 라우터에 우리가 만든 규칙이 없을 수 있습니다.", + "upnpRemoveAttempt": "UPnP 매핑 제거 시도 결과: {{message}} (없으면 정상)", + "upnpRemoveDone": "UPnP 매핑 제거 완료(포트 {{port}}).", + "platformDownload": "플랫폼({{type}}) 다운로드: {{url}}", + "platformSaved": "플랫폼 설치파일 저장: {{path}} (사용자가 직접 실행하거나 마인크래프트 런처에서 인식할 수 있습니다.)", + "platformSkipped": "플랫폼 설치 건너뜀. 바닐라로 진행합니다.", + "fabricFetchInstallerList": "Fabric installer 최신 버전 조회 중...", + "fabricInstallerDownload": "Fabric installer {{version}} 다운로드: {{url}}", + "javaUsed": "Java 사용: {{path}}", + "fabricInstallStart": "Fabric 자동 설치 시작: {{mc}} / loader {{loader}} → {{dir}}", + "fabricInstallDone": "Fabric 자동 설치 완료.", + "launcherProfilesMissing": "launcher_profiles.json을 찾을 수 없습니다: {{path}}", + "javaArgsUpdated": "JVM 인수 갱신(메모리 + G1 GC 튜닝 추가): \"{{before}}\" → \"{{after}}\"", + "lastVersionId": "launcher_profiles 의 lastVersionId = {{id}}", + "versionMissingWarn": "경고: .minecraft/versions/{{id}} 가 없습니다. 마인크래프트 런처에서 해당 버전을 한 번 받아주거나, 플랫폼 설치를 먼저 마쳐주세요.", + "launcherProfilesUpdated": "launcher_profiles.json 갱신: 프로필 \"{{profile}}\", gameDir={{dir}}", + "minecraftRootMissing": ".minecraft 폴더가 없어 기존 설정 복사를 건너뜁니다.", + "settingCopyFail": "설정 복사 실패 ({{name}}): {{message}}", + "settingCopySummary": "기존 마인크래프트 설정 복사: 새로 복사 {{copied}}개 / 보존(이미 존재) {{skipped}}개.", + "settingCopyError": "기존 설정 복사 중 오류: {{message}}", + "runtimeDirMissing": ".minecraft/{{dir}} 가 없습니다. 마인크래프트 런처를 한 번 실행한 뒤 다시 시도해주세요.", + "runtimeDirExists": ".mc_custom/{{dir}} 가 실제 폴더로 이미 존재 — 건너뜀.", + "runtimeLinkCreated": "링크 생성: .mc_custom/{{dir}} → .minecraft/{{dir}}", + "runtimeLinkFail": "링크 생성 실패 ({{dir}}): {{message}}", + "shortcutCreated": "바로가기 생성: {{path}}", + "shortcutFailed": "바로가기 생성 실패", + "shortcutDescription": "음악퀴즈 서버 실행", + "runBatMissingPath": "run.bat을 찾을 수 없습니다: {{path}}", + "serverStartRequested": "서버 실행 요청 완료.", + "launcherUrlSchemeNonWin": "마인크래프트 런처 실행 요청 완료(URL 스킴, 비-Windows).", + "launcherFail": "런처 실행 실패: {{message}}", + "launcherExecShell": "마인크래프트 런처 실행({{label}}, 셸 경유): {{path}}", + "launcherExec": "마인크래프트 런처 실행({{label}}): {{path}}", + "launcherCandFail": "{{path}} 실행 실패: {{message}}", + "launcherAppsFolderTry": "AppsFolder 로 MS Store 런처 실행 시도: {{aumid}}", + "launcherAppsFolderFail": "AppsFolder 실행 실패: {{message}}", + "launcherUrlSchemeFallback": "마지막 시도: minecraft:// URL 스킴 (런처가 없으면 MS Store 가 열릴 수 있음).", + "launcherUrlSchemeFail": "URL 스킴 실행 실패: {{message}}.", + "launcherAllFail": "Minecraft Launcher 실행 시도가 모두 실패했습니다. minecraft.net 또는 Microsoft Store 에서 \"Minecraft Launcher\" 를 설치한 뒤 다시 시도해 주세요." + }, + "candidates": { + "winProgramFiles86": "Win32 설치(Program Files (x86))", + "winProgramFiles": "Win32 설치(Program Files)", + "winLegacy86": "Win32 설치(legacy Minecraft 폴더)", + "winLegacy": "Win32 설치(legacy Minecraft 폴더)", + "xboxGamePass": "Xbox / Game Pass", + "npmPortable": "npm/portable", + "appAliasMinecraft": "App Execution Alias(Minecraft.exe)", + "appAliasLauncher": "App Execution Alias(MinecraftLauncher.exe)" + } +} diff --git a/src/installer/main.ts b/src/installer/main.ts index 93011fd..a9528f0 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -21,9 +21,14 @@ import type { import type { Manifest, PackDefinition } from '../shared/types.js' import { normalizePackDefinition } from '../shared/store.js' import { loadEnv, getManifestUrl } from '../shared/env.js' +import { loadComponentI18n } from '../shared/i18n.js' loadEnv() +const i18n = loadComponentI18n('installer') +const t = i18n.t +export const localeDict = i18n.dict + interface InstallerState { manifestUrl: string baseUrl: string @@ -100,7 +105,7 @@ function fetchBuffer(url: string): Promise { response.on('end', () => resolve(Buffer.concat(chunks))) }) request.on('error', reject) - request.on('timeout', () => request.destroy(new Error('요청 시간 초과'))) + request.on('timeout', () => request.destroy(new Error(t('errors.requestTimeout')))) }) } @@ -124,7 +129,7 @@ ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise< state.manifestUrl = manifestUrlInput state.baseUrl = deriveBaseUrl(manifestUrlInput) } - sendLog(`manifest 다운로드: ${state.manifestUrl}`) + sendLog(t('log.manifestDownload', { url: state.manifestUrl })) const manifest = await fetchJson(state.manifestUrl) const results: FetchedPack[] = [] for (const entry of manifest.packs ?? []) { @@ -135,21 +140,21 @@ ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise< const pack = normalizePackDefinition(raw) results.push({ key: entry.file, name: entry.name || pack.name, pack }) } catch (error) { - sendLog(`pack 로드 실패 (${entry.file}): ${(error as Error).message}`) + sendLog(t('log.packLoadFail', { file: entry.file, message: (error as Error).message })) } } state.packs.clear() for (const item of results) state.packs.set(item.key, item) - sendLog(`로드된 음악퀴즈: ${results.length}개`) + sendLog(t('log.packsLoaded', { count: results.length })) return results }) ipcMain.handle('packs:select', async (_event, packKey: string) => { if (!state.packs.has(packKey)) { - throw new Error('선택한 음악퀴즈를 찾을 수 없습니다.') + throw new Error(t('errors.packNotFound')) } state.selectedKey = packKey - sendLog(`선택: ${packKey}`) + sendLog(t('log.selectedPack', { key: packKey })) }) ipcMain.handle('dialog:pickFolder', async (): Promise => { @@ -163,10 +168,10 @@ ipcMain.handle('dialog:pickFolder', async (): Promise => { ipcMain.handle('install:validatePath', async (_event, target: string) => { if (!target || target.trim().length === 0) { - return { ok: false, message: '서버 설치 경로를 입력해 주세요.' } + return { ok: false, message: t('errors.installPathRequired') } } if (containsHangul(target)) { - return { ok: false, message: '경로에 한글이 포함되면 마인크래프트 서버가 정상 동작하지 않습니다.' } + return { ok: false, message: t('errors.installPathHangul') } } const absolute = path.resolve(target) state.installPath = absolute @@ -225,7 +230,7 @@ function downloadStream( ): Promise { return new Promise((resolve, reject) => { if (signal.aborted) { - reject(new Error('취소되었습니다.')) + reject(new Error(t('errors.canceled'))) return } const u = new URL(url) @@ -233,7 +238,7 @@ function downloadStream( const fileStream = fs.createWriteStream(target) let settled = false const onAbort = () => { - try { req.destroy(new Error('취소되었습니다.')) } catch { /* noop */ } + try { req.destroy(new Error(t('errors.canceled'))) } catch { /* noop */ } try { fileStream.close() } catch { /* noop */ } } signal.addEventListener('abort', onAbort) @@ -279,13 +284,13 @@ function downloadStream( fileStream.close(() => {}) if (!settled) { settled = true; reject(err) } }) - req.on('timeout', () => req.destroy(new Error('요청 시간 초과'))) + req.on('timeout', () => req.destroy(new Error(t('errors.requestTimeout')))) }) } ipcMain.handle('jdk:install', async (): Promise<{ ok: boolean; path?: string; message?: string }> => { if (jdkInstall.inProgress) { - return { ok: false, message: '이미 JDK 설치가 진행 중입니다.' } + return { ok: false, message: t('errors.jdkBusy') } } jdkInstall.inProgress = true const controller = new AbortController() @@ -298,20 +303,24 @@ ipcMain.handle('jdk:install', async (): Promise<{ ok: boolean; path?: string; me try { // Adoptium API v3: latest GA JDK 21 Windows x64. 본문은 307 로 GitHub 릴리즈로 리다이렉트. const url = 'https://api.adoptium.net/v3/binary/latest/21/ga/windows/x64/jdk/hotspot/normal/eclipse?project=jdk' - sendLog('JDK(Temurin 21) 자동 설치 시작 — 다운로드 중...') + sendLog(t('log.jdkInstallStart')) let lastPctReported = -1 await downloadStream(url, tempZip, controller.signal, (loaded, total) => { if (total > 0) { const pct = Math.floor((loaded / total) * 100) if (pct >= lastPctReported + 5) { lastPctReported = pct - sendLog(`JDK 다운로드: ${pct}% (${Math.floor(loaded / 1024 / 1024)}MB / ${Math.floor(total / 1024 / 1024)}MB)`) + sendLog(t('log.jdkDownloadProgress', { + percent: pct, + loaded: Math.floor(loaded / 1024 / 1024), + total: Math.floor(total / 1024 / 1024) + })) } } }) - if (controller.signal.aborted) throw new Error('취소되었습니다.') + if (controller.signal.aborted) throw new Error(t('errors.canceled')) - sendLog('JDK 압축 해제 중...') + sendLog(t('log.jdkExtracting')) await fsp.rm(destDir, { recursive: true, force: true }) await fsp.mkdir(destDir, { recursive: true }) await extractZip(tempZip, { dir: destDir }) @@ -323,19 +332,19 @@ ipcMain.handle('jdk:install', async (): Promise<{ ok: boolean; path?: string; me if (innerJdk) javaRoot = path.join(destDir, innerJdk.name) const javaExe = path.join(javaRoot, 'bin', process.platform === 'win32' ? 'java.exe' : 'java') if (!fs.existsSync(javaExe)) { - throw new Error(`설치 후 java 실행 파일을 찾지 못했습니다: ${javaExe}`) + throw new Error(t('errors.javaExeMissing', { path: javaExe })) } - sendLog(`JDK 자동 설치 완료: ${javaRoot}`) + sendLog(t('log.jdkDoneRoot', { path: javaRoot })) return { ok: true, path: javaRoot } } catch (err) { const msg = (err as Error).message || String(err) if (controller.signal.aborted || /취소/.test(msg)) { - sendLog('JDK 설치가 취소되었습니다.') + sendLog(t('log.jdkCanceled')) try { await fsp.rm(destDir, { recursive: true, force: true }) } catch { /* noop */ } - return { ok: false, message: '취소됨' } + return { ok: false, message: t('errors.canceledShort') } } - sendLog(`JDK 설치 실패: ${msg}`) + sendLog(t('log.jdkInstallFailedLog', { message: msg })) return { ok: false, message: msg } } finally { try { await fsp.rm(tempZip, { force: true }) } catch { /* noop */ } @@ -348,7 +357,7 @@ ipcMain.handle('jdk:install', async (): Promise<{ ok: boolean; path?: string; me ipcMain.handle('jdk:cancelInstall', async (): Promise<{ ok: boolean }> => { if (jdkInstall.controller) { jdkInstall.controller.abort() - sendLog('JDK 설치 취소 요청을 보냈습니다.') + sendLog(t('log.jdkCancelRequested')) } return { ok: true } }) @@ -368,9 +377,9 @@ async function downloadAndExtractZip(url: string, label: string, extractDir: str const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mq-zip-')) const tempZip = path.join(tempDir, 'package.zip') try { - sendLog(`${label} 다운로드: ${url}`) + sendLog(t('log.labelDownload', { label, url })) await downloadFile(url, tempZip) - sendLog(`${label} 압축 해제: ${extractDir}`) + sendLog(t('log.labelExtract', { label, dir: extractDir })) await extractZip(tempZip, { dir: extractDir }) } finally { await fsp.rm(tempDir, { recursive: true, force: true }) @@ -379,36 +388,36 @@ async function downloadAndExtractZip(url: string, label: string, extractDir: str async function downloadServerZip(pack: PackDefinition, targetDir: string): Promise { if (!pack.serverPath) { - sendLog('서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.') + sendLog(t('log.skipServerZip')) return } const url = resolveManifestRelative(pack.serverPath, 'servers') - await downloadAndExtractZip(url, '서버 파일', targetDir) + await downloadAndExtractZip(url, t('log.labelServerFile'), targetDir) } async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise { if (!pack.mapPath) { - sendLog('맵 파일(mapPath)이 비어 있어 맵 다운로드를 건너뜁니다.') + sendLog(t('log.skipMapZip')) return } const url = resolveManifestRelative(pack.mapPath, 'maps') const savesDir = path.join(customRoot, 'saves') - await downloadAndExtractZip(url, '맵', savesDir) + await downloadAndExtractZip(url, t('log.labelMap'), savesDir) } async function downloadModsFolder(pack: PackDefinition, customRoot: string): Promise { if (!pack.modsFolder) { - sendLog('modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.') + sendLog(t('log.skipModsFolder')) return } const indexUrl = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/index.json` - sendLog(`모드 목록 조회: ${indexUrl}`) + sendLog(t('log.modsIndexFetch', { url: indexUrl })) const listing = await fetchJson<{ files?: unknown }>(indexUrl) const files = Array.isArray(listing.files) ? listing.files.filter((name): name is string => typeof name === 'string' && /\.jar$/i.test(name)) : [] if (files.length === 0) { - sendLog(`/file/mods/${pack.modsFolder}/ 안에 .jar 파일이 없습니다.`) + sendLog(t('log.modsFolderEmpty', { folder: pack.modsFolder })) return } const modsDir = path.join(customRoot, 'mods') @@ -416,33 +425,33 @@ async function downloadModsFolder(pack: PackDefinition, customRoot: string): Pro for (const fileName of files) { const url = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/${encodeURIComponent(fileName)}` const target = path.join(modsDir, fileName) - sendLog(`모드 다운로드: ${fileName}`) + sendLog(t('log.modDownload', { file: fileName })) await downloadFile(url, target) } } async function downloadResourcepackZip(pack: PackDefinition, customRoot: string): Promise { if (!pack.resourcepackPath) { - sendLog('resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.') + sendLog(t('log.skipResourcepack')) return } const url = `${state.baseUrl}/file/resourcepacks/${pack.resourcepackPath.replace(/^\/+/, '')}` const target = path.join(customRoot, 'resourcepacks', pack.resourcepackPath.replace(/^\/+/, '')) await fsp.mkdir(path.dirname(target), { recursive: true }) - sendLog(`리소스팩 다운로드: ${url}`) + sendLog(t('log.resourcepackDownload', { url })) await downloadFile(url, target) } ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) => { const pack = state.packs.get(payload.packKey) - if (!pack) throw new Error('음악퀴즈를 찾을 수 없습니다.') + if (!pack) throw new Error(t('errors.packNotFound2')) if (containsHangul(payload.installPath)) { - throw new Error('경로에 한글이 포함되면 안 됩니다.') + throw new Error(t('errors.installPathHangulShort')) } const installPath = path.resolve(payload.installPath) state.installPath = installPath await fsp.mkdir(installPath, { recursive: true }) - sendLog(`서버 설치 경로: ${installPath}`) + sendLog(t('log.serverInstallPath', { path: installPath })) await downloadServerZip(pack.pack, installPath) // 다운로드한 zip에 들어있을 수 있는 eula.txt를 그대로 보존한다. @@ -469,20 +478,20 @@ ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) = async function injectUpnpToRunBat(installPath: string): Promise { const runBat = path.join(installPath, 'run.bat') if (!fs.existsSync(runBat)) { - sendLog('run.bat 이 없어 UPnP 자동 등록 스크립트 주입을 건너뜁니다.') + sendLog(t('log.runBatMissing')) return } const MARKER = 'REM === UPNP MANAGED BY MUSICQUIZ INSTALLER ===' const original = await fsp.readFile(runBat, 'utf8') if (original.includes(MARKER)) { - sendLog('run.bat 에 이미 UPnP 자동 등록 스크립트가 들어 있어 건너뜁니다.') + sendLog(t('log.runBatAlreadyInjected')) return } const lines = original.split(/\r?\n/) const javaIdx = lines.findIndex((line) => /^\s*java(\.exe)?[\s"]/i.test(line)) if (javaIdx === -1) { - sendLog('run.bat 에서 java 호출 라인을 찾지 못해 UPnP 자동 등록 주입을 건너뜁니다.') + sendLog(t('log.runBatNoJava')) return } let pauseIdx = -1 @@ -521,7 +530,7 @@ async function injectUpnpToRunBat(installPath: string): Promise { // bat 파일은 CRLF 가 안전. const output = merged.join('\r\n') await fsp.writeFile(runBat, output, 'utf8') - sendLog('run.bat 에 서버 기동/종료 시 UPnP 자동 등록·해제 스크립트를 추가했습니다.') + sendLog(t('log.runBatInjected')) } ipcMain.handle('server:readEula', async (_event, installPath: string): Promise<{ exists: boolean; content: string }> => { @@ -542,7 +551,7 @@ ipcMain.handle('server:fetchMinecraftEula', async (): Promise<{ url: string; htm const buffer = await fetchBuffer(url) return { url, html: buffer.toString('utf8') } } catch (error) { - sendLog(`Minecraft EULA 페이지 조회 실패: ${(error as Error).message}`) + sendLog(t('log.mojangEulaFetchFail', { message: (error as Error).message })) return { url, html: '' } } }) @@ -550,12 +559,12 @@ ipcMain.handle('server:fetchMinecraftEula', async (): Promise<{ url: string; htm ipcMain.handle('server:acceptEula', async (_event, installPath: string) => { const target = path.join(installPath, 'eula.txt') await fsp.writeFile(target, `# Generated by music quiz installer\neula=true\n`, 'utf8') - sendLog('EULA 동의 저장 완료.') + sendLog(t('log.eulaAccepted')) }) ipcMain.handle('server:checkRam', async (_event, packKey: string): Promise => { const pack = state.packs.get(packKey) - if (!pack) throw new Error('음악퀴즈를 찾을 수 없습니다.') + if (!pack) throw new Error(t('errors.packNotFound2')) const systemRamMb = Math.floor(os.totalmem() / (1024 * 1024)) if (systemRamMb >= pack.pack.serverMaxRam) { return { systemRamMb, decision: 'maxOk', appliedRamMb: pack.pack.serverMaxRam } @@ -578,14 +587,14 @@ ipcMain.handle('server:configEditor', async (_event, installPath: string) => { } catch (error) { res.statusCode = 500 res.setHeader('content-type', 'text/plain; charset=utf-8') - res.end(`서버 오류: ${(error as Error).message}`) + res.end(t('configEditor.serverError', { message: (error as Error).message })) } }) await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)) state.configEditorServer = server state.configEditorPort = port const url = `http://127.0.0.1:${port}/` - sendLog(`서버 설정 편집기 실행: ${url}`) + sendLog(t('log.configEditorOpen', { url })) await shell.openExternal(url) return { url } }) @@ -599,7 +608,7 @@ async function pickPort(): Promise { const address = probe.address() probe.close(() => { if (address && typeof address === 'object') resolve(address.port) - else reject(new Error('포트를 할당할 수 없습니다.')) + else reject(new Error(t('errors.portAllocFail'))) }) }) }) @@ -619,7 +628,7 @@ async function handleConfigEditorRequest(installPath: string, req: http.Incoming const target = url.searchParams.get('name') if (!target || !SERVER_CONFIG_FILES.includes(target)) { res.statusCode = 400 - res.end('알 수 없는 파일') + res.end(t('configEditor.unknownFile')) return } const filePath = path.join(installPath, target) @@ -640,7 +649,7 @@ async function handleConfigEditorRequest(installPath: string, req: http.Incoming const content = params.get('content') ?? '' if (!SERVER_CONFIG_FILES.includes(target)) { res.statusCode = 400 - res.end('알 수 없는 파일') + res.end(t('configEditor.unknownFile')) return } const filePath = path.join(installPath, target) @@ -668,15 +677,17 @@ function renderConfigEditorPage(fileSet: string[]): string { const optionMarkup = safeList .map((file, index) => ``) .join('') + const savedText = JSON.stringify(t('configEditor.saved')) + const saveFailedText = JSON.stringify(t('configEditor.saveFailed')) return ` -서버 설정 편집기 +${t('configEditor.pageTitle')} -

서버 설정 편집기

-

아래 파일을 직접 편집한 후 "적용" 버튼으로 저장합니다. 설치기 화면에서 다음 단계로 진행하기 전 마음껏 편집할 수 있습니다.

- +

${t('configEditor.heading')}

+

${t('configEditor.intro')}

+ - +

` } @@ -700,72 +711,75 @@ function readBody(req: http.IncomingMessage): Promise { ipcMain.handle('server:portForward', async (_event, port: number): Promise => { const targetPort = Number.isFinite(port) && port > 0 ? port : 25565 - sendLog(`포트포워딩 점검 시작: 포트 ${targetPort}`) + sendLog(t('log.portCheckStart', { port: targetPort })) // 1차 점검 전에 우리가 이전 실행에서 만든 UPnP 매핑이 남아 있으면 먼저 제거한다. // 이렇게 해야 "사용자 라우터 규칙이 활성화돼서 외부 접근이 가능한 상태" 와 "UPnP 매핑 덕분에 접근 가능한 상태" 가 구별된다. // 사용자 규칙이 비활성/없으면 1차 점검은 false 가 되어 UPnP 시도 단계로 자연스럽게 넘어간다. - sendLog('이전 실행의 UPnP 매핑이 남아 있으면 제거합니다(중복 방지)...') + sendLog(t('log.upnpCleanup')) await removeUpnpMapping(targetPort) // 외부 IP 확보: 공용 API → 실패 시 UPnP 게이트웨이의 외부 IP로 폴백. let externalIp = await detectExternalIpHttp() if (externalIp) { - sendLog(`외부 IP 확인(HTTP): ${externalIp}`) + sendLog(t('log.externalIpHttp', { ip: externalIp })) } else { - sendLog('외부 IP 확인 실패(HTTP). UPnP 게이트웨이를 통한 조회 시도...') + sendLog(t('log.externalIpHttpFail')) externalIp = await detectExternalIpUpnp() - if (externalIp) sendLog(`외부 IP 확인(UPnP): ${externalIp}`) - else sendLog('UPnP 게이트웨이에서도 외부 IP를 얻지 못했습니다.') + if (externalIp) sendLog(t('log.externalIpUpnp', { ip: externalIp })) + else sendLog(t('log.externalIpUpnpFail')) } // 1차 점검: 외부에서 이미 접근 가능한지 (서버가 떠 있거나, 우리가 임시 리스너 띄워서 검증). - sendLog('외부 포트체크 서비스(ifconfig.co)로 1차 점검합니다...') + sendLog(t('log.probeStart')) let probe = await probePortFromOutside(targetPort, externalIp) if (!externalIp && probe.detectedIp) externalIp = probe.detectedIp - sendLog(`1차 점검 결과: ${probe.reachable === true ? '성공' : probe.reachable === false ? '실패' : '확인 불가'} (${probe.detail})`) + const verdict = probe.reachable === true + ? t('log.probeVerdictSuccess') + : probe.reachable === false ? t('log.probeVerdictFail') : t('log.probeVerdictUnknown') + sendLog(t('log.probeResult', { verdict, detail: probe.detail })) if (probe.reachable === true) { - sendLog(`외부에서 ${externalIp || '(IP 미상)'}:${targetPort} 접근 확인됨. 사용자 규칙으로 포워딩 됨.`) + sendLog(t('log.probePreForwarded', { addr: externalIp || t('log.ipUnknown'), port: targetPort })) return { status: 'preForwarded', externalIp, port: targetPort } } // UPnP 시도. - sendLog(`UPnP로 포트 ${targetPort} 자동 개방 시도(TCP)...`) + sendLog(t('log.upnpTryOpen', { port: targetPort })) try { await openPortViaUpnp(targetPort) - sendLog('UPnP portMapping 요청 성공. 외부 접근을 재확인합니다.') + sendLog(t('log.upnpReqOk')) } catch (error) { const msg = (error as Error).message || String(error) - sendLog(`UPnP 시도 실패: ${msg}`) + sendLog(t('log.upnpTryFail', { message: msg })) return { status: 'upnpFailed', externalIp, port: targetPort, - message: `UPnP 실패: ${msg}. 라우터에서 UPnP가 꺼져 있을 수 있습니다. 직접 포트포워딩을 해주세요.` + message: t('log.upnpFailDetail', { message: msg }) } } // NAT 반영 지연을 고려해 최대 3회 재점검. for (let attempt = 1; attempt <= 3; attempt++) { await sleep(1500) - sendLog(`UPnP 적용 후 재점검 ${attempt}/3...`) + sendLog(t('log.upnpRecheck', { attempt })) probe = await probePortFromOutside(targetPort, externalIp) if (!externalIp && probe.detectedIp) externalIp = probe.detectedIp if (probe.reachable === true) { - sendLog(`UPnP로 포트 ${targetPort} 자동 개방 완료. 테스트 매핑을 제거합니다(실제 개방은 run.bat 이 서버 기동 시 자동으로 처리).`) + sendLog(t('log.upnpDone', { port: targetPort })) await removeUpnpMapping(targetPort) return { status: 'upnpOk', externalIp, port: targetPort } } } // 테스트 목적으로 만든 매핑 정리. 실제 개방은 run.bat 이 담당. - sendLog('테스트용 UPnP 매핑을 정리합니다.') + sendLog(t('log.upnpCleanupTest')) await removeUpnpMapping(targetPort) const reason = probe.reachable === false - ? 'UPnP 매핑은 등록됐지만 외부 포트체크 서비스에서 연결이 닿지 않았습니다. ISP 차단, 이중 NAT, 또는 방화벽 설정을 확인하세요.' - : `외부 포트체크 결과를 받지 못했습니다(${probe.detail}). UPnP 매핑은 등록됐을 수 있습니다.` + ? t('log.upnpFailReason1') + : t('log.upnpFailReason2', { detail: probe.detail }) sendLog(reason) return { status: 'upnpFailed', externalIp, port: targetPort, message: reason } }) @@ -792,12 +806,12 @@ function detectExternalIpUpnp(): Promise { try { client = natUpnp.createClient() } catch (err) { - sendLog(`UPnP 클라이언트 생성 실패: ${(err as Error).message}`) + sendLog(t('log.upnpClientFail', { message: (err as Error).message })) finish('') return } const timer = setTimeout(() => { - sendLog('UPnP externalIp 조회 타임아웃(8s).') + sendLog(t('log.upnpExternalTimeout')) try { client && client.close() } catch {} finish('') }, 8000) @@ -805,7 +819,7 @@ function detectExternalIpUpnp(): Promise { clearTimeout(timer) try { client && client.close() } catch {} if (err || !ip) { - if (err) sendLog(`UPnP externalIp 오류: ${err.message}`) + if (err) sendLog(t('log.upnpExternalErr', { message: err.message })) finish('') } else { finish(ip) @@ -846,9 +860,9 @@ async function probePortFromOutside( } catch (err) { const code = (err as NodeJS.ErrnoException).code if (code === 'EADDRINUSE') { - sendLog(`포트 ${port}이(가) 이미 사용 중. 임시 리스너 없이 외부 서비스 응답만으로 판정합니다.`) + sendLog(t('log.portInUse', { port })) } else { - sendLog(`임시 리스너 바인딩 실패: ${(err as Error).message}`) + sendLog(t('log.listenerBindFail', { message: (err as Error).message })) } try { server && server.close() } catch {} server = null @@ -886,20 +900,20 @@ async function probePortFromOutside( let reachable: boolean | null = null const details: string[] = [] if (listenerBound) { - details.push(`임시 리스너 도달=${gotInboundConnection ? 'yes' : 'no'}`) + details.push(t('log.detailListenerHit', { value: gotInboundConnection ? 'yes' : 'no' })) if (gotInboundConnection) reachable = true } else { - details.push('임시 리스너=skip(포트 사용중)') + details.push(t('log.detailListenerSkip')) } let detectedIp = '' if ('ok' in externalResult && externalResult.ok) { - details.push(`ifconfig.co reachable=${externalResult.reachable} ip=${externalResult.ip || '?'}`) + details.push(t('log.detailIfconfig', { reachable: String(externalResult.reachable), ip: externalResult.ip || '?' })) detectedIp = externalResult.ip || '' if (externalResult.reachable === true) reachable = true else if (reachable !== true && externalResult.reachable === false) reachable = false } else if ('ok' in externalResult && !externalResult.ok) { - details.push(`ifconfig.co 실패=${(externalResult as { error: string }).error}`) + details.push(t('log.detailIfconfigFail', { error: (externalResult as { error: string }).error })) } // 임시 리스너가 떴고 외부 서비스도 닿지 않았다면 명확한 false. @@ -907,7 +921,7 @@ async function probePortFromOutside( return { reachable, - detail: details.join(', ') || '결과 없음', + detail: details.join(', ') || t('log.detailNone'), detectedIp: detectedIp || hintIp || '' } } @@ -934,12 +948,12 @@ function fetchIfconfigCoPort(port: number): Promise<{ ok: true; reachable: boole const ip = typeof json.ip === 'string' ? json.ip : '' resolve({ ok: true, reachable, ip }) } catch (err) { - resolve({ ok: false, error: `응답 파싱 실패: ${text.slice(0, 80)}` }) + resolve({ ok: false, error: t('errors.parseResponseFailed', { snippet: text.slice(0, 80) }) }) } }) }) req.on('error', (err) => resolve({ ok: false, error: err.message })) - req.on('timeout', () => req.destroy(new Error('요청 시간 초과(15s)'))) + req.on('timeout', () => req.destroy(new Error(t('errors.requestTimeout15s')))) }) } @@ -951,20 +965,20 @@ function removeUpnpMapping(port: number): Promise { try { client = natUpnp.createClient() } catch (err) { - sendLog(`UPnP 클라이언트 생성 실패(매핑 제거 단계): ${(err as Error).message}`) + sendLog(t('log.upnpClientFailRemove', { message: (err as Error).message })) done() return } const timer = setTimeout(() => { try { client && client.close() } catch {} - sendLog(`UPnP 매핑 제거 응답 없음(타임아웃 8s). 라우터에 우리가 만든 규칙이 없을 수 있습니다.`) + sendLog(t('log.upnpRemoveTimeout')) done() }, 8000) client.portUnmapping({ public: port, protocol: 'tcp' }, (err: Error | null) => { clearTimeout(timer) try { client && client.close() } catch {} - if (err) sendLog(`UPnP 매핑 제거 시도 결과: ${err.message} (없으면 정상)`) - else sendLog(`UPnP 매핑 제거 완료(포트 ${port}).`) + if (err) sendLog(t('log.upnpRemoveAttempt', { message: err.message })) + else sendLog(t('log.upnpRemoveDone', { port })) done() }) }) @@ -988,7 +1002,7 @@ function openPortViaUpnp(port: number): Promise { } const timer = setTimeout(() => { try { client && client.close() } catch {} - done(new Error('UPnP 응답 없음(타임아웃 15s). 라우터의 UPnP가 꺼져 있거나 SSDP 패킷이 차단됐을 수 있습니다.')) + done(new Error(t('errors.upnpTimeout'))) }, 15000) client.portMapping( { public: port, private: port, ttl: 0, description: 'MusicQuiz Server', protocol: 'tcp' }, @@ -1007,7 +1021,7 @@ function sleep(ms: number): Promise { ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) => { const pack = state.packs.get(payload.packKey) - if (!pack) throw new Error('음악퀴즈를 찾을 수 없습니다.') + if (!pack) throw new Error(t('errors.packNotFound2')) const customRoot = path.join(getAppDataDir(), '.mc_custom') await fsp.mkdir(path.join(customRoot, 'mods'), { recursive: true }) await fsp.mkdir(path.join(customRoot, 'resourcepacks'), { recursive: true }) @@ -1023,11 +1037,11 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) = const cacheDir = path.join(customRoot, 'platform-cache') await fsp.mkdir(cacheDir, { recursive: true }) const installerPath = path.join(cacheDir, deriveFileName(platformUrl) || 'platform-installer.jar') - sendLog(`플랫폼(${pack.pack.platform.type}) 다운로드: ${platformUrl}`) + sendLog(t('log.platformDownload', { type: pack.pack.platform.type, url: platformUrl })) await downloadFile(platformUrl, installerPath) - sendLog(`플랫폼 설치파일 저장: ${installerPath} (사용자가 직접 실행하거나 마인크래프트 런처에서 인식할 수 있습니다.)`) + sendLog(t('log.platformSaved', { path: installerPath })) } else if (!payload.installPlatform) { - sendLog('플랫폼 설치 건너뜀. 바닐라로 진행합니다.') + sendLog(t('log.platformSkipped')) } await downloadModsFolder(pack.pack, customRoot) @@ -1051,17 +1065,17 @@ interface FabricInstallerMeta { async function installFabricLoader(pack: PackDefinition, customRoot: string): Promise { const loaderVersion = pack.platform.loaderVersion if (!loaderVersion) { - throw new Error('Fabric 로더 버전이 음악퀴즈에 지정되지 않았습니다. 관리 사이트에서 platform.loaderVersion 을 설정해 주세요.') + throw new Error(t('errors.fabricLoaderRequired')) } // 1) 최신 fabric-installer 메타데이터 조회. - sendLog('Fabric installer 최신 버전 조회 중...') + sendLog(t('log.fabricFetchInstallerList')) const installerList = await fetchJson('https://meta.fabricmc.net/v2/versions/installer') if (!installerList || installerList.length === 0) { - throw new Error('Fabric installer 목록을 받지 못했습니다.') + throw new Error(t('errors.fabricInstallerListEmpty')) } const latest = installerList.find((item) => item.stable) || installerList[0] - sendLog(`Fabric installer ${latest.version} 다운로드: ${latest.url}`) + sendLog(t('log.fabricInstallerDownload', { version: latest.version, url: latest.url })) // 2) installer jar 캐시. const cacheDir = path.join(customRoot, 'platform-cache') @@ -1071,7 +1085,7 @@ async function installFabricLoader(pack: PackDefinition, customRoot: string): Pr // 3) Java 실행파일 확보. const javaCmd = await findJavaExecutable() - sendLog(`Java 사용: ${javaCmd}`) + sendLog(t('log.javaUsed', { path: javaCmd })) // 4) fabric-installer CLI 자동 실행. // client 모드 + -noprofile: launcher_profiles.json 은 우리 코드가 직접 갱신하므로 fabric-installer 가 덮어쓰지 않게 한다. @@ -1083,9 +1097,9 @@ async function installFabricLoader(pack: PackDefinition, customRoot: string): Pr '-dir', customRoot, '-noprofile' ] - sendLog(`Fabric 자동 설치 시작: ${pack.mcVersion} / loader ${loaderVersion} → ${customRoot}`) + sendLog(t('log.fabricInstallStart', { mc: pack.mcVersion, loader: loaderVersion, dir: customRoot })) await runJavaProcess(javaCmd, args) - sendLog('Fabric 자동 설치 완료.') + sendLog(t('log.fabricInstallDone')) } async function findJavaExecutable(): Promise { @@ -1157,13 +1171,13 @@ function runJavaProcess(cmd: string, args: string[]): Promise { if (stderrTail.length > 4000) stderrTail = stderrTail.slice(-4000) emitLines(chunk, '[fabric-err]') }) - child.on('error', (err) => reject(new Error(`Java 실행 실패: ${err.message}`))) + child.on('error', (err) => reject(new Error(t('errors.javaSpawnFailed', { message: err.message })))) child.on('close', (code) => { if (code === 0) { resolve() } else { const detail = stderrTail.trim().split(/\r?\n/).slice(-3).join(' | ') - reject(new Error(`fabric-installer 종료 코드 ${code}${detail ? ' — ' + detail : ''}`)) + reject(new Error(t('errors.fabricInstallerExit', { code: code ?? '', detail: detail ? ' — ' + detail : '' }))) } }) }) @@ -1291,7 +1305,7 @@ function resolveLastVersionId(pack: PackDefinition): string { async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Promise { const launcherPath = path.join(getAppDataDir(), '.minecraft', 'launcher_profiles.json') if (!fs.existsSync(launcherPath)) { - sendLog(`launcher_profiles.json을 찾을 수 없습니다: ${launcherPath}`) + sendLog(t('log.launcherProfilesMissing', { path: launcherPath })) return } const raw = await fsp.readFile(launcherPath, 'utf8') @@ -1303,14 +1317,14 @@ async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Pro const ramMerged = mergeRamArgs(existingJavaArgs, pack.serverMaxRam) const javaArgs = mergeJvmTuningFlags(ramMerged, DEFAULT_JVM_TUNING_FLAGS) if (existingJavaArgs !== javaArgs) { - sendLog(`JVM 인수 갱신(메모리 + G1 GC 튜닝 추가): "${existingJavaArgs}" → "${javaArgs}"`) + sendLog(t('log.javaArgsUpdated', { before: existingJavaArgs, after: javaArgs })) } const lastVersionId = resolveLastVersionId(pack) - sendLog(`launcher_profiles 의 lastVersionId = ${lastVersionId}`) + sendLog(t('log.lastVersionId', { id: lastVersionId })) // 해당 version 폴더 존재 확인. 없으면 런처가 "Unable to prepare assets for download" 로 실패한다. const versionDir = path.join(getAppDataDir(), '.minecraft', 'versions', lastVersionId) if (!fs.existsSync(versionDir)) { - sendLog(`경고: .minecraft/versions/${lastVersionId} 가 없습니다. 마인크래프트 런처에서 해당 버전을 한 번 받아주거나, 플랫폼 설치를 먼저 마쳐주세요.`) + sendLog(t('log.versionMissingWarn', { id: lastVersionId })) } json.profiles[profileKey] = { ...existingProfile, @@ -1321,7 +1335,7 @@ async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Pro javaArgs } await fsp.writeFile(launcherPath, `${JSON.stringify(json, null, 2)}\n`, 'utf8') - sendLog(`launcher_profiles.json 갱신: 프로필 "${profileKey}", gameDir=${gameDir}`) + sendLog(t('log.launcherProfilesUpdated', { profile: profileKey, dir: gameDir })) } /** @@ -1333,7 +1347,7 @@ async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Pro async function copyMinecraftUserSettings(customRoot: string): Promise { const mcRoot = path.join(getAppDataDir(), '.minecraft') if (!fs.existsSync(mcRoot)) { - sendLog('.minecraft 폴더가 없어 기존 설정 복사를 건너뜁니다.') + sendLog(t('log.minecraftRootMissing')) return } let copied = 0 @@ -1352,12 +1366,12 @@ async function copyMinecraftUserSettings(customRoot: string): Promise { await fsp.copyFile(src, dst) copied += 1 } catch (err) { - sendLog(`설정 복사 실패 (${entry.name}): ${(err as Error).message}`) + sendLog(t('log.settingCopyFail', { name: entry.name, message: (err as Error).message })) } } - sendLog(`기존 마인크래프트 설정 복사: 새로 복사 ${copied}개 / 보존(이미 존재) ${skipped}개.`) + sendLog(t('log.settingCopySummary', { copied, skipped })) } catch (err) { - sendLog(`기존 설정 복사 중 오류: ${(err as Error).message}`) + sendLog(t('log.settingCopyError', { message: (err as Error).message })) } } @@ -1375,23 +1389,23 @@ async function linkMinecraftRuntimeDirs(customRoot: string): Promise { const src = path.join(mcRoot, dir) const dst = path.join(customRoot, dir) if (!fs.existsSync(src)) { - sendLog(`.minecraft/${dir} 가 없습니다. 마인크래프트 런처를 한 번 실행한 뒤 다시 시도해주세요.`) + sendLog(t('log.runtimeDirMissing', { dir })) continue } let existing: import('node:fs').Stats | null = null try { existing = await fsp.lstat(dst) } catch { existing = null } if (existing) { if (existing.isSymbolicLink()) continue // 이미 링크됨 - sendLog(`.mc_custom/${dir} 가 실제 폴더로 이미 존재 — 건너뜀.`) + sendLog(t('log.runtimeDirExists', { dir })) continue } try { // 'junction' 은 Windows 에서 권한 없이 만들 수 있는 디렉터리 링크. // 다른 OS 에서는 Node 가 알아서 일반 symlink 로 처리. await fsp.symlink(src, dst, 'junction') - sendLog(`링크 생성: .mc_custom/${dir} → .minecraft/${dir}`) + sendLog(t('log.runtimeLinkCreated', { dir })) } catch (err) { - sendLog(`링크 생성 실패 (${dir}): ${(err as Error).message}`) + sendLog(t('log.runtimeLinkFail', { dir, message: (err as Error).message })) } } } @@ -1409,20 +1423,20 @@ ipcMain.handle('finish:desktopShortcut', async () => { const ok = require('electron').shell.writeShortcutLink(shortcutPath, 'create', { target: runBat, cwd: state.installPath, - description: '음악퀴즈 서버 실행' + description: t('log.shortcutDescription') }) - sendLog(ok ? `바로가기 생성: ${shortcutPath}` : '바로가기 생성 실패') + sendLog(ok ? t('log.shortcutCreated', { path: shortcutPath }) : t('log.shortcutFailed')) }) ipcMain.handle('finish:startServer', async () => { if (!state.installPath) return const runBat = path.join(state.installPath, 'run.bat') if (!fs.existsSync(runBat)) { - sendLog(`run.bat을 찾을 수 없습니다: ${runBat}`) + sendLog(t('log.runBatMissingPath', { path: runBat })) return } spawn('cmd.exe', ['/c', 'start', '', runBat], { cwd: state.installPath, detached: true, stdio: 'ignore' }).unref() - sendLog('서버 실행 요청 완료.') + sendLog(t('log.serverStartRequested')) }) ipcMain.handle('finish:startLauncher', async () => { @@ -1435,9 +1449,9 @@ ipcMain.handle('finish:startLauncher', async () => { if (process.platform !== 'win32') { try { await shell.openExternal('minecraft://') - sendLog('마인크래프트 런처 실행 요청 완료(URL 스킴, 비-Windows).') + sendLog(t('log.launcherUrlSchemeNonWin')) } catch (err) { - sendLog(`런처 실행 실패: ${(err as Error).message}`) + sendLog(t('log.launcherFail', { message: (err as Error).message })) } return } @@ -1449,15 +1463,15 @@ ipcMain.handle('finish:startLauncher', async () => { type LauncherCandidate = { label: string; path: string; viaShell: boolean } const candidates: LauncherCandidate[] = [ // Win32 설치판 — 실행 파일 직접 spawn. - { label: 'Win32 설치(Program Files (x86))', path: path.join(programFilesX86, 'Minecraft Launcher', 'MinecraftLauncher.exe'), viaShell: false }, - { label: 'Win32 설치(Program Files)', path: path.join(programFiles, 'Minecraft Launcher', 'MinecraftLauncher.exe'), viaShell: false }, - { label: 'Win32 설치(legacy Minecraft 폴더)', path: path.join(programFilesX86, 'Minecraft', 'MinecraftLauncher.exe'), viaShell: false }, - { label: 'Win32 설치(legacy Minecraft 폴더)', path: path.join(programFiles, 'Minecraft', 'MinecraftLauncher.exe'), viaShell: false }, - { label: 'Xbox / Game Pass', path: 'C:\\XboxGames\\Minecraft Launcher\\Content\\Minecraft.exe', viaShell: false }, - { label: 'npm/portable', path: path.join(localAppData, 'Programs', 'minecraft-launcher', 'MinecraftLauncher.exe'), viaShell: false }, + { label: t('candidates.winProgramFiles86'), path: path.join(programFilesX86, 'Minecraft Launcher', 'MinecraftLauncher.exe'), viaShell: false }, + { label: t('candidates.winProgramFiles'), path: path.join(programFiles, 'Minecraft Launcher', 'MinecraftLauncher.exe'), viaShell: false }, + { label: t('candidates.winLegacy86'), path: path.join(programFilesX86, 'Minecraft', 'MinecraftLauncher.exe'), viaShell: false }, + { label: t('candidates.winLegacy'), path: path.join(programFiles, 'Minecraft', 'MinecraftLauncher.exe'), viaShell: false }, + { label: t('candidates.xboxGamePass'), path: 'C:\\XboxGames\\Minecraft Launcher\\Content\\Minecraft.exe', viaShell: false }, + { label: t('candidates.npmPortable'), path: path.join(localAppData, 'Programs', 'minecraft-launcher', 'MinecraftLauncher.exe'), viaShell: false }, // App Execution Alias(MS Store 설치 시 자동 생성, reparse point 라 cmd /c start 로 띄워야 안정적). - { label: 'App Execution Alias(Minecraft.exe)', path: path.join(localAppData, 'Microsoft', 'WindowsApps', 'Minecraft.exe'), viaShell: true }, - { label: 'App Execution Alias(MinecraftLauncher.exe)', path: path.join(localAppData, 'Microsoft', 'WindowsApps', 'MinecraftLauncher.exe'), viaShell: true } + { label: t('candidates.appAliasMinecraft'), path: path.join(localAppData, 'Microsoft', 'WindowsApps', 'Minecraft.exe'), viaShell: true }, + { label: t('candidates.appAliasLauncher'), path: path.join(localAppData, 'Microsoft', 'WindowsApps', 'MinecraftLauncher.exe'), viaShell: true } ] for (const cand of candidates) { @@ -1466,15 +1480,15 @@ ipcMain.handle('finish:startLauncher', async () => { if (!exists) continue try { if (cand.viaShell) { - sendLog(`마인크래프트 런처 실행(${cand.label}, 셸 경유): ${cand.path}`) + sendLog(t('log.launcherExecShell', { label: cand.label, path: cand.path })) spawn('cmd.exe', ['/c', 'start', '', cand.path], { detached: true, stdio: 'ignore' }).unref() } else { - sendLog(`마인크래프트 런처 실행(${cand.label}): ${cand.path}`) + sendLog(t('log.launcherExec', { label: cand.label, path: cand.path })) spawn(cand.path, [], { detached: true, stdio: 'ignore' }).unref() } return } catch (err) { - sendLog(`${cand.path} 실행 실패: ${(err as Error).message}`) + sendLog(t('log.launcherCandFail', { path: cand.path, message: (err as Error).message })) } } @@ -1482,24 +1496,26 @@ ipcMain.handle('finish:startLauncher', async () => { // 마인크래프트 런처(Java) PFN: Microsoft.4297127D64EC6_8wekyb3d8bbwe, AppId: Minecraft. try { const aumid = 'shell:AppsFolder\\Microsoft.4297127D64EC6_8wekyb3d8bbwe!Minecraft' - sendLog(`AppsFolder 로 MS Store 런처 실행 시도: ${aumid}`) + sendLog(t('log.launcherAppsFolderTry', { aumid })) spawn('explorer.exe', [aumid], { detached: true, stdio: 'ignore' }).unref() return } catch (err) { - sendLog(`AppsFolder 실행 실패: ${(err as Error).message}`) + sendLog(t('log.launcherAppsFolderFail', { message: (err as Error).message })) } // 마지막 수단: minecraft:// URL 스킴. 런처가 없으면 MS Store 가 열린다. try { - sendLog('마지막 시도: minecraft:// URL 스킴 (런처가 없으면 MS Store 가 열릴 수 있음).') + sendLog(t('log.launcherUrlSchemeFallback')) await shell.openExternal('minecraft://') } catch (err) { - sendLog(`URL 스킴 실행 실패: ${(err as Error).message}.`) + sendLog(t('log.launcherUrlSchemeFail', { message: (err as Error).message })) } - sendLog('Minecraft Launcher 실행 시도가 모두 실패했습니다. minecraft.net 또는 Microsoft Store 에서 "Minecraft Launcher" 를 설치한 뒤 다시 시도해 주세요.') + sendLog(t('log.launcherAllFail')) }) +ipcMain.handle('i18n:dict', () => localeDict) + ipcMain.handle('app:quit', () => { // 모든 창을 닫고 앱 종료. macOS에서도 종료(설치기는 한 번 쓰고 끝이니 잔류시키지 않음). app.quit() diff --git a/src/installer/preload.ts b/src/installer/preload.ts index c77b503..3286d5a 100644 --- a/src/installer/preload.ts +++ b/src/installer/preload.ts @@ -2,6 +2,9 @@ import { contextBridge, ipcRenderer } from 'electron' import type { ClientInstallPayload, FetchedPack, RamCheckResult, ServerInstallPayload, PortForwardResult } from './types.js' const api = { + // i18n + loadLocale: (): Promise> => ipcRenderer.invoke('i18n:dict'), + // 1단계 loadPacks: (manifestUrl?: string): Promise => ipcRenderer.invoke('packs:load', manifestUrl),