From ca1c5f237f3ba750af9b267ba67a6cddd6dac2fd Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sat, 16 May 2026 22:16:43 +0900 Subject: [PATCH] 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 --- locales/installer/ko-kr.json | 1 + src/installer/main.ts | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/locales/installer/ko-kr.json b/locales/installer/ko-kr.json index 97228d7..e3fe696 100644 --- a/locales/installer/ko-kr.json +++ b/locales/installer/ko-kr.json @@ -191,6 +191,7 @@ "labelMap": "맵", "skipServerZip": "서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.", "skipMapZip": "맵 다운로드를 건너뜁니다 (mapPath 비어 있음 또는 참가자 모드).", + "cleanupInstallerMap": "이전 설치에서 풀어둔 맵 {{count}}개를 정리합니다.", "skipModsFolder": "modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.", "modsIndexFetch": "모드 목록 조회: {{url}}", "modsFolderEmpty": "/file/mods/{{folder}}/ 안에 .jar 파일이 없습니다.", diff --git a/src/installer/main.ts b/src/installer/main.ts index 58635a8..b2761db 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -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 { + 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 { + 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 { + 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 { 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(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 { @@ -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)