installer-rp: add resume-on-retry and discard-on-quit for failed installs
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>
This commit is contained in:
@@ -485,10 +485,43 @@ function renderStep2() {
|
|||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
state.installing = false
|
state.installing = false
|
||||||
if (stopProgress) stopProgress()
|
if (stopProgress) stopProgress()
|
||||||
if (!cancelInitiated) {
|
if (cancelInitiated) {
|
||||||
alert(tt('common.installFailed', { message: (err && err.message) || err }))
|
// 취소: backend 가 임시 파일을 이미 정리했음. 조용히 처음 단계로.
|
||||||
}
|
|
||||||
renderStep1()
|
renderStep1()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 그 외 오류: 받아둔 음악·사진은 보존되어 있으므로 '재시도' 로 이어받을 수 있다.
|
||||||
|
showInstallError((err && err.message) || String(err))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설치 실패 화면: 이어받기('재시도')와 처음으로('처음으로') 선택지를 제공한다.
|
||||||
|
// 재시도 시 이미 받아둔 곡·사진은 건너뛰고 실패한 지점부터 이어서 설치한다.
|
||||||
|
function showInstallError(message) {
|
||||||
|
setActiveStep(2)
|
||||||
|
clearPage()
|
||||||
|
var section = document.createElement('section')
|
||||||
|
section.className = 'page'
|
||||||
|
section.innerHTML =
|
||||||
|
'<h2>' + escapeHtml(tt('step2.heading')) + '</h2>' +
|
||||||
|
'<p class="formMessage error">' + escapeHtml(tt('install.errorMessage', { message: message })) + '</p>' +
|
||||||
|
'<p class="formMessage">' + escapeHtml(tt('install.resumeHint')) + '</p>' +
|
||||||
|
'<div class="actionRow">' +
|
||||||
|
' <button class="secondaryBtn" id="startOver">' + escapeHtml(tt('install.startOver')) + '</button>' +
|
||||||
|
' <button class="primaryBtn" id="retry">' + escapeHtml(tt('install.retry')) + '</button>' +
|
||||||
|
'</div>'
|
||||||
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,12 @@
|
|||||||
"heading": "완료",
|
"heading": "완료",
|
||||||
"message": "리소스팩 설치를 완료했습니다."
|
"message": "리소스팩 설치를 완료했습니다."
|
||||||
},
|
},
|
||||||
|
"install": {
|
||||||
|
"errorMessage": "설치 중 오류가 발생했습니다: {{message}}",
|
||||||
|
"resumeHint": "재시도를 누르면 이미 받아둔 음악·사진은 건너뛰고 실패한 지점부터 이어서 설치합니다. 처음으로를 누르거나 프로그램을 닫으면 지금까지 받아둔 파일은 삭제됩니다.",
|
||||||
|
"retry": "재시도",
|
||||||
|
"startOver": "처음으로"
|
||||||
|
},
|
||||||
"log": {
|
"log": {
|
||||||
"manifestDownload": "manifest 다운로드: {{url}}",
|
"manifestDownload": "manifest 다운로드: {{url}}",
|
||||||
"packDefFailed": "팩 정의 로드 실패 ({{file}}): {{message}} — mcVersion 폴백",
|
"packDefFailed": "팩 정의 로드 실패 ({{file}}): {{message}} — mcVersion 폴백",
|
||||||
@@ -82,12 +88,14 @@
|
|||||||
"musicStart": "음악 다운로드 시작 ({{total}}곡, 동시 {{concurrency}}개, 시차 {{stagger}}ms)",
|
"musicStart": "음악 다운로드 시작 ({{total}}곡, 동시 {{concurrency}}개, 시차 {{stagger}}ms)",
|
||||||
"musicTrackStart": "{{idx}}번 노래 다운로드 시작",
|
"musicTrackStart": "{{idx}}번 노래 다운로드 시작",
|
||||||
"musicTrackDone": "{{idx}}번 노래 완료: {{name}}",
|
"musicTrackDone": "{{idx}}번 노래 완료: {{name}}",
|
||||||
|
"musicTrackSkip": "{{idx}}번 노래는 이전에 받아둠 → 건너뜀(이어받기)",
|
||||||
"musicRefreshRetry": "{{count}}곡 다운로드 실패 → yt-dlp/ffmpeg 최신 버전으로 재설치 후 실패한 곡만 재시도",
|
"musicRefreshRetry": "{{count}}곡 다운로드 실패 → yt-dlp/ffmpeg 최신 버전으로 재설치 후 실패한 곡만 재시도",
|
||||||
"ytdlpReinstall": "yt-dlp.exe 최신 버전으로 강제 재설치 중…",
|
"ytdlpReinstall": "yt-dlp.exe 최신 버전으로 강제 재설치 중…",
|
||||||
"ffmpegReinstall": "ffmpeg.exe 최신 버전으로 강제 재설치 중…",
|
"ffmpegReinstall": "ffmpeg.exe 최신 버전으로 강제 재설치 중…",
|
||||||
"imageStart": "사진 다운로드 시작 ({{total}}장)",
|
"imageStart": "사진 다운로드 시작 ({{total}}장)",
|
||||||
"imageDownloading": "{{idx}}번 사진 다운로드 중…",
|
"imageDownloading": "{{idx}}번 사진 다운로드 중…",
|
||||||
"imageDone": "{{idx}}번 사진 완료: {{name}}",
|
"imageDone": "{{idx}}번 사진 완료: {{name}}",
|
||||||
|
"imageSkip": "{{idx}}번 사진은 이전에 받아둠 → 건너뜀(이어받기)",
|
||||||
"baseDownload": "베이스 리소스팩 다운로드: {{path}}",
|
"baseDownload": "베이스 리소스팩 다운로드: {{path}}",
|
||||||
"baseUrl": " URL: {{url}}",
|
"baseUrl": " URL: {{url}}",
|
||||||
"baseReceived": "베이스 리소스팩 받음 ({{kb}} KB)",
|
"baseReceived": "베이스 리소스팩 받음 ({{kb}} KB)",
|
||||||
|
|||||||
@@ -89,6 +89,16 @@ function acquireMusicStartSlot(): Promise<void> {
|
|||||||
return slot
|
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 DEFAULT_MANIFEST_URL = getManifestUrl()
|
||||||
|
|
||||||
const state: RpInstallerState = {
|
const state: RpInstallerState = {
|
||||||
@@ -406,6 +416,15 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
|||||||
if (state.cancelRequested) return
|
if (state.cancelRequested) return
|
||||||
const i = nextIndex++
|
const i = nextIndex++
|
||||||
if (i >= musicTotal) return
|
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 간격을 둠.
|
// 시차 게이트: 새 다운로드 시작은 직전 시작과 최소 MUSIC_START_STAGGER_MS 간격을 둠.
|
||||||
await acquireMusicStartSlot()
|
await acquireMusicStartSlot()
|
||||||
if (state.cancelRequested) return
|
if (state.cancelRequested) return
|
||||||
@@ -451,6 +470,13 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
|||||||
throwIfCancelled()
|
throwIfCancelled()
|
||||||
const entry = pack.list.images[i]
|
const entry = pack.list.images[i]
|
||||||
const idx = i + 1
|
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 }))
|
sendLog(t('log.imageDownloading', { idx }))
|
||||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running' })
|
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running' })
|
||||||
let buf: Buffer
|
let buf: Buffer
|
||||||
@@ -539,11 +565,23 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendProgress({ phase: 'package', message: t('progress.installComplete'), done: true })
|
sendProgress({ phase: 'package', message: t('progress.installComplete'), done: true })
|
||||||
|
// 성공: 임시 파일 정리
|
||||||
|
await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
|
||||||
return { resourcepackPath }
|
return { resourcepackPath }
|
||||||
} finally {
|
} catch (err) {
|
||||||
// 임시 파일 정리
|
// 사용자가 취소한 경우에만 임시 파일을 지운다(처음부터 새로 시작).
|
||||||
|
// 그 외 오류는 받아둔 음악·사진을 보존해 '재시도' 시 실패 지점부터 이어받게 한다.
|
||||||
|
// (재시도 없이 프로그램을 닫으면 window-all-closed 에서 .temp 를 정리한다.)
|
||||||
|
if (state.cancelRequested) {
|
||||||
await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
|
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 () => {
|
ipcMain.handle('rp:install:cancel', async () => {
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ const api = {
|
|||||||
cancelInstall: (): Promise<void> =>
|
cancelInstall: (): Promise<void> =>
|
||||||
ipcRenderer.invoke('rp:install:cancel'),
|
ipcRenderer.invoke('rp:install:cancel'),
|
||||||
|
|
||||||
|
/** 재시도하지 않고 처음으로 돌아갈 때 받아둔 임시 파일을 정리한다. */
|
||||||
|
discardInstall: (): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('rp:install:discard'),
|
||||||
|
|
||||||
/** %appdata%/.mc_custom/resourcepacks/ 폴더를 OS 파일 탐색기로 연다. */
|
/** %appdata%/.mc_custom/resourcepacks/ 폴더를 OS 파일 탐색기로 연다. */
|
||||||
openResourcepackFolder: (): Promise<void> =>
|
openResourcepackFolder: (): Promise<void> =>
|
||||||
ipcRenderer.invoke('rp:finish:openFolder'),
|
ipcRenderer.invoke('rp:finish:openFolder'),
|
||||||
|
|||||||
Reference in New Issue
Block a user