Files
Hytale-F2P-2/backend/managers/gameLauncher.js
AMIAY e5b44341f1 Develop (#282)
* fix: resolve cross-platform EPERM permissions errors

modManager.js:
- Switch from hardcoded 'junction' to dynamic symlink type based on OS (fixing Linux EPERM).
- Add retry logic for directory removal to handle file locking race conditions.
- Improve broken symlink detection during profile sync.

gameManager.js:
- Implement retry loop (3 attempts) for game directory removal in updateGameFiles to prevent EBUSY/EPERM errors on Windows.

paths.js:
- Prevent fs.mkdirSync failure in getModsPath by pre-checking for broken symbolic links.

* fix: missing pacman builds

* prepare release for 2.1.1

minor fix for EPERM error permission

* prepare release 2.1.1

minor fix EPERM permission error

* prepare release 2.1.1

* Update README.md Windows Prequisites for ARM64 builds

* fix: remove broken symlink after detected

* fix: add pathexists for paths.js to check symlink

* fix: isbrokenlink should be true to remove the symlink

* add arch package .pkg.tar.zst for release

* fix: release workflow for build-arch and build-linux

* build-arch job now only build arch .pkg.tar.zst package instead of the whole generic linux.
* build-linux job now exclude .pacman package since its deprecated and should not be used.

* fix: removes pacman build as it replaced by tar.zst and adds build:arch shortcut for pkgbuild

* aur: add proper VCS (-git) PKGBUILD

created clean VCS-based PKGBUILD following arch packaging conventions.

this explicitly marked as a rolling (-git) build and derives its version dynamically from git tags and commit history via pkgver(). previous hybrid approach has been changed.

key changes:
- use -git suffix to clearly indicate rolling source builds
- set pkgver=0 and compute the actual version via pkgver()
- build only a directory layout using electron-builder (--dir)
- avoid generating AppImage, deb, rpm, or pacman installers
- align build and package steps with Arch packaging guidelines

note: this PKGBUILD is intended for development and AUR use only and is not suitable for binary redistribution or release artifacts.

* ci: add fixed-version PKGBUILD for Arch Linux releases

this PKGBUILD intended for CI and GitHub release artifacts. targets tagged releases only and uses a fixed pkgver that matches the corresponding git tag. all of the VCS logic has been removed to PKGBUILD-git to ensure reproducible builds and stable versioning suitable for binary distribution.

the build process relies on electron-builder directory output (--dir) and packages only the unpacked application into a standard Arch Linux package (.pkg.tar.zst). other distro format are excluded from this path and handled separately.

this change establishes a clear separation between:
- rolling AUR development builds (-git)
- CI-generated, versioned Arch Linux release packages

the result is predictable artifact naming, correct version alignment, and Arch-compliant packaging for downstream users.

* Update README.md

adds information for Arch build

* Update README.md

BUILD.md location was changed and now this link is poiting to nothing

* Update PKGBUILD

* Update PKGBUILD-git

* chore: fix ubuntu/debian part in README.md

* Polish language support (#195)

* Update support_request.yml

Added hardware specification

* Update bug_report.yml

Add logs textfield to bug report

* chore: add changelog in README.md

* fix screenshot input in feature_request.yml

* add hardware spec input in bug_report.yml

* fix: PKGBUILD pkgname variable fix

* userdata migration [need review from other OS]

* french translate

* Add German and Swedish translations

Added de.json and sv.json locale files for German and Swedish language support. Updated i18n.js to register 'de' and 'sv' as available languages in the launcher.

* Update README.md

* chore: add offline-mode warning to the README.md

* chore: add downloads counter in README.md

* fix: Steam Deck/Ubuntu crash - use system libzstd.so

The bundled libzstd.so is incompatible with glibc 2.41's stricter heap
validation, causing "free(): invalid pointer" crashes.

Solution: Automatically replace bundled libzstd.so with system version
on Linux. The launcher detects and symlinks to /usr/lib/libzstd.so.1.

- Auto-detect system libzstd at common paths (Arch, Debian, Fedora)
- Backup bundled version as libzstd.so.bundled
- Create symlink to system version
- Add HYTALE_NO_LIBZSTD_FIX=1 to disable if needed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: remove Windows and Linux ARM64 information on the README.md

* Update support_request.yml

* fix: improve update system UX and macOS compatibility

Update System Improvements:
- Fix duplicate update popups by disabling legacy updater.js
- Add skip button to update popup (shows after 30s, on error, or after download)
- Add macOS-specific handling with manual download as primary option
- Add missing open-download-page IPC handler
- Add missing unblockInterface() method to properly clean up after popup close
- Add quitAndInstallUpdate alias in preload for compatibility
- Remove pulse animation when download completes
- Fix manual download button to show correct status and close popup
- Sync player name to settings input after first install

Client Patcher Cleanup:
- Remove server patching code (server uses pre-patched JAR from CDN)
- Simplify to client-only patching
- Remove unused imports (crypto, AdmZip, execSync, spawn, javaManager)
- Remove unused methods (stringToUtf8, findAndReplaceDomainUtf8)
- Move localhost dev code to backup file for reference

Code Quality Fixes:
- Fix duplicate DOMContentLoaded handlers in install.js
- Fix duplicate checkForUpdates definition in preload.js
- Fix redundant if/else in onProgressUpdate callback
- Fix typo "Harwadre" -> "Hardware" in preload.js

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add Russian language support

Added Russian (ru) to the list of available languages.

* chore: drafting documentation on SERVER.md

* Some updates in Russian language localization file

* fix

* Update ru.json

* Fixed Java runtime name and fixed typo

* fixed untranslated place

* Update ru.json

* Update ru.json

* Update ru.json

* Update ru.json

* Update ru.json

* fix: timeout getLatestClient 

fixes #138

* fix: change default version to 7.pwr in main.js

* fix: change default release version to 7.pwr

* fix: change version release to 7.pwr

* docs: Add comprehensive troubleshooting guide (#209)

Add TROUBLESHOOTING.md with solutions for common issues including:

- Windows: Firewall configuration, duplicate mods, SmartScreen
- Linux: GPU detection (NVIDIA/AMD), SDL3_image/libpng dependencies,
  Wayland/X11 issues, Steam Deck support
- macOS: Rosetta 2 for Apple Silicon, code signing, quarantine
- Connection: Server boot failures, regional restrictions
- Authentication: Token errors, config reset procedures
- Avatar/Cosmetics: F2P limitations documentation
- Backup locations for all platforms
- Log locations for bug reports

Solutions compiled from closed GitHub issues (#205, #155, #90, #60,
#144, #192) and community feedback.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* Standardize language codes, improve formatting, and update all locale files. (#224)

* Update German (Germany) localization

* Update Español (España) localization

* Update French (France) localization

* Update Polish (Poland) localization

* Update Portuguese (Brazil) localization

* Update Russian (Russia) localization

* Update Swedish (Sweden) localization

* Update Turkish (Turkey) localization

* Update language codes, names and alphabetical in i18n system

* Changed Spanish language name to the Formal name "Spanish (Spain)"

* Fix PKGBUILD-git

* Fix PKGBUILD

* delete cache after installation

* Enforce 16-char player name limit and update mod sync

Added a maxlength attribute to the player name input and enforced a 16-character limit in both install and settings scripts, providing user feedback if exceeded. Refactored modManager.js to replace symlink-based mod management with a copy-based system, copying enabled mods to HytaleSaves\Mods and removing legacy symlink logic to improve compatibility and avoid permission issues.

* Update installation subtitle

* chore: update quickstart link in README.md

* chore: delete warning of Ubuntu-Debian at Linux Prequisites section

* added featured server list from api

* Add Featured Servers page to GUI

* Update Discord invite URL in client patcher

* Add differential update system

* Remove launcher chat and add Discord popup

* fix: removed 'check disk space' alert on permission file error

* fix: upgrade tar to ^7.5.6 version

* fix: re-add universal arch for mac

* fix: upgrade electron/rebuild to 4.0.3

* fix: removed override tar version

* fix: pkgbuild version to 2.1.2

* fix: src.tar.zst and srcinfo missing files

* feat: add Indonesian language translation

* fix: GPU preference hint to Laptop-only

* feat: create two columns for settings page

* Add Discord invite link to rpc

* docs: add recordings form, fix OS list

* Release v2.2.0

* Release v2.2.0

* Release v2.2.0

* chore: delete icon.ico, moved to build folder

* chore: delete icon.png, moved to build folder

* fix: build and release for tag push-only in release.yml

* fix: gamescope steam deck issue fixes #186 hopefully

* Support branch selection for server patching

* chose: add auto-patch system for pre-release JAR

* fix: preserves arch x64 on linux target for #242

* fix: removed arm64 flags

* fix: redo package.json arch

* update package-lock.json

* Update release.yml

* chore: sync package-lock with package.json

* fix: reorder fedora libzstd paths to first iteration

* feat: enhance gpu detection, drafting

* fix: comprehensive UUID/username persistence bug fixes (#252)

* fix: comprehensive UUID/username persistence bug fixes

Major fixes for UUID/skin reset issues that caused players to lose cosmetics:

Core fixes:
- Username rename now preserves UUID (atomic rename, not new identity)
- Atomic config writes with backup/recovery system
- Case-insensitive UUID lookup with case-preserving storage
- Pre-launch validation blocks play if no username configured
- Removed saveUsername calls from launch/install flows

UUID Modal fixes:
- Fixed isCurrent badge showing on wrong user
- Added switch identity button to change between saved usernames
- Fixed custom UUID input using unsaved DOM username
- UUID list now refreshes when player name changes
- Enabled copy/paste in custom UUID input field

UI/UX improvements:
- Added translation keys for switch username functionality
- CSS user-select fix for UUID input fields
- Allowed Ctrl+V/C/X/A shortcuts in Electron

Files: config.js, gameLauncher.js, gameManager.js, playerManager.js,
launcher.js, settings.js, main.js, preload.js, style.css, en.json

See UUID_BUGS_FIX_PLAN.md for detailed bug list (18 bugs, 16 fixed)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(i18n): add switch username translations to all locales

Added translation keys for username switching functionality:
- notifications.noUsername
- notifications.switchUsernameSuccess
- notifications.switchUsernameFailed
- notifications.playerNameTooLong
- confirm.switchUsernameTitle
- confirm.switchUsernameMessage
- confirm.switchUsernameButton

Languages updated: de-DE, es-ES, fr-FR, id-ID, pl-PL, pt-BR, ru-RU, sv-SE, tr-TR

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: move UUID_BUGS_FIX_PLAN.md to docs folder

* docs: update UUID_BUGS_FIX_PLAN with complete fix details

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* chore: rearrange, fix, and improve README.md

* chore: link downloads, platform, and version to release page in README.md

* chore: update discord link

* chore: insert contact link in CODE_OF_CONDUCT.md

* fix: missing version text on launcher

* chore: update quickstart button link to header

* chore: update discord link and give warning quickstart

* chore revise online play hosting instructions in README

Updated instructions for hosting an online game and clarified troubleshooting steps.

* Fix Turkish translations in tr-TR.json

* fix: EPERM error in Repair Game Button [windows testing needed]

* fix: invalid generated token that caused hangs on exit [windows testing needed]

* fix: major bug - hytale won't launch with laptop machine and ghost processes

* fix: discord RPC destroy error if not connected

* fix: major bug - detach game process to avoid launcher-held handles causing zombie process

* docs: add analysis on ghost process and launcher cleanup

* revert generateLocalTokens, wrong analysis on game launching issue

* revert add deps for generateLocalTokens

* Add proxy client and route downloads through it

* fix: Prevent JAR file corruption during proxy downloads

Fixed binary file corruption when downloading through proxy by using PassThrough stream to preserve data integrity while tracking download progress.

* Improve featured servers layout with Discord integration

- Add Discord button to server cards when discord link is present in API data
- Remove HF2P Servers section to use full width for featured servers
- Increase server card size (300x180px banner, larger fonts and spacing)
- Simplify layout from 2-column grid to single full-width container
- Discord button opens external browser with server invite link

* package version to 2.2.1

Update package.json version from 2.2.0 to 2.2.1 to publish a patch release.

* fix: add game_running_marker to prevent duplicate launches

* Add smart proxy with direct-fallback and logging

* fix: remove duplicate check

* fix: cache invalidation from .env prevents multiple launch attempts

for all env related, it is necessary to clear cache first, otherwise on few launch attempts the game wouldn't run

* fix: redact proxy_url and remove timed out emoji

* Prepare Release v2.2.1

* docs: enhance bug report template with placeholders and options

Updated the bug report template to include placeholders and additional Linux distributions.

* chore revise windows prerequisites and changelog

Updated prerequisites and changelog for version 2.2.1.

* chore: improvise badges, relocate star history, fix discord links

* chore: fix release notes for v2.2.1

* feat(macos): add code signing and notarization support

Add macOS code signing and notarization for Gatekeeper compatibility:

- Add hardened runtime configuration in package.json
- Add entitlements.mac.plist for required app permissions
- Enable built-in electron-builder notarization
- Add code signing and notarization secrets to workflow

Required GitHub Secrets:
- CSC_LINK: Base64-encoded .p12 certificate file
- CSC_KEY_PASSWORD: Password for the .p12 certificate
- APPLE_ID: Apple Developer account email
- APPLE_APP_SPECIFIC_PASSWORD: App-specific password from appleid.apple.com
- APPLE_TEAM_ID: 10-character Apple Developer Team ID

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Revise and enhance Hytale F2P Server Guide

Updated the Hytale F2P Server Guide with new sections and improved formatting.

* Update SERVER.md official accounts info

Added CloudNord hosting information and new section for playing online with official accounts.

* Update SERVER.md

* refactor: replace pre-patched JAR download with ByteBuddy agent

Migrate from downloading pre-patched server JARs from CDN to downloading
the DualAuth ByteBuddy Agent from GitHub releases. The server JAR stays
pristine - auth patching happens at runtime via -javaagent: flag.

clientPatcher.js:
- Replace patchServer() with ensureAgentAvailable()
- Download dualauth-agent.jar to Server/ directory
- Remove serverJarContainsDualAuth() and validateServerJarSize()

gameLauncher.js:
- Set JAVA_TOOL_OPTIONS env var with -javaagent: for runtime patching
- Update logging to show agent status instead of server patch count

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Bump package version to 2.2.2

Update package.json version from 2.2.1 to 2.2.2 to mark a patch release.

* Use new version API and default to v8 PWR

---------

Co-authored-by: Fazri Gading <fazrigading@gmail.com>
Co-authored-by: TalesAmaral <57869141+TalesAmaral@users.noreply.github.com>
Co-authored-by: walti0 <95646872+walti0@users.noreply.github.com>
Co-authored-by: Fazri Gading <super.fai700@gmail.com>
Co-authored-by: sanasol <mail@sanasol.ws>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Terromur <79866197+Terromur@users.noreply.github.com>
Co-authored-by: Zakhar Smokotov <zaharb840@gmail.com>
Co-authored-by: xSamiVS <samtaiebc@gmail.com>
Co-authored-by: MetricsLite <66024355+MetricsLite@users.noreply.github.com>
2026-02-11 11:13:14 +01:00

602 lines
22 KiB
JavaScript

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 {
saveInstallPath,
loadJavaPath,
getUuidForUser,
getAuthServerUrl,
getAuthDomain,
loadVersionBranch,
loadVersionClient,
saveVersionClient,
loadUsername,
hasUsername,
checkLaunchReady
} = require('../core/config');
const { resolveJavaPath, getJavaExec, getBundledJavaPath, detectSystemJava, JAVA_EXECUTABLE } = require('./javaManager');
const { getLatestClientVersion } = require('../services/versionManager');
const { FORCE_CLEAN_INSTALL_VERSION, CLEAN_INSTALL_TEST_VERSION } = require('../core/testConfig');
const { ensureGameInstalled } = require('./differentialUpdateManager');
const { syncModsForCurrentProfile } = require('./modManager');
const { getUserDataPath } = require('../utils/userDataMigration');
const { syncServerList } = require('../utils/serverListSync');
// 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(playerNameOverride = null, progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) {
// ==========================================================================
// CACHE INVALIDATION: Clear proxyClient module cache to force fresh .env load
// This prevents stale cached values from affecting multiple launch attempts
// ==========================================================================
try {
const proxyClientPath = require.resolve('../utils/proxyClient');
if (require.cache[proxyClientPath]) {
delete require.cache[proxyClientPath];
console.log('[Launcher] Cleared proxyClient cache for fresh .env load');
}
} catch (cacheErr) {
console.warn('[Launcher] Could not clear proxyClient cache:', cacheErr.message);
}
// ==========================================================================
// STEP 1: Validate player identity FIRST (before any other operations)
// ==========================================================================
const launchState = checkLaunchReady();
// Load username from config - single source of truth
let playerName = loadUsername();
if (!playerName) {
// No username configured - this is a critical error
const error = new Error('No username configured. Please set your username in Settings before playing.');
console.error('[Launcher] Launch blocked:', error.message);
throw error;
}
// Allow override only if explicitly provided (for testing/migration)
if (playerNameOverride && typeof playerNameOverride === 'string' && playerNameOverride.trim()) {
const overrideName = playerNameOverride.trim();
if (overrideName !== playerName && overrideName !== 'Player') {
console.warn(`[Launcher] Username override requested: "${overrideName}" (saved: "${playerName}")`);
// Use override for this session but DON'T save it - config is source of truth
playerName = overrideName;
}
}
// Warn if using default 'Player' name (likely misconfiguration)
if (playerName === 'Player') {
console.warn('[Launcher] Warning: Using default username "Player". This may cause cosmetic issues.');
}
console.log(`[Launcher] Launching game for player: "${playerName}"`);
// ==========================================================================
// STEP 2: Synchronize server list
// ==========================================================================
try {
console.log('[Launcher] Synchronizing server list...');
await syncServerList();
} catch (syncError) {
console.warn('[Launcher] Server list sync failed, continuing launch:', syncError.message);
}
// ==========================================================================
// STEP 3: Setup paths and directories
// ==========================================================================
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.2.0: 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.');
}
// NOTE: We do NOT save username here anymore - username is only saved
// when user explicitly changes it in Settings. This prevents accidental
// overwrites from race conditions or default values.
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);
}
}, null, branch);
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.agent) {
console.log(` Agent: ${patchResult.agent.alreadyExists ? 'already present' : patchResult.agent.success ? 'downloaded' : 'failed'}`);
}
} 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: Replace bundled libzstd.so with system version to fix glibc 2.41+ crash
// The bundled libzstd causes "free(): invalid pointer" on Steam Deck / Ubuntu LTS
if (process.platform === 'linux' && process.env.HYTALE_NO_LIBZSTD_FIX !== '1') {
const clientDir = path.dirname(clientPath);
const bundledLibzstd = path.join(clientDir, 'libzstd.so');
const backupLibzstd = path.join(clientDir, 'libzstd.so.bundled');
// Common system libzstd paths
const systemLibzstdPaths = [
'/usr/lib64/libzstd.so.1', // Fedora/RHEL
'/usr/lib/libzstd.so.1', // Arch Linux, Steam Deck
'/usr/lib/x86_64-linux-gnu/libzstd.so.1' // Debian/Ubuntu
];
let systemLibzstd = null;
for (const p of systemLibzstdPaths) {
if (fs.existsSync(p)) {
systemLibzstd = p;
break;
}
}
if (systemLibzstd && fs.existsSync(bundledLibzstd)) {
try {
const stats = fs.lstatSync(bundledLibzstd);
// Only replace if it's not already a symlink to system version
if (!stats.isSymbolicLink()) {
// Backup bundled version
if (!fs.existsSync(backupLibzstd)) {
fs.renameSync(bundledLibzstd, backupLibzstd);
console.log(`Linux: Backed up bundled libzstd.so`);
} else {
fs.unlinkSync(bundledLibzstd);
}
// Create symlink to system version
fs.symlinkSync(systemLibzstd, bundledLibzstd);
console.log(`Linux: Linked libzstd.so to system version (${systemLibzstd}) for glibc 2.41+ compatibility`);
} else {
const linkTarget = fs.readlinkSync(bundledLibzstd);
console.log(`Linux: libzstd.so already linked to ${linkTarget}`);
}
} catch (libzstdError) {
console.warn(`Linux: Could not replace libzstd.so: ${libzstdError.message}`);
}
}
}
// DualAuth Agent: Set JAVA_TOOL_OPTIONS so java picks up -javaagent: flag
// This enables runtime auth patching without modifying the server JAR
const agentJar = path.join(gameLatest, 'Server', 'dualauth-agent.jar');
if (fs.existsSync(agentJar)) {
const agentFlag = `-javaagent:${agentJar}`;
env.JAVA_TOOL_OPTIONS = env.JAVA_TOOL_OPTIONS
? `${env.JAVA_TOOL_OPTIONS} ${agentFlag}`
: agentFlag;
console.log('DualAuth Agent: enabled via JAVA_TOOL_OPTIONS');
}
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);
// Release process reference immediately so it's truly independent
// This works on all platforms (Windows, macOS, Linux)
child.unref();
console.log(`Game process started with PID: ${child.pid}`);
let hasExited = false;
let outputReceived = false;
let launchCheckTimeout;
if (child.stdout) {
child.stdout.on('data', (data) => {
outputReceived = true;
const msg = data.toString().trim();
console.log(`Game output: ${msg}`);
});
}
if (child.stderr) {
child.stderr.on('data', (data) => {
outputReceived = true;
const msg = data.toString().trim();
console.error(`Game error: ${msg}`);
});
}
child.on('error', (error) => {
hasExited = true;
clearTimeout(launchCheckTimeout);
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;
clearTimeout(launchCheckTimeout);
if (code !== null) {
console.log(`Game process exited with code ${code}`);
if (code !== 0) {
console.error(`[Launcher] Game crashed or exited with error code ${code}`);
if (progressCallback) {
progressCallback(`Game exited with error code ${code}`, -1, null, null, null);
}
}
} else if (signal) {
console.log(`Game process terminated by signal ${signal}`);
}
});
// Process is detached and unref'd - it runs independently from the launcher
// We cannot reliably detect if the game window actually appears from here,
// so we report success after spawning. stdout/stderr logging above provides debugging info.
console.log('Game process spawned and detached successfully');
if (progressCallback) {
progressCallback('Game launched successfully', 100, null, null, null);
}
// Return immediately after spawn
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(playerNameOverride = null, progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) {
try {
// ==========================================================================
// PRE-LAUNCH VALIDATION: Check username is configured
// ==========================================================================
const launchState = checkLaunchReady();
if (!launchState.hasUsername) {
const error = 'No username configured. Please set your username in Settings before playing.';
console.error('[Launcher] Launch blocked:', error);
if (progressCallback) {
progressCallback(error, -1, null, null, null);
}
return { success: false, error: error, needsUsername: true };
}
console.log(`[Launcher] Pre-launch check passed. Username: "${launchState.username}"`);
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 {
let versionToInstall = latestVersion;
if (FORCE_CLEAN_INSTALL_VERSION && !installedVersion) {
versionToInstall = CLEAN_INSTALL_TEST_VERSION;
console.log(`TESTING MODE: Clean install detected, forcing version ${versionToInstall} instead of ${latestVersion}`);
}
await ensureGameInstalled(versionToInstall, branch, progressCallback, customGameDir, customCacheDir, customToolsDir);
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(playerNameOverride, 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
};