diff --git a/docs/installer.md b/docs/installer.md index a6c7fd2..2aa5a03 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -28,9 +28,11 @@ ### 3-2. JDK 확인 -- 환경변수(`JAVA_HOME`, `JDK_HOME`) → 자동 설치 위치(`%APPDATA%\jdk\temurin-21`) → `C:\Program Files\Java` 순으로 자동 탐색. -- **자동 설치** 버튼을 누르면 Adoptium Temurin 21 LTS Windows x64 zip 을 받아 `%APPDATA%\jdk\temurin-21\` 에 풀어 사용합니다. +- 환경변수(`JAVA_HOME`, `JDK_HOME`) → 자동 설치 위치(`%APPDATA%\jdk\temurin-25`) → `C:\Program Files\Java` 순으로 자동 탐색. +- 최신 마인크래프트 서버는 Java 25 이상을 요구합니다(예: 서버 번들러 class file 69.0). 그보다 낮은 자바(17/21 등)는 있어도 `UnsupportedClassVersionError` 로 서버가 뜨지 않으므로, 자동 탐색은 **Java 25 미만을 "없음"으로 처리**해 자동 설치로 유도합니다. +- **자동 설치** 버튼을 누르면 Adoptium Temurin 25 LTS Windows x64 zip 을 받아 `%APPDATA%\jdk\temurin-25\` 에 풀어 사용합니다. - 설치 중 같은 버튼이 "설치 취소" 로 바뀌고, 누르면 다운로드를 즉시 중단하고 부분 파일을 정리합니다. +- 서버 zip 의 `run.bat` 이 시스템 PATH 의 `java` 를 그대로 쓰면 낡은 자바로 실행돼 실패할 수 있어, 설치기가 준비/선택한 JDK 의 `java` 를 쓰도록 `run.bat` 을 자동으로 수정합니다(자동 설치 JDK 는 `%APPDATA%` 전개형 경로라 한글 사용자명에도 안전). ### 3-3. 서버 다운로드 및 설치 diff --git a/installer/renderer.js b/installer/renderer.js index eb56145..536ebbd 100644 --- a/installer/renderer.js +++ b/installer/renderer.js @@ -596,7 +596,7 @@ function renderSubStep32(host, back, done) { host.innerHTML = '

' + tt('step3.sub32.heading') + '

' + '

' + tt('step3.sub32.description') + '

