installer: clean up installer-extracted map when switching to participant

When the user installs as single (skipMap=false) and then navigates back
to choose participant (skipMap=true), the previously-extracted map files
in .mc_custom/saves/ would remain because skipMap=true only skipped the
download. The final participant install state was therefore inconsistent
with the chosen role.

Track the top-level entries that downloadMapZip extracts via a marker
file (.musicquiz-installer-map.json) inside saves/. On participant
install (skipMap=true) or before a re-download, only the entries listed
in the marker are removed, so user-created worlds are preserved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 22:16:43 +09:00
parent 5ea9b49630
commit ca1c5f237f
2 changed files with 53 additions and 0 deletions

View File

@@ -191,6 +191,7 @@
"labelMap": "맵",
"skipServerZip": "서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.",
"skipMapZip": "맵 다운로드를 건너뜁니다 (mapPath 비어 있음 또는 참가자 모드).",
"cleanupInstallerMap": "이전 설치에서 풀어둔 맵 {{count}}개를 정리합니다.",
"skipModsFolder": "modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.",
"modsIndexFetch": "모드 목록 조회: {{url}}",
"modsFolderEmpty": "/file/mods/{{folder}}/ 안에 .jar 파일이 없습니다.",

View File

@@ -395,14 +395,63 @@ async function downloadServerZip(pack: PackDefinition, targetDir: string): Promi
await downloadAndExtractZip(url, t('log.labelServerFile'), targetDir)
}
/**
* 설치러가 saves/ 에 풀어놓은 최상위 폴더(또는 파일) 목록을 기록하는 마커 파일.
* 재설치 시 잔여물을 안전하게 정리하고, 싱글→참가자 전환 시에도
* 사용자가 직접 만든 월드는 보존한 채 설치러가 만든 맵만 제거하기 위함이다.
*/
const INSTALLER_MAP_MARKER = '.musicquiz-installer-map.json'
async function readInstallerMapMarker(customRoot: string): Promise<string[]> {
const markerPath = path.join(customRoot, 'saves', INSTALLER_MAP_MARKER)
try {
const raw = await fsp.readFile(markerPath, 'utf8')
const data = JSON.parse(raw) as { entries?: unknown }
if (Array.isArray(data.entries)) {
return data.entries.filter((s): s is string => typeof s === 'string')
}
} catch {
// 마커가 없거나 파싱 실패 — 빈 목록 반환
}
return []
}
async function writeInstallerMapMarker(customRoot: string, entries: string[]): Promise<void> {
const savesDir = path.join(customRoot, 'saves')
await fsp.mkdir(savesDir, { recursive: true })
const markerPath = path.join(savesDir, INSTALLER_MAP_MARKER)
await fsp.writeFile(markerPath, JSON.stringify({ entries }, null, 2), 'utf8')
}
async function cleanupInstallerMap(customRoot: string): Promise<void> {
const savesDir = path.join(customRoot, 'saves')
const entries = await readInstallerMapMarker(customRoot)
if (entries.length === 0) return
sendLog(t('log.cleanupInstallerMap', { count: entries.length }))
for (const name of entries) {
// 안전장치: 경로 구분자/상대경로 토큰이 섞인 항목은 무시
if (!name || name.includes('/') || name.includes('\\') || name === '.' || name === '..') continue
const target = path.join(savesDir, name)
await fsp.rm(target, { recursive: true, force: true })
}
await fsp.rm(path.join(savesDir, INSTALLER_MAP_MARKER), { force: true })
}
async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise<void> {
if (!pack.mapPath) {
sendLog(t('log.skipMapZip'))
return
}
// 이전 설치러가 풀어놓은 맵이 남아 있으면 먼저 제거 (다른 팩/재설치 시 잔여물 방지).
await cleanupInstallerMap(customRoot)
const url = resolveManifestRelative(pack.mapPath, 'maps')
const savesDir = path.join(customRoot, 'saves')
await fsp.mkdir(savesDir, { recursive: true })
const before = new Set<string>(await fsp.readdir(savesDir).catch(() => [] as string[]))
await downloadAndExtractZip(url, t('log.labelMap'), savesDir)
const after = await fsp.readdir(savesDir).catch(() => [] as string[])
const added = after.filter((name) => !before.has(name) && name !== INSTALLER_MAP_MARKER)
await writeInstallerMapMarker(customRoot, added)
}
async function downloadModsFolder(pack: PackDefinition, customRoot: string): Promise<void> {
@@ -1048,6 +1097,9 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) =
await downloadResourcepackZip(pack.pack, customRoot)
if (payload.skipMap) {
// 참가자 모드: 이전 설치 흐름에서 설치러가 풀어둔 맵이 있다면 제거한다.
// 사용자가 직접 만든 월드는 마커에 포함되지 않으므로 그대로 보존된다.
await cleanupInstallerMap(customRoot)
sendLog(t('log.skipMapZip'))
} else {
await downloadMapZip(pack.pack, customRoot)