2단계 페이지 진입 즉시 설치를 시작하고, 음악·사진을 1번부터 카드 그리드로 한눈에 볼 수 있게 만든다. 다운로드는 % 게이지로, 완료/실패는 색상으로 표시. - main: prep/item/package phase 의 ProgressEvent 를 renderer 로 송신 - music.ts: yt-dlp stdout 의 [download] X% 라인을 파싱해 onProgress 호출 - preload: onProgress 채널 구독 함수 노출 - renderer: 다음 버튼 제거, prep chip + music/image 카드 그리드 + 빌드 상태 - styles: progressCard / prepChip / progressGrid 스타일 추가 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
337 lines
13 KiB
TypeScript
337 lines
13 KiB
TypeScript
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<string, RpFetchedPack>
|
|
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)
|
|
}
|
|
|
|
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<Buffer> {
|
|
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<T>(url: string): Promise<T> {
|
|
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<RpFetchedPack[]> => {
|
|
if (typeof manifestUrlInput === 'string' && manifestUrlInput.length > 0) {
|
|
state.manifestUrl = manifestUrlInput
|
|
state.baseUrl = deriveBaseUrl(manifestUrlInput)
|
|
}
|
|
sendLog(`manifest 다운로드: ${state.manifestUrl}`)
|
|
const manifest = await fetchJson<Manifest>(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<Partial<PackList>>(listUrl),
|
|
fetchJson<Partial<PackDefinition>>(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<PackDefinition>).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 })
|
|
|
|
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('yt-dlp 준비 중…')
|
|
sendProgress({ phase: 'prep', message: 'yt-dlp 준비 중' })
|
|
const ytDlpBin = await ensureYtDlpExe(sendLog)
|
|
sendLog(`yt-dlp 경로: ${ytDlpBin}`)
|
|
throwIfCancelled()
|
|
sendLog('ffmpeg 준비 중…')
|
|
sendProgress({ phase: 'prep', message: 'ffmpeg 준비 중' })
|
|
const ffmpegBin = await ensureFfmpegExe(sendLog)
|
|
sendLog(`ffmpeg 경로: ${ffmpegBin}`)
|
|
sendProgress({ phase: 'prep', message: '준비 완료', done: true })
|
|
throwIfCancelled()
|
|
|
|
// 2-2. 음악 다운로드 (1번부터 순차, ogg 변환)
|
|
const musicDir = path.join(tempRoot, 'music')
|
|
await fsp.mkdir(musicDir, { recursive: true })
|
|
sendLog(`음악 다운로드 시작 (${musicTotal}곡)`)
|
|
for (let i = 0; i < musicTotal; i++) {
|
|
throwIfCancelled()
|
|
const entry = pack.list.music[i]
|
|
const idx = i + 1
|
|
sendLog(`${idx}번 노래 다운로드 중…`)
|
|
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
|
|
try {
|
|
const outPath = await downloadMusicTrack({
|
|
ytdlpExe: ytDlpBin,
|
|
ffmpegExe: ffmpegBin,
|
|
tempDir: musicDir,
|
|
index: idx,
|
|
url: entry.url,
|
|
log: sendLog,
|
|
onChild: (c) => { state.currentChild = 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'
|
|
})
|
|
}
|
|
})
|
|
state.currentChild = null
|
|
sendLog(`${idx}번 노래 완료: ${path.basename(outPath)}`)
|
|
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
|
|
} catch (err) {
|
|
state.currentChild = null
|
|
if (state.cancelRequested) {
|
|
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: '취소됨' })
|
|
throwIfCancelled()
|
|
}
|
|
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}`)
|
|
}
|
|
}
|
|
|
|
// 2-3. 사진 다운로드 + painting variant 정규화
|
|
const paintingDir = path.join(tempRoot, 'painting')
|
|
await fsp.mkdir(paintingDir, { recursive: true })
|
|
sendLog(`사진 다운로드 시작 (${imageTotal}장)`)
|
|
for (let i = 0; i < imageTotal; i++) {
|
|
throwIfCancelled()
|
|
const entry = pack.list.images[i]
|
|
const idx = i + 1
|
|
sendLog(`${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}`)
|
|
}
|
|
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(`${idx}번 사진 정규화 실패: ${(err as Error).message}`)
|
|
}
|
|
sendLog(`${idx}번 사진 완료: ${path.basename(outPath)}`)
|
|
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
|
|
}
|
|
|
|
// 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})`)
|
|
sendProgress({ phase: 'package', message: 'zip 빌드 중' })
|
|
await buildResourcepackZip({
|
|
musicDir,
|
|
paintingDir,
|
|
packName: pack.name,
|
|
mcVersion: pack.mcVersion,
|
|
workDir: tempRoot,
|
|
outZipPath: resourcepackPath,
|
|
log: sendLog
|
|
})
|
|
|
|
// 2-5. %appdata%/.minecraft/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장)
|
|
sendLog(`설치 완료: ${resourcepackPath}`)
|
|
sendProgress({ phase: 'package', message: '설치 완료', done: true })
|
|
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()
|
|
})
|