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 }> => {