installer: address vanilla mods preservation, robust map rename, new app icon
Reviewer follow-ups:
1) Preserve mods/ for vanilla packs. `downloadModsFolder` now checks
`!pack.modsFolder` BEFORE wiping — vanilla packs (no modsFolder) no
longer clobber a user's hand-curated mods directory. Wipe still runs
for modded packs to keep different MC versions from colliding.
2) Always rename the extracted map to `saves/<퀴즈이름>/`, regardless of
the zip's top-level layout. The zip is now extracted into a temp
directory under saves/, and:
- if the temp has a single subdirectory, that subdirectory's content
becomes the world;
- otherwise the temp dir itself (e.g. level.dat + region/ at root) is
the world.
In either case, it is renamed atomically to `saves/<sanitized name>`
(or `<name>_2` etc. if a user world collides). Marker tracks the
final folder name for participant cleanup.
User request: replace both .exe icons.
- Added build/icon.ico (multi-size 16/32/48/64/128/256) and build/icon.png
generated from the new music-note artwork.
- electron-builder.yml: set win.icon, nsis installer/uninstaller icons,
buildResources=build, include build/icon.* in files for runtime use.
- New electron-builder-rp.yml + dist:win:rp script so the resourcepack
installer also packages with the same icon.
- BrowserWindow({ icon }) wired in both installer and installer-rp main
processes so the running window's titlebar/taskbar icon matches.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BIN
build/icon.ico
Normal file
BIN
build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
build/icon.png
Normal file
BIN
build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
31
electron-builder-rp.yml
Normal file
31
electron-builder-rp.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
appId: kr.tkrmagid.musicquiz.installer-rp
|
||||
productName: MusicQuizResourcepackInstaller
|
||||
directories:
|
||||
output: release
|
||||
buildResources: build
|
||||
files:
|
||||
- dist/installer-rp/**
|
||||
- dist/shared/**
|
||||
- installer-rp/**
|
||||
- build/icon.*
|
||||
- package.json
|
||||
# 메인 설치기와 동일하게 .env, locales 를 함께 배포.
|
||||
extraResources:
|
||||
- from: .
|
||||
to: .
|
||||
filter:
|
||||
- .env
|
||||
- from: locales
|
||||
to: locales
|
||||
filter:
|
||||
- "**/*"
|
||||
win:
|
||||
target: nsis
|
||||
artifactName: ${productName}-${version}-Setup.${ext}
|
||||
icon: build/icon.ico
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
perMachine: false
|
||||
installerIcon: build/icon.ico
|
||||
uninstallerIcon: build/icon.ico
|
||||
@@ -2,10 +2,12 @@ appId: kr.tkrmagid.musicquiz.installer
|
||||
productName: MusicQuizInstaller
|
||||
directories:
|
||||
output: release
|
||||
buildResources: build
|
||||
files:
|
||||
- dist/installer/**
|
||||
- dist/shared/**
|
||||
- installer/**
|
||||
- build/icon.*
|
||||
- package.json
|
||||
# 빌드 시점의 .env 를 설치기 옆에 함께 배포(없으면 조용히 패스).
|
||||
# 패키징 후 운영자가 resources/.env 만 교체해서 도메인을 바꿀 수도 있음.
|
||||
@@ -23,7 +25,10 @@ extraResources:
|
||||
win:
|
||||
target: nsis
|
||||
artifactName: ${productName}-${version}-Setup.${ext}
|
||||
icon: build/icon.ico
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
perMachine: false
|
||||
installerIcon: build/icon.ico
|
||||
uninstallerIcon: build/icon.ico
|
||||
|
||||
@@ -192,8 +192,7 @@
|
||||
"skipServerZip": "서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.",
|
||||
"skipMapZip": "맵 다운로드를 건너뜁니다 (mapPath 비어 있음 또는 참가자 모드).",
|
||||
"cleanupInstallerMap": "이전 설치에서 풀어둔 맵 {{count}}개를 정리합니다.",
|
||||
"mapRenamed": "맵 폴더 이름 변경: {{from}} → {{to}}",
|
||||
"mapRenameFailed": "맵 폴더 이름 변경 실패 ({{from}} → {{to}}): {{message}}",
|
||||
"mapInstalledAs": "맵을 saves/{{name}} 으로 설치했습니다.",
|
||||
"clearMods": "기존 mods 폴더({{dir}})를 비우고 새로 받습니다.",
|
||||
"skipModsFolder": "modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.",
|
||||
"modsIndexFetch": "모드 목록 조회: {{url}}",
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"dev:server": "tsc -p tsconfig.server.json && node dist/server/app.js",
|
||||
"installer": "tsc -p tsconfig.installer.json && electron .",
|
||||
"installer:rp": "tsc -p tsconfig.installer-rp.json && electron dist/installer-rp/main.js",
|
||||
"dist:win": "tsc -p tsconfig.installer.json && electron-builder --win"
|
||||
"dist:win": "tsc -p tsconfig.installer.json && electron-builder --win --config electron-builder.yml",
|
||||
"dist:win:rp": "tsc -p tsconfig.installer-rp.json && electron-builder --win --config electron-builder-rp.yml"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/archiver": "^7.0.0",
|
||||
|
||||
@@ -98,9 +98,12 @@ function deriveBaseUrl(manifestUrl: string): string {
|
||||
}
|
||||
|
||||
function createMainWindow(): void {
|
||||
// 메인 설치기와 동일한 아이콘 사용. dev/prod, Windows/기타 분기까지 같은 규칙.
|
||||
const iconPath = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png')
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 680,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
|
||||
@@ -63,9 +63,13 @@ function deriveBaseUrl(manifestUrl: string): string {
|
||||
}
|
||||
|
||||
function createMainWindow(): void {
|
||||
// 패키징 시 build/icon.ico, dev 실행 시 build/icon.png 모두 동일 경로에서 발견되도록
|
||||
// 프로젝트 루트의 build/ 를 가리킨다. 파일이 없으면 Electron 이 기본 아이콘으로 fallback.
|
||||
const iconPath = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png')
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 980,
|
||||
height: 720,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
@@ -461,55 +465,55 @@ async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise
|
||||
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)
|
||||
|
||||
// 맵 폴더 이름을 퀴즈 이름으로 통일. 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()) {
|
||||
// zip 의 최상위 구조(단일 폴더 / 루트에 level.dat) 와 관계없이 최종 폴더 이름이
|
||||
// 항상 퀴즈 이름이 되도록, 우선 saves/ 안의 임시 폴더에 풀고 적절히 옮긴다.
|
||||
// saves 와 같은 디렉터리에서 만들기 때문에 rename 이 cross-device 실패 없이 동작.
|
||||
const tempExtractDir = await fsp.mkdtemp(path.join(savesDir, '.mq-map-extract-'))
|
||||
try {
|
||||
await downloadAndExtractZip(url, t('log.labelMap'), tempExtractDir)
|
||||
|
||||
// zip 이 단일 최상위 폴더면 그 안을 월드 콘텐츠로, 아니면 임시 디렉터리 자체가
|
||||
// 월드 콘텐츠(level.dat 등이 루트). 어느 쪽이든 결과적으로 saves/<퀴즈이름>/ 로.
|
||||
const entries = await fsp.readdir(tempExtractDir)
|
||||
let sourceDir = tempExtractDir
|
||||
if (entries.length === 1) {
|
||||
const candidate = path.join(tempExtractDir, entries[0])
|
||||
const stat = await fsp.stat(candidate).catch(() => null)
|
||||
if (stat?.isDirectory()) sourceDir = candidate
|
||||
}
|
||||
|
||||
const desired = sanitizeMapFolderName(pack.name)
|
||||
if (desired !== original) {
|
||||
// 사용자 월드와 이름이 겹치면 덮어쓰지 않고 _2, _3 … 으로 회피.
|
||||
// 사용자가 직접 만든 동명 월드와 겹치면 덮어쓰지 않고 _2, _3 … 으로 회피.
|
||||
let target = desired
|
||||
let suffix = 2
|
||||
while (before.has(target) || fs.existsSync(path.join(savesDir, target))) {
|
||||
while (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 }))
|
||||
const targetDir = path.join(savesDir, target)
|
||||
await fsp.rename(sourceDir, targetDir)
|
||||
sendLog(t('log.mapInstalledAs', { name: target }))
|
||||
await writeInstallerMapMarker(customRoot, [target])
|
||||
} finally {
|
||||
// sourceDir 가 tempExtractDir 자체였다면 rename 으로 사라졌고, 단일 하위 폴더였다면
|
||||
// 비어 있는 껍데기만 남아 있다. 어느 경우든 안전하게 정리.
|
||||
await fsp.rm(tempExtractDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await writeInstallerMapMarker(customRoot, markerEntries)
|
||||
}
|
||||
|
||||
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 })
|
||||
|
||||
// 바닐라 팩(modsFolder 비어 있음)은 모드 자체와 무관하므로 기존 mods/ 를 건드리지
|
||||
// 않는다 — 사용자가 다른 곳에서 받아 둔 모드까지 지워버리는 부작용 방지.
|
||||
if (!pack.modsFolder) {
|
||||
sendLog(t('log.skipModsFolder'))
|
||||
return
|
||||
}
|
||||
const modsDir = path.join(customRoot, 'mods')
|
||||
// 모드팩인 경우엔 이전 버전/이전 팩 모드가 섞이면 로딩이 실패하므로 매번 비우고 받는다.
|
||||
sendLog(t('log.clearMods', { dir: modsDir }))
|
||||
await fsp.rm(modsDir, { recursive: true, force: true })
|
||||
await fsp.mkdir(modsDir, { recursive: true })
|
||||
const indexUrl = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/index.json`
|
||||
sendLog(t('log.modsIndexFetch', { url: indexUrl }))
|
||||
const listing = await fetchJson<{ files?: unknown }>(indexUrl)
|
||||
|
||||
Reference in New Issue
Block a user