Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf29270102 |
@@ -28,9 +28,11 @@
|
|||||||
|
|
||||||
### 3-2. JDK 확인
|
### 3-2. JDK 확인
|
||||||
|
|
||||||
- 환경변수(`JAVA_HOME`, `JDK_HOME`) → 자동 설치 위치(`%APPDATA%\jdk\temurin-21`) → `C:\Program Files\Java` 순으로 자동 탐색.
|
- 환경변수(`JAVA_HOME`, `JDK_HOME`) → 자동 설치 위치(`%APPDATA%\jdk\temurin-25`) → `C:\Program Files\Java` 순으로 자동 탐색.
|
||||||
- **자동 설치** 버튼을 누르면 Adoptium Temurin 21 LTS Windows x64 zip 을 받아 `%APPDATA%\jdk\temurin-21\` 에 풀어 사용합니다.
|
- 최신 마인크래프트 서버는 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. 서버 다운로드 및 설치
|
### 3-3. 서버 다운로드 및 설치
|
||||||
|
|
||||||
|
|||||||
@@ -596,7 +596,7 @@ function renderSubStep32(host, back, done) {
|
|||||||
host.innerHTML =
|
host.innerHTML =
|
||||||
'<h3>' + tt('step3.sub32.heading') + '</h3>' +
|
'<h3>' + tt('step3.sub32.heading') + '</h3>' +
|
||||||
'<p class="formMessage">' + tt('step3.sub32.description') + '</p>' +
|
'<p class="formMessage">' + tt('step3.sub32.description') + '</p>' +
|
||||||
'<div class="fieldset"><label><input id="jdkPath" type="text" placeholder="C:\\Program Files\\Java\\jdk-17" value="' + (state.serverInstall.jdk || '') + '" /></label>' +
|
'<div class="fieldset"><label><input id="jdkPath" type="text" placeholder="C:\\Program Files\\Java\\jdk-25" value="' + (state.serverInstall.jdk || '') + '" /></label>' +
|
||||||
'<button class="secondaryBtn" id="pickJdk">' + tt('step3.sub32.pickFolder') + '</button>' +
|
'<button class="secondaryBtn" id="pickJdk">' + tt('step3.sub32.pickFolder') + '</button>' +
|
||||||
'<button class="secondaryBtn" id="auto">' + tt('step3.sub32.auto') + '</button>' +
|
'<button class="secondaryBtn" id="auto">' + tt('step3.sub32.auto') + '</button>' +
|
||||||
'<button class="secondaryBtn" id="install">' + tt('step3.sub32.install') + '</button></div>' +
|
'<button class="secondaryBtn" id="install">' + tt('step3.sub32.install') + '</button></div>' +
|
||||||
|
|||||||
@@ -92,8 +92,8 @@
|
|||||||
"installCancel": "설치 취소",
|
"installCancel": "설치 취소",
|
||||||
"found": "JDK 발견: {{path}}",
|
"found": "JDK 발견: {{path}}",
|
||||||
"autoDetected": "JDK 자동 탐색됨: {{path}}",
|
"autoDetected": "JDK 자동 탐색됨: {{path}}",
|
||||||
"notFound": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 눌러 JDK를 설치하거나 직접 선택해 주세요.",
|
"notFound": "서버 실행에 필요한 Java 25 이상을 찾지 못했습니다(낮은 버전이 설치돼 있어도 서버가 뜨지 않습니다). \"자동 설치\" 를 눌러 설치하거나 직접 선택해 주세요.",
|
||||||
"notFoundHint": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 누르면 JDK를 받아 설치합니다.",
|
"notFoundHint": "Java 25 이상을 찾지 못했습니다. \"자동 설치\" 를 누르면 Temurin 25 를 받아 설치합니다.",
|
||||||
"cancelRequested": "JDK 설치 취소 요청 중...",
|
"cancelRequested": "JDK 설치 취소 요청 중...",
|
||||||
"downloading": "JDK 다운로드 중...",
|
"downloading": "JDK 다운로드 중...",
|
||||||
"installComplete": "JDK 자동 설치 완료: {{path}}",
|
"installComplete": "JDK 자동 설치 완료: {{path}}",
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
"packLoadFail": "pack 로드 실패 ({{file}}): {{message}}",
|
"packLoadFail": "pack 로드 실패 ({{file}}): {{message}}",
|
||||||
"packsLoaded": "로드된 음악퀴즈: {{count}}개",
|
"packsLoaded": "로드된 음악퀴즈: {{count}}개",
|
||||||
"selectedPack": "선택: {{key}}",
|
"selectedPack": "선택: {{key}}",
|
||||||
"jdkInstallStart": "JDK(Temurin 21) 자동 설치 시작 — 다운로드 중...",
|
"jdkInstallStart": "JDK(Temurin 25) 자동 설치 시작 — 다운로드 중...",
|
||||||
"jdkDownloadProgress": "JDK 다운로드: {{percent}}% ({{loaded}}MB / {{total}}MB)",
|
"jdkDownloadProgress": "JDK 다운로드: {{percent}}% ({{loaded}}MB / {{total}}MB)",
|
||||||
"jdkExtracting": "JDK 압축 해제 중...",
|
"jdkExtracting": "JDK 압축 해제 중...",
|
||||||
"jdkDoneRoot": "JDK 자동 설치 완료: {{path}}",
|
"jdkDoneRoot": "JDK 자동 설치 완료: {{path}}",
|
||||||
@@ -224,6 +224,8 @@
|
|||||||
"skipResourcepack": "resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.",
|
"skipResourcepack": "resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.",
|
||||||
"resourcepackDownload": "리소스팩 다운로드: {{url}}",
|
"resourcepackDownload": "리소스팩 다운로드: {{url}}",
|
||||||
"serverInstallPath": "서버 설치 경로: {{path}}",
|
"serverInstallPath": "서버 설치 경로: {{path}}",
|
||||||
|
"runBatJavaPatched": "run.bat 이 설치기가 준비한 자바를 쓰도록 수정했습니다: {{java}}",
|
||||||
|
"runBatJavaSkip": "설치기가 준비한 JDK 를 찾지 못해 run.bat 의 자바 경로는 그대로 둡니다(시스템 자바 사용).",
|
||||||
"mojangEulaFetchFail": "Minecraft EULA 페이지 조회 실패: {{message}}",
|
"mojangEulaFetchFail": "Minecraft EULA 페이지 조회 실패: {{message}}",
|
||||||
"eulaAccepted": "EULA 동의 저장 완료.",
|
"eulaAccepted": "EULA 동의 저장 완료.",
|
||||||
"configEditorOpen": "서버 설정 편집기 실행: {{url}}",
|
"configEditorOpen": "서버 설정 편집기 실행: {{url}}",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "minecraft-music-quiz-installer",
|
"name": "minecraft-music-quiz-installer",
|
||||||
"version": "0.4.2",
|
"version": "0.4.3",
|
||||||
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
||||||
"main": "dist/installer/main.js",
|
"main": "dist/installer/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import os from 'node:os'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import fsp from 'node:fs/promises'
|
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 { URL } from 'node:url'
|
||||||
import natUpnp from 'nat-upnp'
|
import natUpnp from 'nat-upnp'
|
||||||
// extract-zip은 CommonJS 기본 export.
|
// extract-zip은 CommonJS 기본 export.
|
||||||
@@ -252,43 +252,75 @@ ipcMain.handle('install:validatePath', async (_event, target: string) => {
|
|||||||
return { ok: true, message: absolute }
|
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 () => {
|
ipcMain.handle('jdk:detect', async () => {
|
||||||
const candidates: string[] = []
|
const candidates: string[] = []
|
||||||
if (process.env.JAVA_HOME) candidates.push(process.env.JAVA_HOME)
|
if (process.env.JAVA_HOME) candidates.push(process.env.JAVA_HOME)
|
||||||
if (process.env.JDK_HOME) candidates.push(process.env.JDK_HOME)
|
if (process.env.JDK_HOME) candidates.push(process.env.JDK_HOME)
|
||||||
// 자동 설치 위치(우리 설치기가 만든 JDK)도 후보에 포함.
|
// 자동 설치 위치(우리 설치기가 만든 JDK)도 후보에 포함.
|
||||||
candidates.push(path.join(getAppDataDir(), 'jdk', 'temurin-21'))
|
candidates.push(path.join(getAppDataDir(), 'jdk', BUNDLED_JDK_DIRNAME))
|
||||||
candidates.push('C:\\Program Files\\Java')
|
candidates.push('C:\\Program Files\\Java')
|
||||||
|
|
||||||
|
// 최신 마인크래프트 서버는 Java 25 이상을 요구한다. 너무 낮은 자바(예: 17/21)는
|
||||||
|
// 있어도 UnsupportedClassVersionError 로 서버가 안 뜨므로, 요구 버전 미만은
|
||||||
|
// "없음" 으로 처리해 자동 설치로 유도한다.
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (!candidate) continue
|
if (!candidate) continue
|
||||||
try {
|
const javaExe = findJavaExeInHome(candidate)
|
||||||
const stat = await fsp.stat(candidate)
|
if (!javaExe) continue
|
||||||
if (stat.isFile()) {
|
if (getJavaMajor(javaExe) < REQUIRED_JAVA_MAJOR) continue
|
||||||
return { found: true, path: candidate }
|
// 홈 디렉터리를 반환(java.exe 가 <home>/bin/java.exe 인 경우 상위 두 단계가 홈).
|
||||||
}
|
const home = path.basename(path.dirname(javaExe)) === 'bin' ? path.dirname(path.dirname(javaExe)) : candidate
|
||||||
if (stat.isDirectory()) {
|
return { found: true, path: home }
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { found: false, path: '' }
|
return { found: false, path: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── JDK 자동 설치(Temurin 21, 취소 가능) ──────────────────────────────
|
// ── JDK 자동 설치(Temurin 25, 취소 가능) ──────────────────────────────
|
||||||
interface JdkInstallState {
|
interface JdkInstallState {
|
||||||
controller: AbortController | null
|
controller: AbortController | null
|
||||||
destDir: string | null
|
destDir: string | null
|
||||||
@@ -371,12 +403,12 @@ ipcMain.handle('jdk:install', async (): Promise<{ ok: boolean; path?: string; me
|
|||||||
jdkInstall.controller = controller
|
jdkInstall.controller = controller
|
||||||
const tmpRoot = path.join(getAppDataDir(), 'jdk-cache')
|
const tmpRoot = path.join(getAppDataDir(), 'jdk-cache')
|
||||||
await fsp.mkdir(tmpRoot, { recursive: true })
|
await fsp.mkdir(tmpRoot, { recursive: true })
|
||||||
const tempZip = path.join(tmpRoot, `temurin-21-${Date.now()}.zip`)
|
const tempZip = path.join(tmpRoot, `${BUNDLED_JDK_DIRNAME}-${Date.now()}.zip`)
|
||||||
const destDir = path.join(getAppDataDir(), 'jdk', 'temurin-21')
|
const destDir = path.join(getAppDataDir(), 'jdk', BUNDLED_JDK_DIRNAME)
|
||||||
jdkInstall.destDir = destDir
|
jdkInstall.destDir = destDir
|
||||||
try {
|
try {
|
||||||
// Adoptium API v3: latest GA JDK 21 Windows x64. 본문은 307 로 GitHub 릴리즈로 리다이렉트.
|
// Adoptium API v3: latest GA JDK(Windows x64). 본문은 307 로 GitHub 릴리즈로 리다이렉트.
|
||||||
const url = 'https://api.adoptium.net/v3/binary/latest/21/ga/windows/x64/jdk/hotspot/normal/eclipse?project=jdk'
|
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'))
|
sendLog(t('log.jdkInstallStart'))
|
||||||
let lastPctReported = -1
|
let lastPctReported = -1
|
||||||
await downloadStream(url, tempZip, controller.signal, (loaded, total) => {
|
await downloadStream(url, tempZip, controller.signal, (loaded, total) => {
|
||||||
@@ -614,6 +646,54 @@ async function downloadResourcepackZip(pack: PackDefinition, customRoot: string)
|
|||||||
await downloadFile(url, target)
|
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<void> {
|
||||||
|
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) => {
|
ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) => {
|
||||||
const pack = state.packs.get(payload.packKey)
|
const pack = state.packs.get(payload.packKey)
|
||||||
if (!pack) throw new Error(t('errors.packNotFound2'))
|
if (!pack) throw new Error(t('errors.packNotFound2'))
|
||||||
@@ -630,6 +710,8 @@ ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) =
|
|||||||
// 동의 흐름은 renderer가 별도 IPC로 읽고 동의 시 덮어쓴다.
|
// 동의 흐름은 renderer가 별도 IPC로 읽고 동의 시 덮어쓴다.
|
||||||
// 설치기는 포트를 직접 열지 않는다(요청). run.bat 에 UPnP 자동 개방을 주입하지
|
// 설치기는 포트를 직접 열지 않는다(요청). run.bat 에 UPnP 자동 개방을 주입하지
|
||||||
// 않으며, 사용자가 라우터에서 수동 포워딩을 하고 포트포워딩 페이지에서 확인만 한다.
|
// 않으며, 사용자가 라우터에서 수동 포워딩을 하고 포트포워딩 페이지에서 확인만 한다.
|
||||||
|
// 단, run.bat 이 시스템의 낡은 자바를 쓰지 않도록 설치기가 준비한 JDK 로 바꿔준다.
|
||||||
|
await patchServerRunBatJava(installPath, payload.jdkPath)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('server:readEula', async (_event, installPath: string): Promise<{ exists: boolean; content: string }> => {
|
ipcMain.handle('server:readEula', async (_event, installPath: string): Promise<{ exists: boolean; content: string }> => {
|
||||||
|
|||||||
Reference in New Issue
Block a user