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

1단계. 음악퀴즈 선택

' + - '

목록을 불러오는 중...

' + - '
' + '

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

' + + '

' + escapeHtml(tt('common.loading')) + '

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

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

' + listEl.innerHTML = '

' + escapeHtml(tt('common.noPacks')) + '

' 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 = '' + escapeHtml(pack.name) + '' + '' + verLabel + - '음악 ' + pack.list.music.length + '곡 · 사진 ' + pack.list.images.length + '장' + escapeHtml(tt('common.trackImageCount', { music: pack.list.music.length, image: pack.list.images.length })) + + '' 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 = '

목록 로드 실패: ' + escapeHtml(err.message || '') + '

' + listEl.innerHTML = '

' + + escapeHtml(tt('common.listLoadFailed', { message: err.message || '' })) + + '

' }) } @@ -115,30 +155,29 @@ function renderStep2() { var section = document.createElement('section') section.className = 'page' section.innerHTML = - '

2단계. 리소스팩 설치

' + - '

음악·사진을 받아 리소스팩을 만들고 ' + - '%appdata%/.mc_custom/resourcepacks/ 에 자동 설치합니다.

' + + '

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

' + + '

' + tt('step2.description') + '

' + '
' + - ' yt-dlp 준비' + - ' ffmpeg 준비' + + ' ' + escapeHtml(tt('step2.chipYtdlp')) + '' + + ' ' + escapeHtml(tt('step2.chipFfmpeg')) + '' + '
' + '
' + - '

음악 다운로드

' + - '
' + musicTotal + '곡
' + + '

' + escapeHtml(tt('step2.musicHeading')) + '

' + + '
' + escapeHtml(tt('step2.musicSub', { count: musicTotal })) + '
' + '
' + '
' + '
' + - '

사진 다운로드

' + - '
' + imageTotal + '장
' + + '

' + escapeHtml(tt('step2.imageHeading')) + '

' + + '
' + escapeHtml(tt('step2.imageSub', { count: imageTotal })) + '
' + '
' + '
' + '
' + - '

리소스팩 빌드

' + - '
대기 중…
' + + '

' + escapeHtml(tt('step2.packageHeading')) + '

' + + '
' + escapeHtml(tt('step2.packageWaiting')) + '
' + '
' + '
' + ' ' + - ' ' + + ' ' + '
' pageHost.appendChild(section) @@ -156,7 +195,7 @@ function renderStep2() { card.innerHTML = '
' + idx + '
' + '
' + - '
대기
' + '
' + escapeHtml(tt('step2.cardWaiting')) + '
' 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 = - '

3단계. 완료

' + - '

리소스팩 설치를 완료했습니다.

' + + '

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

' + + '

' + escapeHtml(tt('step3.message')) + '

' + (state.resourcepackPath ? '

' + escapeHtml(state.resourcepackPath) + '

' : '') + '
' + - ' ' + - ' ' + + ' ' + + ' ' + '
' 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() +})() diff --git a/locales/installer-rp/ko-kr.json b/locales/installer-rp/ko-kr.json new file mode 100644 index 0000000..f1270b0 --- /dev/null +++ b/locales/installer-rp/ko-kr.json @@ -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": "음악·사진을 받아 리소스팩을 만들고 %appdata%/.mc_custom/resourcepacks/ 에 자동 설치합니다.", + "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}}" + } +} diff --git a/src/installer-rp/ffmpeg.ts b/src/installer-rp/ffmpeg.ts index 778f153..04777ad 100644 --- a/src/installer-rp/ffmpeg.ts +++ b/src/installer-rp/ffmpeg.ts @@ -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 = require('extract-zip') @@ -31,7 +34,7 @@ export async function ensureFfmpegExe( ): Promise { 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 { function downloadToFile(url: string, dest: string, redirects = 0): Promise { 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 diff --git a/src/installer-rp/images.ts b/src/installer-rp/images.ts index 9cf2498..a7d23a4 100644 --- a/src/installer-rp/images.ts +++ b/src/installer-rp/images.ts @@ -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 { 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 { 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) diff --git a/src/installer-rp/main.ts b/src/installer-rp/main.ts index 1083d4f..c5e58ea 100644 --- a/src/installer-rp/main.ts +++ b/src/installer-rp/main.ts @@ -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 { 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(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>(listUrl), fetchJson>(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')) } } diff --git a/src/installer-rp/music.ts b/src/installer-rp/music.ts index e3950a2..0912553 100644 --- a/src/installer-rp/music.ts +++ b/src/installer-rp/music.ts @@ -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 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 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 await fs.access(outPath) resolve(outPath) } catch { - reject(new Error(`예상 출력파일이 없음: ${outPath}`)) + reject(new Error(t('errors.ytdlpMissingOutput', { path: outPath }))) } }) }) diff --git a/src/installer-rp/pack.ts b/src/installer-rp/pack.ts index b915d6c..6ab5558 100644 --- a/src/installer-rp/pack.ts +++ b/src/installer-rp/pack.ts @@ -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 - opts.log?.(`기존 sounds.json 병합 (${Object.keys(soundsJson).length}개 항목)`) + opts.log?.(t('log.soundsMerged', { count: Object.keys(soundsJson).length })) } } catch { // 없으면 새로 생성. diff --git a/src/installer-rp/preload.ts b/src/installer-rp/preload.ts index cc022d8..7cdf9c7 100644 --- a/src/installer-rp/preload.ts +++ b/src/installer-rp/preload.ts @@ -2,6 +2,9 @@ import { contextBridge, ipcRenderer } from 'electron' import type { RpFetchedPack } from './types.js' const api = { + /** i18n 사전을 렌더러에 전달. */ + loadLocale: (): Promise> => ipcRenderer.invoke('rp:i18n:dict'), + /** manifest 와 각 음악퀴즈의 file/list/.json 까지 한 번에 로드. */ loadPacks: (manifestUrl?: string): Promise => ipcRenderer.invoke('rp:packs:load', manifestUrl), diff --git a/src/installer-rp/ytdlp.ts b/src/installer-rp/ytdlp.ts index 1e65524..24382e0 100644 --- a/src/installer-rp/ytdlp.ts +++ b/src/installer-rp/ytdlp.ts @@ -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 { 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 { function downloadToFile(url: string, dest: string, redirects = 0): Promise { 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