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:
@@ -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)
|
||||
|
||||
@@ -265,14 +265,16 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
|
||||
|
||||
const platformType = pickFirstValue(req.body.platformType)
|
||||
const platformDownloadUrl = pickFirstValue(req.body.platformDownloadUrl).trim()
|
||||
const platformLoaderVersion = pickFirstValue(req.body.platformLoaderVersion).trim()
|
||||
|
||||
const partial: Partial<PackDefinition> & Record<string, unknown> = {
|
||||
name: pickFirstValue(req.body.displayName),
|
||||
mcVersion: pickFirstValue(req.body.mcVersion),
|
||||
platform: {
|
||||
type: (platformType as PackDefinition['platform']['type']) || 'vanilla',
|
||||
downloadUrl: platformDownloadUrl.length > 0 ? platformDownloadUrl : undefined
|
||||
},
|
||||
downloadUrl: platformDownloadUrl.length > 0 ? platformDownloadUrl : undefined,
|
||||
loaderVersion: platformLoaderVersion.length > 0 ? platformLoaderVersion : undefined
|
||||
} as PackDefinition['platform'] & { loaderVersion?: string },
|
||||
modsFolder: pickFirstValue(req.body.modsFolder),
|
||||
resourcepackPath: pickFirstValue(req.body.resourcepackPath),
|
||||
serverMinRam: Number(pickFirstValue(req.body.serverMinRam)),
|
||||
|
||||
@@ -81,8 +81,16 @@ export function normalizePackDefinition(input: Partial<PackDefinition> & Record<
|
||||
: fallback.mcVersion,
|
||||
platform: {
|
||||
type: platformType,
|
||||
downloadUrl: typeof platform.downloadUrl === 'string' && platform.downloadUrl.trim().length > 0
|
||||
// fabric 은 downloadUrl 을 쓰지 않고 loaderVersion 기반으로 자동 설치한다.
|
||||
downloadUrl: platformType !== 'fabric'
|
||||
&& typeof platform.downloadUrl === 'string'
|
||||
&& platform.downloadUrl.trim().length > 0
|
||||
? platform.downloadUrl.trim()
|
||||
: undefined,
|
||||
loaderVersion: platformType === 'fabric'
|
||||
&& typeof (platform as { loaderVersion?: unknown }).loaderVersion === 'string'
|
||||
&& ((platform as { loaderVersion?: string }).loaderVersion ?? '').trim().length > 0
|
||||
? ((platform as { loaderVersion?: string }).loaderVersion ?? '').trim()
|
||||
: undefined
|
||||
},
|
||||
modsFolder: sanitizeFolderName(input.modsFolder),
|
||||
|
||||
@@ -2,7 +2,10 @@ export type LoaderType = 'vanilla' | 'forge' | 'fabric' | 'neoforge'
|
||||
|
||||
export interface PackPlatform {
|
||||
type: LoaderType
|
||||
/** forge / neoforge 처럼 사용자가 직접 업로드한 installer jar 의 URL. */
|
||||
downloadUrl?: string
|
||||
/** fabric 의 경우 Fabric Meta 에서 선택한 로더 버전(예: "0.16.0"). 설치 시 최신 fabric-installer 를 받아 CLI 로 자동 설치. */
|
||||
loaderVersion?: string
|
||||
}
|
||||
|
||||
export interface PackDefinition {
|
||||
|
||||
@@ -52,6 +52,13 @@
|
||||
<input name="platformDownloadUrl" value="<%= pack.platform.downloadUrl || '' %>" placeholder="/forge-installer.jar 또는 https://example.com/forge-installer.jar" />
|
||||
<small class="muted">도메인 없이 입력하면 manifest.json 도메인의 <code>/file/platforms/<파일명></code>으로 해석됩니다.</small>
|
||||
</label>
|
||||
<label class="fullSpan" id="platformLoaderField" hidden>
|
||||
<span>Fabric Loader 버전</span>
|
||||
<select name="platformLoaderVersion" id="platformLoaderVersion" data-current="<%= pack.platform.loaderVersion || '' %>">
|
||||
<option value="">불러오는 중...</option>
|
||||
</select>
|
||||
<small class="muted">선택한 마인크래프트 버전 기준 Fabric Loader 목록입니다. 설치기는 최신 fabric-installer 를 받아 자동으로 CLI 설치합니다.</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>서버 최소 램 (MB)</span>
|
||||
<input type="number" name="serverMinRam" value="<%= pack.serverMinRam %>" min="512" required />
|
||||
@@ -100,18 +107,88 @@
|
||||
<script>
|
||||
(function () {
|
||||
var platformSelect = document.getElementById('platformType')
|
||||
var mcVersionSelect = document.querySelector('select[name="mcVersion"]')
|
||||
var downloadField = document.getElementById('platformDownloadField')
|
||||
var loaderField = document.getElementById('platformLoaderField')
|
||||
var loaderSelect = document.getElementById('platformLoaderVersion')
|
||||
var currentLoader = loaderSelect.getAttribute('data-current') || ''
|
||||
var loaderCache = {} // mcVersion -> [loader versions]
|
||||
var loaderFetchSeq = 0
|
||||
|
||||
function syncPlatformVisibility() {
|
||||
if (platformSelect.value === 'vanilla') {
|
||||
var type = platformSelect.value
|
||||
if (type === 'fabric') {
|
||||
loaderField.removeAttribute('hidden')
|
||||
downloadField.setAttribute('hidden', '')
|
||||
downloadField.querySelector('input').value = ''
|
||||
loadFabricLoaders()
|
||||
} else if (type === 'vanilla') {
|
||||
downloadField.setAttribute('hidden', '')
|
||||
loaderField.setAttribute('hidden', '')
|
||||
downloadField.querySelector('input').value = ''
|
||||
loaderSelect.innerHTML = '<option value=""></option>'
|
||||
} else {
|
||||
downloadField.removeAttribute('hidden')
|
||||
loaderField.setAttribute('hidden', '')
|
||||
loaderSelect.innerHTML = '<option value=""></option>'
|
||||
}
|
||||
}
|
||||
|
||||
function populateLoaderOptions(versions, preselect) {
|
||||
if (!versions || versions.length === 0) {
|
||||
loaderSelect.innerHTML = '<option value="">호환 로더 없음</option>'
|
||||
return
|
||||
}
|
||||
var html = ''
|
||||
for (var i = 0; i < versions.length; i++) {
|
||||
var v = versions[i]
|
||||
var sel = v.version === preselect ? ' selected' : ''
|
||||
var label = v.version + (v.stable ? '' : ' (beta)')
|
||||
html += '<option value="' + v.version + '"' + sel + '>' + label + '</option>'
|
||||
}
|
||||
loaderSelect.innerHTML = html
|
||||
// 사용자가 저장해둔 값이 목록에 없으면 첫 번째(최신) 자동 선택.
|
||||
if (preselect && !versions.some(function (v) { return v.version === preselect })) {
|
||||
loaderSelect.value = versions[0].version
|
||||
}
|
||||
}
|
||||
|
||||
function loadFabricLoaders() {
|
||||
var mc = (mcVersionSelect && mcVersionSelect.value) || ''
|
||||
if (!mc) {
|
||||
loaderSelect.innerHTML = '<option value="">마인크래프트 버전을 먼저 선택하세요</option>'
|
||||
return
|
||||
}
|
||||
if (loaderCache[mc]) {
|
||||
populateLoaderOptions(loaderCache[mc], currentLoader)
|
||||
return
|
||||
}
|
||||
var seq = ++loaderFetchSeq
|
||||
loaderSelect.innerHTML = '<option value="">불러오는 중...</option>'
|
||||
fetch('https://meta.fabricmc.net/v2/versions/loader/' + encodeURIComponent(mc))
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status)
|
||||
return res.json()
|
||||
})
|
||||
.then(function (list) {
|
||||
if (seq !== loaderFetchSeq) return // 더 새로운 요청이 들어왔으면 무시
|
||||
// 응답: [{ loader: { version, stable, ... }, intermediary: {...} }, ...]
|
||||
var versions = (list || []).map(function (item) {
|
||||
return { version: item.loader.version, stable: !!item.loader.stable }
|
||||
})
|
||||
loaderCache[mc] = versions
|
||||
populateLoaderOptions(versions, currentLoader)
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (seq !== loaderFetchSeq) return
|
||||
loaderSelect.innerHTML = '<option value="">로더 목록 로드 실패: ' + (err && err.message ? err.message : err) + '</option>'
|
||||
})
|
||||
}
|
||||
|
||||
platformSelect.addEventListener('change', syncPlatformVisibility)
|
||||
if (mcVersionSelect) mcVersionSelect.addEventListener('change', function () {
|
||||
if (platformSelect.value === 'fabric') loadFabricLoaders()
|
||||
})
|
||||
syncPlatformVisibility()
|
||||
|
||||
var form = document.getElementById('editorForm')
|
||||
@@ -121,6 +198,11 @@
|
||||
if (clientMin > clientReco) {
|
||||
event.preventDefault()
|
||||
alert('클라이언트 최소 램은 권장 램보다 클 수 없습니다.')
|
||||
return
|
||||
}
|
||||
if (platformSelect.value === 'fabric' && !loaderSelect.value) {
|
||||
event.preventDefault()
|
||||
alert('Fabric 로더 버전을 선택해 주세요.')
|
||||
}
|
||||
})
|
||||
})()
|
||||
|
||||
Reference in New Issue
Block a user