From 4ee0a59f2bbfdecd618355f3de14d40e4789b47a Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 18 May 2026 00:06:11 +0900 Subject: [PATCH] installer: wipe mods/ before install and rename extracted map to pack name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups requested by the user (and the first flagged by the reviewer for omission): 1) Different Minecraft versions or different packs leave behind mod jars that crash Fabric on load. `downloadModsFolder` now removes the entire `.mc_custom/mods/` directory before every install — including when the pack is vanilla (no modsFolder) so leftovers from a previous modded pack get cleared too. 2) `downloadMapZip` renames the single extracted top-level folder to the pack name (sanitized for Windows: forbidden chars `<>:"/\|?*` and control chars → `_`, trailing space/dot trimmed, reserved names like CON/NUL prefixed, empty fallback to `map`). Collisions with user worlds get `_2`, `_3` … suffixes so we never overwrite the user's own worlds. The marker file tracks the post-rename folder so future participant cleanup still removes only what the installer created. Co-Authored-By: Claude Opus 4.7 --- locales/installer/ko-kr.json | 3 ++ src/installer/main.ts | 55 ++++++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/locales/installer/ko-kr.json b/locales/installer/ko-kr.json index e3fe696..a395974 100644 --- a/locales/installer/ko-kr.json +++ b/locales/installer/ko-kr.json @@ -192,6 +192,9 @@ "skipServerZip": "서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.", "skipMapZip": "맵 다운로드를 건너뜁니다 (mapPath 비어 있음 또는 참가자 모드).", "cleanupInstallerMap": "이전 설치에서 풀어둔 맵 {{count}}개를 정리합니다.", + "mapRenamed": "맵 폴더 이름 변경: {{from}} → {{to}}", + "mapRenameFailed": "맵 폴더 이름 변경 실패 ({{from}} → {{to}}): {{message}}", + "clearMods": "기존 mods 폴더({{dir}})를 비우고 새로 받습니다.", "skipModsFolder": "modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.", "modsIndexFetch": "모드 목록 조회: {{url}}", "modsFolderEmpty": "/file/mods/{{folder}}/ 안에 .jar 파일이 없습니다.", diff --git a/src/installer/main.ts b/src/installer/main.ts index b2761db..dc0dd2a 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -437,6 +437,20 @@ async function cleanupInstallerMap(customRoot: string): Promise { await fsp.rm(path.join(savesDir, INSTALLER_MAP_MARKER), { force: true }) } +/** + * Windows 폴더 이름으로 쓸 수 없는 문자를 모두 `_` 로 치환. + * 금지 문자: `<>:"/\|?*` 와 제어 문자(0x00~0x1f) + * 추가 제한: 끝의 공백/마침표 제거, 빈 문자열 fallback, 예약 이름(CON, NUL 등) 회피. + * 참고: https://learn.microsoft.com/windows/win32/fileio/naming-a-file + */ +function sanitizeMapFolderName(name: string): string { + let cleaned = (name || '').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') + cleaned = cleaned.replace(/[ .]+$/, '') + if (!cleaned) cleaned = 'map' + if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(cleaned)) cleaned = '_' + cleaned + return cleaned +} + async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise { if (!pack.mapPath) { sendLog(t('log.skipMapZip')) @@ -451,10 +465,47 @@ async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise 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) + + // 맵 폴더 이름을 퀴즈 이름으로 통일. zip 안에서 단일 최상위 폴더로 추출된 + // 경우에만 안전하게 rename — 여러 항목이거나 파일이면 zip 구성을 존중해 그대로 둔다. + let markerEntries = added + if (added.length === 1) { + const original = added[0] + const sourcePath = path.join(savesDir, original) + const sourceStat = await fsp.stat(sourcePath).catch(() => null) + if (sourceStat?.isDirectory()) { + const desired = sanitizeMapFolderName(pack.name) + if (desired !== original) { + // 사용자 월드와 이름이 겹치면 덮어쓰지 않고 _2, _3 … 으로 회피. + let target = desired + let suffix = 2 + while (before.has(target) || fs.existsSync(path.join(savesDir, target))) { + target = `${desired}_${suffix}` + suffix++ + } + try { + await fsp.rename(sourcePath, path.join(savesDir, target)) + sendLog(t('log.mapRenamed', { from: original, to: target })) + markerEntries = [target] + } catch (err) { + // rename 실패해도 설치 자체는 성공한 상태이므로 원래 이름으로 마커만 유지. + sendLog(t('log.mapRenameFailed', { from: original, to: target, message: (err as Error).message })) + } + } + } + } + await writeInstallerMapMarker(customRoot, markerEntries) } async function downloadModsFolder(pack: PackDefinition, customRoot: string): Promise { + const modsDir = path.join(customRoot, 'mods') + // 다른 마인크래프트 버전 / 이전 팩의 모드가 섞이면 게임이 실행되지 않으므로 + // 매번 mods 폴더 전체를 비우고 새로 받는다. modsFolder 가 비어 있어 모드 자체가 + // 필요 없는 경우(=바닐라)에도 잔존 jar 가 남지 않도록 동일하게 wipe. + sendLog(t('log.clearMods', { dir: modsDir })) + await fsp.rm(modsDir, { recursive: true, force: true }) + await fsp.mkdir(modsDir, { recursive: true }) + if (!pack.modsFolder) { sendLog(t('log.skipModsFolder')) return @@ -469,8 +520,6 @@ async function downloadModsFolder(pack: PackDefinition, customRoot: string): Pro sendLog(t('log.modsFolderEmpty', { folder: pack.modsFolder })) return } - const modsDir = path.join(customRoot, 'mods') - await fsp.mkdir(modsDir, { recursive: true }) for (const fileName of files) { const url = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/${encodeURIComponent(fileName)}` const target = path.join(modsDir, fileName)