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 { ChildProcess } from 'node:child_process' import type { Manifest, PackDefinition, PackList } from '../shared/types.js' import { normalizePackDefinition } from '../shared/store.js' import { getAppDataDir, getMcCustomDir } from '../shared/paths.js' import type { RpFetchedPack } from './types.js' import { ensureYtDlpExe } from './ytdlp.js' import { ensureFfmpegExe } from './ffmpeg.js' import { downloadMusicTrack } from './music.js' import { downloadImage, normalizeToCover, coverFileName } from './images.js' import { buildResourcepackZip } from './pack.js' interface RpInstallerState { manifestUrl: string baseUrl: string packs: Map selectedKey: string | null /** 현재 설치 진행 중인지 여부. 취소 신호로 사용. */ cancelRequested: boolean /** 현재 실행 중인 외부 프로세스(yt-dlp/ffmpeg). 취소 시 kill 대상. */ currentChild: ChildProcess | null } 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, currentChild: null } 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` const packUrl = `${state.baseUrl}/manifest/${encodeURIComponent(entry.file)}.json` try { // 목록(필수) + 팩 정의(mcVersion 용, 실패해도 폴백) 동시 로드. const [listRaw, packRaw] = await Promise.all([ fetchJson>(listUrl), fetchJson>(packUrl).catch((err) => { sendLog(`팩 정의 로드 실패 (${entry.file}): ${(err as Error).message} — mcVersion 폴백`) return null }) ]) const list: PackList = { musicPlaylistUrl: typeof listRaw.musicPlaylistUrl === 'string' ? listRaw.musicPlaylistUrl : '', imagePlaylistUrl: typeof listRaw.imagePlaylistUrl === 'string' ? listRaw.imagePlaylistUrl : '', music: Array.isArray(listRaw.music) ? listRaw.music : [], images: Array.isArray(listRaw.images) ? listRaw.images : [] } const mcVersion = packRaw ? normalizePackDefinition(packRaw as Partial).mcVersion : '' results.push({ key: entry.file, name: entry.name || entry.file, mcVersion, 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 / ffmpeg 준비 (%appdata%/.mc_custom/{yt-dlp,ffmpeg}.exe) sendLog('yt-dlp 준비 중…') const ytDlpBin = await ensureYtDlpExe(sendLog) sendLog(`yt-dlp 경로: ${ytDlpBin}`) throwIfCancelled() sendLog('ffmpeg 준비 중…') const ffmpegBin = await ensureFfmpegExe(sendLog) sendLog(`ffmpeg 경로: ${ffmpegBin}`) throwIfCancelled() // 2-2. 음악 다운로드 (1번부터 순차, ogg 변환) const musicDir = path.join(tempRoot, 'music') await fsp.mkdir(musicDir, { recursive: true }) sendLog(`음악 다운로드 시작 (${pack.list.music.length}곡)`) for (let i = 0; i < pack.list.music.length; i++) { throwIfCancelled() const entry = pack.list.music[i] sendLog(`${i + 1}번 노래 다운로드 중…`) try { const outPath = await downloadMusicTrack({ ytdlpExe: ytDlpBin, ffmpegExe: ffmpegBin, tempDir: musicDir, index: i + 1, url: entry.url, log: sendLog, onChild: (c) => { state.currentChild = c } }) state.currentChild = null sendLog(`${i + 1}번 노래 완료: ${path.basename(outPath)}`) } catch (err) { state.currentChild = null // 취소된 경우는 throwIfCancelled 가 일관된 메시지로 다시 던지게 함. if (state.cancelRequested) throwIfCancelled() throw new Error(`${i + 1}번 노래 다운로드 실패: ${(err as Error).message}`) } } // 2-3. 사진 다운로드 + painting variant 정규화 const paintingDir = path.join(tempRoot, 'painting') await fsp.mkdir(paintingDir, { recursive: true }) sendLog(`사진 다운로드 시작 (${pack.list.images.length}장)`) for (let i = 0; i < pack.list.images.length; i++) { throwIfCancelled() const entry = pack.list.images[i] sendLog(`${i + 1}번 사진 다운로드 중…`) let buf: Buffer try { buf = await downloadImage(entry.url) } catch (err) { throw new Error(`${i + 1}번 사진 다운로드 실패: ${(err as Error).message}`) } throwIfCancelled() const outPath = path.join(paintingDir, coverFileName(i + 1)) try { await normalizeToCover(buf, outPath) } catch (err) { throw new Error(`${i + 1}번 사진 정규화 실패: ${(err as Error).message}`) } sendLog(`${i + 1}번 사진 완료: ${path.basename(outPath)}`) } // 2-4. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지) throwIfCancelled() const resourcepackName = `${state.selectedKey}_musicquiz.zip` const resourcepackDir = path.join(getAppDataDir(), '.minecraft', 'resourcepacks') const resourcepackPath = path.join(resourcepackDir, resourcepackName) sendLog(`리소스팩 zip 빌드 중… (${resourcepackName})`) await buildResourcepackZip({ musicDir, paintingDir, packName: pack.name, mcVersion: pack.mcVersion, workDir: tempRoot, outZipPath: resourcepackPath, log: sendLog }) // 2-5. %appdata%/.minecraft/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장) sendLog(`설치 완료: ${resourcepackPath}`) return { resourcepackPath } } finally { // 임시 파일 정리 await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {}) } }) ipcMain.handle('rp:install:cancel', async () => { state.cancelRequested = true sendLog('취소 요청됨. 진행 중 작업을 중단합니다…') if (state.currentChild && !state.currentChild.killed) { state.currentChild.kill() } }) 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() })