mirror of
https://gitea.shironeko-all.duckdns.org/shironeko/Hytale-F2P-2.git
synced 2026-02-26 10:41:46 -03:00
Add launcher.js for game management and launching
This commit is contained in:
472
backend/launcher.js
Normal file
472
backend/launcher.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user