Files
hytale-f2p-mirror/backend/managers/javaManager.js
sanasol e1a3f919a2 Fix JRE flatten failing silently on Windows EPERM
When extracting the bundled JRE, flattenJREDir renames files from
the nested jdk subdirectory up one level. On Windows this fails with
EPERM when antivirus or file indexing holds handles open, leaving
the JRE nested and unfindable — causing "Server failed to boot".

- Fall back to copy+delete when rename gets EPERM/EACCES/EBUSY
- getBundledJavaPath checks nested JRE subdirs as last resort
2026-02-26 23:39:43 +01:00

499 lines
14 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const { promisify } = require('util');
const axios = require('axios');
const AdmZip = require('adm-zip');
const crypto = require('crypto');
const tar = require('tar');
const { expandHome, JRE_DIR } = require('../core/paths');
const { getOS, getArch } = require('../utils/platformUtils');
const { loadConfig } = require('../core/config');
const { downloadFile, retryDownload } = require('../utils/fileManager');
const execFileAsync = promisify(execFile);
const JAVA_EXECUTABLE = 'java' + (process.platform === 'win32' ? '.exe' : '');
async function findJavaOnPath(commandName = 'java') {
const lookupCmd = process.platform === 'win32' ? 'where' : 'which';
try {
const { stdout } = await execFileAsync(lookupCmd, [commandName]);
const line = stdout.split(/\r?\n/).map(lineItem => lineItem.trim()).find(Boolean);
return line || null;
} catch (err) {
return null;
}
}
async function getMacJavaHome() {
if (process.platform !== 'darwin') {
return null;
}
try {
const { stdout } = await execFileAsync('/usr/libexec/java_home');
const home = stdout.trim();
if (!home) {
return null;
}
return path.join(home, 'bin', JAVA_EXECUTABLE);
} catch (err) {
return null;
}
}
async function resolveJavaPath(inputPath) {
const trimmed = (inputPath || '').trim();
if (!trimmed) {
return null;
}
const expanded = expandHome(trimmed);
if (fs.existsSync(expanded)) {
const stat = fs.statSync(expanded);
if (stat.isDirectory()) {
const candidate = path.join(expanded, 'bin', JAVA_EXECUTABLE);
return fs.existsSync(candidate) ? candidate : null;
}
return expanded;
}
if (!path.isAbsolute(expanded)) {
return await findJavaOnPath(trimmed);
}
return null;
}
async function detectSystemJava() {
const envHome = process.env.JAVA_HOME;
if (envHome) {
const envJava = path.join(envHome, 'bin', JAVA_EXECUTABLE);
if (fs.existsSync(envJava)) {
return envJava;
}
}
const macJava = await getMacJavaHome();
if (macJava && fs.existsSync(macJava)) {
return macJava;
}
const pathJava = await findJavaOnPath('java');
if (pathJava && fs.existsSync(pathJava)) {
return pathJava;
}
return null;
}
function loadJavaPath() {
const config = loadConfig();
return config.javaPath || '';
}
function getBundledJavaPath(jreDir = JRE_DIR) {
const candidates = [
path.join(jreDir, 'bin', JAVA_EXECUTABLE)
];
if (process.platform === 'darwin') {
candidates.push(path.join(jreDir, 'Contents', 'Home', 'bin', JAVA_EXECUTABLE));
}
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
// Fallback: check for nested JRE directory (e.g. jdk-25.0.2+10-jre/bin/java)
// This happens when flattenJREDir fails due to EPERM/EACCES on Windows
try {
if (fs.existsSync(jreDir)) {
const entries = fs.readdirSync(jreDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== 'bin' && entry.name !== 'lib') {
const nestedCandidate = path.join(jreDir, entry.name, 'bin', JAVA_EXECUTABLE);
if (fs.existsSync(nestedCandidate)) {
console.log(`[JRE] Using nested Java path: ${nestedCandidate}`);
return nestedCandidate;
}
}
}
}
} catch (_) { /* ignore */ }
return null;
}
function getJavaExec(jreDir = JRE_DIR) {
const bundledJava = getBundledJavaPath(jreDir);
if (bundledJava) {
return bundledJava;
}
console.log('Notice: Java runtime not found, using system default');
return 'java';
}
async function getJavaDetection() {
const candidates = [];
const bundledJava = getBundledJavaPath() || path.join(JRE_DIR, 'bin', JAVA_EXECUTABLE);
candidates.push({
label: 'Bundled JRE',
path: bundledJava,
exists: fs.existsSync(bundledJava)
});
const javaHomeEnv = process.env.JAVA_HOME;
if (javaHomeEnv) {
const envJava = path.join(javaHomeEnv, 'bin', JAVA_EXECUTABLE);
candidates.push({
label: 'JAVA_HOME',
path: envJava,
exists: fs.existsSync(envJava),
note: fs.existsSync(envJava) ? '' : 'Not found'
});
} else {
candidates.push({
label: 'JAVA_HOME',
path: '',
exists: false,
note: 'Not set'
});
}
if (process.platform === 'darwin') {
const macJava = await getMacJavaHome();
if (macJava) {
candidates.push({
label: 'java_home',
path: macJava,
exists: fs.existsSync(macJava),
note: fs.existsSync(macJava) ? '' : 'Not found'
});
} else {
candidates.push({
label: 'java_home',
path: '',
exists: false,
note: 'Not found'
});
}
}
const pathJava = await findJavaOnPath('java');
if (pathJava) {
candidates.push({
label: 'PATH',
path: pathJava,
exists: true
});
} else {
candidates.push({
label: 'PATH',
path: '',
exists: false,
note: 'java not found'
});
}
return {
javaPath: loadJavaPath(),
candidates
};
}
// Manual retry function for JRE downloads
async function retryJREDownload(url, cacheFile, progressCallback) {
console.log('Initiating manual JRE retry...');
// Ensure cache directory exists before retrying
const cacheDir = path.dirname(cacheFile);
if (!fs.existsSync(cacheDir)) {
console.log('Creating JRE cache directory:', cacheDir);
fs.mkdirSync(cacheDir, { recursive: true });
}
return await retryDownload(url, cacheFile, progressCallback);
}
async function downloadJRE(progressCallback, cacheDir, jreDir = JRE_DIR) {
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
const osName = getOS();
const arch = getArch();
const bundledJava = getBundledJavaPath(jreDir);
if (bundledJava) {
console.log('Java runtime found, skipping download');
return;
}
console.log('Requesting Java runtime information...');
const response = await axios.get('https://launcher.hytale.com/version/release/jre.json', {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.9'
}
});
const jreData = response.data;
const osData = jreData.download_url[osName];
if (!osData) {
throw new Error(`Java runtime unavailable for platform: ${osName}`);
}
const platform = osData[arch];
if (!platform) {
throw new Error(`Java runtime unavailable for architecture ${arch} on ${osName}`);
}
const fileName = path.basename(platform.url);
const cacheFile = path.join(cacheDir, fileName);
if (!fs.existsSync(cacheFile)) {
if (progressCallback) {
progressCallback('Fetching Java runtime...', null, null, null, null);
}
console.log('Fetching Java runtime...');
let jreFile;
try {
jreFile = await downloadFile(platform.url, cacheFile, progressCallback);
// If downloadFile returns false or undefined, it means the download failed
// We should retry the download with a manual retry
if (!jreFile || typeof jreFile !== 'string') {
console.log('[JRE Download] JRE file download failed or incomplete, attempting retry...');
jreFile = await retryJREDownload(platform.url, cacheFile, progressCallback);
}
// Double-check we have a valid file
if (!jreFile || typeof jreFile !== 'string') {
throw new Error(`JRE download failed: received invalid path ${jreFile}. Please retry download.`);
}
} catch (downloadError) {
console.error('[JRE Download] JRE download failed:', downloadError.message);
// Enhance error with retry information for the UI
const enhancedError = new Error(`JRE download failed: ${downloadError.message}`);
enhancedError.originalError = downloadError;
enhancedError.canRetry = downloadError.isConnectionLost ? false : (downloadError.canRetry !== false);
enhancedError.jreUrl = platform.url;
enhancedError.jreDest = cacheFile;
enhancedError.osName = osName;
enhancedError.arch = arch;
enhancedError.fileName = fileName;
enhancedError.cacheDir = cacheDir;
enhancedError.isJREError = true; // Flag to identify JRE errors
enhancedError.isConnectionLost = downloadError.isConnectionLost || false;
throw enhancedError;
}
console.log('Download finished');
}
if (progressCallback) {
progressCallback('Validating files...', null, null, null, null);
}
console.log('Validating files...');
const fileBuffer = fs.readFileSync(cacheFile);
const hashSum = crypto.createHash('sha256');
hashSum.update(fileBuffer);
const hex = hashSum.digest('hex');
if (hex !== platform.sha256) {
fs.unlinkSync(cacheFile);
throw new Error(`File validation failed: expected ${platform.sha256} but got ${hex}`);
}
if (progressCallback) {
progressCallback('Unpacking Java runtime...', null, null, null, null);
}
console.log('Unpacking Java runtime...');
await extractJRE(cacheFile, jreDir);
if (process.platform !== 'win32') {
const javaCandidates = [
path.join(jreDir, 'bin', JAVA_EXECUTABLE),
path.join(jreDir, 'Contents', 'Home', 'bin', JAVA_EXECUTABLE)
];
for (const javaPath of javaCandidates) {
if (fs.existsSync(javaPath)) {
fs.chmodSync(javaPath, 0o755);
}
}
}
flattenJREDir(jreDir);
try {
fs.unlinkSync(cacheFile);
} catch (err) {
console.log('Notice: could not delete cached Java files:', err.message);
}
console.log('Java runtime ready');
}
async function extractJRE(archivePath, destDir) {
if (fs.existsSync(destDir)) {
fs.rmSync(destDir, { recursive: true, force: true });
}
fs.mkdirSync(destDir, { recursive: true });
if (archivePath.endsWith('.zip')) {
return extractZip(archivePath, destDir);
} else if (archivePath.endsWith('.tar.gz')) {
return extractTarGz(archivePath, destDir);
} else {
throw new Error(`Archive type not supported: ${archivePath}`);
}
}
function extractZip(zipPath, dest) {
try {
const zip = new AdmZip(zipPath);
const entries = zip.getEntries();
for (const entry of entries) {
const entryPath = path.join(dest, entry.entryName);
// Security check: prevent zip slip attacks
const resolvedPath = path.resolve(entryPath);
const resolvedDest = path.resolve(dest);
if (!resolvedPath.startsWith(resolvedDest)) {
throw new Error(`Invalid file path detected: ${entryPath}`);
}
try {
if (entry.isDirectory) {
fs.mkdirSync(entryPath, { recursive: true });
} else {
// Ensure parent directory exists
const parentDir = path.dirname(entryPath);
fs.mkdirSync(parentDir, { recursive: true });
// Get file data and write it
const data = entry.getData();
if (!data) {
console.warn(`Warning: No data for file ${entry.entryName}, skipping`);
continue;
}
fs.writeFileSync(entryPath, data);
// Set permissions on non-Windows platforms
if (process.platform !== 'win32') {
try {
const mode = entry.header.attr >>> 16;
if (mode > 0) {
fs.chmodSync(entryPath, mode);
}
} catch (chmodError) {
console.warn(`Warning: Could not set permissions for ${entryPath}: ${chmodError.message}`);
}
}
}
} catch (entryError) {
console.error(`Error extracting ${entry.entryName}: ${entryError.message}`);
// Continue with other entries rather than failing completely
continue;
}
}
} catch (error) {
throw new Error(`Failed to extract ZIP archive: ${error.message}`);
}
}
function extractTarGz(tarGzPath, dest) {
try {
return tar.extract({
file: tarGzPath,
cwd: dest,
strip: 0
});
} catch (error) {
throw new Error(`Failed to extract TAR.GZ archive: ${error.message}`);
}
}
function flattenJREDir(jreLatest) {
try {
const entries = fs.readdirSync(jreLatest, { withFileTypes: true });
if (entries.length !== 1 || !entries[0].isDirectory()) {
return;
}
const nested = path.join(jreLatest, entries[0].name);
const files = fs.readdirSync(nested, { withFileTypes: true });
for (const file of files) {
const oldPath = path.join(nested, file.name);
const newPath = path.join(jreLatest, file.name);
try {
fs.renameSync(oldPath, newPath);
} catch (renameErr) {
if (renameErr.code === 'EPERM' || renameErr.code === 'EACCES' || renameErr.code === 'EBUSY') {
console.log(`[JRE] Rename failed for ${file.name} (${renameErr.code}), using copy fallback`);
copyRecursiveSync(oldPath, newPath);
} else {
throw renameErr;
}
}
}
try {
fs.rmSync(nested, { recursive: true, force: true });
} catch (rmErr) {
console.log('[JRE] Could not remove nested JRE dir (non-critical):', rmErr.message);
}
} catch (err) {
console.error('[JRE] Failed to restructure Java directory:', err.message);
// Last resort: check if java exists in a nested subdir and skip flatten
try {
const entries = fs.readdirSync(jreLatest, { withFileTypes: true });
const nestedDir = entries.find(e => e.isDirectory() && e.name !== 'bin' && e.name !== 'lib');
if (nestedDir) {
const nestedBin = path.join(jreLatest, nestedDir.name, 'bin', process.platform === 'win32' ? 'java.exe' : 'java');
if (fs.existsSync(nestedBin)) {
console.log(`[JRE] Java found in nested dir: ${nestedDir.name}, leaving structure as-is`);
}
}
} catch (_) { /* ignore */ }
}
}
function copyRecursiveSync(src, dest) {
const stat = fs.statSync(src);
if (stat.isDirectory()) {
fs.mkdirSync(dest, { recursive: true });
for (const child of fs.readdirSync(src)) {
copyRecursiveSync(path.join(src, child), path.join(dest, child));
}
} else {
fs.copyFileSync(src, dest);
}
}
module.exports = {
findJavaOnPath,
getMacJavaHome,
resolveJavaPath,
detectSystemJava,
loadJavaPath,
getBundledJavaPath,
getJavaExec,
getJavaDetection,
downloadJRE,
extractJRE,
retryJREDownload,
JAVA_EXECUTABLE
};