|
|
|
|
@@ -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 가 <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 자동 설치(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<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 +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 }> => {
|
|
|
|
|
|