On install failure the temp folder is now preserved instead of wiped, so already-downloaded songs/images are skipped on the next attempt. The error screen offers 재시도 (resume from the failed item) and 처음으로 (discard the partial download and restart). Closing the program without retrying still wipes the partial download via window-all-closed, and an explicit cancel also clears it. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
627 lines
27 KiB
TypeScript
627 lines
27 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
|
|
}
|
|
|
|
/** 파일이 존재하면 true. 이어받기(재시도) 시 이미 받아둔 산출물 감지에 사용. */
|
|
async function fileExists(p: string): Promise<boolean> {
|
|
try {
|
|
await fsp.access(p)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
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') })
|
|
let ytDlpBin = await ensureYtDlpExe(sendLog)
|
|
sendLog(t('log.ytdlpPath', { path: ytDlpBin }))
|
|
throwIfCancelled()
|
|
sendLog(t('log.ffmpegPreparing'))
|
|
sendProgress({ phase: 'prep', message: t('progress.ffmpegPreparing') })
|
|
let ffmpegBin = await ensureFfmpegExe(sendLog)
|
|
sendLog(t('log.ffmpegPath', { path: ffmpegBin }))
|
|
sendProgress({ phase: 'prep', message: t('progress.ready'), done: true })
|
|
throwIfCancelled()
|
|
|
|
// 음악 다운로드가 실패하면 yt-dlp/ffmpeg 가 너무 오래된 버전이라 유튜브 변경을
|
|
// 못 따라가는 경우일 수 있다. 그때 최신 버전으로 한 번만 강제 재설치한다.
|
|
// 워커 여러 개가 동시에 실패해도 재설치는 단 한 번만 일어나도록 락으로 직렬화.
|
|
let binRefreshPromise: Promise<void> | null = null
|
|
async function refreshBinariesOnce(): Promise<void> {
|
|
if (!binRefreshPromise) {
|
|
binRefreshPromise = (async () => {
|
|
ytDlpBin = await ensureYtDlpExe(sendLog, true)
|
|
ffmpegBin = await ensureFfmpegExe(sendLog, true)
|
|
})()
|
|
}
|
|
await binRefreshPromise
|
|
}
|
|
|
|
// 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
|
|
// 곡별 마지막 실패 메시지(재시도 단계에서 최종 에러 메시지로 사용).
|
|
const failedMessages = new Map<number, string>()
|
|
|
|
// 한 곡을 한 번 받아본다. 성공 true / 실패 false.
|
|
// emitErrorProgress=false 면 실패해도 UI 에 'error' 상태를 보내지 않는다(재시도 예정).
|
|
async function tryDownloadTrack(i: number, emitErrorProgress: boolean): Promise<boolean> {
|
|
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' })
|
|
return true
|
|
} 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 false
|
|
}
|
|
failedMessages.set(i, (err as Error).message)
|
|
if (emitErrorProgress) {
|
|
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
// 1차 다운로드: 동시 워커로 전부 받아보고, 실패한 곡 인덱스만 모은다.
|
|
// 여기서는 yt-dlp/ffmpeg 재설치를 하지 않는다(다른 워커가 같은 exe 를 실행 중일 수
|
|
// 있어 Windows 파일 잠금으로 삭제/덮어쓰기가 실패할 수 있기 때문).
|
|
const failed: number[] = []
|
|
let nextIndex = 0
|
|
async function musicWorker(): Promise<void> {
|
|
while (true) {
|
|
if (state.cancelRequested) return
|
|
const i = nextIndex++
|
|
if (i >= musicTotal) return
|
|
const idx = i + 1
|
|
// 이전 시도에서 이미 받아둔 곡(.ogg 존재)은 시차 게이트 없이 즉시 완료 처리
|
|
// 한다. '재시도' 로 이어받을 때 받았던 곡을 다시 받지 않기 위함.
|
|
const outPath = path.join(musicDir, String(idx).padStart(2, '0') + '.ogg')
|
|
if (await fileExists(outPath)) {
|
|
sendLog(t('log.musicTrackSkip', { idx }))
|
|
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
|
|
continue
|
|
}
|
|
// 시차 게이트: 새 다운로드 시작은 직전 시작과 최소 MUSIC_START_STAGGER_MS 간격을 둠.
|
|
await acquireMusicStartSlot()
|
|
if (state.cancelRequested) return
|
|
const ok = await tryDownloadTrack(i, false)
|
|
if (!ok && !state.cancelRequested) failed.push(i)
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
// 1차에서 실패한 곡이 있으면, 모든 워커가 끝나 실행 중인 yt-dlp/ffmpeg 자식
|
|
// 프로세스가 하나도 없는 지금 시점에 단 한 번 최신 버전으로 강제 재설치한다.
|
|
// (각 워커 promise 는 자식 프로세스 close 후 resolve 되므로 여기선 exe 가 잠겨
|
|
// 있지 않다 → Windows 파일 잠금 문제 없음.) 그런 다음 실패한 곡만 순차 재시도.
|
|
if (failed.length > 0) {
|
|
failed.sort((a, b) => a - b)
|
|
sendLog(t('log.musicRefreshRetry', { count: failed.length }))
|
|
await refreshBinariesOnce()
|
|
throwIfCancelled()
|
|
nextMusicStartAt = Date.now()
|
|
for (const i of failed) {
|
|
throwIfCancelled()
|
|
await acquireMusicStartSlot()
|
|
throwIfCancelled()
|
|
const ok = await tryDownloadTrack(i, true)
|
|
if (!ok) {
|
|
throwIfCancelled()
|
|
const idx = i + 1
|
|
throw new Error(t('errors.musicDownloadFailed', { idx, message: failedMessages.get(i) ?? '' }))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// 이전 시도에서 이미 정규화해둔 사진은 건너뛴다(이어받기).
|
|
const coverPath = path.join(paintingDir, coverFileName(idx))
|
|
if (await fileExists(coverPath)) {
|
|
sendLog(t('log.imageSkip', { idx }))
|
|
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
|
|
continue
|
|
}
|
|
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 })
|
|
// 성공: 임시 파일 정리
|
|
await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
|
|
return { resourcepackPath }
|
|
} catch (err) {
|
|
// 사용자가 취소한 경우에만 임시 파일을 지운다(처음부터 새로 시작).
|
|
// 그 외 오류는 받아둔 음악·사진을 보존해 '재시도' 시 실패 지점부터 이어받게 한다.
|
|
// (재시도 없이 프로그램을 닫으면 window-all-closed 에서 .temp 를 정리한다.)
|
|
if (state.cancelRequested) {
|
|
await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
|
|
}
|
|
throw err
|
|
}
|
|
})
|
|
|
|
// '처음으로' 버튼: 재시도하지 않고 처음 단계로 돌아갈 때 받아둔 임시 파일을 정리한다.
|
|
ipcMain.handle('rp:install:discard', async () => {
|
|
await fsp.rm(path.join(getMcCustomDir(), '.temp'), { 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()
|
|
})
|