- _meta.json: customLabels -> terms.{label,showInInstaller,showInInstallerRp}
- Drop builtin protection; any term kind can be deleted/added/toggled
- New public route /manifest/terms/<pack>/index.json for installer term lists
- Installers fetch terms:list dynamically; skip agreement step if list empty
- Term editor: 2 visibility checkboxes (설치기 / 리소스팩 설치기), multi-select
- Migration from old schema preserves custom labels (default: visible in both)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
534 lines
22 KiB
TypeScript
534 lines
22 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 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<string, RpFetchedPack>
|
|
selectedKey: string | null
|
|
/** 현재 설치 진행 중인지 여부. 취소 신호로 사용. */
|
|
cancelRequested: boolean
|
|
/** 현재 실행 중인 외부 프로세스들(yt-dlp/ffmpeg). 취소 시 모두 kill. */
|
|
activeChildren: Set<ChildProcess>
|
|
}
|
|
|
|
/**
|
|
* 사용자가 사이트에서 지정한 "생성되는 리소스팩 이름" 을 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<void> = Promise.resolve()
|
|
let nextMusicStartAt = 0
|
|
function acquireMusicStartSlot(): Promise<void> {
|
|
const slot = musicStartChain.then(async () => {
|
|
const wait = Math.max(0, nextMusicStartAt - Date.now())
|
|
if (wait > 0) await new Promise<void>((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<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(t('common.requestTimeout'))))
|
|
})
|
|
}
|
|
|
|
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(t('log.manifestDownload', { url: 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(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<PackDefinition>) : 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/<packKey>/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<string, unknown>
|
|
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<void> {
|
|
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<void>[] = []
|
|
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
|
|
// 결과가 빈 문자열이면 `<packKey>_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/<resourcepackPath>` 에 받아둔
|
|
// 원본 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()
|
|
})
|