From 7d0f1719f32511568686b54d6209d4a960d3d7a5 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 13 May 2026 01:28:45 +0900 Subject: [PATCH] =?UTF-8?q?fabric:=20=EB=A1=9C=EB=8D=94=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EC=84=A0=ED=83=9D=20+=20fabric-installer=20CLI=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리 사이트에서 모드 플랫폼으로 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 또는 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 -loader -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 --- src/installer/main.ts | 131 +++++++++++++++++++++++++++++++++++++++- src/server/routes/op.ts | 6 +- src/shared/store.ts | 10 ++- src/shared/types.ts | 3 + views/op/editor.ejs | 84 +++++++++++++++++++++++++- 5 files changed, 229 insertions(+), 5 deletions(-) diff --git a/src/installer/main.ts b/src/installer/main.ts index ee2a55b..22517e2 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -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 { + const loaderVersion = pack.platform.loaderVersion + if (!loaderVersion) { + throw new Error('Fabric 로더 버전이 음악퀴즈에 지정되지 않았습니다. 관리 사이트에서 platform.loaderVersion 을 설정해 주세요.') + } + + // 1) 최신 fabric-installer 메타데이터 조회. + sendLog('Fabric installer 최신 버전 조회 중...') + const installerList = await fetchJson('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 { + 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\\\\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 { + 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) diff --git a/src/server/routes/op.ts b/src/server/routes/op.ts index 7c02312..53f7a48 100644 --- a/src/server/routes/op.ts +++ b/src/server/routes/op.ts @@ -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 & Record = { 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)), diff --git a/src/shared/store.ts b/src/shared/store.ts index 3304002..97f2956 100644 --- a/src/shared/store.ts +++ b/src/shared/store.ts @@ -81,8 +81,16 @@ export function normalizePackDefinition(input: Partial & 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), diff --git a/src/shared/types.ts b/src/shared/types.ts index 9f06572..e0b0eb6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 { diff --git a/views/op/editor.ejs b/views/op/editor.ejs index a5788c8..81f876d 100644 --- a/views/op/editor.ejs +++ b/views/op/editor.ejs @@ -52,6 +52,13 @@ 도메인 없이 입력하면 manifest.json 도메인의 /file/platforms/<파일명>으로 해석됩니다. +