const fs = require('fs'); const path = require('path'); const os = require('os'); const { exec, execFile } = require('child_process'); const { promisify } = require('util'); const axios = require('axios'); const AdmZip = require('adm-zip'); const { v4: uuidv4 } = require('uuid'); const crypto = require('crypto'); const execAsync = promisify(exec); const execFileAsync = promisify(execFile); const JAVA_EXECUTABLE = 'java' + (process.platform === 'win32' ? '.exe' : ''); function getAppDir() { const home = os.homedir(); if (process.platform === 'win32') { return path.join(home, 'AppData', 'Local', 'HytaleF2P'); } else if (process.platform === 'darwin') { return path.join(home, 'Library', 'Application Support', 'HytaleF2P'); } else { return path.join(home, '.hytalef2p'); } } const APP_DIR = getAppDir(); const CACHE_DIR = path.join(APP_DIR, 'cache'); const TOOLS_DIR = path.join(APP_DIR, 'butler'); const GAME_DIR = path.join(APP_DIR, 'release', 'package', 'game', 'latest'); const JRE_DIR = path.join(APP_DIR, 'release', 'package', 'jre', 'latest'); const CONFIG_FILE = path.join(APP_DIR, 'config.json'); function expandHome(inputPath) { if (!inputPath) { return inputPath; } if (inputPath === '~') { return os.homedir(); } if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) { return path.join(os.homedir(), inputPath.slice(2)); } return inputPath; } function loadConfig() { try { if (fs.existsSync(CONFIG_FILE)) { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } } catch (err) { console.log('Notice: could not load config:', err.message); } return {}; } function saveConfig(update) { try { createFolders(); const config = loadConfig(); const next = { ...config, ...update }; fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), 'utf8'); } catch (err) { console.log('Notice: could not save config:', err.message); } } 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; } 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 }; } function getOS() { if (process.platform === 'win32') return 'windows'; if (process.platform === 'darwin') return 'darwin'; if (process.platform === 'linux') return 'linux'; return 'unknown'; } function getArch() { return process.arch === 'x64' ? 'amd64' : process.arch; } function createFolders() { const dirs = [ APP_DIR, CACHE_DIR, TOOLS_DIR, path.join(APP_DIR, 'release', 'package', 'jre'), path.join(APP_DIR, 'release', 'package', 'game') ]; dirs.forEach(dir => { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } }); } async function downloadFile(url, dest, progressCallback) { const response = await axios({ method: 'GET', url: url, responseType: 'stream', 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': '*/*', 'Accept-Language': 'en-US,en;q=0.9', 'Referer': 'https://launcher.hytale.com/' } }); const totalSize = parseInt(response.headers['content-length'], 10); let downloaded = 0; const startTime = Date.now(); const writer = fs.createWriteStream(dest); response.data.on('data', (chunk) => { downloaded += chunk.length; if (progressCallback && totalSize > 0) { const percent = Math.min(100, Math.max(0, (downloaded / totalSize) * 100)); const elapsed = (Date.now() - startTime) / 1000; const speed = elapsed > 0 ? downloaded / elapsed : 0; progressCallback(null, percent, speed, downloaded, totalSize); } }); response.data.pipe(writer); return new Promise((resolve, reject) => { writer.on('finish', resolve); writer.on('error', reject); response.data.on('error', reject); }); } async function installButler() { createFolders(); const butlerName = process.platform === 'win32' ? 'butler.exe' : 'butler'; const butlerPath = path.join(TOOLS_DIR, butlerName); const zipPath = path.join(TOOLS_DIR, 'butler.zip'); if (fs.existsSync(butlerPath)) { return butlerPath; } let urls = []; const osName = getOS(); const arch = getArch(); if (osName === 'windows') { urls = ['https://broth.itch.zone/butler/windows-amd64/LATEST/archive/default']; } else if (osName === 'darwin') { if (arch === 'arm64') { urls = [ 'https://broth.itch.zone/butler/darwin-arm64/LATEST/archive/default', 'https://broth.itch.zone/butler/darwin-amd64/LATEST/archive/default' ]; } else { urls = ['https://broth.itch.zone/butler/darwin-amd64/LATEST/archive/default']; } } else if (osName === 'linux') { urls = ['https://broth.itch.zone/butler/linux-amd64/LATEST/archive/default']; } else { throw new Error('Operating system not supported'); } console.log('Fetching Butler tool...'); let lastError = null; for (const url of urls) { try { await downloadFile(url, zipPath); lastError = null; break; } catch (error) { lastError = error; } } if (lastError) { throw lastError; } console.log('Unpacking Butler...'); const zip = new AdmZip(zipPath); zip.extractAllTo(TOOLS_DIR, true); if (process.platform !== 'win32') { fs.chmodSync(butlerPath, 0o755); } try { fs.unlinkSync(zipPath); } catch (err) { console.log('Notice: could not delete butler.zip'); } return butlerPath; } async function downloadPWR(version = 'release', fileName = '1.pwr', progressCallback) { createFolders(); const osName = getOS(); const arch = getArch(); const url = `https://game-patches.hytale.com/patches/${osName}/${arch}/${version}/0/${fileName}`; const dest = path.join(CACHE_DIR, fileName); if (fs.existsSync(dest)) { console.log('PWR file found in cache:', dest); return dest; } console.log('Fetching PWR patch file:', url); await downloadFile(url, dest, progressCallback); console.log('PWR saved to:', dest); return dest; } async function applyPWR(pwrFile, progressCallback) { const butlerPath = await installButler(); const gameLatest = GAME_DIR; const stagingDir = path.join(gameLatest, 'staging-temp'); const clientPath = findClientPath(gameLatest); if (clientPath) { console.log('Game files detected, skipping patch installation.'); return; } if (!fs.existsSync(gameLatest)) { fs.mkdirSync(gameLatest, { recursive: true }); } if (!fs.existsSync(stagingDir)) { fs.mkdirSync(stagingDir, { recursive: true }); } if (progressCallback) { progressCallback('Installing game patch...', null, null, null, null); } console.log('Installing game patch...'); if (!fs.existsSync(butlerPath)) { throw new Error(`Butler tool not found at: ${butlerPath}`); } if (!fs.existsSync(pwrFile)) { throw new Error(`PWR file not found at: ${pwrFile}`); } const args = [ 'apply', '--staging-dir', stagingDir, pwrFile, gameLatest ]; try { await new Promise((resolve, reject) => { const child = execFile(butlerPath, args, { maxBuffer: 1024 * 1024 * 10, timeout: 600000 }, (error, stdout, stderr) => { if (error) { console.error('Butler stderr:', stderr); console.error('Butler stdout:', stdout); reject(new Error(`Patch installation failed: ${error.message}${stderr ? '\n' + stderr : ''}`)); } else { resolve(); } }); }); } catch (error) { throw error; } if (fs.existsSync(stagingDir)) { fs.rmSync(stagingDir, { recursive: true, force: true }); } if (progressCallback) { progressCallback('Installation complete', null, null, null, null); } console.log('Installation complete'); } async function downloadJRE(progressCallback) { createFolders(); const osName = getOS(); const arch = getArch(); const bundledJava = getBundledJavaPath(); 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(CACHE_DIR, fileName); if (!fs.existsSync(cacheFile)) { if (progressCallback) { progressCallback('Fetching Java runtime...', null, null, null, null); } console.log('Fetching Java runtime...'); await downloadFile(platform.url, cacheFile, progressCallback); 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, JRE_DIR); if (process.platform !== 'win32') { const javaCandidates = [ path.join(JRE_DIR, 'bin', JAVA_EXECUTABLE), path.join(JRE_DIR, 'Contents', 'Home', 'bin', JAVA_EXECUTABLE) ]; for (const javaPath of javaCandidates) { if (fs.existsSync(javaPath)) { fs.chmodSync(javaPath, 0o755); } } } flattenJREDir(JRE_DIR); 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) { const zip = new AdmZip(zipPath); const entries = zip.getEntries(); for (const entry of entries) { const entryPath = path.join(dest, entry.entryName); const resolvedPath = path.resolve(entryPath); const resolvedDest = path.resolve(dest); if (!resolvedPath.startsWith(resolvedDest)) { throw new Error(`Invalid file path detected: ${entryPath}`); } if (entry.isDirectory) { fs.mkdirSync(entryPath, { recursive: true }); } else { fs.mkdirSync(path.dirname(entryPath), { recursive: true }); fs.writeFileSync(entryPath, entry.getData()); if (process.platform !== 'win32') { fs.chmodSync(entryPath, entry.header.attr >>> 16); } } } } function extractTarGz(tarGzPath, dest) { const tar = require('tar'); return tar.extract({ file: tarGzPath, cwd: dest, strip: 0 }); } 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); fs.renameSync(oldPath, newPath); } fs.rmSync(nested, { recursive: true, force: true }); } catch (err) { console.log('Notice: could not restructure Java directory:', err.message); } } function getBundledJavaPath() { const candidates = [ path.join(JRE_DIR, 'bin', JAVA_EXECUTABLE) ]; if (process.platform === 'darwin') { candidates.push(path.join(JRE_DIR, 'Contents', 'Home', 'bin', JAVA_EXECUTABLE)); } for (const candidate of candidates) { if (fs.existsSync(candidate)) { return candidate; } } return null; } function getJavaExec() { const bundledJava = getBundledJavaPath(); if (bundledJava) { return bundledJava; } console.log('Notice: Java runtime not found, using system default'); return 'java'; } function getClientCandidates(gameLatest) { const candidates = []; if (process.platform === 'win32') { candidates.push(path.join(gameLatest, 'Client', 'HytaleClient.exe')); } else if (process.platform === 'darwin') { candidates.push(path.join(gameLatest, 'Client', 'Hytale.app', 'Contents', 'MacOS', 'HytaleClient')); candidates.push(path.join(gameLatest, 'Client', 'HytaleClient')); } else { candidates.push(path.join(gameLatest, 'Client', 'HytaleClient')); } return candidates; } function findClientPath(gameLatest) { const candidates = getClientCandidates(gameLatest); for (const candidate of candidates) { if (fs.existsSync(candidate)) { return candidate; } } return null; } async function launchGame(playerName = 'Player', progressCallback, javaPathOverride) { createFolders(); saveUsername(playerName); const configuredJava = (javaPathOverride !== undefined && javaPathOverride !== null ? javaPathOverride : loadJavaPath() || '').trim(); let javaBin = null; if (configuredJava) { javaBin = await resolveJavaPath(configuredJava); if (!javaBin) { throw new Error(`Configured Java path not found: ${configuredJava}`); } } else { try { await downloadJRE(progressCallback); } catch (error) { const fallback = await detectSystemJava(); if (fallback) { javaBin = fallback; } else { throw error; } } if (!javaBin) { javaBin = getJavaExec(); } } const gameLatest = GAME_DIR; let clientPath = findClientPath(gameLatest); if (!clientPath) { if (progressCallback) { progressCallback('Fetching game files...', null, null, null, null); } console.log('Game files missing, downloading and installing patch...'); const pwrFile = await downloadPWR('release', '1.pwr', progressCallback); await applyPWR(pwrFile, progressCallback); } clientPath = findClientPath(gameLatest); if (!clientPath) { const attempted = getClientCandidates(gameLatest).join(', '); throw new Error(`Game client missing. Tried: ${attempted}`); } const uuid = getUuidForUser(playerName); const args = [ '--app-dir', gameLatest, '--java-exec', javaBin, '--auth-mode', 'offline', '--uuid', uuid, '--name', playerName ]; if (progressCallback) { progressCallback('Starting game...', null, null, null, null); } console.log('Starting game...'); console.log(`Command: "${clientPath}" ${args.join(' ')}`); const child = exec(`"${clientPath}" ${args.map(a => `"${a}"`).join(' ')}`, { stdio: 'inherit', detached: true }); child.unref(); } function saveUsername(username) { saveConfig({ username: username || 'Player' }); } function loadUsername() { const config = loadConfig(); return config.username || 'Player'; } function getUuidForUser(username) { const config = loadConfig(); const userUuids = config.userUuids || {}; if (userUuids[username]) { return userUuids[username]; } const newUuid = uuidv4(); userUuids[username] = newUuid; saveConfig({ userUuids }); return newUuid; } function saveJavaPath(javaPath) { const trimmed = (javaPath || '').trim(); saveConfig({ javaPath: trimmed }); } function loadJavaPath() { const config = loadConfig(); return config.javaPath || ''; } module.exports = { launchGame, saveUsername, loadUsername, saveJavaPath, loadJavaPath, getJavaDetection };