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'),