From 9f9cffffeba37d3c653b81767b8a62fe5b33bf68 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 12 May 2026 20:01:15 +0900 Subject: [PATCH] feat(installer-rp): auto-start install with progress card grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- installer-rp/renderer.js | 145 ++++++++++++++++++++++++++++-------- installer/styles.css | 82 ++++++++++++++++++++ src/installer-rp/main.ts | 77 +++++++++++++++---- src/installer-rp/music.ts | 25 ++++++- src/installer-rp/preload.ts | 7 ++ 5 files changed, 288 insertions(+), 48 deletions(-) diff --git a/installer-rp/renderer.js b/installer-rp/renderer.js index 7e1973d..7e79370 100644 --- a/installer-rp/renderer.js +++ b/installer-rp/renderer.js @@ -104,52 +104,137 @@ function renderStep1() { function renderStep2() { setActiveStep(2) clearPage() + + var pack = null + for (var i = 0; i < state.packs.length; i++) { + if (state.packs[i].key === state.selectedKey) { pack = state.packs[i]; break } + } + var musicTotal = pack ? pack.list.music.length : 0 + var imageTotal = pack ? pack.list.images.length : 0 + var section = document.createElement('section') section.className = 'page' section.innerHTML = '

2단계. 리소스팩 설치

' + - '

아래 "다음"을 누르면 음악·사진을 받아 리소스팩을 만들고 ' + - '%appdata%/.minecraft/resourcepacks/ 에 넣습니다.

' + + '

음악·사진을 받아 리소스팩을 만들고 ' + + '%appdata%/.minecraft/resourcepacks/ 에 자동 설치합니다.

' + + '
' + + ' yt-dlp 준비' + + ' ffmpeg 준비' + + '
' + + '
' + + '

음악 다운로드

' + + '
' + musicTotal + '곡
' + + '
' + + '
' + + '
' + + '

사진 다운로드

' + + '
' + imageTotal + '장
' + + '
' + + '
' + + '
' + + '

리소스팩 빌드

' + + '
대기 중…
' + + '
' + '
' + - ' ' + - ' ' + - ' ' + + ' ' + + ' ' + '
' pageHost.appendChild(section) - var prevBtn = section.querySelector('#prev') - var startBtn = section.querySelector('#start') + + var musicGrid = section.querySelector('#musicGrid') + var imageGrid = section.querySelector('#imageGrid') + var chipYtdlp = section.querySelector('#chip-ytdlp') + var chipFfmpeg = section.querySelector('#chip-ffmpeg') + var pkgSub = section.querySelector('#pkg-sub') var cancelBtn = section.querySelector('#cancel') - prevBtn.addEventListener('click', function () { - if (state.installing) return - renderStep1() - }) + function buildCard(idx) { + var card = document.createElement('div') + card.className = 'progressCard pending' + card.setAttribute('data-idx', String(idx)) + card.innerHTML = + '
' + idx + '
' + + '
' + + '
대기
' + return card + } + for (var m = 1; m <= musicTotal; m++) musicGrid.appendChild(buildCard(m)) + for (var k = 1; k <= imageTotal; k++) imageGrid.appendChild(buildCard(k)) - startBtn.addEventListener('click', function () { - if (state.installing) return - state.installing = true - startBtn.disabled = true - prevBtn.disabled = true - cancelBtn.hidden = false - logViewer.hidden = false + function updateCard(grid, index, percent, status) { + var card = grid.querySelector('[data-idx="' + index + '"]') + if (!card) return + card.classList.remove('pending', 'running', 'done', 'error') + card.classList.add(status) + var bar = card.querySelector('.bar > span') + if (bar) bar.style.width = Math.max(0, Math.min(100, percent)) + '%' + var pct = card.querySelector('.pct') + var icon = card.querySelector('.icon') + if (status === 'done') { + if (pct) pct.textContent = '완료' + if (icon) icon.textContent = '✓' + if (bar) bar.style.width = '100%' + } else if (status === 'error') { + if (pct) pct.textContent = '실패' + if (icon) icon.textContent = '✕' + } else if (status === 'running') { + if (pct) pct.textContent = Math.round(percent) + '%' + if (icon) icon.textContent = '⏳' + } else { + if (pct) pct.textContent = '대기' + if (icon) icon.textContent = '○' + } + } - api.startInstall().then(function (result) { - state.installing = false - state.installed = true - state.resourcepackPath = (result && result.resourcepackPath) || '' - renderStep3() - }).catch(function (err) { - state.installing = false - startBtn.disabled = false - prevBtn.disabled = false - cancelBtn.hidden = true - alert('설치 실패: ' + (err.message || err)) - }) + var stopProgress = api.onProgress(function (payload) { + if (!payload || typeof payload !== 'object') return + if (payload.phase === 'prep') { + if (payload.done) { + chipYtdlp.classList.remove('active'); chipYtdlp.classList.add('done') + chipFfmpeg.classList.remove('active'); chipFfmpeg.classList.add('done') + return + } + if (payload.message && payload.message.indexOf('yt-dlp') >= 0) { + chipYtdlp.classList.add('active') + } else if (payload.message && payload.message.indexOf('ffmpeg') >= 0) { + chipYtdlp.classList.remove('active'); chipYtdlp.classList.add('done') + chipFfmpeg.classList.add('active') + } + return + } + if (payload.phase === 'item') { + var grid = payload.kind === 'music' ? musicGrid : imageGrid + updateCard(grid, payload.index, payload.percent || 0, payload.status) + return + } + if (payload.phase === 'package') { + pkgSub.textContent = payload.done ? '설치 완료' : (payload.message || '빌드 중…') + return + } }) cancelBtn.addEventListener('click', function () { + if (!state.installing) return + cancelBtn.disabled = true api.cancelInstall() }) + + // 페이지 진입 즉시 설치 시작 + state.installing = true + logViewer.hidden = false + api.startInstall().then(function (result) { + state.installing = false + state.installed = true + state.resourcepackPath = (result && result.resourcepackPath) || '' + if (stopProgress) stopProgress() + renderStep3() + }).catch(function (err) { + state.installing = false + if (stopProgress) stopProgress() + alert('설치 실패: ' + ((err && err.message) || err)) + renderStep1() + }) } // ── 3단계: 완료 ──────────────────────────────────── diff --git a/installer/styles.css b/installer/styles.css index 848eba9..434e105 100644 --- a/installer/styles.css +++ b/installer/styles.css @@ -221,3 +221,85 @@ main { .statusBadge.ok { background: rgba(63, 185, 80, 0.2); color: var(--success); } .statusBadge.warn { background: rgba(248, 197, 49, 0.2); color: #f0c244; } .statusBadge.fail { background: rgba(248, 81, 73, 0.2); color: var(--danger); } + +/* 설치 진행 카드 그리드 */ +.progressSection { margin: 18px 0 8px; } +.progressSection h3 { margin: 0 0 10px; font-size: 15px; } +.progressSection .sectionSub { font-size: 12px; color: var(--text-muted); margin-bottom: 10px; } + +.progressGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 8px; +} + +.progressCard { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 10px 8px; + display: flex; + flex-direction: column; + gap: 6px; + min-height: 72px; + transition: border-color 0.15s, background 0.15s; +} +.progressCard.running { border-color: var(--accent); background: rgba(47, 129, 247, 0.10); } +.progressCard.done { border-color: var(--success); background: rgba(63, 185, 80, 0.10); } +.progressCard.error { border-color: var(--danger); background: rgba(248, 81, 73, 0.10); } + +.progressCard .cardTop { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; + font-weight: 600; +} +.progressCard .cardTop .label { color: var(--text); } +.progressCard .cardTop .icon { font-size: 14px; } +.progressCard.pending .cardTop .icon { color: var(--text-muted); } +.progressCard.running .cardTop .icon { color: var(--accent); } +.progressCard.done .cardTop .icon { color: var(--success); } +.progressCard.error .cardTop .icon { color: var(--danger); } + +.progressCard .bar { + height: 6px; + background: #2a2f37; + border-radius: 4px; + overflow: hidden; +} +.progressCard .bar > span { + display: block; + height: 100%; + width: 0%; + background: var(--accent); + transition: width 0.18s linear; +} +.progressCard.done .bar > span { background: var(--success); } +.progressCard.error .bar > span { background: var(--danger); } + +.progressCard .pct { + font-size: 11px; + color: var(--text-muted); + text-align: right; +} + +.prepRow { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 12px; +} +.prepChip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + background: var(--bg-card); + border: 1px solid var(--border); + font-size: 12px; + color: var(--text-muted); +} +.prepChip.active { border-color: var(--accent); color: var(--text); } +.prepChip.done { border-color: var(--success); color: var(--success); } diff --git a/src/installer-rp/main.ts b/src/installer-rp/main.ts index 65f4feb..6196c48 100644 --- a/src/installer-rp/main.ts +++ b/src/installer-rp/main.ts @@ -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 { 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 { // 임시 파일 정리 diff --git a/src/installer-rp/music.ts b/src/installer-rp/music.ts index 489fe2e..33441de 100644 --- a/src/installer-rp/music.ts +++ b/src/installer-rp/music.ts @@ -14,6 +14,8 @@ export interface DownloadMusicOptions { log?: (line: string) => void /** 현재 실행 중인 자식 프로세스를 외부에 알림 (취소용). */ onChild?: (child: ChildProcess) => void + /** yt-dlp 의 다운로드 퍼센트 (0~100). 변환 단계는 별도. */ + onProgress?: (percent: number) => void } /** @@ -40,9 +42,28 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise const child = spawn(opts.ytdlpExe, args, { stdio: ['ignore', 'pipe', 'pipe'] }) opts.onChild?.(child) let stderr = '' + let stdoutBuf = '' + let lastReportedPct = -1 child.stdout?.on('data', (chunk: Buffer) => { - const line = chunk.toString('utf8').trimEnd() - if (line) opts.log?.(`yt-dlp> ${line}`) + stdoutBuf += chunk.toString('utf8') + // yt-dlp 는 `[download] 3.3% of 3.72MiB at ...` 형식으로 + // \r 로 같은 줄을 갱신한다. \r 과 \n 을 모두 split 해서 마지막 진행률을 뽑는다. + const lines = stdoutBuf.split(/[\r\n]/) + stdoutBuf = lines.pop() ?? '' + for (const raw of lines) { + const line = raw.trimEnd() + if (!line) continue + opts.log?.(`yt-dlp> ${line}`) + const m = line.match(/\[download\]\s+([\d.]+)%/) + if (m) { + const pct = Math.min(100, Math.max(0, parseFloat(m[1]))) + // 너무 잦은 이벤트를 피하기 위해 1% 단위로만 전달. + if (Math.floor(pct) !== lastReportedPct) { + lastReportedPct = Math.floor(pct) + opts.onProgress?.(pct) + } + } + } }) child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') diff --git a/src/installer-rp/preload.ts b/src/installer-rp/preload.ts index ee6939d..3d3ec5b 100644 --- a/src/installer-rp/preload.ts +++ b/src/installer-rp/preload.ts @@ -27,6 +27,13 @@ const api = { const listener = (_event: unknown, line: string) => handler(line) ipcRenderer.on('log', listener) return () => ipcRenderer.removeListener('log', listener) + }, + + /** 설치 진행 이벤트 구독. payload 구조는 renderer 가 알아서 분기. */ + onProgress: (handler: (payload: unknown) => void): (() => void) => { + const listener = (_event: unknown, payload: unknown) => handler(payload) + ipcRenderer.on('progress', listener) + return () => ipcRenderer.removeListener('progress', listener) } }