6 Commits

Author SHA1 Message Date
bf225f51e1 installer: force fabric-installer JVM stdout to UTF-8
Korean Windows defaults the JVM's stdout to cp949 (MS949), so the
fabric-installer's Korean status lines came through as mojibake when
Node decoded them as UTF-8 (e.g. "���가져오는중 (org.ow2.asm:asm:9.9)").

Pass -Dfile.encoding/-Dstdout.encoding/-Dstderr.encoding=UTF-8 before
-jar so the JVM writes UTF-8 and our existing utf-8 decode matches.
stdout/stderr.encoding properties take effect on Java 18+;
file.encoding covers older JDKs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 21:22:44 +09:00
2371af4411 installer: clean platform-cache in finally so failures don't leak
Previous version only deleted platform-cache at the very end of the
success path. If anything between platform install and launcher
profile update failed, the cache jar stuck around. Move the rm into
a finally block so the directory is always cleaned up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 19:10:01 +09:00
1f59f6a98b installer: move yt-dlp/ffmpeg under .mc_custom/installer/, clean platform-cache
- yt-dlp.exe, ffmpeg.exe now live in %appdata%/.mc_custom/installer/ so
  the .mc_custom root stays a clean Minecraft game folder. Existing
  binaries at the old location are migrated on first run.
- After a successful install, the platform-cache (downloaded fabric /
  forge / neoforge installer jars) is deleted — it's regenerable and
  was just wasting disk space.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 19:02:01 +09:00
794ad9b778 installer-rp: rename fallback pack name musicquiz → resourcepack
Per user request: when outputPackName is empty, fall back to
`<packKey>_resourcepack` instead of `<packKey>_musicquiz`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:54:31 +09:00
f810719d92 installer-rp: site-configured outputPackName for built zip
Adds a new "생성되는 리소스팩 이름" admin field saved to the pack
manifest and consumed by the rp installer when naming the final zip.
Empty value falls back to <packKey>_musicquiz; Windows-invalid chars
are sanitized to '_'. Bumps version 0.1.1 → 0.2.0 (new feature).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:34:46 +09:00
ae771668de installer-rp: ship installer/styles.css so packaged UI renders
The rp installer's `index.html` references `../installer/styles.css`,
which works in dev because both source directories sit side by side.
The packaged exe's `files` list only included `installer-rp/**`, so
inside the asar the stylesheet path resolved to nothing and the UI
rendered completely unstyled (per user screenshot).

Add the single shared file `installer/styles.css` to the rp build's
file list. The cross-directory `<link>` reference now resolves inside
the asar, and we avoid duplicating the stylesheet.

Bump to 0.1.1 — small patch-level fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:14:42 +09:00
13 changed files with 164 additions and 44 deletions

View File

