The previous hardcoded pack_format 34 + supported_formats 34..75
covered 1.21 through 1.21.11 only, so a pack generated for the
current latest (26.1.2 → format 84) was rejected as outdated.
Add src/installer-rp/packFormat.ts with a 1.21 → 26.2 lookup table
from the Minecraft wiki and resolveResourcePackFormat() that returns
{matched, format}. Unknown mcVersion falls back to the table's most
recent entry, with a log line warning the user.
Plumb mcVersion through the load → install flow:
- rp:packs:load now also fetches /manifest/<key>.json alongside
/file/list/<key>.json and runs it through the existing
normalizePackDefinition so the editor and the installer agree on
the mcVersion shape. Pack manifest load failures fall back to an
empty mcVersion (which then triggers the latest-format fallback).
- RpFetchedPack carries mcVersion; the install handler hands it to
buildResourcepackZip.
- buildResourcepackZip drops the constant pack_format / supported_
formats and uses the resolved format both as pack_format and as
the {min,max} of supported_formats. Each pack is thus pinned to
exactly the MC version it was authored for.
- The renderer's pack card now shows "마인크래프트 <version>" in
the small line so the user can confirm before installing.
Verified locally: pack.mcmeta generated for mcVersion "1.21",
"1.21.6", "26.1.2", and the bogus "99.9.9" produce pack_format
34 / 63 / 84 / 86 (last falls back to the table tail) respectively.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
292 lines
11 KiB
TypeScript
292 lines
11 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)
|
|
}
|
|
|
|
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 })
|
|
|
|
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()
|
|
})
|