const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { exec } = require('child_process'); const { promisify } = require('util'); const { spawn } = require('child_process'); const { v4: uuidv4 } = require('uuid'); const { getResolvedAppDir, findClientPath } = require('../core/paths'); const { setupWaylandEnvironment, setupGpuEnvironment } = require('../utils/platformUtils'); const { saveUsername, saveInstallPath, loadJavaPath, getUuidForUser, getAuthServerUrl, getAuthDomain, loadVersionBranch, loadVersionClient, saveVersionClient } = require('../core/config'); const { resolveJavaPath, getJavaExec, getBundledJavaPath, detectSystemJava, JAVA_EXECUTABLE } = require('./javaManager'); const { getLatestClientVersion } = require('../services/versionManager'); const { updateGameFiles } = require('./gameManager'); const { syncModsForCurrentProfile } = require('./modManager'); const { getUserDataPath } = require('../utils/userDataMigration'); // Client patcher for custom auth server (sanasol.ws) let clientPatcher = null; try { clientPatcher = require('../utils/clientPatcher'); } catch (err) { console.log('[Launcher] Client patcher not available:', err.message); } const execAsync = promisify(exec); // Fetch tokens from the auth server (properly signed with server's Ed25519 key) async function fetchAuthTokens(uuid, name) { const authServerUrl = getAuthServerUrl(); try { console.log(`Fetching auth tokens from ${authServerUrl}/game-session/child`); const response = await fetch(`${authServerUrl}/game-session/child`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uuid: uuid, name: name, scopes: ['hytale:server', 'hytale:client'] }) }); if (!response.ok) { throw new Error(`Auth server returned ${response.status}`); } const data = await response.json(); console.log('Auth tokens received from server'); return { identityToken: data.IdentityToken || data.identityToken, sessionToken: data.SessionToken || data.sessionToken }; } catch (error) { console.error('Failed to fetch auth tokens:', error.message); // Fallback to local generation if server unavailable return generateLocalTokens(uuid, name); } } // Fallback: Generate tokens locally (won't pass signature validation but allows offline testing) function generateLocalTokens(uuid, name) { console.log('Using locally generated tokens (fallback mode)'); const authServerUrl = getAuthServerUrl(); const now = Math.floor(Date.now() / 1000); const exp = now + 36000; const header = Buffer.from(JSON.stringify({ alg: 'EdDSA', kid: '2025-10-01', typ: 'JWT' })).toString('base64url'); const identityPayload = Buffer.from(JSON.stringify({ sub: uuid, name: name, username: name, entitlements: ['game.base'], scope: 'hytale:server hytale:client', iat: now, exp: exp, iss: authServerUrl, jti: uuidv4() })).toString('base64url'); const sessionPayload = Buffer.from(JSON.stringify({ sub: uuid, scope: 'hytale:server', iat: now, exp: exp, iss: authServerUrl, jti: uuidv4() })).toString('base64url'); const signature = crypto.randomBytes(64).toString('base64url'); return { identityToken: `${header}.${identityPayload}.${signature}`, sessionToken: `${header}.${sessionPayload}.${signature}` }; } async function launchGame(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) { const branch = branchOverride || loadVersionBranch(); const customAppDir = getResolvedAppDir(installPathOverride); const customGameDir = path.join(customAppDir, branch, 'package', 'game', 'latest'); const customJreDir = path.join(customAppDir, branch, 'package', 'jre', 'latest'); // NEW 2.1.2: Use centralized UserData location const userDataDir = getUserDataPath(); const gameLatest = customGameDir; let clientPath = findClientPath(gameLatest); if (!clientPath) { throw new Error('Game is not installed. Please install the game first.'); } saveUsername(playerName); if (installPathOverride) { saveInstallPath(installPathOverride); } 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 { javaBin = getJavaExec(customJreDir); if (!getBundledJavaPath(customJreDir)) { const fallback = await detectSystemJava(); if (fallback) { javaBin = fallback; } else { throw new Error('Java runtime not found. Please install the game first or configure Java path.'); } } } const uuid = getUuidForUser(playerName); // Fetch tokens from auth server if (progressCallback) { progressCallback('Fetching authentication tokens...', null, null, null, null); } const { identityToken, sessionToken } = await fetchAuthTokens(uuid, playerName); // Patch client and server binaries to use custom auth server (BEFORE signing on macOS) // FORCE patch on every launch to ensure consistency const authDomain = getAuthDomain(); if (clientPatcher) { try { if (progressCallback) { progressCallback('Patching game for custom server...', null, null, null, null); } console.log(`Force patching game binaries for ${authDomain}...`); const patchResult = await clientPatcher.ensureClientPatched(gameLatest, (msg, percent) => { // console.log(`[Patcher] ${msg}`); if (progressCallback && msg) { progressCallback(msg, percent, null, null, null); } }); if (patchResult.success) { console.log(`Game patched successfully (${patchResult.patchCount} total occurrences)`); if (patchResult.client) { console.log(` Client: ${patchResult.client.patchCount || 0} occurrences`); } if (patchResult.server) { console.log(` Server: ${patchResult.server.patchCount || 0} occurrences`); } } else { console.warn('Game patching failed:', patchResult.error); } } catch (patchError) { console.warn('Game patching failed (game may not connect to custom server):', patchError.message); } } // macOS: Sign binaries AFTER patching so the patched binaries have valid signatures if (process.platform === 'darwin') { try { const appBundle = path.join(gameLatest, 'Client', 'Hytale.app'); const serverDir = path.join(gameLatest, 'Server'); const signPath = async (targetPath, deep = false) => { await execAsync(`xattr -cr "${targetPath}"`).catch(() => { }); const deepFlag = deep ? '--deep ' : ''; await execAsync(`codesign --force ${deepFlag}--sign - "${targetPath}"`).catch(() => { }); }; if (fs.existsSync(appBundle)) { await signPath(appBundle, true); console.log('Signed macOS app bundle (after patching)'); } else { await signPath(path.dirname(clientPath), true); console.log('Signed macOS client binary (after patching)'); } if (javaBin && fs.existsSync(javaBin)) { let jreRoot = path.dirname(path.dirname(javaBin)); if (jreRoot.endsWith('Home')) { jreRoot = path.dirname(path.dirname(jreRoot)); } await signPath(jreRoot, true); await signPath(javaBin, false); console.log('Signed Java runtime'); } if (fs.existsSync(serverDir)) { await execAsync(`xattr -cr "${serverDir}"`).catch(() => { }); await execAsync(`find "${serverDir}" -type f -perm +111 -exec codesign --force --sign - {} \\;`).catch(() => { }); console.log('Signed server binaries (after patching)'); } if (javaBin && fs.existsSync(javaBin)) { const javaWrapperPath = path.join(path.dirname(javaBin), 'java-wrapper'); const wrapperScript = `#!/bin/bash # Java wrapper for macOS - adds --disable-sentry to fix Sentry hang issue REAL_JAVA="${javaBin}" ARGS=("$@") for i in "\${!ARGS[@]}"; do if [[ "\${ARGS[$i]}" == *"HytaleServer.jar"* ]]; then ARGS=("\${ARGS[@]:0:$((i+1))}" "--disable-sentry" "\${ARGS[@]:$((i+1))}") break fi done exec "$REAL_JAVA" "\${ARGS[@]}" `; fs.writeFileSync(javaWrapperPath, wrapperScript, { mode: 0o755 }); await signPath(javaWrapperPath, false); console.log('Created java wrapper with --disable-sentry fix'); javaBin = javaWrapperPath; } } catch (signError) { console.log('Notice: macOS signing step failed:', signError.message); console.log('The game may still launch if Gatekeeper allows it'); } } const args = [ '--app-dir', gameLatest, '--java-exec', javaBin, '--auth-mode', 'authenticated', '--uuid', uuid, '--name', playerName, '--identity-token', identityToken, '--session-token', sessionToken, '--user-dir', userDataDir ]; if (progressCallback) { progressCallback('Starting game...', null, null, null, null); } // Ensure mods are synced for the active profile before launching try { console.log('Syncing mods for active profile before launch...'); if (progressCallback) progressCallback('Syncing mods...', null, null, null, null); await syncModsForCurrentProfile(); } catch (syncError) { console.error('Failed to sync mods before launch:', syncError); // Continue anyway? Or fail? // Warn user but continue might be safer to avoid blocking play if sync is just glitchy } console.log('Starting game...'); console.log(`Command: "${clientPath}" ${args.join(' ')}`); const env = { ...process.env }; const waylandEnv = setupWaylandEnvironment(); Object.assign(env, waylandEnv); const gpuEnv = setupGpuEnvironment(gpuPreference); Object.assign(env, gpuEnv); // Linux memory allocator fixes for "free(): invalid pointer" crashes // on Steam Deck (glibc 2.41) and Ubuntu LTS if (process.platform === 'linux') { // Option 1: Disable glibc heap validation env.MALLOC_CHECK_ = '0'; // Option 2: Try to use jemalloc if available (more robust allocator) // User can set HYTALE_USE_JEMALLOC=1 to enable if (process.env.HYTALE_USE_JEMALLOC === '1') { const jemalloc = require('fs').existsSync('/usr/lib/libjemalloc.so.2') ? '/usr/lib/libjemalloc.so.2' : require('fs').existsSync('/usr/lib/x86_64-linux-gnu/libjemalloc.so.2') ? '/usr/lib/x86_64-linux-gnu/libjemalloc.so.2' : null; if (jemalloc) { env.LD_PRELOAD = jemalloc + (env.LD_PRELOAD ? ':' + env.LD_PRELOAD : ''); console.log(`Linux: Using jemalloc allocator (${jemalloc})`); } else { console.log('Linux: jemalloc not found, using glibc with MALLOC_CHECK_=0'); } } else { console.log('Linux: Using glibc with MALLOC_CHECK_=0 (set HYTALE_USE_JEMALLOC=1 to try jemalloc)'); } } try { let spawnOptions = { stdio: ['ignore', 'pipe', 'pipe'], detached: true, env: env }; if (process.platform === 'win32') { spawnOptions.shell = false; spawnOptions.windowsHide = true; } const child = spawn(clientPath, args, spawnOptions); console.log(`Game process started with PID: ${child.pid}`); let hasExited = false; let outputReceived = false; child.stdout.on('data', (data) => { outputReceived = true; console.log(`Game output: ${data.toString().trim()}`); }); child.stderr.on('data', (data) => { outputReceived = true; console.error(`Game error: ${data.toString().trim()}`); }); child.on('error', (error) => { hasExited = true; console.error(`Failed to start game process: ${error.message}`); if (progressCallback) { progressCallback(`Failed to start game: ${error.message}`, -1, null, null, null); } }); child.on('exit', (code, signal) => { hasExited = true; if (code !== null) { console.log(`Game process exited with code ${code}`); if (code !== 0 && progressCallback) { progressCallback(`Game exited with error code ${code}`, -1, null, null, null); } } else if (signal) { console.log(`Game process terminated by signal ${signal}`); } }); // Monitor game process status in background setTimeout(() => { if (!hasExited) { console.log('Game appears to be running successfully'); child.unref(); if (progressCallback) { progressCallback('Game launched successfully', 100, null, null, null); } } else if (!outputReceived) { console.warn('Game process exited immediately with no output - possible issue with game files or dependencies'); } }, 3000); // Return immediately, don't wait for setTimeout return { success: true, installed: true, launched: true, pid: child.pid }; } catch (spawnError) { console.error(`Error spawning game process: ${spawnError.message}`); if (progressCallback) { progressCallback(`Error launching game: ${spawnError.message}`, -1, null, null, null); } throw spawnError; } } async function launchGameWithVersionCheck(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) { try { const branch = branchOverride || loadVersionBranch(); if (progressCallback) { progressCallback('Checking for updates...', 0, null, null, null); } const installedVersion = loadVersionClient(); const latestVersion = await getLatestClientVersion(branch); console.log(`Installed version: ${installedVersion}, Latest version: ${latestVersion} (branch: ${branch})`); let needsUpdate = false; if (!installedVersion || installedVersion !== latestVersion) { needsUpdate = true; console.log('Version mismatch or not installed, update required'); } if (needsUpdate) { if (progressCallback) { progressCallback('Game update required, starting update process...', 10, null, null, null); } const customAppDir = getResolvedAppDir(installPathOverride); const customGameDir = path.join(customAppDir, branch, 'package', 'game', 'latest'); const customToolsDir = path.join(customAppDir, 'butler'); const customCacheDir = path.join(customAppDir, 'cache'); try { await updateGameFiles(latestVersion, progressCallback, customGameDir, customToolsDir, customCacheDir, branch); console.log('Game updated successfully, patching will be forced on launch...'); if (progressCallback) { progressCallback('Preparing game launch...', 90, null, null, null); } await new Promise(resolve => setTimeout(resolve, 3000)); } catch (updateError) { console.error('Update failed:', updateError); if (progressCallback) { progressCallback(`Update failed: ${updateError.message}`, -1, null, null, null); } throw updateError; } } if (progressCallback) { progressCallback('Launching game...', 80, null, null, null); } const launchResult = await launchGame(playerName, progressCallback, javaPathOverride, installPathOverride, gpuPreference, branch); // Ensure we always return a result if (!launchResult) { console.error('launchGame returned null/undefined, creating fallback response'); return { success: false, error: 'Game launch failed - no response from launcher' }; } return launchResult; } catch (error) { console.error('Error in version check and launch:', error); if (progressCallback) { progressCallback(`Error: ${error.message}`, -1, null, null, null); } // Always return an error response instead of throwing return { success: false, error: error.message || 'Unknown launch error' }; } } module.exports = { launchGame, launchGameWithVersionCheck };