3 Commits

Author SHA1 Message Date
6cd402121b i18n: 리소스팩 설치기 UI 문구를 locales/installer-rp/ko-kr.json 으로 분리
- main/preload/ytdlp/ffmpeg/music/images/pack/renderer 전반에서 로그·에러·진행
  메시지 문자열을 locales/installer-rp/ko-kr.json 사전 키로 교체
- preload 에 loadLocale 추가, main 에 rp:i18n:dict IPC 핸들러 추가
- 패키징된 .exe 에서도 한국어 사전이 적용되도록 electron-builder.yml 의
  extraResources 에 locales/ 폴더 추가

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 04:00:31 +09:00
135bc98840 i18n: 음악퀴즈 설치기 UI 문구를 locales/installer/ko-kr.json 으로 분리
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 03:53:55 +09:00
c2fcc2fbbf i18n: 서버 측 모든 UI 문구를 locales/server/ko-kr.json 으로 분리
- src/shared/i18n.ts: 공용 i18n 로더 (dotted-key + {{placeholder}} 보간)
- locales/server/ko-kr.json: 사이트 + 라우터 + 데이터팩 출력 사전
- EJS 뷰는 res.locals.t 미들웨어로 일괄 적용
- listEditor.js 등 클라이언트 JS 는 사전을 inline <script> 로 주입받아 tt() 헬퍼 사용
2026-05-13 03:43:04 +09:00
29 changed files with 1414 additions and 519 deletions

View File

@@ -9,11 +9,17 @@ files:
- package.json
# 빌드 시점의 .env 를 설치기 옆에 함께 배포(없으면 조용히 패스).
# 패키징 후 운영자가 resources/.env 만 교체해서 도메인을 바꿀 수도 있음.
# locales/ 폴더는 i18n.ts 가 process.resourcesPath/locales/<component>/ko-kr.json
# 을 찾아 로드하므로, 빌드된 .exe 에서도 한국어 사전이 적용되도록 함께 배포.
extraResources:
- from: .
to: .
filter:
- .env
- from: locales
to: locales
filter:
- "**/*"
win:
target: nsis
artifactName: ${productName}-${version}-Setup.${ext}

View File

