fabric: 로더 버전 선택 + fabric-installer CLI 자동 설치

관리 사이트에서 모드 플랫폼으로 fabric 을 선택하면 jar 파일 업로드 대신, 선택한 마인크래프트 버전을 기준으로 Fabric Meta v2 API 에서 호환 로더 목록을 가져와 드롭다운으로 선택하도록 했다. 설치기는 platform.loaderVersion 만 보고 최신 fabric-installer.jar 를 받아 CLI 로 자동 설치(GUI 미표시)한다.

스키마:
- PackPlatform 에 loaderVersion?: string 추가. fabric 일 때만 사용.
- normalizePackDefinition: fabric 이면 downloadUrl 무시하고 loaderVersion 만 저장, 그 외에는 기존 downloadUrl 유지.

웹 UI(views/op/editor.ejs):
- platformType 이 fabric 일 때 platformLoaderVersion select 노출. mcVersion 셀렉트 값을 가지고 https://meta.fabricmc.net/v2/versions/loader/<mcVersion> 호출.
- mcVersion 또는 platformType 변경 시 자동 재조회. 동시 요청 경쟁은 sequence 비교로 무시.
- 이전 저장값을 우선 선택하되 목록에 없으면 최신 stable 자동 선택.
- 폼 제출 시 fabric 인데 로더 미선택이면 경고.
- 라우트(op.ts): platformLoaderVersion 폼 필드 수신.

설치기(installer/main.ts):
- client:install 분기 추가. fabric 이면 installFabricLoader 호출.
- installFabricLoader: Fabric Meta installer 메타 조회 → 최신 stable installer jar 캐시 다운로드 → java -jar fabric-installer.jar client -mcversion <ver> -loader <ver> -dir <.mc_custom> -noprofile 실행. launcher_profiles 갱신은 우리 코드(updateLauncherProfile)가 담당하므로 -noprofile.
- findJavaExecutable: JAVA_HOME → .minecraft\runtime 의 번들 자바(델타/감마/베타 등 우선순위) → PATH 폴백.
- runJavaProcess: stdout/stderr 를 로그 뷰어에 prefix 와 함께 스트리밍. 실패 시 stderr 끝부분을 메시지에 포함.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 01:28:45 +09:00
parent 536e94474f
commit 7d0f1719f3
5 changed files with 229 additions and 5 deletions

View File

