feat(installer-rp): auto-start install with progress card grid
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>
This commit is contained in:
@@ -69,6 +69,24 @@ function sendLog(line: string): void {
|
||||
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)
|
||||
@@ -161,67 +179,92 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
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(`음악 다운로드 시작 (${pack.list.music.length}곡)`)
|
||||
for (let i = 0; i < pack.list.music.length; i++) {
|
||||
sendLog(`음악 다운로드 시작 (${musicTotal}곡)`)
|
||||
for (let i = 0; i < musicTotal; i++) {
|
||||
throwIfCancelled()
|
||||
const entry = pack.list.music[i]
|
||||
sendLog(`${i + 1}번 노래 다운로드 중…`)
|
||||
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: i + 1,
|
||||
index: idx,
|
||||
url: entry.url,
|
||||
log: sendLog,
|
||||
onChild: (c) => { state.currentChild = c }
|
||||
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(`${i + 1}번 노래 완료: ${path.basename(outPath)}`)
|
||||
sendLog(`${idx}번 노래 완료: ${path.basename(outPath)}`)
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
|
||||
} catch (err) {
|
||||
state.currentChild = null
|
||||
// 취소된 경우는 throwIfCancelled 가 일관된 메시지로 다시 던지게 함.
|
||||
if (state.cancelRequested) throwIfCancelled()
|
||||
throw new Error(`${i + 1}번 노래 다운로드 실패: ${(err as Error).message}`)
|
||||
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(`사진 다운로드 시작 (${pack.list.images.length}장)`)
|
||||
for (let i = 0; i < pack.list.images.length; i++) {
|
||||
sendLog(`사진 다운로드 시작 (${imageTotal}장)`)
|
||||
for (let i = 0; i < imageTotal; i++) {
|
||||
throwIfCancelled()
|
||||
const entry = pack.list.images[i]
|
||||
sendLog(`${i + 1}번 사진 다운로드 중…`)
|
||||
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) {
|
||||
throw new Error(`${i + 1}번 사진 다운로드 실패: ${(err as Error).message}`)
|
||||
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()
|
||||
const outPath = path.join(paintingDir, coverFileName(i + 1))
|
||||
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) {
|
||||
throw new Error(`${i + 1}번 사진 정규화 실패: ${(err as Error).message}`)
|
||||
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(`${i + 1}번 사진 완료: ${path.basename(outPath)}`)
|
||||
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 + 음악·이미지)
|
||||
@@ -230,6 +273,7 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
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,
|
||||
@@ -242,6 +286,7 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
|
||||
// 2-5. %appdata%/.minecraft/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장)
|
||||
sendLog(`설치 완료: ${resourcepackPath}`)
|
||||
sendProgress({ phase: 'package', message: '설치 완료', done: true })
|
||||
return { resourcepackPath }
|
||||
} finally {
|
||||
// 임시 파일 정리
|
||||
|
||||
Reference in New Issue
Block a user