From db5a1e0eac36b30e87066691b0e390671834a244 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 12 May 2026 15:11:41 +0900 Subject: [PATCH] Scaffold resource pack installer entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a second Electron entry under src/installer-rp/ + installer-rp/ launched by `npm run installer:rp`. It is structurally separate from the music quiz installer (own tsconfig, own preload, own renderer), shares the existing styles via a relative link, and exposes a three-step UI: pick a 음악퀴즈, run the install, then a 완료 page that can open the resourcepacks folder or quit. The install IPC handler currently scaffolds the flow per docs/add.md (yt-dlp prep → music download → image normalize → zip build → place at %appdata%/.minecraft/resourcepacks/) but the actual work is still TODO. Cancel/cleanup of %appdata%/.mc_custom/.temp/ is wired up so that future iterations can plug each step in without rewiring. Co-Authored-By: Claude Opus 4.7 --- installer-rp/index.html | 27 +++++ installer-rp/renderer.js | 184 ++++++++++++++++++++++++++++++ package.json | 1 + src/installer-rp/main.ts | 219 ++++++++++++++++++++++++++++++++++++ src/installer-rp/preload.ts | 39 +++++++ src/installer-rp/types.ts | 15 +++ tsconfig.installer-rp.json | 4 + 7 files changed, 489 insertions(+) create mode 100644 installer-rp/index.html create mode 100644 installer-rp/renderer.js create mode 100644 src/installer-rp/main.ts create mode 100644 src/installer-rp/preload.ts create mode 100644 src/installer-rp/types.ts create mode 100644 tsconfig.installer-rp.json 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 @@ + + + + + 마인크래프트 음악퀴즈 리소스팩 간편설치기 + + + +
+

마인크래프트 음악퀴즈 리소스팩 간편설치기

+
    +
  1. 1. 음악퀴즈
  2. +
  3. 2. 설치
  4. +
  5. 3. 완료
  6. +
+
+ +
+ + + + + + 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"] +}