@@ -780,7 +780,9 @@ 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 })
if (payload.installPlatform && pack.pack.platform.type !== 'vanilla' && pack.pack.platform.downloadUrl) {
if (payload.installPlatform && pack.pack.platform.type === 'fabric') {
await installFabricLoader(pack.pack, customRoot)
} else if (payload.installPlatform && pack.pack.platform.type !== 'vanilla' && pack.pack.platform.downloadUrl) {
const platformUrl = resolveManifestRelative(pack.pack.platform.downloadUrl, 'platforms')
const cacheDir = path.join(customRoot, 'platform-cache')
await fsp.mkdir(cacheDir, { recursive: true })
@@ -804,6 +806,133 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) =
await updateLauncherProfile(pack.pack, customRoot)
})
interface FabricInstallerMeta {
url: string
version: string
stable: boolean
}
async function installFabricLoader(pack: PackDefinition, customRoot: string): Promise<void> {
const loaderVersion = pack.platform.loaderVersion
if (!loaderVersion) {
throw new Error('Fabric 로더 버전이 음악퀴즈에 지정되지 않았습니다. 관리 사이트에서 platform.loaderVersion 을 설정해 주세요.')
}
// 1) 최신 fabric-installer 메타데이터 조회.
sendLog('Fabric installer 최신 버전 조회 중...')
const installerList = await fetchJson<FabricInstallerMeta[]>('https://meta.fabricmc.net/v2/versions/installer')
if (!installerList || installerList.length === 0) {
throw new Error('Fabric installer 목록을 받지 못했습니다.')
}
const latest = installerList.find((item) => item.stable) || installerList[0]
sendLog(`Fabric installer ${latest.version} 다운로드: ${latest.url}`)
// 2) installer jar 캐시.
const cacheDir = path.join(customRoot, 'platform-cache')
await fsp.mkdir(cacheDir, { recursive: true })
const installerJar = path.join(cacheDir, `fabric-installer-${latest.version}.jar`)
await downloadFile(latest.url, installerJar)
// 3) Java 실행파일 확보.
const javaCmd = await findJavaExecutable()
sendLog(`Java 사용: ${javaCmd}`)
// 4) fabric-installer CLI 자동 실행.
// client 모드 + -noprofile: launcher_profiles.json 은 우리 코드가 직접 갱신하므로 fabric-installer 가 덮어쓰지 않게 한다.
const args = [
'-jar', installerJar,
'client',
'-mcversion', pack.mcVersion,
'-loader', loaderVersion,
'-dir', customRoot,
'-noprofile'
]
sendLog(`Fabric 자동 설치 시작: ${pack.mcVersion} / loader ${loaderVersion}${customRoot}`)
await runJavaProcess(javaCmd, args)
sendLog('Fabric 자동 설치 완료.')
}
async function findJavaExecutable(): Promise<string> {
const javaName = process.platform === 'win32' ? 'java.exe' : 'java'
// 1) JAVA_HOME 우선.
const javaHome = process.env.JAVA_HOME
if (javaHome) {
const exe = path.join(javaHome, 'bin', javaName)
if (fs.existsSync(exe)) return exe
}
// 2) 마인크래프트 런처가 번들한 자바 런타임. .minecraft\runtime\<name>\<os>\<name>\bin\java.exe 구조.
try {
const runtimeBase = path.join(getAppDataDir(), '.minecraft', 'runtime')
if (fs.existsSync(runtimeBase)) {
const priority = [
'java-runtime-delta',
'java-runtime-gamma',
'java-runtime-beta',
'java-runtime-alpha',
'java-runtime-legacy',
'jre-legacy'
]
const names = await fsp.readdir(runtimeBase)
const sorted = names.slice().sort((a, b) => {
const ia = priority.indexOf(a)
const ib = priority.indexOf(b)
if (ia === -1 && ib === -1) return 0
if (ia === -1) return 1
if (ib === -1) return -1
return ia - ib
})
for (const name of sorted) {
const dir = path.join(runtimeBase, name)
try {
const osDirs = await fsp.readdir(dir)
for (const osDir of osDirs) {
const exe = path.join(dir, osDir, name, 'bin', javaName)
if (fs.existsSync(exe)) return exe
}
} catch {
// skip
}
}
}
} catch {
// skip
}
// 3) PATH 폴백.
return javaName
}
function runJavaProcess(cmd: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
let stderrTail = ''
const emitLines = (chunk: Buffer, prefix: string) => {
const text = chunk.toString('utf8')
text.split(/\r?\n/).forEach((line) => {
if (line.trim().length === 0) return
sendLog(` ${prefix} ${line}`)
})
}
child.stdout?.on('data', (chunk: Buffer) => emitLines(chunk, '[fabric]'))
child.stderr?.on('data', (chunk: Buffer) => {
stderrTail += chunk.toString('utf8')
if (stderrTail.length > 4000) stderrTail = stderrTail.slice(-4000)
emitLines(chunk, '[fabric-err]')
})
child.on('error', (err) => reject(new Error(`Java 실행 실패: ${err.message}`)))
child.on('close', (code) => {
if (code === 0) {
resolve()
} else {
const detail = stderrTail.trim().split(/\r?\n/).slice(-3).join(' | ')
reject(new Error(`fabric-installer 종료 코드 ${code}${detail ? ' — ' + detail : ''}`))
}
})
})
}
function deriveFileName(url: string): string {
try {
const parsed = new URL(url)