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/<파일명>으로 해석됩니다. +