installer: wipe mods/ before install and rename extracted map to pack name

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 00:06:11 +09:00
parent 06b35abcb1
commit 4ee0a59f2b
2 changed files with 55 additions and 3 deletions

View File

@@ -192,6 +192,9 @@
"skipServerZip": "서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.", "skipServerZip": "서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.",
"skipMapZip": "맵 다운로드를 건너뜁니다 (mapPath 비어 있음 또는 참가자 모드).", "skipMapZip": "맵 다운로드를 건너뜁니다 (mapPath 비어 있음 또는 참가자 모드).",
"cleanupInstallerMap": "이전 설치에서 풀어둔 맵 {{count}}개를 정리합니다.", "cleanupInstallerMap": "이전 설치에서 풀어둔 맵 {{count}}개를 정리합니다.",
"mapRenamed": "맵 폴더 이름 변경: {{from}} → {{to}}",
"mapRenameFailed": "맵 폴더 이름 변경 실패 ({{from}} → {{to}}): {{message}}",
"clearMods": "기존 mods 폴더({{dir}})를 비우고 새로 받습니다.",
"skipModsFolder": "modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.", "skipModsFolder": "modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.",
"modsIndexFetch": "모드 목록 조회: {{url}}", "modsIndexFetch": "모드 목록 조회: {{url}}",
"modsFolderEmpty": "/file/mods/{{folder}}/ 안에 .jar 파일이 없습니다.", "modsFolderEmpty": "/file/mods/{{folder}}/ 안에 .jar 파일이 없습니다.",

View File

@@ -437,6 +437,20 @@ async function cleanupInstallerMap(customRoot: string): Promise<void> {
await fsp.rm(path.join(savesDir, INSTALLER_MAP_MARKER), { force: true }) 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<void> { async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise<void> {
if (!pack.mapPath) { if (!pack.mapPath) {
sendLog(t('log.skipMapZip')) sendLog(t('log.skipMapZip'))
@@ -451,10 +465,47 @@ async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise
await downloadAndExtractZip(url, t('log.labelMap'), savesDir) await downloadAndExtractZip(url, t('log.labelMap'), savesDir)
const after = await fsp.readdir(savesDir).catch(() => [] as string[]) const after = await fsp.readdir(savesDir).catch(() => [] as string[])
const added = after.filter((name) => !before.has(name) && name !== INSTALLER_MAP_MARKER) 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<void> { async function downloadModsFolder(pack: PackDefinition, customRoot: string): Promise<void> {
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) { if (!pack.modsFolder) {
sendLog(t('log.skipModsFolder')) sendLog(t('log.skipModsFolder'))
return return
@@ -469,8 +520,6 @@ async function downloadModsFolder(pack: PackDefinition, customRoot: string): Pro
sendLog(t('log.modsFolderEmpty', { folder: pack.modsFolder })) sendLog(t('log.modsFolderEmpty', { folder: pack.modsFolder }))
return return
} }
const modsDir = path.join(customRoot, 'mods')
await fsp.mkdir(modsDir, { recursive: true })
for (const fileName of files) { for (const fileName of files) {
const url = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/${encodeURIComponent(fileName)}` const url = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/${encodeURIComponent(fileName)}`
const target = path.join(modsDir, fileName) const target = path.join(modsDir, fileName)