diff --git a/installer-rp/index.html b/installer-rp/index.html
new file mode 100644
index 0000000..a1341be
--- /dev/null
+++ b/installer-rp/index.html
@@ -0,0 +1,27 @@
+
+
+
+
+ 마인크래프트 음악퀴즈 리소스팩 간편설치기
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/installer-rp/renderer.js b/installer-rp/renderer.js
new file mode 100644
index 0000000..fa6a790
--- /dev/null
+++ b/installer-rp/renderer.js
@@ -0,0 +1,184 @@
+'use strict'
+
+const api = window.rpInstaller
+
+const state = {
+ packs: [],
+ selectedKey: null,
+ installing: false,
+ installed: false,
+ resourcepackPath: ''
+}
+
+const pageHost = document.getElementById('pageHost')
+const stepIndicator = document.getElementById('stepIndicator')
+const logViewer = document.getElementById('logViewer')
+const logBody = document.getElementById('logBody')
+const logToggle = document.getElementById('logToggle')
+
+logToggle.addEventListener('click', function () {
+ logViewer.classList.toggle('collapsed')
+ if (logViewer.classList.contains('collapsed')) {
+ logViewer.style.height = '36px'
+ logToggle.textContent = '펼치기'
+ } else {
+ logViewer.style.height = ''
+ logToggle.textContent = '접기'
+ }
+})
+
+api.onLog(function (line) {
+ logViewer.hidden = false
+ logBody.textContent += line + '\n'
+ logBody.scrollTop = logBody.scrollHeight
+})
+
+function setActiveStep(step) {
+ stepIndicator.querySelectorAll('li').forEach(function (item) {
+ var index = Number(item.getAttribute('data-step'))
+ item.classList.remove('active', 'done')
+ if (index < step) item.classList.add('done')
+ if (index === step) item.classList.add('active')
+ })
+}
+
+function clearPage() { pageHost.innerHTML = '' }
+
+// ── 1단계: 음악퀴즈 선택 ────────────────────────────
+function renderStep1() {
+ setActiveStep(1)
+ clearPage()
+ var section = document.createElement('section')
+ section.className = 'page'
+ section.innerHTML =
+ '1단계. 음악퀴즈 선택
' +
+ '' +
+ '
'
+ pageHost.appendChild(section)
+ var listEl = section.querySelector('#packList')
+ var nextBtn = section.querySelector('#next')
+
+ function renderList() {
+ listEl.innerHTML = ''
+ if (state.packs.length === 0) {
+ listEl.innerHTML = '등록된 음악퀴즈가 없습니다.
'
+ return
+ }
+ state.packs.forEach(function (pack) {
+ var card = document.createElement('button')
+ card.type = 'button'
+ card.className = 'choiceCard'
+ if (state.selectedKey === pack.key) card.classList.add('active')
+ card.innerHTML =
+ '' + escapeHtml(pack.name) + '' +
+ '음악 ' + pack.list.music.length + '곡 · 사진 ' + pack.list.images.length + '장'
+ card.addEventListener('click', function () {
+ state.selectedKey = pack.key
+ nextBtn.disabled = false
+ renderList()
+ })
+ listEl.appendChild(card)
+ })
+ }
+
+ nextBtn.addEventListener('click', function () {
+ if (!state.selectedKey) return
+ api.selectPack(state.selectedKey).then(function () {
+ renderStep2()
+ }).catch(function (err) {
+ alert(err.message || '선택 실패')
+ })
+ })
+
+ api.loadPacks().then(function (packs) {
+ state.packs = packs || []
+ renderList()
+ }).catch(function (err) {
+ listEl.innerHTML = '목록 로드 실패: ' + escapeHtml(err.message || '') + '
'
+ })
+}
+
+// ── 2단계: 설치 진행 ────────────────────────────────
+function renderStep2() {
+ setActiveStep(2)
+ clearPage()
+ var section = document.createElement('section')
+ section.className = 'page'
+ section.innerHTML =
+ '2단계. 리소스팩 설치
' +
+ '아래 "다음"을 누르면 음악·사진을 받아 리소스팩을 만들고 ' +
+ '%appdata%/.minecraft/resourcepacks/ 에 넣습니다.
' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '
'
+ pageHost.appendChild(section)
+ var prevBtn = section.querySelector('#prev')
+ var startBtn = section.querySelector('#start')
+ var cancelBtn = section.querySelector('#cancel')
+
+ prevBtn.addEventListener('click', function () {
+ if (state.installing) return
+ renderStep1()
+ })
+
+ startBtn.addEventListener('click', function () {
+ if (state.installing) return
+ state.installing = true
+ startBtn.disabled = true
+ prevBtn.disabled = true
+ cancelBtn.hidden = false
+ logViewer.hidden = false
+
+ api.startInstall().then(function (result) {
+ state.installing = false
+ state.installed = true
+ state.resourcepackPath = (result && result.resourcepackPath) || ''
+ renderStep3()
+ }).catch(function (err) {
+ state.installing = false
+ startBtn.disabled = false
+ prevBtn.disabled = false
+ cancelBtn.hidden = true
+ alert('설치 실패: ' + (err.message || err))
+ })
+ })
+
+ cancelBtn.addEventListener('click', function () {
+ api.cancelInstall()
+ })
+}
+
+// ── 3단계: 완료 ────────────────────────────────────
+function renderStep3() {
+ setActiveStep(3)
+ clearPage()
+ var section = document.createElement('section')
+ section.className = 'page'
+ section.innerHTML =
+ '3단계. 완료
' +
+ '리소스팩 설치를 완료했습니다.
' +
+ (state.resourcepackPath
+ ? '' + escapeHtml(state.resourcepackPath) + '
'
+ : '') +
+ '' +
+ ' ' +
+ ' ' +
+ '
'
+ pageHost.appendChild(section)
+ section.querySelector('#openFolder').addEventListener('click', function () {
+ api.openResourcepackFolder()
+ })
+ section.querySelector('#finish').addEventListener('click', function () {
+ api.quit()
+ })
+}
+
+function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, function (c) {
+ return c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : '''
+ })
+}
+
+renderStep1()
diff --git a/package.json b/package.json
index 4a2143e..826abe0 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"start": "tsc -p tsconfig.server.json && node dist/server/app.js",
"dev:server": "tsc -p tsconfig.server.json && node dist/server/app.js",
"installer": "tsc -p tsconfig.installer.json && electron .",
+ "installer:rp": "tsc -p tsconfig.installer-rp.json && electron dist/installer-rp/main.js",
"dist:win": "tsc -p tsconfig.installer.json && electron-builder --win"
},
"dependencies": {
diff --git a/src/installer-rp/main.ts b/src/installer-rp/main.ts
new file mode 100644
index 0000000..ce3b91f
--- /dev/null
+++ b/src/installer-rp/main.ts
@@ -0,0 +1,219 @@
+import { app, BrowserWindow, ipcMain, shell } from 'electron'
+import http from 'node:http'
+import https from 'node:https'
+import path from 'node:path'
+import fs from 'node:fs'
+import fsp from 'node:fs/promises'
+import { URL } from 'node:url'
+import type { Manifest, PackList } from '../shared/types.js'
+import { getAppDataDir, getMcCustomDir } from '../shared/paths.js'
+import type { RpFetchedPack } from './types.js'
+
+interface RpInstallerState {
+ manifestUrl: string
+ baseUrl: string
+ packs: Map
+ selectedKey: string | null
+ /** 현재 설치 진행 중인지 여부. 취소 신호로 사용. */
+ cancelRequested: boolean
+}
+
+const DEFAULT_MANIFEST_URL = process.env.MANIFEST_URL ?? 'http://127.0.0.1:3000/manifest.json'
+
+const state: RpInstallerState = {
+ manifestUrl: DEFAULT_MANIFEST_URL,
+ baseUrl: deriveBaseUrl(DEFAULT_MANIFEST_URL),
+ packs: new Map(),
+ selectedKey: null,
+ cancelRequested: false
+}
+
+let mainWindow: BrowserWindow | null = null
+
+function deriveBaseUrl(manifestUrl: string): string {
+ try {
+ const parsed = new URL(manifestUrl)
+ return `${parsed.protocol}//${parsed.host}`
+ } catch {
+ return ''
+ }
+}
+
+function createMainWindow(): void {
+ mainWindow = new BrowserWindow({
+ width: 900,
+ height: 680,
+ webPreferences: {
+ preload: path.join(__dirname, 'preload.js'),
+ contextIsolation: true,
+ nodeIntegration: false
+ }
+ })
+ mainWindow.removeMenu()
+ void mainWindow.loadFile(path.join(__dirname, '..', '..', 'installer-rp', 'index.html'))
+}
+
+function sendLog(line: string): void {
+ if (!mainWindow || mainWindow.isDestroyed()) return
+ const stamped = `[${new Date().toLocaleTimeString('ko-KR', { hour12: false })}] ${line}`
+ mainWindow.webContents.send('log', stamped)
+}
+
+function fetchBuffer(url: string): Promise {
+ return new Promise((resolve, reject) => {
+ const target = new URL(url)
+ const transport = target.protocol === 'https:' ? https : http
+ const request = transport.get(target, { timeout: 30000 }, (response) => {
+ if (response.statusCode === 301 || response.statusCode === 302) {
+ const redirect = response.headers.location
+ if (redirect) {
+ response.resume()
+ fetchBuffer(new URL(redirect, target).toString()).then(resolve, reject)
+ return
+ }
+ }
+ if ((response.statusCode ?? 0) >= 400) {
+ response.resume()
+ reject(new Error(`HTTP ${response.statusCode}`))
+ return
+ }
+ const chunks: Buffer[] = []
+ response.on('data', (chunk: Buffer) => chunks.push(chunk))
+ response.on('end', () => resolve(Buffer.concat(chunks)))
+ })
+ request.on('error', reject)
+ request.on('timeout', () => request.destroy(new Error('요청 시간 초과')))
+ })
+}
+
+async function fetchJson(url: string): Promise {
+ const buffer = await fetchBuffer(url)
+ return JSON.parse(buffer.toString('utf8')) as T
+}
+
+// ── IPC: 1단계 manifest 로드 ─────────────────────────
+ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promise => {
+ if (typeof manifestUrlInput === 'string' && manifestUrlInput.length > 0) {
+ state.manifestUrl = manifestUrlInput
+ state.baseUrl = deriveBaseUrl(manifestUrlInput)
+ }
+ sendLog(`manifest 다운로드: ${state.manifestUrl}`)
+ const manifest = await fetchJson(state.manifestUrl)
+ const results: RpFetchedPack[] = []
+ for (const entry of manifest.packs ?? []) {
+ if (typeof entry?.file !== 'string') continue
+ const listUrl = `${state.baseUrl}/file/list/${encodeURIComponent(entry.file)}.json`
+ try {
+ const raw = await fetchJson>(listUrl)
+ const list: PackList = {
+ musicPlaylistUrl: typeof raw.musicPlaylistUrl === 'string' ? raw.musicPlaylistUrl : '',
+ imagePlaylistUrl: typeof raw.imagePlaylistUrl === 'string' ? raw.imagePlaylistUrl : '',
+ music: Array.isArray(raw.music) ? raw.music : [],
+ images: Array.isArray(raw.images) ? raw.images : []
+ }
+ results.push({ key: entry.file, name: entry.name || entry.file, list })
+ } catch (error) {
+ sendLog(`목록 로드 실패 (${entry.file}): ${(error as Error).message}`)
+ }
+ }
+ state.packs.clear()
+ for (const item of results) state.packs.set(item.key, item)
+ sendLog(`로드된 음악퀴즈: ${results.length}개`)
+ return results
+})
+
+ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
+ if (!state.packs.has(packKey)) {
+ throw new Error('선택한 음악퀴즈를 찾을 수 없습니다.')
+ }
+ state.selectedKey = packKey
+ sendLog(`선택: ${packKey}`)
+})
+
+// ── IPC: 2단계 설치 ──────────────────────────────────
+ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => {
+ if (!state.selectedKey) throw new Error('음악퀴즈를 먼저 선택해주세요.')
+ const pack = state.packs.get(state.selectedKey)
+ if (!pack) throw new Error('선택된 음악퀴즈를 찾을 수 없습니다.')
+ state.cancelRequested = false
+
+ const tempRoot = path.join(getMcCustomDir(), '.temp')
+ await fsp.mkdir(tempRoot, { recursive: true })
+
+ try {
+ // 2-1. yt-dlp 준비
+ sendLog('yt-dlp 준비 중… (TODO)')
+ // TODO: ensureYtDlp() — shared 모듈로 분리 예정.
+ throwIfCancelled()
+
+ // 2-2. 음악 다운로드 (1번부터 순차, ogg 변환)
+ sendLog(`음악 다운로드 시작 (${pack.list.music.length}곡) … (TODO)`)
+ for (let i = 0; i < pack.list.music.length; i++) {
+ throwIfCancelled()
+ sendLog(`${i + 1}번 노래 다운로드 중… (TODO)`)
+ }
+
+ // 2-3. 사진 다운로드 + painting variant 정규화
+ sendLog(`사진 다운로드 시작 (${pack.list.images.length}장) … (TODO)`)
+ for (let i = 0; i < pack.list.images.length; i++) {
+ throwIfCancelled()
+ sendLog(`${i + 1}번 사진 다운로드 중… (TODO)`)
+ }
+
+ // 2-4. 리소스팩 zip 빌드
+ sendLog('리소스팩 zip 빌드 중… (TODO)')
+ throwIfCancelled()
+
+ // 2-5. %appdata%/.minecraft/resourcepacks/ 에 배치
+ const resourcepackName = `${state.selectedKey}_musicquiz.zip`
+ const resourcepackDir = path.join(getAppDataDir(), '.minecraft', 'resourcepacks')
+ await fsp.mkdir(resourcepackDir, { recursive: true })
+ const resourcepackPath = path.join(resourcepackDir, resourcepackName)
+ // TODO: 실제 zip 파일을 위치시킴. 지금은 빈 placeholder.
+ await fsp.writeFile(resourcepackPath, '', { flag: 'a' })
+
+ sendLog(`설치 완료: ${resourcepackPath}`)
+ return { resourcepackPath }
+ } finally {
+ // 임시 파일 정리
+ await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
+ }
+})
+
+ipcMain.handle('rp:install:cancel', async () => {
+ state.cancelRequested = true
+ sendLog('취소 요청됨. 진행 중 작업을 중단합니다…')
+})
+
+function throwIfCancelled(): void {
+ if (state.cancelRequested) {
+ throw new Error('사용자가 설치를 취소했습니다.')
+ }
+}
+
+// ── IPC: 3단계 완료 ──────────────────────────────────
+ipcMain.handle('rp:finish:openFolder', async () => {
+ const dir = path.join(getAppDataDir(), '.minecraft', 'resourcepacks')
+ if (!fs.existsSync(dir)) {
+ await fsp.mkdir(dir, { recursive: true })
+ }
+ await shell.openPath(dir)
+})
+
+ipcMain.handle('rp:quit', async () => {
+ app.quit()
+})
+
+// ── 앱 라이프사이클 ───────────────────────────────
+app.whenReady().then(() => {
+ createMainWindow()
+ app.on('activate', () => {
+ if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
+ })
+})
+
+app.on('window-all-closed', () => {
+ // 강제 종료 시에도 임시 파일은 정리.
+ fsp.rm(path.join(getMcCustomDir(), '.temp'), { recursive: true, force: true }).catch(() => {})
+ if (process.platform !== 'darwin') app.quit()
+})
diff --git a/src/installer-rp/preload.ts b/src/installer-rp/preload.ts
new file mode 100644
index 0000000..ee6939d
--- /dev/null
+++ b/src/installer-rp/preload.ts
@@ -0,0 +1,39 @@
+import { contextBridge, ipcRenderer } from 'electron'
+import type { RpFetchedPack } from './types.js'
+
+const api = {
+ /** manifest 와 각 음악퀴즈의 file/list/.json 까지 한 번에 로드. */
+ loadPacks: (manifestUrl?: string): Promise =>
+ ipcRenderer.invoke('rp:packs:load', manifestUrl),
+ /** 음악퀴즈 키를 선택. */
+ selectPack: (packKey: string): Promise =>
+ ipcRenderer.invoke('rp:packs:select', packKey),
+
+ /** 리소스팩 빌드/설치 시작. 완료 또는 취소될 때까지 resolve 되지 않을 수 있음. */
+ startInstall: (): Promise<{ resourcepackPath: string }> =>
+ ipcRenderer.invoke('rp:install:start'),
+ /** 진행 중인 설치 취소. 임시 파일 정리 후 종료. */
+ cancelInstall: (): Promise =>
+ ipcRenderer.invoke('rp:install:cancel'),
+
+ /** %appdata%/.minecraft/resourcepacks/ 폴더를 OS 파일 탐색기로 연다. */
+ openResourcepackFolder: (): Promise =>
+ ipcRenderer.invoke('rp:finish:openFolder'),
+ /** 프로그램 종료. */
+ quit: (): Promise => ipcRenderer.invoke('rp:quit'),
+
+ /** 로그 스트림 구독. */
+ onLog: (handler: (line: string) => void): (() => void) => {
+ const listener = (_event: unknown, line: string) => handler(line)
+ ipcRenderer.on('log', listener)
+ return () => ipcRenderer.removeListener('log', listener)
+ }
+}
+
+contextBridge.exposeInMainWorld('rpInstaller', api)
+
+declare global {
+ interface Window {
+ rpInstaller: typeof api
+ }
+}
diff --git a/src/installer-rp/types.ts b/src/installer-rp/types.ts
new file mode 100644
index 0000000..ba5e659
--- /dev/null
+++ b/src/installer-rp/types.ts
@@ -0,0 +1,15 @@
+import type { PackList } from '../shared/types.js'
+
+export interface RpFetchedPack {
+ key: string
+ name: string
+ /** /file/list/.json 의 음악·사진 목록. */
+ list: PackList
+}
+
+export interface RpInstallProgress {
+ step: 'yt-dlp' | 'music' | 'image' | 'package' | 'place'
+ index?: number
+ total?: number
+ message?: string
+}
diff --git a/tsconfig.installer-rp.json b/tsconfig.installer-rp.json
new file mode 100644
index 0000000..ffff367
--- /dev/null
+++ b/tsconfig.installer-rp.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["src/installer-rp/**/*.ts", "src/shared/**/*.ts"]
+}