' + - '
' + + '
' + '' + '' + '
' + diff --git a/locales/installer/ko-kr.json b/locales/installer/ko-kr.json index 7b14c11..1d29aa9 100644 --- a/locales/installer/ko-kr.json +++ b/locales/installer/ko-kr.json @@ -92,8 +92,8 @@ "installCancel": "설치 취소", "found": "JDK 발견: {{path}}", "autoDetected": "JDK 자동 탐색됨: {{path}}", - "notFound": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 눌러 JDK를 설치하거나 직접 선택해 주세요.", - "notFoundHint": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 누르면 JDK를 받아 설치합니다.", + "notFound": "서버 실행에 필요한 Java 25 이상을 찾지 못했습니다(낮은 버전이 설치돼 있어도 서버가 뜨지 않습니다). \"자동 설치\" 를 눌러 설치하거나 직접 선택해 주세요.", + "notFoundHint": "Java 25 이상을 찾지 못했습니다. \"자동 설치\" 를 누르면 Temurin 25 를 받아 설치합니다.", "cancelRequested": "JDK 설치 취소 요청 중...", "downloading": "JDK 다운로드 중...", "installComplete": "JDK 자동 설치 완료: {{path}}", @@ -201,7 +201,7 @@ "packLoadFail": "pack 로드 실패 ({{file}}): {{message}}", "packsLoaded": "로드된 음악퀴즈: {{count}}개", "selectedPack": "선택: {{key}}", - "jdkInstallStart": "JDK(Temurin 21) 자동 설치 시작 — 다운로드 중...", + "jdkInstallStart": "JDK(Temurin 25) 자동 설치 시작 — 다운로드 중...", "jdkDownloadProgress": "JDK 다운로드: {{percent}}% ({{loaded}}MB / {{total}}MB)", "jdkExtracting": "JDK 압축 해제 중...", "jdkDoneRoot": "JDK 자동 설치 완료: {{path}}", @@ -224,6 +224,8 @@ "skipResourcepack": "resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.", "resourcepackDownload": "리소스팩 다운로드: {{url}}", "serverInstallPath": "서버 설치 경로: {{path}}", + "runBatJavaPatched": "run.bat 이 설치기가 준비한 자바를 쓰도록 수정했습니다: {{java}}", + "runBatJavaSkip": "설치기가 준비한 JDK 를 찾지 못해 run.bat 의 자바 경로는 그대로 둡니다(시스템 자바 사용).", "mojangEulaFetchFail": "Minecraft EULA 페이지 조회 실패: {{message}}", "eulaAccepted": "EULA 동의 저장 완료.", "configEditorOpen": "서버 설정 편집기 실행: {{url}}", diff --git a/package.json b/package.json index a70ce44..9409e8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minecraft-music-quiz-installer", - "version": "0.4.2", + "version": "0.4.3", "description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트", "main": "dist/installer/main.js", "scripts": { diff --git a/src/installer/main.ts b/src/installer/main.ts index c86d8b8..9778876 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -6,7 +6,7 @@ import os from 'node:os' import path from 'node:path' import fs from 'node:fs' import fsp from 'node:fs/promises' -import { spawn } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import { URL } from 'node:url' import natUpnp from 'nat-upnp' // extract-zip은 CommonJS 기본 export. @@ -252,43 +252,75 @@ ipcMain.handle('install:validatePath', async (_event, target: string) => { return { ok: true, message: absolute } }) +// 자동 설치할 JDK 메이저 버전. 최신 마인크래프트 서버(번들러)가 요구하는 자바 버전에 +// 맞춘다. 마인크래프트 서버 jar 이 "class file version 69.0"(=Java 25)처럼 더 최신 +// 자바를 요구하면, 낮은 자바로 실행 시 UnsupportedClassVersionError 로 서버가 뜨지 +// 않는다. 최신 마인크래프트는 Java 25(LTS)를 요구하므로 25 로 맞춘다. +const BUNDLED_JDK_MAJOR = '25' +const BUNDLED_JDK_DIRNAME = `temurin-${BUNDLED_JDK_MAJOR}` +const REQUIRED_JAVA_MAJOR = Number(BUNDLED_JDK_MAJOR) + +/** + * java 실행 파일의 메이저 버전을 조회한다(`java -version` 은 stderr 로 출력). + * 'openjdk version "25.0.3"' → 25, '"1.8.0_xx"' → 8. + * 실행 실패/파싱 실패 시 0. + */ +function getJavaMajor(javaExe: string): number { + try { + const res = spawnSync(javaExe, ['-version'], { encoding: 'utf8', timeout: 8000 }) + const out = `${res.stdout || ''}${res.stderr || ''}` + const m = out.match(/version\s+"(\d+)(?:\.(\d+))?/i) + if (!m) return 0 + let major = parseInt(m[1], 10) + if (major === 1 && m[2]) major = parseInt(m[2], 10) // 1.8 → 8 + return Number.isNaN(major) ? 0 : major + } catch { + return 0 + } +} + +/** 후보 JDK 홈에서 java 실행 파일 경로를 찾는다(홈 직하 또는 한 단계 감싼 jdk-* 하위까지). */ +function findJavaExeInHome(candidate: string): string { + const javaName = process.platform === 'win32' ? 'java.exe' : 'java' + try { + const stat = fs.statSync(candidate) + if (stat.isFile()) return candidate + const direct = path.join(candidate, 'bin', javaName) + if (fs.existsSync(direct)) return direct + for (const entry of fs.readdirSync(candidate)) { + const childJava = path.join(candidate, entry, 'bin', javaName) + if (fs.existsSync(childJava)) return childJava + } + } catch { + return '' + } + return '' +} + ipcMain.handle('jdk:detect', async () => { const candidates: string[] = [] if (process.env.JAVA_HOME) candidates.push(process.env.JAVA_HOME) if (process.env.JDK_HOME) candidates.push(process.env.JDK_HOME) // 자동 설치 위치(우리 설치기가 만든 JDK)도 후보에 포함. - candidates.push(path.join(getAppDataDir(), 'jdk', 'temurin-21')) + candidates.push(path.join(getAppDataDir(), 'jdk', BUNDLED_JDK_DIRNAME)) candidates.push('C:\\Program Files\\Java') + // 최신 마인크래프트 서버는 Java 25 이상을 요구한다. 너무 낮은 자바(예: 17/21)는 + // 있어도 UnsupportedClassVersionError 로 서버가 안 뜨므로, 요구 버전 미만은 + // "없음" 으로 처리해 자동 설치로 유도한다. for (const candidate of candidates) { if (!candidate) continue - try { - const stat = await fsp.stat(candidate) - if (stat.isFile()) { - return { found: true, path: candidate } - } - if (stat.isDirectory()) { - const javaExe = path.join(candidate, 'bin', process.platform === 'win32' ? 'java.exe' : 'java') - if (fs.existsSync(javaExe)) { - return { found: true, path: candidate } - } - const entries = await fsp.readdir(candidate) - for (const entry of entries) { - const child = path.join(candidate, entry) - const childJava = path.join(child, 'bin', process.platform === 'win32' ? 'java.exe' : 'java') - if (fs.existsSync(childJava)) { - return { found: true, path: child } - } - } - } - } catch { - continue - } + const javaExe = findJavaExeInHome(candidate) + if (!javaExe) continue + if (getJavaMajor(javaExe) < REQUIRED_JAVA_MAJOR) continue + // 홈 디렉터리를 반환(java.exe 가 /bin/java.exe 인 경우 상위 두 단계가 홈). + const home = path.basename(path.dirname(javaExe)) === 'bin' ? path.dirname(path.dirname(javaExe)) : candidate + return { found: true, path: home } } return { found: false, path: '' } }) -// ── JDK 자동 설치(Temurin 21, 취소 가능) ────────────────────────────── +// ── JDK 자동 설치(Temurin 25, 취소 가능) ────────────────────────────── interface JdkInstallState { controller: AbortController | null destDir: string | null @@ -371,12 +403,12 @@ ipcMain.handle('jdk:install', async (): Promise<{ ok: boolean; path?: string; me jdkInstall.controller = controller const tmpRoot = path.join(getAppDataDir(), 'jdk-cache') await fsp.mkdir(tmpRoot, { recursive: true }) - const tempZip = path.join(tmpRoot, `temurin-21-${Date.now()}.zip`) - const destDir = path.join(getAppDataDir(), 'jdk', 'temurin-21') + const tempZip = path.join(tmpRoot, `${BUNDLED_JDK_DIRNAME}-${Date.now()}.zip`) + const destDir = path.join(getAppDataDir(), 'jdk', BUNDLED_JDK_DIRNAME) jdkInstall.destDir = destDir try { - // Adoptium API v3: latest GA JDK 21 Windows x64. 본문은 307 로 GitHub 릴리즈로 리다이렉트. - const url = 'https://api.adoptium.net/v3/binary/latest/21/ga/windows/x64/jdk/hotspot/normal/eclipse?project=jdk' + // Adoptium API v3: latest GA JDK(Windows x64). 본문은 307 로 GitHub 릴리즈로 리다이렉트. + const url = `https://api.adoptium.net/v3/binary/latest/${BUNDLED_JDK_MAJOR}/ga/windows/x64/jdk/hotspot/normal/eclipse?project=jdk` sendLog(t('log.jdkInstallStart')) let lastPctReported = -1 await downloadStream(url, tempZip, controller.signal, (loaded, total) => { @@ -614,6 +646,54 @@ async function downloadResourcepackZip(pack: PackDefinition, customRoot: string) await downloadFile(url, target) } +/** + * run.bat 에 넣을 java 실행 명령을 만든다. 자동 설치 JDK 는 %APPDATA% 아래에 있으므로 + * 사용자명이 한글이어도 인코딩 문제 없이 실행되도록 `%APPDATA%\...\java.exe` 형태로 + * 만든다(사용자명은 cmd 가 런타임에 전개). %APPDATA% 밖(예: Program Files)이면 절대경로. + */ +function javaCommandForRunBat(javaExe: string): string { + const appData = getAppDataDir() + const rel = path.relative(appData, javaExe) + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { + return `"%APPDATA%\\${rel.split(/[\\/]/).join('\\')}"` + } + return `"${javaExe}"` +} + +/** + * 서버 zip 의 run.bat 이 시스템 PATH 의 `java` 를 그대로 쓰면, 사용자의 낡은 자바(예: 17)로 + * 실행돼 최신 마인크래프트 서버가 UnsupportedClassVersionError 로 뜨지 않는다. + * 설치기가 준비/선택한 JDK 의 java 를 쓰도록 run.bat 의 `java` 실행 토큰을 치환한다. + * - `java ... -jar ...`, `java @args nogui`, `"java" ...` 형태 지원. + * - 이미 경로/다른 실행기를 쓰는 줄은 건드리지 않는다. + * 바이트 보존을 위해 latin1 로 읽고 쓰되(비-ASCII 주석 깨짐 방지), 삽입하는 경로는 + * %APPDATA% 전개형이라 ASCII 라 안전하다. + */ +async function patchServerRunBatJava(installPath: string, jdkPath: string): Promise { + const runBat = path.join(installPath, 'run.bat') + if (!fs.existsSync(runBat)) return + const javaExe = findJavaExeInHome(jdkPath) + if (!javaExe) { + sendLog(t('log.runBatJavaSkip')) + return + } + const command = javaCommandForRunBat(javaExe) + const original = await fsp.readFile(runBat, { encoding: 'latin1' }) + const lines = original.split(/\r?\n/) + let changed = false + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/^(\s*)"?java(?:\.exe)?"?(\s+.*)$/i) + if (m && /(-jar|\bnogui\b|@|-Xm)/i.test(m[2])) { + lines[i] = `${m[1]}${command}${m[2]}` + changed = true + } + } + if (changed) { + await fsp.writeFile(runBat, lines.join('\r\n'), { encoding: 'latin1' }) + sendLog(t('log.runBatJavaPatched', { java: command })) + } +} + ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) => { const pack = state.packs.get(payload.packKey) if (!pack) throw new Error(t('errors.packNotFound2')) @@ -630,6 +710,8 @@ ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) = // 동의 흐름은 renderer가 별도 IPC로 읽고 동의 시 덮어쓴다. // 설치기는 포트를 직접 열지 않는다(요청). run.bat 에 UPnP 자동 개방을 주입하지 // 않으며, 사용자가 라우터에서 수동 포워딩을 하고 포트포워딩 페이지에서 확인만 한다. + // 단, run.bat 이 시스템의 낡은 자바를 쓰지 않도록 설치기가 준비한 JDK 로 바꿔준다. + await patchServerRunBatJava(installPath, payload.jdkPath) }) ipcMain.handle('server:readEula', async (_event, installPath: string): Promise<{ exists: boolean; content: string }> => {