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:
2026-06-05 16:23:34 +09:00
parent 399f4af808
commit fe0d2f75e3
4 changed files with 89 additions and 6 deletions

View File

@@ -89,6 +89,16 @@ function acquireMusicStartSlot(): Promise<void> {
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 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 }))