diff --git a/installer-rp/renderer.js b/installer-rp/renderer.js index db5fd80..3feaa88 100644 --- a/installer-rp/renderer.js +++ b/installer-rp/renderer.js @@ -485,10 +485,43 @@ function renderStep2() { }).catch(function (err) { state.installing = false if (stopProgress) stopProgress() - if (!cancelInitiated) { - alert(tt('common.installFailed', { message: (err && err.message) || err })) + if (cancelInitiated) { + // 취소: backend 가 임시 파일을 이미 정리했음. 조용히 처음 단계로. + renderStep1() + return } - renderStep1() + // 그 외 오류: 받아둔 음악·사진은 보존되어 있으므로 '재시도' 로 이어받을 수 있다. + showInstallError((err && err.message) || String(err)) + }) +} + +// 설치 실패 화면: 이어받기('재시도')와 처음으로('처음으로') 선택지를 제공한다. +// 재시도 시 이미 받아둔 곡·사진은 건너뛰고 실패한 지점부터 이어서 설치한다. +function showInstallError(message) { + setActiveStep(2) + clearPage() + var section = document.createElement('section') + section.className = 'page' + section.innerHTML = + '

' + escapeHtml(tt('step2.heading')) + '

' + + '

' + escapeHtml(tt('install.errorMessage', { message: message })) + '

' + + '

' + escapeHtml(tt('install.resumeHint')) + '

' + + '
' + + ' ' + + ' ' + + '
' + pageHost.appendChild(section) + section.querySelector('#retry').addEventListener('click', function () { + // 같은 음악퀴즈로 설치를 다시 시작. backend 가 받아둔 산출물을 건너뛴다. + renderStep2() + }) + section.querySelector('#startOver').addEventListener('click', function () { + // 이어받지 않고 처음으로: 받아둔 임시 파일을 정리한 뒤 1단계로. + api.discardInstall().then(function () { + renderStep1() + }).catch(function () { + renderStep1() + }) }) } diff --git a/locales/installer-rp/ko-kr.json b/locales/installer-rp/ko-kr.json index a76583c..fa1e52d 100644 --- a/locales/installer-rp/ko-kr.json +++ b/locales/installer-rp/ko-kr.json @@ -65,6 +65,12 @@ "heading": "완료", "message": "리소스팩 설치를 완료했습니다." }, + "install": { + "errorMessage": "설치 중 오류가 발생했습니다: {{message}}", + "resumeHint": "재시도를 누르면 이미 받아둔 음악·사진은 건너뛰고 실패한 지점부터 이어서 설치합니다. 처음으로를 누르거나 프로그램을 닫으면 지금까지 받아둔 파일은 삭제됩니다.", + "retry": "재시도", + "startOver": "처음으로" + }, "log": { "manifestDownload": "manifest 다운로드: {{url}}", "packDefFailed": "팩 정의 로드 실패 ({{file}}): {{message}} — mcVersion 폴백", @@ -82,12 +88,14 @@ "musicStart": "음악 다운로드 시작 ({{total}}곡, 동시 {{concurrency}}개, 시차 {{stagger}}ms)", "musicTrackStart": "{{idx}}번 노래 다운로드 시작", "musicTrackDone": "{{idx}}번 노래 완료: {{name}}", + "musicTrackSkip": "{{idx}}번 노래는 이전에 받아둠 → 건너뜀(이어받기)", "musicRefreshRetry": "{{count}}곡 다운로드 실패 → yt-dlp/ffmpeg 최신 버전으로 재설치 후 실패한 곡만 재시도", "ytdlpReinstall": "yt-dlp.exe 최신 버전으로 강제 재설치 중…", "ffmpegReinstall": "ffmpeg.exe 최신 버전으로 강제 재설치 중…", "imageStart": "사진 다운로드 시작 ({{total}}장)", "imageDownloading": "{{idx}}번 사진 다운로드 중…", "imageDone": "{{idx}}번 사진 완료: {{name}}", + "imageSkip": "{{idx}}번 사진은 이전에 받아둠 → 건너뜀(이어받기)", "baseDownload": "베이스 리소스팩 다운로드: {{path}}", "baseUrl": " URL: {{url}}", "baseReceived": "베이스 리소스팩 받음 ({{kb}} KB)", diff --git a/src/installer-rp/main.ts b/src/installer-rp/main.ts index e1e0691..8a75e19 100644 --- a/src/installer-rp/main.ts +++ b/src/installer-rp/main.ts @@ -89,6 +89,16 @@ function acquireMusicStartSlot(): Promise { return slot } +/** 파일이 존재하면 true. 이어받기(재시도) 시 이미 받아둔 산출물 감지에 사용. */ +async function fileExists(p: string): Promise { + try { + await fsp.access(p) + return true + } catch { + return false + } +} + const DEFAULT_MANIFEST_URL = getManifestUrl() const state: RpInstallerState = { @@ -406,6 +416,15 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string 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 @@ -451,6 +470,13 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string 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 @@ -539,13 +565,25 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string } sendProgress({ phase: 'package', message: t('progress.installComplete'), done: true }) - return { resourcepackPath } - } finally { - // 임시 파일 정리 + // 성공: 임시 파일 정리 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 })) diff --git a/src/installer-rp/preload.ts b/src/installer-rp/preload.ts index 58c6fb8..9be56da 100644 --- a/src/installer-rp/preload.ts +++ b/src/installer-rp/preload.ts @@ -27,6 +27,10 @@ const api = { cancelInstall: (): Promise => ipcRenderer.invoke('rp:install:cancel'), + /** 재시도하지 않고 처음으로 돌아갈 때 받아둔 임시 파일을 정리한다. */ + discardInstall: (): Promise => + ipcRenderer.invoke('rp:install:discard'), + /** %appdata%/.mc_custom/resourcepacks/ 폴더를 OS 파일 탐색기로 연다. */ openResourcepackFolder: (): Promise => ipcRenderer.invoke('rp:finish:openFolder'),