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 os from 'node:os' 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 { 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' import { downloadMusicTrack } from './music.js' 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 baseUrl: string packs: Map selectedKey: string | null /** 현재 설치 진행 중인지 여부. 취소 신호로 사용. */ cancelRequested: boolean /** 현재 실행 중인 외부 프로세스들(yt-dlp/ffmpeg). 취소 시 모두 kill. */ activeChildren: Set } /** * 사용자가 사이트에서 지정한 "생성되는 리소스팩 이름" 을 Windows 파일명으로 쓸 수 * 있게 정리한다. 금지 문자(\<\>:"/\\|?*\x00-\x1f) 는 `_` 로, 끝의 공백/마침표는 * 제거, 예약어(CON/PRN/...)는 앞에 `_` 를 붙인다. 빈 입력은 빈 문자열 반환 → * 호출 측에서 폴백을 결정한다. */ function sanitizeOutputPackName(name: string): string { let cleaned = (name || '').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') cleaned = cleaned.replace(/[ .]+$/, '') if (!cleaned) return '' if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(cleaned)) cleaned = '_' + cleaned return cleaned } /** * 동시 yt-dlp 프로세스 수를 CPU 코어 수로 자동 결정. * - yt-dlp + ffmpeg 변환이 CPU 바운드라 코어 수가 가장 좋은 프록시. * - 유튜브가 IP 단위로 throttle 걸기 때문에 5 이상은 효과 없음 → 상한 5. * - 환경변수 MUSIC_CONCURRENCY 로 강제 오버라이드 가능. */ function pickMusicConcurrency(): number { const override = Number(process.env.MUSIC_CONCURRENCY) if (Number.isFinite(override) && override >= 1) { return Math.min(8, Math.floor(override)) } const cores = os.cpus()?.length ?? 4 if (cores <= 2) return 2 if (cores <= 4) return 3 if (cores <= 8) return 4 return 5 } /** * 새 다운로드 시작 사이의 최소 간격(ms). * - 동시 N개를 모두 t=0 에 시작하면 카드들이 0% 에서 같이 정지된 듯 보임. * - 시차를 두고 시작하면 "1번 끝남 → 4번 시작 → 2번 끝남 → 5번 시작" 식으로 * 유저 입장에서 항상 뭔가 새로 시작/완료되는 흐름이 보임. * - 너무 길면 동시성 이득을 깎아먹음. 2s 가 체감/속도 균형점. */ const MUSIC_START_STAGGER_MS = 2000 /** start-gate. 여러 worker 가 동시에 acquire 해도 직렬화되어 순차 통과. */ let musicStartChain: Promise = Promise.resolve() let nextMusicStartAt = 0 function acquireMusicStartSlot(): Promise { const slot = musicStartChain.then(async () => { const wait = Math.max(0, nextMusicStartAt - Date.now()) if (wait > 0) await new Promise((r) => setTimeout(r, wait)) nextMusicStartAt = Date.now() + MUSIC_START_STAGGER_MS }) musicStartChain = slot.catch(() => {}) return slot } const DEFAULT_MANIFEST_URL = getManifestUrl() const state: RpInstallerState = { manifestUrl: DEFAULT_MANIFEST_URL, baseUrl: deriveBaseUrl(DEFAULT_MANIFEST_URL), packs: new Map(), selectedKey: null, cancelRequested: false, activeChildren: new Set() } 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 { // 메인 설치기와 동일한 아이콘 사용. dev/prod, Windows/기타 분기까지 같은 규칙. const iconPath = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png') mainWindow = new BrowserWindow({ width: 900, height: 680, icon: iconPath, 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) } type ProgressEvent = | { phase: 'prep'; message: string; done?: boolean } | { phase: 'item' kind: 'music' | 'image' index: number total: number percent: number status: 'running' | 'done' | 'error' message?: string } | { phase: 'package'; message: string; done?: boolean } function sendProgress(payload: ProgressEvent): void { if (!mainWindow || mainWindow.isDestroyed()) return mainWindow.webContents.send('progress', payload) } 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(t('common.requestTimeout')))) }) } 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(t('log.manifestDownload', { url: 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(t('log.packDefFailed', { file: entry.file, message: (err as Error).message })) 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 normalized = packRaw ? normalizePackDefinition(packRaw as Partial) : null const mcVersion = normalized?.mcVersion ?? '' const resourcepackPath = normalized?.resourcepackPath ?? '' const outputPackName = normalized?.outputPackName ?? '' results.push({ key: entry.file, name: entry.name || entry.file, mcVersion, resourcepackPath, outputPackName, list }) } catch (error) { 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(t('log.packsLoaded', { count: results.length })) for (const item of results) { 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(t('errors.selectedPackNotFound')) } state.selectedKey = packKey sendLog(t('log.selectedPack', { key: packKey })) }) ipcMain.handle('rp:i18n:dict', () => localeDict) // ── IPC: 약관 다운로드 ────────────────────────────── // v0.3.4~ : 사이트에서 임의 kind 가 만들어질 수 있으니 5종 화이트리스트 대신 // kind 형식만 검증한다. 어떤 약관을 rp 인스톨러에 보여줄지는 사이트의 visibility 토글이 결정. const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/ ipcMain.handle('rp:terms:get', async (_event, kind: string) => { if (typeof kind !== 'string' || !TERM_KIND_RE.test(kind)) { return { ok: false, message: 'invalid term kind' } } if (!state.selectedKey) return { ok: false, message: 'pack not selected' } try { const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/${encodeURIComponent(kind)}.md` const buf = await fetchBuffer(url) return { ok: true, content: buf.toString('utf8') } } catch (error) { return { ok: false, message: (error as Error).message } } }) // rp 인스톨러용 약관 목록. /manifest/terms//index.json 을 받아 // showInInstallerRp=true 인 항목만 추려 반환. 비어 있으면 렌더러가 약관 단계를 건너뛴다. ipcMain.handle('rp:terms:list', async (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> => { if (!state.selectedKey) return { ok: false, message: 'pack not selected' } try { const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/index.json` const buf = await fetchBuffer(url) const parsed = JSON.parse(buf.toString('utf8')) as { terms?: unknown } const items = Array.isArray(parsed.terms) ? parsed.terms : [] const terms: Array<{ kind: string; label: string }> = [] for (const it of items) { if (!it || typeof it !== 'object') continue const entry = it as Record if (entry.showInInstallerRp !== true) continue const kind = typeof entry.kind === 'string' ? entry.kind : '' const label = typeof entry.label === 'string' ? entry.label : '' if (!TERM_KIND_RE.test(kind) || label.length === 0) continue terms.push({ kind, label }) } return { ok: true, terms } } catch (error) { return { ok: false, message: (error as Error).message } } }) // ── IPC: 2단계 설치 ────────────────────────────────── ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => { if (!state.selectedKey) throw new Error(t('errors.selectPackFirst')) const pack = state.packs.get(state.selectedKey) if (!pack) throw new Error(t('errors.currentPackNotFound')) state.cancelRequested = false const tempRoot = path.join(getMcCustomDir(), '.temp') await fsp.mkdir(tempRoot, { recursive: true }) const musicTotal = pack.list.music.length const imageTotal = pack.list.images.length try { // 2-1. yt-dlp / ffmpeg 준비 (%appdata%/.mc_custom/{yt-dlp,ffmpeg}.exe) sendLog(t('log.ytdlpPreparing')) sendProgress({ phase: 'prep', message: t('progress.ytdlpPreparing') }) const ytDlpBin = await ensureYtDlpExe(sendLog) sendLog(t('log.ytdlpPath', { path: ytDlpBin })) throwIfCancelled() sendLog(t('log.ffmpegPreparing')) sendProgress({ phase: 'prep', message: t('progress.ffmpegPreparing') }) const ffmpegBin = await ensureFfmpegExe(sendLog) sendLog(t('log.ffmpegPath', { path: ffmpegBin })) sendProgress({ phase: 'prep', message: t('progress.ready'), done: true }) throwIfCancelled() // 2-2. 음악 다운로드 (CPU 코어 수 기반 자동 동시 다운로드, 시차 출발, ogg 변환) const musicDir = path.join(tempRoot, 'music') await fsp.mkdir(musicDir, { recursive: true }) const concurrency = pickMusicConcurrency() const cpuCount = os.cpus()?.length ?? 0 // 첫 음악은 즉시 시작 가능하도록 base 를 현재 시각으로. nextMusicStartAt = Date.now() 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 let nextIndex = 0 async function musicWorker(): Promise { while (true) { if (state.cancelRequested) return const i = nextIndex++ if (i >= musicTotal) return // 시차 게이트: 새 다운로드 시작은 직전 시작과 최소 MUSIC_START_STAGGER_MS 간격을 둠. await acquireMusicStartSlot() if (state.cancelRequested) return const entry = musicList[i] const idx = i + 1 sendLog(t('log.musicTrackStart', { idx })) sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' }) let child: ChildProcess | null = null try { const outPath = await downloadMusicTrack({ ytdlpExe: ytDlpBin, ffmpegExe: ffmpegBin, tempDir: musicDir, index: idx, url: entry.url, log: sendLog, onChild: (c) => { child = c state.activeChildren.add(c) }, onProgress: (pct) => { // 다운로드(0~90%) + 변환(90~100%) 으로 매핑. sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: Math.min(90, pct * 0.9), status: 'running' }) } }) if (child) state.activeChildren.delete(child) 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: 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(t('errors.musicDownloadFailed', { idx, message: (err as Error).message })) } } } const workerCount = Math.min(concurrency, musicTotal) const workers: Promise[] = [] for (let w = 0; w < workerCount; w++) workers.push(musicWorker()) await Promise.all(workers) throwIfCancelled() // 2-3. 사진 다운로드 + painting variant 정규화 const paintingDir = path.join(tempRoot, 'painting') await fsp.mkdir(paintingDir, { recursive: true }) 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(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(t('errors.imageDownloadFailed', { idx, message: (err as Error).message })) } throwIfCancelled() sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 60, status: 'running' }) const outPath = path.join(paintingDir, coverFileName(idx)) try { 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(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message })) } sendLog(t('log.imageDone', { idx, name: path.basename(outPath) })) sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' }) } // 2-4. 베이스 리소스팩 다운로드 (있을 때만) throwIfCancelled() let baseZipPath: string | undefined if (pack.resourcepackPath) { // 파일명에 공백·괄호가 있을 수 있어 encodeURIComponent 로 인코딩. const cleaned = pack.resourcepackPath.replace(/^\/+/, '') const baseUrl = `${state.baseUrl}/file/resourcepacks/${encodeURIComponent(cleaned)}` baseZipPath = path.join(tempRoot, 'base.zip') 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(t('log.baseReceived', { kb: (buf.length / 1024).toFixed(1) })) } catch (err) { throw new Error(t('errors.baseDownloadFailed', { message: (err as Error).message })) } } else { sendLog(t('log.baseAbsent')) } // 2-5. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지, 베이스 위에 얹기) throwIfCancelled() // 사이트에서 지정한 "생성되는 리소스팩 이름" 을 우선 사용. 비어있거나 sanitize // 결과가 빈 문자열이면 `_resourcepack` 로 폴백. const sanitizedOutputName = sanitizeOutputPackName(pack.outputPackName) const resourcepackBaseName = sanitizedOutputName || `${state.selectedKey}_resourcepack` const resourcepackName = `${resourcepackBaseName}.zip` const resourcepackDir = path.join(getMcCustomDir(), 'resourcepacks') const resourcepackPath = path.join(resourcepackDir, resourcepackName) sendLog(t('log.buildingZip', { name: resourcepackName })) sendProgress({ phase: 'package', message: baseZipPath ? t('progress.buildingWithBase') : t('progress.buildingZip') }) await buildResourcepackZip({ musicDir, paintingDir, packName: pack.name, mcVersion: pack.mcVersion, workDir: tempRoot, outZipPath: resourcepackPath, baseZipPath, log: sendLog, // build 내부에서도 단계 사이/zip 도중에 폴링해서 취소를 빠르게 반영한다. cancelChecker: () => state.cancelRequested }) throwIfCancelled() // 2-6. %appdata%/.mc_custom/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장) sendLog(t('log.installComplete', { path: resourcepackPath })) // 2-7. 베이스 리소스팩은 우리가 임시폴더에 받아서 빌드에 이미 얹었으므로, // 메인 설치기가 `.mc_custom/resourcepacks/` 에 받아둔 // 원본 zip 은 MC 리소스팩 목록에 굳이 남길 필요 없다. 삭제하되, 사용자가 // outputPackName 을 base 파일명과 똑같이 둬서 우리가 방금 쓴 최종 zip 과 // 같은 경로면 그대로 둔다(우리 산출물을 지우면 안 되므로). if (pack.resourcepackPath) { const basePackPath = path.join(resourcepackDir, pack.resourcepackPath) if (path.resolve(basePackPath) !== path.resolve(resourcepackPath)) { try { await fsp.rm(basePackPath, { force: true }) sendLog(t('log.baseRemoved', { path: basePackPath })) } catch { /* 없으면 무시 */ } } } sendProgress({ phase: 'package', message: t('progress.installComplete'), done: true }) return { resourcepackPath } } finally { // 임시 파일 정리 await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {}) } }) ipcMain.handle('rp:install:cancel', async () => { state.cancelRequested = true sendLog(t('log.cancelRequested', { count: state.activeChildren.size })) for (const child of state.activeChildren) { if (!child.killed) child.kill() } }) function throwIfCancelled(): void { if (state.cancelRequested) { throw new Error(t('errors.cancelledByUser')) } } // ── IPC: 3단계 완료 ────────────────────────────────── ipcMain.handle('rp:finish:openFolder', async () => { const dir = path.join(getMcCustomDir(), '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() })