diff --git a/build/icon.ico b/build/icon.ico new file mode 100644 index 0000000..f48a121 Binary files /dev/null and b/build/icon.ico differ diff --git a/build/icon.png b/build/icon.png new file mode 100644 index 0000000..7cf98b1 Binary files /dev/null and b/build/icon.png differ diff --git a/electron-builder-rp.yml b/electron-builder-rp.yml new file mode 100644 index 0000000..e9f79e9 --- /dev/null +++ b/electron-builder-rp.yml @@ -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 diff --git a/electron-builder.yml b/electron-builder.yml index fab4245..4caa984 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -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 diff --git a/locales/installer/ko-kr.json b/locales/installer/ko-kr.json index a395974..d8eaa7f 100644 --- a/locales/installer/ko-kr.json +++ b/locales/installer/ko-kr.json @@ -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}}", diff --git a/package.json b/package.json index d6ad284..ea860cf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/installer-rp/main.ts b/src/installer-rp/main.ts index 907d497..ad61cbd 100644 --- a/src/installer-rp/main.ts +++ b/src/installer-rp/main.ts @@ -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, diff --git a/src/installer/main.ts b/src/installer/main.ts index dc0dd2a..6d71572 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -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(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()) { - 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 })) - } - } + // 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) + // 사용자가 직접 만든 동명 월드와 겹치면 덮어쓰지 않고 _2, _3 … 으로 회피. + let target = desired + let suffix = 2 + while (fs.existsSync(path.join(savesDir, target))) { + target = `${desired}_${suffix}` + suffix++ + } + 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 { - 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)