Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6118039a3 | |||
| 48e0421135 | |||
| cf29270102 |
@@ -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. 서버 다운로드 및 설치
|
||||
|
||||
|
||||
@@ -596,7 +596,7 @@ function renderSubStep32(host, back, done) {
|
||||
host.innerHTML =
|
||||
'<h3>' + tt('step3.sub32.heading') + '</h3>' +
|
||||
'<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="auto">' + tt('step3.sub32.auto') + '</button>' +
|
||||
'<button class="secondaryBtn" id="install">' + tt('step3.sub32.install') + '</button></div>' +
|
||||
@@ -686,14 +686,37 @@ function renderSubStep32(host, back, done) {
|
||||
if (installing) return
|
||||
back()
|
||||
})
|
||||
nextBtn.addEventListener('click', function () {
|
||||
nextBtn.addEventListener('click', async function () {
|
||||
if (installing) return
|
||||
if (!input.value.trim()) {
|
||||
var val = input.value.trim()
|
||||
if (!val) {
|
||||
msg.textContent = tt('step3.sub32.pathRequired')
|
||||
msg.classList.add('error')
|
||||
return
|
||||
}
|
||||
state.serverInstall.jdk = input.value.trim()
|
||||
// 직접 입력/선택한 JDK 도 서버 실행에 충분한 버전(Java 25+)인지 검증한다.
|
||||
// 너무 낮으면 run.bat 이 그 자바를 써도 UnsupportedClassVersionError 로 서버가 안 뜬다.
|
||||
nextBtn.disabled = true
|
||||
msg.classList.remove('success', 'error')
|
||||
msg.textContent = tt('step3.sub32.verifying')
|
||||
try {
|
||||
var v = await installerApi.verifyJdk(val)
|
||||
if (!v || !v.ok) {
|
||||
msg.classList.add('error')
|
||||
msg.textContent = (v && v.major)
|
||||
? tt('step3.sub32.versionTooLow', { major: v.major, required: (v && v.required) || 25 })
|
||||
: tt('step3.sub32.versionUnknown')
|
||||
nextBtn.disabled = false
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
msg.classList.add('error')
|
||||
msg.textContent = tt('step3.sub32.versionUnknown')
|
||||
nextBtn.disabled = false
|
||||
return
|
||||
}
|
||||
nextBtn.disabled = false
|
||||
state.serverInstall.jdk = val
|
||||
done()
|
||||
})
|
||||
;(async function () {
|
||||
|
||||
@@ -100,6 +100,8 @@
|
||||
"imageDownloading": "{{idx}}번 사진 다운로드 중…",
|
||||
"imageDone": "{{idx}}번 사진 완료: {{name}}",
|
||||
"imageSkip": "{{idx}}번 사진은 이전에 받아둠 → 건너뜀(이어받기)",
|
||||
"imageCooldown": "음악 다운로드 직후라 유튜브 속도제한을 피하려고 {{secs}}초 대기합니다…",
|
||||
"imageRetry": "{{idx}}번 사진 재시도 {{attempt}}회차 (HTTP {{code}}) — {{secs}}초 후 다시 시도",
|
||||
"baseDownload": "베이스 리소스팩 다운로드: {{path}}",
|
||||
"baseUrl": " URL: {{url}}",
|
||||
"baseReceived": "베이스 리소스팩 받음 ({{kb}} KB)",
|
||||
@@ -133,7 +135,8 @@
|
||||
"baseDownloading": "베이스 리소스팩 다운로드 중",
|
||||
"buildingWithBase": "베이스에 음악·사진 추가 중",
|
||||
"buildingZip": "zip 빌드 중",
|
||||
"installComplete": "설치 완료"
|
||||
"installComplete": "설치 완료",
|
||||
"imageRetry": "속도제한(HTTP {{code}}) — {{secs}}초 후 재시도"
|
||||
},
|
||||
"pack": {
|
||||
"description": "음악퀴즈 리소스팩 - {{name}}"
|
||||
@@ -145,6 +148,7 @@
|
||||
"cancelledByUser": "사용자가 설치를 취소했습니다.",
|
||||
"musicDownloadFailed": "{{idx}}번 노래 다운로드 실패: {{message}}",
|
||||
"imageDownloadFailed": "{{idx}}번 사진 다운로드 실패: {{message}}",
|
||||
"imageRateLimitHint": "유튜브가 IP를 잠시 속도제한(429)했습니다. 몇 분 뒤 다시 설치를 시도하면 이미 받은 사진은 건너뛰고 이어받습니다.",
|
||||
"imageNormalizeFailed": "{{idx}}번 사진 정규화 실패: {{message}}",
|
||||
"baseDownloadFailed": "베이스 리소스팩 다운로드 실패: {{message}}",
|
||||
"ytdlpSignal": "yt-dlp 가 신호 {{signal}} 로 종료됨",
|
||||
|
||||
@@ -92,15 +92,18 @@
|
||||
"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}}",
|
||||
"installCanceled": "JDK 설치 취소됨",
|
||||
"installFailed": "JDK 설치 실패: {{message}}",
|
||||
"installError": "JDK 설치 오류: {{message}}",
|
||||
"pathRequired": "JDK 경로를 입력해 주세요."
|
||||
"pathRequired": "JDK 경로를 입력해 주세요.",
|
||||
"verifying": "선택한 JDK 버전을 확인하는 중…",
|
||||
"versionTooLow": "선택한 Java {{major}} 는 너무 낮습니다. 서버 실행에는 Java {{required}} 이상이 필요합니다. \"자동 설치\" 로 Temurin 25 를 받거나 Java 25 이상 폴더를 선택해 주세요.",
|
||||
"versionUnknown": "선택한 경로에서 Java 실행 파일을 찾지 못했거나 버전을 확인할 수 없습니다. JDK 폴더(bin\\java.exe 가 있는 곳)를 선택하거나 \"자동 설치\" 를 눌러 주세요."
|
||||
},
|
||||
"sub33": {
|
||||
"heading": "서버 다운로드 및 설치",
|
||||
@@ -201,7 +204,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 +227,8 @@
|
||||
"skipResourcepack": "resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.",
|
||||
"resourcepackDownload": "리소스팩 다운로드: {{url}}",
|
||||
"serverInstallPath": "서버 설치 경로: {{path}}",
|
||||
"runBatJavaPatched": "run.bat 이 설치기가 준비한 자바를 쓰도록 수정했습니다: {{java}}",
|
||||
"runBatJavaSkip": "설치기가 준비한 JDK 를 찾지 못해 run.bat 의 자바 경로는 그대로 둡니다(시스템 자바 사용).",
|
||||
"mojangEulaFetchFail": "Minecraft EULA 페이지 조회 실패: {{message}}",
|
||||
"eulaAccepted": "EULA 동의 저장 완료.",
|
||||
"configEditorOpen": "서버 설정 편집기 실행: {{url}}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minecraft-music-quiz-installer",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.5",
|
||||
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
||||
"main": "dist/installer/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -35,10 +35,16 @@ export function ytIdFromUrl(url: string): string {
|
||||
* 5xx 게이트웨이 계열도 잠깐 뒤 다시 받으면 성공하는 경우가 많다.
|
||||
*/
|
||||
const TRANSIENT_CODES = new Set([408, 425, 429, 500, 502, 503, 504])
|
||||
const MAX_RETRIES = 5
|
||||
const MAX_RETRIES = 6
|
||||
/** 백오프 상한(ms). Retry-After 헤더가 비정상적으로 커도 이 이상은 기다리지 않는다. */
|
||||
const MAX_BACKOFF_MS = 60000
|
||||
|
||||
/**
|
||||
* 재시도가 일어날 때 호출되는 훅. 상위(main)에서 로그/진행률로 표시해, 429 백오프
|
||||
* 대기 중에도 화면이 멈춘 것처럼 보이지 않게 한다.
|
||||
*/
|
||||
export type ImageRetryHook = (info: { attempt: number; delayMs: number; code: number | null }) => void
|
||||
|
||||
/** Retry-After 헤더(초 또는 HTTP-date) → 대기 ms. 못 읽으면 null. */
|
||||
function parseRetryAfter(h: string | string[] | undefined): number | null {
|
||||
if (!h) return null
|
||||
@@ -57,7 +63,7 @@ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
|
||||
* 429/5xx 등 일시적 오류는 지수 백오프(+jitter, Retry-After 우선)로 최대
|
||||
* MAX_RETRIES 회 재시도한다. 그 외 4xx 나 재시도 소진 시 reject.
|
||||
*/
|
||||
function fetchBuffer(url: string, redirects = 0, attempt = 0): Promise<Buffer> {
|
||||
function fetchBuffer(url: string, redirects = 0, attempt = 0, onRetry?: ImageRetryHook): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (redirects > 8) {
|
||||
reject(new Error(t('common.tooManyRedirects')))
|
||||
@@ -65,10 +71,11 @@ function fetchBuffer(url: string, redirects = 0, attempt = 0): Promise<Buffer> {
|
||||
}
|
||||
const target = new URL(url)
|
||||
const lib = target.protocol === 'https:' ? https : http
|
||||
const retryLater = (headerDelay: number | null): void => {
|
||||
const retryLater = (headerDelay: number | null, code: number | null): void => {
|
||||
const backoff = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** attempt) + Math.floor(Math.random() * 500)
|
||||
const delay = headerDelay ?? backoff
|
||||
sleep(delay).then(() => fetchBuffer(url, redirects, attempt + 1).then(resolve, reject))
|
||||
try { onRetry?.({ attempt: attempt + 1, delayMs: delay, code }) } catch { /* noop */ }
|
||||
sleep(delay).then(() => fetchBuffer(url, redirects, attempt + 1, onRetry).then(resolve, reject))
|
||||
}
|
||||
const req = lib.get(target, {
|
||||
timeout: 30000,
|
||||
@@ -77,13 +84,13 @@ function fetchBuffer(url: string, redirects = 0, attempt = 0): Promise<Buffer> {
|
||||
const code = res.statusCode || 0
|
||||
if (code >= 300 && code < 400 && res.headers.location) {
|
||||
res.resume()
|
||||
fetchBuffer(new URL(res.headers.location, target).toString(), redirects + 1, attempt)
|
||||
fetchBuffer(new URL(res.headers.location, target).toString(), redirects + 1, attempt, onRetry)
|
||||
.then(resolve, reject)
|
||||
return
|
||||
}
|
||||
if (TRANSIENT_CODES.has(code) && attempt < MAX_RETRIES) {
|
||||
res.resume()
|
||||
retryLater(parseRetryAfter(res.headers['retry-after']))
|
||||
retryLater(parseRetryAfter(res.headers['retry-after']), code)
|
||||
return
|
||||
}
|
||||
if (code !== 200) {
|
||||
@@ -98,7 +105,7 @@ function fetchBuffer(url: string, redirects = 0, attempt = 0): Promise<Buffer> {
|
||||
req.on('error', (err) => {
|
||||
// 연결 끊김/리셋 등 네트워크 오류도 몇 번은 재시도.
|
||||
if (attempt < MAX_RETRIES) {
|
||||
retryLater(null)
|
||||
retryLater(null, null)
|
||||
return
|
||||
}
|
||||
reject(err)
|
||||
@@ -134,18 +141,18 @@ function decodeDataUrl(url: string): Buffer | null {
|
||||
* 실패하면 `hqdefault.jpg` 로 폴백.
|
||||
* - 그 외 URL 은 HTTP GET 으로 그대로 받음.
|
||||
*/
|
||||
export async function downloadImage(rawUrl: string): Promise<Buffer> {
|
||||
export async function downloadImage(rawUrl: string, onRetry?: ImageRetryHook): Promise<Buffer> {
|
||||
const dataBuf = decodeDataUrl(rawUrl)
|
||||
if (dataBuf) return dataBuf
|
||||
const ytId = ytIdFromUrl(rawUrl)
|
||||
if (ytId) {
|
||||
try {
|
||||
return await fetchBuffer(`https://i.ytimg.com/vi/${ytId}/maxresdefault.jpg`)
|
||||
return await fetchBuffer(`https://i.ytimg.com/vi/${ytId}/maxresdefault.jpg`, 0, 0, onRetry)
|
||||
} catch {
|
||||
return await fetchBuffer(`https://i.ytimg.com/vi/${ytId}/hqdefault.jpg`)
|
||||
return await fetchBuffer(`https://i.ytimg.com/vi/${ytId}/hqdefault.jpg`, 0, 0, onRetry)
|
||||
}
|
||||
}
|
||||
return fetchBuffer(rawUrl)
|
||||
return fetchBuffer(rawUrl, 0, 0, onRetry)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -77,6 +77,13 @@ function pickMusicConcurrency(): number {
|
||||
*/
|
||||
const MUSIC_START_STAGGER_MS = 2000
|
||||
|
||||
/** 사진(썸네일) 다운로드 사이 최소 간격(ms). i.ytimg.com 429(rate limit) 를 유발하지 않도록 순차 요청을 살짝 벌린다. */
|
||||
const IMAGE_REQUEST_INTERVAL_MS = 800
|
||||
/** 음악(yt-dlp) 단계 직후엔 유튜브가 IP 를 일시 throttle 할 수 있어, 사진 단계 전 잠깐 쉬어 429 를 피한다. */
|
||||
const IMAGE_PHASE_COOLDOWN_MS = 4000
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
/** start-gate. 여러 worker 가 동시에 acquire 해도 직렬화되어 순차 통과. */
|
||||
let musicStartChain: Promise<void> = Promise.resolve()
|
||||
let nextMusicStartAt = 0
|
||||
@@ -503,6 +510,13 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
const paintingDir = path.join(tempRoot, 'painting')
|
||||
await fsp.mkdir(paintingDir, { recursive: true })
|
||||
sendLog(t('log.imageStart', { total: imageTotal }))
|
||||
// 음악(yt-dlp) 단계에서 유튜브를 많이 두드렸다면 IP throttle 이 남아 사진 첫 장부터
|
||||
// 429 가 날 수 있다. 다운로드할 사진이 실제로 있고 직전에 음악을 받았다면 잠깐 쉰다.
|
||||
if (imageTotal > 0 && musicTotal > 0) {
|
||||
sendLog(t('log.imageCooldown', { secs: Math.round(IMAGE_PHASE_COOLDOWN_MS / 1000) }))
|
||||
await sleep(IMAGE_PHASE_COOLDOWN_MS)
|
||||
}
|
||||
let imageRequests = 0
|
||||
for (let i = 0; i < imageTotal; i++) {
|
||||
throwIfCancelled()
|
||||
const entry = pack.list.images[i]
|
||||
@@ -514,16 +528,30 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
|
||||
continue
|
||||
}
|
||||
// 실제 네트워크 요청을 하는 사진들 사이에만 간격을 둔다(건너뛴 것 사이엔 대기 없음).
|
||||
if (imageRequests > 0) await sleep(IMAGE_REQUEST_INTERVAL_MS + Math.floor(Math.random() * 300))
|
||||
imageRequests++
|
||||
sendLog(t('log.imageDownloading', { idx }))
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running' })
|
||||
let buf: Buffer
|
||||
try {
|
||||
buf = await downloadImage(entry.url)
|
||||
buf = await downloadImage(entry.url, (info) => {
|
||||
const secs = Math.ceil(info.delayMs / 1000)
|
||||
sendLog(t('log.imageRetry', { idx, attempt: info.attempt, code: info.code ?? '-', secs }))
|
||||
sendProgress({
|
||||
phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running',
|
||||
message: t('progress.imageRetry', { code: info.code ?? '-', secs })
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
// 부분 생성됐을 수 있는 커버 파일 제거(이어받기 시 완성본 오인 방지).
|
||||
await fsp.rm(coverPath, { force: true }).catch(() => {})
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||
throw new Error(t('errors.imageDownloadFailed', { idx, message: (err as Error).message }))
|
||||
const rawMsg = (err as Error).message
|
||||
// 429(rate limit)는 유튜브가 IP 를 일시 차단한 것. 잠시 뒤 다시 시도하면
|
||||
// 이미 받은 사진은 건너뛰고 이어받는다는 안내를 덧붙인다.
|
||||
const msg = /429/.test(rawMsg) ? `${rawMsg} — ${t('errors.imageRateLimitHint')}` : rawMsg
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: msg })
|
||||
throw new Error(t('errors.imageDownloadFailed', { idx, message: msg }))
|
||||
}
|
||||
throwIfCancelled()
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 60, status: 'running' })
|
||||
|
||||
@@ -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,84 @@ 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 가 <home>/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 경로가 서버 실행에 충분한 버전인지 확인한다.
|
||||
// ok=true → major >= 요구 버전(25).
|
||||
// major=0 → 경로에서 java 를 못 찾거나 버전을 못 읽음(실행 실패 등).
|
||||
ipcMain.handle('jdk:verify', async (_event, jdkPath: string): Promise<{ ok: boolean; major: number; required: number }> => {
|
||||
const javaExe = jdkPath ? findJavaExeInHome(jdkPath) : ''
|
||||
const major = javaExe ? getJavaMajor(javaExe) : 0
|
||||
return { ok: major >= REQUIRED_JAVA_MAJOR, major, required: REQUIRED_JAVA_MAJOR }
|
||||
})
|
||||
|
||||
// ── JDK 자동 설치(Temurin 25, 취소 가능) ──────────────────────────────
|
||||
interface JdkInstallState {
|
||||
controller: AbortController | null
|
||||
destDir: string | null
|
||||
@@ -371,12 +412,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 +655,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<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) => {
|
||||
const pack = state.packs.get(payload.packKey)
|
||||
if (!pack) throw new Error(t('errors.packNotFound2'))
|
||||
@@ -630,6 +719,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 }> => {
|
||||
|
||||
@@ -25,6 +25,8 @@ const api = {
|
||||
|
||||
// 3-2
|
||||
detectJdk: (): Promise<{ found: boolean; path: string }> => ipcRenderer.invoke('jdk:detect'),
|
||||
verifyJdk: (jdkPath: string): Promise<{ ok: boolean; major: number; required: number }> =>
|
||||
ipcRenderer.invoke('jdk:verify', jdkPath),
|
||||
installJdk: (): Promise<{ ok: boolean; path?: string; message?: string }> => ipcRenderer.invoke('jdk:install'),
|
||||
cancelJdkInstall: (): Promise<{ ok: boolean }> => ipcRenderer.invoke('jdk:cancelInstall'),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user