@@ -10,6 +10,25 @@ const state = {
resourcepackPath: ''
}
let I18N = {}
function tt(key, params) {
var parts = String(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 {
return key
}
}
if (typeof cur !== 'string') return key
if (!params) return cur
return cur.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, name) {
return name in params ? String(params[name]) : '{{' + name + '}}'
})
}
const pageHost = document.getElementById('pageHost')
const stepIndicator = document.getElementById('stepIndicator')
const logViewer = document.getElementById('logViewer')
@@ -20,10 +39,10 @@ logToggle.addEventListener('click', function () {
logViewer.classList.toggle('collapsed')
if (logViewer.classList.contains('collapsed')) {
logViewer.style.height = '36px'
logToggle.textContent = '펼치기'
logToggle.textContent = tt('logViewer.expand')
} else {
logViewer.style.height = ''
logToggle.textContent = '접기'
logToggle.textContent = tt('logViewer.collapse')
}
})
@@ -33,6 +52,22 @@ api.onLog(function (line) {
logBody.scrollTop = logBody.scrollHeight
})
function applyStaticI18n() {
document.title = tt('app.title')
var h1 = document.querySelector('.appHeader h1')
if (h1) h1.textContent = tt('app.title')
var stepLis = stepIndicator.querySelectorAll('li')
stepLis.forEach(function (item) {
var idx = item.getAttribute('data-step')
if (idx === '1') item.textContent = tt('stepIndicator.step1')
else if (idx === '2') item.textContent = tt('stepIndicator.step2')
else if (idx === '3') item.textContent = tt('stepIndicator.step3')
})
var logH2 = logViewer.querySelector('header h2')
if (logH2) logH2.textContent = tt('logViewer.heading')
logToggle.textContent = tt('logViewer.collapse')
}
function setActiveStep(step) {
stepIndicator.querySelectorAll('li').forEach(function (item) {
var index = Number(item.getAttribute('data-step'))
@@ -51,9 +86,9 @@ function renderStep1() {
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>1단계. 음악퀴즈 선택</h2>' +
'<div id="packList" class="cardChoice"><p class="formMessage">목록을 불러오는 중...</p></div>' +
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>다음</button></div>'
'<h2>' + escapeHtml(tt('step1.heading')) + '</h2>' +
'<div id="packList" class="cardChoice"><p class="formMessage">' + escapeHtml(tt('common.loading')) + '</p></div>' +
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>' + escapeHtml(tt('common.next')) + '</button></div>'
pageHost.appendChild(section)
var listEl = section.querySelector('#packList')
var nextBtn = section.querySelector('#next')
@@ -61,7 +96,7 @@ function renderStep1() {
function renderList() {
listEl.innerHTML = ''
if (state.packs.length === 0) {
listEl.innerHTML = '<p class="formMessage error">등록된 음악퀴즈가 없습니다.</p>'
listEl.innerHTML = '<p class="formMessage error">' + escapeHtml(tt('common.noPacks')) + '</p>'
return
}
state.packs.forEach(function (pack) {
@@ -69,11 +104,14 @@ function renderStep1() {
card.type = 'button'
card.className = 'choiceCard'
if (state.selectedKey === pack.key) card.classList.add('selected')
var verLabel = pack.mcVersion ? '마인크래프트 ' + escapeHtml(pack.mcVersion) + ' · ' : ''
var verLabel = pack.mcVersion
? escapeHtml(tt('common.mcVersionLabel', { version: pack.mcVersion }))
: ''
card.innerHTML =
'<strong>' + escapeHtml(pack.name) + '</strong>' +
'<small>' + verLabel +
'음악 ' + pack.list.music.length + '곡 · 사진 ' + pack.list.images.length + '장</small>'
escapeHtml(tt('common.trackImageCount', { music: pack.list.music.length, image: pack.list.images.length })) +
'</small>'
card.addEventListener('click', function () {
state.selectedKey = pack.key
nextBtn.disabled = false
@@ -88,7 +126,7 @@ function renderStep1() {
api.selectPack(state.selectedKey).then(function () {
renderStep2()
}).catch(function (err) {
alert(err.message || '선택 실패')
alert(err.message || tt('common.selectFailed'))
})
})
@@ -96,7 +134,9 @@ function renderStep1() {
state.packs = packs || []
renderList()
}).catch(function (err) {
listEl.innerHTML = '<p class="formMessage error">목록 로드 실패: ' + escapeHtml(err.message || '') + '</p>'
listEl.innerHTML = '<p class="formMessage error">' +
escapeHtml(tt('common.listLoadFailed', { message: err.message || '' })) +
'</p>'
})
}
@@ -115,30 +155,29 @@ function renderStep2() {
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>2단계. 리소스팩 설치</h2>' +
'<p class="formMessage">음악·사진을 받아 리소스팩을 만들고 ' +
'<code>%appdata%/.mc_custom/resourcepacks/</code> 에 자동 설치합니다.</p>' +
'<h2>' + escapeHtml(tt('step2.heading')) + '</h2>' +
'<p class="formMessage">' + tt('step2.description') + '</p>' +
'<div class="prepRow">' +
' <span class="prepChip" id="chip-ytdlp">yt-dlp 준비</span>' +
' <span class="prepChip" id="chip-ffmpeg">ffmpeg 준비</span>' +
' <span class="prepChip" id="chip-ytdlp">' + escapeHtml(tt('step2.chipYtdlp')) + '</span>' +
' <span class="prepChip" id="chip-ffmpeg">' + escapeHtml(tt('step2.chipFfmpeg')) + '</span>' +
'</div>' +
'<div class="progressSection">' +
' <h3>음악 다운로드</h3>' +
' <div class="sectionSub" id="music-sub">' + musicTotal + '</div>' +
' <h3>' + escapeHtml(tt('step2.musicHeading')) + '</h3>' +
' <div class="sectionSub" id="music-sub">' + escapeHtml(tt('step2.musicSub', { count: musicTotal })) + '</div>' +
' <div class="progressGrid" id="musicGrid"></div>' +
'</div>' +
'<div class="progressSection">' +
' <h3>사진 다운로드</h3>' +
' <div class="sectionSub" id="image-sub">' + imageTotal + '</div>' +
' <h3>' + escapeHtml(tt('step2.imageHeading')) + '</h3>' +
' <div class="sectionSub" id="image-sub">' + escapeHtml(tt('step2.imageSub', { count: imageTotal })) + '</div>' +
' <div class="progressGrid" id="imageGrid"></div>' +
'</div>' +
'<div class="progressSection">' +
' <h3>리소스팩 빌드</h3>' +
' <div class="sectionSub" id="pkg-sub">대기 중…</div>' +
' <h3>' + escapeHtml(tt('step2.packageHeading')) + '</h3>' +
' <div class="sectionSub" id="pkg-sub">' + escapeHtml(tt('step2.packageWaiting')) + '</div>' +
'</div>' +
'<div class="actionRow">' +
' <span></span>' +
' <button class="dangerBtn" id="cancel">취소</button>' +
' <button class="dangerBtn" id="cancel">' + escapeHtml(tt('common.cancel')) + '</button>' +
'</div>'
pageHost.appendChild(section)
@@ -156,7 +195,7 @@ function renderStep2() {
card.innerHTML =
'<div class="cardTop"><span class="label">' + idx + '</span><span class="icon">○</span></div>' +
'<div class="bar"><span></span></div>' +
'<div class="pct">대기</div>'
'<div class="pct">' + escapeHtml(tt('step2.cardWaiting')) + '</div>'
return card
}
for (var m = 1; m <= musicTotal; m++) musicGrid.appendChild(buildCard(m))
@@ -172,17 +211,17 @@ function renderStep2() {
var pct = card.querySelector('.pct')
var icon = card.querySelector('.icon')
if (status === 'done') {
if (pct) pct.textContent = '완료'
if (pct) pct.textContent = tt('step2.cardDone')
if (icon) icon.textContent = '✓'
if (bar) bar.style.width = '100%'
} else if (status === 'error') {
if (pct) pct.textContent = '실패'
if (pct) pct.textContent = tt('step2.cardError')
if (icon) icon.textContent = '✕'
} else if (status === 'running') {
if (pct) pct.textContent = Math.round(percent) + '%'
if (icon) icon.textContent = '⏳'
} else {
if (pct) pct.textContent = '대기'
if (pct) pct.textContent = tt('step2.cardWaiting')
if (icon) icon.textContent = '○'
}
}
@@ -209,7 +248,9 @@ function renderStep2() {
return
}
if (payload.phase === 'package') {
pkgSub.textContent = payload.done ? '설치 완료' : (payload.message || '빌드 중…')
pkgSub.textContent = payload.done
? tt('step2.packageDone')
: (payload.message || tt('step2.packageBuilding'))
return
}
})
@@ -232,7 +273,7 @@ function renderStep2() {
}).catch(function (err) {
state.installing = false
if (stopProgress) stopProgress()
alert('설치 실패: ' + ((err && err.message) || err))
alert(tt('common.installFailed', { message: (err && err.message) || err }))
renderStep1()
})
}
@@ -244,14 +285,14 @@ function renderStep3() {
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>3단계. 완료</h2>' +
'<p class="formMessage">리소스팩 설치를 완료했습니다.</p>' +
'<h2>' + escapeHtml(tt('step3.heading')) + '</h2>' +
'<p class="formMessage">' + escapeHtml(tt('step3.message')) + '</p>' +
(state.resourcepackPath
? '<p class="formMessage"><code>' + escapeHtml(state.resourcepackPath) + '</code></p>'
: '') +
'<div class="actionRow">' +
' <button class="secondaryBtn" id="openFolder">리소스팩 폴더 열기</button>' +
' <button class="primaryBtn" id="finish">확인</button>' +
' <button class="secondaryBtn" id="openFolder">' + escapeHtml(tt('common.openFolder')) + '</button>' +
' <button class="primaryBtn" id="finish">' + escapeHtml(tt('common.confirm')) + '</button>' +
'</div>'
pageHost.appendChild(section)
section.querySelector('#openFolder').addEventListener('click', function () {
@@ -268,4 +309,8 @@ function escapeHtml(s) {
})
}
renderStep1()
;(async function () {
try { I18N = (await api.loadLocale()) || {} } catch (_) { I18N = {} }
applyStaticI18n()
renderStep1()
})()

View File

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

View File

@@ -0,0 +1,127 @@
{
"app": {
"title": "마인크래프트 음악퀴즈 리소스팩 간편설치기"
},
"stepIndicator": {
"step1": "1. 음악퀴즈",
"step2": "2. 설치",
"step3": "3. 완료"
},
"logViewer": {
"heading": "설치 로그",
"collapse": "접기",
"expand": "펼치기"
},
"common": {
"next": "다음",
"cancel": "취소",
"confirm": "확인",
"openFolder": "리소스팩 폴더 열기",
"loading": "목록을 불러오는 중...",
"selectFailed": "선택 실패",
"listLoadFailed": "목록 로드 실패: {{message}}",
"installFailed": "설치 실패: {{message}}",
"noPacks": "등록된 음악퀴즈가 없습니다.",
"mcVersionLabel": "마인크래프트 {{version}} · ",
"trackImageCount": "음악 {{music}}곡 · 사진 {{image}}장",
"requestTimeout": "요청 시간 초과",
"tooManyRedirects": "redirect 가 너무 많습니다."
},
"step1": {
"heading": "1단계. 음악퀴즈 선택"
},
"step2": {
"heading": "2단계. 리소스팩 설치",
"description": "음악·사진을 받아 리소스팩을 만들고 <code>%appdata%/.mc_custom/resourcepacks/</code> 에 자동 설치합니다.",
"chipYtdlp": "yt-dlp 준비",
"chipFfmpeg": "ffmpeg 준비",
"musicHeading": "음악 다운로드",
"musicSub": "{{count}}곡",
"imageHeading": "사진 다운로드",
"imageSub": "{{count}}장",
"packageHeading": "리소스팩 빌드",
"packageWaiting": "대기 중…",
"packageBuilding": "빌드 중…",
"packageDone": "설치 완료",
"cardWaiting": "대기",
"cardDone": "완료",
"cardError": "실패"
},
"step3": {
"heading": "3단계. 완료",
"message": "리소스팩 설치를 완료했습니다."
},
"log": {
"manifestDownload": "manifest 다운로드: {{url}}",
"packDefFailed": "팩 정의 로드 실패 ({{file}}): {{message}} — mcVersion 폴백",
"listLoadFailed": "목록 로드 실패 ({{file}}): {{message}}",
"packsLoaded": "로드된 음악퀴즈: {{count}}개",
"packEntry": " - {{key}}: mc={{mc}} 베이스={{base}}",
"packEntryUnknownVersion": "?",
"packEntryNoBase": "(없음)",
"selectedPack": "선택: {{key}}",
"ytdlpPreparing": "yt-dlp 준비 중…",
"ytdlpPath": "yt-dlp 경로: {{path}}",
"ffmpegPreparing": "ffmpeg 준비 중…",
"ffmpegPath": "ffmpeg 경로: {{path}}",
"cpuDetected": "CPU 코어 {{cores}}개 감지 → 동시 다운로드 {{concurrency}}개",
"musicStart": "음악 다운로드 시작 ({{total}}곡, 동시 {{concurrency}}개, 시차 {{stagger}}ms)",
"musicTrackStart": "{{idx}}번 노래 다운로드 시작",
"musicTrackDone": "{{idx}}번 노래 완료: {{name}}",
"imageStart": "사진 다운로드 시작 ({{total}}장)",
"imageDownloading": "{{idx}}번 사진 다운로드 중…",
"imageDone": "{{idx}}번 사진 완료: {{name}}",
"baseDownload": "베이스 리소스팩 다운로드: {{path}}",
"baseUrl": " URL: {{url}}",
"baseReceived": "베이스 리소스팩 받음 ({{kb}} KB)",
"baseAbsent": "베이스 리소스팩 없음(resourcepackPath 빈 값) — 새 리소스팩으로 생성",
"buildingZip": "리소스팩 zip 빌드 중… ({{name}})",
"installComplete": "설치 완료: {{path}}",
"cancelRequested": "취소 요청됨. 실행 중 프로세스 {{count}}개 중단…",
"ytdlpExists": "yt-dlp.exe 이미 있음: {{path}}",
"ytdlpDownloading": "yt-dlp.exe 다운로드 중: {{url}}",
"ytdlpReady": "yt-dlp.exe 준비 완료: {{path}}",
"ffmpegExists": "ffmpeg.exe 이미 있음: {{path}}",
"ffmpegDownloading": "ffmpeg.exe 다운로드 중: {{url}}",
"ffmpegExtracting": "ffmpeg zip 압축 해제 중…",
"ffmpegReady": "ffmpeg.exe 준비 완료: {{path}}",
"baseExtract": "베이스 리소스팩 압축 해제: {{name}}",
"packFormatMatched": "pack_format = {{format}} (mcVersion {{matched}})",
"packFormatFallback": "pack_format = {{format}} (mcVersion \"{{version}}\" 매칭 실패, 최신 폴백)",
"soundsMerged": "기존 sounds.json 병합 ({{count}}개 항목)",
"ytdlpLine": "yt-dlp> {{line}}"
},
"progress": {
"ytdlpPreparing": "yt-dlp 준비 중",
"ffmpegPreparing": "ffmpeg 준비 중",
"ready": "준비 완료",
"cancelled": "취소됨",
"baseDownloading": "베이스 리소스팩 다운로드 중",
"buildingWithBase": "베이스에 음악·사진 추가 중",
"buildingZip": "zip 빌드 중",
"installComplete": "설치 완료"
},
"pack": {
"description": "음악퀴즈 리소스팩 - {{name}}"
},
"errors": {
"selectedPackNotFound": "선택한 음악퀴즈를 찾을 수 없습니다.",
"selectPackFirst": "음악퀴즈를 먼저 선택해주세요.",
"currentPackNotFound": "선택된 음악퀴즈를 찾을 수 없습니다.",
"cancelledByUser": "사용자가 설치를 취소했습니다.",
"musicDownloadFailed": "{{idx}}번 노래 다운로드 실패: {{message}}",
"imageDownloadFailed": "{{idx}}번 사진 다운로드 실패: {{message}}",
"imageNormalizeFailed": "{{idx}}번 사진 정규화 실패: {{message}}",
"baseDownloadFailed": "베이스 리소스팩 다운로드 실패: {{message}}",
"ytdlpSignal": "yt-dlp 가 신호 {{signal}} 로 종료됨",
"ytdlpExit": "yt-dlp 종료 코드 {{code}}: {{stderr}}",
"ytdlpNoStderr": "(stderr 없음)",
"ytdlpMissingOutput": "예상 출력파일이 없음: {{path}}",
"imageMetaUnknown": "이미지 크기를 읽지 못함",
"ytdlpVerifyFailed": "yt-dlp.exe 다운로드는 됐지만 실행 검증에 실패했습니다.",
"ytdlpInstallFailed": "yt-dlp.exe 자동 설치 실패: {{message}}",
"ffmpegNotInZip": "zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.",
"ffmpegVerifyFailed": "ffmpeg.exe 다운로드는 됐지만 실행 검증에 실패했습니다.",
"ffmpegInstallFailed": "ffmpeg.exe 자동 설치 실패: {{message}}"
}
}

View File

@@ -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를 표시합니다 (<a href=\"{{url}}\" target=\"_blank\">{{url}}</a>).",
"loadFailed": "EULA 페이지를 불러올 수 없습니다. 직접 확인해 주세요: <a href=\"https://www.minecraft.net/en-us/eula\" target=\"_blank\">https://www.minecraft.net/en-us/eula</a>"
},
"sub34": {
"heading": "3-4. 서버 설정 편집",
"description": "로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.",
"open": "편집기 열기",
"openedAt": "편집기 주소: <a href=\"{{url}}\" target=\"_blank\">{{url}}</a>",
"openFailed": "편집기 실행 실패: {{message}}"
},
"sub35": {
"heading": "3-5. 포트포워딩 점검",
"description": "서버의 외부 접근 가능 여부를 확인합니다. UPnP를 시도해도 안 되면 직접 포트포워딩을 안내합니다.",
"portLabel": "포트",
"recheck": "재점검",
"checking": "확인 중...",
"preForwarded": "이미 외부 접속 가능: {{ip}}:{{port}}",
"upnpOk": "UPnP로 자동 개방 완료: {{ip}}:{{port}}",
"manualHint": "직접 포트포워딩을 해주세요.",
"manualDetail": "<br><small>외부 IP: {{ip}}, 포트: {{port}}</small>",
"checkFailed": "점검 실패: {{message}}",
"ipUnknown": "확인 불가"
}
},
"step4": {
"heading": "4단계. 유저 클라이언트 설정",
"sub41": {
"heading": "4-1. 모드 플랫폼",
"vanillaInfo": "선택한 음악퀴즈의 플랫폼: <strong>vanilla</strong>",
"vanillaNoInstall": "바닐라이므로 별도 설치는 필요 없습니다.",
"info": "선택한 음악퀴즈의 플랫폼: <strong>{{platform}}</strong>",
"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)"
}
}

166
locales/server/ko-kr.json Normal file
View File

@@ -0,0 +1,166 @@
{
"common": {
"back": "← 돌아가기",
"backToList": "목록으로",
"save": "저장",
"cancel": "취소",
"ok": "확인",
"delete": "삭제",
"edit": "수정",
"close": "×",
"loading": "불러오는 중..."
},
"site": {
"indexTitle": "음악퀴즈 목록",
"heroTitle": "마인크래프트 음악퀴즈",
"heroSubtitle": "설치기에서 사용 가능한 음악퀴즈 목록입니다.",
"empty": "등록된 음악퀴즈가 없습니다.",
"fileLabel": "파일: {{file}}.json",
"mcVersion": "마인크래프트",
"platform": "플랫폼",
"modsFolder": "모드 폴더",
"resourcepack": "리소스팩",
"noneFallback": "없음"
},
"nav": {
"brand": "관리자 페이지",
"logout": "로그아웃"
},
"login": {
"title": "관리자 로그인",
"password": "비밀번호",
"submit": "로그인",
"wrongPassword": "비밀번호가 올바르지 않습니다."
},
"dashboard": {
"title": "음악퀴즈 목록",
"browserTitle": "관리자 대시보드",
"editList": "음악목록 수정",
"editDatapack": "데이터팩 수정",
"addPack": "음악퀴즈 추가",
"deletePack": "음악퀴즈 삭제",
"emptyHint": "등록된 음악퀴즈가 없습니다. \"음악퀴즈 추가\" 버튼으로 새로 만들어 보세요.",
"select": "선택",
"confirmDelete": "삭제 확인",
"mcShort": "MC"
},
"list": {
"browserTitle": "음악목록 수정",
"title": "음악목록 수정"
},
"listEditor": {
"browserTitle": "{{name}} — 음악/사진 목록",
"dirtyTooltip": "저장되지 않은 변경사항이 있습니다",
"tabMusic": "음악목록",
"tabImage": "사진목록",
"saveList": "목록 저장",
"clearList": "목록 초기화",
"playlistPlaceholder": "유튜브 플레이리스트 URL",
"fetchPlaylist": "플레이리스트 불러오기",
"imageFromMusic": "음악목록에서 가져오기",
"modalConfirmTitle": "확인",
"musicEditTitle": "음악 항목 수정",
"musicEditUrl": "유튜브 영상 주소",
"musicEditHint": "저장하면 yt-dlp 로 제목·가수·재생시간을 자동으로 갱신합니다.",
"imageEditTitle": "사진 항목 수정",
"imageSegYt": "유튜브 주소",
"imageSegImg": "이미지 주소",
"imageEditUrl": "주소",
"titleFallback": "(제목 없음)",
"artistFallback": "(가수 미상)",
"rowEditTooltip": "더블클릭해서 수정",
"metaLoading": "메타데이터 가져오는 중…",
"metaFailedShort": "메타 조회 실패",
"metaFailedTitle": "메타데이터 조회 실패",
"metaFailedAsk": "{{message}}\n주소만 변경하고 제목/가수/시간은 그대로 둘까요?",
"saving": "저장 중…",
"saved": "저장 완료",
"saveFailed": "저장 실패: {{message}}",
"fetchEnterUrl": "플레이리스트 주소를 입력해 주세요.",
"fetchTitle": "플레이리스트 불러오기",
"fetchConfirm": "현재 {{type}}목록 순서가 모두 사라집니다. 진행할까요?",
"fetchTypeMusic": "음악",
"fetchTypeImage": "사진",
"fetchLoading": "불러오는 중…",
"fetchedCount": "{{count}}개 항목을 불러왔습니다.",
"failed": "실패: {{message}}",
"clearTitle": "목록 초기화",
"clearConfirm": "\"{{type}}목록\"을 비웁니다. 진행할까요?",
"imageFromMusicEmpty": "음악목록이 비어 있어 가져올 수 없습니다.",
"imageFromMusicTitle": "사진목록 가져오기",
"imageFromMusicConfirm": "저장된 음악목록의 영상 {{count}}개를 그대로 사진목록으로 가져옵니다.\n현재 사진목록은 모두 사라집니다. 진행할까요?",
"leaveTitle": "저장되지 않은 변경사항",
"leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?"
},
"editor": {
"browserTitle": "{{name}} 편집",
"eyebrow": "PACK EDITOR",
"displayName": "음악퀴즈 이름",
"fileName": "JSON 파일 이름 (확장자 제외)",
"mcVersion": "마인크래프트 버전",
"platformType": "모드 플랫폼",
"platformDownloadUrl": "플랫폼 설치파일 URL",
"platformDownloadHint": "도메인 없이 입력하면 manifest.json 도메인의 <code>/file/platforms/&lt;파일명&gt;</code>으로 해석됩니다.",
"platformLoaderVersion": "Fabric Loader 버전",
"platformLoaderHint": "선택한 마인크래프트 버전 기준 Fabric Loader 목록입니다. 설치기는 최신 fabric-installer 를 받아 자동으로 CLI 설치합니다.",
"platformLoaderEmpty": "호환 로더 없음",
"platformLoaderPickMc": "마인크래프트 버전을 먼저 선택하세요",
"platformLoaderLoadFailed": "로더 목록 로드 실패: {{message}}",
"serverMinRam": "서버 최소 램 (MB)",
"serverMaxRam": "서버 최대 램 (MB)",
"clientMinRam": "클라이언트 최소 램 (MB)",
"clientRecommendedRam": "클라이언트 권장 램 (MB)",
"mapPath": "맵 파일 (.zip)",
"mapPathHint": "/file/maps/ 아래 zip 파일 이름.",
"serverPath": "서버 파일 (.zip)",
"serverPathHint": "/file/servers/ 아래 zip 파일 이름. 멀티 모드 전용.",
"modsFolder": "모드 폴더 이름",
"modsFolderHint": "/file/mods/&lt;폴더이름&gt;/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.",
"resourcepackPath": "리소스팩 (.zip)",
"resourcepackHint": "/file/resourcepacks/ 아래 .zip 파일 이름. 비워두면 리소스팩을 받지 않습니다.",
"ramOrderInvalid": "클라이언트 최소 램은 권장 램보다 클 수 없습니다.",
"fabricLoaderRequired": "Fabric 로더 버전을 선택해 주세요."
},
"datapack": {
"browserTitle": "데이터팩 수정",
"title": "데이터팩 수정",
"pickPack": "음악퀴즈 선택",
"pickedNone": "선택된 음악퀴즈 없음",
"pickedLabel": "선택: {{name}}",
"totalCount": "총 {{count}}개의 음악을 찾았습니다.",
"export": "데이터팩 출력",
"copy": "복사",
"copied": "복사됨",
"exporting": "출력 중…",
"exported": "출력 완료",
"failed": "실패: {{message}}",
"modalPickTitle": "음악퀴즈 선택"
},
"errors": {
"packNotFound": "해당 음악퀴즈를 찾을 수 없습니다.",
"packNotFoundJson": "음악퀴즈를 찾을 수 없습니다.",
"videoUrlRequired": "영상 주소를 입력해 주세요.",
"playlistUrlRequired": "플레이리스트 주소를 입력해 주세요.",
"metaNotFound": "메타데이터를 찾을 수 없습니다.",
"ramOrderInvalid": "clientMinRam은 clientRecommendedRam보다 클 수 없습니다.",
"unknown": "알 수 없는 오류",
"serverError": "서버 오류: {{message}}"
},
"youtube": {
"ytdlpUnavailable": "yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)",
"ytdlpVerifyFailed": "yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.",
"ytdlpInstallFailed": "yt-dlp 자동 설치에 실패했습니다: {{message}}",
"ytdlpVideoFailed": "yt-dlp 영상 조회 실패 (code={{code}}): {{detail}}",
"ytdlpPlaylistFailed": "yt-dlp 플레이리스트 조회 실패 (code={{code}}): {{detail}}",
"tooManyRedirects": "redirect 가 너무 많습니다."
},
"datapackOutput": {
"header": "# === musicquiz: {{name}} ===",
"summary": "# 총 {{musicCount}}곡 / 사진 {{imageCount}}장",
"initLine": "say [musicquiz] 데이터팩 초기화",
"placeholder": "# 곡별 placeholder. 실제 포맷 확정되면 교체 예정.",
"trackLine": "# {{index}}. {{title}} - {{artist}} ({{duration}}s)",
"titleFallback": "(제목 없음)",
"artistFallback": "(가수 미상)"
}
}

View File

@@ -1,6 +1,22 @@
(function () {
'use strict'
// listEditor.ejs 에서 주입되는 사전 (locales/server/ko-kr.json 의 listEditor + common 섹션).
// 키가 비어 있어도 lookup 함수가 키를 그대로 반환해 UI 가 깨지지는 않는다.
function tt(key, params) {
var parts = key.split('.')
var cur = (typeof I18N !== 'undefined') ? 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 + '}}')
})
}
var state = {
musicPlaylistUrl: (INITIAL.musicPlaylistUrl) || '',
imagePlaylistUrl: (INITIAL.imagePlaylistUrl) || '',
@@ -87,10 +103,10 @@
'<span class="rowNum">' + (idx + 1) + '</span>' +
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy" draggable="false"/>' +
'<div class="rowMeta">' +
'<div class="rowTitle" spellcheck="false" data-field="title" data-placeholder="(제목 없음)" title="더블클릭해서 수정">' +
'<div class="rowTitle" spellcheck="false" data-field="title" data-placeholder="' + escapeHtml(tt('titleFallback')) + '" title="' + escapeHtml(tt('rowEditTooltip')) + '">' +
escapeHtml(entry.title || '') +
'</div>' +
'<div class="rowSub" spellcheck="false" data-field="artist" data-placeholder="(가수 미상)" title="더블클릭해서 수정">' +
'<div class="rowSub" spellcheck="false" data-field="artist" data-placeholder="' + escapeHtml(tt('artistFallback')) + '" title="' + escapeHtml(tt('rowEditTooltip')) + '">' +
escapeHtml(entry.artist || '') +
'</div>' +
'</div>' +
@@ -116,7 +132,7 @@
'<img src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>' +
'</div>' +
'<div class="cardCaption">' +
'<div class="cardTitle" title="' + escapeHtml(cap.title) + '">' + (escapeHtml(cap.title) || '<span class="muted">(제목 없음)</span>') + '</div>' +
'<div class="cardTitle" title="' + escapeHtml(cap.title) + '">' + (escapeHtml(cap.title) || ('<span class="muted">' + escapeHtml(tt('titleFallback')) + '</span>')) + '</div>' +
'<div class="cardSub">' + escapeHtml(cap.sub) + '</div>' +
'</div>'
attachDraggable(card, 'image', idx)
@@ -330,7 +346,7 @@
if (!url) return
var prev = state.music[editingIdx] || { url: '', title: '', artist: '', durationSec: 0 }
if (url === prev.url) { closeAllModals(); return }
setStatus('edit-music-status', '메타데이터 가져오는 중…')
setStatus('edit-music-status', tt('metaLoading'))
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/video-meta', {
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -339,8 +355,8 @@
return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body } })
}).then(function (result) {
if (!result.ok || !result.body || !result.body.ok) {
var msg = (result.body && result.body.message) ? result.body.message : '메타 조회 실패'
ask('메타데이터 조회 실패', msg + '\n주소만 변경하고 제목/가수/시간은 그대로 둘까요?', function () {
var msg = (result.body && result.body.message) ? result.body.message : tt('metaFailedShort')
ask(tt('metaFailedTitle'), tt('metaFailedAsk', { message: msg }), function () {
state.music[editingIdx].url = url
markDirty()
closeAllModals()
@@ -360,7 +376,7 @@
closeAllModals()
renderMusic()
}).catch(function (err) {
setStatus('edit-music-status', '실패: ' + err.message, true)
setStatus('edit-music-status', tt('failed', { message: err.message }), true)
})
})
@@ -389,17 +405,16 @@
// ── 사진목록: 음악목록 그대로 복사 ─────────────────
document.getElementById('image-from-music').addEventListener('click', function () {
if (state.music.length === 0) {
setStatus('status-image', '음악목록이 비어 있어 가져올 수 없습니다.', true)
setStatus('status-image', tt('imageFromMusicEmpty'), true)
return
}
ask('사진목록 가져오기',
'저장된 음악목록의 영상 ' + state.music.length + '개를 그대로 사진목록으로 가져옵니다.\n'
+ '현재 사진목록은 모두 사라집니다. 진행할까요?',
ask(tt('imageFromMusicTitle'),
tt('imageFromMusicConfirm', { count: state.music.length }),
function () {
state.images = state.music.map(function (m) { return { url: m.url } })
markDirty()
renderImage()
setStatus('status-image', state.images.length + '개 항목을 불러왔습니다.')
setStatus('status-image', tt('fetchedCount', { count: state.images.length }))
})
})
@@ -431,7 +446,8 @@
var action = btn.getAttribute('data-action')
var target = btn.getAttribute('data-target')
if (action === 'clear') {
ask('목록 초기화', '"' + (target === 'music' ? '음악' : '사진') + '목록"을 비웁니다. 진행할까요?', function () {
var typeLabel = target === 'music' ? tt('fetchTypeMusic') : tt('fetchTypeImage')
ask(tt('clearTitle'), tt('clearConfirm', { type: typeLabel }), function () {
if (target === 'music') { state.music = []; renderMusic() }
else { state.images = []; renderImage() }
markDirty()
@@ -457,7 +473,7 @@
}
})
var statusId = 'status-' + target
setStatus(statusId, '저장 중…')
setStatus(statusId, tt('saving'))
fetch('/op/list/' + encodeURIComponent(PACK_KEY), {
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -465,10 +481,10 @@
}).then(function (r) {
return r.json().then(function (body) { return { ok: r.ok, body: body } })
}).then(function (result) {
if (result.ok && result.body.ok) { setStatus(statusId, '저장 완료'); markClean() }
else setStatus(statusId, '저장 실패: ' + (result.body.message || ''), true)
if (result.ok && result.body.ok) { setStatus(statusId, tt('saved')); markClean() }
else setStatus(statusId, tt('saveFailed', { message: result.body.message || '' }), true)
}).catch(function (err) {
setStatus(statusId, '저장 실패: ' + err.message, true)
setStatus(statusId, tt('saveFailed', { message: err.message }), true)
})
}
@@ -476,11 +492,12 @@
var input = document.getElementById(target + '-playlist-url')
var url = input.value.trim()
if (!url) {
setStatus('status-' + target, '플레이리스트 주소를 입력해 주세요.', true)
setStatus('status-' + target, tt('fetchEnterUrl'), true)
return
}
ask('플레이리스트 불러오기', '현재 ' + (target === 'music' ? '음악' : '사진') + '목록 순서가 모두 사라집니다. 진행할까요?', function () {
setStatus('status-' + target, '불러오는 중…')
var typeLabel = target === 'music' ? tt('fetchTypeMusic') : tt('fetchTypeImage')
ask(tt('fetchTitle'), tt('fetchConfirm', { type: typeLabel }), function () {
setStatus('status-' + target, tt('fetchLoading'))
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/playlist', {
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -489,7 +506,7 @@
return r.json().then(function (body) { return { ok: r.ok, body: body } })
}).then(function (result) {
if (!result.ok || !result.body.ok) {
setStatus('status-' + target, '실패: ' + (result.body.message || ''), true)
setStatus('status-' + target, tt('failed', { message: result.body.message || '' }), true)
return
}
var entries = result.body.entries || []
@@ -503,9 +520,9 @@
renderImage()
}
markDirty()
setStatus('status-' + target, entries.length + '개 항목을 불러왔습니다.')
setStatus('status-' + target, tt('fetchedCount', { count: entries.length }))
}).catch(function (err) {
setStatus('status-' + target, '실패: ' + err.message, true)
setStatus('status-' + target, tt('failed', { message: err.message }), true)
})
})
}
@@ -527,9 +544,7 @@
if (!dirty) return
e.preventDefault()
var href = a.getAttribute('href')
ask('저장되지 않은 변경사항',
'저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?',
function () {
ask(tt('leaveTitle'), tt('leaveConfirm'), function () {
markClean()
window.location.href = href
})

View File

@@ -4,6 +4,9 @@ import path from 'node:path'
import https from 'node:https'
import http from 'node:http'
import { getMcCustomDir } from '../shared/paths.js'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
// extract-zip 은 CommonJS 기본 export 라 require 로 받음.
const extractZip: (source: string, options: { dir: string }) => Promise<void> = require('extract-zip')
@@ -31,7 +34,7 @@ export async function ensureFfmpegExe(
): Promise<string> {
const target = getFfmpegExePath()
if (await canExecute(target)) {
log?.(`ffmpeg.exe 이미 있음: ${target}`)
log?.(t('log.ffmpegExists', { path: target }))
return target
}
if (installPromise) return installPromise
@@ -46,14 +49,14 @@ export async function ensureFfmpegExe(
await fs.rm(zipPath, { force: true })
await fs.rm(extractDir, { recursive: true, force: true })
log?.(`ffmpeg.exe 다운로드 중: ${FFMPEG_ZIP_URL}`)
log?.(t('log.ffmpegDownloading', { url: FFMPEG_ZIP_URL }))
await downloadToFile(FFMPEG_ZIP_URL, zipPath)
log?.('ffmpeg zip 압축 해제 중…')
log?.(t('log.ffmpegExtracting'))
await extractZip(zipPath, { dir: extractDir })
const found = await findFile(extractDir, 'ffmpeg.exe')
if (!found) {
throw new Error('zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.')
throw new Error(t('errors.ffmpegNotInZip'))
}
// 같은 파일시스템(=같은 드라이브) 일 가능성이 높아 rename 시도, 실패 시 copyFile fallback.
try {
@@ -63,14 +66,15 @@ export async function ensureFfmpegExe(
}
const ok = await probeVersion(target)
if (!ok) throw new Error('ffmpeg.exe 다운로드는 됐지만 실행 검증에 실패했습니다.')
log?.(`ffmpeg.exe 준비 완료: ${target}`)
if (!ok) throw new Error(t('errors.ffmpegVerifyFailed'))
log?.(t('log.ffmpegReady', { path: target }))
return target
} catch (err) {
try { await fs.unlink(target) } catch { /* noop */ }
throw new Error(
'ffmpeg.exe 자동 설치 실패: ' +
(err instanceof Error ? err.message : String(err))
t('errors.ffmpegInstallFailed', {
message: err instanceof Error ? err.message : String(err)
})
)
} finally {
// 임시 파일/폴더 정리
@@ -114,7 +118,7 @@ async function findFile(root: string, name: string): Promise<string | null> {
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
return new Promise((resolve, reject) => {
if (redirects > 8) {
reject(new Error('redirect 가 너무 많습니다.'))
reject(new Error(t('common.tooManyRedirects')))
return
}
const lib = url.startsWith('https://') ? https : http

View File

@@ -4,6 +4,9 @@ import http from 'node:http'
import https from 'node:https'
import { URL } from 'node:url'
import sharp from 'sharp'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
/** painting variant 텍스처의 최대 변 길이(px). 슬롯 4x4 × 256px. */
const MAX_SIDE = 1024
@@ -30,7 +33,7 @@ export function ytIdFromUrl(url: string): string {
function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
return new Promise((resolve, reject) => {
if (redirects > 8) {
reject(new Error('redirect 가 너무 많습니다.'))
reject(new Error(t('common.tooManyRedirects')))
return
}
const target = new URL(url)
@@ -56,7 +59,7 @@ function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
res.on('end', () => resolve(Buffer.concat(chunks)))
})
req.on('error', reject)
req.on('timeout', () => req.destroy(new Error('요청 시간 초과')))
req.on('timeout', () => req.destroy(new Error(t('common.requestTimeout'))))
})
}
@@ -91,7 +94,7 @@ export async function normalizeToCover(buffer: Buffer, outPath: string): Promise
const meta = await img.metadata()
const w = meta.width ?? 0
const h = meta.height ?? 0
if (w <= 0 || h <= 0) throw new Error('이미지 크기를 읽지 못함')
if (w <= 0 || h <= 0) throw new Error(t('errors.imageMetaUnknown'))
const s = Math.min(w, h)
const left = Math.floor((w - s) / 2)
const top = Math.floor((h - s) / 2)

View File

@@ -11,6 +11,7 @@ import type { Manifest, PackDefinition, PackList } from '../shared/types.js'
import { normalizePackDefinition } from '../shared/store.js'
import { getAppDataDir, getMcCustomDir } from '../shared/paths.js'
import { loadEnv, getManifestUrl } from '../shared/env.js'
import { loadComponentI18n } from '../shared/i18n.js'
import type { RpFetchedPack } from './types.js'
import { ensureYtDlpExe } from './ytdlp.js'
import { ensureFfmpegExe } from './ffmpeg.js'
@@ -19,6 +20,9 @@ import { downloadImage, normalizeToCover, coverFileName } from './images.js'
import { buildResourcepackZip } from './pack.js'
loadEnv()
const i18n = loadComponentI18n('installer-rp')
const t = i18n.t
export const localeDict = i18n.dict
interface RpInstallerState {
manifestUrl: string
@@ -154,7 +158,7 @@ function fetchBuffer(url: string): Promise<Buffer> {
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('common.requestTimeout'))))
})
}
@@ -169,7 +173,7 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
state.manifestUrl = manifestUrlInput
state.baseUrl = deriveBaseUrl(manifestUrlInput)
}
sendLog(`manifest 다운로드: ${state.manifestUrl}`)
sendLog(t('log.manifestDownload', { url: state.manifestUrl }))
const manifest = await fetchJson<Manifest>(state.manifestUrl)
const results: RpFetchedPack[] = []
for (const entry of manifest.packs ?? []) {
@@ -181,7 +185,7 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
const [listRaw, packRaw] = await Promise.all([
fetchJson<Partial<PackList>>(listUrl),
fetchJson<Partial<PackDefinition>>(packUrl).catch((err) => {
sendLog(`팩 정의 로드 실패 (${entry.file}): ${(err as Error).message} — mcVersion 폴백`)
sendLog(t('log.packDefFailed', { file: entry.file, message: (err as Error).message }))
return null
})
])
@@ -202,31 +206,37 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
list
})
} catch (error) {
sendLog(`목록 로드 실패 (${entry.file}): ${(error as Error).message}`)
sendLog(t('log.listLoadFailed', { 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 }))
for (const item of results) {
sendLog(` - ${item.key}: mc=${item.mcVersion || '?'} 베이스=${item.resourcepackPath || '(없음)'}`)
sendLog(t('log.packEntry', {
key: item.key,
mc: item.mcVersion || t('log.packEntryUnknownVersion'),
base: item.resourcepackPath || t('log.packEntryNoBase')
}))
}
return results
})
ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
if (!state.packs.has(packKey)) {
throw new Error('선택한 음악퀴즈를 찾을 수 없습니다.')
throw new Error(t('errors.selectedPackNotFound'))
}
state.selectedKey = packKey
sendLog(`선택: ${packKey}`)
sendLog(t('log.selectedPack', { key: packKey }))
})
ipcMain.handle('rp:i18n:dict', () => localeDict)
// ── IPC: 2단계 설치 ──────────────────────────────────
ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => {
if (!state.selectedKey) throw new Error('음악퀴즈를 먼저 선택해주세요.')
if (!state.selectedKey) throw new Error(t('errors.selectPackFirst'))
const pack = state.packs.get(state.selectedKey)
if (!pack) throw new Error('선택된 음악퀴즈를 찾을 수 없습니다.')
if (!pack) throw new Error(t('errors.currentPackNotFound'))
state.cancelRequested = false
const tempRoot = path.join(getMcCustomDir(), '.temp')
@@ -237,16 +247,16 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
try {
// 2-1. yt-dlp / ffmpeg 준비 (%appdata%/.mc_custom/{yt-dlp,ffmpeg}.exe)
sendLog('yt-dlp 준비 중…')
sendProgress({ phase: 'prep', message: 'yt-dlp 준비 중' })
sendLog(t('log.ytdlpPreparing'))
sendProgress({ phase: 'prep', message: t('progress.ytdlpPreparing') })
const ytDlpBin = await ensureYtDlpExe(sendLog)
sendLog(`yt-dlp 경로: ${ytDlpBin}`)
sendLog(t('log.ytdlpPath', { path: ytDlpBin }))
throwIfCancelled()
sendLog('ffmpeg 준비 중…')
sendProgress({ phase: 'prep', message: 'ffmpeg 준비 중' })
sendLog(t('log.ffmpegPreparing'))
sendProgress({ phase: 'prep', message: t('progress.ffmpegPreparing') })
const ffmpegBin = await ensureFfmpegExe(sendLog)
sendLog(`ffmpeg 경로: ${ffmpegBin}`)
sendProgress({ phase: 'prep', message: '준비 완료', done: true })
sendLog(t('log.ffmpegPath', { path: ffmpegBin }))
sendProgress({ phase: 'prep', message: t('progress.ready'), done: true })
throwIfCancelled()
// 2-2. 음악 다운로드 (CPU 코어 수 기반 자동 동시 다운로드, 시차 출발, ogg 변환)
@@ -256,8 +266,8 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
const cpuCount = os.cpus()?.length ?? 0
// 첫 음악은 즉시 시작 가능하도록 base 를 현재 시각으로.
nextMusicStartAt = Date.now()
sendLog(`CPU 코어 ${cpuCount}개 감지 → 동시 다운로드 ${concurrency}`)
sendLog(`음악 다운로드 시작 (${musicTotal}곡, 동시 ${concurrency}개, 시차 ${MUSIC_START_STAGGER_MS}ms)`)
sendLog(t('log.cpuDetected', { cores: cpuCount, concurrency }))
sendLog(t('log.musicStart', { total: musicTotal, concurrency, stagger: MUSIC_START_STAGGER_MS }))
// 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias.
const musicList = pack.list.music
@@ -272,7 +282,7 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
if (state.cancelRequested) return
const entry = musicList[i]
const idx = i + 1
sendLog(`${idx}번 노래 다운로드 시작`)
sendLog(t('log.musicTrackStart', { idx }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
let child: ChildProcess | null = null
try {
@@ -296,16 +306,16 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
}
})
if (child) state.activeChildren.delete(child)
sendLog(`${idx}번 노래 완료: ${path.basename(outPath)}`)
sendLog(t('log.musicTrackDone', { idx, name: path.basename(outPath) }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
} catch (err) {
if (child) state.activeChildren.delete(child)
if (state.cancelRequested) {
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: '취소됨' })
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: t('progress.cancelled') })
return
}
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(`${idx}번 노래 다운로드 실패: ${(err as Error).message}`)
throw new Error(t('errors.musicDownloadFailed', { idx, message: (err as Error).message }))
}
}
}
@@ -319,19 +329,19 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
// 2-3. 사진 다운로드 + painting variant 정규화
const paintingDir = path.join(tempRoot, 'painting')
await fsp.mkdir(paintingDir, { recursive: true })
sendLog(`사진 다운로드 시작 (${imageTotal}장)`)
sendLog(t('log.imageStart', { total: imageTotal }))
for (let i = 0; i < imageTotal; i++) {
throwIfCancelled()
const entry = pack.list.images[i]
const idx = i + 1
sendLog(`${idx}번 사진 다운로드 중…`)
sendLog(t('log.imageDownloading', { idx }))
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running' })
let buf: Buffer
try {
buf = await downloadImage(entry.url)
} catch (err) {
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(`${idx}번 사진 다운로드 실패: ${(err as Error).message}`)
throw new Error(t('errors.imageDownloadFailed', { idx, message: (err as Error).message }))
}
throwIfCancelled()
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 60, status: 'running' })
@@ -340,9 +350,9 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
await normalizeToCover(buf, outPath)
} catch (err) {
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(`${idx}번 사진 정규화 실패: ${(err as Error).message}`)
throw new Error(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message }))
}
sendLog(`${idx}번 사진 완료: ${path.basename(outPath)}`)
sendLog(t('log.imageDone', { idx, name: path.basename(outPath) }))
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
}
@@ -354,18 +364,18 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
const cleaned = pack.resourcepackPath.replace(/^\/+/, '')
const baseUrl = `${state.baseUrl}/file/resourcepacks/${encodeURIComponent(cleaned)}`
baseZipPath = path.join(tempRoot, 'base.zip')
sendLog(`베이스 리소스팩 다운로드: ${cleaned}`)
sendLog(` URL: ${baseUrl}`)
sendProgress({ phase: 'package', message: '베이스 리소스팩 다운로드 중' })
sendLog(t('log.baseDownload', { path: cleaned }))
sendLog(t('log.baseUrl', { url: baseUrl }))
sendProgress({ phase: 'package', message: t('progress.baseDownloading') })
try {
const buf = await fetchBuffer(baseUrl)
await fsp.writeFile(baseZipPath, buf)
sendLog(`베이스 리소스팩 받음 (${(buf.length / 1024).toFixed(1)} KB)`)
sendLog(t('log.baseReceived', { kb: (buf.length / 1024).toFixed(1) }))
} catch (err) {
throw new Error(`베이스 리소스팩 다운로드 실패: ${(err as Error).message}`)
throw new Error(t('errors.baseDownloadFailed', { message: (err as Error).message }))
}
} else {
sendLog('베이스 리소스팩 없음(resourcepackPath 빈 값) — 새 리소스팩으로 생성')
sendLog(t('log.baseAbsent'))
}
// 2-5. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지, 베이스 위에 얹기)
@@ -373,8 +383,8 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
const resourcepackName = `${state.selectedKey}_musicquiz.zip`
const resourcepackDir = path.join(getMcCustomDir(), 'resourcepacks')
const resourcepackPath = path.join(resourcepackDir, resourcepackName)
sendLog(`리소스팩 zip 빌드 중… (${resourcepackName})`)
sendProgress({ phase: 'package', message: baseZipPath ? '베이스에 음악·사진 추가 중' : 'zip 빌드 중' })
sendLog(t('log.buildingZip', { name: resourcepackName }))
sendProgress({ phase: 'package', message: baseZipPath ? t('progress.buildingWithBase') : t('progress.buildingZip') })
await buildResourcepackZip({
musicDir,
paintingDir,
@@ -387,8 +397,8 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
})
// 2-6. %appdata%/.mc_custom/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장)
sendLog(`설치 완료: ${resourcepackPath}`)
sendProgress({ phase: 'package', message: '설치 완료', done: true })
sendLog(t('log.installComplete', { path: resourcepackPath }))
sendProgress({ phase: 'package', message: t('progress.installComplete'), done: true })
return { resourcepackPath }
} finally {
// 임시 파일 정리
@@ -398,7 +408,7 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
ipcMain.handle('rp:install:cancel', async () => {
state.cancelRequested = true
sendLog(`취소 요청됨. 실행 중 프로세스 ${state.activeChildren.size}개 중단…`)
sendLog(t('log.cancelRequested', { count: state.activeChildren.size }))
for (const child of state.activeChildren) {
if (!child.killed) child.kill()
}
@@ -406,7 +416,7 @@ ipcMain.handle('rp:install:cancel', async () => {
function throwIfCancelled(): void {
if (state.cancelRequested) {
throw new Error('사용자가 설치를 취소했습니다.')
throw new Error(t('errors.cancelledByUser'))
}
}

View File

@@ -1,6 +1,9 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
export interface DownloadMusicOptions {
ytdlpExe: string
@@ -58,7 +61,7 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
for (const raw of lines) {
const line = raw.trimEnd()
if (!line) continue
opts.log?.(`yt-dlp> ${line}`)
opts.log?.(t('log.ytdlpLine', { line }))
const m = line.match(/\[download\]\s+([\d.]+)%/)
if (m) {
const pct = Math.min(100, Math.max(0, parseFloat(m[1])))
@@ -76,11 +79,16 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
child.on('error', (err) => reject(err))
child.on('close', async (code, signal) => {
if (signal) {
reject(new Error(`yt-dlp 가 신호 ${signal} 로 종료됨`))
reject(new Error(t('errors.ytdlpSignal', { signal: String(signal) })))
return
}
if (code !== 0) {
reject(new Error(`yt-dlp 종료 코드 ${code}: ${stderr.trim() || '(stderr 없음)'}`))
reject(new Error(
t('errors.ytdlpExit', {
code: code ?? '',
stderr: stderr.trim() || t('errors.ytdlpNoStderr')
})
))
return
}
// .ogg 가 실제로 생성됐는지 확인
@@ -88,7 +96,7 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
await fs.access(outPath)
resolve(outPath)
} catch {
reject(new Error(`예상 출력파일이 없음: ${outPath}`))
reject(new Error(t('errors.ytdlpMissingOutput', { path: outPath })))
}
})
})

View File

@@ -3,6 +3,9 @@ import path from 'node:path'
import archiver from 'archiver'
import extract from 'extract-zip'
import { resolveResourcePackFormat } from './packFormat.js'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
const NAMESPACE = 'musicquiz'
@@ -45,7 +48,7 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
// 0) 베이스 리소스팩이 지정되면 먼저 풀어둔다. 그 위에 우리 파일을 얹는다.
if (opts.baseZipPath) {
opts.log?.(`베이스 리소스팩 압축 해제: ${path.basename(opts.baseZipPath)}`)
opts.log?.(t('log.baseExtract', { name: path.basename(opts.baseZipPath) }))
await extract(opts.baseZipPath, { dir: root })
}
@@ -57,13 +60,13 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
// 1) pack.mcmeta 는 mcVersion 에 맞춰 항상 덮어쓴다 (베이스가 다른 버전일 수 있으니).
const resolved = resolveResourcePackFormat(opts.mcVersion)
if (resolved.matched) {
opts.log?.(`pack_format = ${resolved.format} (mcVersion ${resolved.matched})`)
opts.log?.(t('log.packFormatMatched', { format: resolved.format, matched: resolved.matched }))
} else {
opts.log?.(`pack_format = ${resolved.format} (mcVersion "${opts.mcVersion}" 매칭 실패, 최신 폴백)`)
opts.log?.(t('log.packFormatFallback', { format: resolved.format, version: opts.mcVersion }))
}
const mcmeta = {
pack: {
description: `음악퀴즈 리소스팩 - ${opts.packName}`,
description: t('pack.description', { name: opts.packName }),
pack_format: resolved.format,
supported_formats: { min_inclusive: resolved.format, max_inclusive: resolved.format }
}
@@ -82,7 +85,7 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
const parsed = JSON.parse(existing)
if (parsed && typeof parsed === 'object') {
soundsJson = parsed as Record<string, unknown>
opts.log?.(`기존 sounds.json 병합 (${Object.keys(soundsJson).length}개 항목)`)
opts.log?.(t('log.soundsMerged', { count: Object.keys(soundsJson).length }))
}
} catch {
// 없으면 새로 생성.

View File

@@ -2,6 +2,9 @@ import { contextBridge, ipcRenderer } from 'electron'
import type { RpFetchedPack } from './types.js'
const api = {
/** i18n 사전을 렌더러에 전달. */
loadLocale: (): Promise<Record<string, unknown>> => ipcRenderer.invoke('rp:i18n:dict'),
/** manifest 와 각 음악퀴즈의 file/list/<key>.json 까지 한 번에 로드. */
loadPacks: (manifestUrl?: string): Promise<RpFetchedPack[]> =>
ipcRenderer.invoke('rp:packs:load', manifestUrl),

View File

@@ -4,6 +4,9 @@ import path from 'node:path'
import https from 'node:https'
import http from 'node:http'
import { getMcCustomDir } from '../shared/paths.js'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
/**
* 리소스팩 간편설치기는 Windows .exe 로 배포되므로 yt-dlp.exe 한 종류만 사용.
@@ -27,7 +30,7 @@ export async function ensureYtDlpExe(
): Promise<string> {
const target = getYtDlpExePath()
if (await canExecute(target)) {
log?.(`yt-dlp.exe 이미 있음: ${target}`)
log?.(t('log.ytdlpExists', { path: target }))
return target
}
if (installPromise) return installPromise
@@ -35,20 +38,21 @@ export async function ensureYtDlpExe(
installPromise = (async () => {
try {
await fs.mkdir(path.dirname(target), { recursive: true })
log?.(`yt-dlp.exe 다운로드 중: ${YT_DLP_DOWNLOAD_URL}`)
log?.(t('log.ytdlpDownloading', { url: YT_DLP_DOWNLOAD_URL }))
await downloadToFile(YT_DLP_DOWNLOAD_URL, target)
const okVersion = await probeVersion(target)
if (!okVersion) {
throw new Error('yt-dlp.exe 다운로드는 됐지만 실행 검증에 실패했습니다.')
throw new Error(t('errors.ytdlpVerifyFailed'))
}
log?.(`yt-dlp.exe 준비 완료: ${target}`)
log?.(t('log.ytdlpReady', { path: target }))
return target
} catch (err) {
// 부분 다운로드 흔적 정리
try { await fs.unlink(target) } catch { /* noop */ }
throw new Error(
'yt-dlp.exe 자동 설치 실패: ' +
(err instanceof Error ? err.message : String(err))
t('errors.ytdlpInstallFailed', {
message: err instanceof Error ? err.message : String(err)
})
)
} finally {
installPromise = null
@@ -80,7 +84,7 @@ function probeVersion(bin: string): Promise<boolean> {
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
return new Promise((resolve, reject) => {
if (redirects > 8) {
reject(new Error('redirect 가 너무 많습니다.'))
reject(new Error(t('common.tooManyRedirects')))
return
}
const lib = url.startsWith('https://') ? https : http

View File

@@ -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<Buffer> {
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<Manifest>(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<string | null> => {
@@ -163,10 +168,10 @@ ipcMain.handle('dialog:pickFolder', async (): Promise<string | null> => {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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<RamCheckResult> => {
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<void>((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<number> {
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) => `<option value="${file}" ${index === 0 ? 'selected' : ''}>${file}</option>`)
.join('')
const savedText = JSON.stringify(t('configEditor.saved'))
const saveFailedText = JSON.stringify(t('configEditor.saveFailed'))
return `<!doctype html>
<html lang="ko"><head><meta charset="utf-8"/><title>서버 설정 편집기</title>
<html lang="ko"><head><meta charset="utf-8"/><title>${t('configEditor.pageTitle')}</title>
<style>body{font-family:sans-serif;background:#0d1117;color:#e6edf3;padding:24px;}select,textarea,button{font:inherit;}textarea{width:100%;height:60vh;background:#161b22;color:#e6edf3;border:1px solid #30363d;padding:12px;border-radius:8px;}button{background:#2f81f7;color:#fff;border:none;padding:10px 16px;border-radius:8px;cursor:pointer;margin-top:12px;}small{color:#8b949e;}</style>
</head><body>
<h1>서버 설정 편집기</h1>
<p><small>아래 파일을 직접 편집한 후 "적용" 버튼으로 저장합니다. 설치기 화면에서 다음 단계로 진행하기 전 마음껏 편집할 수 있습니다.</small></p>
<label>대상 파일 <select id="file">${optionMarkup}</select></label>
<h1>${t('configEditor.heading')}</h1>
<p><small>${t('configEditor.intro')}</small></p>
<label>${t('configEditor.targetLabel')} <select id="file">${optionMarkup}</select></label>
<textarea id="content"></textarea>
<button id="save">적용</button>
<button id="save">${t('configEditor.applyButton')}</button>
<p id="status"><small></small></p>
<script>
const file=document.getElementById('file');
@@ -684,7 +695,7 @@ const content=document.getElementById('content');
const status=document.querySelector('#status small');
async function load(){const r=await fetch('/file?name='+encodeURIComponent(file.value));content.value=await r.text();}
file.addEventListener('change',load);
document.getElementById('save').addEventListener('click',async()=>{const body=new URLSearchParams();body.set('name',file.value);body.set('content',content.value);const r=await fetch('/save',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:body.toString()});status.textContent=r.ok?'저장 완료':'저장 실패';});
document.getElementById('save').addEventListener('click',async()=>{const body=new URLSearchParams();body.set('name',file.value);body.set('content',content.value);const r=await fetch('/save',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:body.toString()});status.textContent=r.ok?${savedText}:${saveFailedText};});
load();
</script></body></html>`
}
@@ -700,72 +711,75 @@ function readBody(req: http.IncomingMessage): Promise<string> {
ipcMain.handle('server:portForward', async (_event, port: number): Promise<PortForwardResult> => {
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<string> {
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<string> {
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<void> {
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<void> {
}
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<void> {
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<void> {
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<FabricInstallerMeta[]>('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<string> {
@@ -1157,13 +1171,13 @@ function runJavaProcess(cmd: string, args: string[]): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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()

View File

@@ -2,6 +2,9 @@ import { contextBridge, ipcRenderer } from 'electron'
import type { ClientInstallPayload, FetchedPack, RamCheckResult, ServerInstallPayload, PortForwardResult } from './types.js'
const api = {
// i18n
loadLocale: (): Promise<Record<string, unknown>> => ipcRenderer.invoke('i18n:dict'),
// 1단계
loadPacks: (manifestUrl?: string): Promise<FetchedPack[]> =>
ipcRenderer.invoke('packs:load', manifestUrl),

View File

@@ -4,6 +4,7 @@ import path from 'node:path'
import fsp from 'node:fs/promises'
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths.js'
import { loadEnv } from '../shared/env.js'
import { t, localeDict } from './i18n.js'
import { indexRouter } from './routes/index.js'
import { opRouter } from './routes/op.js'
@@ -23,6 +24,14 @@ app.set('trust proxy', 1)
app.use(express.urlencoded({ extended: true }))
app.use(express.json())
// 모든 EJS 뷰에서 t('key') 로 ko-kr.json 의 문구를 가져올 수 있도록 노출.
// localeDict 는 클라이언트 측 JS 로 사전을 통째로 전달할 때 사용(listEditor 등).
app.use((_req, res, next) => {
res.locals.t = t
res.locals.localeDict = localeDict
next()
})
app.use(session({
secret: process.env.SESSION_SECRET ?? 'music-quiz-installer-dev-secret',
resave: false,
@@ -104,8 +113,8 @@ app.use('/', opRouter)
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err)
const message = err instanceof Error ? err.message : '알 수 없는 오류'
res.status(500).send(`서버 오류: ${message}`)
const message = err instanceof Error ? err.message : t('errors.unknown')
res.status(500).send(t('errors.serverError', { message }))
})
app.listen(PORT, HOST, () => {

6
src/server/i18n.ts Normal file
View File

@@ -0,0 +1,6 @@
import { loadComponentI18n } from '../shared/i18n.js'
// 서버 진입 시 한 번 로드. routes/views 어디서든 동일한 사전을 공유.
const i18n = loadComponentI18n('server')
export const t = i18n.t
export const localeDict = i18n.dict

View File

@@ -16,6 +16,7 @@ import { fetchReleaseVersions } from '../../shared/mojang.js'
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
import { requireAuth } from '../middleware/auth.js'
import type { PackDefinition, PackList } from '../../shared/types.js'
import { t } from '../i18n.js'
export const opRouter = Router()
@@ -46,7 +47,7 @@ opRouter.post('/op', async (req, res, next) => {
const accounts = await readAccounts()
const matched = accounts.find((entry) => entry.password === password)
if (!matched) {
res.status(401).render('op/login', { error: '비밀번호가 올바르지 않습니다.' })
res.status(401).render('op/login', { error: t('login.wrongPassword') })
return
}
req.session.userId = matched.id
@@ -106,7 +107,7 @@ opRouter.get('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).send('해당 음악퀴즈를 찾을 수 없습니다.')
res.status(404).send(t('errors.packNotFound'))
return
}
const releases = await fetchReleaseVersions()
@@ -142,7 +143,7 @@ opRouter.get('/op/list/:packName', requireAuth, async (req, res, next) => {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).send('해당 음악퀴즈를 찾을 수 없습니다.')
res.status(404).send(t('errors.packNotFound'))
return
}
const list = await loadPackList(packKey)
@@ -163,7 +164,7 @@ opRouter.post('/op/list/:packName', requireAuth, async (req, res, next) => {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).json({ ok: false, message: '음악퀴즈를 찾을 수 없습니다.' })
res.status(404).json({ ok: false, message: t('errors.packNotFoundJson') })
return
}
const normalized = normalizePackList(req.body)
@@ -179,13 +180,13 @@ opRouter.post('/op/list/:packName', requireAuth, async (req, res, next) => {
opRouter.post('/op/list/:packName/video-meta', requireAuth, async (req, res) => {
const url = pickFirstValue(req.body?.url).trim()
if (!url) {
res.status(400).json({ ok: false, message: '영상 주소를 입력해 주세요.' })
res.status(400).json({ ok: false, message: t('errors.videoUrlRequired') })
return
}
try {
const entry = await fetchVideoMeta(url)
if (!entry) {
res.status(404).json({ ok: false, message: '메타데이터를 찾을 수 없습니다.' })
res.status(404).json({ ok: false, message: t('errors.metaNotFound') })
return
}
res.json({ ok: true, entry })
@@ -203,7 +204,7 @@ opRouter.post('/op/list/:packName/video-meta', requireAuth, async (req, res) =>
opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => {
const url = pickFirstValue(req.body?.url).trim()
if (!url) {
res.status(400).json({ ok: false, message: '플레이리스트 주소를 입력해 주세요.' })
res.status(400).json({ ok: false, message: t('errors.playlistUrlRequired') })
return
}
try {
@@ -238,19 +239,27 @@ opRouter.get('/op/datapack/:packName/generate', requireAuth, async (req, res, ne
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).type('text/plain').send('음악퀴즈를 찾을 수 없습니다.')
res.status(404).type('text/plain').send(t('errors.packNotFoundJson'))
return
}
const list = await loadPackList(packKey)
const lines: string[] = []
lines.push(`# === musicquiz: ${definition.name} ===`)
lines.push(`# 총 ${list.music.length}곡 / 사진 ${list.images.length}`)
lines.push(`say [musicquiz] 데이터팩 초기화`)
lines.push(`# 곡별 placeholder. 실제 포맷 확정되면 교체 예정.`)
lines.push(t('datapackOutput.header', { name: definition.name }))
lines.push(t('datapackOutput.summary', {
musicCount: list.music.length,
imageCount: list.images.length
}))
lines.push(t('datapackOutput.initLine'))
lines.push(t('datapackOutput.placeholder'))
list.music.forEach((entry, index) => {
const title = entry.title || '(제목 없음)'
const artist = entry.artist || '(가수 미상)'
lines.push(`# ${index + 1}. ${title} - ${artist} (${entry.durationSec}s)`)
const title = entry.title || t('datapackOutput.titleFallback')
const artist = entry.artist || t('datapackOutput.artistFallback')
lines.push(t('datapackOutput.trackLine', {
index: index + 1,
title,
artist,
duration: entry.durationSec
}))
})
res.type('text/plain; charset=utf-8').send(lines.join('\n') + '\n')
} catch (error) {
@@ -287,7 +296,7 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
const normalized = normalizePackDefinition(partial)
if (normalized.clientMinRam > normalized.clientRecommendedRam) {
res.status(400).send('clientMinRam은 clientRecommendedRam보다 클 수 없습니다.')
res.status(400).send(t('errors.ramOrderInvalid'))
return
}
const finalKey = await renamePack(packKey, requestedKey, normalized)

View File

@@ -4,6 +4,7 @@ import path from 'node:path'
import https from 'node:https'
import http from 'node:http'
import { getMcCustomDir } from '../shared/paths.js'
import { t } from './i18n.js'
export interface YtPlaylistEntry {
id: string
@@ -15,7 +16,7 @@ export interface YtPlaylistEntry {
export class YtDlpUnavailableError extends Error {
constructor(message?: string) {
super(message || 'yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)')
super(message || t('youtube.ytdlpUnavailable'))
}
}
@@ -62,7 +63,7 @@ export async function ensureYtDlp(): Promise<string> {
// 검증
const okVersion = await probeVersion(target)
if (!okVersion) {
throw new YtDlpUnavailableError('yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.')
throw new YtDlpUnavailableError(t('youtube.ytdlpVerifyFailed'))
}
return target
} catch (err) {
@@ -71,7 +72,7 @@ export async function ensureYtDlp(): Promise<string> {
throw err instanceof YtDlpUnavailableError
? err
: new YtDlpUnavailableError(
'yt-dlp 자동 설치에 실패했습니다: ' + (err instanceof Error ? err.message : String(err))
t('youtube.ytdlpInstallFailed', { message: err instanceof Error ? err.message : String(err) })
)
} finally {
installPromise = null
@@ -112,7 +113,7 @@ function probeVersion(bin: string): Promise<boolean> {
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
return new Promise((resolve, reject) => {
if (redirects > 8) {
reject(new Error('redirect 가 너무 많습니다.'))
reject(new Error(t('youtube.tooManyRedirects')))
return
}
const lib = url.startsWith('https://') ? https : http
@@ -161,7 +162,7 @@ export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | nul
child.on('error', (err) => reject(err))
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(`yt-dlp 영상 조회 실패 (code=${code}): ${stderr.trim() || stdout.trim()}`))
reject(new Error(t('youtube.ytdlpVideoFailed', { code: String(code), detail: stderr.trim() || stdout.trim() })))
return
}
const line = stdout.trim().split('\n').find((l) => l.trim().length > 0)
@@ -208,7 +209,7 @@ export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry
child.on('error', (err) => reject(err))
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(`yt-dlp 플레이리스트 조회 실패 (code=${code}): ${stderr.trim() || stdout.trim()}`))
reject(new Error(t('youtube.ytdlpPlaylistFailed', { code: String(code), detail: stderr.trim() || stdout.trim() })))
return
}
const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)

93
src/shared/i18n.ts Normal file
View File

@@ -0,0 +1,93 @@
import fs from 'node:fs'
import path from 'node:path'
/**
* 단순 키-문자열 사전. 중첩 객체도 허용해서 그룹화 가능.
* { step1: { title: '1단계. 음악퀴즈 선택' } }
* t('step1.title') → '1단계. 음악퀴즈 선택'
*/
export type Locale = Record<string, unknown>
/**
* 자유 형식 ko-kr.json 을 로드하고 `t(key, params)` 헬퍼를 만들어 반환.
*
* 사용 패턴:
* const { t, dict } = createI18n(path.join(__dirname, 'locales', 'ko-kr.json'))
* t('step1.title')
* t('install.downloading', { idx: 3 }) // → '3번 노래 다운로드 중…'
*
* 키가 사전에 없으면 키 자체를 반환(개발 중 누락 빨리 찾도록).
* 사전이 비어 있어도 빌드는 깨지지 않고 키만 노출.
*/
export interface I18n {
/** 키로 문자열 lookup. 누락 시 키 그대로 반환. */
t(key: string, params?: Record<string, string | number>): string
/** 렌더러로 전달하기 위한 원본 사전(JSON 그대로). */
dict: Locale
}
export function createI18n(filePath: string): I18n {
let dict: Locale = {}
try {
const raw = fs.readFileSync(filePath, 'utf-8')
dict = JSON.parse(raw) as Locale
} catch {
// 파일이 없거나 깨진 경우 빈 사전. t() 가 키 자체를 돌려주므로 UI 가 깨지진 않음.
dict = {}
}
function lookup(key: string): string | undefined {
const parts = key.split('.')
let cur: unknown = dict
for (const p of parts) {
if (cur && typeof cur === 'object' && p in (cur as Record<string, unknown>)) {
cur = (cur as Record<string, unknown>)[p]
} else {
return undefined
}
}
return typeof cur === 'string' ? cur : undefined
}
function interpolate(tpl: string, params?: Record<string, string | number>): string {
if (!params) return tpl
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name: string) => {
return name in params ? String(params[name]) : `{{${name}}}`
})
}
return {
t(key, params) {
const found = lookup(key)
return interpolate(found ?? key, params)
},
dict
}
}
/**
* 진입점에서 호출할 표준 로더. 컴포넌트 이름과 `__dirname`(컴파일 후) 만 주면
* `locales/<component>/ko-kr.json` 을 찾아 로드.
*
* 탐색 순서(처음 발견된 것만 사용):
* 1. 패키징된 Electron 앱이면 `process.resourcesPath/locales/<component>/ko-kr.json`
* 2. `<프로젝트 루트>/locales/<component>/ko-kr.json`
*/
export function loadComponentI18n(component: 'server' | 'installer' | 'installer-rp'): I18n {
// 컴파일된 dist/shared/i18n.js 기준으로 프로젝트 루트는 2단계 위.
const projectRoot = path.resolve(__dirname, '..', '..')
const candidates: string[] = []
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
if (typeof resourcesPath === 'string' && resourcesPath.length > 0) {
candidates.push(path.join(resourcesPath, 'locales', component, 'ko-kr.json'))
}
candidates.push(path.join(projectRoot, 'locales', component, 'ko-kr.json'))
for (const p of candidates) {
if (fs.existsSync(p)) {
return createI18n(p)
}
}
return createI18n(candidates[candidates.length - 1] ?? '')
}

View File

@@ -3,29 +3,29 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>음악퀴즈 목록</title>
<title><%= t('site.indexTitle') %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
<main class="pageWrap">
<section class="hero">
<h1>마인크래프트 음악퀴즈</h1>
<p>설치기에서 사용 가능한 음악퀴즈 목록입니다.</p>
<h1><%= t('site.heroTitle') %></h1>
<p><%= t('site.heroSubtitle') %></p>
</section>
<section class="cardRow horizontalScroll">
<% if (packs.length === 0) { %>
<p class="muted">등록된 음악퀴즈가 없습니다.</p>
<p class="muted"><%= t('site.empty') %></p>
<% } %>
<% packs.forEach(function (entry) { %>
<article class="packCard">
<h2><%= entry.name %></h2>
<p class="muted">파일: <%= entry.file %>.json</p>
<p class="muted"><%= t('site.fileLabel', { file: entry.file }) %></p>
<% if (entry.definition) { %>
<ul class="metaList">
<li>마인크래프트 <strong><%= entry.definition.mcVersion %></strong></li>
<li>플랫폼 <strong><%= entry.definition.platform.type %></strong></li>
<li>모드 폴더 <%= entry.definition.modsFolder || '없음' %> / 리소스팩 <%= entry.definition.resourcepackPath || '없음' %></li>
<li><%= t('site.mcVersion') %> <strong><%= entry.definition.mcVersion %></strong></li>
<li><%= t('site.platform') %> <strong><%= entry.definition.platform.type %></strong></li>
<li><%= t('site.modsFolder') %> <%= entry.definition.modsFolder || t('site.noneFallback') %> / <%= t('site.resourcepack') %> <%= entry.definition.resourcepackPath || t('site.noneFallback') %></li>
</ul>
<% } %>
</article>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>관리자 대시보드</title>
<title><%= t('dashboard.browserTitle') %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
@@ -11,36 +11,36 @@
<main class="pageWrap">
<section class="dashboardHeader">
<h1>음악퀴즈 목록</h1>
<h1><%= t('dashboard.title') %></h1>
<div class="dashboardActions">
<a class="secondaryButton" href="/op/list">음악목록 수정</a>
<a class="secondaryButton" href="/op/datapack">데이터팩 수정</a>
<a class="secondaryButton" href="/op/list"><%= t('dashboard.editList') %></a>
<a class="secondaryButton" href="/op/datapack"><%= t('dashboard.editDatapack') %></a>
<form method="post" action="/op/dashboard/create" class="inlineForm">
<button type="submit" class="primaryButton">음악퀴즈 추가</button>
<button type="submit" class="primaryButton"><%= t('dashboard.addPack') %></button>
</form>
<button type="button" class="secondaryButton" id="deleteToggle">음악퀴즈 삭제</button>
<button type="button" class="secondaryButton" id="deleteToggle"><%= t('dashboard.deletePack') %></button>
</div>
</section>
<form method="post" action="/op/dashboard/delete" id="deleteForm" class="dashboardListForm">
<section class="cardRow horizontalScroll">
<% if (items.length === 0) { %>
<p class="muted">등록된 음악퀴즈가 없습니다. "음악퀴즈 추가" 버튼으로 새로 만들어 보세요.</p>
<p class="muted"><%= t('dashboard.emptyHint') %></p>
<% } %>
<% items.forEach(function (item) { %>
<article class="packCard editableCard" data-key="<%= item.key %>">
<label class="cardCheckbox" hidden>
<input type="checkbox" name="targetKey" value="<%= item.key %>" />
<span>선택</span>
<span><%= t('dashboard.select') %></span>
</label>
<a class="cardLink" href="/op/dashboard/<%= item.key %>">
<h2><%= item.definition ? item.definition.name : item.key %></h2>
<p class="muted"><%= item.key %>.json</p>
<% if (item.definition) { %>
<ul class="metaList">
<li>MC <%= item.definition.mcVersion %></li>
<li>플랫폼 <%= item.definition.platform.type %></li>
<li>모드 폴더 <%= item.definition.modsFolder || '없음' %></li>
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
<li><%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %></li>
</ul>
<% } %>
</a>
@@ -48,8 +48,8 @@
<% }) %>
</section>
<div class="deleteConfirmRow" id="deleteConfirm" hidden>
<button type="button" class="secondaryButton" id="deleteCancel">취소</button>
<button type="submit" class="dangerButton">삭제 확인</button>
<button type="button" class="secondaryButton" id="deleteCancel"><%= t('common.cancel') %></button>
<button type="submit" class="dangerButton"><%= t('dashboard.confirmDelete') %></button>
</div>
</form>
</main>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>데이터팩 수정</title>
<title><%= t('datapack.browserTitle') %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
@@ -12,21 +12,21 @@
<main class="pageWrap">
<section class="dashboardHeader">
<div>
<a class="ghostLink" href="/op/dashboard">← 돌아가기</a>
<h1 style="margin-top:20px;">데이터팩 수정</h1>
<a class="ghostLink" href="/op/dashboard"><%= t('common.back') %></a>
<h1 style="margin-top:20px;"><%= t('datapack.title') %></h1>
</div>
</section>
<section class="dpControls">
<button type="button" class="primaryButton" id="pickPackBtn">음악퀴즈 선택</button>
<span class="muted" id="pickedLabel">선택된 음악퀴즈 없음</span>
<button type="button" class="primaryButton" id="pickPackBtn"><%= t('datapack.pickPack') %></button>
<span class="muted" id="pickedLabel"><%= t('datapack.pickedNone') %></span>
</section>
<p class="muted" id="countLabel"></p>
<section class="dpActions" hidden id="dpActions">
<button type="button" class="secondaryButton" id="exportBtn">데이터팩 출력</button>
<button type="button" class="secondaryButton" id="copyBtn">복사</button>
<button type="button" class="secondaryButton" id="exportBtn"><%= t('datapack.export') %></button>
<button type="button" class="secondaryButton" id="copyBtn"><%= t('datapack.copy') %></button>
<span class="statusText" id="dp-status"></span>
</section>
@@ -36,8 +36,8 @@
<!-- 음악퀴즈 선택 팝업 -->
<div class="modalOverlay" id="pickModal" hidden>
<div class="modalCard">
<header><h3>음악퀴즈 선택</h3>
<button class="modalClose" type="button" data-modal-close>×</button>
<header><h3><%= t('datapack.modalPickTitle') %></h3>
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
</header>
<div class="modalBody">
<div class="cardRow horizontalScroll" id="pickList">
@@ -47,8 +47,8 @@
<p class="muted"><%= item.key %>.json</p>
<% if (item.definition) { %>
<ul class="metaList">
<li>MC <%= item.definition.mcVersion %></li>
<li>플랫폼 <%= item.definition.platform.type %></li>
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
</ul>
<% } %>
</article>
@@ -58,6 +58,11 @@
</div>
</div>
<script>
var I18N = <%- JSON.stringify(localeDict.datapack) %>;
// 데이터팩 출력 본문의 "총 N곡" 패턴은 datapackOutput.summary 와 동일.
var SUMMARY_PATTERN = <%- JSON.stringify(localeDict.datapackOutput.summary) %>;
</script>
<script>
(function () {
var pickModal = document.getElementById('pickModal')
@@ -75,12 +80,10 @@
card.addEventListener('click', function () {
pickedKey = card.getAttribute('data-key')
var name = card.getAttribute('data-name')
document.getElementById('pickedLabel').textContent = '선택: ' + name
document.getElementById('pickedLabel').textContent = I18N.pickedLabel.replace('{{name}}', name)
pickModal.hidden = true
document.getElementById('dpActions').hidden = false
// 곡 수 미리 가져오기
fetch('/op/list/' + encodeURIComponent(pickedKey)).catch(function () {})
// 더 직접적으로: generate 호출 시점에 카운트도 나옴. 일단 비워둠.
document.getElementById('countLabel').textContent = ''
document.getElementById('codeOut').hidden = true
})
@@ -88,30 +91,30 @@
document.getElementById('exportBtn').addEventListener('click', function () {
if (!pickedKey) return
var s = document.getElementById('dp-status')
s.textContent = '출력 중…'; s.classList.remove('error')
s.textContent = I18N.exporting; s.classList.remove('error')
fetch('/op/datapack/' + encodeURIComponent(pickedKey) + '/generate')
.then(function (r) { return r.text().then(function (t) { return { ok: r.ok, text: t } }) })
.then(function (res) {
if (!res.ok) {
s.textContent = '실패: ' + res.text; s.classList.add('error')
s.textContent = I18N.failed.replace('{{message}}', res.text); s.classList.add('error')
return
}
var out = document.getElementById('codeOut')
out.textContent = res.text
out.hidden = false
// 첫줄/둘째줄에서 카운트 가져와 표기
// 첫줄/둘째줄에서 곡 개수를 추출해 카운트 라벨에 표시.
var m = res.text.match(/총\s+(\d+)곡/)
if (m) document.getElementById('countLabel').textContent = '총 ' + m[1] + '개의 음악을 찾았습니다.'
s.textContent = '출력 완료'
if (m) document.getElementById('countLabel').textContent = I18N.totalCount.replace('{{count}}', m[1])
s.textContent = I18N.exported
})
.catch(function (err) { s.textContent = '실패: ' + err.message; s.classList.add('error') })
.catch(function (err) { s.textContent = I18N.failed.replace('{{message}}', err.message); s.classList.add('error') })
})
document.getElementById('copyBtn').addEventListener('click', function () {
var out = document.getElementById('codeOut')
if (out.hidden) return
navigator.clipboard.writeText(out.textContent).then(function () {
var s = document.getElementById('dp-status')
s.textContent = '복사됨'
s.textContent = I18N.copied
s.classList.remove('error')
})
})

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= pack.name %> 편집</title>
<title><%= t('editor.browserTitle', { name: pack.name }) %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
@@ -12,27 +12,27 @@
<main class="pageWrap">
<section class="editorHeader">
<div>
<p class="eyebrow">PACK EDITOR</p>
<p class="eyebrow"><%= t('editor.eyebrow') %></p>
<h1><%= pack.name %></h1>
</div>
<a class="ghostLink" href="/op/dashboard">목록으로</a>
<a class="ghostLink" href="/op/dashboard"><%= t('common.backToList') %></a>
</section>
<form method="post" class="editorForm" id="editorForm">
<div class="gridTwo">
<label>
<span>음악퀴즈 이름</span>
<span><%= t('editor.displayName') %></span>
<input name="displayName" value="<%= pack.name %>" required />
</label>
<label>
<span>JSON 파일 이름 (확장자 제외)</span>
<span><%= t('editor.fileName') %></span>
<input name="fileName" value="<%= packKey %>" required pattern="[a-zA-Z0-9_\-]+" />
</label>
</div>
<div class="gridTwo">
<label>
<span>마인크래프트 버전</span>
<span><%= t('editor.mcVersion') %></span>
<select name="mcVersion" required>
<% releases.forEach(function (release) { %>
<option value="<%= release %>" <%= release === pack.mcVersion ? 'selected' : '' %>><%= release %></option>
@@ -40,7 +40,7 @@
</select>
</label>
<label>
<span>모드 플랫폼</span>
<span><%= t('editor.platformType') %></span>
<select name="platformType" id="platformType">
<% ['vanilla','forge','fabric','neoforge'].forEach(function (loader) { %>
<option value="<%= loader %>" <%= pack.platform.type === loader ? 'selected' : '' %>><%= loader %></option>
@@ -48,62 +48,75 @@
</select>
</label>
<label class="fullSpan" id="platformDownloadField">
<span>플랫폼 설치파일 URL</span>
<span><%= t('editor.platformDownloadUrl') %></span>
<input name="platformDownloadUrl" value="<%= pack.platform.downloadUrl || '' %>" placeholder="/forge-installer.jar 또는 https://example.com/forge-installer.jar" />
<small class="muted">도메인 없이 입력하면 manifest.json 도메인의 <code>/file/platforms/&lt;파일명&gt;</code>으로 해석됩니다.</small>
<small class="muted"><%- t('editor.platformDownloadHint') %></small>
</label>
<label class="fullSpan" id="platformLoaderField" hidden>
<span>Fabric Loader 버전</span>
<span><%= t('editor.platformLoaderVersion') %></span>
<select name="platformLoaderVersion" id="platformLoaderVersion" data-current="<%= pack.platform.loaderVersion || '' %>">
<option value="">불러오는 중...</option>
<option value=""><%= t('common.loading') %></option>
</select>
<small class="muted">선택한 마인크래프트 버전 기준 Fabric Loader 목록입니다. 설치기는 최신 fabric-installer 를 받아 자동으로 CLI 설치합니다.</small>
<small class="muted"><%= t('editor.platformLoaderHint') %></small>
</label>
<label>
<span>서버 최소 램 (MB)</span>
<span><%= t('editor.serverMinRam') %></span>
<input type="number" name="serverMinRam" value="<%= pack.serverMinRam %>" min="512" required />
</label>
<label>
<span>서버 최대 램 (MB)</span>
<span><%= t('editor.serverMaxRam') %></span>
<input type="number" name="serverMaxRam" value="<%= pack.serverMaxRam %>" min="512" required />
</label>
<label>
<span>클라이언트 최소 램 (MB)</span>
<span><%= t('editor.clientMinRam') %></span>
<input type="number" name="clientMinRam" value="<%= pack.clientMinRam %>" min="512" required />
</label>
<label>
<span>클라이언트 권장 램 (MB)</span>
<span><%= t('editor.clientRecommendedRam') %></span>
<input type="number" name="clientRecommendedRam" value="<%= pack.clientRecommendedRam %>" min="512" required />
</label>
<label>
<span>맵 파일 (.zip)</span>
<span><%= t('editor.mapPath') %></span>
<input name="mapPath" value="<%= pack.mapPath %>" placeholder="my-map.zip" pattern=".+\.zip" />
<small class="muted">/file/maps/ 아래 zip 파일 이름.</small>
<small class="muted"><%= t('editor.mapPathHint') %></small>
</label>
<label>
<span>서버 파일 (.zip)</span>
<span><%= t('editor.serverPath') %></span>
<input name="serverPath" value="<%= pack.serverPath %>" placeholder="my-server.zip" pattern=".+\.zip" />
<small class="muted">/file/servers/ 아래 zip 파일 이름. 멀티 모드 전용.</small>
<small class="muted"><%= t('editor.serverPathHint') %></small>
</label>
</div>
<div class="gridTwo">
<label>
<span>모드 폴더 이름</span>
<span><%= t('editor.modsFolder') %></span>
<input name="modsFolder" value="<%= pack.modsFolder %>" placeholder="my-pack" pattern="[a-zA-Z0-9_\-]*" />
<small class="muted">/file/mods/&lt;폴더이름&gt;/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.</small>
<small class="muted"><%- t('editor.modsFolderHint') %></small>
</label>
<label>
<span>리소스팩 (.zip)</span>
<span><%= t('editor.resourcepackPath') %></span>
<input name="resourcepackPath" value="<%= pack.resourcepackPath %>" placeholder="my-pack.zip" pattern=".*\.zip|" />
<small class="muted">/file/resourcepacks/ 아래 .zip 파일 이름. 비워두면 리소스팩을 받지 않습니다.</small>
<small class="muted"><%= t('editor.resourcepackHint') %></small>
</label>
</div>
<button class="primaryButton" type="submit">저장</button>
<button class="primaryButton" type="submit"><%= t('common.save') %></button>
</form>
</main>
<script>
var I18N = {
ramOrderInvalid: <%- JSON.stringify(t('editor.ramOrderInvalid')) %>,
fabricLoaderRequired: <%- JSON.stringify(t('editor.fabricLoaderRequired')) %>,
loaderEmpty: <%- JSON.stringify(t('editor.platformLoaderEmpty')) %>,
loaderPickMc: <%- JSON.stringify(t('editor.platformLoaderPickMc')) %>,
loaderLoadFailedPrefix: <%- JSON.stringify(t('editor.platformLoaderLoadFailed', { message: '__M__' })) %>,
loading: <%- JSON.stringify(t('common.loading')) %>
}
function formatLoaderLoadFailed(message) {
return I18N.loaderLoadFailedPrefix.replace('__M__', message)
}
</script>
<script>
(function () {
var platformSelect = document.getElementById('platformType')
@@ -136,7 +149,7 @@
function populateLoaderOptions(versions, preselect) {
if (!versions || versions.length === 0) {
loaderSelect.innerHTML = '<option value="">호환 로더 없음</option>'
loaderSelect.innerHTML = '<option value="">' + I18N.loaderEmpty + '</option>'
return
}
var html = ''
@@ -156,7 +169,7 @@
function loadFabricLoaders() {
var mc = (mcVersionSelect && mcVersionSelect.value) || ''
if (!mc) {
loaderSelect.innerHTML = '<option value="">마인크래프트 버전을 먼저 선택하세요</option>'
loaderSelect.innerHTML = '<option value="">' + I18N.loaderPickMc + '</option>'
return
}
if (loaderCache[mc]) {
@@ -164,7 +177,7 @@
return
}
var seq = ++loaderFetchSeq
loaderSelect.innerHTML = '<option value="">불러오는 중...</option>'
loaderSelect.innerHTML = '<option value="">' + I18N.loading + '</option>'
fetch('https://meta.fabricmc.net/v2/versions/loader/' + encodeURIComponent(mc))
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status)
@@ -181,7 +194,8 @@
})
.catch(function (err) {
if (seq !== loaderFetchSeq) return
loaderSelect.innerHTML = '<option value="">로더 목록 로드 실패: ' + (err && err.message ? err.message : err) + '</option>'
var msg = (err && err.message) ? err.message : String(err)
loaderSelect.innerHTML = '<option value="">' + formatLoaderLoadFailed(msg) + '</option>'
})
}
@@ -197,12 +211,12 @@
var clientReco = Number(form.clientRecommendedRam.value)
if (clientMin > clientReco) {
event.preventDefault()
alert('클라이언트 최소 램은 권장 램보다 클 수 없습니다.')
alert(I18N.ramOrderInvalid)
return
}
if (platformSelect.value === 'fabric' && !loaderSelect.value) {
event.preventDefault()
alert('Fabric 로더 버전을 선택해 주세요.')
alert(I18N.fabricLoaderRequired)
}
})
})()

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>음악목록 수정</title>
<title><%= t('list.browserTitle') %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
@@ -12,14 +12,14 @@
<main class="pageWrap">
<section class="dashboardHeader">
<div>
<a class="ghostLink" href="/op/dashboard">← 돌아가기</a>
<h1 style="margin-top:20px;">음악목록 수정</h1>
<a class="ghostLink" href="/op/dashboard"><%= t('common.back') %></a>
<h1 style="margin-top:20px;"><%= t('list.title') %></h1>
</div>
</section>
<section class="cardRow horizontalScroll">
<% if (items.length === 0) { %>
<p class="muted">등록된 음악퀴즈가 없습니다.</p>
<p class="muted"><%= t('site.empty') %></p>
<% } %>
<% items.forEach(function (item) { %>
<article class="packCard">
@@ -28,9 +28,9 @@
<p class="muted"><%= item.key %>.json</p>
<% if (item.definition) { %>
<ul class="metaList">
<li>MC <%= item.definition.mcVersion %></li>
<li>플랫폼 <%= item.definition.platform.type %></li>
<li>모드 폴더 <%= item.definition.modsFolder || '없음' %></li>
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
<li><%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %></li>
</ul>
<% } %>
</a>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= pack.name %> — 음악/사진 목록</title>
<title><%= t('listEditor.browserTitle', { name: pack.name }) %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
@@ -12,31 +12,31 @@
<main class="pageWrap">
<section class="dashboardHeader">
<div>
<a class="ghostLink" href="/op/list">← 돌아가기</a>
<a class="ghostLink" href="/op/list"><%= t('common.back') %></a>
<h1 style="margin-top:20px;"><%= pack.name %></h1>
<p class="muted"><%= packKey %>.json</p>
</div>
<div class="dirtyMark" id="dirty-mark" hidden title="저장되지 않은 변경사항이 있습니다">*</div>
<div class="dirtyMark" id="dirty-mark" hidden title="<%= t('listEditor.dirtyTooltip') %>">*</div>
</section>
<div class="tabBar">
<button type="button" class="tabBtn active" data-tab="music">음악목록</button>
<button type="button" class="tabBtn" data-tab="image">사진목록</button>
<button type="button" class="tabBtn active" data-tab="music"><%= t('listEditor.tabMusic') %></button>
<button type="button" class="tabBtn" data-tab="image"><%= t('listEditor.tabImage') %></button>
</div>
<!-- 음악 탭 -->
<section class="tabPanel" id="tab-music">
<div class="listActionsRow">
<button type="button" class="primaryButton" data-action="save" data-target="music">목록 저장</button>
<button type="button" class="dangerButton" data-action="clear" data-target="music">목록 초기화</button>
<button type="button" class="primaryButton" data-action="save" data-target="music"><%= t('listEditor.saveList') %></button>
<button type="button" class="dangerButton" data-action="clear" data-target="music"><%= t('listEditor.clearList') %></button>
<span class="statusText" id="status-music"></span>
</div>
<div class="playlistRow">
<input type="url" class="textInput" id="music-playlist-url"
placeholder="유튜브 플레이리스트 URL"
placeholder="<%= t('listEditor.playlistPlaceholder') %>"
value="<%= list.musicPlaylistUrl %>" />
<button type="button" class="secondaryButton" data-action="fetch" data-target="music">플레이리스트 불러오기</button>
<button type="button" class="secondaryButton" data-action="fetch" data-target="music"><%= t('listEditor.fetchPlaylist') %></button>
</div>
<ol class="trackList" id="music-list"></ol>
@@ -45,17 +45,17 @@
<!-- 사진 탭 -->
<section class="tabPanel" id="tab-image" hidden>
<div class="listActionsRow">
<button type="button" class="primaryButton" data-action="save" data-target="image">목록 저장</button>
<button type="button" class="dangerButton" data-action="clear" data-target="image">목록 초기화</button>
<button type="button" class="secondaryButton" id="image-from-music">음악목록에서 가져오기</button>
<button type="button" class="primaryButton" data-action="save" data-target="image"><%= t('listEditor.saveList') %></button>
<button type="button" class="dangerButton" data-action="clear" data-target="image"><%= t('listEditor.clearList') %></button>
<button type="button" class="secondaryButton" id="image-from-music"><%= t('listEditor.imageFromMusic') %></button>
<span class="statusText" id="status-image"></span>
</div>
<div class="playlistRow">
<input type="url" class="textInput" id="image-playlist-url"
placeholder="유튜브 플레이리스트 URL"
placeholder="<%= t('listEditor.playlistPlaceholder') %>"
value="<%= list.imagePlaylistUrl %>" />
<button type="button" class="secondaryButton" data-action="fetch" data-target="image">플레이리스트 불러오기</button>
<button type="button" class="secondaryButton" data-action="fetch" data-target="image"><%= t('listEditor.fetchPlaylist') %></button>
</div>
<div class="imageGrid" id="image-list"></div>
@@ -64,22 +64,22 @@
<!-- Context menu -->
<div class="ctxMenu" id="ctxMenu" hidden>
<button type="button" data-ctx="edit">수정</button>
<button type="button" data-ctx="delete">삭제</button>
<button type="button" data-ctx="edit"><%= t('common.edit') %></button>
<button type="button" data-ctx="delete"><%= t('common.delete') %></button>
</div>
<!-- Confirm modal -->
<div class="modalOverlay" id="confirmModal" hidden>
<div class="modalCard">
<header><h3 id="confirm-title">확인</h3>
<button class="modalClose" type="button" data-modal-close>×</button>
<header><h3 id="confirm-title"><%= t('listEditor.modalConfirmTitle') %></h3>
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
</header>
<div class="modalBody">
<p id="confirm-message"></p>
</div>
<footer style="display:flex;gap:8px;justify-content:flex-end;">
<button type="button" class="secondaryButton" data-modal-close>취소</button>
<button type="button" class="primaryButton" id="confirm-ok">확인</button>
<button type="button" class="secondaryButton" data-modal-close><%= t('common.cancel') %></button>
<button type="button" class="primaryButton" id="confirm-ok"><%= t('common.ok') %></button>
</footer>
</div>
</div>
@@ -87,21 +87,21 @@
<!-- Edit modal (music) -->
<div class="modalOverlay" id="editMusicModal" hidden>
<div class="modalCard">
<header><h3>음악 항목 수정</h3>
<button class="modalClose" type="button" data-modal-close>×</button>
<header><h3><%= t('listEditor.musicEditTitle') %></h3>
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
</header>
<div class="modalBody">
<label>유튜브 영상 주소
<label><%= t('listEditor.musicEditUrl') %>
<input type="url" id="edit-music-url" class="textInput" />
</label>
<p class="muted" style="margin-top:6px;font-size:12px;">
저장하면 yt-dlp 로 제목·가수·재생시간을 자동으로 갱신합니다.
<%= t('listEditor.musicEditHint') %>
</p>
<p class="statusText" id="edit-music-status" style="margin-top:4px;"></p>
</div>
<footer style="display:flex;gap:8px;justify-content:flex-end;">
<button type="button" class="secondaryButton" data-modal-close>취소</button>
<button type="button" class="primaryButton" id="edit-music-save">저장</button>
<button type="button" class="secondaryButton" data-modal-close><%= t('common.cancel') %></button>
<button type="button" class="primaryButton" id="edit-music-save"><%= t('common.save') %></button>
</footer>
</div>
</div>
@@ -109,21 +109,21 @@
<!-- Edit modal (image) -->
<div class="modalOverlay" id="editImageModal" hidden>
<div class="modalCard">
<header><h3>사진 항목 수정</h3>
<button class="modalClose" type="button" data-modal-close>×</button>
<header><h3><%= t('listEditor.imageEditTitle') %></h3>
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
</header>
<div class="modalBody">
<div class="segmentedRow">
<button type="button" class="segBtn active" data-seg="yt">유튜브 주소</button>
<button type="button" class="segBtn" data-seg="img">이미지 주소</button>
<button type="button" class="segBtn active" data-seg="yt"><%= t('listEditor.imageSegYt') %></button>
<button type="button" class="segBtn" data-seg="img"><%= t('listEditor.imageSegImg') %></button>
</div>
<label>주소
<label><%= t('listEditor.imageEditUrl') %>
<input type="url" id="edit-image-url" class="textInput" />
</label>
</div>
<footer style="display:flex;gap:8px;justify-content:flex-end;">
<button type="button" class="secondaryButton" data-modal-close>취소</button>
<button type="button" class="primaryButton" id="edit-image-save">저장</button>
<button type="button" class="secondaryButton" data-modal-close><%= t('common.cancel') %></button>
<button type="button" class="primaryButton" id="edit-image-save"><%= t('common.save') %></button>
</footer>
</div>
</div>
@@ -131,6 +131,8 @@
<script>
var PACK_KEY = <%- JSON.stringify(packKey) %>;
var INITIAL = <%- JSON.stringify(list) %>;
var I18N = <%- JSON.stringify(localeDict.listEditor) %>;
I18N.common = <%- JSON.stringify(localeDict.common) %>;
</script>
<script src="/static/listEditor.js"></script>
</body>

View File

@@ -3,21 +3,21 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>관리자 로그인</title>
<title><%= t('login.title') %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody centerLayout">
<main class="loginCard">
<h1>관리자 로그인</h1>
<h1><%= t('login.title') %></h1>
<% if (error) { %>
<p class="errorBanner"><%= error %></p>
<% } %>
<form method="post" action="/op" class="loginForm">
<label>
<span>비밀번호</span>
<span><%= t('login.password') %></span>
<input name="password" type="password" autocomplete="current-password" required autofocus />
</label>
<button class="primaryButton" type="submit">로그인</button>
<button class="primaryButton" type="submit"><%= t('login.submit') %></button>
</form>
</main>
</body>

View File

@@ -1,13 +1,13 @@
<header class="topNav">
<a class="navBrand" href="/op/dashboard">
<span class="navLogo">🎵</span>
<span class="navTitle">관리자 페이지</span>
<span class="navTitle"><%= t('nav.brand') %></span>
</a>
<div class="navUser">
<button type="button" class="navUserButton" id="userMenuToggle"><%= userId %></button>
<div class="navUserMenu" id="userMenu" hidden>
<form method="post" action="/op/logout">
<button type="submit" class="dangerLink">로그아웃</button>
<button type="submit" class="dangerLink"><%= t('nav.logout') %></button>
</form>
</div>
</div>