diff --git a/backend/launcher.js b/backend/launcher.js new file mode 100644 index 0000000..cc522cf --- /dev/null +++ b/backend/launcher.js @@ -0,0 +1,472 @@ +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); + +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 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 url; + const osName = getOS(); + if (osName === 'windows') { + url = 'https://broth.itch.zone/butler/windows-amd64/LATEST/archive/default'; + } else if (osName === 'darwin') { + url = 'https://broth.itch.zone/butler/darwin-amd64/LATEST/archive/default'; + } else if (osName === 'linux') { + url = 'https://broth.itch.zone/butler/linux-amd64/LATEST/archive/default'; + } else { + throw new Error('Operating system not supported'); + } + + console.log('Fetching Butler tool...'); + await downloadFile(url, zipPath); + + 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 gameClient = process.platform === 'win32' ? 'HytaleClient.exe' : 'HytaleClient'; + const clientPath = path.join(gameLatest, 'Client', gameClient); + + if (fs.existsSync(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 javaBin = path.join(JRE_DIR, 'bin', 'java' + (process.platform === 'win32' ? '.exe' : '')); + if (fs.existsSync(javaBin)) { + 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 java = path.join(JRE_DIR, 'bin', 'java'); + if (fs.existsSync(java)) { + fs.chmodSync(java, 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 getJavaExec() { + const javaBin = path.join(JRE_DIR, 'bin', 'java' + (process.platform === 'win32' ? '.exe' : '')); + + if (fs.existsSync(javaBin)) { + return javaBin; + } + + console.log('Notice: Java runtime not found, using system default'); + return 'java'; +} + +async function launchGame(playerName = 'Player', progressCallback) { + createFolders(); + + saveUsername(playerName); + + await downloadJRE(progressCallback); + + const javaBin = getJavaExec(); + + const gameLatest = GAME_DIR; + const gameClient = process.platform === 'win32' ? 'HytaleClient.exe' : 'HytaleClient'; + const clientPath = path.join(gameLatest, 'Client', gameClient); + + if (!fs.existsSync(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); + } + + if (!fs.existsSync(clientPath)) { + throw new Error(`Game client missing at path: ${clientPath}`); + } + + const uuid = uuidv4(); + 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) { + try { + createFolders(); + const config = { username: username || 'Player' }; + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8'); + } catch (err) { + console.log('Notice: could not save username:', err.message); + } +} + +function loadUsername() { + try { + if (fs.existsSync(CONFIG_FILE)) { + const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); + return config.username || 'Player'; + } + } catch (err) { + console.log('Notice: could not load username:', err.message); + } + return 'Player'; +} + +module.exports = { + launchGame, + saveUsername, + loadUsername +};