@@ -11,6 +11,10 @@ files:
- dist/installer-rp/**
- dist/shared/**
- installer-rp/**
# rp 의 index.html 은 메인 설치기와 동일한 styles.css 를 공유함
# (`<link href="../installer/styles.css">`). asar 안에 해당 파일이 없으면
# UI 가 무스타일로 렌더링되므로 그 한 파일만 명시적으로 포함.
- installer/styles.css
- build/icon.*
- package.json
# sharp 는 플랫폼별 prebuilt 가 분리 패키지로 배포됨. Windows 빌드에서는

View File

@@ -124,8 +124,11 @@
"serverPathHint": "/file/servers/ 아래 zip 파일 이름. 멀티 모드 전용.",
"modsFolder": "모드 폴더 이름",
"modsFolderHint": "/file/mods/&lt;폴더이름&gt;/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.",
"resourcepackPath": "리소스팩 (.zip)",
"resourcepackHint": "/file/resourcepacks/ 아래 .zip 파일 이름. 비워두면 리소스팩을 받지 않습니다.",
"resourcepackPath": "베이스 리소스팩 (.zip)",
"resourcepackHint": "/file/resourcepacks/ 아래 .zip 파일 이름. 리소스팩 설치기가 이 zip 위에 음악·사진을 얹어 최종 리소스팩을 만듭니다. 비워두면 처음부터 새로 만듭니다.",
"outputPackName": "생성되는 리소스팩 이름",
"outputPackNamePlaceholder": "예: 음악퀴즈 테스트팩",
"outputPackNameHint": "리소스팩 설치기가 만들어 내는 zip 파일 이름이자, 마인크래프트 리소스팩 목록의 제목이 됩니다. 비워두면 파일이름_resourcepack 형태로 자동 지정됩니다. Windows 파일명 금지 문자(\\ / : * ? \" &lt; &gt; |)는 자동으로 _ 로 바뀝니다.",
"ramOrderInvalid": "클라이언트 최소 램은 권장 램보다 클 수 없습니다.",
"fabricLoaderRequired": "Fabric 로더 버전을 선택해 주세요."
},

View File

@@ -1,6 +1,6 @@
{
"name": "minecraft-music-quiz-installer",
"version": "0.1.0",
"version": "0.2.4",
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
"main": "dist/installer/main.js",
"scripts": {

View File

@@ -3,7 +3,7 @@ import { promises as fs, createWriteStream, constants as fsConst } from 'node:fs
import path from 'node:path'
import https from 'node:https'
import http from 'node:http'
import { getMcCustomDir } from '../shared/paths.js'
import { getMcCustomDir, getMcCustomInstallerDir } from '../shared/paths.js'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
@@ -13,10 +13,30 @@ const extractZip: (source: string, options: { dir: string }) => Promise<void> =
/**
* 리소스팩 간편설치기는 Windows .exe 로 배포되므로 ffmpeg.exe 한 종류만 사용.
* 경로: %appdata%/.mc_custom/ffmpeg.exe
* 경로: %appdata%/.mc_custom/installer/ffmpeg.exe
*/
export function getFfmpegExePath(): string {
return path.join(getMcCustomDir(), 'ffmpeg.exe')
return path.join(getMcCustomInstallerDir(), 'ffmpeg.exe')
}
/**
* 0.2.1 이전 버전이 `.mc_custom/ffmpeg.exe` 에 받아둔 파일이 있으면 새 위치로
* 옮긴다.
*/
async function migrateLegacyExe(target: string): Promise<void> {
const legacy = path.join(getMcCustomDir(), 'ffmpeg.exe')
if (legacy === target) return
try {
await fs.access(legacy, fsConst.F_OK)
} catch {
return
}
try {
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.rename(legacy, target)
} catch {
try { await fs.unlink(legacy) } catch { /* noop */ }
}
}
/** BtbN/FFmpeg-Builds 의 win64-gpl 빌드. zip 내부에 bin/ffmpeg.exe 가 들어 있음. */
@@ -33,6 +53,7 @@ export async function ensureFfmpegExe(
log?: (line: string) => void
): Promise<string> {
const target = getFfmpegExePath()
await migrateLegacyExe(target)
if (await canExecute(target)) {
log?.(t('log.ffmpegExists', { path: target }))
return target
@@ -40,7 +61,7 @@ export async function ensureFfmpegExe(
if (installPromise) return installPromise
installPromise = (async () => {
const dir = getMcCustomDir()
const dir = getMcCustomInstallerDir()
const zipPath = path.join(dir, '.tmp_ffmpeg.zip')
const extractDir = path.join(dir, '.tmp_ffmpeg')
try {

View File

@@ -35,6 +35,20 @@ interface RpInstallerState {
activeChildren: Set<ChildProcess>
}
/**
* 사용자가 사이트에서 지정한 "생성되는 리소스팩 이름" 을 Windows 파일명으로 쓸 수
* 있게 정리한다. 금지 문자(\<\>:"/\\|?*\x00-\x1f) 는 `_` 로, 끝의 공백/마침표는
* 제거, 예약어(CON/PRN/...)는 앞에 `_` 를 붙인다. 빈 입력은 빈 문자열 반환 →
* 호출 측에서 폴백을 결정한다.
*/
function sanitizeOutputPackName(name: string): string {
let cleaned = (name || '').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
cleaned = cleaned.replace(/[ .]+$/, '')
if (!cleaned) return ''
if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(cleaned)) cleaned = '_' + cleaned
return cleaned
}
/**
* 동시 yt-dlp 프로세스 수를 CPU 코어 수로 자동 결정.
* - yt-dlp + ffmpeg 변환이 CPU 바운드라 코어 수가 가장 좋은 프록시.
@@ -201,11 +215,13 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
const normalized = packRaw ? normalizePackDefinition(packRaw as Partial<PackDefinition>) : null
const mcVersion = normalized?.mcVersion ?? ''
const resourcepackPath = normalized?.resourcepackPath ?? ''
const outputPackName = normalized?.outputPackName ?? ''
results.push({
key: entry.file,
name: entry.name || entry.file,
mcVersion,
resourcepackPath,
outputPackName,
list
})
} catch (error) {
@@ -383,7 +399,11 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
// 2-5. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지, 베이스 위에 얹기)
throwIfCancelled()
const resourcepackName = `${state.selectedKey}_musicquiz.zip`
// 사이트에서 지정한 "생성되는 리소스팩 이름" 을 우선 사용. 비어있거나 sanitize
// 결과가 빈 문자열이면 `<packKey>_resourcepack` 로 폴백.
const sanitizedOutputName = sanitizeOutputPackName(pack.outputPackName)
const resourcepackBaseName = sanitizedOutputName || `${state.selectedKey}_resourcepack`
const resourcepackName = `${resourcepackBaseName}.zip`
const resourcepackDir = path.join(getMcCustomDir(), 'resourcepacks')
const resourcepackPath = path.join(resourcepackDir, resourcepackName)
sendLog(t('log.buildingZip', { name: resourcepackName }))

View File

@@ -10,6 +10,12 @@ export interface RpFetchedPack {
* 빈 문자열이면 새 리소스팩을 처음부터 생성.
*/
resourcepackPath: string
/**
* /manifest/<key>.json 의 outputPackName. 관리 사이트에서 설정한 "생성되는
* 리소스팩 이름". 비어 있으면 설치기가 `<key>_resourcepack` 형식으로 폴백.
* 파일명으로 쓰기 전에 Windows 금지 문자(\<\>:"/\\|?*) 는 `_` 로 치환.
*/
outputPackName: string
/** /file/list/<key>.json 의 음악·사진 목록. */
list: PackList
}

View File

@@ -3,17 +3,38 @@ import { promises as fs, createWriteStream, constants as fsConst } from 'node:fs
import path from 'node:path'
import https from 'node:https'
import http from 'node:http'
import { getMcCustomDir } from '../shared/paths.js'
import { getMcCustomDir, getMcCustomInstallerDir } from '../shared/paths.js'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
/**
* 리소스팩 간편설치기는 Windows .exe 로 배포되므로 yt-dlp.exe 한 종류만 사용.
* 경로: %appdata%/.mc_custom/yt-dlp.exe
* 경로: %appdata%/.mc_custom/installer/yt-dlp.exe
*/
export function getYtDlpExePath(): string {
return path.join(getMcCustomDir(), 'yt-dlp.exe')
return path.join(getMcCustomInstallerDir(), 'yt-dlp.exe')
}
/**
* 0.2.1 이전 버전이 `.mc_custom/yt-dlp.exe` 에 받아둔 파일이 있으면 새 위치로
* 옮긴다. 마인크래프트 게임 폴더 루트가 외부 도구 파일로 더럽혀지지 않도록.
*/
async function migrateLegacyExe(target: string): Promise<void> {
const legacy = path.join(getMcCustomDir(), 'yt-dlp.exe')
if (legacy === target) return
try {
await fs.access(legacy, fsConst.F_OK)
} catch {
return
}
try {
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.rename(legacy, target)
} catch {
// 권한·드라이브 문제 등으로 실패하면 그냥 새로 받으면 되므로 무시.
try { await fs.unlink(legacy) } catch { /* noop */ }
}
}
const YT_DLP_DOWNLOAD_URL =
@@ -29,6 +50,7 @@ export async function ensureYtDlpExe(
log?: (line: string) => void
): Promise<string> {
const target = getYtDlpExePath()
await migrateLegacyExe(target)
if (await canExecute(target)) {
log?.(t('log.ytdlpExists', { path: target }))
return target

View File

@@ -1128,6 +1128,7 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) =
await fsp.mkdir(path.join(customRoot, 'mods'), { recursive: true })
await fsp.mkdir(path.join(customRoot, 'resourcepacks'), { recursive: true })
try {
// 사용자가 기존 .minecraft 에 저장해둔 설정(options.txt, servers.dat 등)을
// .mc_custom 으로 가져온다. 이미 있는 파일은 보존.
await copyMinecraftUserSettings(customRoot)
@@ -1163,6 +1164,13 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) =
await linkMinecraftRuntimeDirs(customRoot)
await updateLauncherProfile(pack.pack, customRoot)
} finally {
// 설치가 끝나면(또는 실패해도) 더 이상 필요 없는 platform-cache(다운받은
// fabric/forge/neoforge installer jar 캐시)를 삭제한다. 다음 실행에서 다시
// 받으면 되고, 남겨두면 사용자 .mc_custom 폴더만 차지한다. 실패 경로에서도
// 정리되도록 finally 에 둔다.
await fsp.rm(path.join(customRoot, 'platform-cache'), { recursive: true, force: true }).catch(() => {})
}
})
interface FabricInstallerMeta {
@@ -1211,7 +1219,16 @@ async function installFabricLoader(pack: PackDefinition, customRoot: string): Pr
// 4) fabric-installer CLI 자동 실행.
// client 모드 + -noprofile: launcher_profiles.json 은 우리 코드가 직접 갱신하므로 fabric-installer 가 덮어쓰지 않게 한다.
// JVM stdout 인코딩 강제 UTF-8:
// 한국 윈도우의 시스템 codepage 는 cp949(MS949) 라서 fabric-installer 가
// 한글을 cp949 로 stdout 에 쓰면 우리가 utf-8 로 디코드해서 깨져 보인다.
// `file.encoding` 은 default Charset, `stdout/stderr.encoding` 은
// System.out/err 의 PrintStream 인코딩(Java 18+). 둘 다 지정하면
// 구버전·신버전 JDK 모두에서 안전.
const args = [
'-Dfile.encoding=UTF-8',
'-Dstdout.encoding=UTF-8',
'-Dstderr.encoding=UTF-8',
'-jar', installerJar,
'client',
'-mcversion', pack.mcVersion,

View File

@@ -314,6 +314,7 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
} as PackDefinition['platform'] & { loaderVersion?: string },
modsFolder: pickFirstValue(req.body.modsFolder),
resourcepackPath: pickFirstValue(req.body.resourcepackPath),
outputPackName: pickFirstValue(req.body.outputPackName),
serverMinRam: Number(pickFirstValue(req.body.serverMinRam)),
serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)),
clientMinRam: Number(pickFirstValue(req.body.clientMinRam)),

View File

@@ -32,3 +32,13 @@ export function getAppDataDir(): string {
export function getMcCustomDir(): string {
return path.join(getAppDataDir(), '.mc_custom')
}
/**
* %appdata%/.mc_custom/installer — 설치기가 자체적으로 다운로드해 사용하는
* 외부 바이너리(yt-dlp.exe, ffmpeg.exe 등) 보관 위치. .mc_custom 루트가
* 마인크래프트 게임 폴더(`mods/`, `resourcepacks/`, `saves/` 등)와 섞이지
* 않도록 별도 하위 폴더에 둔다.
*/
export function getMcCustomInstallerDir(): string {
return path.join(getMcCustomDir(), 'installer')
}

View File

@@ -37,6 +37,7 @@ export function defaultPackDefinition(name: string): PackDefinition {
platform: { type: 'vanilla' },
modsFolder: '',
resourcepackPath: '',
outputPackName: '',
serverMinRam: 2048,
serverMaxRam: 4096,
clientMinRam: 2048,
@@ -95,6 +96,8 @@ export function normalizePackDefinition(input: Partial<PackDefinition> & Record<
},
modsFolder: sanitizeFolderName(input.modsFolder),
resourcepackPath: sanitizeZipFileName(input.resourcepackPath),
// 표시명은 사용자 입력을 보존(공백/마침표 trim 만). 파일명 안전 처리는 설치기 측에서.
outputPackName: typeof input.outputPackName === 'string' ? input.outputPackName.trim() : '',
serverMinRam: clampNumber(input.serverMinRam, fallback.serverMinRam),
serverMaxRam: clampNumber(input.serverMaxRam, fallback.serverMaxRam),
clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam),

View File

@@ -16,6 +16,14 @@ export interface PackDefinition {
modsFolder: string
/** /file/resourcepacks/<resourcepackPath> 의 단일 .zip을 그대로 다운로드. */
resourcepackPath: string
/**
* 리소스팩 설치기가 만들어 내는 최종 zip 파일의 이름(확장자 제외).
* 빈 문자열이면 설치기가 `<packKey>_resourcepack` 형식으로 기본 이름을 만든다.
* 마인크래프트 리소스팩 목록에서 사용자에게 제목처럼 보이는 값이므로
* 한글 등 자유 입력을 그대로 보존하고, 파일 시스템에서 사용할 때 금지 문자만
* `_` 로 치환한다(치환 책임은 설치기 측에 있음).
*/
outputPackName: string
serverMinRam: number
serverMaxRam: number
clientMinRam: number

View File

@@ -98,6 +98,11 @@
<input name="resourcepackPath" value="<%= pack.resourcepackPath %>" placeholder="my-pack.zip" pattern=".*\.zip|" />
<small class="muted"><%= t('editor.resourcepackHint') %></small>
</label>
<label class="fullSpan">
<span><%= t('editor.outputPackName') %></span>
<input name="outputPackName" value="<%= pack.outputPackName %>" placeholder="<%= t('editor.outputPackNamePlaceholder') %>" />
<small class="muted"><%= t('editor.outputPackNameHint') %></small>
</label>
</div>
<button class="primaryButton" type="submit"><%= t('common.save') %></button>