diff --git a/docs/installer.md b/docs/installer.md index 6fe7365..6c9c835 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -78,7 +78,7 @@ 2. `.minecraft` 최상위 설정 파일(`options.txt`, `optionsof.txt`, `servers.dat`, `usercache.json`, …) 을 `.mc_custom` 으로 복사. 이미 같은 이름이 있으면 보존. 3. 플랫폼 설치: - `vanilla` → 건너뜀. - - `fabric` → Adoptium 자동 설치 → 최신 fabric-installer.jar 다운로드 → `java -jar fabric-installer.jar client -mcversion X -loader Y -dir .minecraft -noprofile` 자동 실행. (fabric 버전/라이브러리는 런처 폴더 `.minecraft` 에 바닐라와 함께 설치해야 런처가 찾을 수 있다. `.mc_custom` 에 설치하면 바닐라 버전·라이브러리가 빠져 "Unable to prepare assets for download" 로 실패한다.) + - `fabric` → fabric-installer(자바 필요) 를 돌리지 않고, fabric meta 에서 버전 프로필 JSON 을 직접 받아 `.minecraft\versions\fabric-loader-Y-X\fabric-loader-Y-X.json` 으로 저장한다(`https://meta.fabricmc.net/v2/versions/loader///profile/json`). 자바가 없어도 되므로 "spawn java ENOENT" 나 바닐라 선행 실행이 필요 없다. 라이브러리는 프로필 JSON 에 적힌 maven url 을 보고 런처가 실행 시 자동으로 받는다. (fabric 버전/라이브러리는 런처 폴더 `.minecraft` 에 있어야 런처가 찾으며, `.mc_custom` 에 두면 "Unable to prepare assets for download" 로 실패한다.) - `forge` / `neoforge` → `platform.downloadUrl` 의 설치 jar 다운로드(사용자가 직접 실행하거나 마인크래프트 런처가 인식). 4. `modsFolder` 의 모든 `.jar` 와 `resourcepackPath` zip, `mapPath` zip 을 자동 다운로드. 5. `.minecraft\{assets,libraries,versions}` 를 `.mc_custom\{assets,libraries,versions}` 로 junction 링크. (없으면 "Unable to prepare assets for download" 오류로 마인크래프트가 실패하기 때문) diff --git a/locales/installer/ko-kr.json b/locales/installer/ko-kr.json index 7d09c81..f839ba6 100644 --- a/locales/installer/ko-kr.json +++ b/locales/installer/ko-kr.json @@ -212,6 +212,7 @@ "fabricInstallerExit": "fabric-installer 종료 코드 {{code}}{{detail}}", "fabricLoaderRequired": "Fabric 로더 버전이 음악퀴즈에 지정되지 않았습니다. 관리 사이트에서 platform.loaderVersion 을 설정해 주세요.", "fabricInstallerListEmpty": "Fabric installer 목록을 받지 못했습니다.", + "fabricProfileInvalid": "Fabric 버전 프로필을 읽지 못했습니다: {{message}}", "portAllocFail": "포트를 할당할 수 없습니다.", "parseResponseFailed": "응답 파싱 실패: {{snippet}}" }, @@ -280,6 +281,7 @@ "platformSaved": "플랫폼 설치파일 저장: {{path}} (사용자가 직접 실행하거나 마인크래프트 런처에서 인식할 수 있습니다.)", "platformSkipped": "플랫폼 설치 건너뜀. 바닐라로 진행합니다.", "fabricFetchInstallerList": "Fabric installer 최신 버전 조회 중...", + "fabricProfileFetch": "Fabric 버전 프로필 다운로드: {{url}}", "fabricInstallerDownload": "Fabric installer {{version}} 다운로드: {{url}}", "javaUsed": "Java 사용: {{path}}", "fabricInstallStart": "Fabric 자동 설치 시작: {{mc}} / loader {{loader}} → {{dir}}", diff --git a/package.json b/package.json index d3564bc..339cc7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minecraft-music-quiz-installer", - "version": "0.4.25", + "version": "0.4.26", "description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트", "main": "dist/installer/main.js", "scripts": { diff --git a/src/installer/main.ts b/src/installer/main.ts index ebed190..f737301 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -1401,7 +1401,7 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) = await copyMinecraftUserSettings(customRoot) if (payload.installPlatform && pack.pack.platform.type === 'fabric') { - await installFabricLoader(pack.pack, customRoot) + await installFabricLoader(pack.pack) } 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') @@ -1446,182 +1446,47 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) = } }) -interface FabricInstallerMeta { - url: string - version: string - stable: boolean -} - -async function installFabricLoader(pack: PackDefinition, customRoot: string): Promise { +async function installFabricLoader(pack: PackDefinition): Promise { const loaderVersion = pack.platform.loaderVersion if (!loaderVersion) { throw new Error(t('errors.fabricLoaderRequired')) } - // 0) 이미 설치돼 있으면 건너뛴다. fabric-installer 는 매번 jar 를 지우고 - // 다시 쓰려고 시도해서, 마인크래프트나 다른 프로세스가 그 파일을 잡고 - // 있으면 FileSystemException 으로 실패한다. 결과 파일이 그대로 있으면 - // 재실행할 필요가 없으므로 그냥 통과. - // fabric 은 .minecraft(런처 폴더)에 설치한다. gameDir(.mc_custom)에 설치하면 fabric 이 - // versions/ 와 libraries/ 를 거기에 실제 폴더로 만드는데, 이 폴더에는 fabric 자체 것만 - // 들어가고 바닐라 마인크래프트의 버전 JSON·라이브러리는 빠져 있다. 그러면 .minecraft 로의 - // 링크가 "이미 폴더 있음" 으로 건너뛰어져, 런처가 바닐라 데이터를 찾지 못하고 - // "Unable to prepare assets for download" 로 실패한다. 그래서 .minecraft 에 설치해 - // 바닐라와 같은 위치에 두고, 링크는 뒤(linkMinecraftRuntimeDirs)에서 성공하게 한다. + // fabric 은 .minecraft(런처 폴더)에 설치한다. gameDir(.mc_custom)에 설치하면 바닐라 버전/ + // 라이브러리가 빠져 런처가 "Unable to prepare assets for download" 로 실패한다. const mcRoot = path.join(getAppDataDir(), '.minecraft') - await fsp.mkdir(mcRoot, { recursive: true }) const versionId = `fabric-loader-${loaderVersion}-${pack.mcVersion}` const versionDir = path.join(mcRoot, 'versions', versionId) const versionJson = path.join(versionDir, `${versionId}.json`) - // 최신 fabric 은 버전 폴더에 .json 만 만든다(.jar 없음). json 존재를 설치 완료 기준으로 본다. if (fs.existsSync(versionJson)) { sendLog(t('log.fabricAlreadyInstalled', { id: versionId, dir: versionDir })) return } - // 1) 최신 fabric-installer 메타데이터 조회. - sendLog(t('log.fabricFetchInstallerList')) - const installerList = await fetchJson('https://meta.fabricmc.net/v2/versions/installer') - if (!installerList || installerList.length === 0) { - throw new Error(t('errors.fabricInstallerListEmpty')) - } - const latest = installerList.find((item) => item.stable) || installerList[0] - sendLog(t('log.fabricInstallerDownload', { version: latest.version, url: 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(t('log.javaUsed', { path: javaCmd })) - - // 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, - '-loader', loaderVersion, - '-dir', mcRoot, - '-noprofile' - ] - sendLog(t('log.fabricInstallStart', { mc: pack.mcVersion, loader: loaderVersion, dir: mcRoot })) + // fabric-installer(자바 필요) 를 돌리는 대신, fabric meta 에서 버전 프로필 JSON 을 직접 + // 받아 versions//.json 에 쓴다. 자바가 없어도 되므로 "spawn java ENOENT" 가 + // 사라지고, 바닐라를 미리 실행할 필요도 없다. 라이브러리는 프로필 JSON 의 각 library 에 + // 적힌 maven url 을 보고 런처가 실행 시 자동으로 받는다. + const profileUrl = + `https://meta.fabricmc.net/v2/versions/loader/${encodeURIComponent(pack.mcVersion)}/${encodeURIComponent(loaderVersion)}/profile/json` + sendLog(t('log.fabricProfileFetch', { url: profileUrl })) + const buf = await fetchBuffer(profileUrl) + let profileId = '' try { - await runJavaProcess(javaCmd, args) + const parsed = JSON.parse(buf.toString('utf8')) as { id?: unknown } + if (typeof parsed.id === 'string') profileId = parsed.id } catch (err) { - // 자바를 못 찾아 spawn ENOENT 면, 대개 바닐라를 한 번도 실행하지 않아 런처 자바 런타임 - // (.minecraft/runtime)이 없고 다른 자바도 없는 경우다. 해당 버전으로 마인크래프트를 먼저 - // 실행하라고 안내한다. - if (/ENOENT/i.test((err as Error).message)) { - throw new Error(t('errors.javaNotFoundRunVanilla', { version: pack.mcVersion })) - } - throw err + throw new Error(t('errors.fabricProfileInvalid', { message: (err as Error).message })) } + if (!profileId) { + throw new Error(t('errors.fabricProfileInvalid', { message: 'missing id' })) + } + await fsp.mkdir(versionDir, { recursive: true }) + // 받은 바이트를 그대로 저장(무손실). 프로필의 id 는 versionId 와 동일하다. + await fsp.writeFile(versionJson, buf) sendLog(t('log.fabricInstallDone')) } -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) 설치기가 자동 설치한 JDK(.mc_custom/jdk) 또는 시스템 JDK(C:\Program Files\Java). - // fabric-installer 실행에는 아무 자바나 되므로(버전 무관) 있으면 그대로 쓴다. - for (const base of [path.join(getAppDataDir(), getMcCustomDirName(), 'jdk'), 'C:\\Program Files\\Java']) { - for (const home of jdkHomesUnder(base)) { - const exe = path.join(home, 'bin', javaName) - if (fs.existsSync(exe)) return exe - } - } - - // 4) 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(t('errors.javaSpawnFailed', { message: err.message })))) - child.on('close', (code) => { - if (code === 0) { - resolve() - } else { - const detail = stderrTail.trim().split(/\r?\n/).slice(-3).join(' | ') - reject(new Error(t('errors.fabricInstallerExit', { code: code ?? '', detail: detail ? ' — ' + detail : '' }))) - } - }) - }) -} - function deriveFileName(url: string): string { try { const parsed = new URL(url)